diff --git a/.buildkite/ftr_platform_stateful_configs.yml b/.buildkite/ftr_platform_stateful_configs.yml index e12e18c520ae3..2187ba5031425 100644 --- a/.buildkite/ftr_platform_stateful_configs.yml +++ b/.buildkite/ftr_platform_stateful_configs.yml @@ -42,6 +42,9 @@ disabled: # Default http2 config to use for performance journeys - x-pack/performance/configs/http2_config.ts + # Gen AI suites, running with their own pipeline + - x-pack/test/functional_gen_ai/inference/config.ts + defaultQueue: 'n2-4-spot' enabled: - test/accessibility/config.ts diff --git a/.buildkite/pipelines/on_merge.yml b/.buildkite/pipelines/on_merge.yml index 35d87d23ff6e0..aae6bd7426366 100644 --- a/.buildkite/pipelines/on_merge.yml +++ b/.buildkite/pipelines/on_merge.yml @@ -169,6 +169,28 @@ steps: - exit_status: '*' limit: 1 + - command: .buildkite/scripts/steps/test/ftr_configs.sh + env: + FTR_CONFIG: "x-pack/test/functional_gen_ai/inference/config.ts" + FTR_CONFIG_GROUP_KEY: 'ftr-ai-infra-gen-ai-inference-api' + FTR_GEN_AI: "1" + label: AppEx AI-Infra Inference APIs FTR tests + key: ai-infra-gen-ai-inference-api + timeout_in_minutes: 50 + parallelism: 1 + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + preemptible: true + retry: + automatic: + - exit_status: '-1' + limit: 3 + - exit_status: '*' + limit: 1 + - command: .buildkite/scripts/steps/functional/security_serverless_explore.sh label: 'Serverless Explore - Security Solution Cypress Tests' agents: diff --git a/.buildkite/pipelines/pull_request/ai_infra_gen_ai.yml b/.buildkite/pipelines/pull_request/ai_infra_gen_ai.yml new file mode 100644 index 0000000000000..650039b278d52 --- /dev/null +++ b/.buildkite/pipelines/pull_request/ai_infra_gen_ai.yml @@ -0,0 +1,30 @@ +steps: + - group: AppEx AI-Infra genAI tests + key: ai-infra-gen-ai + depends_on: + - build + - quick_checks + - checks + - linting + - linting_with_types + - check_types + - check_oas_snapshot + steps: + - command: .buildkite/scripts/steps/test/ftr_configs.sh + env: + FTR_CONFIG: "x-pack/test/functional_gen_ai/inference/config.ts" + FTR_CONFIG_GROUP_KEY: 'ftr-ai-infra-gen-ai-inference-api' + FTR_GEN_AI: "1" + label: AppEx AI-Infra Inference APIs FTR tests + key: ai-infra-gen-ai-inference-api + timeout_in_minutes: 50 + parallelism: 1 + agents: + machineType: n2-standard-4 + preemptible: true + retry: + automatic: + - exit_status: '-1' + limit: 3 + - exit_status: '*' + limit: 1 diff --git a/.buildkite/scripts/common/setup_job_env.sh b/.buildkite/scripts/common/setup_job_env.sh index b2e3bfdd024d3..d05719cbbbb32 100644 --- a/.buildkite/scripts/common/setup_job_env.sh +++ b/.buildkite/scripts/common/setup_job_env.sh @@ -132,6 +132,14 @@ EOF export ELASTIC_APM_API_KEY } +# Set up GenAI keys +{ + if [[ "${FTR_GEN_AI:-}" =~ ^(1|true)$ ]]; then + echo "FTR_GEN_AI was set - exposing LLM connectors" + export KIBANA_TESTING_AI_CONNECTORS="$(vault_get ai-infra-ci-connectors connectors-config)" + fi +} + # Set up GCS Service Account for CDN { GCS_SA_CDN_KEY="$(vault_get gcs-sa-cdn-prod key)" diff --git a/.buildkite/scripts/pipelines/pull_request/pipeline.ts b/.buildkite/scripts/pipelines/pull_request/pipeline.ts index ca4ca228b0c91..294a9821cf73a 100644 --- a/.buildkite/scripts/pipelines/pull_request/pipeline.ts +++ b/.buildkite/scripts/pipelines/pull_request/pipeline.ts @@ -113,33 +113,59 @@ const getPipeline = (filename: string, removeSteps = true) => { pipeline.push(getPipeline('.buildkite/pipelines/pull_request/fleet_cypress.yml')); } - if (await doAnyChangesMatch([/^x-pack\/plugins\/observability_solution\/exploratory_view/])) { + if ( + (await doAnyChangesMatch([/^x-pack\/plugins\/observability_solution\/exploratory_view/])) || + GITHUB_PR_LABELS.includes('ci:synthetics-runner-suites') + ) { pipeline.push(getPipeline('.buildkite/pipelines/pull_request/exploratory_view_plugin.yml')); } if ( - await doAnyChangesMatch([ + (await doAnyChangesMatch([ /^x-pack\/plugins\/observability_solution\/synthetics/, /^x-pack\/plugins\/observability_solution\/exploratory_view/, - ]) + ])) || + GITHUB_PR_LABELS.includes('ci:synthetics-runner-suites') ) { pipeline.push(getPipeline('.buildkite/pipelines/pull_request/synthetics_plugin.yml')); pipeline.push(getPipeline('.buildkite/pipelines/pull_request/uptime_plugin.yml')); } if ( - await doAnyChangesMatch([ + (await doAnyChangesMatch([ /^x-pack\/plugins\/observability_solution\/ux/, /^x-pack\/plugins\/observability_solution\/exploratory_view/, - ]) + ])) || + GITHUB_PR_LABELS.includes('ci:synthetics-runner-suites') ) { pipeline.push(getPipeline('.buildkite/pipelines/pull_request/ux_plugin_e2e.yml')); } - if (await doAnyChangesMatch([/^x-pack\/plugins\/observability_solution/])) { + if ( + (await doAnyChangesMatch([ + /^x-pack\/plugins\/observability_solution/, + /^package.json/, + /^yarn.lock/, + ])) || + GITHUB_PR_LABELS.includes('ci:synthetics-runner-suites') + ) { pipeline.push(getPipeline('.buildkite/pipelines/pull_request/slo_plugin_e2e.yml')); } + if ( + (await doAnyChangesMatch([ + /^x-pack\/packages\/ai-infra/, + /^x-pack\/plugins\/ai_infra/, + /^x-pack\/plugins\/inference/, + /^x-pack\/plugins\/stack_connectors\/server\/connector_types\/bedrock/, + /^x-pack\/plugins\/stack_connectors\/server\/connector_types\/gemini/, + /^x-pack\/plugins\/stack_connectors\/server\/connector_types\/openai/, + ])) || + GITHUB_PR_LABELS.includes('ci:all-gen-ai-suites') + ) { + pipeline.push(getPipeline('.buildkite/pipelines/pull_request/ai_infra_gen_ai.yml')); + } + if ( GITHUB_PR_LABELS.includes('ci:deploy-cloud') || GITHUB_PR_LABELS.includes('ci:cloud-deploy') || diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3a4af880c2e04..bcb189973fcec 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -493,6 +493,7 @@ packages/kbn-ftr-common-functional-services @elastic/kibana-operations @elastic/ packages/kbn-ftr-common-functional-ui-services @elastic/appex-qa packages/kbn-ftr-screenshot-filename @elastic/kibana-operations @elastic/appex-qa x-pack/test/functional_with_es_ssl/plugins/cases @elastic/response-ops +packages/kbn-gen-ai-functional-testing @elastic/appex-ai-infra x-pack/examples/gen_ai_streaming_response_example @elastic/response-ops packages/kbn-generate @elastic/kibana-operations packages/kbn-generate-console-definitions @elastic/kibana-management @@ -1414,6 +1415,7 @@ x-pack/test/**/deployment_agnostic/ @elastic/appex-qa #temporarily to monitor te # AppEx AI Infra /x-pack/plugins/inference @elastic/appex-ai-infra @elastic/obs-ai-assistant @elastic/security-generative-ai +/x-pack/test/functional_gen_ai/inference @elastic/appex-ai-infra # AppEx Platform Services Security x-pack/test_serverless/api_integration/test_suites/common/security_response_headers.ts @elastic/kibana-security diff --git a/package.json b/package.json index 5caec99c65ccc..e81d9cd8b0b15 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,7 @@ "@types/react": "~18.2.0", "@types/react-dom": "~18.2.0", "@xstate5/react/**/xstate": "^5.18.1", - "globby/fast-glob": "^3.2.11" + "globby/fast-glob": "^3.3.2" }, "dependencies": { "@appland/sql-parser": "^1.5.1", @@ -1030,7 +1030,7 @@ "@langchain/google-vertexai": "^0.1.0", "@langchain/langgraph": "0.2.19", "@langchain/openai": "^0.3.11", - "@langtrase/trace-attributes": "^3.0.8", + "@langtrase/trace-attributes": "^7.5.0", "@launchdarkly/node-server-sdk": "^9.7.2", "@launchdarkly/openfeature-node-server": "^1.0.0", "@loaders.gl/core": "^3.4.7", @@ -1057,12 +1057,12 @@ "@reduxjs/toolkit": "1.9.7", "@slack/webhook": "^7.0.1", "@smithy/eventstream-codec": "^3.1.1", - "@smithy/eventstream-serde-node": "^3.0.3", + "@smithy/eventstream-serde-node": "^3.0.12", "@smithy/middleware-stack": "^3.0.10", "@smithy/node-http-handler": "^3.3.1", "@smithy/protocol-http": "^4.1.7", - "@smithy/signature-v4": "^3.1.1", - "@smithy/types": "^3.2.0", + "@smithy/signature-v4": "^4.2.3", + "@smithy/types": "^3.7.1", "@smithy/util-utf8": "^3.0.0", "@tanstack/react-query": "^4.29.12", "@tanstack/react-query-devtools": "^4.29.12", @@ -1081,8 +1081,8 @@ "@xyflow/react": "^12.3.2", "adm-zip": "^0.5.9", "ai": "^2.2.33", - "ajv": "^8.12.0", - "ansi-regex": "^6.0.1", + "ajv": "^8.17.1", + "ansi-regex": "^6.1.0", "antlr4": "^4.13.1-patch-1", "archiver": "^7.0.1", "async": "^3.2.3", @@ -1129,7 +1129,7 @@ "dotenv": "^16.4.5", "elastic-apm-node": "^4.7.3", "email-addresses": "^5.0.0", - "eventsource-parser": "^1.1.1", + "eventsource-parser": "^3.0.0", "execa": "^5.1.1", "expiry-js": "0.1.7", "exponential-backoff": "^3.1.1", @@ -1147,7 +1147,7 @@ "getos": "^3.1.0", "globby": "^11.1.0", "google-auth-library": "^9.10.0", - "gpt-tokenizer": "^2.1.2", + "gpt-tokenizer": "^2.6.2", "handlebars": "4.7.8", "he": "^1.2.0", "history": "^4.9.0", @@ -1175,7 +1175,7 @@ "jsts": "^1.6.2", "kea": "^2.6.0", "langchain": "^0.3.5", - "langsmith": "^0.2.3", + "langsmith": "^0.2.5", "launchdarkly-js-client-sdk": "^3.5.0", "load-json-file": "^6.2.0", "lodash": "^4.17.21", @@ -1204,7 +1204,7 @@ "nunjucks": "^3.2.4", "object-hash": "^1.3.1", "object-path-immutable": "^3.1.1", - "openai": "^4.68.0", + "openai": "^4.72.0", "openpgp": "5.10.1", "opn": "^5.5.0", "ora": "^4.0.4", @@ -1452,6 +1452,7 @@ "@kbn/ftr-common-functional-services": "link:packages/kbn-ftr-common-functional-services", "@kbn/ftr-common-functional-ui-services": "link:packages/kbn-ftr-common-functional-ui-services", "@kbn/ftr-screenshot-filename": "link:packages/kbn-ftr-screenshot-filename", + "@kbn/gen-ai-functional-testing": "link:packages/kbn-gen-ai-functional-testing", "@kbn/generate": "link:packages/kbn-generate", "@kbn/get-repo-files": "link:packages/kbn-get-repo-files", "@kbn/import-locator": "link:packages/kbn-import-locator", @@ -1591,7 +1592,7 @@ "@types/js-search": "^1.4.0", "@types/js-yaml": "^3.11.1", "@types/jsdom": "^20.0.1", - "@types/json-schema": "^7", + "@types/json-schema": "^7.0.15", "@types/json-stable-stringify": "^1.0.32", "@types/json5": "^2.2.0", "@types/jsonwebtoken": "^9.0.0", @@ -1835,7 +1836,7 @@ "svgo": "^2.8.0", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", - "table": "^6.8.1", + "table": "^6.8.2", "tape": "^5.0.1", "terser": "^5.31.6", "terser-webpack-plugin": "^4.2.3", diff --git a/packages/kbn-esql-editor/src/editor_footer/index.tsx b/packages/kbn-esql-editor/src/editor_footer/index.tsx index 4e60e65f19ca4..e6973e39657d9 100644 --- a/packages/kbn-esql-editor/src/editor_footer/index.tsx +++ b/packages/kbn-esql-editor/src/editor_footer/index.tsx @@ -297,7 +297,13 @@ export const EditorFooter = memo(function EditorFooter({ /> )} - + diff --git a/packages/kbn-esql-validation-autocomplete/src/definitions/generated/scalar_functions.ts b/packages/kbn-esql-validation-autocomplete/src/definitions/generated/scalar_functions.ts index 6f16d69c1e1f8..d76b4d9b03ad9 100644 --- a/packages/kbn-esql-validation-autocomplete/src/definitions/generated/scalar_functions.ts +++ b/packages/kbn-esql-validation-autocomplete/src/definitions/generated/scalar_functions.ts @@ -522,6 +522,84 @@ const atan2Definition: FunctionDefinition = { examples: ['ROW y=12.9, x=.6\n| EVAL atan2=ATAN2(y, x)'], }; +// Do not edit this manually... generated by scripts/generate_function_definitions.ts +const bitLengthDefinition: FunctionDefinition = { + type: 'eval', + name: 'bit_length', + description: i18n.translate('kbn-esql-validation-autocomplete.esql.definitions.bit_length', { + defaultMessage: 'Returns the bit length of a string.', + }), + preview: false, + alias: undefined, + signatures: [ + { + params: [ + { + name: 'string', + type: 'keyword', + optional: false, + }, + ], + returnType: 'integer', + }, + { + params: [ + { + name: 'string', + type: 'text', + optional: false, + }, + ], + returnType: 'integer', + }, + ], + supportedCommands: ['stats', 'inlinestats', 'metrics', 'eval', 'where', 'row', 'sort'], + supportedOptions: ['by'], + validate: undefined, + examples: [ + 'FROM airports\n| WHERE country == "India"\n| KEEP city\n| EVAL fn_length = LENGTH(city), fn_bit_length = BIT_LENGTH(city)', + ], +}; + +// Do not edit this manually... generated by scripts/generate_function_definitions.ts +const byteLengthDefinition: FunctionDefinition = { + type: 'eval', + name: 'byte_length', + description: i18n.translate('kbn-esql-validation-autocomplete.esql.definitions.byte_length', { + defaultMessage: 'Returns the byte length of a string.', + }), + preview: false, + alias: undefined, + signatures: [ + { + params: [ + { + name: 'string', + type: 'keyword', + optional: false, + }, + ], + returnType: 'integer', + }, + { + params: [ + { + name: 'string', + type: 'text', + optional: false, + }, + ], + returnType: 'integer', + }, + ], + supportedCommands: ['stats', 'inlinestats', 'metrics', 'eval', 'where', 'row', 'sort'], + supportedOptions: ['by'], + validate: undefined, + examples: [ + 'FROM airports\n| WHERE country == "India"\n| KEEP city\n| EVAL fn_length = LENGTH(city), fn_byte_length = BYTE_LENGTH(city)', + ], +}; + // Do not edit this manually... generated by scripts/generate_function_definitions.ts const categorizeDefinition: FunctionDefinition = { type: 'eval', @@ -2757,7 +2835,9 @@ const lengthDefinition: FunctionDefinition = { supportedCommands: ['stats', 'inlinestats', 'metrics', 'eval', 'where', 'row', 'sort'], supportedOptions: ['by'], validate: undefined, - examples: ['FROM employees\n| KEEP first_name, last_name\n| EVAL fn_length = LENGTH(first_name)'], + examples: [ + 'FROM airports\n| WHERE country == "India"\n| KEEP city\n| EVAL fn_length = LENGTH(city)', + ], }; // Do not edit this manually... generated by scripts/generate_function_definitions.ts @@ -9551,6 +9631,8 @@ export const scalarFunctionDefinitions = [ asinDefinition, atanDefinition, atan2Definition, + bitLengthDefinition, + byteLengthDefinition, categorizeDefinition, cbrtDefinition, ceilDefinition, diff --git a/packages/kbn-expandable-flyout/src/hooks/use_initialize_from_local_storage.test.ts b/packages/kbn-expandable-flyout/src/hooks/use_initialize_from_local_storage.test.ts index 26b3daf8161db..cc3623f9238c6 100644 --- a/packages/kbn-expandable-flyout/src/hooks/use_initialize_from_local_storage.test.ts +++ b/packages/kbn-expandable-flyout/src/hooks/use_initialize_from_local_storage.test.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { useInitializeFromLocalStorage } from './use_initialize_from_local_storage'; import { localStorageMock } from '../../__mocks__'; import { diff --git a/packages/kbn-expandable-flyout/src/hooks/use_sections.test.tsx b/packages/kbn-expandable-flyout/src/hooks/use_sections.test.tsx index 4526f128affd3..8dc4aacaefbcf 100644 --- a/packages/kbn-expandable-flyout/src/hooks/use_sections.test.tsx +++ b/packages/kbn-expandable-flyout/src/hooks/use_sections.test.tsx @@ -8,8 +8,7 @@ */ import React from 'react'; -import { renderHook } from '@testing-library/react-hooks'; -import type { RenderHookResult } from '@testing-library/react-hooks'; +import { renderHook, RenderHookResult } from '@testing-library/react'; import type { UseSectionsParams, UseSectionsResult } from './use_sections'; import { useSections } from './use_sections'; import { useExpandableFlyoutState } from '../..'; @@ -17,7 +16,7 @@ import { useExpandableFlyoutState } from '../..'; jest.mock('../..'); describe('useSections', () => { - let hookResult: RenderHookResult; + let hookResult: RenderHookResult; it('should return undefined for all values if no registeredPanels', () => { (useExpandableFlyoutState as jest.Mock).mockReturnValue({ diff --git a/packages/kbn-expandable-flyout/src/hooks/use_window_width.test.ts b/packages/kbn-expandable-flyout/src/hooks/use_window_width.test.ts index 72ab9148743db..696191e7fbdf6 100644 --- a/packages/kbn-expandable-flyout/src/hooks/use_window_width.test.ts +++ b/packages/kbn-expandable-flyout/src/hooks/use_window_width.test.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { FULL_WIDTH_PADDING, MAX_RESOLUTION_BREAKPOINT, diff --git a/packages/kbn-gen-ai-functional-testing/.gitignore b/packages/kbn-gen-ai-functional-testing/.gitignore new file mode 100644 index 0000000000000..fea551a7bacc7 --- /dev/null +++ b/packages/kbn-gen-ai-functional-testing/.gitignore @@ -0,0 +1,2 @@ +## local version of the connector config +connector_config.json diff --git a/packages/kbn-gen-ai-functional-testing/README.md b/packages/kbn-gen-ai-functional-testing/README.md new file mode 100644 index 0000000000000..df33821142e1d --- /dev/null +++ b/packages/kbn-gen-ai-functional-testing/README.md @@ -0,0 +1,49 @@ +# @kbn/gen-ai-functional-testing + +Package exposing various utilities for GenAI/LLM related functional testing. + +## Features + +### LLM connectors + +Utilizing LLM connectors on FTR tests can be done via the `getPreconfiguredConnectorConfig` and `getAvailableConnectors` tools. + +`getPreconfiguredConnectorConfig` should be used to define the list of connectors when creating the FTR test's configuration. + +```ts +import { getPreconfiguredConnectorConfig } from '@kbn/gen-ai-functional-testing' + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const xpackFunctionalConfig = {...}; + const preconfiguredConnectors = getPreconfiguredConnectorConfig(); + + return { + ...xpackFunctionalConfig.getAll(), + kbnTestServer: { + ...xpackFunctionalConfig.get('kbnTestServer'), + serverArgs: [ + ...xpackFunctionalConfig.get('kbnTestServer.serverArgs'), + `--xpack.actions.preconfigured=${JSON.stringify(preconfiguredConnectors)}`, + ], + }, + }; +} +``` + +then the `getAvailableConnectors` can be used during the test suite to retrieve the list of LLM connectors. + +For example to run some predefined test suite against all exposed LLM connectors: + +```ts +import { getAvailableConnectors } from '@kbn/gen-ai-functional-testing'; + +export default function (providerContext: FtrProviderContext) { + describe('Some GenAI FTR test suite', async () => { + getAvailableConnectors().forEach((connector) => { + describe(`Using connector ${connector.id}`, () => { + myTestSuite(connector, providerContext); + }); + }); + }); +} +``` diff --git a/packages/kbn-gen-ai-functional-testing/index.ts b/packages/kbn-gen-ai-functional-testing/index.ts new file mode 100644 index 0000000000000..b0fc5fe6ab3ba --- /dev/null +++ b/packages/kbn-gen-ai-functional-testing/index.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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { + AI_CONNECTORS_VAR_ENV, + getPreconfiguredConnectorConfig, + getAvailableConnectors, + type AvailableConnector, + type AvailableConnectorWithId, +} from './src/connectors'; diff --git a/packages/kbn-gen-ai-functional-testing/jest.config.js b/packages/kbn-gen-ai-functional-testing/jest.config.js new file mode 100644 index 0000000000000..624ab023e16a1 --- /dev/null +++ b/packages/kbn-gen-ai-functional-testing/jest.config.js @@ -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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +module.exports = { + preset: '@kbn/test/jest_node', + rootDir: '../..', + roots: ['/packages/kbn-gen-ai-functional-testing'], +}; diff --git a/packages/kbn-gen-ai-functional-testing/kibana.jsonc b/packages/kbn-gen-ai-functional-testing/kibana.jsonc new file mode 100644 index 0000000000000..dfc83f235de1f --- /dev/null +++ b/packages/kbn-gen-ai-functional-testing/kibana.jsonc @@ -0,0 +1,6 @@ +{ + "type": "shared-common", + "id": "@kbn/gen-ai-functional-testing", + "owner": "@elastic/appex-ai-infra", + "devOnly": true +} diff --git a/packages/kbn-gen-ai-functional-testing/package.json b/packages/kbn-gen-ai-functional-testing/package.json new file mode 100644 index 0000000000000..a687a7c9ec94b --- /dev/null +++ b/packages/kbn-gen-ai-functional-testing/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/gen-ai-functional-testing", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0" +} \ No newline at end of file diff --git a/packages/kbn-gen-ai-functional-testing/scripts/format_connector_config.js b/packages/kbn-gen-ai-functional-testing/scripts/format_connector_config.js new file mode 100644 index 0000000000000..d8ac404413bbb --- /dev/null +++ b/packages/kbn-gen-ai-functional-testing/scripts/format_connector_config.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +require('@kbn/babel-register').install(); +require('../src/manage_connector_config').formatCurrentConfig(); diff --git a/packages/kbn-gen-ai-functional-testing/scripts/retrieve_connector_config.js b/packages/kbn-gen-ai-functional-testing/scripts/retrieve_connector_config.js new file mode 100644 index 0000000000000..e9b3c9b196920 --- /dev/null +++ b/packages/kbn-gen-ai-functional-testing/scripts/retrieve_connector_config.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +require('@kbn/babel-register').install(); +require('../src/manage_connector_config').retrieveFromVault(); diff --git a/packages/kbn-gen-ai-functional-testing/scripts/upload_connector_config.js b/packages/kbn-gen-ai-functional-testing/scripts/upload_connector_config.js new file mode 100644 index 0000000000000..e9cc8d9738c1a --- /dev/null +++ b/packages/kbn-gen-ai-functional-testing/scripts/upload_connector_config.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +require('@kbn/babel-register').install(); +require('../src/manage_connector_config').uploadToVault(); diff --git a/packages/kbn-gen-ai-functional-testing/src/connectors.ts b/packages/kbn-gen-ai-functional-testing/src/connectors.ts new file mode 100644 index 0000000000000..6bfe3f7030484 --- /dev/null +++ b/packages/kbn-gen-ai-functional-testing/src/connectors.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { schema } from '@kbn/config-schema'; + +/** + * The environment variable that is used by the CI to load the connectors configuration + */ +export const AI_CONNECTORS_VAR_ENV = 'KIBANA_TESTING_AI_CONNECTORS'; + +const connectorsSchema = schema.recordOf( + schema.string(), + schema.object({ + name: schema.string(), + actionTypeId: schema.string(), + config: schema.recordOf(schema.string(), schema.any()), + secrets: schema.recordOf(schema.string(), schema.any()), + }) +); + +export interface AvailableConnector { + name: string; + actionTypeId: string; + config: Record; + secrets: Record; +} + +export interface AvailableConnectorWithId extends AvailableConnector { + id: string; +} + +const loadConnectors = (): Record => { + const envValue = process.env[AI_CONNECTORS_VAR_ENV]; + if (!envValue) { + return {}; + } + + let connectors: Record; + try { + connectors = JSON.parse(Buffer.from(envValue, 'base64').toString('utf-8')); + } catch (e) { + throw new Error( + `Error trying to parse value from KIBANA_AI_CONNECTORS environment variable: ${e.message}` + ); + } + return connectorsSchema.validate(connectors); +}; + +/** + * Retrieve the list of preconfigured connectors that should be used when defining the + * FTR configuration of suites using the connectors. + * + * @example + * ```ts + * import { getPreconfiguredConnectorConfig } from '@kbn/gen-ai-functional-testing' + * + * export default async function ({ readConfigFile }: FtrConfigProviderContext) { + * const xpackFunctionalConfig = {...}; + * const preconfiguredConnectors = getPreconfiguredConnectorConfig(); + * + * return { + * ...xpackFunctionalConfig.getAll(), + * kbnTestServer: { + * ...xpackFunctionalConfig.get('kbnTestServer'), + * serverArgs: [ + * ...xpackFunctionalConfig.get('kbnTestServer.serverArgs'), + * `--xpack.actions.preconfigured=${JSON.stringify(preconfiguredConnectors)}`, + * ], + * }, + * }; + * } + * ``` + */ +export const getPreconfiguredConnectorConfig = () => { + return loadConnectors(); +}; + +export const getAvailableConnectors = (): AvailableConnectorWithId[] => { + return Object.entries(loadConnectors()).map(([id, connector]) => { + return { + id, + ...connector, + }; + }); +}; diff --git a/packages/kbn-gen-ai-functional-testing/src/manage_connector_config.ts b/packages/kbn-gen-ai-functional-testing/src/manage_connector_config.ts new file mode 100644 index 0000000000000..484ff9d4bc48a --- /dev/null +++ b/packages/kbn-gen-ai-functional-testing/src/manage_connector_config.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import execa from 'execa'; +import Path from 'path'; +import { writeFile, readFile } from 'fs/promises'; +import { REPO_ROOT } from '@kbn/repo-info'; + +const LOCAL_FILE = Path.join( + REPO_ROOT, + 'packages', + 'kbn-gen-ai-functional-testing', + 'connector_config.json' +); + +export const retrieveFromVault = async () => { + const { stdout } = await execa( + 'vault', + ['read', '-field=connectors-config', 'secret/ci/elastic-kibana/ai-infra-ci-connectors'], + { + cwd: REPO_ROOT, + buffer: true, + } + ); + + const config = JSON.parse(Buffer.from(stdout, 'base64').toString('utf-8')); + + await writeFile(LOCAL_FILE, JSON.stringify(config, undefined, 2)); + + // eslint-disable-next-line no-console + console.log(`Config dumped into ${LOCAL_FILE}`); +}; + +export const formatCurrentConfig = async () => { + const config = await readFile(LOCAL_FILE, 'utf-8'); + const asB64 = Buffer.from(config).toString('base64'); + // eslint-disable-next-line no-console + console.log(asB64); +}; + +export const uploadToVault = async () => { + const config = await readFile(LOCAL_FILE, 'utf-8'); + const asB64 = Buffer.from(config).toString('base64'); + + await execa( + 'vault', + ['write', 'secret/ci/elastic-kibana/ai-infra-ci-connectors', `connectors-config=${asB64}`], + { + cwd: REPO_ROOT, + buffer: true, + } + ); +}; diff --git a/packages/kbn-gen-ai-functional-testing/tsconfig.json b/packages/kbn-gen-ai-functional-testing/tsconfig.json new file mode 100644 index 0000000000000..7ad2ded097a42 --- /dev/null +++ b/packages/kbn-gen-ai-functional-testing/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/config-schema", + "@kbn/repo-info", + ] +} diff --git a/packages/kbn-grid-layout/grid/grid_layout.test.tsx b/packages/kbn-grid-layout/grid/grid_layout.test.tsx new file mode 100644 index 0000000000000..33b1bad784618 --- /dev/null +++ b/packages/kbn-grid-layout/grid/grid_layout.test.tsx @@ -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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { getSampleLayout } from './test_utils/sample_layout'; +import { GridLayout, GridLayoutProps } from './grid_layout'; +import { gridSettings, mockRenderPanelContents } from './test_utils/mocks'; +import { cloneDeep } from 'lodash'; + +describe('GridLayout', () => { + const renderGridLayout = (propsOverrides: Partial = {}) => { + const defaultProps: GridLayoutProps = { + accessMode: 'EDIT', + layout: getSampleLayout(), + gridSettings, + renderPanelContents: mockRenderPanelContents, + onLayoutChange: jest.fn(), + }; + + const { rerender, ...rtlRest } = render(); + + return { + ...rtlRest, + rerender: (overrides: Partial) => + rerender(), + }; + }; + const getAllThePanelIds = () => + screen + .getAllByRole('button', { name: /panelId:panel/i }) + .map((el) => el.getAttribute('aria-label')?.replace(/panelId:/g, '')); + + const startDragging = (handle: HTMLElement, options = { clientX: 0, clientY: 0 }) => { + fireEvent.mouseDown(handle, options); + }; + const moveTo = (options = { clientX: 256, clientY: 128 }) => { + fireEvent.mouseMove(document, options); + }; + const drop = (handle: HTMLElement) => { + fireEvent.mouseUp(handle); + }; + + const assertTabThroughPanel = async (panelId: string) => { + await userEvent.tab(); // tab to drag handle + await userEvent.tab(); // tab to the panel + expect(screen.getByLabelText(`panelId:${panelId}`)).toHaveFocus(); + await userEvent.tab(); // tab to the resize handle + }; + + const expectedInitialOrder = [ + 'panel1', + 'panel5', + 'panel2', + 'panel3', + 'panel7', + 'panel6', + 'panel8', + 'panel4', + 'panel9', + 'panel10', + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it(`'renderPanelContents' is not called during dragging`, () => { + renderGridLayout(); + + expect(mockRenderPanelContents).toHaveBeenCalledTimes(10); // renderPanelContents is called for each of 10 panels + jest.clearAllMocks(); + + const panel1DragHandle = screen.getAllByRole('button', { name: /drag to move/i })[0]; + startDragging(panel1DragHandle); + moveTo({ clientX: 256, clientY: 128 }); + expect(mockRenderPanelContents).toHaveBeenCalledTimes(0); // renderPanelContents should not be called during dragging + + drop(panel1DragHandle); + expect(mockRenderPanelContents).toHaveBeenCalledTimes(0); // renderPanelContents should not be called after reordering + }); + + describe('panels order: panels are rendered from left to right, from top to bottom', () => { + it('focus management - tabbing through the panels', async () => { + renderGridLayout(); + // we only test a few panels because otherwise that test would execute for too long + await assertTabThroughPanel('panel1'); + await assertTabThroughPanel('panel5'); + await assertTabThroughPanel('panel2'); + await assertTabThroughPanel('panel3'); + }); + it('on initializing', () => { + renderGridLayout(); + expect(getAllThePanelIds()).toEqual(expectedInitialOrder); + }); + + it('after reordering some panels', async () => { + renderGridLayout(); + + const panel1DragHandle = screen.getAllByRole('button', { name: /drag to move/i })[0]; + startDragging(panel1DragHandle); + + moveTo({ clientX: 256, clientY: 128 }); + expect(getAllThePanelIds()).toEqual(expectedInitialOrder); // the panels shouldn't be reordered till we drop + + drop(panel1DragHandle); + expect(getAllThePanelIds()).toEqual([ + 'panel2', + 'panel5', + 'panel3', + 'panel7', + 'panel1', + 'panel8', + 'panel6', + 'panel4', + 'panel9', + 'panel10', + ]); + }); + it('after removing a panel', async () => { + const { rerender } = renderGridLayout(); + const sampleLayoutWithoutPanel1 = cloneDeep(getSampleLayout()); + delete sampleLayoutWithoutPanel1[0].panels.panel1; + rerender({ layout: sampleLayoutWithoutPanel1 }); + + expect(getAllThePanelIds()).toEqual([ + 'panel2', + 'panel5', + 'panel3', + 'panel7', + 'panel6', + 'panel8', + 'panel4', + 'panel9', + 'panel10', + ]); + }); + it('after replacing a panel id', async () => { + const { rerender } = renderGridLayout(); + const modifiedLayout = cloneDeep(getSampleLayout()); + const newPanel = { ...modifiedLayout[0].panels.panel1, id: 'panel11' }; + delete modifiedLayout[0].panels.panel1; + modifiedLayout[0].panels.panel11 = newPanel; + + rerender({ layout: modifiedLayout }); + + expect(getAllThePanelIds()).toEqual([ + 'panel11', + 'panel5', + 'panel2', + 'panel3', + 'panel7', + 'panel6', + 'panel8', + 'panel4', + 'panel9', + 'panel10', + ]); + }); + }); +}); diff --git a/packages/kbn-grid-layout/grid/grid_layout.tsx b/packages/kbn-grid-layout/grid/grid_layout.tsx index 2a14456b1ef62..1406d4b6eb55d 100644 --- a/packages/kbn-grid-layout/grid/grid_layout.tsx +++ b/packages/kbn-grid-layout/grid/grid_layout.tsx @@ -21,7 +21,7 @@ import { useGridLayoutState } from './use_grid_layout_state'; import { isLayoutEqual } from './utils/equality_checks'; import { resolveGridRow } from './utils/resolve_grid_row'; -interface GridLayoutProps { +export interface GridLayoutProps { layout: GridLayoutData; gridSettings: GridSettings; renderPanelContents: (panelId: string) => React.ReactNode; @@ -121,11 +121,6 @@ export const GridLayout = ({ rowIndex={rowIndex} renderPanelContents={renderPanelContents} gridLayoutStateManager={gridLayoutStateManager} - toggleIsCollapsed={() => { - const newLayout = cloneDeep(gridLayoutStateManager.gridLayout$.value); - newLayout[rowIndex].isCollapsed = !newLayout[rowIndex].isCollapsed; - gridLayoutStateManager.gridLayout$.next(newLayout); - }} setInteractionEvent={(nextInteractionEvent) => { if (!nextInteractionEvent) { gridLayoutStateManager.activePanel$.next(undefined); diff --git a/packages/kbn-grid-layout/grid/grid_panel/drag_handle.tsx b/packages/kbn-grid-layout/grid/grid_panel/drag_handle.tsx new file mode 100644 index 0000000000000..90305812ff8d5 --- /dev/null +++ b/packages/kbn-grid-layout/grid/grid_panel/drag_handle.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; + +import { EuiIcon, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { i18n } from '@kbn/i18n'; +import { PanelInteractionEvent } from '../types'; + +export const DragHandle = ({ + interactionStart, +}: { + interactionStart: ( + type: PanelInteractionEvent['type'] | 'drop', + e: React.MouseEvent + ) => void; +}) => { + const { euiTheme } = useEuiTheme(); + return ( + + ); +}; diff --git a/packages/kbn-grid-layout/grid/grid_panel/grid_panel.test.tsx b/packages/kbn-grid-layout/grid/grid_panel/grid_panel.test.tsx new file mode 100644 index 0000000000000..2829a320abab4 --- /dev/null +++ b/packages/kbn-grid-layout/grid/grid_panel/grid_panel.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { GridPanel, GridPanelProps } from './grid_panel'; +import { gridLayoutStateManagerMock } from '../test_utils/mocks'; + +describe('GridPanel', () => { + const mockRenderPanelContents = jest.fn((panelId) =>
Panel Content {panelId}
); + const mockInteractionStart = jest.fn(); + + const renderGridPanel = (propsOverrides: Partial = {}) => { + return render( + + ); + }; + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders panel contents correctly', () => { + renderGridPanel(); + expect(screen.getByText('Panel Content panel1')).toBeInTheDocument(); + }); + + describe('drag handle interaction', () => { + it('calls `drag` interactionStart on mouse down', () => { + renderGridPanel(); + const dragHandle = screen.getByRole('button', { name: /drag to move/i }); + fireEvent.mouseDown(dragHandle); + expect(mockInteractionStart).toHaveBeenCalledWith('drag', expect.any(Object)); + }); + it('calls `drop` interactionStart on mouse up', () => { + renderGridPanel(); + const dragHandle = screen.getByRole('button', { name: /drag to move/i }); + fireEvent.mouseUp(dragHandle); + expect(mockInteractionStart).toHaveBeenCalledWith('drop', expect.any(Object)); + }); + }); + describe('resize handle interaction', () => { + it('calls `resize` interactionStart on mouse down', () => { + renderGridPanel(); + const resizeHandle = screen.getByRole('button', { name: /resize/i }); + fireEvent.mouseDown(resizeHandle); + expect(mockInteractionStart).toHaveBeenCalledWith('resize', expect.any(Object)); + }); + it('calls `drop` interactionStart on mouse up', () => { + renderGridPanel(); + const resizeHandle = screen.getByRole('button', { name: /resize/i }); + fireEvent.mouseUp(resizeHandle); + expect(mockInteractionStart).toHaveBeenCalledWith('drop', expect.any(Object)); + }); + }); +}); diff --git a/packages/kbn-grid-layout/grid/grid_panel.tsx b/packages/kbn-grid-layout/grid/grid_panel/grid_panel.tsx similarity index 67% rename from packages/kbn-grid-layout/grid/grid_panel.tsx rename to packages/kbn-grid-layout/grid/grid_panel/grid_panel.tsx index 91f935f4507f1..e817f5fc3871b 100644 --- a/packages/kbn-grid-layout/grid/grid_panel.tsx +++ b/packages/kbn-grid-layout/grid/grid_panel/grid_panel.tsx @@ -10,39 +10,30 @@ import React, { forwardRef, useEffect, useMemo } from 'react'; import { combineLatest, skip } from 'rxjs'; -import { - EuiIcon, - EuiPanel, - euiFullHeight, - transparentize, - useEuiOverflowScroll, - useEuiTheme, -} from '@elastic/eui'; +import { EuiPanel, euiFullHeight, useEuiOverflowScroll } from '@elastic/eui'; import { css } from '@emotion/react'; import { euiThemeVars } from '@kbn/ui-theme'; - -import { GridLayoutStateManager, PanelInteractionEvent } from './types'; -import { getKeysInOrder } from './utils/resolve_grid_row'; - -export const GridPanel = forwardRef< - HTMLDivElement, - { - panelId: string; - rowIndex: number; - renderPanelContents: (panelId: string) => React.ReactNode; - interactionStart: ( - type: PanelInteractionEvent['type'] | 'drop', - e: React.MouseEvent - ) => void; - gridLayoutStateManager: GridLayoutStateManager; - } ->( +import { GridLayoutStateManager, PanelInteractionEvent } from '../types'; +import { getKeysInOrder } from '../utils/resolve_grid_row'; +import { DragHandle } from './drag_handle'; +import { ResizeHandle } from './resize_handle'; + +export interface GridPanelProps { + panelId: string; + rowIndex: number; + renderPanelContents: (panelId: string) => React.ReactNode; + interactionStart: ( + type: PanelInteractionEvent['type'] | 'drop', + e: React.MouseEvent + ) => void; + gridLayoutStateManager: GridLayoutStateManager; +} + +export const GridPanel = forwardRef( ( { panelId, rowIndex, renderPanelContents, interactionStart, gridLayoutStateManager }, panelRef ) => { - const { euiTheme } = useEuiTheme(); - /** Set initial styles based on state at mount to prevent styles from "blipping" */ const initialStyles = useMemo(() => { const initialPanel = gridLayoutStateManager.gridLayout$.getValue()[rowIndex].panels[panelId]; @@ -158,7 +149,7 @@ export const GridPanel = forwardRef< const panel = allPanels[panelId]; if (!ref || !panel) return; - const sortedKeys = getKeysInOrder(gridLayout[rowIndex]); + const sortedKeys = getKeysInOrder(gridLayout[rowIndex].panels); const currentPanelPosition = sortedKeys.indexOf(panelId); const sortedKeysBefore = sortedKeys.slice(0, currentPanelPosition); const responsiveGridRowStart = sortedKeysBefore.reduce( @@ -180,7 +171,6 @@ export const GridPanel = forwardRef< // eslint-disable-next-line react-hooks/exhaustive-deps [] ); - /** * Memoize panel contents to prevent unnecessary re-renders */ @@ -189,93 +179,29 @@ export const GridPanel = forwardRef< }, [panelId, renderPanelContents]); return ( - <> -
- + + +
- {/* drag handle */} -
interactionStart('drag', e)} - onMouseUp={(e) => interactionStart('drop', e)} - > - -
- {/* Resize handle */} -
interactionStart('resize', e)} - onMouseUp={(e) => interactionStart('drop', e)} - css={css` - right: 0; - bottom: 0; - opacity: 0; - margin: -2px; - position: absolute; - width: ${euiThemeVars.euiSizeL}; - height: ${euiThemeVars.euiSizeL}; - transition: opacity 0.2s, border 0.2s; - border-radius: 7px 0 7px 0; - border-bottom: 2px solid ${euiThemeVars.euiColorSuccess}; - border-right: 2px solid ${euiThemeVars.euiColorSuccess}; - :hover { - opacity: 1; - background-color: ${transparentize(euiThemeVars.euiColorSuccess, 0.05)}; - cursor: se-resize; - } - .kbnGrid--static & { - opacity: 0 !important; - display: none; - } - `} - /> -
- {panelContents} -
- -
- + {panelContents} +
+ +
+
); } ); diff --git a/packages/kbn-grid-layout/grid/grid_panel/index.tsx b/packages/kbn-grid-layout/grid/grid_panel/index.tsx new file mode 100644 index 0000000000000..e286fc92fd9f7 --- /dev/null +++ b/packages/kbn-grid-layout/grid/grid_panel/index.tsx @@ -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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { GridPanel } from './grid_panel'; diff --git a/packages/kbn-grid-layout/grid/grid_panel/resize_handle.tsx b/packages/kbn-grid-layout/grid/grid_panel/resize_handle.tsx new file mode 100644 index 0000000000000..4c4a2d60ee5cb --- /dev/null +++ b/packages/kbn-grid-layout/grid/grid_panel/resize_handle.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; + +import { transparentize } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { i18n } from '@kbn/i18n'; +import { PanelInteractionEvent } from '../types'; + +export const ResizeHandle = ({ + interactionStart, +}: { + interactionStart: ( + type: PanelInteractionEvent['type'] | 'drop', + e: React.MouseEvent + ) => void; +}) => { + return ( + +)); + +const runtimeSettings$ = new BehaviorSubject({ + ...gridSettings, + columnPixelWidth: 0, +}); + +export const gridLayoutStateManagerMock: GridLayoutStateManager = { + expandedPanelId$: new BehaviorSubject(undefined), + isMobileView$: new BehaviorSubject(false), + gridLayout$, + runtimeSettings$, + panelRefs: { current: [] }, + rowRefs: { current: [] }, + interactionEvent$: new BehaviorSubject(undefined), + activePanel$: new BehaviorSubject(undefined), + gridDimensions$: new BehaviorSubject({ width: 600, height: 900 }), +}; diff --git a/packages/kbn-grid-layout/grid/test_utils/sample_layout.ts b/packages/kbn-grid-layout/grid/test_utils/sample_layout.ts new file mode 100644 index 0000000000000..035a6f1dda2ee --- /dev/null +++ b/packages/kbn-grid-layout/grid/test_utils/sample_layout.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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { GridLayoutData } from '../types'; + +export const getSampleLayout = (): GridLayoutData => [ + { + title: 'Large section', + isCollapsed: false, + panels: { + panel1: { + id: 'panel1', + row: 0, + column: 0, + width: 12, + height: 6, + }, + panel2: { + id: 'panel2', + row: 6, + column: 0, + width: 8, + height: 4, + }, + panel3: { + id: 'panel3', + row: 6, + column: 8, + width: 12, + height: 4, + }, + panel4: { + id: 'panel4', + row: 10, + column: 0, + width: 48, + height: 4, + }, + panel5: { + id: 'panel5', + row: 0, + column: 12, + width: 36, + height: 6, + }, + panel6: { + id: 'panel6', + row: 6, + column: 24, + width: 24, + height: 4, + }, + panel7: { + id: 'panel7', + row: 6, + column: 20, + width: 4, + height: 2, + }, + panel8: { + id: 'panel8', + row: 8, + column: 20, + width: 4, + height: 2, + }, + }, + }, + { + title: 'Small section', + isCollapsed: false, + panels: { + panel9: { + id: 'panel9', + row: 0, + column: 0, + width: 12, + height: 16, + }, + }, + }, + { + title: 'Another small section', + isCollapsed: false, + panels: { + panel10: { + id: 'panel10', + row: 0, + column: 24, + width: 12, + height: 6, + }, + }, + }, +]; diff --git a/packages/kbn-grid-layout/grid/use_grid_layout_events.ts b/packages/kbn-grid-layout/grid/use_grid_layout_events.ts index 9a6d6d2303909..64cc8f482838e 100644 --- a/packages/kbn-grid-layout/grid/use_grid_layout_events.ts +++ b/packages/kbn-grid-layout/grid/use_grid_layout_events.ts @@ -87,6 +87,7 @@ export const useGridLayoutEvents = ({ bottom: mouseTargetPixel.y - interactionEvent.mouseOffsets.bottom, right: mouseTargetPixel.x - interactionEvent.mouseOffsets.right, }; + gridLayoutStateManager.activePanel$.next({ id: interactionEvent.id, position: previewRect }); // find the grid that the preview rect is over diff --git a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts index 9a6f28d006e0a..38b778b5d0571 100644 --- a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts +++ b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts @@ -34,11 +34,11 @@ const getAllCollisionsWithPanel = ( return collidingPanels; }; -export const getKeysInOrder = (rowData: GridRowData, draggedId?: string): string[] => { - const panelKeys = Object.keys(rowData.panels); +export const getKeysInOrder = (panels: GridRowData['panels'], draggedId?: string): string[] => { + const panelKeys = Object.keys(panels); return panelKeys.sort((panelKeyA, panelKeyB) => { - const panelA = rowData.panels[panelKeyA]; - const panelB = rowData.panels[panelKeyB]; + const panelA = panels[panelKeyA]; + const panelB = panels[panelKeyB]; // sort by row first if (panelA.row > panelB.row) return 1; @@ -60,7 +60,7 @@ export const getKeysInOrder = (rowData: GridRowData, draggedId?: string): string const compactGridRow = (originalLayout: GridRowData) => { const nextRowData = { ...originalLayout, panels: { ...originalLayout.panels } }; // compact all vertical space. - const sortedKeysAfterMove = getKeysInOrder(nextRowData); + const sortedKeysAfterMove = getKeysInOrder(nextRowData.panels); for (const panelKey of sortedKeysAfterMove) { const panel = nextRowData.panels[panelKey]; // try moving panel up one row at a time until it collides @@ -90,7 +90,7 @@ export const resolveGridRow = ( // return nextRowData; // push all panels down if they collide with another panel - const sortedKeys = getKeysInOrder(nextRowData, dragRequest?.id); + const sortedKeys = getKeysInOrder(nextRowData.panels, dragRequest?.id); for (const key of sortedKeys) { const panel = nextRowData.panels[key]; diff --git a/packages/kbn-grid-layout/tsconfig.json b/packages/kbn-grid-layout/tsconfig.json index f0dd3232a42d5..bd16ae0f0adeb 100644 --- a/packages/kbn-grid-layout/tsconfig.json +++ b/packages/kbn-grid-layout/tsconfig.json @@ -2,12 +2,6 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "target/types", - "types": [ - "jest", - "node", - "react", - "@emotion/react/types/css-prop" - ] }, "include": [ "**/*.ts", diff --git a/packages/kbn-language-documentation/src/sections/generated/scalar_functions.tsx b/packages/kbn-language-documentation/src/sections/generated/scalar_functions.tsx index 77d7ef4621cd5..67cbb7bc66b2b 100644 --- a/packages/kbn-language-documentation/src/sections/generated/scalar_functions.tsx +++ b/packages/kbn-language-documentation/src/sections/generated/scalar_functions.tsx @@ -176,6 +176,43 @@ export const functions = { ROW y=12.9, x=.6 | EVAL atan2=ATAN2(y, x) \`\`\` + `, + description: + 'Text is in markdown. Do not translate function names, special characters, or field names like sum(bytes)', + ignoreTag: true, + } + )} + /> + ), + }, + // Do not edit manually... automatically generated by scripts/generate_esql_docs.ts + { + label: i18n.translate('languageDocumentation.documentationESQL.bit_length', { + defaultMessage: 'BIT_LENGTH', + }), + preview: false, + description: ( + + + ### BIT_LENGTH + Returns the bit length of a string. + + \`\`\` + FROM airports + | WHERE country == "India" + | KEEP city + | EVAL fn_length = LENGTH(city), fn_bit_length = BIT_LENGTH(city) + \`\`\` + Note: All strings are in UTF-8, so a single character can use multiple bytes. `, description: 'Text is in markdown. Do not translate function names, special characters, or field names like sum(bytes)', @@ -213,6 +250,43 @@ export const functions = { | STATS hire_date = MV_SORT(VALUES(hire_date)) BY month = BUCKET(hire_date, 20, "1985-01-01T00:00:00Z", "1986-01-01T00:00:00Z") | SORT hire_date \`\`\` + `, + description: + 'Text is in markdown. Do not translate function names, special characters, or field names like sum(bytes)', + ignoreTag: true, + } + )} + /> + ), + }, + // Do not edit manually... automatically generated by scripts/generate_esql_docs.ts + { + label: i18n.translate('languageDocumentation.documentationESQL.byte_length', { + defaultMessage: 'BYTE_LENGTH', + }), + preview: false, + description: ( + + + ### BYTE_LENGTH + Returns the byte length of a string. + + \`\`\` + FROM airports + | WHERE country == "India" + | KEEP city + | EVAL fn_length = LENGTH(city), fn_byte_length = BYTE_LENGTH(city) + \`\`\` + Note: All strings are in UTF-8, so a single character can use multiple bytes. `, description: 'Text is in markdown. Do not translate function names, special characters, or field names like sum(bytes)', @@ -1027,10 +1101,12 @@ export const functions = { Returns the character length of a string. \`\`\` - FROM employees - | KEEP first_name, last_name - | EVAL fn_length = LENGTH(first_name) + FROM airports + | WHERE country == "India" + | KEEP city + | EVAL fn_length = LENGTH(city) \`\`\` + Note: All strings are in UTF-8, so a single character can use multiple bytes. `, description: 'Text is in markdown. Do not translate function names, special characters, or field names like sum(bytes)', diff --git a/packages/kbn-search-connectors/components/configuration/connector_configuration.tsx b/packages/kbn-search-connectors/components/configuration/connector_configuration.tsx index cd80b2489012e..8adc6ac2e38c4 100644 --- a/packages/kbn-search-connectors/components/configuration/connector_configuration.tsx +++ b/packages/kbn-search-connectors/components/configuration/connector_configuration.tsx @@ -47,6 +47,7 @@ interface ConnectorConfigurationProps { isLoading: boolean; saveConfig: (configuration: Record) => void; saveAndSync?: (configuration: Record) => void; + onEditStateChange?: (isEdit: boolean) => void; stackManagementLink?: string; subscriptionLink?: string; children?: React.ReactNode; @@ -94,6 +95,7 @@ export const ConnectorConfigurationComponent: FC< isLoading, saveConfig, saveAndSync, + onEditStateChange, subscriptionLink, stackManagementLink, }) => { @@ -110,6 +112,15 @@ export const ConnectorConfigurationComponent: FC< ); const [isEditing, setIsEditing] = useState(false); + useEffect( + function propogateEditState() { + if (onEditStateChange) { + onEditStateChange(isEditing); + } + }, + [isEditing, onEditStateChange] + ); + useEffect(() => { if (!isDeepEqual(configuration, configurationRef.current)) { configurationRef.current = configuration; diff --git a/packages/kbn-securitysolution-utils/src/esql/index.ts b/packages/kbn-securitysolution-utils/src/esql/index.ts index 22c2cc42a9fed..930ff246988ea 100644 --- a/packages/kbn-securitysolution-utils/src/esql/index.ts +++ b/packages/kbn-securitysolution-utils/src/esql/index.ts @@ -9,3 +9,4 @@ export * from './compute_if_esql_query_aggregating'; export * from './get_index_list_from_esql_query'; +export * from './parse_esql_query'; diff --git a/packages/kbn-securitysolution-utils/src/esql/parse_esql_query.test.ts b/packages/kbn-securitysolution-utils/src/esql/parse_esql_query.test.ts new file mode 100644 index 0000000000000..6c4fdafd8e70b --- /dev/null +++ b/packages/kbn-securitysolution-utils/src/esql/parse_esql_query.test.ts @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { parseEsqlQuery } from './parse_esql_query'; + +describe('parseEsqlQuery', () => { + describe('ES|QL query syntax', () => { + it.each([['incorrect syntax'], ['from test* metadata']])( + 'detects incorrect syntax in "%s"', + (esqlQuery) => { + const result = parseEsqlQuery(esqlQuery); + expect(result.errors.length).toEqual(1); + expect(result.errors[0].message.startsWith('SyntaxError:')).toBeTruthy(); + expect(parseEsqlQuery(esqlQuery)).toMatchObject({ + hasMetadataOperator: false, + isEsqlQueryAggregating: false, + }); + } + ); + + it.each([ + ['from test* metadata _id'], + [ + 'FROM kibana_sample_data_logs | STATS total_bytes = SUM(bytes) BY host | WHERE total_bytes > 200000 | SORT total_bytes DESC | LIMIT 10', + ], + [ + `from packetbeat* metadata + _id + | limit 100`, + ], + [ + `FROM kibana_sample_data_logs | + STATS total_bytes = SUM(bytes) BY host | + WHERE total_bytes > 200000 | + SORT total_bytes DESC | + LIMIT 10`, + ], + ])('parses correctly valid syntax in "%s"', (esqlQuery) => { + const result = parseEsqlQuery(esqlQuery); + expect(result.errors.length).toEqual(0); + expect(result).toMatchObject({ errors: [] }); + }); + }); + + describe('METADATA operator', () => { + it.each([ + ['from test*'], + ['from metadata*'], + ['from test* | keep metadata'], + ['from test* | eval x="metadata _id"'], + ])('detects when METADATA operator is missing in a NON aggregating query "%s"', (esqlQuery) => { + expect(parseEsqlQuery(esqlQuery)).toEqual({ + errors: [], + hasMetadataOperator: false, + isEsqlQueryAggregating: false, + }); + }); + + it.each([ + ['from test* metadata _id'], + ['from test* metadata _id, _index'], + ['from test* metadata _index, _id'], + ['from test* metadata _id '], + ['from test* metadata _id '], + ['from test* metadata _id | limit 10'], + [ + `from packetbeat* metadata + + _id + | limit 100`, + ], + ])('detects existin METADATA operator in a NON aggregating query "%s"', (esqlQuery) => + expect(parseEsqlQuery(esqlQuery)).toEqual({ + errors: [], + hasMetadataOperator: true, + isEsqlQueryAggregating: false, + }) + ); + + it('detects missing METADATA operator in an aggregating query "from test* | stats c = count(*) by fieldA"', () => + expect(parseEsqlQuery('from test* | stats c = count(*) by fieldA')).toEqual({ + errors: [], + hasMetadataOperator: false, + isEsqlQueryAggregating: true, + })); + }); + + describe('METADATA _id field for NON aggregating queries', () => { + it('detects missing METADATA "_id" field', () => { + expect(parseEsqlQuery('from test*')).toEqual({ + errors: [], + hasMetadataOperator: false, + isEsqlQueryAggregating: false, + }); + }); + + it('detects existing METADATA "_id" field', async () => { + expect(parseEsqlQuery('from test* metadata _id')).toEqual({ + errors: [], + hasMetadataOperator: true, + isEsqlQueryAggregating: false, + }); + }); + }); + + describe('METADATA _id field for aggregating queries', () => { + it('detects existing METADATA operator with missing "_id" field', () => { + expect( + parseEsqlQuery('from test* metadata someField | stats c = count(*) by fieldA') + ).toEqual({ errors: [], hasMetadataOperator: false, isEsqlQueryAggregating: true }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/validation/esql_query.ts b/packages/kbn-securitysolution-utils/src/esql/parse_esql_query.ts similarity index 66% rename from x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/validation/esql_query.ts rename to packages/kbn-securitysolution-utils/src/esql/parse_esql_query.ts index a8c1d6acff408..2a62aed8873a0 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/validation/esql_query.ts +++ b/packages/kbn-securitysolution-utils/src/esql/parse_esql_query.ts @@ -1,21 +1,40 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { ESQLAstQueryExpression, ESQLCommandOption, EditorError } from '@kbn/esql-ast'; -import { parse } from '@kbn/esql-ast'; +import { type ESQLAstQueryExpression, parse, ESQLCommandOption, EditorError } from '@kbn/esql-ast'; import { isColumnItem, isOptionItem } from '@kbn/esql-validation-autocomplete'; -import { isAggregatingQuery } from '@kbn/securitysolution-utils'; +import { isAggregatingQuery } from './compute_if_esql_query_aggregating'; -interface ParseEsqlQueryResult { +export interface ParseEsqlQueryResult { errors: EditorError[]; isEsqlQueryAggregating: boolean; hasMetadataOperator: boolean; } +/** + * check if esql query valid for Security rule: + * - if it's non aggregation query it must have metadata operator + */ +export const parseEsqlQuery = (query: string): ParseEsqlQueryResult => { + const { root, errors } = parse(query); + const isEsqlQueryAggregating = isAggregatingQuery(root); + + return { + errors, + isEsqlQueryAggregating, + hasMetadataOperator: computeHasMetadataOperator(root), + }; +}; + +/** + * checks whether query has metadata _id operator + */ function computeHasMetadataOperator(astExpression: ESQLAstQueryExpression): boolean { // Check whether the `from` command has `metadata` operator const metadataOption = getMetadataOption(astExpression); @@ -50,13 +69,3 @@ function getMetadataOption(astExpression: ESQLAstQueryExpression): ESQLCommandOp return undefined; } - -export const parseEsqlQuery = (query: string): ParseEsqlQueryResult => { - const { root, errors } = parse(query); - const isEsqlQueryAggregating = isAggregatingQuery(root); - return { - errors, - isEsqlQueryAggregating, - hasMetadataOperator: computeHasMetadataOperator(root), - }; -}; diff --git a/packages/kbn-securitysolution-utils/tsconfig.json b/packages/kbn-securitysolution-utils/tsconfig.json index 5b9520c487e31..d45b0c973af87 100644 --- a/packages/kbn-securitysolution-utils/tsconfig.json +++ b/packages/kbn-securitysolution-utils/tsconfig.json @@ -13,7 +13,8 @@ "kbn_references": [ "@kbn/i18n", "@kbn/esql-utils", - "@kbn/esql-ast" + "@kbn/esql-ast", + "@kbn/esql-validation-autocomplete" ], "exclude": [ "target/**/*", diff --git a/packages/kbn-server-route-repository-client/src/create_observable_from_http_response.ts b/packages/kbn-server-route-repository-client/src/create_observable_from_http_response.ts index 1690244ca19a7..ccd0980617f86 100644 --- a/packages/kbn-server-route-repository-client/src/create_observable_from_http_response.ts +++ b/packages/kbn-server-route-repository-client/src/create_observable_from_http_response.ts @@ -37,10 +37,10 @@ export function createObservableFromHttpResponse( } return new Observable((subscriber) => { - const parser = createParser((event) => { - if (event.type === 'event') { + const parser = createParser({ + onEvent: (event) => { subscriber.next(event.data); - } + }, }); const readStream = async () => { diff --git a/packages/kbn-sse-utils-client/src/create_observable_from_http_response.ts b/packages/kbn-sse-utils-client/src/create_observable_from_http_response.ts index 814a528535c75..cf9ef8ee30a7b 100644 --- a/packages/kbn-sse-utils-client/src/create_observable_from_http_response.ts +++ b/packages/kbn-sse-utils-client/src/create_observable_from_http_response.ts @@ -29,8 +29,8 @@ export function createObservableFromHttpResponse((subscriber) => { - const parser = createParser((event) => { - if (event.type === 'event') + const parser = createParser({ + onEvent: (event) => { try { const data = JSON.parse(event.data); if (event.event === 'error') { @@ -48,6 +48,7 @@ export function createObservableFromHttpResponse { diff --git a/packages/kbn-sse-utils-server/index.ts b/packages/kbn-sse-utils-server/index.ts index ec2c60a2fe81b..bf2718738f4f7 100644 --- a/packages/kbn-sse-utils-server/index.ts +++ b/packages/kbn-sse-utils-server/index.ts @@ -8,3 +8,4 @@ */ export { observableIntoEventSourceStream } from './src/observable_into_event_source_stream'; +export { supertestToObservable } from './src/supertest_to_observable'; diff --git a/packages/kbn-sse-utils-server/src/supertest_to_observable.ts b/packages/kbn-sse-utils-server/src/supertest_to_observable.ts new file mode 100644 index 0000000000000..f2dfce24c1d1b --- /dev/null +++ b/packages/kbn-sse-utils-server/src/supertest_to_observable.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type supertest from 'supertest'; +import { PassThrough } from 'stream'; +import { createParser } from 'eventsource-parser'; +import { Observable } from 'rxjs'; + +/** + * Convert a supertest response to an SSE observable. + * + * Note: the supertest response should *NOT* be awaited when using that utility, + * or at least not before calling it. + * + * @example + * ```ts + * const response = supertest + * .post(`/some/sse/endpoint`) + * .set('kbn-xsrf', 'kibana') + * .send({ + * some: 'thing' + * }); + * const events = supertestIntoObservable(response); + * ``` + */ +export function supertestToObservable(response: supertest.Test): Observable { + const stream = new PassThrough(); + response.pipe(stream); + + return new Observable((subscriber) => { + const parser = createParser({ + onEvent: (event) => { + subscriber.next(JSON.parse(event.data)); + }, + }); + + const readStream = async () => { + return new Promise((resolve, reject) => { + const decoder = new TextDecoder(); + + const processChunk = (value: BufferSource) => { + parser.feed(decoder.decode(value, { stream: true })); + }; + + stream.on('data', (chunk) => { + processChunk(chunk); + }); + + stream.on('end', () => resolve()); + stream.on('error', (err) => reject(err)); + }); + }; + + readStream() + .then(() => { + subscriber.complete(); + }) + .catch((error) => { + subscriber.error(error); + }); + }); +} diff --git a/packages/response-ops/rule_form/src/rule_definition/rule_definition.test.tsx b/packages/response-ops/rule_form/src/rule_definition/rule_definition.test.tsx index ca01bbc484570..9ee39ca93f1be 100644 --- a/packages/response-ops/rule_form/src/rule_definition/rule_definition.test.tsx +++ b/packages/response-ops/rule_form/src/rule_definition/rule_definition.test.tsx @@ -236,6 +236,34 @@ describe('Rule Definition', () => { expect(screen.queryByTestId('ruleConsumerSelection')).not.toBeInTheDocument(); }); + test('Hides consumer selection if there are irrelevant consumers and only 1 consumer to select', () => { + useRuleFormState.mockReturnValue({ + plugins, + formData: { + id: 'test-id', + params: {}, + schedule: { + interval: '1m', + }, + alertDelay: { + active: 5, + }, + notifyWhen: null, + consumer: 'stackAlerts', + ruleTypeId: '.es-query', + }, + selectedRuleType: ruleType, + selectedRuleTypeModel: ruleModel, + availableRuleTypes: [ruleType], + canShowConsumerSelect: true, + validConsumers: ['logs', 'observability'], + }); + + render(); + + expect(screen.queryByTestId('ruleConsumerSelection')).not.toBeInTheDocument(); + }); + test('Hides consumer selection if valid consumers contain observability', () => { useRuleFormState.mockReturnValue({ plugins, diff --git a/packages/response-ops/rule_form/src/rule_definition/rule_definition.tsx b/packages/response-ops/rule_form/src/rule_definition/rule_definition.tsx index eaa608ab42434..5cedd0d117527 100644 --- a/packages/response-ops/rule_form/src/rule_definition/rule_definition.tsx +++ b/packages/response-ops/rule_form/src/rule_definition/rule_definition.tsx @@ -30,7 +30,6 @@ import { RuleSettingsFlappingTitleTooltip, } from '@kbn/alerts-ui-shared/lib'; import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; -import { AlertConsumers } from '@kbn/rule-data-utils'; import React, { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; import { ALERTING_FEATURE_ID, MULTI_CONSUMER_RULE_TYPE_IDS } from '../constants'; import { IS_RULE_SPECIFIC_FLAPPING_ENABLED } from '../constants/rule_flapping'; @@ -41,6 +40,7 @@ import { ALERT_DELAY_HELP_TEXT, ALERT_DELAY_TITLE, ALERT_FLAPPING_DETECTION_DESCRIPTION, + FEATURE_NAME_MAP, ALERT_FLAPPING_DETECTION_TITLE, DOC_LINK_TITLE, LOADING_RULE_TYPE_PARAMS_TITLE, @@ -116,15 +116,18 @@ export const RuleDefinition = () => { if (!canShowConsumerSelection) { return false; } - if (!authorizedConsumers.length) { - return false; - } - if ( - authorizedConsumers.length <= 1 || - authorizedConsumers.includes(AlertConsumers.OBSERVABILITY) - ) { + + /* + * This will filter out values like 'alerts' and 'observability' that will not be displayed + * in the drop down. It will allow us to hide the consumer select when there is only one + * selectable value. + */ + const authorizedValidConsumers = authorizedConsumers.filter((c) => c in FEATURE_NAME_MAP); + + if (authorizedValidConsumers.length <= 1) { return false; } + return !!(ruleTypeId && MULTI_CONSUMER_RULE_TYPE_IDS.includes(ruleTypeId)); }, [ruleTypeId, authorizedConsumers, canShowConsumerSelection]); diff --git a/src/plugins/data/server/kql_telemetry/route.ts b/src/plugins/data/server/kql_telemetry/route.ts index 4d6d3b5871bb0..ef9aaa16ec027 100644 --- a/src/plugins/data/server/kql_telemetry/route.ts +++ b/src/plugins/data/server/kql_telemetry/route.ts @@ -24,6 +24,12 @@ export function registerKqlTelemetryRoute( .addVersion( { version: KQL_TELEMETRY_ROUTE_LATEST_VERSION, + security: { + authz: { + enabled: false, + reason: 'This route is opted out from authorization', + }, + }, validate: { request: { body: schema.object({ diff --git a/src/plugins/data/server/query/routes.ts b/src/plugins/data/server/query/routes.ts index 950363d498a1c..cb1f79af1144d 100644 --- a/src/plugins/data/server/query/routes.ts +++ b/src/plugins/data/server/query/routes.ts @@ -43,6 +43,11 @@ export function registerSavedQueryRoutes({ http }: CoreSetup): void { router.versioned.post({ path: `${SAVED_QUERY_BASE_URL}/_is_duplicate_title`, access }).addVersion( { version, + security: { + authz: { + requiredPrivileges: ['savedQuery:read'], + }, + }, validate: { request: { body: schema.object({ @@ -75,6 +80,11 @@ export function registerSavedQueryRoutes({ http }: CoreSetup): void { router.versioned.post({ path: `${SAVED_QUERY_BASE_URL}/_create`, access }).addVersion( { version, + security: { + authz: { + requiredPrivileges: ['savedQuery:manage'], + }, + }, validate: { request: { body: SAVED_QUERY_ATTRS_CONFIG, @@ -101,6 +111,11 @@ export function registerSavedQueryRoutes({ http }: CoreSetup): void { router.versioned.put({ path: `${SAVED_QUERY_BASE_URL}/{id}`, access }).addVersion( { version, + security: { + authz: { + requiredPrivileges: ['savedQuery:manage'], + }, + }, validate: { request: { params: SAVED_QUERY_ID_CONFIG, @@ -129,6 +144,11 @@ export function registerSavedQueryRoutes({ http }: CoreSetup): void { router.versioned.get({ path: `${SAVED_QUERY_BASE_URL}/{id}`, access }).addVersion( { version, + security: { + authz: { + requiredPrivileges: ['savedQuery:read'], + }, + }, validate: { request: { params: SAVED_QUERY_ID_CONFIG, @@ -156,6 +176,11 @@ export function registerSavedQueryRoutes({ http }: CoreSetup): void { router.versioned.get({ path: `${SAVED_QUERY_BASE_URL}/_count`, access }).addVersion( { version, + security: { + authz: { + requiredPrivileges: ['savedQuery:read'], + }, + }, validate: { request: {}, response: { @@ -180,6 +205,11 @@ export function registerSavedQueryRoutes({ http }: CoreSetup): void { router.versioned.post({ path: `${SAVED_QUERY_BASE_URL}/_find`, access }).addVersion( { version, + security: { + authz: { + requiredPrivileges: ['savedQuery:read'], + }, + }, validate: { request: { body: schema.object({ @@ -214,6 +244,11 @@ export function registerSavedQueryRoutes({ http }: CoreSetup): void { router.versioned.delete({ path: `${SAVED_QUERY_BASE_URL}/{id}`, access }).addVersion( { version, + security: { + authz: { + requiredPrivileges: ['savedQuery:manage'], + }, + }, validate: { request: { params: SAVED_QUERY_ID_CONFIG, diff --git a/src/plugins/data/server/scripts/route.ts b/src/plugins/data/server/scripts/route.ts index f4c802f551d51..2e4cd5364ce01 100644 --- a/src/plugins/data/server/scripts/route.ts +++ b/src/plugins/data/server/scripts/route.ts @@ -20,6 +20,12 @@ export function registerScriptsRoute(router: IRouter) { .addVersion( { version: SCRIPT_LANGUAGES_ROUTE_LATEST_VERSION, + security: { + authz: { + enabled: false, + reason: 'This route is opted out from authorization', + }, + }, validate: { response: { '200': { diff --git a/src/plugins/data/server/search/routes/session.ts b/src/plugins/data/server/search/routes/session.ts index 0ae8eebe8180c..b339631e162f9 100644 --- a/src/plugins/data/server/search/routes/session.ts +++ b/src/plugins/data/server/search/routes/session.ts @@ -24,11 +24,8 @@ import { searchSessionsUpdateSchema, } from './response_schema'; -const STORE_SEARCH_SESSIONS_ROLE_TAG = `access:store_search_session`; const access = 'internal'; -const options = { - tags: [STORE_SEARCH_SESSIONS_ROLE_TAG], -}; +const requiredPrivileges = ['store_search_session']; const pathPrefix = '/internal/session'; export const INITIAL_SEARCH_SESSION_REST_VERSION = '1'; const version = INITIAL_SEARCH_SESSION_REST_VERSION; @@ -37,9 +34,12 @@ const idAndAttrsOnly = (so?: SearchSessionRestResponse) => so && { id: so.id, attributes: so.attributes }; export function registerSessionRoutes(router: DataPluginRouter, logger: Logger): void { - router.versioned.post({ path: pathPrefix, access, options }).addVersion( + router.versioned.post({ path: pathPrefix, access }).addVersion( { version, + security: { + authz: { requiredPrivileges }, + }, validate: { request: { body: schema.object({ @@ -85,9 +85,12 @@ export function registerSessionRoutes(router: DataPluginRouter, logger: Logger): } ); - router.versioned.get({ path: `${pathPrefix}/{id}`, access, options }).addVersion( + router.versioned.get({ path: `${pathPrefix}/{id}`, access }).addVersion( { version, + security: { + authz: { requiredPrivileges }, + }, validate: { request: { params: schema.object({ @@ -117,9 +120,12 @@ export function registerSessionRoutes(router: DataPluginRouter, logger: Logger): } ); - router.versioned.get({ path: `${pathPrefix}/{id}/status`, access, options }).addVersion( + router.versioned.get({ path: `${pathPrefix}/{id}/status`, access }).addVersion( { version, + security: { + authz: { requiredPrivileges }, + }, validate: { request: { params: schema.object({ @@ -150,9 +156,12 @@ export function registerSessionRoutes(router: DataPluginRouter, logger: Logger): } ); - router.versioned.post({ path: `${pathPrefix}/_find`, access, options }).addVersion( + router.versioned.post({ path: `${pathPrefix}/_find`, access }).addVersion( { version, + security: { + authz: { requiredPrivileges }, + }, validate: { request: { body: schema.object({ @@ -200,9 +209,12 @@ export function registerSessionRoutes(router: DataPluginRouter, logger: Logger): } ); - router.versioned.delete({ path: `${pathPrefix}/{id}`, access, options }).addVersion( + router.versioned.delete({ path: `${pathPrefix}/{id}`, access }).addVersion( { version, + security: { + authz: { requiredPrivileges }, + }, validate: { request: { params: schema.object({ @@ -226,9 +238,12 @@ export function registerSessionRoutes(router: DataPluginRouter, logger: Logger): } ); - router.versioned.post({ path: `${pathPrefix}/{id}/cancel`, access, options }).addVersion( + router.versioned.post({ path: `${pathPrefix}/{id}/cancel`, access }).addVersion( { version, + security: { + authz: { requiredPrivileges }, + }, validate: { request: { params: schema.object({ @@ -252,9 +267,12 @@ export function registerSessionRoutes(router: DataPluginRouter, logger: Logger): } ); - router.versioned.put({ path: `${pathPrefix}/{id}`, access, options }).addVersion( + router.versioned.put({ path: `${pathPrefix}/{id}`, access }).addVersion( { version, + security: { + authz: { requiredPrivileges }, + }, validate: { request: { params: schema.object({ @@ -291,9 +309,12 @@ export function registerSessionRoutes(router: DataPluginRouter, logger: Logger): } ); - router.versioned.post({ path: `${pathPrefix}/{id}/_extend`, access, options }).addVersion( + router.versioned.post({ path: `${pathPrefix}/{id}/_extend`, access }).addVersion( { version, + security: { + authz: { requiredPrivileges }, + }, validate: { request: { params: schema.object({ diff --git a/src/plugins/discover/public/embeddable/__mocks__/get_mocked_api.ts b/src/plugins/discover/public/embeddable/__mocks__/get_mocked_api.ts index fdc9cba8a5bfa..592cf3d80faef 100644 --- a/src/plugins/discover/public/embeddable/__mocks__/get_mocked_api.ts +++ b/src/plugins/discover/public/embeddable/__mocks__/get_mocked_api.ts @@ -8,7 +8,7 @@ */ import { BehaviorSubject } from 'rxjs'; - +import type { Adapters } from '@kbn/inspector-plugin/common'; import { SearchSource } from '@kbn/data-plugin/common'; import type { DataView } from '@kbn/data-views-plugin/common'; import { DataTableRecord } from '@kbn/discover-utils'; @@ -58,6 +58,7 @@ export const getMockedSearchApi = ({ rows: new BehaviorSubject([]), totalHitCount: new BehaviorSubject(0), columnsMeta: new BehaviorSubject | undefined>(undefined), + inspectorAdapters: new BehaviorSubject({}), }, }; }; diff --git a/src/plugins/discover/public/embeddable/get_search_embeddable_factory.tsx b/src/plugins/discover/public/embeddable/get_search_embeddable_factory.tsx index 4117a36a4e048..c1b5cf9f7695f 100644 --- a/src/plugins/discover/public/embeddable/get_search_embeddable_factory.tsx +++ b/src/plugins/discover/public/embeddable/get_search_embeddable_factory.tsx @@ -196,6 +196,7 @@ export const getSearchEmbeddableFactory = ({ savedObjectId: savedObjectId$.getValue(), discoverServices, }), + getInspectorAdapters: () => searchEmbeddable.stateManager.inspectorAdapters.getValue(), }, { ...titleComparators, diff --git a/src/plugins/discover/public/embeddable/initialize_fetch.test.ts b/src/plugins/discover/public/embeddable/initialize_fetch.test.ts index 6fa66115da6e0..061a934dfa2b5 100644 --- a/src/plugins/discover/public/embeddable/initialize_fetch.test.ts +++ b/src/plugins/discover/public/embeddable/initialize_fetch.test.ts @@ -69,6 +69,7 @@ describe('initialize fetch', () => { ].map((hit) => buildDataTableRecord(hit, dataViewMock)) ); expect(stateManager.totalHitCount.getValue()).toEqual(2); + expect(stateManager.inspectorAdapters.getValue().requests).toBeDefined(); }); it('should catch and emit error', async () => { diff --git a/src/plugins/discover/public/embeddable/initialize_fetch.ts b/src/plugins/discover/public/embeddable/initialize_fetch.ts index b502ea94bd371..9ef2a3c167272 100644 --- a/src/plugins/discover/public/embeddable/initialize_fetch.ts +++ b/src/plugins/discover/public/embeddable/initialize_fetch.ts @@ -90,7 +90,7 @@ export function initializeFetch({ stateManager: SearchEmbeddableStateManager; discoverServices: DiscoverServices; }) { - const requestAdapter = new RequestAdapter(); + const inspectorAdapters = { requests: new RequestAdapter() }; let abortController: AbortController | undefined; const fetchSubscription = combineLatest([fetch$(api), api.savedSearch$, api.dataViews]) @@ -127,7 +127,7 @@ export function initializeFetch({ const searchSourceQuery = savedSearch.searchSource.getField('query'); // Log request to inspector - requestAdapter.reset(); + inspectorAdapters.requests.reset(); try { api.dataLoading.next(true); @@ -156,7 +156,7 @@ export function initializeFetch({ filters: fetchContext.filters, dataView, abortSignal: currentAbortController.signal, - inspectorAdapters: discoverServices.inspector, + inspectorAdapters, data: discoverServices.data, expressions: discoverServices.expressions, profilesManager: discoverServices.profilesManager, @@ -181,9 +181,9 @@ export function initializeFetch({ abortSignal: currentAbortController.signal, sessionId: searchSessionId, inspector: { - adapter: requestAdapter, - title: i18n.translate('discover.embeddable.inspectorRequestDataTitle', { - defaultMessage: 'Data', + adapter: inspectorAdapters.requests, + title: i18n.translate('discover.embeddable.inspectorTableRequestTitle', { + defaultMessage: 'Table', }), description: i18n.translate('discover.embeddable.inspectorRequestDescription', { defaultMessage: @@ -195,7 +195,7 @@ export function initializeFetch({ }) ); const interceptedWarnings: SearchResponseWarning[] = []; - discoverServices.data.search.showWarnings(requestAdapter, (warning) => { + discoverServices.data.search.showWarnings(inspectorAdapters.requests, (warning) => { interceptedWarnings.push(warning); return true; // suppress the default behaviour }); @@ -225,6 +225,8 @@ export function initializeFetch({ stateManager.rows.next(next.rows ?? []); stateManager.totalHitCount.next(next.hitCount); + stateManager.inspectorAdapters.next(inspectorAdapters); + api.fetchWarnings$.next(next.warnings ?? []); api.fetchContext$.next(next.fetchContext); if (Object.hasOwn(next, 'columnsMeta')) { diff --git a/src/plugins/discover/public/embeddable/initialize_search_embeddable_api.tsx b/src/plugins/discover/public/embeddable/initialize_search_embeddable_api.tsx index 650d2e95852bb..65584892ff7d3 100644 --- a/src/plugins/discover/public/embeddable/initialize_search_embeddable_api.tsx +++ b/src/plugins/discover/public/embeddable/initialize_search_embeddable_api.tsx @@ -10,7 +10,7 @@ import { pick } from 'lodash'; import deepEqual from 'react-fast-compare'; import { BehaviorSubject, combineLatest, map, Observable, skip } from 'rxjs'; - +import type { Adapters } from '@kbn/inspector-plugin/common'; import { ISearchSource, SerializedSearchSourceFields } from '@kbn/data-plugin/common'; import { DataView } from '@kbn/data-views-plugin/common'; import { DataTableRecord } from '@kbn/discover-utils/types'; @@ -114,6 +114,7 @@ export const initializeSearchEmbeddableApi = async ( const rows$ = new BehaviorSubject([]); const columnsMeta$ = new BehaviorSubject(undefined); const totalHitCount$ = new BehaviorSubject(undefined); + const inspectorAdapters$ = new BehaviorSubject({}); /** * The state manager is used to modify the state of the saved search - this should never be @@ -132,6 +133,7 @@ export const initializeSearchEmbeddableApi = async ( totalHitCount: totalHitCount$, viewMode: savedSearchViewMode$, density: density$, + inspectorAdapters: inspectorAdapters$, }; /** The saved search should be the source of truth for all state */ diff --git a/src/plugins/discover/public/embeddable/types.ts b/src/plugins/discover/public/embeddable/types.ts index 64cf5a390da3a..94d0b10cc3f64 100644 --- a/src/plugins/discover/public/embeddable/types.ts +++ b/src/plugins/discover/public/embeddable/types.ts @@ -9,6 +9,7 @@ import { DataTableRecord } from '@kbn/discover-utils/types'; import type { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public'; +import { HasInspectorAdapters } from '@kbn/inspector-plugin/public'; import { EmbeddableApiContext, HasEditCapabilities, @@ -47,6 +48,7 @@ export type SearchEmbeddableState = Pick< rows: DataTableRecord[]; columnsMeta: DataTableColumnsMeta | undefined; totalHitCount: number | undefined; + inspectorAdapters: Record; }; export type SearchEmbeddableStateManager = { @@ -55,7 +57,7 @@ export type SearchEmbeddableStateManager = { export type SearchEmbeddableSerializedAttributes = Omit< SearchEmbeddableState, - 'rows' | 'columnsMeta' | 'totalHitCount' | 'searchSource' + 'rows' | 'columnsMeta' | 'totalHitCount' | 'searchSource' | 'inspectorAdapters' > & Pick; @@ -98,6 +100,7 @@ export type SearchEmbeddableApi = DefaultEmbeddableApi< PublishesWritableUnifiedSearch & HasInPlaceLibraryTransforms & HasTimeRange & + HasInspectorAdapters & Partial; export interface PublishesSavedSearch { diff --git a/tsconfig.base.json b/tsconfig.base.json index cd8e15a2d2d98..41b1118510288 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -980,6 +980,8 @@ "@kbn/ftr-screenshot-filename/*": ["packages/kbn-ftr-screenshot-filename/*"], "@kbn/functional-with-es-ssl-cases-test-plugin": ["x-pack/test/functional_with_es_ssl/plugins/cases"], "@kbn/functional-with-es-ssl-cases-test-plugin/*": ["x-pack/test/functional_with_es_ssl/plugins/cases/*"], + "@kbn/gen-ai-functional-testing": ["packages/kbn-gen-ai-functional-testing"], + "@kbn/gen-ai-functional-testing/*": ["packages/kbn-gen-ai-functional-testing/*"], "@kbn/gen-ai-streaming-response-example-plugin": ["x-pack/examples/gen_ai_streaming_response_example"], "@kbn/gen-ai-streaming-response-example-plugin/*": ["x-pack/examples/gen_ai_streaming_response_example/*"], "@kbn/generate": ["packages/kbn-generate"], diff --git a/typings/emotion.d.ts b/typings/emotion.d.ts new file mode 100644 index 0000000000000..cdcacdcc90f38 --- /dev/null +++ b/typings/emotion.d.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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import '@emotion/react'; +import type { UseEuiTheme } from '@elastic/eui'; + +declare module '@emotion/react' { + // eslint-disable-next-line @typescript-eslint/no-empty-interface + export interface Theme extends UseEuiTheme {} +} diff --git a/x-pack/packages/kbn-slo-schema/src/schema/indicators.ts b/x-pack/packages/kbn-slo-schema/src/schema/indicators.ts index 6c3149c3c35e1..f246d36382c8f 100644 --- a/x-pack/packages/kbn-slo-schema/src/schema/indicators.ts +++ b/x-pack/packages/kbn-slo-schema/src/schema/indicators.ts @@ -11,26 +11,31 @@ import { allOrAnyString } from './common'; const kqlQuerySchema = t.string; const filtersSchema = t.array( - t.type({ - meta: t.partial({ - alias: t.union([t.string, t.null]), - disabled: t.boolean, - negate: t.boolean, - // controlledBy is there to identify who owns the filter - controlledBy: t.string, - // allows grouping of filters - group: t.string, - // index and type are optional only because when you create a new filter, there are no defaults - index: t.string, - isMultiIndex: t.boolean, - type: t.string, - key: t.string, - field: t.string, - params: t.any, - value: t.string, + t.intersection([ + t.type({ + meta: t.partial({ + alias: t.union([t.string, t.null]), + disabled: t.boolean, + negate: t.boolean, + // controlledBy is there to identify who owns the filter + controlledBy: t.string, + // allows grouping of filters + group: t.string, + // index and type are optional only because when you create a new filter, there are no defaults + index: t.string, + isMultiIndex: t.boolean, + type: t.string, + key: t.string, + field: t.string, + params: t.any, + value: t.string, + }), + query: t.record(t.string, t.any), + }), + t.partial({ + $state: t.any, }), - query: t.record(t.string, t.any), - }) + ]) ); const kqlWithFiltersSchema = t.type({ diff --git a/x-pack/packages/ml/data_grid/tsconfig.json b/x-pack/packages/ml/data_grid/tsconfig.json index 590cb613b7bf0..16e44151edb55 100644 --- a/x-pack/packages/ml/data_grid/tsconfig.json +++ b/x-pack/packages/ml/data_grid/tsconfig.json @@ -14,7 +14,7 @@ "include": [ "**/*.ts", "**/*.tsx", - "./emotion.d.ts", // Emotion EUI theme typing + "../../../../typings/emotion.d.ts" ], "exclude": [ "target/**/*" diff --git a/x-pack/packages/security/ui_components/kibana.jsonc b/x-pack/packages/security/ui_components/kibana.jsonc index a73db6166798d..5290e9fe10ef9 100644 --- a/x-pack/packages/security/ui_components/kibana.jsonc +++ b/x-pack/packages/security/ui_components/kibana.jsonc @@ -1,5 +1,5 @@ { - "type": "shared-common", + "type": "shared-browser", "id": "@kbn/security-ui-components", "owner": "@elastic/kibana-security", "group": "platform", diff --git a/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table.test.tsx b/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table.test.tsx index 2ed172a49ad8b..6e55fa7ed7bbf 100644 --- a/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table.test.tsx +++ b/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table.test.tsx @@ -54,6 +54,7 @@ const setup = (config: TestConfig) => { kibanaPrivileges={kibanaPrivileges} onChange={onChange} onChangeAll={onChangeAll} + showAdditionalPermissionsMessage={true} canCustomizeSubFeaturePrivileges={config.canCustomizeSubFeaturePrivileges} privilegeIndex={config.privilegeIndex} allSpacesSelected={true} diff --git a/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table.tsx b/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table.tsx index 2f77b55ce5bac..9ee1ea3517dae 100644 --- a/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table.tsx +++ b/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table.tsx @@ -45,13 +45,10 @@ interface Props { privilegeIndex: number; onChange: (featureId: string, privileges: string[]) => void; onChangeAll: (privileges: string[]) => void; + showAdditionalPermissionsMessage: boolean; canCustomizeSubFeaturePrivileges: boolean; allSpacesSelected: boolean; disabled?: boolean; - /** - * default is true, to remain backwards compatible - */ - showTitle?: boolean; } interface State { @@ -62,7 +59,6 @@ export class FeatureTable extends Component { public static defaultProps = { privilegeIndex: -1, showLocks: true, - showTitle: true, }; private featureCategories: Map = new Map(); @@ -189,20 +185,7 @@ export class FeatureTable extends Component { return (
- - {this.props.showTitle && ( - - - {i18n.translate( - 'xpack.security.management.editRole.featureTable.featureVisibilityTitle', - { - defaultMessage: 'Customize feature privileges', - } - )} - - - )} - + {!this.props.disabled && ( { }; private getCategoryHelpText = (category: AppCategory) => { - if (category.id === 'management') { + if (category.id === 'management' && this.props.showAdditionalPermissionsMessage) { return i18n.translate( 'xpack.security.management.editRole.featureTable.managementCategoryHelpText', { diff --git a/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.tsx b/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.tsx index 48ef98c8dffa8..e644c9604d495 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.tsx +++ b/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.tsx @@ -15,7 +15,7 @@ import { coreMock } from '@kbn/core/public/mocks'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import type { ILicense } from '@kbn/licensing-plugin/public'; import type { StartServices } from '../../../types'; -import type { EuiTheme } from '@kbn/kibana-react-plugin/common'; +import type { UseEuiTheme } from '@elastic/eui'; import { securityMock } from '@kbn/security-plugin/public/mocks'; import { spacesPluginMock } from '@kbn/spaces-plugin/public/mocks'; import { triggersActionsUiMock } from '@kbn/triggers-actions-ui-plugin/public/mocks'; @@ -118,5 +118,5 @@ export const createKibanaContextProviderMock = () => { React.createElement(KibanaContextProvider, { services }, children); }; -export const getMockTheme = (partialTheme: RecursivePartial): EuiTheme => - partialTheme as EuiTheme; +export const getMockTheme = (partialTheme: RecursivePartial): UseEuiTheme => + partialTheme as UseEuiTheme; diff --git a/x-pack/plugins/cases/public/components/empty_value/empty_value.test.tsx b/x-pack/plugins/cases/public/components/empty_value/empty_value.test.tsx index d49dd2c17bfb5..049a8e0d56635 100644 --- a/x-pack/plugins/cases/public/components/empty_value/empty_value.test.tsx +++ b/x-pack/plugins/cases/public/components/empty_value/empty_value.test.tsx @@ -21,7 +21,7 @@ import { import { getMockTheme } from '../../common/lib/kibana/kibana_react.mock'; describe('EmptyValue', () => { - const mockTheme = getMockTheme({ eui: { euiColorMediumShade: '#ece' } }); + const mockTheme = getMockTheme({ euiTheme: { colors: { mediumShade: '#ece' } } }); test('it renders against snapshot', () => { const wrapper = shallow(

{getEmptyString()}

); diff --git a/x-pack/plugins/cases/public/components/empty_value/index.tsx b/x-pack/plugins/cases/public/components/empty_value/index.tsx index c89cd8fae91e6..8773174558de3 100644 --- a/x-pack/plugins/cases/public/components/empty_value/index.tsx +++ b/x-pack/plugins/cases/public/components/empty_value/index.tsx @@ -7,14 +7,11 @@ import { get, isString } from 'lodash/fp'; import React from 'react'; -import { css } from '@emotion/react'; -import { euiThemeVars } from '@kbn/ui-theme'; +import type { UseEuiTheme } from '@elastic/eui'; import * as i18n from './translations'; -const emptyWrapperCss = css` - color: ${euiThemeVars.euiColorMediumShade}; -`; +const emptyWrapperCss = ({ euiTheme }: UseEuiTheme) => ({ color: euiTheme.colors.mediumShade }); export const getEmptyValue = () => '—'; export const getEmptyString = () => `(${i18n.EMPTY_STRING})`; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/configuration_step.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/configuration_step.tsx index 9749c49ea4d68..f8bff23aa56a1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/configuration_step.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/configuration_step.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { useActions, useValues } from 'kea'; @@ -21,6 +21,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; import { ConnectorConfigurationComponent, ConnectorStatus } from '@kbn/search-connectors'; @@ -40,6 +41,8 @@ export const ConfigurationStep: React.FC = ({ title, set const { connector } = useValues(ConnectorViewLogic); const { updateConnectorConfiguration } = useActions(ConnectorViewLogic); const { setFormDirty } = useActions(NewConnectorLogic); + const { overlays } = useKibana().services; + const [isFormEditing, setIsFormEditing] = useState(false); const { status } = useValues(ConnectorConfigurationApiLogic); const isSyncing = false; @@ -77,6 +80,7 @@ export const ConfigurationStep: React.FC = ({ title, set connectorId: connector.id, }); }} + onEditStateChange={setIsFormEditing} /> {isSyncing && ( @@ -111,7 +115,38 @@ export const ConfigurationStep: React.FC = ({ title, set { + onClick={async () => { + if (isFormEditing) { + const confirmResponse = await overlays?.openConfirm( + i18n.translate('xpack.enterpriseSearch.configureConnector.unsavedPrompt.body', { + defaultMessage: + 'You are still editing connector configuration, are you sure you want to continue without saving? You can complete the setup later in the connector configuration page, but this guided flow offers more help.', + }), + { + title: i18n.translate( + 'xpack.enterpriseSearch.configureConnector.unsavedPrompt.title', + { + defaultMessage: 'Connector configuration is not saved', + } + ), + cancelButtonText: i18n.translate( + 'xpack.enterpriseSearch.configureConnector.unsavedPrompt.cancel', + { + defaultMessage: 'Continue setup', + } + ), + confirmButtonText: i18n.translate( + 'xpack.enterpriseSearch.configureConnector.unsavedPrompt.confirm', + { + defaultMessage: 'Leave the page', + } + ), + } + ); + if (!confirmResponse) { + return; + } + } setFormDirty(false); setCurrentStep('finish'); }} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/group_assignment_selector.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/group_assignment_selector.tsx index 5d8ef7ababc8c..f6c88cb7ae6ed 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/group_assignment_selector.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/group_assignment_selector.tsx @@ -9,7 +9,13 @@ import React from 'react'; import { useActions, useValues } from 'kea'; -import { EuiComboBox, EuiFormRow, EuiHorizontalRule, EuiRadioGroup } from '@elastic/eui'; +import { + EuiComboBox, + EuiFormRow, + EuiHorizontalRule, + EuiRadioGroup, + useGeneratedHtmlId, +} from '@elastic/eui'; import { RoleOptionLabel } from '../../../shared/role_mapping'; @@ -31,6 +37,8 @@ export const GroupAssignmentSelector: React.FC = () => { const { includeInAllGroups, availableGroups, selectedGroups, selectedOptions } = useValues(RoleMappingsLogic); + const groupAssigmentLabelId = useGeneratedHtmlId(); + const hasGroupAssignment = selectedGroups.size > 0 || includeInAllGroups; const groupOptions = [ @@ -51,11 +59,12 @@ export const GroupAssignmentSelector: React.FC = () => { handleAllGroupsSelectionChange(id === 'all')} legend={{ - children: {GROUP_ASSIGNMENT_LABEL}, + children: {GROUP_ASSIGNMENT_LABEL}, }} /> @@ -69,6 +78,7 @@ export const GroupAssignmentSelector: React.FC = () => { }} fullWidth isDisabled={includeInAllGroups} + aria-labelledby={groupAssigmentLabelId} /> diff --git a/x-pack/plugins/entity_manager/kibana.jsonc b/x-pack/plugins/entity_manager/kibana.jsonc index c18822d48ac0a..02919893a8757 100644 --- a/x-pack/plugins/entity_manager/kibana.jsonc +++ b/x-pack/plugins/entity_manager/kibana.jsonc @@ -13,7 +13,8 @@ "requiredPlugins": [ "security", "encryptedSavedObjects", - "licensing" + "licensing", + "features" ], "requiredBundles": [] } diff --git a/x-pack/plugins/entity_manager/server/index.ts b/x-pack/plugins/entity_manager/server/index.ts index ebb826a500a5a..7327233c1fad2 100644 --- a/x-pack/plugins/entity_manager/server/index.ts +++ b/x-pack/plugins/entity_manager/server/index.ts @@ -18,6 +18,14 @@ export type { }; export { config }; +export { + CREATE_ENTITY_TYPE_DEFINITION_PRIVILEGE, + CREATE_ENTITY_SOURCE_DEFINITION_PRIVILEGE, + READ_ENTITY_TYPE_DEFINITION_PRIVILEGE, + READ_ENTITY_SOURCE_DEFINITION_PRIVILEGE, + READ_ENTITIES_PRIVILEGE, +} from './lib/v2/constants'; + export const plugin = async (context: PluginInitializerContext) => { const { EntityManagerServerPlugin } = await import('./plugin'); return new EntityManagerServerPlugin(context); diff --git a/x-pack/plugins/entity_manager/server/lib/client/index.ts b/x-pack/plugins/entity_manager/server/lib/client/index.ts deleted file mode 100644 index 90562264851ce..0000000000000 --- a/x-pack/plugins/entity_manager/server/lib/client/index.ts +++ /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 type { IScopedClusterClient, SavedObjectsClientContract } from '@kbn/core/server'; -import { EntityDefinition } from '@kbn/entities-schema'; -import { findEntityDefinitions } from '../entities/find_entity_definition'; -import type { EntityDefinitionWithState } from '../entities/types'; - -export class EntityManagerClient { - constructor( - private readonly esClient: IScopedClusterClient, - private readonly soClient: SavedObjectsClientContract - ) {} - - findEntityDefinitions({ page, perPage }: { page?: number; perPage?: number } = {}): Promise< - EntityDefinition[] | EntityDefinitionWithState[] - > { - return findEntityDefinitions({ - esClient: this.esClient.asCurrentUser, - soClient: this.soClient, - page, - perPage, - }); - } -} diff --git a/x-pack/plugins/entity_manager/server/lib/entity_client.ts b/x-pack/plugins/entity_manager/server/lib/entity_client.ts index 35abe63c5fd23..dd4eeedbc4fc2 100644 --- a/x-pack/plugins/entity_manager/server/lib/entity_client.ts +++ b/x-pack/plugins/entity_manager/server/lib/entity_client.ts @@ -5,10 +5,9 @@ * 2.0. */ -import { without } from 'lodash'; -import { EntityV2, EntityDefinition, EntityDefinitionUpdate } from '@kbn/entities-schema'; +import { EntityDefinition, EntityDefinitionUpdate } from '@kbn/entities-schema'; import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; -import { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import { IScopedClusterClient } from '@kbn/core-elasticsearch-server'; import { Logger } from '@kbn/logging'; import { installEntityDefinition, @@ -19,40 +18,24 @@ import { startTransforms } from './entities/start_transforms'; import { findEntityDefinitionById, findEntityDefinitions } from './entities/find_entity_definition'; import { uninstallEntityDefinition } from './entities/uninstall_entity_definition'; import { EntityDefinitionNotFound } from './entities/errors/entity_not_found'; - import { stopTransforms } from './entities/stop_transforms'; import { deleteIndices } from './entities/delete_index'; import { EntityDefinitionWithState } from './entities/types'; import { EntityDefinitionUpdateConflict } from './entities/errors/entity_definition_update_conflict'; -import { EntitySource, SortBy, getEntityInstancesQuery } from './queries'; -import { mergeEntitiesList, runESQLQuery } from './queries/utils'; -import { UnknownEntityType } from './entities/errors/unknown_entity_type'; - -interface SearchCommon { - start: string; - end: string; - sort?: SortBy; - metadataFields?: string[]; - filters?: string[]; - limit?: number; -} - -export type SearchByType = SearchCommon & { - type: string; -}; - -export type SearchBySources = SearchCommon & { - sources: EntitySource[]; -}; +import { EntityClient as EntityClient_v2 } from './v2/entity_client'; export class EntityClient { + public v2: EntityClient_v2; + constructor( private options: { - esClient: ElasticsearchClient; + clusterClient: IScopedClusterClient; soClient: SavedObjectsClientContract; logger: Logger; } - ) {} + ) { + this.v2 = new EntityClient_v2(options); + } async createEntityDefinition({ definition, @@ -66,13 +49,17 @@ export class EntityClient { ); const installedDefinition = await installEntityDefinition({ definition, - esClient: this.options.esClient, + esClient: this.options.clusterClient.asCurrentUser, soClient: this.options.soClient, logger: this.options.logger, }); if (!installOnly) { - await startTransforms(this.options.esClient, installedDefinition, this.options.logger); + await startTransforms( + this.options.clusterClient.asCurrentUser, + installedDefinition, + this.options.logger + ); } return installedDefinition; @@ -88,7 +75,7 @@ export class EntityClient { const definition = await findEntityDefinitionById({ id, soClient: this.options.soClient, - esClient: this.options.esClient, + esClient: this.options.clusterClient.asCurrentUser, includeState: true, }); @@ -115,12 +102,16 @@ export class EntityClient { definition, definitionUpdate, soClient: this.options.soClient, - esClient: this.options.esClient, + esClient: this.options.clusterClient.asCurrentUser, logger: this.options.logger, }); if (shouldRestartTransforms) { - await startTransforms(this.options.esClient, updatedDefinition, this.options.logger); + await startTransforms( + this.options.clusterClient.asCurrentUser, + updatedDefinition, + this.options.logger + ); } return updatedDefinition; } @@ -128,7 +119,7 @@ export class EntityClient { async deleteEntityDefinition({ id, deleteData = false }: { id: string; deleteData?: boolean }) { const definition = await findEntityDefinitionById({ id, - esClient: this.options.esClient, + esClient: this.options.clusterClient.asCurrentUser, soClient: this.options.soClient, }); @@ -141,13 +132,17 @@ export class EntityClient { ); await uninstallEntityDefinition({ definition, - esClient: this.options.esClient, + esClient: this.options.clusterClient.asCurrentUser, soClient: this.options.soClient, logger: this.options.logger, }); if (deleteData) { - await deleteIndices(this.options.esClient, definition, this.options.logger); + await deleteIndices( + this.options.clusterClient.asCurrentUser, + definition, + this.options.logger + ); } } @@ -167,7 +162,7 @@ export class EntityClient { builtIn?: boolean; }) { const definitions = await findEntityDefinitions({ - esClient: this.options.esClient, + esClient: this.options.clusterClient.asCurrentUser, soClient: this.options.soClient, page, perPage, @@ -182,124 +177,19 @@ export class EntityClient { async startEntityDefinition(definition: EntityDefinition) { this.options.logger.info(`Starting transforms for definition [${definition.id}]`); - return startTransforms(this.options.esClient, definition, this.options.logger); + return startTransforms( + this.options.clusterClient.asCurrentUser, + definition, + this.options.logger + ); } async stopEntityDefinition(definition: EntityDefinition) { this.options.logger.info(`Stopping transforms for definition [${definition.id}]`); - return stopTransforms(this.options.esClient, definition, this.options.logger); - } - - async getEntitySources({ type }: { type: string }) { - const result = await this.options.esClient.search({ - index: 'kibana_entity_definitions', - query: { - bool: { - must: { - term: { entity_type: type }, - }, - }, - }, - }); - - return result.hits.hits.map((hit) => hit._source) as EntitySource[]; - } - - async searchEntities({ - type, - start, - end, - sort, - metadataFields = [], - filters = [], - limit = 10, - }: SearchByType) { - const sources = await this.getEntitySources({ type }); - if (sources.length === 0) { - throw new UnknownEntityType(`No sources found for entity type [${type}]`); - } - - return this.searchEntitiesBySources({ - sources, - start, - end, - metadataFields, - filters, - sort, - limit, - }); - } - - async searchEntitiesBySources({ - sources, - start, - end, - sort, - metadataFields = [], - filters = [], - limit = 10, - }: SearchBySources) { - const entities = await Promise.all( - sources.map(async (source) => { - const mandatoryFields = [ - ...source.identity_fields, - ...(source.timestamp_field ? [source.timestamp_field] : []), - ...(source.display_name ? [source.display_name] : []), - ]; - const metaFields = [...metadataFields, ...source.metadata_fields]; - - // operations on an unmapped field result in a failing query so we verify - // field capabilities beforehand - const { fields } = await this.options.esClient.fieldCaps({ - index: source.index_patterns, - fields: [...mandatoryFields, ...metaFields], - }); - - const sourceHasMandatoryFields = mandatoryFields.every((field) => !!fields[field]); - if (!sourceHasMandatoryFields) { - // we can't build entities without id fields so we ignore the source. - // TODO filters should likely behave similarly. we should also throw - const missingFields = mandatoryFields.filter((field) => !fields[field]); - this.options.logger.info( - `Ignoring source for type [${source.type}] with index_patterns [${ - source.index_patterns - }] because some mandatory fields [${missingFields.join(', ')}] are not mapped` - ); - return []; - } - - // but metadata field not being available is fine - const availableMetadataFields = metaFields.filter((field) => fields[field]); - if (availableMetadataFields.length < metaFields.length) { - this.options.logger.info( - `Ignoring unmapped fields [${without(metaFields, ...availableMetadataFields).join( - ', ' - )}]` - ); - } - - const query = getEntityInstancesQuery({ - source: { - ...source, - metadata_fields: availableMetadataFields, - filters: [...source.filters, ...filters], - }, - start, - end, - sort, - limit, - }); - this.options.logger.debug(`Entity query: ${query}`); - - const rawEntities = await runESQLQuery({ - query, - esClient: this.options.esClient, - }); - - return rawEntities; - }) - ).then((results) => results.flat()); - - return mergeEntitiesList(sources, entities).slice(0, limit); + return stopTransforms( + this.options.clusterClient.asCurrentUser, + definition, + this.options.logger + ); } } diff --git a/x-pack/plugins/entity_manager/server/lib/v2/constants.ts b/x-pack/plugins/entity_manager/server/lib/v2/constants.ts new file mode 100644 index 0000000000000..71d9369318225 --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/v2/constants.ts @@ -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. + */ + +// Definitions index + +export const DEFINITIONS_ALIAS = '.kibana-entities-definitions'; +export const TEMPLATE_VERSION = 1; + +// Privileges + +export const CREATE_ENTITY_TYPE_DEFINITION_PRIVILEGE = 'create_entity_type_definition'; +export const CREATE_ENTITY_SOURCE_DEFINITION_PRIVILEGE = 'create_entity_source_definition'; +export const READ_ENTITY_TYPE_DEFINITION_PRIVILEGE = 'read_entity_type_definition'; +export const READ_ENTITY_SOURCE_DEFINITION_PRIVILEGE = 'read_entity_source_definition'; +export const READ_ENTITIES_PRIVILEGE = 'read_entities'; diff --git a/x-pack/plugins/entity_manager/server/lib/v2/definitions/setup_entity_definitions_index.ts b/x-pack/plugins/entity_manager/server/lib/v2/definitions/setup_entity_definitions_index.ts new file mode 100644 index 0000000000000..b9e3f39a3dd62 --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/v2/definitions/setup_entity_definitions_index.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { errors } from '@elastic/elasticsearch'; +import { IClusterClient, Logger } from '@kbn/core/server'; +import { DEFINITIONS_ALIAS, TEMPLATE_VERSION } from '../constants'; + +const definitionsIndexTemplate = { + name: `${DEFINITIONS_ALIAS}-template`, + index_patterns: [`${DEFINITIONS_ALIAS}-*`], + _meta: { + description: "Index template for the Elastic Entity Model's entity definitions index.", + managed: true, + managed_by: 'elastic_entity_model', + }, + version: TEMPLATE_VERSION, + template: { + settings: { + hidden: true, + }, + aliases: { + [DEFINITIONS_ALIAS]: { + is_hidden: true, + }, + }, + mappings: { + dynamic: false, + properties: { + template_version: { + type: 'short', + }, + definition_type: { + type: 'keyword', + }, + source: { + type: 'object', + properties: { + type_id: { + type: 'keyword', + }, + }, + }, + }, + }, + }, +}; + +const CURRENT_INDEX = `${DEFINITIONS_ALIAS}-${TEMPLATE_VERSION}` as const; + +export async function setupEntityDefinitionsIndex(clusterClient: IClusterClient, logger: Logger) { + const esClient = clusterClient.asInternalUser; + try { + logger.debug(`Installing entity definitions index template for version ${TEMPLATE_VERSION}`); + await esClient.indices.putIndexTemplate(definitionsIndexTemplate); + + await esClient.indices.get({ + index: CURRENT_INDEX, + }); + + logger.debug(`Entity definitions index already exists (${CURRENT_INDEX})`); + } catch (error) { + if ( + error instanceof errors.ResponseError && + error.message.includes('index_not_found_exception') + ) { + logger.debug(`Creating entity definitions index (${CURRENT_INDEX})`); + + await esClient.indices.create({ + index: CURRENT_INDEX, + }); + + return; + } + + throw error; + } +} diff --git a/x-pack/plugins/entity_manager/server/lib/v2/definitions/source_definition.ts b/x-pack/plugins/entity_manager/server/lib/v2/definitions/source_definition.ts new file mode 100644 index 0000000000000..7ed1779708e54 --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/v2/definitions/source_definition.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IScopedClusterClient, Logger } from '@kbn/core/server'; +import { DEFINITIONS_ALIAS, TEMPLATE_VERSION } from '../constants'; +import { EntitySourceDefinition, StoredEntitySourceDefinition } from '../types'; +import { SourceAs, runESQLQuery } from '../run_esql_query'; +import { EntityDefinitionConflict } from '../errors/entity_definition_conflict'; + +export async function storeSourceDefinition( + source: EntitySourceDefinition, + clusterClient: IScopedClusterClient, + logger: Logger +): Promise { + const esClient = clusterClient.asInternalUser; + + const sources = await runESQLQuery('fetch source definition for conflict check', { + esClient, + query: `FROM ${DEFINITIONS_ALIAS} METADATA _id | WHERE definition_type == "source" AND _id == "source:${source.id}" | KEEP _id`, + logger, + }); + + if (sources.length !== 0) { + throw new EntityDefinitionConflict('source', source.id); + } + + const definition: StoredEntitySourceDefinition = { + template_version: TEMPLATE_VERSION, + definition_type: 'source', + source, + }; + + await esClient.index({ + index: DEFINITIONS_ALIAS, + id: `source:${definition.source.id}`, + document: definition, + }); + + return definition.source; +} + +export interface ReadSourceDefinitionOptions { + type?: string; +} + +export async function readSourceDefinitions( + clusterClient: IScopedClusterClient, + logger: Logger, + options?: ReadSourceDefinitionOptions +): Promise { + const esClient = clusterClient.asInternalUser; + + const typeFilter = options?.type ? `AND source.type_id == "${options.type}"` : ''; + const sources = await runESQLQuery>( + 'fetch all source definitions', + { + esClient, + query: `FROM ${DEFINITIONS_ALIAS} METADATA _source | WHERE definition_type == "source" ${typeFilter} | KEEP _source`, + logger, + } + ); + + return sources.map((storedTypeDefinition) => storedTypeDefinition._source.source); +} diff --git a/x-pack/plugins/entity_manager/server/lib/v2/definitions/type_definition.ts b/x-pack/plugins/entity_manager/server/lib/v2/definitions/type_definition.ts new file mode 100644 index 0000000000000..710e176a4a128 --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/v2/definitions/type_definition.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IScopedClusterClient, Logger } from '@kbn/core/server'; +import { DEFINITIONS_ALIAS, TEMPLATE_VERSION } from '../constants'; +import { EntityTypeDefinition, StoredEntityTypeDefinition } from '../types'; +import { SourceAs, runESQLQuery } from '../run_esql_query'; +import { EntityDefinitionConflict } from '../errors/entity_definition_conflict'; + +export async function storeTypeDefinition( + type: EntityTypeDefinition, + clusterClient: IScopedClusterClient, + logger: Logger +): Promise { + const esClient = clusterClient.asInternalUser; + + const types = await runESQLQuery('fetch type definition for conflict check', { + esClient, + query: `FROM ${DEFINITIONS_ALIAS} METADATA _id | WHERE definition_type == "type" AND _id == "type:${type.id}" | KEEP _id`, + logger, + }); + + if (types.length !== 0) { + throw new EntityDefinitionConflict('type', type.id); + } + + const definition: StoredEntityTypeDefinition = { + template_version: TEMPLATE_VERSION, + definition_type: 'type', + type, + }; + + await esClient.index({ + index: DEFINITIONS_ALIAS, + id: `type:${definition.type.id}`, + document: definition, + }); + + return definition.type; +} + +export async function readTypeDefinitions( + clusterClient: IScopedClusterClient, + logger: Logger +): Promise { + const esClient = clusterClient.asInternalUser; + + const types = await runESQLQuery>( + 'fetch all type definitions', + { + esClient, + query: `FROM ${DEFINITIONS_ALIAS} METADATA _source | WHERE definition_type == "type" | KEEP _source`, + logger, + } + ); + + return types.map((storedTypeDefinition) => storedTypeDefinition._source.type); +} diff --git a/x-pack/plugins/entity_manager/server/lib/v2/entity_client.ts b/x-pack/plugins/entity_manager/server/lib/v2/entity_client.ts new file mode 100644 index 0000000000000..9eb2127ddc818 --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/v2/entity_client.ts @@ -0,0 +1,142 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; +import { IScopedClusterClient, Logger } from '@kbn/core/server'; +import { EntityV2 } from '@kbn/entities-schema'; +import { without } from 'lodash'; +import { + ReadSourceDefinitionOptions, + readSourceDefinitions, + storeSourceDefinition, +} from './definitions/source_definition'; +import { readTypeDefinitions, storeTypeDefinition } from './definitions/type_definition'; +import { getEntityInstancesQuery } from './queries'; +import { mergeEntitiesList } from './queries/utils'; +import { + EntitySourceDefinition, + EntityTypeDefinition, + SearchByType, + SearchBySources, +} from './types'; +import { UnknownEntityType } from './errors/unknown_entity_type'; +import { runESQLQuery } from './run_esql_query'; + +export class EntityClient { + constructor( + private options: { + clusterClient: IScopedClusterClient; + soClient: SavedObjectsClientContract; + logger: Logger; + } + ) {} + + async searchEntities({ type, ...options }: SearchByType) { + const sources = await this.readSourceDefinitions({ + type, + }); + + if (sources.length === 0) { + throw new UnknownEntityType(`No sources found for entity type [${type}]`); + } + + return this.searchEntitiesBySources({ + sources, + ...options, + }); + } + + async searchEntitiesBySources({ + sources, + metadata_fields: metadataFields, + filters, + start, + end, + sort, + limit, + }: SearchBySources) { + const entities = await Promise.all( + sources.map(async (source) => { + const mandatoryFields = [ + ...source.identity_fields, + ...(source.timestamp_field ? [source.timestamp_field] : []), + ...(source.display_name ? [source.display_name] : []), + ]; + const metaFields = [...metadataFields, ...source.metadata_fields]; + + // operations on an unmapped field result in a failing query so we verify + // field capabilities beforehand + const { fields } = await this.options.clusterClient.asCurrentUser.fieldCaps({ + index: source.index_patterns, + fields: [...mandatoryFields, ...metaFields], + }); + + const sourceHasMandatoryFields = mandatoryFields.every((field) => !!fields[field]); + if (!sourceHasMandatoryFields) { + // we can't build entities without id fields so we ignore the source. + // TODO filters should likely behave similarly. we should also throw + const missingFields = mandatoryFields.filter((field) => !fields[field]); + this.options.logger.info( + `Ignoring source for type [${source.type_id}] with index_patterns [${ + source.index_patterns + }] because some mandatory fields [${missingFields.join(', ')}] are not mapped` + ); + return []; + } + + // but metadata field not being available is fine + const availableMetadataFields = metaFields.filter((field) => fields[field]); + if (availableMetadataFields.length < metaFields.length) { + this.options.logger.info( + `Ignoring unmapped fields [${without(metaFields, ...availableMetadataFields).join( + ', ' + )}]` + ); + } + + const query = getEntityInstancesQuery({ + source: { + ...source, + metadata_fields: availableMetadataFields, + filters: [...source.filters, ...filters], + }, + start, + end, + sort, + limit, + }); + this.options.logger.debug(`Entity query: ${query}`); + + const rawEntities = await runESQLQuery('resolve entities', { + query, + esClient: this.options.clusterClient.asCurrentUser, + logger: this.options.logger, + }); + + return rawEntities; + }) + ).then((results) => results.flat()); + + return mergeEntitiesList(sources, entities).slice(0, limit); + } + + async storeTypeDefinition(type: EntityTypeDefinition) { + return storeTypeDefinition(type, this.options.clusterClient, this.options.logger); + } + + async readTypeDefinitions() { + return readTypeDefinitions(this.options.clusterClient, this.options.logger); + } + + async storeSourceDefinition(source: EntitySourceDefinition) { + return storeSourceDefinition(source, this.options.clusterClient, this.options.logger); + } + + async readSourceDefinitions(options?: ReadSourceDefinitionOptions) { + return readSourceDefinitions(this.options.clusterClient, this.options.logger, options); + } +} diff --git a/x-pack/plugins/entity_manager/server/lib/v2/errors/entity_definition_conflict.ts b/x-pack/plugins/entity_manager/server/lib/v2/errors/entity_definition_conflict.ts new file mode 100644 index 0000000000000..9000685713e36 --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/v2/errors/entity_definition_conflict.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 { DefinitionType } from '../types'; + +export class EntityDefinitionConflict extends Error { + constructor(definitionType: DefinitionType, id: string) { + super(`An entity ${definitionType} definition with the ID "${id}" already exists.`); + this.name = 'EntityDefinitionConflict'; + } +} diff --git a/x-pack/plugins/entity_manager/server/lib/entities/errors/unknown_entity_type.ts b/x-pack/plugins/entity_manager/server/lib/v2/errors/unknown_entity_type.ts similarity index 100% rename from x-pack/plugins/entity_manager/server/lib/entities/errors/unknown_entity_type.ts rename to x-pack/plugins/entity_manager/server/lib/v2/errors/unknown_entity_type.ts diff --git a/x-pack/plugins/entity_manager/server/lib/queries/index.test.ts b/x-pack/plugins/entity_manager/server/lib/v2/queries/index.test.ts similarity index 96% rename from x-pack/plugins/entity_manager/server/lib/queries/index.test.ts rename to x-pack/plugins/entity_manager/server/lib/v2/queries/index.test.ts index d8b3c9347cdd1..7485b19a2c7c0 100644 --- a/x-pack/plugins/entity_manager/server/lib/queries/index.test.ts +++ b/x-pack/plugins/entity_manager/server/lib/v2/queries/index.test.ts @@ -12,7 +12,8 @@ describe('getEntityInstancesQuery', () => { it('generates a valid esql query', () => { const query = getEntityInstancesQuery({ source: { - type: 'service', + id: 'service_source', + type_id: 'service', index_patterns: ['logs-*', 'metrics-*'], identity_fields: ['service.name'], metadata_fields: ['host.name'], diff --git a/x-pack/plugins/entity_manager/server/lib/queries/index.ts b/x-pack/plugins/entity_manager/server/lib/v2/queries/index.ts similarity index 75% rename from x-pack/plugins/entity_manager/server/lib/queries/index.ts rename to x-pack/plugins/entity_manager/server/lib/v2/queries/index.ts index 83c25d756c170..7926b67849f5d 100644 --- a/x-pack/plugins/entity_manager/server/lib/queries/index.ts +++ b/x-pack/plugins/entity_manager/server/lib/v2/queries/index.ts @@ -5,26 +5,9 @@ * 2.0. */ -import { z } from '@kbn/zod'; - -export const entitySourceSchema = z.object({ - type: z.string(), - timestamp_field: z.optional(z.string()), - index_patterns: z.array(z.string()), - identity_fields: z.array(z.string()), - metadata_fields: z.array(z.string()), - filters: z.array(z.string()), - display_name: z.optional(z.string()), -}); - -export interface SortBy { - field: string; - direction: 'ASC' | 'DESC'; -} - -export type EntitySource = z.infer; +import { EntitySourceDefinition, SortBy } from '../types'; -const sourceCommand = ({ source }: { source: EntitySource }) => { +const sourceCommand = ({ source }: { source: EntitySourceDefinition }) => { let query = `FROM ${source.index_patterns.join(', ')}`; const esMetadataFields = source.metadata_fields.filter((field) => @@ -42,7 +25,7 @@ const whereCommand = ({ start, end, }: { - source: EntitySource; + source: EntitySourceDefinition; start: string; end: string; }) => { @@ -60,7 +43,7 @@ const whereCommand = ({ return filters.map((filter) => `WHERE ${filter}`).join(' | '); }; -const statsCommand = ({ source }: { source: EntitySource }) => { +const statsCommand = ({ source }: { source: EntitySourceDefinition }) => { const aggs = source.metadata_fields .filter((field) => !source.identity_fields.some((idField) => idField === field)) .map((field) => `${field} = VALUES(${field})`); @@ -78,7 +61,7 @@ const statsCommand = ({ source }: { source: EntitySource }) => { return `STATS ${aggs.join(', ')} BY ${source.identity_fields.join(', ')}`; }; -const evalCommand = ({ source }: { source: EntitySource }) => { +const evalCommand = ({ source }: { source: EntitySourceDefinition }) => { const id = source.identity_fields.length === 1 ? source.identity_fields[0] @@ -89,13 +72,13 @@ const evalCommand = ({ source }: { source: EntitySource }) => { : 'entity.id'; return `EVAL ${[ - `entity.type = "${source.type}"`, + `entity.type = "${source.type_id}"`, `entity.id = ${id}`, `entity.display_name = ${displayName}`, ].join(', ')}`; }; -const sortCommand = ({ source, sort }: { source: EntitySource; sort?: SortBy }) => { +const sortCommand = ({ source, sort }: { source: EntitySourceDefinition; sort?: SortBy }) => { if (sort) { return `SORT ${sort.field} ${sort.direction}`; } @@ -114,7 +97,7 @@ export function getEntityInstancesQuery({ end, sort, }: { - source: EntitySource; + source: EntitySourceDefinition; limit: number; start: string; end: string; diff --git a/x-pack/plugins/entity_manager/server/lib/queries/utils.test.ts b/x-pack/plugins/entity_manager/server/lib/v2/queries/utils.test.ts similarity index 95% rename from x-pack/plugins/entity_manager/server/lib/queries/utils.test.ts rename to x-pack/plugins/entity_manager/server/lib/v2/queries/utils.test.ts index df5c8a2a4a826..295ab7796585c 100644 --- a/x-pack/plugins/entity_manager/server/lib/queries/utils.test.ts +++ b/x-pack/plugins/entity_manager/server/lib/v2/queries/utils.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { EntitySource } from '.'; +import { EntitySourceDefinition } from '../types'; import { mergeEntitiesList } from './utils'; describe('mergeEntitiesList', () => { @@ -68,7 +68,7 @@ describe('mergeEntitiesList', () => { { metadata_fields: ['host.name', 'agent.name', 'service.environment', 'only_in_record_2'], }, - ] as EntitySource[], + ] as EntitySourceDefinition[], entities ); expect(mergedEntities.length).toEqual(1); @@ -124,7 +124,7 @@ describe('mergeEntitiesList', () => { { metadata_fields: ['host.name'], }, - ] as EntitySource[], + ] as EntitySourceDefinition[], entities ); expect(mergedEntities.length).toEqual(1); @@ -154,7 +154,10 @@ describe('mergeEntitiesList', () => { ]; const mergedEntities = mergeEntitiesList( - [{ metadata_fields: ['host.name'] }, { metadata_fields: ['host.name'] }] as EntitySource[], + [ + { metadata_fields: ['host.name'] }, + { metadata_fields: ['host.name'] }, + ] as EntitySourceDefinition[], entities ); expect(mergedEntities.length).toEqual(1); @@ -199,7 +202,7 @@ describe('mergeEntitiesList', () => { { metadata_fields: ['host.name'], }, - ] as EntitySource[], + ] as EntitySourceDefinition[], entities ); expect(mergedEntities.length).toEqual(1); diff --git a/x-pack/plugins/entity_manager/server/lib/queries/utils.ts b/x-pack/plugins/entity_manager/server/lib/v2/queries/utils.ts similarity index 63% rename from x-pack/plugins/entity_manager/server/lib/queries/utils.ts rename to x-pack/plugins/entity_manager/server/lib/v2/queries/utils.ts index a18f6fb837140..1d20d3caea0dc 100644 --- a/x-pack/plugins/entity_manager/server/lib/queries/utils.ts +++ b/x-pack/plugins/entity_manager/server/lib/v2/queries/utils.ts @@ -5,11 +5,9 @@ * 2.0. */ -import { compact, uniq } from 'lodash'; -import { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import { EntityV2 } from '@kbn/entities-schema'; -import { ESQLSearchResponse } from '@kbn/es-types'; -import { EntitySource } from '.'; +import { compact, uniq } from 'lodash'; +import { EntitySourceDefinition } from '../types'; function getLatestDate(date1?: string, date2?: string) { if (!date1 && !date2) return; @@ -45,7 +43,10 @@ function mergeEntities(metadataFields: string[], entity1: EntityV2, entity2: Ent return merged; } -export function mergeEntitiesList(sources: EntitySource[], entities: EntityV2[]): EntityV2[] { +export function mergeEntitiesList( + sources: EntitySourceDefinition[], + entities: EntityV2[] +): EntityV2[] { const metadataFields = uniq( sources.flatMap((source) => compact([source.timestamp_field, ...source.metadata_fields])) ); @@ -64,39 +65,3 @@ export function mergeEntitiesList(sources: EntitySource[], entities: EntityV2[]) return Object.values(instances); } - -export async function runESQLQuery({ - esClient, - query, -}: { - esClient: ElasticsearchClient; - query: string; -}): Promise { - const esqlResponse = (await esClient.esql.query( - { - query, - format: 'json', - }, - { querystring: { drop_null_columns: true } } - )) as unknown as ESQLSearchResponse; - - const documents = esqlResponse.values.map((row) => - row.reduce>((acc, value, index) => { - const column = esqlResponse.columns[index]; - - if (!column) { - return acc; - } - - // Removes the type suffix from the column name - const name = column.name.replace(/\.(text|keyword)$/, ''); - if (!acc[name]) { - acc[name] = value; - } - - return acc; - }, {}) - ) as T[]; - - return documents; -} diff --git a/x-pack/plugins/entity_manager/server/lib/v2/run_esql_query.ts b/x-pack/plugins/entity_manager/server/lib/v2/run_esql_query.ts new file mode 100644 index 0000000000000..eda36a007ffe6 --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/v2/run_esql_query.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 { withSpan } from '@kbn/apm-utils'; +import { ElasticsearchClient, Logger } from '@kbn/core/server'; +import { ESQLColumn, ESQLRow, ESQLSearchResponse } from '@kbn/es-types'; + +export interface SourceAs { + _source: T; +} + +export async function runESQLQuery( + operationName: string, + { + esClient, + logger, + query, + }: { + esClient: ElasticsearchClient; + logger: Logger; + query: string; + } +): Promise { + logger.trace(() => `Request (${operationName}):\n${query}`); + return withSpan( + { name: operationName, labels: { plugin: '@kbn/entityManager-plugin' } }, + async () => + esClient.esql.query( + { + query, + format: 'json', + }, + { querystring: { drop_null_columns: true } } + ) + ) + .then((response) => { + logger.trace(() => `Response (${operationName}):\n${JSON.stringify(response, null, 2)}`); + + const esqlResponse = response as unknown as ESQLSearchResponse; + + const documents = esqlResponse.values.map((row) => + rowToObject(row, esqlResponse.columns) + ) as T[]; + + return documents; + }) + .catch((error) => { + logger.trace(() => `Error (${operationName}):\n${error.message}`); + throw error; + }); +} + +function rowToObject(row: ESQLRow, columns: ESQLColumn[]) { + return row.reduce>((object, value, index) => { + const column = columns[index]; + + if (!column) { + return object; + } + + // Removes the type suffix from the column name + const name = column.name.replace(/\.(text|keyword)$/, ''); + if (!object[name]) { + object[name] = value; + } + + return object; + }, {}); +} diff --git a/x-pack/plugins/entity_manager/server/lib/v2/types.ts b/x-pack/plugins/entity_manager/server/lib/v2/types.ts new file mode 100644 index 0000000000000..e9815942592de --- /dev/null +++ b/x-pack/plugins/entity_manager/server/lib/v2/types.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from '@kbn/zod'; +import moment from 'moment'; + +// Definitions + +export const entityTypeDefinitionRt = z.object({ + id: z.string(), +}); + +export type EntityTypeDefinition = z.TypeOf; + +export const entitySourceDefinitionRt = z.object({ + id: z.string(), + type_id: z.string(), + index_patterns: z.array(z.string()), + identity_fields: z.array(z.string()), + metadata_fields: z.array(z.string()), + filters: z.array(z.string()), + timestamp_field: z.optional(z.string()), + display_name: z.optional(z.string()), +}); + +export type EntitySourceDefinition = z.TypeOf; + +// Stored definitions + +export type DefinitionType = 'type' | 'source'; + +export interface BaseEntityDefinition { + definition_type: DefinitionType; + template_version: number; +} + +export interface StoredEntityTypeDefinition extends BaseEntityDefinition { + type: EntityTypeDefinition; +} + +export interface StoredEntitySourceDefinition extends BaseEntityDefinition { + source: EntitySourceDefinition; +} + +// API parameters + +const sortByRt = z.object({ + field: z.string(), + direction: z.enum(['ASC', 'DESC']), +}); + +export type SortBy = z.TypeOf; + +const searchCommonRt = z.object({ + start: z + .optional(z.string()) + .default(() => moment().subtract(5, 'minutes').toISOString()) + .refine((val) => moment(val).isValid(), { + message: '[start] should be a date in ISO format', + }), + end: z + .optional(z.string()) + .default(() => moment().toISOString()) + .refine((val) => moment(val).isValid(), { + message: '[end] should be a date in ISO format', + }), + sort: z.optional(sortByRt), + limit: z.optional(z.number()).default(10), + metadata_fields: z.optional(z.array(z.string())).default([]), + filters: z.optional(z.array(z.string())).default([]), +}); + +export const searchByTypeRt = z.intersection( + searchCommonRt, + z.object({ + type: z.string(), + }) +); + +export type SearchByType = z.output; + +export const searchBySourcesRt = z.intersection( + searchCommonRt, + z.object({ + sources: z.array(entitySourceDefinitionRt), + }) +); + +export type SearchBySources = z.output; diff --git a/x-pack/plugins/entity_manager/server/plugin.ts b/x-pack/plugins/entity_manager/server/plugin.ts index 101fdde95c9dc..d2e9d12a33797 100644 --- a/x-pack/plugins/entity_manager/server/plugin.ts +++ b/x-pack/plugins/entity_manager/server/plugin.ts @@ -8,6 +8,7 @@ import { CoreSetup, CoreStart, + DEFAULT_APP_CATEGORIES, KibanaRequest, Logger, Plugin, @@ -16,6 +17,7 @@ import { } from '@kbn/core/server'; import { registerRoutes } from '@kbn/server-route-repository'; import { firstValueFrom } from 'rxjs'; +import { KibanaFeatureScope } from '@kbn/features-plugin/common'; import { EntityManagerConfig, configSchema, exposeToBrowserConfig } from '../common/config'; import { builtInDefinitions } from './lib/entities/built_in'; import { upgradeBuiltInEntityDefinitions } from './lib/entities/upgrade_entity_definition'; @@ -29,6 +31,14 @@ import { EntityManagerPluginStartDependencies, EntityManagerServerSetup, } from './types'; +import { setupEntityDefinitionsIndex } from './lib/v2/definitions/setup_entity_definitions_index'; +import { + CREATE_ENTITY_TYPE_DEFINITION_PRIVILEGE, + CREATE_ENTITY_SOURCE_DEFINITION_PRIVILEGE, + READ_ENTITY_TYPE_DEFINITION_PRIVILEGE, + READ_ENTITY_SOURCE_DEFINITION_PRIVILEGE, + READ_ENTITIES_PRIVILEGE, +} from './lib/v2/constants'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface EntityManagerServerPluginSetup {} @@ -63,6 +73,46 @@ export class EntityManagerServerPlugin core: CoreSetup, plugins: EntityManagerPluginSetupDependencies ): EntityManagerServerPluginSetup { + const ENTITY_MANAGER_FEATURE_ID = 'entityManager'; + plugins.features.registerKibanaFeature({ + id: ENTITY_MANAGER_FEATURE_ID, + name: 'Entity Manager', + description: 'All features related to the Elastic Entity model', + category: DEFAULT_APP_CATEGORIES.management, + scope: [KibanaFeatureScope.Spaces, KibanaFeatureScope.Security], + app: [ENTITY_MANAGER_FEATURE_ID], + privileges: { + all: { + app: [ENTITY_MANAGER_FEATURE_ID], + api: [ + CREATE_ENTITY_TYPE_DEFINITION_PRIVILEGE, + CREATE_ENTITY_SOURCE_DEFINITION_PRIVILEGE, + READ_ENTITY_TYPE_DEFINITION_PRIVILEGE, + READ_ENTITY_SOURCE_DEFINITION_PRIVILEGE, + READ_ENTITIES_PRIVILEGE, + ], + ui: [], + savedObject: { + all: [], + read: [], + }, + }, + read: { + app: [ENTITY_MANAGER_FEATURE_ID], + api: [ + READ_ENTITY_TYPE_DEFINITION_PRIVILEGE, + READ_ENTITY_SOURCE_DEFINITION_PRIVILEGE, + READ_ENTITIES_PRIVILEGE, + ], + ui: [], + savedObject: { + all: [], + read: [], + }, + }, + }, + }); + core.savedObjects.registerType(entityDefinition); core.savedObjects.registerType(EntityDiscoveryApiKeyType); plugins.encryptedSavedObjects.registerType({ @@ -99,9 +149,9 @@ export class EntityManagerServerPlugin request: KibanaRequest; coreStart: CoreStart; }) { - const esClient = coreStart.elasticsearch.client.asScoped(request).asCurrentUser; + const clusterClient = coreStart.elasticsearch.client.asScoped(request); const soClient = coreStart.savedObjects.getScopedClient(request); - return new EntityClient({ esClient, soClient, logger: this.logger }); + return new EntityClient({ clusterClient, soClient, logger: this.logger }); } public start( @@ -117,6 +167,7 @@ export class EntityManagerServerPlugin const esClient = core.elasticsearch.client.asInternalUser; + // Setup v1 definitions index installEntityManagerTemplates({ esClient, logger: this.logger }) .then(async () => { // the api key validation requires a check against the cluster license @@ -133,6 +184,11 @@ export class EntityManagerServerPlugin }) .catch((err) => this.logger.error(err)); + // Setup v2 definitions index + setupEntityDefinitionsIndex(core.elasticsearch.client, this.logger).catch((error) => { + this.logger.error(error); + }); + return { getScopedClient: async ({ request }: { request: KibanaRequest }) => { return this.getScopedClient({ request, coreStart: core }); diff --git a/x-pack/plugins/entity_manager/server/routes/entities/index.ts b/x-pack/plugins/entity_manager/server/routes/entities/index.ts index 52300ab2601b6..539423c6a5e17 100644 --- a/x-pack/plugins/entity_manager/server/routes/entities/index.ts +++ b/x-pack/plugins/entity_manager/server/routes/entities/index.ts @@ -10,7 +10,6 @@ import { deleteEntityDefinitionRoute } from './delete'; import { getEntityDefinitionRoute } from './get'; import { resetEntityDefinitionRoute } from './reset'; import { updateEntityDefinitionRoute } from './update'; -import { searchEntitiesRoute, searchEntitiesPreviewRoute } from '../v2/search'; export const entitiesRoutes = { ...createEntityDefinitionRoute, @@ -18,6 +17,4 @@ export const entitiesRoutes = { ...getEntityDefinitionRoute, ...resetEntityDefinitionRoute, ...updateEntityDefinitionRoute, - ...searchEntitiesRoute, - ...searchEntitiesPreviewRoute, }; diff --git a/x-pack/plugins/entity_manager/server/routes/index.ts b/x-pack/plugins/entity_manager/server/routes/index.ts index e3f2d3a75bbef..487dfe86028d7 100644 --- a/x-pack/plugins/entity_manager/server/routes/index.ts +++ b/x-pack/plugins/entity_manager/server/routes/index.ts @@ -7,10 +7,12 @@ import { enablementRoutes } from './enablement'; import { entitiesRoutes } from './entities'; +import { v2Routes } from './v2'; export const entityManagerRouteRepository = { ...enablementRoutes, ...entitiesRoutes, + ...v2Routes, }; export type EntityManagerRouteRepository = typeof entityManagerRouteRepository; diff --git a/x-pack/plugins/entity_manager/server/routes/v2/index.ts b/x-pack/plugins/entity_manager/server/routes/v2/index.ts new file mode 100644 index 0000000000000..c601ebe988a29 --- /dev/null +++ b/x-pack/plugins/entity_manager/server/routes/v2/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { searchRoutes } from './search'; +import { typeDefinitionRoutes } from './type_definition_routes'; +import { sourceDefinitionRoutes } from './source_definition_routes'; + +export const v2Routes = { + ...searchRoutes, + ...typeDefinitionRoutes, + ...sourceDefinitionRoutes, +}; diff --git a/x-pack/plugins/entity_manager/server/routes/v2/search.ts b/x-pack/plugins/entity_manager/server/routes/v2/search.ts index 451ff7cfff43f..82d0e5e84f2da 100644 --- a/x-pack/plugins/entity_manager/server/routes/v2/search.ts +++ b/x-pack/plugins/entity_manager/server/routes/v2/search.ts @@ -5,62 +5,26 @@ * 2.0. */ -import moment from 'moment'; import { z } from '@kbn/zod'; import { createEntityManagerServerRoute } from '../create_entity_manager_server_route'; -import { entitySourceSchema } from '../../lib/queries'; -import { UnknownEntityType } from '../../lib/entities/errors/unknown_entity_type'; +import { UnknownEntityType } from '../../lib/v2/errors/unknown_entity_type'; +import { searchBySourcesRt, searchByTypeRt } from '../../lib/v2/types'; +import { READ_ENTITIES_PRIVILEGE } from '../../lib/v2/constants'; export const searchEntitiesRoute = createEntityManagerServerRoute({ endpoint: 'POST /internal/entities/v2/_search', + security: { + authz: { + requiredPrivileges: [READ_ENTITIES_PRIVILEGE], + }, + }, params: z.object({ - body: z.object({ - type: z.string(), - metadata_fields: z.optional(z.array(z.string())).default([]), - filters: z.optional(z.array(z.string())).default([]), - start: z - .optional(z.string()) - .default(() => moment().subtract(5, 'minutes').toISOString()) - .refine((val) => moment(val).isValid(), { - message: '[start] should be a date in ISO format', - }), - end: z - .optional(z.string()) - .default(() => moment().toISOString()) - .refine((val) => moment(val).isValid(), { - message: '[end] should be a date in ISO format', - }), - sort: z.optional( - z.object({ - field: z.string(), - direction: z.enum(['ASC', 'DESC']), - }) - ), - limit: z.optional(z.number()).default(10), - }), + body: searchByTypeRt, }), handler: async ({ request, response, params, logger, getScopedClient }) => { try { - const { - type, - start, - end, - limit, - filters, - sort, - metadata_fields: metadataFields, - } = params.body; - const client = await getScopedClient({ request }); - const entities = await client.searchEntities({ - type, - filters, - metadataFields, - start, - end, - sort, - limit, - }); + const entities = await client.v2.searchEntities(params.body); return response.ok({ body: { entities } }); } catch (e) { @@ -77,42 +41,23 @@ export const searchEntitiesRoute = createEntityManagerServerRoute({ export const searchEntitiesPreviewRoute = createEntityManagerServerRoute({ endpoint: 'POST /internal/entities/v2/_search/preview', + security: { + authz: { + requiredPrivileges: [READ_ENTITIES_PRIVILEGE], + }, + }, params: z.object({ - body: z.object({ - sources: z.array(entitySourceSchema), - start: z - .optional(z.string()) - .default(() => moment().subtract(5, 'minutes').toISOString()) - .refine((val) => moment(val).isValid(), { - message: '[start] should be a date in ISO format', - }), - end: z - .optional(z.string()) - .default(() => moment().toISOString()) - .refine((val) => moment(val).isValid(), { - message: '[end] should be a date in ISO format', - }), - sort: z.optional( - z.object({ - field: z.string(), - direction: z.enum(['ASC', 'DESC']), - }) - ), - limit: z.optional(z.number()).default(10), - }), + body: searchBySourcesRt, }), handler: async ({ request, response, params, getScopedClient }) => { - const { sources, start, end, limit, sort } = params.body; - const client = await getScopedClient({ request }); - const entities = await client.searchEntitiesBySources({ - sources, - start, - end, - sort, - limit, - }); + const entities = await client.v2.searchEntitiesBySources(params.body); return response.ok({ body: { entities } }); }, }); + +export const searchRoutes = { + ...searchEntitiesRoute, + ...searchEntitiesPreviewRoute, +}; diff --git a/x-pack/plugins/entity_manager/server/routes/v2/source_definition_routes.ts b/x-pack/plugins/entity_manager/server/routes/v2/source_definition_routes.ts new file mode 100644 index 0000000000000..4fdfa281cfd72 --- /dev/null +++ b/x-pack/plugins/entity_manager/server/routes/v2/source_definition_routes.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from '@kbn/zod'; +import { + CREATE_ENTITY_SOURCE_DEFINITION_PRIVILEGE, + READ_ENTITY_SOURCE_DEFINITION_PRIVILEGE, +} from '../../lib/v2/constants'; +import { entitySourceDefinitionRt } from '../../lib/v2/types'; +import { createEntityManagerServerRoute } from '../create_entity_manager_server_route'; +import { EntityDefinitionConflict } from '../../lib/v2/errors/entity_definition_conflict'; + +const createSourceDefinitionRoute = createEntityManagerServerRoute({ + endpoint: 'POST /internal/entities/v2/definitions/sources', + security: { + authz: { + requiredPrivileges: [CREATE_ENTITY_SOURCE_DEFINITION_PRIVILEGE], + }, + }, + params: z.object({ + body: z.object({ + source: entitySourceDefinitionRt, + }), + }), + handler: async ({ request, response, params, getScopedClient }) => { + try { + const client = await getScopedClient({ request }); + const source = await client.v2.storeSourceDefinition(params.body.source); + + return response.created({ + body: { + source, + }, + headers: { + location: `GET /internal/entities/v2/definitions/sources/${source.id}`, + }, + }); + } catch (error) { + if (error instanceof EntityDefinitionConflict) { + response.conflict({ + body: { + message: error.message, + }, + }); + } + + throw error; + } + }, +}); + +const readSourceDefinitionsRoute = createEntityManagerServerRoute({ + endpoint: 'GET /internal/entities/v2/definitions/sources', + security: { + authz: { + requiredPrivileges: [READ_ENTITY_SOURCE_DEFINITION_PRIVILEGE], + }, + }, + handler: async ({ request, response, getScopedClient }) => { + const client = await getScopedClient({ request }); + const sources = await client.v2.readSourceDefinitions(); + + return response.ok({ + body: { + sources, + }, + }); + }, +}); + +export const sourceDefinitionRoutes = { + ...createSourceDefinitionRoute, + ...readSourceDefinitionsRoute, +}; diff --git a/x-pack/plugins/entity_manager/server/routes/v2/type_definition_routes.ts b/x-pack/plugins/entity_manager/server/routes/v2/type_definition_routes.ts new file mode 100644 index 0000000000000..8de41f7caf62f --- /dev/null +++ b/x-pack/plugins/entity_manager/server/routes/v2/type_definition_routes.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from '@kbn/zod'; +import { + CREATE_ENTITY_TYPE_DEFINITION_PRIVILEGE, + READ_ENTITY_TYPE_DEFINITION_PRIVILEGE, +} from '../../lib/v2/constants'; +import { entityTypeDefinitionRt } from '../../lib/v2/types'; +import { createEntityManagerServerRoute } from '../create_entity_manager_server_route'; +import { EntityDefinitionConflict } from '../../lib/v2/errors/entity_definition_conflict'; + +const createTypeDefinitionRoute = createEntityManagerServerRoute({ + endpoint: 'POST /internal/entities/v2/definitions/types', + security: { + authz: { + requiredPrivileges: [CREATE_ENTITY_TYPE_DEFINITION_PRIVILEGE], + }, + }, + params: z.object({ + body: z.object({ + type: entityTypeDefinitionRt, + }), + }), + handler: async ({ request, response, params, getScopedClient }) => { + try { + const client = await getScopedClient({ request }); + const type = await client.v2.storeTypeDefinition(params.body.type); + + return response.created({ + body: { + type, + }, + headers: { + location: `GET /internal/entities/v2/definitions/types/${type.id}`, + }, + }); + } catch (error) { + if (error instanceof EntityDefinitionConflict) { + return response.conflict({ + body: { + message: error.message, + }, + }); + } + + throw error; + } + }, +}); + +const readTypeDefinitionsRoute = createEntityManagerServerRoute({ + endpoint: 'GET /internal/entities/v2/definitions/types', + security: { + authz: { + requiredPrivileges: [READ_ENTITY_TYPE_DEFINITION_PRIVILEGE], + }, + }, + handler: async ({ request, response, getScopedClient }) => { + const client = await getScopedClient({ request }); + const types = await client.v2.readTypeDefinitions(); + + return response.ok({ + body: { + types, + }, + }); + }, +}); + +export const typeDefinitionRoutes = { + ...createTypeDefinitionRoute, + ...readTypeDefinitionsRoute, +}; diff --git a/x-pack/plugins/entity_manager/server/types.ts b/x-pack/plugins/entity_manager/server/types.ts index cd7a4a49b9882..0841ba8a2c950 100644 --- a/x-pack/plugins/entity_manager/server/types.ts +++ b/x-pack/plugins/entity_manager/server/types.ts @@ -12,6 +12,7 @@ import { EncryptedSavedObjectsPluginStart, } from '@kbn/encrypted-saved-objects-plugin/server'; import { LicensingPluginStart } from '@kbn/licensing-plugin/server'; +import { FeaturesPluginSetup } from '@kbn/features-plugin/server'; import { EntityManagerConfig } from '../common/config'; export interface EntityManagerServerSetup { @@ -29,6 +30,7 @@ export interface ElasticsearchAccessorOptions { export interface EntityManagerPluginSetupDependencies { encryptedSavedObjects: EncryptedSavedObjectsPluginSetup; + features: FeaturesPluginSetup; } export interface EntityManagerPluginStartDependencies { diff --git a/x-pack/plugins/entity_manager/tsconfig.json b/x-pack/plugins/entity_manager/tsconfig.json index 2ef8551f373fd..4c75ac101f6ad 100644 --- a/x-pack/plugins/entity_manager/tsconfig.json +++ b/x-pack/plugins/entity_manager/tsconfig.json @@ -36,5 +36,7 @@ "@kbn/licensing-plugin", "@kbn/core-saved-objects-server", "@kbn/es-types", + "@kbn/apm-utils", + "@kbn/features-plugin", ] } diff --git a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap index b5614562f18a4..140d20f8ebdb8 100644 --- a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap +++ b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap @@ -548,6 +548,8 @@ Array [ "api": Array [ "bulkGetUserProfiles", "dashboardUsageStats", + "savedQuery:manage", + "savedQuery:read", "store_search_session", ], "app": Array [ @@ -607,6 +609,7 @@ Array [ "api": Array [ "bulkGetUserProfiles", "dashboardUsageStats", + "savedQuery:read", ], "app": Array [ "dashboards", @@ -709,6 +712,8 @@ Array [ }, "api": Array [ "fileUpload:analyzeFile", + "savedQuery:manage", + "savedQuery:read", "store_search_session", ], "app": Array [ @@ -757,6 +762,9 @@ Array [ }, Object { "privilege": Object { + "api": Array [ + "savedQuery:read", + ], "app": Array [ "discover", "kibana", @@ -1004,6 +1012,10 @@ exports[`buildOSSFeatures with a basic license returns the savedQueryManagement Array [ Object { "privilege": Object { + "api": Array [ + "savedQuery:manage", + "savedQuery:read", + ], "app": Array [ "kibana", ], @@ -1022,10 +1034,14 @@ Array [ }, Object { "privilege": Object { - "disabled": true, + "api": Array [ + "savedQuery:read", + ], "savedObject": Object { "all": Array [], - "read": Array [], + "read": Array [ + "query", + ], }, "ui": Array [], }, @@ -1048,7 +1064,10 @@ Array [ "read": Array [], }, }, - "api": Array [], + "api": Array [ + "savedQuery:manage", + "savedQuery:read", + ], "app": Array [ "visualize", "lens", @@ -1094,6 +1113,9 @@ Array [ }, Object { "privilege": Object { + "api": Array [ + "savedQuery:read", + ], "app": Array [ "visualize", "lens", @@ -1190,6 +1212,8 @@ Array [ "api": Array [ "bulkGetUserProfiles", "dashboardUsageStats", + "savedQuery:manage", + "savedQuery:read", "store_search_session", ], "app": Array [ @@ -1249,6 +1273,7 @@ Array [ "api": Array [ "bulkGetUserProfiles", "dashboardUsageStats", + "savedQuery:read", ], "app": Array [ "dashboards", @@ -1351,6 +1376,8 @@ Array [ }, "api": Array [ "fileUpload:analyzeFile", + "savedQuery:manage", + "savedQuery:read", "store_search_session", ], "app": Array [ @@ -1399,6 +1426,9 @@ Array [ }, Object { "privilege": Object { + "api": Array [ + "savedQuery:read", + ], "app": Array [ "discover", "kibana", @@ -1646,6 +1676,10 @@ exports[`buildOSSFeatures with a enterprise license returns the savedQueryManage Array [ Object { "privilege": Object { + "api": Array [ + "savedQuery:manage", + "savedQuery:read", + ], "app": Array [ "kibana", ], @@ -1664,10 +1698,14 @@ Array [ }, Object { "privilege": Object { - "disabled": true, + "api": Array [ + "savedQuery:read", + ], "savedObject": Object { "all": Array [], - "read": Array [], + "read": Array [ + "query", + ], }, "ui": Array [], }, @@ -1690,7 +1728,10 @@ Array [ "read": Array [], }, }, - "api": Array [], + "api": Array [ + "savedQuery:manage", + "savedQuery:read", + ], "app": Array [ "visualize", "lens", @@ -1736,6 +1777,9 @@ Array [ }, Object { "privilege": Object { + "api": Array [ + "savedQuery:read", + ], "app": Array [ "visualize", "lens", diff --git a/x-pack/plugins/features/server/oss_features.ts b/x-pack/plugins/features/server/oss_features.ts index 0f243e7e6bda8..12978c35777e7 100644 --- a/x-pack/plugins/features/server/oss_features.ts +++ b/x-pack/plugins/features/server/oss_features.ts @@ -37,7 +37,7 @@ export const buildOSSFeatures = ({ privileges: { all: { app: ['discover', 'kibana'], - api: ['fileUpload:analyzeFile'], + api: ['fileUpload:analyzeFile', 'savedQuery:manage', 'savedQuery:read'], catalogue: ['discover'], savedObject: { all: ['search', 'query'], @@ -53,6 +53,7 @@ export const buildOSSFeatures = ({ read: ['index-pattern', 'search', 'query'], }, ui: ['show'], + api: ['savedQuery:read'], }, }, subFeatures: [ @@ -139,6 +140,7 @@ export const buildOSSFeatures = ({ read: ['index-pattern', 'search', 'tag'], }, ui: ['show', 'delete', 'save', 'saveQuery'], + api: ['savedQuery:manage', 'savedQuery:read'], }, read: { app: ['visualize', 'lens', 'kibana'], @@ -148,6 +150,7 @@ export const buildOSSFeatures = ({ read: ['index-pattern', 'search', 'visualization', 'query', 'lens', 'tag'], }, ui: ['show'], + api: ['savedQuery:read'], }, }, subFeatures: [ @@ -213,7 +216,12 @@ export const buildOSSFeatures = ({ ], }, ui: ['createNew', 'show', 'showWriteControls', 'saveQuery'], - api: ['bulkGetUserProfiles', 'dashboardUsageStats'], + api: [ + 'bulkGetUserProfiles', + 'dashboardUsageStats', + 'savedQuery:manage', + 'savedQuery:read', + ], }, read: { app: ['dashboards', 'kibana'], @@ -234,7 +242,7 @@ export const buildOSSFeatures = ({ ], }, ui: ['show'], - api: ['bulkGetUserProfiles', 'dashboardUsageStats'], + api: ['bulkGetUserProfiles', 'dashboardUsageStats', 'savedQuery:read'], }, }, subFeatures: [ @@ -545,7 +553,7 @@ export const buildOSSFeatures = ({ catalogue: [], privilegesTooltip: i18n.translate('xpack.features.savedQueryManagementTooltip', { defaultMessage: - 'If set to "All", saved queries can be managed across Kibana in all applications that support them. If set to "None", saved query privileges will be determined independently by each application.', + 'If set to "All", saved queries can be managed across Kibana in all applications that support them. Otherwise, saved query privileges will be determined independently by each application.', }), privileges: { all: { @@ -556,9 +564,16 @@ export const buildOSSFeatures = ({ read: [], }, ui: ['saveQuery'], + api: ['savedQuery:manage', 'savedQuery:read'], + }, + read: { + savedObject: { + all: [], + read: ['query'], + }, + ui: [], + api: ['savedQuery:read'], }, - // No read-only mode supported - read: { disabled: true, savedObject: { all: [], read: [] }, ui: [] }, }, }, ]; diff --git a/x-pack/plugins/fleet/cypress/e2e/space_awareness/policies.cy.ts b/x-pack/plugins/fleet/cypress/e2e/space_awareness/policies.cy.ts index 8975de388248c..010f147882482 100644 --- a/x-pack/plugins/fleet/cypress/e2e/space_awareness/policies.cy.ts +++ b/x-pack/plugins/fleet/cypress/e2e/space_awareness/policies.cy.ts @@ -28,6 +28,7 @@ describe('Space aware policies creation', { testIsolation: false }, () => { beforeEach(() => { cy.intercept('GET', /\/api\/fleet\/agent_policies/).as('getAgentPolicies'); + cy.intercept('PUT', /\/api\/fleet\/agent_policies\/.*/).as('putAgentPolicy'); cy.intercept('GET', /\/internal\/fleet\/agent_policies_spaces/).as('getAgentPoliciesSpaces'); }); @@ -59,6 +60,7 @@ describe('Space aware policies creation', { testIsolation: false }, () => { cy.getBySel(AGENT_POLICY_DETAILS_PAGE.SPACE_SELECTOR_COMBOBOX).click().type('default{enter}'); cy.getBySel(AGENT_POLICY_DETAILS_PAGE.SAVE_BUTTON).click(); + cy.wait('@putAgentPolicy'); }); it('the policy should be visible in the test space', () => { @@ -72,4 +74,22 @@ describe('Space aware policies creation', { testIsolation: false }, () => { cy.wait('@getAgentPolicies'); cy.getBySel(AGENT_POLICIES_TABLE).contains(POLICY_NAME); }); + + it('should redirect to the agent policies list when removing the current space from a policy', () => { + cy.visit('/s/test/app/fleet/policies'); + cy.getBySel(AGENT_POLICIES_TABLE).contains(POLICY_NAME).click(); + + cy.getBySel(AGENT_POLICY_DETAILS_PAGE.SETTINGS_TAB).click(); + cy.wait('@getAgentPoliciesSpaces'); + + cy.get('[title="Remove Test from selection in this group"]').click(); + + cy.getBySel(AGENT_POLICY_DETAILS_PAGE.SAVE_BUTTON).click(); + cy.wait('@putAgentPolicy'); + + cy.wait('@getAgentPolicies'); + cy.location('pathname').should('eq', '/s/test/app/fleet/policies'); + + cy.getBySel(AGENT_POLICIES_TABLE).contains(NO_AGENT_POLICIES); + }); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/settings/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/settings/index.tsx index c2f69efd8e916..990e787efc0a3 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/settings/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/settings/index.tsx @@ -18,9 +18,10 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; +import { useHistory } from 'react-router-dom'; import { useSpaceSettingsContext } from '../../../../../../../hooks/use_space_settings_context'; - import type { AgentPolicy } from '../../../../../types'; import { useStartServices, @@ -30,6 +31,8 @@ import { sendGetAgentStatus, useAgentPolicyRefresh, useBreadcrumbs, + useFleetStatus, + useLink, } from '../../../../../hooks'; import { AgentPolicyForm, @@ -79,14 +82,17 @@ export const SettingsView = memo<{ agentPolicy: AgentPolicy }>( ({ agentPolicy: originalAgentPolicy }) => { useBreadcrumbs('policy_details', { policyName: originalAgentPolicy.name }); const { notifications } = useStartServices(); + const { spaceId } = useFleetStatus(); const { agents: { enabled: isFleetEnabled }, } = useConfig(); + const { getPath } = useLink(); const hasAllAgentPoliciesPrivileges = useAuthz().fleet.allAgentPolicies; const refreshAgentPolicy = useAgentPolicyRefresh(); const [agentPolicy, setAgentPolicy] = useState({ ...originalAgentPolicy, }); + const history = useHistory(); const spaceSettings = useSpaceSettingsContext(); const [isLoading, setIsLoading] = useState(false); @@ -121,8 +127,15 @@ export const SettingsView = memo<{ agentPolicy: AgentPolicy }>( values: { name: agentPolicy.name }, }) ); - refreshAgentPolicy(); - setHasChanges(false); + if ( + agentPolicy.space_ids && + !agentPolicy.space_ids.includes(spaceId ?? DEFAULT_SPACE_ID) + ) { + history.replace(getPath('policies_list')); + } else { + refreshAgentPolicy(); + setHasChanges(false); + } } else { notifications.toasts.addDanger( error diff --git a/x-pack/plugins/inference/public/util/create_observable_from_http_response.ts b/x-pack/plugins/inference/public/util/create_observable_from_http_response.ts index 862986ce1c73a..d7520a86d6922 100644 --- a/x-pack/plugins/inference/public/util/create_observable_from_http_response.ts +++ b/x-pack/plugins/inference/public/util/create_observable_from_http_response.ts @@ -26,10 +26,10 @@ export function createObservableFromHttpResponse( } return new Observable((subscriber) => { - const parser = createParser((event) => { - if (event.type === 'event') { + const parser = createParser({ + onEvent: (event) => { subscriber.next(event.data); - } + }, }); const readStream = async () => { diff --git a/x-pack/plugins/inference/server/chat_complete/adapters/gemini/process_vertex_stream.test.ts b/x-pack/plugins/inference/server/chat_complete/adapters/gemini/process_vertex_stream.test.ts index 8613799846e3b..01c93107a199a 100644 --- a/x-pack/plugins/inference/server/chat_complete/adapters/gemini/process_vertex_stream.test.ts +++ b/x-pack/plugins/inference/server/chat_complete/adapters/gemini/process_vertex_stream.test.ts @@ -97,6 +97,11 @@ describe('processVertexStream', () => { expectObservable(processed$).toBe('--(ab)', { a: { + content: 'last chunk', + tool_calls: [], + type: ChatCompletionEventType.ChatCompletionChunk, + }, + b: { tokens: { completion: 1, prompt: 2, @@ -104,11 +109,6 @@ describe('processVertexStream', () => { }, type: ChatCompletionEventType.ChatCompletionTokenCount, }, - b: { - content: 'last chunk', - tool_calls: [], - type: ChatCompletionEventType.ChatCompletionChunk, - }, }); }); }); diff --git a/x-pack/plugins/inference/server/chat_complete/adapters/gemini/process_vertex_stream.ts b/x-pack/plugins/inference/server/chat_complete/adapters/gemini/process_vertex_stream.ts index 3081317882c65..7b2ed2869c21d 100644 --- a/x-pack/plugins/inference/server/chat_complete/adapters/gemini/process_vertex_stream.ts +++ b/x-pack/plugins/inference/server/chat_complete/adapters/gemini/process_vertex_stream.ts @@ -18,18 +18,6 @@ export function processVertexStream() { return (source: Observable) => new Observable((subscriber) => { function handleNext(value: GenerateContentResponseChunk) { - // completion: only present on last chunk - if (value.usageMetadata) { - subscriber.next({ - type: ChatCompletionEventType.ChatCompletionTokenCount, - tokens: { - prompt: value.usageMetadata.promptTokenCount, - completion: value.usageMetadata.candidatesTokenCount, - total: value.usageMetadata.totalTokenCount, - }, - }); - } - const contentPart = value.candidates?.[0].content.parts[0]; const completion = contentPart?.text; const toolCall = contentPart?.functionCall; @@ -49,6 +37,18 @@ export function processVertexStream() { : [], }); } + + // completion: only present on last chunk + if (value.usageMetadata) { + subscriber.next({ + type: ChatCompletionEventType.ChatCompletionTokenCount, + tokens: { + prompt: value.usageMetadata.promptTokenCount, + completion: value.usageMetadata.candidatesTokenCount, + total: value.usageMetadata.totalTokenCount, + }, + }); + } } source.subscribe({ diff --git a/x-pack/plugins/inference/server/routes/chat_complete.ts b/x-pack/plugins/inference/server/routes/chat_complete.ts index b363c88352994..8b4cc49dfaa46 100644 --- a/x-pack/plugins/inference/server/routes/chat_complete.ts +++ b/x-pack/plugins/inference/server/routes/chat_complete.ts @@ -13,7 +13,13 @@ import type { RequestHandlerContext, KibanaRequest, } from '@kbn/core/server'; -import { MessageRole, ToolCall, ToolChoiceType } from '@kbn/inference-common'; +import { + MessageRole, + ToolCall, + ToolChoiceType, + InferenceTaskEventType, + isInferenceError, +} from '@kbn/inference-common'; import type { ChatCompleteRequestBody } from '../../common/http_apis'; import { createClient as createInferenceClient } from '../inference_client'; import { InferenceServerStart, InferenceStartDependencies } from '../types'; @@ -130,10 +136,22 @@ export function registerChatCompleteRoute({ }, }, async (context, request, response) => { - const chatCompleteResponse = await callChatComplete({ request, stream: false }); - return response.ok({ - body: chatCompleteResponse, - }); + try { + const chatCompleteResponse = await callChatComplete({ request, stream: false }); + return response.ok({ + body: chatCompleteResponse, + }); + } catch (e) { + return response.custom({ + statusCode: isInferenceError(e) ? e.meta?.status ?? 500 : 500, + bypassErrorFormat: true, + body: { + type: InferenceTaskEventType.error, + code: e.code ?? 'unknown', + message: e.message, + }, + }); + } } ); @@ -145,9 +163,9 @@ export function registerChatCompleteRoute({ }, }, async (context, request, response) => { - const chatCompleteResponse = await callChatComplete({ request, stream: true }); + const chatCompleteEvents$ = await callChatComplete({ request, stream: true }); return response.ok({ - body: observableIntoEventSourceStream(chatCompleteResponse, logger), + body: observableIntoEventSourceStream(chatCompleteEvents$, logger), }); } ); diff --git a/x-pack/plugins/inference/server/util/event_source_stream_into_observable.ts b/x-pack/plugins/inference/server/util/event_source_stream_into_observable.ts index cad0a8e84d6a7..42844632aa03b 100644 --- a/x-pack/plugins/inference/server/util/event_source_stream_into_observable.ts +++ b/x-pack/plugins/inference/server/util/event_source_stream_into_observable.ts @@ -11,10 +11,10 @@ import { Observable } from 'rxjs'; export function eventSourceStreamIntoObservable(readable: Readable) { return new Observable((subscriber) => { - const parser = createParser((event) => { - if (event.type === 'event') { + const parser = createParser({ + onEvent: (event) => { subscriber.next(event.data); - } + }, }); async function processStream() { diff --git a/x-pack/plugins/inference/server/util/observable_into_event_source_stream.test.ts b/x-pack/plugins/inference/server/util/observable_into_event_source_stream.test.ts index 8ece214c27599..a815b22854118 100644 --- a/x-pack/plugins/inference/server/util/observable_into_event_source_stream.test.ts +++ b/x-pack/plugins/inference/server/util/observable_into_event_source_stream.test.ts @@ -72,10 +72,10 @@ describe('observableIntoEventSourceStream', () => { const events: Array> = []; - const parser = createParser((event) => { - if (event.type === 'event') { + const parser = createParser({ + onEvent: (event) => { events.push(JSON.parse(event.data)); - } + }, }); chunks.forEach((chunk) => { diff --git a/x-pack/plugins/maps/server/plugin.ts b/x-pack/plugins/maps/server/plugin.ts index 1b98310f798e4..9d82aa0cc931e 100644 --- a/x-pack/plugins/maps/server/plugin.ts +++ b/x-pack/plugins/maps/server/plugin.ts @@ -188,6 +188,7 @@ export class MapsPlugin implements Plugin { read: ['index-pattern', 'tag'], }, ui: ['save', 'show', 'saveQuery'], + api: ['savedQuery:manage', 'savedQuery:read'], }, read: { app: [APP_ID, 'kibana'], @@ -197,6 +198,7 @@ export class MapsPlugin implements Plugin { read: [MAP_SAVED_OBJECT_TYPE, 'index-pattern', 'query', 'tag'], }, ui: ['show'], + api: ['savedQuery:read'], }, }, }); diff --git a/x-pack/plugins/ml/common/types/trained_models.ts b/x-pack/plugins/ml/common/types/trained_models.ts index f4ed52ff21f52..25d7e231bf166 100644 --- a/x-pack/plugins/ml/common/types/trained_models.ts +++ b/x-pack/plugins/ml/common/types/trained_models.ts @@ -5,14 +5,25 @@ * 2.0. */ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import type { TrainedModelType } from '@kbn/ml-trained-models-utils'; +import type { + InferenceInferenceEndpointInfo, + MlInferenceConfigCreateContainer, +} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { + ModelDefinitionResponse, + ModelState, + TrainedModelType, +} from '@kbn/ml-trained-models-utils'; +import { + BUILT_IN_MODEL_TAG, + ELASTIC_MODEL_TAG, + TRAINED_MODEL_TYPE, +} from '@kbn/ml-trained-models-utils'; import type { DataFrameAnalyticsConfig, FeatureImportanceBaseline, TotalFeatureImportance, } from '@kbn/ml-data-frame-analytics-utils'; -import type { IndexName, IndicesIndexState } from '@elastic/elasticsearch/lib/api/types'; -import type { InferenceAPIConfigResponse } from '@kbn/ml-trained-models-utils'; import type { XOR } from './common'; import type { MlSavedObjectType } from './saved_objects'; @@ -95,33 +106,12 @@ export type PutTrainedModelConfig = { >; // compressed_definition and definition are mutually exclusive export type TrainedModelConfigResponse = estypes.MlTrainedModelConfig & { - /** - * Associated pipelines. Extends response from the ES endpoint. - */ - pipelines?: Record | null; - origin_job_exists?: boolean; - - metadata?: { - analytics_config: DataFrameAnalyticsConfig; + metadata?: estypes.MlTrainedModelConfig['metadata'] & { + analytics_config?: DataFrameAnalyticsConfig; input: unknown; total_feature_importance?: TotalFeatureImportance[]; feature_importance_baseline?: FeatureImportanceBaseline; - model_aliases?: string[]; } & Record; - model_id: string; - model_type: TrainedModelType; - tags: string[]; - version: string; - inference_config?: Record; - indices?: Array>; - /** - * Whether the model has inference services - */ - hasInferenceServices?: boolean; - /** - * Inference services associated with the model - */ - inference_apis?: InferenceAPIConfigResponse[]; }; export interface PipelineDefinition { @@ -309,3 +299,125 @@ export interface ModelDownloadState { total_parts: number; downloaded_parts: number; } + +export type Stats = Omit; + +/** + * Additional properties for all items in the Trained models table + * */ +interface BaseModelItem { + type?: string[]; + tags: string[]; + /** + * Whether the model has inference services + */ + hasInferenceServices?: boolean; + /** + * Inference services associated with the model + */ + inference_apis?: InferenceInferenceEndpointInfo[]; + /** + * Associated pipelines. Extends response from the ES endpoint. + */ + pipelines?: Record; + /** + * Indices with associated pipelines that have inference processors utilizing the model deployments. + */ + indices?: string[]; +} + +/** Common properties for existing NLP models and NLP model download configs */ +interface BaseNLPModelItem extends BaseModelItem { + disclaimer?: string; + recommended?: boolean; + supported?: boolean; + state: ModelState | undefined; + downloadState?: ModelDownloadState; +} + +/** Model available for download */ +export type ModelDownloadItem = BaseNLPModelItem & + Omit & { + putModelConfig?: object; + softwareLicense?: string; + }; +/** Trained NLP model, i.e. pytorch model returned by the trained_models API */ +export type NLPModelItem = BaseNLPModelItem & + TrainedModelItem & { + stats: Stats & { deployment_stats: TrainedModelDeploymentStatsResponse[] }; + /** + * Description of the current model state + */ + stateDescription?: string; + /** + * Deployment ids extracted from the deployment stats + */ + deployment_ids: string[]; + }; + +export function isBaseNLPModelItem(item: unknown): item is BaseNLPModelItem { + return ( + typeof item === 'object' && + item !== null && + 'type' in item && + Array.isArray(item.type) && + item.type.includes(TRAINED_MODEL_TYPE.PYTORCH) + ); +} + +export function isNLPModelItem(item: unknown): item is NLPModelItem { + return isExistingModel(item) && item.model_type === TRAINED_MODEL_TYPE.PYTORCH; +} + +export const isElasticModel = (item: TrainedModelConfigResponse) => + item.tags.includes(ELASTIC_MODEL_TAG); + +export type ExistingModelBase = TrainedModelConfigResponse & BaseModelItem; + +/** Any model returned by the trained_models API, e.g. lang_ident, elser, dfa model */ +export type TrainedModelItem = ExistingModelBase & { stats: Stats }; + +/** Trained DFA model */ +export type DFAModelItem = Omit & { + origin_job_exists?: boolean; + inference_config?: Pick; + metadata?: estypes.MlTrainedModelConfig['metadata'] & { + analytics_config: DataFrameAnalyticsConfig; + input: unknown; + total_feature_importance?: TotalFeatureImportance[]; + feature_importance_baseline?: FeatureImportanceBaseline; + } & Record; +}; + +export type TrainedModelWithPipelines = TrainedModelItem & { + pipelines: Record; +}; + +export function isExistingModel(item: unknown): item is TrainedModelItem { + return ( + typeof item === 'object' && + item !== null && + 'model_type' in item && + 'create_time' in item && + !!item.create_time + ); +} + +export function isDFAModelItem(item: unknown): item is DFAModelItem { + return isExistingModel(item) && item.model_type === TRAINED_MODEL_TYPE.TREE_ENSEMBLE; +} + +export function isModelDownloadItem(item: TrainedModelUIItem): item is ModelDownloadItem { + return 'putModelConfig' in item && !!item.type?.includes(TRAINED_MODEL_TYPE.PYTORCH); +} + +export const isBuiltInModel = (item: TrainedModelConfigResponse | TrainedModelUIItem) => + item.tags.includes(BUILT_IN_MODEL_TAG); +/** + * This type represents a union of different model entities: + * - Any existing trained model returned by the API, e.g., lang_ident_model_1, DFA models, etc. + * - Hosted model configurations available for download, e.g., ELSER or E5 + * - NLP models already downloaded into Elasticsearch + * - DFA models + */ +export type TrainedModelUIItem = TrainedModelItem | ModelDownloadItem | NLPModelItem | DFAModelItem; diff --git a/x-pack/plugins/ml/public/application/components/ml_inference/add_inference_pipeline_flyout.tsx b/x-pack/plugins/ml/public/application/components/ml_inference/add_inference_pipeline_flyout.tsx index 1d58dce866449..5bd47702ed3f0 100644 --- a/x-pack/plugins/ml/public/application/components/ml_inference/add_inference_pipeline_flyout.tsx +++ b/x-pack/plugins/ml/public/application/components/ml_inference/add_inference_pipeline_flyout.tsx @@ -20,7 +20,7 @@ import { import { i18n } from '@kbn/i18n'; import { extractErrorProperties } from '@kbn/ml-error-utils'; -import type { ModelItem } from '../../model_management/models_list'; +import type { DFAModelItem } from '../../../../common/types/trained_models'; import type { AddInferencePipelineSteps } from './types'; import { ADD_INFERENCE_PIPELINE_STEPS } from './constants'; import { AddInferencePipelineFooter } from '../shared'; @@ -39,7 +39,7 @@ import { useFetchPipelines } from './hooks/use_fetch_pipelines'; export interface AddInferencePipelineFlyoutProps { onClose: () => void; - model: ModelItem; + model: DFAModelItem; } export const AddInferencePipelineFlyout: FC = ({ diff --git a/x-pack/plugins/ml/public/application/components/ml_inference/components/processor_configuration.tsx b/x-pack/plugins/ml/public/application/components/ml_inference/components/processor_configuration.tsx index cd8bdf52166e0..0803cd98679a8 100644 --- a/x-pack/plugins/ml/public/application/components/ml_inference/components/processor_configuration.tsx +++ b/x-pack/plugins/ml/public/application/components/ml_inference/components/processor_configuration.tsx @@ -25,7 +25,7 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { CodeEditor } from '@kbn/code-editor'; -import type { ModelItem } from '../../../model_management/models_list'; +import type { DFAModelItem } from '../../../../../common/types/trained_models'; import { EDIT_MESSAGE, CANCEL_EDIT_MESSAGE, @@ -56,9 +56,9 @@ interface Props { condition?: string; fieldMap: MlInferenceState['fieldMap']; handleAdvancedConfigUpdate: (configUpdate: Partial) => void; - inferenceConfig: ModelItem['inference_config']; - modelInferenceConfig: ModelItem['inference_config']; - modelInputFields: ModelItem['input']; + inferenceConfig: DFAModelItem['inference_config']; + modelInferenceConfig: DFAModelItem['inference_config']; + modelInputFields: DFAModelItem['input']; modelType?: InferenceModelTypes; setHasUnsavedChanges: React.Dispatch>; tag?: string; diff --git a/x-pack/plugins/ml/public/application/components/ml_inference/state.ts b/x-pack/plugins/ml/public/application/components/ml_inference/state.ts index 787a2335717df..26bfe934eb46b 100644 --- a/x-pack/plugins/ml/public/application/components/ml_inference/state.ts +++ b/x-pack/plugins/ml/public/application/components/ml_inference/state.ts @@ -6,10 +6,10 @@ */ import { getAnalysisType } from '@kbn/ml-data-frame-analytics-utils'; +import type { DFAModelItem } from '../../../../common/types/trained_models'; import type { MlInferenceState } from './types'; -import type { ModelItem } from '../../model_management/models_list'; -export const getModelType = (model: ModelItem): string | undefined => { +export const getModelType = (model: DFAModelItem): string | undefined => { const analysisConfig = model.metadata?.analytics_config?.analysis; return analysisConfig !== undefined ? getAnalysisType(analysisConfig) : undefined; }; @@ -54,13 +54,17 @@ export const getDefaultOnFailureConfiguration = (): MlInferenceState['onFailure' }, ]; -export const getInitialState = (model: ModelItem): MlInferenceState => { +export const getInitialState = (model: DFAModelItem): MlInferenceState => { const modelType = getModelType(model); let targetField; if (modelType !== undefined) { targetField = model.inference_config - ? `ml.inference.${model.inference_config[modelType].results_field}` + ? `ml.inference.${ + model.inference_config[ + modelType as keyof Exclude + ]!.results_field + }` : undefined; } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/components/analytics_selector/analytics_id_selector.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/components/analytics_selector/analytics_id_selector.tsx index bf786436919a9..9fe4da68aa6f8 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/components/analytics_selector/analytics_id_selector.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/components/analytics_selector/analytics_id_selector.tsx @@ -154,6 +154,7 @@ export function AnalyticsIdSelector({ async function fetchAnalyticsModels() { setIsLoading(true); try { + // FIXME should if fetch all trained models? const response = await trainedModelsApiService.getTrainedModels(); setTrainedModels(response); } catch (e) { diff --git a/x-pack/plugins/ml/public/application/model_management/add_model_flyout.tsx b/x-pack/plugins/ml/public/application/model_management/add_model_flyout.tsx index 5a92a67962579..24c8ce0915234 100644 --- a/x-pack/plugins/ml/public/application/model_management/add_model_flyout.tsx +++ b/x-pack/plugins/ml/public/application/model_management/add_model_flyout.tsx @@ -30,12 +30,12 @@ import { FormattedMessage } from '@kbn/i18n-react'; import React, { type FC, useMemo, useState } from 'react'; import { groupBy } from 'lodash'; import { ElandPythonClient } from '@kbn/inference_integration_flyout'; +import type { ModelDownloadItem } from '../../../common/types/trained_models'; import { usePermissionCheck } from '../capabilities/check_capabilities'; import { useMlKibana } from '../contexts/kibana'; -import type { ModelItem } from './models_list'; export interface AddModelFlyoutProps { - modelDownloads: ModelItem[]; + modelDownloads: ModelDownloadItem[]; onClose: () => void; onSubmit: (modelId: string) => void; } @@ -138,7 +138,7 @@ export const AddModelFlyout: FC = ({ onClose, onSubmit, mod }; interface ClickToDownloadTabContentProps { - modelDownloads: ModelItem[]; + modelDownloads: ModelDownloadItem[]; onModelDownload: (modelId: string) => void; } diff --git a/x-pack/plugins/ml/public/application/model_management/create_pipeline_for_model/create_pipeline_for_model_flyout.tsx b/x-pack/plugins/ml/public/application/model_management/create_pipeline_for_model/create_pipeline_for_model_flyout.tsx index 21fac6f6a28f8..580341800f3b5 100644 --- a/x-pack/plugins/ml/public/application/model_management/create_pipeline_for_model/create_pipeline_for_model_flyout.tsx +++ b/x-pack/plugins/ml/public/application/model_management/create_pipeline_for_model/create_pipeline_for_model_flyout.tsx @@ -21,7 +21,7 @@ import { i18n } from '@kbn/i18n'; import { extractErrorProperties } from '@kbn/ml-error-utils'; import type { SupportedPytorchTasksType } from '@kbn/ml-trained-models-utils'; -import type { ModelItem } from '../models_list'; +import type { TrainedModelItem } from '../../../../common/types/trained_models'; import type { AddInferencePipelineSteps } from '../../components/ml_inference/types'; import { ADD_INFERENCE_PIPELINE_STEPS } from '../../components/ml_inference/constants'; import { AddInferencePipelineFooter } from '../../components/shared'; @@ -40,7 +40,7 @@ import { useTestTrainedModelsContext } from '../test_models/test_trained_models_ export interface CreatePipelineForModelFlyoutProps { onClose: (refreshList?: boolean) => void; - model: ModelItem; + model: TrainedModelItem; } export const CreatePipelineForModelFlyout: FC = ({ diff --git a/x-pack/plugins/ml/public/application/model_management/create_pipeline_for_model/state.ts b/x-pack/plugins/ml/public/application/model_management/create_pipeline_for_model/state.ts index 603a542e7964f..586537222c3c5 100644 --- a/x-pack/plugins/ml/public/application/model_management/create_pipeline_for_model/state.ts +++ b/x-pack/plugins/ml/public/application/model_management/create_pipeline_for_model/state.ts @@ -7,8 +7,8 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { IngestInferenceProcessor } from '@elastic/elasticsearch/lib/api/types'; +import type { TrainedModelItem } from '../../../../common/types/trained_models'; import { getDefaultOnFailureConfiguration } from '../../components/ml_inference/state'; -import type { ModelItem } from '../models_list'; export interface InferecePipelineCreationState { creatingPipeline: boolean; @@ -26,7 +26,7 @@ export interface InferecePipelineCreationState { } export const getInitialState = ( - model: ModelItem, + model: TrainedModelItem, initialPipelineConfig: estypes.IngestPipeline | undefined ): InferecePipelineCreationState => ({ creatingPipeline: false, diff --git a/x-pack/plugins/ml/public/application/model_management/create_pipeline_for_model/test_trained_model.tsx b/x-pack/plugins/ml/public/application/model_management/create_pipeline_for_model/test_trained_model.tsx index 46ec8a6060ac5..ba25e3b26f920 100644 --- a/x-pack/plugins/ml/public/application/model_management/create_pipeline_for_model/test_trained_model.tsx +++ b/x-pack/plugins/ml/public/application/model_management/create_pipeline_for_model/test_trained_model.tsx @@ -12,13 +12,13 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import type { ModelItem } from '../models_list'; +import type { TrainedModelItem } from '../../../../common/types/trained_models'; import { TestTrainedModelContent } from '../test_models/test_trained_model_content'; import { useMlKibana } from '../../contexts/kibana'; import { type InferecePipelineCreationState } from './state'; interface ContentProps { - model: ModelItem; + model: TrainedModelItem; handlePipelineConfigUpdate: (configUpdate: Partial) => void; externalPipelineConfig?: estypes.IngestPipeline; } diff --git a/x-pack/plugins/ml/public/application/model_management/delete_models_modal.tsx b/x-pack/plugins/ml/public/application/model_management/delete_models_modal.tsx index 0f5c515c22776..7afad711521dc 100644 --- a/x-pack/plugins/ml/public/application/model_management/delete_models_modal.tsx +++ b/x-pack/plugins/ml/public/application/model_management/delete_models_modal.tsx @@ -22,14 +22,15 @@ import { EuiSpacer, } from '@elastic/eui'; import { isPopulatedObject } from '@kbn/ml-is-populated-object'; +import type { TrainedModelItem, TrainedModelUIItem } from '../../../common/types/trained_models'; +import { isExistingModel } from '../../../common/types/trained_models'; import { type WithRequired } from '../../../common/types/common'; import { useTrainedModelsApiService } from '../services/ml_api_service/trained_models'; import { useToastNotificationService } from '../services/toast_notification_service'; import { DeleteSpaceAwareItemCheckModal } from '../components/delete_space_aware_item_check_modal'; -import { type ModelItem } from './models_list'; interface DeleteModelsModalProps { - models: ModelItem[]; + models: TrainedModelUIItem[]; onClose: (refreshList?: boolean) => void; } @@ -42,11 +43,14 @@ export const DeleteModelsModal: FC = ({ models, onClose const modelIds = models.map((m) => m.model_id); - const modelsWithPipelines = models.filter((m) => isPopulatedObject(m.pipelines)) as Array< - WithRequired - >; + const modelsWithPipelines = models.filter( + (m): m is WithRequired => + isExistingModel(m) && isPopulatedObject(m.pipelines) + ); - const modelsWithInferenceAPIs = models.filter((m) => m.hasInferenceServices); + const modelsWithInferenceAPIs = models.filter( + (m): m is TrainedModelItem => isExistingModel(m) && !!m.hasInferenceServices + ); const inferenceAPIsIDs: string[] = modelsWithInferenceAPIs.flatMap((model) => { return (model.inference_apis ?? []).map((inference) => inference.inference_id); diff --git a/x-pack/plugins/ml/public/application/model_management/deployment_setup.tsx b/x-pack/plugins/ml/public/application/model_management/deployment_setup.tsx index 87fff2bf3eb75..c5b38feb4c799 100644 --- a/x-pack/plugins/ml/public/application/model_management/deployment_setup.tsx +++ b/x-pack/plugins/ml/public/application/model_management/deployment_setup.tsx @@ -42,9 +42,11 @@ import { css } from '@emotion/react'; import { toMountPoint } from '@kbn/react-kibana-mount'; import { dictionaryValidator } from '@kbn/ml-validators'; import type { NLPSettings } from '../../../common/constants/app'; -import type { TrainedModelDeploymentStatsResponse } from '../../../common/types/trained_models'; +import type { + NLPModelItem, + TrainedModelDeploymentStatsResponse, +} from '../../../common/types/trained_models'; import { type CloudInfo, getNewJobLimits } from '../services/ml_server_info'; -import type { ModelItem } from './models_list'; import type { MlStartTrainedModelDeploymentRequestNew } from './deployment_params_mapper'; import { DeploymentParamsMapper } from './deployment_params_mapper'; @@ -645,7 +647,7 @@ export const DeploymentSetup: FC = ({ }; interface StartDeploymentModalProps { - model: ModelItem; + model: NLPModelItem; startModelDeploymentDocUrl: string; onConfigChange: (config: DeploymentParamsUI) => void; onClose: () => void; @@ -845,7 +847,7 @@ export const getUserInputModelDeploymentParamsProvider = nlpSettings: NLPSettings ) => ( - model: ModelItem, + model: NLPModelItem, initialParams?: TrainedModelDeploymentStatsResponse, deploymentIds?: string[] ): Promise => { diff --git a/x-pack/plugins/ml/public/application/model_management/expanded_row.tsx b/x-pack/plugins/ml/public/application/model_management/expanded_row.tsx index eaec5776c3b81..4fc5fde65948a 100644 --- a/x-pack/plugins/ml/public/application/model_management/expanded_row.tsx +++ b/x-pack/plugins/ml/public/application/model_management/expanded_row.tsx @@ -26,18 +26,23 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { FIELD_FORMAT_IDS } from '@kbn/field-formats-plugin/common'; import { isPopulatedObject } from '@kbn/ml-is-populated-object'; import { isDefined } from '@kbn/ml-is-defined'; -import { TRAINED_MODEL_TYPE } from '@kbn/ml-trained-models-utils'; +import { MODEL_STATE, TRAINED_MODEL_TYPE } from '@kbn/ml-trained-models-utils'; import { dynamic } from '@kbn/shared-ux-utility'; import { InferenceApi } from './inference_api_tab'; -import type { ModelItemFull } from './models_list'; import { ModelPipelines } from './pipelines'; import { AllocatedModels } from '../memory_usage/nodes_overview/allocated_models'; -import type { AllocatedModel, TrainedModelStat } from '../../../common/types/trained_models'; +import type { + AllocatedModel, + NLPModelItem, + TrainedModelItem, + TrainedModelStat, +} from '../../../common/types/trained_models'; import { useFieldFormatter } from '../contexts/kibana/use_field_formatter'; import { useEnabledFeatures } from '../contexts/ml'; +import { isNLPModelItem } from '../../../common/types/trained_models'; interface ExpandedRowProps { - item: ModelItemFull; + item: TrainedModelItem; } const JobMap = dynamic(async () => ({ @@ -169,8 +174,14 @@ export const ExpandedRow: FC = ({ item }) => { license_level, ]); + const hideColumns = useMemo(() => { + return showNodeInfo ? ['model_id'] : ['model_id', 'node_name']; + }, [showNodeInfo]); + const deploymentStatItems = useMemo(() => { - const deploymentStats = stats.deployment_stats; + if (!isNLPModelItem(item)) return []; + + const deploymentStats = (stats as NLPModelItem['stats'])!.deployment_stats; const modelSizeStats = stats.model_size_stats; if (!deploymentStats || !modelSizeStats) return []; @@ -227,11 +238,7 @@ export const ExpandedRow: FC = ({ item }) => { }; }); }); - }, [stats]); - - const hideColumns = useMemo(() => { - return showNodeInfo ? ['model_id'] : ['model_id', 'node_name']; - }, [showNodeInfo]); + }, [stats, item]); const tabs = useMemo(() => { return [ @@ -319,9 +326,7 @@ export const ExpandedRow: FC = ({ item }) => {
@@ -528,7 +533,9 @@ export const ExpandedRow: FC = ({ item }) => { ]); const initialSelectedTab = - item.state === 'started' ? tabs.find((t) => t.id === 'stats') : tabs[0]; + isNLPModelItem(item) && item.state === MODEL_STATE.STARTED + ? tabs.find((t) => t.id === 'stats') + : tabs[0]; return ( void; onConfirm: (deploymentIds: string[]) => void; } @@ -220,7 +220,7 @@ export const StopModelDeploymentsConfirmDialog: FC) => - async (forceStopModel: ModelItem): Promise => { + async (forceStopModel: NLPModelItem): Promise => { return new Promise(async (resolve, reject) => { try { const modalSession = overlays.openModal( diff --git a/x-pack/plugins/ml/public/application/model_management/get_model_state.tsx b/x-pack/plugins/ml/public/application/model_management/get_model_state.tsx index d8bf2b8084a6a..75f8f9faa7a91 100644 --- a/x-pack/plugins/ml/public/application/model_management/get_model_state.tsx +++ b/x-pack/plugins/ml/public/application/model_management/get_model_state.tsx @@ -5,40 +5,18 @@ * 2.0. */ -import React from 'react'; -import { DEPLOYMENT_STATE, MODEL_STATE, type ModelState } from '@kbn/ml-trained-models-utils'; import { EuiBadge, - EuiHealth, - EuiLoadingSpinner, - type EuiHealthProps, EuiFlexGroup, EuiFlexItem, + EuiHealth, + EuiLoadingSpinner, EuiText, + type EuiHealthProps, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import type { ModelItem } from './models_list'; - -/** - * Resolves result model state based on the state of each deployment. - * - * If at least one deployment is in the STARTED state, the model state is STARTED. - * Then if none of the deployments are in the STARTED state, but at least one is in the STARTING state, the model state is STARTING. - * If all deployments are in the STOPPING state, the model state is STOPPING. - */ -export const getModelDeploymentState = (model: ModelItem): ModelState | undefined => { - if (!model.stats?.deployment_stats?.length) return; - - if (model.stats?.deployment_stats?.some((v) => v.state === DEPLOYMENT_STATE.STARTED)) { - return MODEL_STATE.STARTED; - } - if (model.stats?.deployment_stats?.some((v) => v.state === DEPLOYMENT_STATE.STARTING)) { - return MODEL_STATE.STARTING; - } - if (model.stats?.deployment_stats?.every((v) => v.state === DEPLOYMENT_STATE.STOPPING)) { - return MODEL_STATE.STOPPING; - } -}; +import { MODEL_STATE, type ModelState } from '@kbn/ml-trained-models-utils'; +import React from 'react'; export const getModelStateColor = ( state: ModelState | undefined diff --git a/x-pack/plugins/ml/public/application/model_management/inference_api_tab.tsx b/x-pack/plugins/ml/public/application/model_management/inference_api_tab.tsx index dc86c359bb1aa..3f55871a93e44 100644 --- a/x-pack/plugins/ml/public/application/model_management/inference_api_tab.tsx +++ b/x-pack/plugins/ml/public/application/model_management/inference_api_tab.tsx @@ -16,10 +16,10 @@ import { EuiTitle, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import type { InferenceAPIConfigResponse } from '@kbn/ml-trained-models-utils'; +import type { InferenceInferenceEndpointInfo } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; export interface InferenceAPITabProps { - inferenceApis: InferenceAPIConfigResponse[]; + inferenceApis: InferenceInferenceEndpointInfo[]; } export const InferenceApi: FC = ({ inferenceApis }) => { diff --git a/x-pack/plugins/ml/public/application/model_management/model_actions.tsx b/x-pack/plugins/ml/public/application/model_management/model_actions.tsx index 133698b0e72f1..1fe008871b0ef 100644 --- a/x-pack/plugins/ml/public/application/model_management/model_actions.tsx +++ b/x-pack/plugins/ml/public/application/model_management/model_actions.tsx @@ -8,18 +8,28 @@ import type { Action } from '@elastic/eui/src/components/basic_table/action_types'; import { i18n } from '@kbn/i18n'; import { isPopulatedObject } from '@kbn/ml-is-populated-object'; -import { EuiToolTip, useIsWithinMaxBreakpoint } from '@elastic/eui'; -import React, { useCallback, useMemo, useEffect, useState } from 'react'; -import { - BUILT_IN_MODEL_TAG, - DEPLOYMENT_STATE, - TRAINED_MODEL_TYPE, -} from '@kbn/ml-trained-models-utils'; +import { useIsWithinMaxBreakpoint } from '@elastic/eui'; +import React, { useMemo, useEffect, useState } from 'react'; +import { DEPLOYMENT_STATE } from '@kbn/ml-trained-models-utils'; import { MODEL_STATE } from '@kbn/ml-trained-models-utils/src/constants/trained_models'; import { getAnalysisType, type DataFrameAnalysisConfigType, } from '@kbn/ml-data-frame-analytics-utils'; +import useMountedState from 'react-use/lib/useMountedState'; +import type { + DFAModelItem, + NLPModelItem, + TrainedModelItem, + TrainedModelUIItem, +} from '../../../common/types/trained_models'; +import { + isBuiltInModel, + isDFAModelItem, + isExistingModel, + isModelDownloadItem, + isNLPModelItem, +} from '../../../common/types/trained_models'; import { useEnabledFeatures, useMlServerInfo } from '../contexts/ml'; import { useTrainedModelsApiService } from '../services/ml_api_service/trained_models'; import { getUserConfirmationProvider } from './force_stop_dialog'; @@ -27,8 +37,7 @@ import { useToastNotificationService } from '../services/toast_notification_serv import { getUserInputModelDeploymentParamsProvider } from './deployment_setup'; import { useMlKibana, useMlLocator, useNavigateToPath } from '../contexts/kibana'; import { ML_PAGES } from '../../../common/constants/locator'; -import { isTestable, isDfaTrainedModel } from './test_models'; -import type { ModelItem } from './models_list'; +import { isTestable } from './test_models'; import { usePermissionCheck } from '../capabilities/check_capabilities'; import { useCloudCheck } from '../components/node_available_warning/hooks'; @@ -44,16 +53,17 @@ export function useModelActions({ onModelDownloadRequest, }: { isLoading: boolean; - onDfaTestAction: (model: ModelItem) => void; - onTestAction: (model: ModelItem) => void; - onModelsDeleteRequest: (models: ModelItem[]) => void; - onModelDeployRequest: (model: ModelItem) => void; + onDfaTestAction: (model: DFAModelItem) => void; + onTestAction: (model: TrainedModelItem) => void; + onModelsDeleteRequest: (models: TrainedModelUIItem[]) => void; + onModelDeployRequest: (model: DFAModelItem) => void; onModelDownloadRequest: (modelId: string) => void; onLoading: (isLoading: boolean) => void; fetchModels: () => Promise; modelAndDeploymentIds: string[]; -}): Array> { +}): Array> { const isMobileLayout = useIsWithinMaxBreakpoint('l'); + const isMounted = useMountedState(); const { services: { @@ -95,23 +105,19 @@ export function useModelActions({ const trainedModelsApiService = useTrainedModelsApiService(); useEffect(() => { - let isMounted = true; mlApi .hasPrivileges({ cluster: ['manage_ingest_pipelines'], }) .then((result) => { - if (isMounted) { + if (isMounted()) { setCanManageIngestPipelines( result.hasPrivileges === undefined || result.hasPrivileges.cluster?.manage_ingest_pipelines === true ); } }); - return () => { - isMounted = false; - }; - }, [mlApi]); + }, [mlApi, isMounted]); const getUserConfirmation = useMemo( () => getUserConfirmationProvider(overlays, startServices), @@ -131,12 +137,7 @@ export function useModelActions({ [overlays, startServices, startModelDeploymentDocUrl, cloudInfo, showNodeInfo, nlpSettings] ); - const isBuiltInModel = useCallback( - (item: ModelItem) => item.tags.includes(BUILT_IN_MODEL_TAG), - [] - ); - - return useMemo>>( + return useMemo>>( () => [ { name: i18n.translate('xpack.ml.trainedModels.modelsList.viewTrainingDataNameActionLabel', { @@ -150,10 +151,10 @@ export function useModelActions({ ), icon: 'visTable', type: 'icon', - available: (item) => !!item.metadata?.analytics_config?.id, - enabled: (item) => item.origin_job_exists === true, + available: (item) => isDFAModelItem(item) && !!item.metadata?.analytics_config?.id, + enabled: (item) => isDFAModelItem(item) && item.origin_job_exists === true, onClick: async (item) => { - if (item.metadata?.analytics_config === undefined) return; + if (!isDFAModelItem(item) || item.metadata?.analytics_config === undefined) return; const analysisType = getAnalysisType( item.metadata?.analytics_config.analysis @@ -185,7 +186,7 @@ export function useModelActions({ icon: 'graphApp', type: 'icon', isPrimary: true, - available: (item) => !!item.metadata?.analytics_config?.id, + available: (item) => isDFAModelItem(item) && !!item.metadata?.analytics_config?.id, onClick: async (item) => { const path = await urlLocator.getUrl({ page: ML_PAGES.DATA_FRAME_ANALYTICS_MAP, @@ -216,15 +217,14 @@ export function useModelActions({ }, available: (item) => { return ( - item.model_type === TRAINED_MODEL_TYPE.PYTORCH && - !!item.state && + isNLPModelItem(item) && item.state !== MODEL_STATE.DOWNLOADING && item.state !== MODEL_STATE.NOT_DOWNLOADED ); }, onClick: async (item) => { const modelDeploymentParams = await getUserInputModelDeploymentParams( - item, + item as NLPModelItem, undefined, modelAndDeploymentIds ); @@ -277,11 +277,13 @@ export function useModelActions({ type: 'icon', isPrimary: false, available: (item) => - item.model_type === TRAINED_MODEL_TYPE.PYTORCH && + isNLPModelItem(item) && canStartStopTrainedModels && !isLoading && !!item.stats?.deployment_stats?.some((v) => v.state === DEPLOYMENT_STATE.STARTED), onClick: async (item) => { + if (!isNLPModelItem(item)) return; + const deploymentIdToUpdate = item.deployment_ids[0]; const targetDeployment = item.stats!.deployment_stats.find( @@ -345,7 +347,7 @@ export function useModelActions({ type: 'icon', isPrimary: false, available: (item) => - item.model_type === TRAINED_MODEL_TYPE.PYTORCH && + isNLPModelItem(item) && canStartStopTrainedModels && // Deployment can be either started, starting, or exist in a failed state (item.state === MODEL_STATE.STARTED || item.state === MODEL_STATE.STARTING) && @@ -358,6 +360,8 @@ export function useModelActions({ )), enabled: (item) => !isLoading, onClick: async (item) => { + if (!isNLPModelItem(item)) return; + const requireForceStop = isPopulatedObject(item.pipelines); const hasMultipleDeployments = item.deployment_ids.length > 1; @@ -423,7 +427,10 @@ export function useModelActions({ // @ts-ignore type: isMobileLayout ? 'icon' : 'button', isPrimary: true, - available: (item) => canCreateTrainedModels && item.state === MODEL_STATE.NOT_DOWNLOADED, + available: (item) => + canCreateTrainedModels && + isModelDownloadItem(item) && + item.state === MODEL_STATE.NOT_DOWNLOADED, enabled: (item) => !isLoading, onClick: async (item) => { onModelDownloadRequest(item.model_id); @@ -431,28 +438,9 @@ export function useModelActions({ }, { name: (model) => { - const hasDeployments = model.state === MODEL_STATE.STARTED; - return ( - - <> - {i18n.translate('xpack.ml.trainedModels.modelsList.deployModelActionLabel', { - defaultMessage: 'Deploy model', - })} - - - ); + return i18n.translate('xpack.ml.trainedModels.modelsList.deployModelActionLabel', { + defaultMessage: 'Deploy model', + }); }, description: i18n.translate('xpack.ml.trainedModels.modelsList.deployModelActionLabel', { defaultMessage: 'Deploy model', @@ -462,23 +450,18 @@ export function useModelActions({ type: 'icon', isPrimary: false, onClick: (model) => { - onModelDeployRequest(model); + onModelDeployRequest(model as DFAModelItem); }, available: (item) => { - return ( - isDfaTrainedModel(item) && - !isBuiltInModel(item) && - !item.putModelConfig && - canManageIngestPipelines - ); + return isDFAModelItem(item) && canManageIngestPipelines; }, enabled: (item) => { - return canStartStopTrainedModels && item.state !== MODEL_STATE.STARTED; + return canStartStopTrainedModels; }, }, { name: (model) => { - return model.state === MODEL_STATE.DOWNLOADING ? ( + return isModelDownloadItem(model) && model.state === MODEL_STATE.DOWNLOADING ? ( <> {i18n.translate('xpack.ml.trainedModels.modelsList.deleteModelActionLabel', { defaultMessage: 'Cancel', @@ -492,33 +475,33 @@ export function useModelActions({ ); }, - description: (model: ModelItem) => { - const hasDeployments = model.deployment_ids.length > 0; - const { hasInferenceServices } = model; - - if (model.state === MODEL_STATE.DOWNLOADING) { + description: (model: TrainedModelUIItem) => { + if (isModelDownloadItem(model) && model.state === MODEL_STATE.DOWNLOADING) { return i18n.translate('xpack.ml.trainedModels.modelsList.cancelDownloadActionLabel', { defaultMessage: 'Cancel download', }); - } else if (hasInferenceServices) { - return i18n.translate( - 'xpack.ml.trainedModels.modelsList.deleteDisabledWithInferenceServicesTooltip', - { - defaultMessage: 'Model is used by the _inference API', - } - ); - } else if (hasDeployments) { - return i18n.translate( - 'xpack.ml.trainedModels.modelsList.deleteDisabledWithDeploymentsTooltip', - { - defaultMessage: 'Model has started deployments', - } - ); - } else { - return i18n.translate('xpack.ml.trainedModels.modelsList.deleteModelActionLabel', { - defaultMessage: 'Delete model', - }); + } else if (isNLPModelItem(model)) { + const hasDeployments = model.deployment_ids?.length ?? 0 > 0; + const { hasInferenceServices } = model; + if (hasInferenceServices) { + return i18n.translate( + 'xpack.ml.trainedModels.modelsList.deleteDisabledWithInferenceServicesTooltip', + { + defaultMessage: 'Model is used by the _inference API', + } + ); + } else if (hasDeployments) { + return i18n.translate( + 'xpack.ml.trainedModels.modelsList.deleteDisabledWithDeploymentsTooltip', + { + defaultMessage: 'Model has started deployments', + } + ); + } } + return i18n.translate('xpack.ml.trainedModels.modelsList.deleteModelActionLabel', { + defaultMessage: 'Delete model', + }); }, 'data-test-subj': 'mlModelsTableRowDeleteAction', icon: 'trash', @@ -530,16 +513,17 @@ export function useModelActions({ onModelsDeleteRequest([model]); }, available: (item) => { - const hasZeroPipelines = Object.keys(item.pipelines ?? {}).length === 0; - return ( - canDeleteTrainedModels && - !isBuiltInModel(item) && - !item.putModelConfig && - (hasZeroPipelines || canManageIngestPipelines) - ); + if (!canDeleteTrainedModels || isBuiltInModel(item)) return false; + + if (isModelDownloadItem(item)) { + return !!item.downloadState; + } else { + const hasZeroPipelines = Object.keys(item.pipelines ?? {}).length === 0; + return hasZeroPipelines || canManageIngestPipelines; + } }, enabled: (item) => { - return item.state !== MODEL_STATE.STARTED; + return !isNLPModelItem(item) || item.state !== MODEL_STATE.STARTED; }, }, { @@ -556,9 +540,9 @@ export function useModelActions({ isPrimary: true, available: (item) => isTestable(item, true), onClick: (item) => { - if (isDfaTrainedModel(item) && !isBuiltInModel(item)) { + if (isDFAModelItem(item)) { onDfaTestAction(item); - } else { + } else if (isExistingModel(item)) { onTestAction(item); } }, @@ -579,19 +563,20 @@ export function useModelActions({ isPrimary: true, available: (item) => { return ( - item?.metadata?.analytics_config !== undefined || - (Array.isArray(item.indices) && item.indices.length > 0) + isDFAModelItem(item) || + (isExistingModel(item) && Array.isArray(item.indices) && item.indices.length > 0) ); }, onClick: async (item) => { - let indexPatterns: string[] | undefined = item?.indices - ?.map((o) => Object.keys(o)) - .flat(); + if (!isDFAModelItem(item) || !isExistingModel(item)) return; - if (item?.metadata?.analytics_config?.dest?.index !== undefined) { + let indexPatterns: string[] | undefined = item.indices; + + if (isDFAModelItem(item) && item?.metadata?.analytics_config?.dest?.index !== undefined) { const destIndex = item.metadata.analytics_config.dest?.index; indexPatterns = [destIndex]; } + const path = await urlLocator.getUrl({ page: ML_PAGES.DATA_DRIFT_CUSTOM, pageState: indexPatterns ? { comparison: indexPatterns.join(',') } : {}, @@ -612,7 +597,6 @@ export function useModelActions({ fetchModels, getUserConfirmation, getUserInputModelDeploymentParams, - isBuiltInModel, isLoading, modelAndDeploymentIds, navigateToPath, diff --git a/x-pack/plugins/ml/public/application/model_management/models_list.tsx b/x-pack/plugins/ml/public/application/model_management/models_list.tsx index d66ab1ab3db16..9547e7c6473bd 100644 --- a/x-pack/plugins/ml/public/application/model_management/models_list.tsx +++ b/x-pack/plugins/ml/public/application/model_management/models_list.tsx @@ -29,33 +29,29 @@ import type { EuiTableSelectionType } from '@elastic/eui/src/components/basic_ta import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { useTimefilter } from '@kbn/ml-date-picker'; -import { isDefined } from '@kbn/ml-is-defined'; import { isPopulatedObject } from '@kbn/ml-is-populated-object'; import { useStorage } from '@kbn/ml-local-storage'; -import { - BUILT_IN_MODEL_TAG, - BUILT_IN_MODEL_TYPE, - ELASTIC_MODEL_TAG, - ELASTIC_MODEL_TYPE, - ELSER_ID_V1, - MODEL_STATE, - type ModelState, -} from '@kbn/ml-trained-models-utils'; +import { ELSER_ID_V1, MODEL_STATE } from '@kbn/ml-trained-models-utils'; import type { ListingPageUrlState } from '@kbn/ml-url-state'; import { usePageUrlState } from '@kbn/ml-url-state'; import { dynamic } from '@kbn/shared-ux-utility'; -import { cloneDeep, groupBy, isEmpty, memoize } from 'lodash'; +import { cloneDeep, isEmpty } from 'lodash'; import type { FC } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import useMountedState from 'react-use/lib/useMountedState'; import { ML_PAGES } from '../../../common/constants/locator'; import { ML_ELSER_CALLOUT_DISMISSED } from '../../../common/types/storage'; import type { - ModelDownloadState, - ModelPipelines, - TrainedModelConfigResponse, - TrainedModelDeploymentStatsResponse, - TrainedModelStat, + DFAModelItem, + NLPModelItem, + TrainedModelItem, + TrainedModelUIItem, +} from '../../../common/types/trained_models'; +import { + isBaseNLPModelItem, + isBuiltInModel, + isModelDownloadItem, + isNLPModelItem, } from '../../../common/types/trained_models'; import { AddInferencePipelineFlyout } from '../components/ml_inference'; import { SavedObjectsWarning } from '../components/saved_objects_warning'; @@ -70,41 +66,11 @@ import { useTrainedModelsApiService } from '../services/ml_api_service/trained_m import { useToastNotificationService } from '../services/toast_notification_service'; import { ModelsTableToConfigMapping } from './config_mapping'; import { DeleteModelsModal } from './delete_models_modal'; -import { getModelDeploymentState, getModelStateColor } from './get_model_state'; +import { getModelStateColor } from './get_model_state'; import { useModelActions } from './model_actions'; import { TestDfaModelsFlyout } from './test_dfa_models_flyout'; import { TestModelAndPipelineCreationFlyout } from './test_models'; -type Stats = Omit; - -export type ModelItem = TrainedModelConfigResponse & { - type?: string[]; - stats?: Stats & { deployment_stats: TrainedModelDeploymentStatsResponse[] }; - pipelines?: ModelPipelines['pipelines'] | null; - origin_job_exists?: boolean; - deployment_ids: string[]; - putModelConfig?: object; - state: ModelState | undefined; - /** - * Description of the current model state - */ - stateDescription?: string; - recommended?: boolean; - supported: boolean; - /** - * Model name, e.g. elser - */ - modelName?: string; - os?: string; - arch?: string; - softwareLicense?: string; - licenseUrl?: string; - downloadState?: ModelDownloadState; - disclaimer?: string; -}; - -export type ModelItemFull = Required; - interface PageUrlState { pageKey: typeof ML_PAGES.TRAINED_MODELS_MANAGE; pageUrlState: ListingPageUrlState; @@ -185,120 +151,29 @@ export const ModelsList: FC = ({ const [isInitialized, setIsInitialized] = useState(false); const [isLoading, setIsLoading] = useState(false); - const [items, setItems] = useState([]); - const [selectedModels, setSelectedModels] = useState([]); - const [modelsToDelete, setModelsToDelete] = useState([]); - const [modelToDeploy, setModelToDeploy] = useState(); + const [items, setItems] = useState([]); + const [selectedModels, setSelectedModels] = useState([]); + const [modelsToDelete, setModelsToDelete] = useState([]); + const [modelToDeploy, setModelToDeploy] = useState(); const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState>( {} ); - const [modelToTest, setModelToTest] = useState(null); - const [dfaModelToTest, setDfaModelToTest] = useState(null); + const [modelToTest, setModelToTest] = useState(null); + const [dfaModelToTest, setDfaModelToTest] = useState(null); const [isAddModelFlyoutVisible, setIsAddModelFlyoutVisible] = useState(false); - const isBuiltInModel = useCallback( - (item: ModelItem) => item.tags.includes(BUILT_IN_MODEL_TAG), - [] - ); - - const isElasticModel = useCallback( - (item: ModelItem) => item.tags.includes(ELASTIC_MODEL_TAG), - [] - ); - // List of downloaded/existing models - const existingModels = useMemo(() => { - return items.filter((i) => !i.putModelConfig); + const existingModels = useMemo>(() => { + return items.filter((i): i is NLPModelItem | DFAModelItem => !isModelDownloadItem(i)); }, [items]); - /** - * Fetch of model definitions available for download needs to happen only once - */ - const getTrainedModelDownloads = memoize(trainedModelsApiService.getTrainedModelDownloads); - /** * Fetches trained models. */ const fetchModelsData = useCallback(async () => { setIsLoading(true); try { - const response = await trainedModelsApiService.getTrainedModels(undefined, { - with_pipelines: true, - with_indices: true, - }); - - const newItems: ModelItem[] = []; - const expandedItemsToRefresh = []; - - for (const model of response) { - const tableItem: ModelItem = { - ...model, - // Extract model types - ...(typeof model.inference_config === 'object' - ? { - type: [ - model.model_type, - ...Object.keys(model.inference_config), - ...(isBuiltInModel(model as ModelItem) ? [BUILT_IN_MODEL_TYPE] : []), - ...(isElasticModel(model as ModelItem) ? [ELASTIC_MODEL_TYPE] : []), - ], - } - : {}), - } as ModelItem; - newItems.push(tableItem); - - if (itemIdToExpandedRowMap[model.model_id]) { - expandedItemsToRefresh.push(tableItem); - } - } - - // Need to fetch stats for all models to enable/disable actions - // TODO combine fetching models definitions and stats into a single function - await fetchModelsStats(newItems); - - let resultItems = newItems; - // don't add any of the built-in models (e.g. elser) if NLP is disabled - if (isNLPEnabled) { - const idMap = new Map( - resultItems.map((model) => [model.model_id, model]) - ); - /** - * Fetches model definitions available for download - */ - const forDownload = await getTrainedModelDownloads(); - - const notDownloaded: ModelItem[] = forDownload - .filter(({ model_id: modelId, hidden, recommended, supported, disclaimer }) => { - if (idMap.has(modelId)) { - const model = idMap.get(modelId)!; - if (recommended) { - model.recommended = true; - } - model.supported = supported; - model.disclaimer = disclaimer; - } - return !idMap.has(modelId) && !hidden; - }) - .map((modelDefinition) => { - return { - model_id: modelDefinition.model_id, - type: modelDefinition.type, - tags: modelDefinition.type?.includes(ELASTIC_MODEL_TAG) ? [ELASTIC_MODEL_TAG] : [], - putModelConfig: modelDefinition.config, - description: modelDefinition.description, - state: MODEL_STATE.NOT_DOWNLOADED, - recommended: !!modelDefinition.recommended, - modelName: modelDefinition.modelName, - os: modelDefinition.os, - arch: modelDefinition.arch, - softwareLicense: modelDefinition.license, - licenseUrl: modelDefinition.licenseUrl, - supported: modelDefinition.supported, - disclaimer: modelDefinition.disclaimer, - } as ModelItem; - }); - resultItems = [...resultItems, ...notDownloaded]; - } + const resultItems = await trainedModelsApiService.getTrainedModelsList(); setItems((prevItems) => { // Need to merge existing items with new items @@ -307,7 +182,7 @@ export const ModelsList: FC = ({ const prevItem = prevItems.find((i) => i.model_id === item.model_id); return { ...item, - ...(prevItem?.state === MODEL_STATE.DOWNLOADING + ...(isBaseNLPModelItem(prevItem) && prevItem?.state === MODEL_STATE.DOWNLOADING ? { state: prevItem.state, downloadState: prevItem.downloadState, @@ -322,7 +197,7 @@ export const ModelsList: FC = ({ return Object.fromEntries( Object.keys(prev).map((modelId) => { const item = resultItems.find((i) => i.model_id === modelId); - return item ? [modelId, ] : []; + return item ? [modelId, ] : []; }) ); }); @@ -365,51 +240,6 @@ export const ModelsList: FC = ({ }; }, [existingModels]); - /** - * Fetches models stats and update the original object - */ - const fetchModelsStats = useCallback(async (models: ModelItem[]) => { - try { - if (models) { - const { trained_model_stats: modelsStatsResponse } = - await trainedModelsApiService.getTrainedModelStats(); - - const groupByModelId = groupBy(modelsStatsResponse, 'model_id'); - - models.forEach((model) => { - const modelStats = groupByModelId[model.model_id]; - model.stats = { - ...(model.stats ?? {}), - ...modelStats[0], - deployment_stats: modelStats.map((d) => d.deployment_stats).filter(isDefined), - }; - - // Extract deployment ids from deployment stats - model.deployment_ids = modelStats - .map((v) => v.deployment_stats?.deployment_id) - .filter(isDefined); - - model.state = getModelDeploymentState(model); - model.stateDescription = model.stats.deployment_stats.reduce((acc, c) => { - if (acc) return acc; - return c.reason ?? ''; - }, ''); - }); - } - - return true; - } catch (error) { - displayErrorToast( - error, - i18n.translate('xpack.ml.trainedModels.modelsList.fetchModelStatsErrorMessage', { - defaultMessage: 'Error loading trained models statistics', - }) - ); - return false; - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - const downLoadStatusFetchInProgress = useRef(false); const abortedDownload = useRef(new Set()); @@ -432,7 +262,7 @@ export const ModelsList: FC = ({ if (isMounted()) { setItems((prevItems) => { return prevItems.map((item) => { - if (!item.type?.includes('pytorch')) { + if (!isBaseNLPModelItem(item)) { return item; } const newItem = cloneDeep(item); @@ -493,7 +323,9 @@ export const ModelsList: FC = ({ if (type) { acc.add(type); } - acc.add(item.model_type); + if (item.model_type) { + acc.add(item.model_type); + } return acc; }, new Set()); return [...result] @@ -504,15 +336,15 @@ export const ModelsList: FC = ({ })); }, [existingModels]); - const modelAndDeploymentIds = useMemo( - () => [ + const modelAndDeploymentIds = useMemo(() => { + const nlpModels = existingModels.filter(isNLPModelItem); + return [ ...new Set([ - ...existingModels.flatMap((v) => v.deployment_ids), - ...existingModels.map((i) => i.model_id), + ...nlpModels.flatMap((v) => v.deployment_ids), + ...nlpModels.map((i) => i.model_id), ]), - ], - [existingModels] - ); + ]; + }, [existingModels]); const onModelDownloadRequest = useCallback( async (modelId: string) => { @@ -550,22 +382,22 @@ export const ModelsList: FC = ({ onModelDownloadRequest, }); - const toggleDetails = async (item: ModelItem) => { + const toggleDetails = async (item: TrainedModelUIItem) => { const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap }; if (itemIdToExpandedRowMapValues[item.model_id]) { delete itemIdToExpandedRowMapValues[item.model_id]; } else { - itemIdToExpandedRowMapValues[item.model_id] = ; + itemIdToExpandedRowMapValues[item.model_id] = ; } setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues); }; - const columns: Array> = [ + const columns: Array> = [ { isExpander: true, align: 'center', - render: (item: ModelItem) => { - if (!item.stats) { + render: (item: TrainedModelUIItem) => { + if (isModelDownloadItem(item) || !item.stats) { return null; } return ( @@ -588,38 +420,38 @@ export const ModelsList: FC = ({ }, { name: modelIdColumnName, - sortable: ({ model_id: modelId }: ModelItem) => modelId, + sortable: ({ model_id: modelId }: TrainedModelUIItem) => modelId, truncateText: false, textOnly: false, 'data-test-subj': 'mlModelsTableColumnId', - render: ({ - description, - model_id: modelId, - recommended, - supported, - type, - disclaimer, - }: ModelItem) => { + render: (item: TrainedModelUIItem) => { + const { description, model_id: modelId, type } = item; + const isTechPreview = description?.includes('(Tech Preview)'); let descriptionText = description?.replace('(Tech Preview)', ''); - if (disclaimer) { - descriptionText += '. ' + disclaimer; - } + let tooltipContent = null; - const tooltipContent = - supported === false ? ( - - ) : recommended === false ? ( - - ) : null; + if (isBaseNLPModelItem(item)) { + const { disclaimer, recommended, supported } = item; + if (disclaimer) { + descriptionText += '. ' + disclaimer; + } + + tooltipContent = + supported === false ? ( + + ) : recommended === false ? ( + + ) : null; + } return ( @@ -675,7 +507,10 @@ export const ModelsList: FC = ({ }), truncateText: false, width: '150px', - render: ({ state, downloadState }: ModelItem) => { + render: (item: TrainedModelUIItem) => { + if (!isBaseNLPModelItem(item)) return null; + + const { state, downloadState } = item; const config = getModelStateColor(state); if (!config) return null; @@ -776,7 +611,7 @@ export const ModelsList: FC = ({ const isSelectionAllowed = canDeleteTrainedModels; - const selection: EuiTableSelectionType | undefined = isSelectionAllowed + const selection: EuiTableSelectionType | undefined = isSelectionAllowed ? { selectableMessage: (selectable, item) => { if (selectable) { @@ -784,31 +619,28 @@ export const ModelsList: FC = ({ defaultMessage: 'Select a model', }); } - if (isPopulatedObject(item.pipelines)) { + // TODO support multiple model downloads with selection + if (!isModelDownloadItem(item) && isPopulatedObject(item.pipelines)) { return i18n.translate('xpack.ml.trainedModels.modelsList.disableSelectableMessage', { defaultMessage: 'Model has associated pipelines', }); } - if (isBuiltInModel(item)) { return i18n.translate('xpack.ml.trainedModels.modelsList.builtInModelMessage', { defaultMessage: 'Built-in model', }); } - return ''; }, selectable: (item) => - !isPopulatedObject(item.pipelines) && - !isBuiltInModel(item) && - !(isElasticModel(item) && !item.state), + !isModelDownloadItem(item) && !isPopulatedObject(item.pipelines) && !isBuiltInModel(item), onSelectionChange: (selectedItems) => { setSelectedModels(selectedItems); }, } : undefined; - const { onTableChange, pagination, sorting } = useTableSettings( + const { onTableChange, pagination, sorting } = useTableSettings( items.length, pageState, updatePageState, @@ -847,7 +679,7 @@ export const ModelsList: FC = ({ return items; } else { // by default show only deployed models or recommended for download - return items.filter((item) => item.create_time || item.recommended); + return items.filter((item) => !isModelDownloadItem(item) || item.recommended); } }, [items, pageState.showAll]); @@ -896,7 +728,7 @@ export const ModelsList: FC = ({
- + tableLayout={'auto'} responsiveBreakpoint={'xl'} allowNeutralSort={false} @@ -952,7 +784,7 @@ export const ModelsList: FC = ({ { modelsToDelete.forEach((model) => { - if (model.state === MODEL_STATE.DOWNLOADING) { + if (isBaseNLPModelItem(model) && model.state === MODEL_STATE.DOWNLOADING) { abortedDownload.current.add(model.model_id); } }); @@ -996,7 +828,7 @@ export const ModelsList: FC = ({ ) : null} {isAddModelFlyoutVisible ? ( i.state === MODEL_STATE.NOT_DOWNLOADED)} + modelDownloads={items.filter(isModelDownloadItem)} onClose={setIsAddModelFlyoutVisible.bind(null, false)} onSubmit={(modelId) => { onModelDownloadRequest(modelId); diff --git a/x-pack/plugins/ml/public/application/model_management/pipelines/pipelines.tsx b/x-pack/plugins/ml/public/application/model_management/pipelines/pipelines.tsx index d144bf2aaf558..384a0736ed6f2 100644 --- a/x-pack/plugins/ml/public/application/model_management/pipelines/pipelines.tsx +++ b/x-pack/plugins/ml/public/application/model_management/pipelines/pipelines.tsx @@ -17,14 +17,14 @@ import { EuiAccordion, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import type { TrainedModelItem } from '../../../../common/types/trained_models'; import { useMlKibana } from '../../contexts/kibana'; -import type { ModelItem } from '../models_list'; import { ProcessorsStats } from './expanded_row'; -export type IngestStatsResponse = Exclude['ingest']; +export type IngestStatsResponse = Exclude['ingest']; interface ModelPipelinesProps { - pipelines: ModelItem['pipelines']; + pipelines: TrainedModelItem['pipelines']; ingestStats: IngestStatsResponse; } diff --git a/x-pack/plugins/ml/public/application/model_management/test_dfa_models_flyout.tsx b/x-pack/plugins/ml/public/application/model_management/test_dfa_models_flyout.tsx index 86ddd16e620ad..4593413154bd5 100644 --- a/x-pack/plugins/ml/public/application/model_management/test_dfa_models_flyout.tsx +++ b/x-pack/plugins/ml/public/application/model_management/test_dfa_models_flyout.tsx @@ -9,14 +9,13 @@ import type { FC } from 'react'; import React, { useMemo } from 'react'; import { EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, EuiSpacer, EuiTitle } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; - +import type { DFAModelItem } from '../../../common/types/trained_models'; import { TestPipeline } from '../components/ml_inference/components/test_pipeline'; import { getInitialState } from '../components/ml_inference/state'; -import type { ModelItem } from './models_list'; import { TEST_PIPELINE_MODE } from '../components/ml_inference/types'; interface Props { - model: ModelItem; + model: DFAModelItem; onClose: () => void; } diff --git a/x-pack/plugins/ml/public/application/model_management/test_models/index.ts b/x-pack/plugins/ml/public/application/model_management/test_models/index.ts index 4b238f477092e..209704581f489 100644 --- a/x-pack/plugins/ml/public/application/model_management/test_models/index.ts +++ b/x-pack/plugins/ml/public/application/model_management/test_models/index.ts @@ -6,4 +6,4 @@ */ export { TestModelAndPipelineCreationFlyout } from './test_model_and_pipeline_creation_flyout'; -export { isTestable, isDfaTrainedModel } from './utils'; +export { isTestable } from './utils'; diff --git a/x-pack/plugins/ml/public/application/model_management/test_models/test_flyout.tsx b/x-pack/plugins/ml/public/application/model_management/test_models/test_flyout.tsx index b8bc2d706b8c0..3b8d3cc7bdea9 100644 --- a/x-pack/plugins/ml/public/application/model_management/test_models/test_flyout.tsx +++ b/x-pack/plugins/ml/public/application/model_management/test_models/test_flyout.tsx @@ -7,15 +7,13 @@ import type { FC } from 'react'; import React from 'react'; - import { FormattedMessage } from '@kbn/i18n-react'; import { EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, EuiSpacer, EuiTitle } from '@elastic/eui'; - -import { type ModelItem } from '../models_list'; +import type { TrainedModelItem } from '../../../../common/types/trained_models'; import { TestTrainedModelContent } from './test_trained_model_content'; interface Props { - model: ModelItem; + model: TrainedModelItem; onClose: () => void; } export const TestTrainedModelFlyout: FC = ({ model, onClose }) => ( diff --git a/x-pack/plugins/ml/public/application/model_management/test_models/test_model_and_pipeline_creation_flyout.tsx b/x-pack/plugins/ml/public/application/model_management/test_models/test_model_and_pipeline_creation_flyout.tsx index 240c2545f3d8e..f78f12cf88211 100644 --- a/x-pack/plugins/ml/public/application/model_management/test_models/test_model_and_pipeline_creation_flyout.tsx +++ b/x-pack/plugins/ml/public/application/model_management/test_models/test_model_and_pipeline_creation_flyout.tsx @@ -7,17 +7,16 @@ import type { FC } from 'react'; import React, { useState } from 'react'; - +import type { TrainedModelItem } from '../../../../common/types/trained_models'; import { type TestTrainedModelsContextType, TestTrainedModelsContext, } from './test_trained_models_context'; -import type { ModelItem } from '../models_list'; import { TestTrainedModelFlyout } from './test_flyout'; import { CreatePipelineForModelFlyout } from '../create_pipeline_for_model/create_pipeline_for_model_flyout'; interface Props { - model: ModelItem; + model: TrainedModelItem; onClose: (refreshList?: boolean) => void; } export const TestModelAndPipelineCreationFlyout: FC = ({ model, onClose }) => { diff --git a/x-pack/plugins/ml/public/application/model_management/test_models/test_trained_model_content.tsx b/x-pack/plugins/ml/public/application/model_management/test_models/test_trained_model_content.tsx index da4c496700687..3c829c8f7cd49 100644 --- a/x-pack/plugins/ml/public/application/model_management/test_models/test_trained_model_content.tsx +++ b/x-pack/plugins/ml/public/application/model_management/test_models/test_trained_model_content.tsx @@ -12,14 +12,15 @@ import { SUPPORTED_PYTORCH_TASKS } from '@kbn/ml-trained-models-utils'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiFormRow, EuiSelect, EuiSpacer, EuiTab, EuiTabs, useEuiPaddingSize } from '@elastic/eui'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { TrainedModelItem } from '../../../../common/types/trained_models'; +import { isNLPModelItem } from '../../../../common/types/trained_models'; import { SelectedModel } from './selected_model'; -import { type ModelItem } from '../models_list'; import { INPUT_TYPE } from './models/inference_base'; import { useTestTrainedModelsContext } from './test_trained_models_context'; import { type InferecePipelineCreationState } from '../create_pipeline_for_model/state'; interface ContentProps { - model: ModelItem; + model: TrainedModelItem; handlePipelineConfigUpdate?: (configUpdate: Partial) => void; externalPipelineConfig?: estypes.IngestPipeline; } @@ -29,7 +30,9 @@ export const TestTrainedModelContent: FC = ({ handlePipelineConfigUpdate, externalPipelineConfig, }) => { - const [deploymentId, setDeploymentId] = useState(model.deployment_ids[0]); + const [deploymentId, setDeploymentId] = useState( + isNLPModelItem(model) ? model.deployment_ids[0] : model.model_id + ); const mediumPadding = useEuiPaddingSize('m'); const [inputType, setInputType] = useState(INPUT_TYPE.TEXT); @@ -46,8 +49,7 @@ export const TestTrainedModelContent: FC = ({ }, [model, createPipelineFlyoutOpen]); return ( <> - {' '} - {model.deployment_ids.length > 1 ? ( + {isNLPModelItem(model) && model.deployment_ids.length > 1 ? ( <> ({ + path: `${ML_INTERNAL_BASE_PATH}/trained_models_list`, + method: 'GET', + version: '1', + }); + }, + /** * Fetches usage information for trained inference models. * @param modelId - Model ID, collection of Model IDs or Model ID pattern. diff --git a/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts b/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts index e720f12fa4dd5..04a14c7f235ff 100644 --- a/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts +++ b/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts @@ -51,7 +51,12 @@ export class AnalyticsManager { private readonly _enabledFeatures: MlFeatures, cloud: CloudSetup ) { - this._modelsProvider = modelsProvider(this._client, this._mlClient, cloud); + this._modelsProvider = modelsProvider( + this._client, + this._mlClient, + cloud, + this._enabledFeatures + ); } private async initData() { diff --git a/x-pack/plugins/ml/public/application/model_management/get_model_state.test.tsx b/x-pack/plugins/ml/server/models/model_management/get_model_state.test.tsx similarity index 94% rename from x-pack/plugins/ml/public/application/model_management/get_model_state.test.tsx rename to x-pack/plugins/ml/server/models/model_management/get_model_state.test.tsx index 1431b2da0439c..16c30395d1b15 100644 --- a/x-pack/plugins/ml/public/application/model_management/get_model_state.test.tsx +++ b/x-pack/plugins/ml/server/models/model_management/get_model_state.test.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import { getModelDeploymentState } from './get_model_state'; import { MODEL_STATE } from '@kbn/ml-trained-models-utils'; -import type { ModelItem } from './models_list'; +import type { NLPModelItem } from '../../../common/types/trained_models'; +import { getModelDeploymentState } from './get_model_state'; describe('getModelDeploymentState', () => { it('returns STARTED if any deployment is in STARTED state', () => { @@ -37,7 +37,7 @@ describe('getModelDeploymentState', () => { }, ], }, - } as unknown as ModelItem; + } as unknown as NLPModelItem; const result = getModelDeploymentState(model); expect(result).toEqual(MODEL_STATE.STARTED); }); @@ -69,7 +69,7 @@ describe('getModelDeploymentState', () => { }, ], }, - } as unknown as ModelItem; + } as unknown as NLPModelItem; const result = getModelDeploymentState(model); expect(result).toEqual(MODEL_STATE.STARTING); }); @@ -96,7 +96,7 @@ describe('getModelDeploymentState', () => { }, ], }, - } as unknown as ModelItem; + } as unknown as NLPModelItem; const result = getModelDeploymentState(model); expect(result).toEqual(MODEL_STATE.STOPPING); }); @@ -112,7 +112,7 @@ describe('getModelDeploymentState', () => { deployment_stats: [], }, - } as unknown as ModelItem; + } as unknown as NLPModelItem; const result = getModelDeploymentState(model); expect(result).toEqual(undefined); }); diff --git a/x-pack/plugins/ml/server/models/model_management/get_model_state.ts b/x-pack/plugins/ml/server/models/model_management/get_model_state.ts new file mode 100644 index 0000000000000..2ee2bf8cb4532 --- /dev/null +++ b/x-pack/plugins/ml/server/models/model_management/get_model_state.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { DEPLOYMENT_STATE, MODEL_STATE, type ModelState } from '@kbn/ml-trained-models-utils'; +import type { NLPModelItem } from '../../../common/types/trained_models'; + +/** + * Resolves result model state based on the state of each deployment. + * + * If at least one deployment is in the STARTED state, the model state is STARTED. + * Then if none of the deployments are in the STARTED state, but at least one is in the STARTING state, the model state is STARTING. + * If all deployments are in the STOPPING state, the model state is STOPPING. + */ +export const getModelDeploymentState = (model: NLPModelItem): ModelState | undefined => { + if (!model.stats?.deployment_stats?.length) return; + + if (model.stats?.deployment_stats?.some((v) => v.state === DEPLOYMENT_STATE.STARTED)) { + return MODEL_STATE.STARTED; + } + if (model.stats?.deployment_stats?.some((v) => v.state === DEPLOYMENT_STATE.STARTING)) { + return MODEL_STATE.STARTING; + } + if (model.stats?.deployment_stats?.every((v) => v.state === DEPLOYMENT_STATE.STOPPING)) { + return MODEL_STATE.STOPPING; + } +}; diff --git a/x-pack/plugins/ml/server/models/model_management/model_provider.test.ts b/x-pack/plugins/ml/server/models/model_management/model_provider.test.ts index 0b9b93720234d..0a73dfa3053db 100644 --- a/x-pack/plugins/ml/server/models/model_management/model_provider.test.ts +++ b/x-pack/plugins/ml/server/models/model_management/model_provider.test.ts @@ -6,45 +6,54 @@ */ import { modelsProvider } from './models_provider'; -import { type IScopedClusterClient } from '@kbn/core/server'; import { cloudMock } from '@kbn/cloud-plugin/server/mocks'; import type { MlClient } from '../../lib/ml_client'; import downloadTasksResponse from './__mocks__/mock_download_tasks.json'; +import type { MlFeatures } from '../../../common/constants/app'; +import { mlLog } from '../../lib/log'; +import { errors } from '@elastic/elasticsearch'; +import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; +import type { ExistingModelBase } from '../../../common/types/trained_models'; +import type { InferenceInferenceEndpointInfo } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; + +jest.mock('../../lib/log'); describe('modelsProvider', () => { - const mockClient = { - asInternalUser: { - transport: { - request: jest.fn().mockResolvedValue({ - _nodes: { - total: 1, - successful: 1, - failed: 0, - }, - cluster_name: 'default', - nodes: { - yYmqBqjpQG2rXsmMSPb9pQ: { - name: 'node-0', - roles: ['ml'], - attributes: {}, - os: { - name: 'Linux', - arch: 'amd64', - }, - }, - }, - }), - }, - tasks: { - list: jest.fn().mockResolvedValue({ tasks: [] }), + const mockClient = elasticsearchClientMock.createScopedClusterClient(); + + mockClient.asInternalUser.transport.request.mockResolvedValue({ + _nodes: { + total: 1, + successful: 1, + failed: 0, + }, + cluster_name: 'default', + nodes: { + yYmqBqjpQG2rXsmMSPb9pQ: { + name: 'node-0', + roles: ['ml'], + attributes: {}, + os: { + name: 'Linux', + arch: 'amd64', + }, }, }, - } as unknown as jest.Mocked; + }); + + mockClient.asInternalUser.tasks.list.mockResolvedValue({ tasks: [] }); const mockMlClient = {} as unknown as jest.Mocked; const mockCloud = cloudMock.createSetup(); - const modelService = modelsProvider(mockClient, mockMlClient, mockCloud); + + const enabledMlFeatures: MlFeatures = { + ad: false, + dfa: true, + nlp: true, + }; + + const modelService = modelsProvider(mockClient, mockMlClient, mockCloud, enabledMlFeatures); afterEach(() => { jest.clearAllMocks(); @@ -122,7 +131,7 @@ describe('modelsProvider', () => { test('provides a list of models with default model as recommended', async () => { mockCloud.cloudId = undefined; - (mockClient.asInternalUser.transport.request as jest.Mock).mockResolvedValueOnce({ + mockClient.asInternalUser.transport.request.mockResolvedValueOnce({ _nodes: { total: 1, successful: 1, @@ -218,7 +227,7 @@ describe('modelsProvider', () => { test('provides a default version if there is no recommended', async () => { mockCloud.cloudId = undefined; - (mockClient.asInternalUser.transport.request as jest.Mock).mockResolvedValueOnce({ + mockClient.asInternalUser.transport.request.mockResolvedValueOnce({ _nodes: { total: 1, successful: 1, @@ -261,7 +270,7 @@ describe('modelsProvider', () => { test('provides a default version if there is no recommended', async () => { mockCloud.cloudId = undefined; - (mockClient.asInternalUser.transport.request as jest.Mock).mockResolvedValueOnce({ + mockClient.asInternalUser.transport.request.mockResolvedValueOnce({ _nodes: { total: 1, successful: 1, @@ -292,9 +301,7 @@ describe('modelsProvider', () => { expect(result).toEqual({}); }); test('provides download status for all models', async () => { - (mockClient.asInternalUser.tasks.list as jest.Mock).mockResolvedValueOnce( - downloadTasksResponse - ); + mockClient.asInternalUser.tasks.list.mockResolvedValueOnce(downloadTasksResponse); const result = await modelService.getModelsDownloadStatus(); expect(result).toEqual({ '.elser_model_2': { downloaded_parts: 0, total_parts: 418 }, @@ -302,4 +309,124 @@ describe('modelsProvider', () => { }); }); }); + + describe('#assignInferenceEndpoints', () => { + let trainedModels: ExistingModelBase[]; + + const inferenceServices = [ + { + service: 'elser', + model_id: 'elser_test', + service_settings: { model_id: '.elser_model_2' }, + }, + { service: 'open_api_01', service_settings: {} }, + ] as InferenceInferenceEndpointInfo[]; + + beforeEach(() => { + trainedModels = [ + { model_id: '.elser_model_2' }, + { model_id: 'model2' }, + ] as ExistingModelBase[]; + + mockClient.asInternalUser.inference.get.mockResolvedValue({ + endpoints: inferenceServices, + }); + + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when the user has required privileges', () => { + beforeEach(() => { + mockClient.asCurrentUser.inference.get.mockResolvedValue({ + endpoints: inferenceServices, + }); + }); + + test('should populate inference services for trained models', async () => { + // act + await modelService.assignInferenceEndpoints(trainedModels, false); + + // assert + expect(mockClient.asCurrentUser.inference.get).toHaveBeenCalledWith({ + inference_id: '_all', + }); + + expect(mockClient.asInternalUser.inference.get).not.toHaveBeenCalled(); + + expect(trainedModels[0].inference_apis).toEqual([ + { + model_id: 'elser_test', + service: 'elser', + service_settings: { model_id: '.elser_model_2' }, + }, + ]); + expect(trainedModels[0].hasInferenceServices).toBe(true); + + expect(trainedModels[1].inference_apis).toEqual(undefined); + expect(trainedModels[1].hasInferenceServices).toBe(false); + + expect(mlLog.error).not.toHaveBeenCalled(); + }); + }); + + describe('when the user does not have required privileges', () => { + beforeEach(() => { + mockClient.asCurrentUser.inference.get.mockRejectedValue( + new errors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 403, + body: { message: 'not allowed' }, + }) + ) + ); + }); + + test('should retry with internal user if an error occurs', async () => { + await modelService.assignInferenceEndpoints(trainedModels, false); + + // assert + expect(mockClient.asCurrentUser.inference.get).toHaveBeenCalledWith({ + inference_id: '_all', + }); + + expect(mockClient.asInternalUser.inference.get).toHaveBeenCalledWith({ + inference_id: '_all', + }); + + expect(trainedModels[0].inference_apis).toEqual(undefined); + expect(trainedModels[0].hasInferenceServices).toBe(true); + + expect(trainedModels[1].inference_apis).toEqual(undefined); + expect(trainedModels[1].hasInferenceServices).toBe(false); + + expect(mlLog.error).not.toHaveBeenCalled(); + }); + }); + + test('should not retry on any other error than 403', async () => { + const notFoundError = new errors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 404, + body: { message: 'not found' }, + }) + ); + + mockClient.asCurrentUser.inference.get.mockRejectedValue(notFoundError); + + await modelService.assignInferenceEndpoints(trainedModels, false); + + // assert + expect(mockClient.asCurrentUser.inference.get).toHaveBeenCalledWith({ + inference_id: '_all', + }); + + expect(mockClient.asInternalUser.inference.get).not.toHaveBeenCalled(); + + expect(mlLog.error).toHaveBeenCalledWith(notFoundError); + }); + }); }); diff --git a/x-pack/plugins/ml/server/models/model_management/models_provider.ts b/x-pack/plugins/ml/server/models/model_management/models_provider.ts index 1c175cee26d14..0f302363f66ea 100644 --- a/x-pack/plugins/ml/server/models/model_management/models_provider.ts +++ b/x-pack/plugins/ml/server/models/model_management/models_provider.ts @@ -8,10 +8,11 @@ import Boom from '@hapi/boom'; import type { IScopedClusterClient } from '@kbn/core/server'; import { JOB_MAP_NODE_TYPES, type MapElements } from '@kbn/ml-data-frame-analytics-utils'; -import { flatten } from 'lodash'; +import { flatten, groupBy, isEmpty } from 'lodash'; import type { InferenceInferenceEndpoint, InferenceTaskType, + MlGetTrainedModelsRequest, TasksTaskInfo, TransformGetTransformTransformSummary, } from '@elastic/elasticsearch/lib/api/types'; @@ -24,22 +25,50 @@ import type { } from '@elastic/elasticsearch/lib/api/types'; import { ELASTIC_MODEL_DEFINITIONS, + ELASTIC_MODEL_TAG, + MODEL_STATE, type GetModelDownloadConfigOptions, type ModelDefinitionResponse, + ELASTIC_MODEL_TYPE, + BUILT_IN_MODEL_TYPE, } from '@kbn/ml-trained-models-utils'; import type { CloudSetup } from '@kbn/cloud-plugin/server'; import type { ElasticCuratedModelName } from '@kbn/ml-trained-models-utils'; -import type { ModelDownloadState, PipelineDefinition } from '../../../common/types/trained_models'; +import { isDefined } from '@kbn/ml-is-defined'; +import { DEFAULT_TRAINED_MODELS_PAGE_SIZE } from '../../../common/constants/trained_models'; +import type { MlFeatures } from '../../../common/constants/app'; +import type { + DFAModelItem, + ExistingModelBase, + ModelDownloadItem, + NLPModelItem, + TrainedModelItem, + TrainedModelUIItem, + TrainedModelWithPipelines, +} from '../../../common/types/trained_models'; +import { isBuiltInModel, isExistingModel } from '../../../common/types/trained_models'; +import { + isDFAModelItem, + isElasticModel, + isNLPModelItem, + type ModelDownloadState, + type PipelineDefinition, + type TrainedModelConfigResponse, +} from '../../../common/types/trained_models'; import type { MlClient } from '../../lib/ml_client'; import type { MLSavedObjectService } from '../../saved_objects'; +import { filterForEnabledFeatureModels } from '../../routes/trained_models'; +import { mlLog } from '../../lib/log'; +import { getModelDeploymentState } from './get_model_state'; export type ModelService = ReturnType; export const modelsProvider = ( client: IScopedClusterClient, mlClient: MlClient, - cloud: CloudSetup -) => new ModelsProvider(client, mlClient, cloud); + cloud: CloudSetup, + enabledFeatures: MlFeatures +) => new ModelsProvider(client, mlClient, cloud, enabledFeatures); interface ModelMapResult { ingestPipelines: Map | null>; @@ -66,7 +95,8 @@ export class ModelsProvider { constructor( private _client: IScopedClusterClient, private _mlClient: MlClient, - private _cloud: CloudSetup + private _cloud: CloudSetup, + private _enabledFeatures: MlFeatures ) {} private async initTransformData() { @@ -110,6 +140,291 @@ export class ModelsProvider { return `${elementOriginalId}-${nodeType}`; } + /** + * Assigns inference endpoints to trained models + * @param trainedModels + * @param asInternal + */ + async assignInferenceEndpoints(trainedModels: ExistingModelBase[], asInternal: boolean = false) { + const esClient = asInternal ? this._client.asInternalUser : this._client.asCurrentUser; + + try { + // Check if model is used by an inference service + const { endpoints } = await esClient.inference.get({ + inference_id: '_all', + }); + + const inferenceAPIMap = groupBy( + endpoints, + (endpoint) => endpoint.service === 'elser' && endpoint.service_settings.model_id + ); + + for (const model of trainedModels) { + const inferenceApis = inferenceAPIMap[model.model_id]; + model.hasInferenceServices = !!inferenceApis; + if (model.hasInferenceServices && !asInternal) { + model.inference_apis = inferenceApis; + } + } + } catch (e) { + if (!asInternal && e.statusCode === 403) { + // retry with internal user to get an indicator if models has associated inference services, without mentioning the names + await this.assignInferenceEndpoints(trainedModels, true); + } else { + mlLog.error(e); + } + } + } + + /** + * Assigns trained model stats to trained models + * @param trainedModels + */ + async assignModelStats(trainedModels: ExistingModelBase[]): Promise { + const { trained_model_stats: modelsStatsResponse } = await this._mlClient.getTrainedModelsStats( + { + size: DEFAULT_TRAINED_MODELS_PAGE_SIZE, + } + ); + + const groupByModelId = groupBy(modelsStatsResponse, 'model_id'); + + return trainedModels.map((model) => { + const modelStats = groupByModelId[model.model_id]; + + const completeModelItem: TrainedModelItem = { + ...model, + // @ts-ignore FIXME: fix modelStats type + stats: { + ...modelStats[0], + ...(isNLPModelItem(model) + ? { deployment_stats: modelStats.map((d) => d.deployment_stats).filter(isDefined) } + : {}), + }, + }; + + if (isNLPModelItem(completeModelItem)) { + // Extract deployment ids from deployment stats + completeModelItem.deployment_ids = modelStats + .map((v) => v.deployment_stats?.deployment_id) + .filter(isDefined); + + completeModelItem.state = getModelDeploymentState(completeModelItem); + + completeModelItem.stateDescription = completeModelItem.stats.deployment_stats.reduce( + (acc, c) => { + if (acc) return acc; + return c.reason ?? ''; + }, + '' + ); + } + + return completeModelItem; + }); + } + + /** + * Merges the list of models with the list of models available for download. + */ + async includeModelDownloads(resultItems: TrainedModelUIItem[]): Promise { + const idMap = new Map( + resultItems.map((model) => [model.model_id, model]) + ); + /** + * Fetches model definitions available for download + */ + const forDownload = await this.getModelDownloads(); + + const notDownloaded: TrainedModelUIItem[] = forDownload + .filter(({ model_id: modelId, hidden, recommended, supported, disclaimer }) => { + if (idMap.has(modelId)) { + const model = idMap.get(modelId)! as NLPModelItem; + if (recommended) { + model.recommended = true; + } + model.supported = supported; + model.disclaimer = disclaimer; + } + return !idMap.has(modelId) && !hidden; + }) + .map((modelDefinition) => { + return { + model_id: modelDefinition.model_id, + type: modelDefinition.type, + tags: modelDefinition.type?.includes(ELASTIC_MODEL_TAG) ? [ELASTIC_MODEL_TAG] : [], + putModelConfig: modelDefinition.config, + description: modelDefinition.description, + state: MODEL_STATE.NOT_DOWNLOADED, + recommended: !!modelDefinition.recommended, + modelName: modelDefinition.modelName, + os: modelDefinition.os, + arch: modelDefinition.arch, + softwareLicense: modelDefinition.license, + licenseUrl: modelDefinition.licenseUrl, + supported: modelDefinition.supported, + disclaimer: modelDefinition.disclaimer, + } as ModelDownloadItem; + }); + + // show model downloads first + return [...notDownloaded, ...resultItems]; + } + + /** + * Assigns pipelines to trained models + */ + async assignPipelines(trainedModels: TrainedModelItem[]): Promise { + // For each model create a dict with model aliases and deployment ids for faster lookup + const modelToAliasesAndDeployments: Record> = Object.fromEntries( + trainedModels.map((model) => [ + model.model_id, + new Set([ + model.model_id, + ...(model.metadata?.model_aliases ?? []), + ...(isNLPModelItem(model) ? model.deployment_ids : []), + ]), + ]) + ); + + // Set of unique model ids, aliases, and deployment ids. + const modelIdsAndAliases: string[] = Object.values(modelToAliasesAndDeployments).flatMap((s) => + Array.from(s) + ); + + try { + // Get all pipelines first in one call: + const modelPipelinesMap = await this.getModelsPipelines(modelIdsAndAliases); + + trainedModels.forEach((model) => { + const modelAliasesAndDeployments = modelToAliasesAndDeployments[model.model_id]; + // Check model pipelines map for any pipelines associated with the model + for (const [modelEntityId, pipelines] of modelPipelinesMap) { + if (modelAliasesAndDeployments.has(modelEntityId)) { + // Merge pipeline definitions into the model + model.pipelines = model.pipelines + ? Object.assign(model.pipelines, pipelines) + : pipelines; + } + } + }); + } catch (e) { + // the user might not have required permissions to fetch pipelines + // log the error to the debug log as this might be a common situation and + // we don't need to fill kibana's log with these messages. + mlLog.debug(e); + } + } + + /** + * Assigns indices to trained models + */ + async assignModelIndices(trainedModels: TrainedModelItem[]): Promise { + // Get a list of all uniquer pipeline ids to retrieve mapping with indices + const pipelineIds = new Set( + trainedModels + .filter((model): model is TrainedModelWithPipelines => isDefined(model.pipelines)) + .flatMap((model) => Object.keys(model.pipelines)) + ); + + const pipelineToIndicesMap = await this.getPipelineToIndicesMap(pipelineIds); + + trainedModels.forEach((model) => { + if (!isEmpty(model.pipelines)) { + model.indices = Object.entries(pipelineToIndicesMap) + .filter(([pipelineId]) => !isEmpty(model.pipelines?.[pipelineId])) + .flatMap(([_, indices]) => indices); + } + }); + } + + /** + * Assign a check for each DFA model if origin job exists + */ + async assignDFAJobCheck(trainedModels: DFAModelItem[]): Promise { + try { + const dfaJobIds = trainedModels + .map((model) => { + const id = model.metadata?.analytics_config?.id; + if (id) { + return `${id}*`; + } + }) + .filter(isDefined); + + if (dfaJobIds.length > 0) { + const { data_frame_analytics: jobs } = await this._mlClient.getDataFrameAnalytics({ + id: dfaJobIds.join(','), + allow_no_match: true, + }); + + trainedModels.forEach((model) => { + const dfaId = model?.metadata?.analytics_config?.id; + if (dfaId !== undefined) { + // if this is a dfa model, set origin_job_exists + model.origin_job_exists = jobs.find((job) => job.id === dfaId) !== undefined; + } + }); + } + } catch (e) { + return; + } + } + + /** + * Returns a complete list of entities for the Trained Models UI + */ + async getTrainedModelList(): Promise { + const resp = await this._mlClient.getTrainedModels({ + size: 1000, + } as MlGetTrainedModelsRequest); + + let resultItems: TrainedModelUIItem[] = []; + + // Filter models based on enabled features + const filteredModels = filterForEnabledFeatureModels( + resp.trained_model_configs, + this._enabledFeatures + ) as TrainedModelConfigResponse[]; + + const formattedModels = filteredModels.map((model) => { + return { + ...model, + // Extract model types + type: [ + model.model_type, + ...(isBuiltInModel(model) ? [BUILT_IN_MODEL_TYPE] : []), + ...(isElasticModel(model) ? [ELASTIC_MODEL_TYPE] : []), + ...(typeof model.inference_config === 'object' + ? Object.keys(model.inference_config) + : []), + ].filter(isDefined), + }; + }); + + // Update inference endpoints info + await this.assignInferenceEndpoints(formattedModels); + + // Assign model stats + resultItems = await this.assignModelStats(formattedModels); + + if (this._enabledFeatures.nlp) { + resultItems = await this.includeModelDownloads(resultItems); + } + + const existingModels = resultItems.filter(isExistingModel); + + // Assign pipelines to existing models + await this.assignPipelines(existingModels); + + // Assign indices + await this.assignModelIndices(existingModels); + + await this.assignDFAJobCheck(resultItems.filter(isDFAModelItem)); + + return resultItems; + } + /** * Simulates the effect of the pipeline on given document. * @@ -170,12 +485,13 @@ export class ModelsProvider { } /** - * Retrieves the map of model ids and aliases with associated pipelines. + * Retrieves the map of model ids and aliases with associated pipelines, + * where key is a model, alias or deployment id, and value is a map of pipeline ids and pipeline definitions. * @param modelIds - Array of models ids and model aliases. */ async getModelsPipelines(modelIds: string[]) { - const modelIdsMap = new Map | null>( - modelIds.map((id: string) => [id, null]) + const modelIdsMap = new Map>( + modelIds.map((id: string) => [id, {}]) ); try { @@ -208,6 +524,53 @@ export class ModelsProvider { return modelIdsMap; } + /** + * Match pipelines to indices based on the default_pipeline setting in the index settings. + */ + async getPipelineToIndicesMap(pipelineIds: Set): Promise> { + const pipelineIdsToDestinationIndices: Record = {}; + + let indicesPermissions; + let indicesSettings; + + try { + indicesSettings = await this._client.asInternalUser.indices.getSettings(); + const hasPrivilegesResponse = await this._client.asCurrentUser.security.hasPrivileges({ + index: [ + { + names: Object.keys(indicesSettings), + privileges: ['read'], + }, + ], + }); + indicesPermissions = hasPrivilegesResponse.index; + } catch (e) { + // Possible that the user doesn't have permissions to view + if (e.meta?.statusCode !== 403) { + mlLog.error(e); + } + return pipelineIdsToDestinationIndices; + } + + // From list of model pipelines, find all indices that have pipeline set as index.default_pipeline + for (const [indexName, { settings }] of Object.entries(indicesSettings)) { + const defaultPipeline = settings?.index?.default_pipeline; + if ( + defaultPipeline && + pipelineIds.has(defaultPipeline) && + indicesPermissions[indexName]?.read === true + ) { + if (Array.isArray(pipelineIdsToDestinationIndices[defaultPipeline])) { + pipelineIdsToDestinationIndices[defaultPipeline].push(indexName); + } else { + pipelineIdsToDestinationIndices[defaultPipeline] = [indexName]; + } + } + } + + return pipelineIdsToDestinationIndices; + } + /** * Retrieves the network map and metadata of model ids, pipelines, and indices that are tied to the model ids. * @param modelIds - Array of models ids and model aliases. @@ -229,7 +592,6 @@ export class ModelsProvider { }; let pipelinesResponse; - let indicesSettings; try { pipelinesResponse = await this.getModelsPipelines([modelId]); @@ -264,44 +626,8 @@ export class ModelsProvider { } if (withIndices === true) { - const pipelineIdsToDestinationIndices: Record = {}; - - let indicesPermissions; - try { - indicesSettings = await this._client.asInternalUser.indices.getSettings(); - const hasPrivilegesResponse = await this._client.asCurrentUser.security.hasPrivileges({ - index: [ - { - names: Object.keys(indicesSettings), - privileges: ['read'], - }, - ], - }); - indicesPermissions = hasPrivilegesResponse.index; - } catch (e) { - // Possible that the user doesn't have permissions to view - // If so, gracefully exit - if (e.meta?.statusCode !== 403) { - // eslint-disable-next-line no-console - console.error(e); - } - return result; - } - - // 2. From list of model pipelines, find all indices that have pipeline set as index.default_pipeline - for (const [indexName, { settings }] of Object.entries(indicesSettings)) { - if ( - settings?.index?.default_pipeline && - pipelineIds.has(settings.index.default_pipeline) && - indicesPermissions[indexName]?.read === true - ) { - if (Array.isArray(pipelineIdsToDestinationIndices[settings.index.default_pipeline])) { - pipelineIdsToDestinationIndices[settings.index.default_pipeline].push(indexName); - } else { - pipelineIdsToDestinationIndices[settings.index.default_pipeline] = [indexName]; - } - } - } + const pipelineIdsToDestinationIndices: Record = + await this.getPipelineToIndicesMap(pipelineIds); // 3. Grab index information for all the indices found, and add their info to the map for (const [pipelineId, indexIds] of Object.entries(pipelineIdsToDestinationIndices)) { diff --git a/x-pack/plugins/ml/server/plugin.ts b/x-pack/plugins/ml/server/plugin.ts index ba6c5387a93cb..e40bed733f0da 100644 --- a/x-pack/plugins/ml/server/plugin.ts +++ b/x-pack/plugins/ml/server/plugin.ts @@ -226,7 +226,8 @@ export class MlServerPlugin getDataViews, () => this.auditService, () => this.isMlReady, - this.compatibleModuleType + this.compatibleModuleType, + this.enabledFeatures ); const routeInit: RouteInitialization = { diff --git a/x-pack/plugins/ml/server/routes/inference_models.ts b/x-pack/plugins/ml/server/routes/inference_models.ts index 866398ac56ce9..8318fadee8ebd 100644 --- a/x-pack/plugins/ml/server/routes/inference_models.ts +++ b/x-pack/plugins/ml/server/routes/inference_models.ts @@ -19,7 +19,7 @@ import { ML_INTERNAL_BASE_PATH } from '../../common/constants/app'; import { syncSavedObjectsFactory } from '../saved_objects'; export function inferenceModelRoutes( - { router, routeGuard }: RouteInitialization, + { router, routeGuard, getEnabledFeatures }: RouteInitialization, cloud: CloudSetup ) { router.versioned @@ -48,7 +48,12 @@ export function inferenceModelRoutes( async ({ client, mlClient, request, response, mlSavedObjectService }) => { try { const { inferenceId, taskType } = request.params; - const body = await modelsProvider(client, mlClient, cloud).createInferenceEndpoint( + const body = await modelsProvider( + client, + mlClient, + cloud, + getEnabledFeatures() + ).createInferenceEndpoint( inferenceId, taskType as InferenceTaskType, request.body as InferenceInferenceEndpoint diff --git a/x-pack/plugins/ml/server/routes/schemas/inference_schema.ts b/x-pack/plugins/ml/server/routes/schemas/inference_schema.ts index f8305cff189ed..1c2ec984fc286 100644 --- a/x-pack/plugins/ml/server/routes/schemas/inference_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/inference_schema.ts @@ -65,8 +65,6 @@ export const optionalModelIdSchema = schema.object({ export const getInferenceQuerySchema = schema.object({ size: schema.maybe(schema.string()), - with_pipelines: schema.maybe(schema.string()), - with_indices: schema.maybe(schema.oneOf([schema.string(), schema.boolean()])), include: schema.maybe(schema.string()), }); diff --git a/x-pack/plugins/ml/server/routes/trained_models.test.ts b/x-pack/plugins/ml/server/routes/trained_models.test.ts deleted file mode 100644 index ca3eb19e757c6..0000000000000 --- a/x-pack/plugins/ml/server/routes/trained_models.test.ts +++ /dev/null @@ -1,139 +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 { errors } from '@elastic/elasticsearch'; -import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; -import type { TrainedModelConfigResponse } from '../../common/types/trained_models'; -import { populateInferenceServicesProvider } from './trained_models'; -import { mlLog } from '../lib/log'; - -jest.mock('../lib/log'); - -describe('populateInferenceServicesProvider', () => { - const client = elasticsearchClientMock.createScopedClusterClient(); - - let trainedModels: TrainedModelConfigResponse[]; - - const inferenceServices = [ - { - service: 'elser', - model_id: 'elser_test', - service_settings: { model_id: '.elser_model_2' }, - }, - { service: 'open_api_01', model_id: 'open_api_model', service_settings: {} }, - ]; - - beforeEach(() => { - trainedModels = [ - { model_id: '.elser_model_2' }, - { model_id: 'model2' }, - ] as TrainedModelConfigResponse[]; - - client.asInternalUser.transport.request.mockResolvedValue({ endpoints: inferenceServices }); - - jest.clearAllMocks(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('when the user has required privileges', () => { - beforeEach(() => { - client.asCurrentUser.transport.request.mockResolvedValue({ endpoints: inferenceServices }); - }); - - test('should populate inference services for trained models', async () => { - const populateInferenceServices = populateInferenceServicesProvider(client); - // act - await populateInferenceServices(trainedModels, false); - - // assert - expect(client.asCurrentUser.transport.request).toHaveBeenCalledWith({ - method: 'GET', - path: '/_inference/_all', - }); - - expect(client.asInternalUser.transport.request).not.toHaveBeenCalled(); - - expect(trainedModels[0].inference_apis).toEqual([ - { - model_id: 'elser_test', - service: 'elser', - service_settings: { model_id: '.elser_model_2' }, - }, - ]); - expect(trainedModels[0].hasInferenceServices).toBe(true); - - expect(trainedModels[1].inference_apis).toEqual(undefined); - expect(trainedModels[1].hasInferenceServices).toBe(false); - - expect(mlLog.error).not.toHaveBeenCalled(); - }); - }); - - describe('when the user does not have required privileges', () => { - beforeEach(() => { - client.asCurrentUser.transport.request.mockRejectedValue( - new errors.ResponseError( - elasticsearchClientMock.createApiResponse({ - statusCode: 403, - body: { message: 'not allowed' }, - }) - ) - ); - }); - - test('should retry with internal user if an error occurs', async () => { - const populateInferenceServices = populateInferenceServicesProvider(client); - await populateInferenceServices(trainedModels, false); - - // assert - expect(client.asCurrentUser.transport.request).toHaveBeenCalledWith({ - method: 'GET', - path: '/_inference/_all', - }); - - expect(client.asInternalUser.transport.request).toHaveBeenCalledWith({ - method: 'GET', - path: '/_inference/_all', - }); - - expect(trainedModels[0].inference_apis).toEqual(undefined); - expect(trainedModels[0].hasInferenceServices).toBe(true); - - expect(trainedModels[1].inference_apis).toEqual(undefined); - expect(trainedModels[1].hasInferenceServices).toBe(false); - - expect(mlLog.error).not.toHaveBeenCalled(); - }); - }); - - test('should not retry on any other error than 403', async () => { - const notFoundError = new errors.ResponseError( - elasticsearchClientMock.createApiResponse({ - statusCode: 404, - body: { message: 'not found' }, - }) - ); - - client.asCurrentUser.transport.request.mockRejectedValue(notFoundError); - - const populateInferenceServices = populateInferenceServicesProvider(client); - await populateInferenceServices(trainedModels, false); - - // assert - expect(client.asCurrentUser.transport.request).toHaveBeenCalledWith({ - method: 'GET', - path: '/_inference/_all', - }); - - expect(client.asInternalUser.transport.request).not.toHaveBeenCalled(); - - expect(mlLog.error).toHaveBeenCalledWith(notFoundError); - }); -}); diff --git a/x-pack/plugins/ml/server/routes/trained_models.ts b/x-pack/plugins/ml/server/routes/trained_models.ts index 2782c4be18207..adedb37b4a7a5 100644 --- a/x-pack/plugins/ml/server/routes/trained_models.ts +++ b/x-pack/plugins/ml/server/routes/trained_models.ts @@ -6,21 +6,18 @@ */ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { groupBy } from 'lodash'; +import type { CloudSetup } from '@kbn/cloud-plugin/server'; import { schema } from '@kbn/config-schema'; import type { ErrorType } from '@kbn/ml-error-utils'; -import type { CloudSetup } from '@kbn/cloud-plugin/server'; -import type { - ElasticCuratedModelName, - ElserVersion, - InferenceAPIConfigResponse, -} from '@kbn/ml-trained-models-utils'; -import { isDefined } from '@kbn/ml-is-defined'; -import type { IScopedClusterClient } from '@kbn/core-elasticsearch-server'; +import type { ElasticCuratedModelName, ElserVersion } from '@kbn/ml-trained-models-utils'; +import { TRAINED_MODEL_TYPE } from '@kbn/ml-trained-models-utils'; +import { ML_INTERNAL_BASE_PATH, type MlFeatures } from '../../common/constants/app'; import { DEFAULT_TRAINED_MODELS_PAGE_SIZE } from '../../common/constants/trained_models'; -import { type MlFeatures, ML_INTERNAL_BASE_PATH } from '../../common/constants/app'; -import type { RouteInitialization } from '../types'; +import { type TrainedModelConfigResponse } from '../../common/types/trained_models'; import { wrapError } from '../client/error_wrapper'; +import { modelsProvider } from '../models/model_management'; +import type { RouteInitialization } from '../types'; +import { forceQuerySchema } from './schemas/anomaly_detectors_schema'; import { createIngestPipelineSchema, curatedModelsParamsSchema, @@ -39,70 +36,57 @@ import { threadingParamsQuerySchema, updateDeploymentParamsSchema, } from './schemas/inference_schema'; -import type { PipelineDefinition } from '../../common/types/trained_models'; -import { type TrainedModelConfigResponse } from '../../common/types/trained_models'; -import { mlLog } from '../lib/log'; -import { forceQuerySchema } from './schemas/anomaly_detectors_schema'; -import { modelsProvider } from '../models/model_management'; export function filterForEnabledFeatureModels< T extends TrainedModelConfigResponse | estypes.MlTrainedModelConfig >(models: T[], enabledFeatures: MlFeatures) { let filteredModels = models; if (enabledFeatures.nlp === false) { - filteredModels = filteredModels.filter((m) => m.model_type === 'tree_ensemble'); + filteredModels = filteredModels.filter((m) => m.model_type !== TRAINED_MODEL_TYPE.PYTORCH); } - if (enabledFeatures.dfa === false) { - filteredModels = filteredModels.filter((m) => m.model_type !== 'tree_ensemble'); + filteredModels = filteredModels.filter( + (m) => m.model_type !== TRAINED_MODEL_TYPE.TREE_ENSEMBLE + ); } - return filteredModels; } -export const populateInferenceServicesProvider = (client: IScopedClusterClient) => { - return async function populateInferenceServices( - trainedModels: TrainedModelConfigResponse[], - asInternal: boolean = false - ) { - const esClient = asInternal ? client.asInternalUser : client.asCurrentUser; - - try { - // Check if model is used by an inference service - const { endpoints } = await esClient.transport.request<{ - endpoints: InferenceAPIConfigResponse[]; - }>({ - method: 'GET', - path: `/_inference/_all`, - }); - - const inferenceAPIMap = groupBy( - endpoints, - (endpoint) => endpoint.service === 'elser' && endpoint.service_settings.model_id - ); - - for (const model of trainedModels) { - const inferenceApis = inferenceAPIMap[model.model_id]; - model.hasInferenceServices = !!inferenceApis; - if (model.hasInferenceServices && !asInternal) { - model.inference_apis = inferenceApis; - } - } - } catch (e) { - if (!asInternal && e.statusCode === 403) { - // retry with internal user to get an indicator if models has associated inference services, without mentioning the names - await populateInferenceServices(trainedModels, true); - } else { - mlLog.error(e); - } - } - }; -}; - export function trainedModelsRoutes( { router, routeGuard, getEnabledFeatures }: RouteInitialization, cloud: CloudSetup ) { + router.versioned + .get({ + path: `${ML_INTERNAL_BASE_PATH}/trained_models_list`, + access: 'internal', + security: { + authz: { + requiredPrivileges: ['ml:canGetTrainedModels'], + }, + }, + summary: 'Get trained models list', + description: + 'Retrieves a complete list of trained models with stats, pipelines, and indices.', + }) + .addVersion( + { + version: '1', + validate: false, + }, + routeGuard.fullLicenseAPIGuard(async ({ client, mlClient, request, response }) => { + try { + const modelsClient = modelsProvider(client, mlClient, cloud, getEnabledFeatures()); + const models = await modelsClient.getTrainedModelList(); + return response.ok({ + body: models, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + router.versioned .get({ path: `${ML_INTERNAL_BASE_PATH}/trained_models/{modelId?}`, @@ -128,14 +112,7 @@ export function trainedModelsRoutes( routeGuard.fullLicenseAPIGuard(async ({ client, mlClient, request, response }) => { try { const { modelId } = request.params; - const { - with_pipelines: withPipelines, - with_indices: withIndicesRaw, - ...getTrainedModelsRequestParams - } = request.query; - - const withIndices = - request.query.with_indices === 'true' || request.query.with_indices === true; + const { ...getTrainedModelsRequestParams } = request.query; const resp = await mlClient.getTrainedModels({ ...getTrainedModelsRequestParams, @@ -146,126 +123,8 @@ export function trainedModelsRoutes( // @ts-ignore const result = resp.trained_model_configs as TrainedModelConfigResponse[]; - const populateInferenceServices = populateInferenceServicesProvider(client); - await populateInferenceServices(result, false); - - try { - if (withPipelines) { - // Also need to retrieve the list of deployment IDs from stats - const stats = await mlClient.getTrainedModelsStats({ - ...(modelId ? { model_id: modelId } : {}), - size: 10000, - }); - - const modelDeploymentsMap = stats.trained_model_stats.reduce((acc, curr) => { - if (!curr.deployment_stats) return acc; - // @ts-ignore elasticsearch-js client is missing deployment_id - const deploymentId = curr.deployment_stats.deployment_id; - if (acc[curr.model_id]) { - acc[curr.model_id].push(deploymentId); - } else { - acc[curr.model_id] = [deploymentId]; - } - return acc; - }, {} as Record); - - const modelIdsAndAliases: string[] = Array.from( - new Set([ - ...result - .map(({ model_id: id, metadata }) => { - return [id, ...(metadata?.model_aliases ?? [])]; - }) - .flat(), - ...Object.values(modelDeploymentsMap).flat(), - ]) - ); - const modelsClient = modelsProvider(client, mlClient, cloud); - - const modelsPipelinesAndIndices = await Promise.all( - modelIdsAndAliases.map(async (modelIdOrAlias) => { - return { - modelIdOrAlias, - result: await modelsClient.getModelsPipelinesAndIndicesMap(modelIdOrAlias, { - withIndices, - }), - }; - }) - ); - - for (const model of result) { - const modelAliases = model.metadata?.model_aliases ?? []; - const modelMap = modelsPipelinesAndIndices.find( - (d) => d.modelIdOrAlias === model.model_id - )?.result; - - const allRelatedModels = modelsPipelinesAndIndices - .filter( - (m) => - [ - model.model_id, - ...modelAliases, - ...(modelDeploymentsMap[model.model_id] ?? []), - ].findIndex((alias) => alias === m.modelIdOrAlias) > -1 - ) - .map((r) => r?.result) - .filter(isDefined); - const ingestPipelinesFromModelAliases = allRelatedModels - .map((r) => r?.ingestPipelines) - .filter(isDefined) as Array>>; - - model.pipelines = ingestPipelinesFromModelAliases.reduce< - Record - >((allPipelines, modelsToPipelines) => { - for (const [, pipelinesObj] of modelsToPipelines?.entries()) { - Object.entries(pipelinesObj).forEach(([pipelineId, pipelineInfo]) => { - allPipelines[pipelineId] = pipelineInfo; - }); - } - return allPipelines; - }, {}); - - if (modelMap && withIndices) { - model.indices = modelMap.indices; - } - } - } - } catch (e) { - // the user might not have required permissions to fetch pipelines - // log the error to the debug log as this might be a common situation and - // we don't need to fill kibana's log with these messages. - mlLog.debug(e); - } - const filteredModels = filterForEnabledFeatureModels(result, getEnabledFeatures()); - try { - const jobIds = filteredModels - .map((model) => { - const id = model.metadata?.analytics_config?.id; - if (id) { - return `${id}*`; - } - }) - .filter((id) => id !== undefined); - - if (jobIds.length) { - const { data_frame_analytics: jobs } = await mlClient.getDataFrameAnalytics({ - id: jobIds.join(','), - allow_no_match: true, - }); - - filteredModels.forEach((model) => { - const dfaId = model?.metadata?.analytics_config?.id; - if (dfaId !== undefined) { - // if this is a dfa model, set origin_job_exists - model.origin_job_exists = jobs.find((job) => job.id === dfaId) !== undefined; - } - }); - } - } catch (e) { - // Swallow error to prevent blocking trained models result - } - return response.ok({ body: filteredModels, }); @@ -367,9 +226,12 @@ export function trainedModelsRoutes( routeGuard.fullLicenseAPIGuard(async ({ client, request, mlClient, response }) => { try { const { modelId } = request.params; - const result = await modelsProvider(client, mlClient, cloud).getModelsPipelines( - modelId.split(',') - ); + const result = await modelsProvider( + client, + mlClient, + cloud, + getEnabledFeatures() + ).getModelsPipelines(modelId.split(',')); return response.ok({ body: [...result].map(([id, pipelines]) => ({ model_id: id, pipelines })), }); @@ -396,9 +258,14 @@ export function trainedModelsRoutes( version: '1', validate: false, }, - routeGuard.fullLicenseAPIGuard(async ({ client, request, mlClient, response }) => { + routeGuard.fullLicenseAPIGuard(async ({ client, mlClient, response }) => { try { - const body = await modelsProvider(client, mlClient, cloud).getPipelines(); + const body = await modelsProvider( + client, + mlClient, + cloud, + getEnabledFeatures() + ).getPipelines(); return response.ok({ body, }); @@ -432,10 +299,12 @@ export function trainedModelsRoutes( routeGuard.fullLicenseAPIGuard(async ({ client, request, mlClient, response }) => { try { const { pipeline, pipelineName } = request.body; - const body = await modelsProvider(client, mlClient, cloud).createInferencePipeline( - pipeline!, - pipelineName - ); + const body = await modelsProvider( + client, + mlClient, + cloud, + getEnabledFeatures() + ).createInferencePipeline(pipeline!, pipelineName); return response.ok({ body, }); @@ -517,7 +386,12 @@ export function trainedModelsRoutes( if (withPipelines) { // first we need to delete pipelines, otherwise ml api return an error - await modelsProvider(client, mlClient, cloud).deleteModelPipelines(modelId.split(',')); + await modelsProvider( + client, + mlClient, + cloud, + getEnabledFeatures() + ).deleteModelPipelines(modelId.split(',')); } const body = await mlClient.deleteTrainedModel({ @@ -773,7 +647,12 @@ export function trainedModelsRoutes( }, routeGuard.fullLicenseAPIGuard(async ({ response, mlClient, client }) => { try { - const body = await modelsProvider(client, mlClient, cloud).getModelDownloads(); + const body = await modelsProvider( + client, + mlClient, + cloud, + getEnabledFeatures() + ).getModelDownloads(); return response.ok({ body, @@ -809,7 +688,7 @@ export function trainedModelsRoutes( try { const { version } = request.query; - const body = await modelsProvider(client, mlClient, cloud).getELSER( + const body = await modelsProvider(client, mlClient, cloud, getEnabledFeatures()).getELSER( version ? { version: Number(version) as ElserVersion } : undefined ); @@ -847,10 +726,12 @@ export function trainedModelsRoutes( async ({ client, mlClient, request, response, mlSavedObjectService }) => { try { const { modelId } = request.params; - const body = await modelsProvider(client, mlClient, cloud).installElasticModel( - modelId, - mlSavedObjectService - ); + const body = await modelsProvider( + client, + mlClient, + cloud, + getEnabledFeatures() + ).installElasticModel(modelId, mlSavedObjectService); return response.ok({ body, @@ -882,7 +763,12 @@ export function trainedModelsRoutes( routeGuard.fullLicenseAPIGuard( async ({ client, mlClient, request, response, mlSavedObjectService }) => { try { - const body = await modelsProvider(client, mlClient, cloud).getModelsDownloadStatus(); + const body = await modelsProvider( + client, + mlClient, + cloud, + getEnabledFeatures() + ).getModelsDownloadStatus(); return response.ok({ body, @@ -920,10 +806,14 @@ export function trainedModelsRoutes( routeGuard.fullLicenseAPIGuard( async ({ client, mlClient, request, response, mlSavedObjectService }) => { try { - const body = await modelsProvider(client, mlClient, cloud).getCuratedModelConfig( - request.params.modelName as ElasticCuratedModelName, - { version: request.query.version as ElserVersion } - ); + const body = await modelsProvider( + client, + mlClient, + cloud, + getEnabledFeatures() + ).getCuratedModelConfig(request.params.modelName as ElasticCuratedModelName, { + version: request.query.version as ElserVersion, + }); return response.ok({ body, diff --git a/x-pack/plugins/ml/server/shared_services/providers/trained_models.ts b/x-pack/plugins/ml/server/shared_services/providers/trained_models.ts index 36d639066f97a..04f12d82688e1 100644 --- a/x-pack/plugins/ml/server/shared_services/providers/trained_models.ts +++ b/x-pack/plugins/ml/server/shared_services/providers/trained_models.ts @@ -12,6 +12,7 @@ import type { GetModelDownloadConfigOptions, ModelDefinitionResponse, } from '@kbn/ml-trained-models-utils'; +import type { MlFeatures } from '../../../common/constants/app'; import type { MlInferTrainedModelRequest, MlStopTrainedModelDeploymentRequest, @@ -59,7 +60,8 @@ export interface TrainedModelsProvider { export function getTrainedModelsProvider( getGuards: GetGuards, - cloud: CloudSetup + cloud: CloudSetup, + enabledFeatures: MlFeatures ): TrainedModelsProvider { return { trainedModelsProvider(request: KibanaRequest, savedObjectsClient: SavedObjectsClientContract) { @@ -134,7 +136,9 @@ export function getTrainedModelsProvider( .isFullLicense() .hasMlCapabilities(['canGetTrainedModels']) .ok(async ({ scopedClient, mlClient }) => { - return modelsProvider(scopedClient, mlClient, cloud).getELSER(params); + return modelsProvider(scopedClient, mlClient, cloud, enabledFeatures).getELSER( + params + ); }); }, async getCuratedModelConfig(...params: GetCuratedModelConfigParams) { @@ -142,7 +146,12 @@ export function getTrainedModelsProvider( .isFullLicense() .hasMlCapabilities(['canGetTrainedModels']) .ok(async ({ scopedClient, mlClient }) => { - return modelsProvider(scopedClient, mlClient, cloud).getCuratedModelConfig(...params); + return modelsProvider( + scopedClient, + mlClient, + cloud, + enabledFeatures + ).getCuratedModelConfig(...params); }); }, async installElasticModel(modelId: string) { @@ -150,10 +159,12 @@ export function getTrainedModelsProvider( .isFullLicense() .hasMlCapabilities(['canGetTrainedModels']) .ok(async ({ scopedClient, mlClient, mlSavedObjectService }) => { - return modelsProvider(scopedClient, mlClient, cloud).installElasticModel( - modelId, - mlSavedObjectService - ); + return modelsProvider( + scopedClient, + mlClient, + cloud, + enabledFeatures + ).installElasticModel(modelId, mlSavedObjectService); }); }, }; diff --git a/x-pack/plugins/ml/server/shared_services/shared_services.ts b/x-pack/plugins/ml/server/shared_services/shared_services.ts index d4af7166435d4..caaf3abb78815 100644 --- a/x-pack/plugins/ml/server/shared_services/shared_services.ts +++ b/x-pack/plugins/ml/server/shared_services/shared_services.ts @@ -16,7 +16,7 @@ import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-ser import type { IClusterClient, IScopedClusterClient } from '@kbn/core-elasticsearch-server'; import type { UiSettingsServiceStart } from '@kbn/core-ui-settings-server'; import type { CoreAuditService } from '@kbn/core-security-server'; -import type { CompatibleModule } from '../../common/constants/app'; +import type { CompatibleModule, MlFeatures } from '../../common/constants/app'; import type { MlLicense } from '../../common/license'; import { licenseChecks } from './license_checks'; @@ -110,7 +110,8 @@ export function createSharedServices( getDataViews: () => DataViewsPluginStart, getAuditService: () => CoreAuditService | null, isMlReady: () => Promise, - compatibleModuleType: CompatibleModule | null + compatibleModuleType: CompatibleModule | null, + enabledFeatures: MlFeatures ): { sharedServicesProviders: SharedServices; internalServicesProviders: MlServicesProviders; @@ -188,7 +189,7 @@ export function createSharedServices( ...getResultsServiceProvider(getGuards), ...getMlSystemProvider(getGuards, mlLicense, getSpaces, cloud, resolveMlCapabilities), ...getAlertingServiceProvider(getGuards), - ...getTrainedModelsProvider(getGuards, cloud), + ...getTrainedModelsProvider(getGuards, cloud, enabledFeatures), }, /** * Services providers for ML internal usage diff --git a/x-pack/plugins/observability_solution/apm/common/alerting/config/apm_alerting_feature_ids.ts b/x-pack/plugins/observability_solution/apm/common/alerting/config/apm_alerting_feature_ids.ts index 784ab0b534256..ff6e86a9f79bc 100644 --- a/x-pack/plugins/observability_solution/apm/common/alerting/config/apm_alerting_feature_ids.ts +++ b/x-pack/plugins/observability_solution/apm/common/alerting/config/apm_alerting_feature_ids.ts @@ -11,7 +11,7 @@ import { type ValidFeatureId, } from '@kbn/rule-data-utils'; -export const apmAlertingConsumers: ValidFeatureId[] = [ +export const APM_ALERTING_CONSUMERS: ValidFeatureId[] = [ AlertConsumers.LOGS, AlertConsumers.APM, AlertConsumers.SLO, @@ -20,4 +20,4 @@ export const apmAlertingConsumers: ValidFeatureId[] = [ AlertConsumers.ALERTS, ]; -export const apmAlertingRuleTypeIds: string[] = [...OBSERVABILITY_RULE_TYPE_IDS]; +export const APM_ALERTING_RULE_TYPE_IDS: string[] = [...OBSERVABILITY_RULE_TYPE_IDS]; diff --git a/x-pack/plugins/observability_solution/apm/public/components/alerting/ui_components/apm_rule_unified_search_bar.tsx b/x-pack/plugins/observability_solution/apm/public/components/alerting/ui_components/apm_rule_unified_search_bar.tsx index d91807ec08462..393492cfae9a0 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/alerting/ui_components/apm_rule_unified_search_bar.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/alerting/ui_components/apm_rule_unified_search_bar.tsx @@ -6,7 +6,8 @@ */ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { Query } from '@kbn/es-query'; +import { EuiFormErrorText } from '@elastic/eui'; +import { Query, fromKueryExpression } from '@kbn/es-query'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { ApmPluginStartDeps } from '../../../plugin'; import { useAdHocApmDataView } from '../../../hooks/use_adhoc_apm_data_view'; @@ -26,7 +27,7 @@ export function ApmRuleUnifiedSearchBar({ setRuleParams: (key: string, value: any) => void; }) { const { services } = useKibana(); - + const [queryError, setQueryError] = React.useState(); const { unifiedSearch: { ui: { SearchBar }, @@ -38,27 +39,38 @@ export function ApmRuleUnifiedSearchBar({ const handleSubmit = (payload: { query?: Query }) => { const { query } = payload; - setRuleParams('searchConfiguration', { query }); + try { + setQueryError(undefined); + fromKueryExpression(query?.query as string); + setRuleParams('searchConfiguration', { query }); + } catch (e) { + setQueryError(e.message); + } }; return ( - + <> + + {queryError && ( + {queryError} + )} + ); } diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/alerts_overview/index.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/alerts_overview/index.tsx index 682634819e623..a14731db9efac 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/app/alerts_overview/index.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/app/alerts_overview/index.tsx @@ -14,8 +14,8 @@ import { BoolQuery } from '@kbn/es-query'; import { AlertConsumers } from '@kbn/rule-data-utils'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { - apmAlertingConsumers, - apmAlertingRuleTypeIds, + APM_ALERTING_CONSUMERS, + APM_ALERTING_RULE_TYPE_IDS, } from '../../../../common/alerting/config/apm_alerting_feature_ids'; import { ApmPluginStartDeps } from '../../../plugin'; import { useAnyOfApmParams } from '../../../hooks/use_apm_params'; @@ -111,8 +111,8 @@ export function AlertsOverview() { alertsTableConfigurationRegistry={alertsTableConfigurationRegistry} id={'service-overview-alerts'} configurationId={AlertConsumers.OBSERVABILITY} - ruleTypeIds={apmAlertingRuleTypeIds} - consumers={apmAlertingConsumers} + ruleTypeIds={APM_ALERTING_RULE_TYPE_IDS} + consumers={APM_ALERTING_CONSUMERS} query={esQuery} showAlertStatusWithFlapping cellContext={{ observabilityRuleTypeRegistry }} diff --git a/x-pack/plugins/observability_solution/apm/server/lib/helpers/get_apm_alerts_client.ts b/x-pack/plugins/observability_solution/apm/server/lib/helpers/get_apm_alerts_client.ts index 95c29472dbc79..fb519e2ef859f 100644 --- a/x-pack/plugins/observability_solution/apm/server/lib/helpers/get_apm_alerts_client.ts +++ b/x-pack/plugins/observability_solution/apm/server/lib/helpers/get_apm_alerts_client.ts @@ -12,7 +12,7 @@ import { DataTier } from '@kbn/observability-shared-plugin/common'; import { searchExcludedDataTiers } from '@kbn/observability-plugin/common/ui_settings_keys'; import { estypes } from '@elastic/elasticsearch'; import { getDataTierFilterCombined } from '@kbn/apm-data-access-plugin/server/utils'; -import { apmAlertingRuleTypeIds } from '../../../common/alerting/config/apm_alerting_feature_ids'; +import { APM_ALERTING_RULE_TYPE_IDS } from '../../../common/alerting/config/apm_alerting_feature_ids'; import type { MinimalAPMRouteHandlerResources } from '../../routes/apm_routes/register_apm_server_routes'; export type ApmAlertsClient = Awaited>; @@ -32,7 +32,9 @@ export async function getApmAlertsClient({ const ruleRegistryPluginStart = await plugins.ruleRegistry.start(); const alertsClient = await ruleRegistryPluginStart.getRacClientWithRequest(request); - const apmAlertsIndices = await alertsClient.getAuthorizedAlertsIndices(apmAlertingRuleTypeIds); + const apmAlertsIndices = await alertsClient.getAuthorizedAlertsIndices( + APM_ALERTING_RULE_TYPE_IDS + ); if (!apmAlertsIndices || isEmpty(apmAlertsIndices)) { throw Error('No alert indices exist for "apm"'); diff --git a/x-pack/plugins/observability_solution/apm/server/routes/services/get_services/get_service_alerts.ts b/x-pack/plugins/observability_solution/apm/server/routes/services/get_services/get_service_alerts.ts index 01a125f456443..c47668bc1ee32 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/services/get_services/get_service_alerts.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/services/get_services/get_service_alerts.ts @@ -18,7 +18,7 @@ import { ALERT_STATUS_ACTIVE, ALERT_UUID, } from '@kbn/rule-data-utils'; -import { observabilityFeatureId } from '@kbn/observability-shared-plugin/common'; +import { APM_ALERTING_CONSUMERS } from '../../../../common/alerting/config/apm_alerting_feature_ids'; import { SERVICE_NAME } from '../../../../common/es_fields/apm'; import { ServiceGroup } from '../../../../common/service_groups'; import { ApmAlertsClient } from '../../../lib/helpers/get_apm_alerts_client'; @@ -58,7 +58,7 @@ export async function getServicesAlerts({ query: { bool: { filter: [ - ...termsQuery(ALERT_RULE_PRODUCER, 'apm', observabilityFeatureId), + ...termsQuery(ALERT_RULE_PRODUCER, ...APM_ALERTING_CONSUMERS), ...termQuery(ALERT_STATUS, ALERT_STATUS_ACTIVE), ...rangeQuery(start, end), ...kqlQuery(kuery), diff --git a/x-pack/plugins/observability_solution/apm/server/routes/transactions/breakdown/index.ts b/x-pack/plugins/observability_solution/apm/server/routes/transactions/breakdown/index.ts index 73da84fe4e98e..a57d437b242aa 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/transactions/breakdown/index.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/transactions/breakdown/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { flatten, orderBy, last } from 'lodash'; +import { flatten, orderBy, last, clamp, round } from 'lodash'; import { rangeQuery, kqlQuery } from '@kbn/observability-plugin/server'; import { ProcessorEvent } from '@kbn/observability-plugin/common'; import { asPercent } from '../../../../common/utils/formatters'; @@ -145,9 +145,15 @@ export async function getTransactionBreakdown({ const type = bucket.key as string; return bucket.subtypes.buckets.map((subBucket) => { + const percentageRaw = + (subBucket.total_self_time_per_subtype.value || 0) / sumAllSelfTimes; + // limit percentage from 0% to 100% and + // round to 8 decimal points (results in 6 decimal points after converting to percentages) to prevent displaying scientific notation in charts + const percentage = round(clamp(percentageRaw, 0, 1), 8); + return { name: (subBucket.key as string) || type, - percentage: (subBucket.total_self_time_per_subtype.value || 0) / sumAllSelfTimes, + percentage, }; }); }) diff --git a/x-pack/plugins/observability_solution/entity_manager_app/public/pages/overview/index.tsx b/x-pack/plugins/observability_solution/entity_manager_app/public/pages/overview/index.tsx index 59740fb8f8135..d628ab306a1b1 100644 --- a/x-pack/plugins/observability_solution/entity_manager_app/public/pages/overview/index.tsx +++ b/x-pack/plugins/observability_solution/entity_manager_app/public/pages/overview/index.tsx @@ -185,7 +185,7 @@ export function EntityManagerOverviewPage() { .filter( (source) => source.index_patterns.length > 0 && source.identity_fields.length > 0 ) - .map((source) => ({ ...source, type: entityType })), + .map((source) => ({ ...source, type_id: entityType })), }, }, } diff --git a/x-pack/plugins/observability_solution/infra/public/alerting/log_threshold/components/alert_details_app_section/index.tsx b/x-pack/plugins/observability_solution/infra/public/alerting/log_threshold/components/alert_details_app_section/index.tsx index 36e2e91af9db0..2e8b417972e91 100644 --- a/x-pack/plugins/observability_solution/infra/public/alerting/log_threshold/components/alert_details_app_section/index.tsx +++ b/x-pack/plugins/observability_solution/infra/public/alerting/log_threshold/components/alert_details_app_section/index.tsx @@ -93,6 +93,7 @@ const AlertDetailsAppSection = ({ rule, alert }: AlertDetailsAppSectionProps) => { + const useKibanaContextForPluginMock = useKibanaContextForPlugin as jest.MockedFunction< + typeof useKibanaContextForPlugin + >; + + const telemetryMock = { reportHostsViewTotalHostCountRetrieved: jest.fn() }; + + useKibanaContextForPluginMock.mockReturnValue({ + services: { telemetry: telemetryMock }, + } as unknown as ReturnType); + + const useUnifiedSearchContextMock = + useUnifiedSearchHooks.useUnifiedSearchContext as jest.MockedFunction< + typeof useUnifiedSearchHooks.useUnifiedSearchContext + >; + + const mockUseUnifiedContext = (searchCriteria: any) => { + useUnifiedSearchContextMock.mockReturnValue({ + buildQuery: jest.fn(() => 'query'), + parsedDateRange: { from: '', to: '' }, + searchCriteria, + } as unknown as ReturnType); + }; + + describe('when data is fetched', () => { + const fetcherDataMock = { count: 10 }; + + beforeAll(() => { + (useFetcher as jest.Mock).mockReturnValue({ + data: fetcherDataMock, + status: 'success', + error: null, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('and there is no filters or query applied', () => { + it('should call reportHostsViewTotalHostCountRetrieved with the correct data', async () => { + mockUseUnifiedContext({ + query: { query: null }, + filters: [], + panelFilters: [], + }); + + await renderHook(() => useHostCount()); + + expect(telemetryMock.reportHostsViewTotalHostCountRetrieved).toHaveBeenCalledWith({ + total: fetcherDataMock.count, + with_query: false, + with_filters: false, + }); + }); + }); + + describe('and query is applied', () => { + it('should call reportHostsViewTotalHostCountRetrieved with the correct data', async () => { + mockUseUnifiedContext({ query: { query: 'test' }, filters: [], panelFilters: [] }); + + await renderHook(() => useHostCount()); + + expect(telemetryMock.reportHostsViewTotalHostCountRetrieved).toHaveBeenCalledWith({ + total: fetcherDataMock.count, + with_query: true, + with_filters: false, + }); + }); + }); + + describe('and filters are applied', () => { + it('should call reportHostsViewTotalHostCountRetrieved with the correct data', async () => { + mockUseUnifiedContext({ + query: { query: null }, + filters: [{ filter: 'filter' }], + panelFilters: [], + }); + + await renderHook(() => useHostCount()); + + expect(telemetryMock.reportHostsViewTotalHostCountRetrieved).toHaveBeenCalledWith({ + total: fetcherDataMock.count, + with_query: false, + with_filters: true, + }); + }); + }); + + describe('and panel filters are applied', () => { + it('should call reportHostsViewTotalHostCountRetrieved with the correct data', async () => { + mockUseUnifiedContext({ + query: { query: null }, + filters: [{ filter: 'filter' }], + panelFilters: [{ filter: 'filter' }], + }); + + await renderHook(() => useHostCount()); + + expect(telemetryMock.reportHostsViewTotalHostCountRetrieved).toHaveBeenCalledWith({ + total: fetcherDataMock.count, + with_query: false, + with_filters: true, + }); + }); + }); + }); + + describe('when data is fetched with error', () => { + beforeAll(() => { + (useFetcher as jest.Mock).mockReturnValue({ + data: {}, + status: 'error', + error: 'error', + }); + }); + + it('should NOT call reportHostsViewTotalHostCountRetrieved ', async () => { + mockUseUnifiedContext(null); + + await renderHook(() => useHostCount()); + + expect(telemetryMock.reportHostsViewTotalHostCountRetrieved).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/observability_solution/infra/public/pages/metrics/hosts/hooks/use_host_count.ts b/x-pack/plugins/observability_solution/infra/public/pages/metrics/hosts/hooks/use_host_count.ts index a09e46ed2792a..fd723fd596146 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/metrics/hosts/hooks/use_host_count.ts +++ b/x-pack/plugins/observability_solution/infra/public/pages/metrics/hosts/hooks/use_host_count.ts @@ -7,13 +7,16 @@ import createContainer from 'constate'; import { decodeOrThrow } from '@kbn/io-ts-utils'; -import { useMemo } from 'react'; +import { useMemo, useEffect } from 'react'; +import { useKibanaContextForPlugin } from '../../../../hooks/use_kibana'; import { GetInfraAssetCountResponsePayloadRT } from '../../../../../common/http_api'; import { isPending, useFetcher } from '../../../../hooks/use_fetcher'; import { useUnifiedSearchContext } from './use_unified_search'; export const useHostCount = () => { - const { buildQuery, parsedDateRange } = useUnifiedSearchContext(); + const { buildQuery, parsedDateRange, searchCriteria } = useUnifiedSearchContext(); + const { services } = useKibanaContextForPlugin(); + const { telemetry } = services; const payload = useMemo( () => @@ -37,6 +40,16 @@ export const useHostCount = () => { [payload] ); + useEffect(() => { + if (data && !error) { + telemetry.reportHostsViewTotalHostCountRetrieved({ + total: data.count ?? 0, + with_query: !!searchCriteria.query.query, + with_filters: searchCriteria.filters.length > 0 || searchCriteria.panelFilters.length > 0, + }); + } + }, [data, error, payload, searchCriteria, telemetry]); + return { errors: error, loading: isPending(status), diff --git a/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/nodes_overview.tsx b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/nodes_overview.tsx index d42670f190fde..c2493df3a3968 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/nodes_overview.tsx +++ b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/nodes_overview.tsx @@ -145,6 +145,18 @@ export const NodesOverview = ({ currentTime={currentTime} onFilter={handleDrilldown} /> + {nodeType === assetType && detailsItemId && ( + + )} ); } diff --git a/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/table_view.tsx b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/table_view.tsx index 182c74c124fd9..e41bf377e40e1 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/table_view.tsx +++ b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/table_view.tsx @@ -17,6 +17,7 @@ import { InfraWaffleMapNode, InfraWaffleMapOptions } from '../../../../common/in import { fieldToName } from '../lib/field_to_display_name'; import { NodeContextMenu } from './waffle/node_context_menu'; import { SnapshotNode, SnapshotNodePath } from '../../../../../common/http_api/snapshot_api'; +import { useAssetDetailsFlyoutState } from '../hooks/use_asset_details_flyout_url_state'; interface Props { nodes: SnapshotNode[]; @@ -49,6 +50,16 @@ export const TableView = (props: Props) => { const { nodes, options, formatter, currentTime, nodeType } = props; const [openPopoverId, setOpenPopoverId] = useState(null); + const [_, setFlyoutUrlState] = useAssetDetailsFlyoutState(); + const isFlyoutMode = nodeType === 'host' || nodeType === 'container'; + + const toggleAssetPopover = (uniqueID: string, nodeId: string) => { + if (isFlyoutMode) { + setFlyoutUrlState({ detailsItemId: nodeId, assetType: nodeType }); + } else { + setOpenPopoverId(uniqueID); + } + }; const closePopover = () => setOpenPopoverId(null); @@ -69,14 +80,14 @@ export const TableView = (props: Props) => { setOpenPopoverId(uniqueID)} + onClick={() => toggleAssetPopover(uniqueID, item.node.id)} > {value} ); - return ( + return !isFlyoutMode ? ( { options={options} /> + ) : ( + button ); }, }, diff --git a/x-pack/plugins/observability_solution/infra/server/config.ts b/x-pack/plugins/observability_solution/infra/server/config.ts new file mode 100644 index 0000000000000..3e3d51f42a5d1 --- /dev/null +++ b/x-pack/plugins/observability_solution/infra/server/config.ts @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; + +import { offeringBasedSchema, schema } from '@kbn/config-schema'; +import { PluginConfigDescriptor } from '@kbn/core-plugins-server'; +import { ConfigDeprecation } from '@kbn/config'; +import { InfraConfig } from './types'; +import { publicConfigKeys } from '../common/plugin_config_types'; + +export type { InfraConfig }; + +export const config: PluginConfigDescriptor = { + schema: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + alerting: schema.object({ + inventory_threshold: schema.object({ + group_by_page_size: schema.number({ defaultValue: 5_000 }), + }), + metric_threshold: schema.object({ + group_by_page_size: schema.number({ defaultValue: 10_000 }), + }), + }), + inventory: schema.object({ + compositeSize: schema.number({ defaultValue: 2000 }), + }), + sources: schema.maybe( + schema.object({ + default: schema.maybe( + schema.object({ + fields: schema.maybe( + schema.object({ + message: schema.maybe(schema.arrayOf(schema.string())), + }) + ), + }) + ), + }) + ), + featureFlags: schema.object({ + customThresholdAlertsEnabled: offeringBasedSchema({ + traditional: schema.boolean({ defaultValue: false }), + serverless: schema.boolean({ defaultValue: false }), + }), + logsUIEnabled: offeringBasedSchema({ + traditional: schema.boolean({ defaultValue: true }), + serverless: schema.boolean({ defaultValue: false }), + }), + metricsExplorerEnabled: offeringBasedSchema({ + traditional: schema.boolean({ defaultValue: true }), + serverless: schema.boolean({ defaultValue: false }), + }), + osqueryEnabled: schema.boolean({ defaultValue: true }), + inventoryThresholdAlertRuleEnabled: offeringBasedSchema({ + traditional: schema.boolean({ defaultValue: true }), + serverless: schema.boolean({ defaultValue: true }), + }), + metricThresholdAlertRuleEnabled: offeringBasedSchema({ + traditional: schema.boolean({ defaultValue: true }), + serverless: schema.boolean({ defaultValue: false }), + }), + logThresholdAlertRuleEnabled: offeringBasedSchema({ + traditional: schema.boolean({ defaultValue: true }), + serverless: schema.boolean({ defaultValue: false }), + }), + alertsAndRulesDropdownEnabled: offeringBasedSchema({ + traditional: schema.boolean({ defaultValue: true }), + serverless: schema.boolean({ defaultValue: true }), + }), + /** + * Depends on optional "profilingDataAccess" and "profiling" + * plugins. Enable both with `xpack.profiling.enabled: true` before + * enabling this feature flag. + */ + profilingEnabled: schema.boolean({ defaultValue: false }), + ruleFormV2Enabled: schema.boolean({ defaultValue: false }), + }), + }), + deprecations: () => [sourceFieldsMessageDeprecation], + exposeToBrowser: publicConfigKeys, +}; + +const sourceFieldsMessageDeprecation: ConfigDeprecation = (settings, fromPath, addDeprecation) => { + const sourceFieldsMessageSetting = settings?.xpack?.infra?.sources?.default?.fields?.message; + + if (sourceFieldsMessageSetting) { + addDeprecation({ + configPath: `${fromPath}.sources.default.fields.message`, + title: i18n.translate('xpack.infra.deprecations.sourcesDefaultFieldsMessage.title', { + defaultMessage: 'The "xpack.infra.sources.default.fields.message" setting is deprecated.', + ignoreTag: true, + }), + message: i18n.translate('xpack.infra.deprecations.sourcesDefaultFieldsMessage.message', { + defaultMessage: + 'Features using this configurations are set to be removed in v9 and this is no longer used.', + }), + level: 'warning', + documentationUrl: `https://www.elastic.co/guide/en/kibana/current/logs-ui-settings-kb.html#general-logs-ui-settings-kb`, + correctiveActions: { + manualSteps: [ + i18n.translate('xpack.infra.deprecations.sourcesDefaultFieldsMessage.manualSteps1', { + defaultMessage: 'Remove "xpack.infra.sources.default.fields.message" from kibana.yml.', + ignoreTag: true, + }), + ], + }, + }); + } +}; diff --git a/x-pack/plugins/observability_solution/infra/server/index.ts b/x-pack/plugins/observability_solution/infra/server/index.ts index 96ac6dc162c24..c381514900a72 100644 --- a/x-pack/plugins/observability_solution/infra/server/index.ts +++ b/x-pack/plugins/observability_solution/infra/server/index.ts @@ -6,11 +6,9 @@ */ import { PluginInitializerContext } from '@kbn/core/server'; -import { config, InfraConfig } from './plugin'; +export { config, type InfraConfig } from './config'; export type { InfraPluginSetup, InfraPluginStart, InfraRequestHandlerContext } from './types'; -export type { InfraConfig }; -export { config }; export async function plugin(context: PluginInitializerContext) { const { InfraServerPlugin } = await import('./plugin'); diff --git a/x-pack/plugins/observability_solution/infra/server/lib/adapters/framework/kibana_framework_adapter.ts b/x-pack/plugins/observability_solution/infra/server/lib/adapters/framework/kibana_framework_adapter.ts index c16dad5445af4..f245214cfa37a 100644 --- a/x-pack/plugins/observability_solution/infra/server/lib/adapters/framework/kibana_framework_adapter.ts +++ b/x-pack/plugins/observability_solution/infra/server/lib/adapters/framework/kibana_framework_adapter.ts @@ -13,8 +13,7 @@ import { UI_SETTINGS } from '@kbn/data-plugin/server'; import { TimeseriesVisData } from '@kbn/vis-type-timeseries-plugin/server'; import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; import { TSVBMetricModel } from '@kbn/metrics-data-access-plugin/common'; -import { InfraConfig } from '../../../plugin'; -import type { InfraPluginRequestHandlerContext } from '../../../types'; +import type { InfraConfig, InfraPluginRequestHandlerContext } from '../../../types'; import { CallWithRequestParams, InfraDatabaseGetIndicesAliasResponse, diff --git a/x-pack/plugins/observability_solution/infra/server/plugin.ts b/x-pack/plugins/observability_solution/infra/server/plugin.ts index b8becb916a4e3..6008954b63bde 100644 --- a/x-pack/plugins/observability_solution/infra/server/plugin.ts +++ b/x-pack/plugins/observability_solution/infra/server/plugin.ts @@ -6,13 +6,7 @@ */ import { Server } from '@hapi/hapi'; -import { schema, offeringBasedSchema } from '@kbn/config-schema'; -import { - CoreStart, - Plugin, - PluginConfigDescriptor, - PluginInitializerContext, -} from '@kbn/core/server'; +import { CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/server'; import { handleEsError } from '@kbn/es-ui-shared-plugin/server'; import { i18n } from '@kbn/i18n'; import { Logger } from '@kbn/logging'; @@ -26,7 +20,6 @@ import { import { type AlertsLocatorParams, alertsLocatorID } from '@kbn/observability-plugin/common'; import { mapValues } from 'lodash'; import { LOGS_FEATURE_ID, METRICS_FEATURE_ID } from '../common/constants'; -import { publicConfigKeys } from '../common/plugin_config_types'; import { LOGS_FEATURE, METRICS_FEATURE } from './features'; import { registerRoutes } from './infra_server'; import { InfraServerPluginSetupDeps, InfraServerPluginStartDeps } from './lib/adapters/framework'; @@ -61,77 +54,6 @@ import { UsageCollector } from './usage/usage_collector'; import { mapSourceToLogView } from './utils/map_source_to_log_view'; import { uiSettings } from '../common/ui_settings'; -export const config: PluginConfigDescriptor = { - schema: schema.object({ - enabled: schema.boolean({ defaultValue: true }), - alerting: schema.object({ - inventory_threshold: schema.object({ - group_by_page_size: schema.number({ defaultValue: 5_000 }), - }), - metric_threshold: schema.object({ - group_by_page_size: schema.number({ defaultValue: 10_000 }), - }), - }), - inventory: schema.object({ - compositeSize: schema.number({ defaultValue: 2000 }), - }), - sources: schema.maybe( - schema.object({ - default: schema.maybe( - schema.object({ - fields: schema.maybe( - schema.object({ - message: schema.maybe(schema.arrayOf(schema.string())), - }) - ), - }) - ), - }) - ), - featureFlags: schema.object({ - customThresholdAlertsEnabled: offeringBasedSchema({ - traditional: schema.boolean({ defaultValue: false }), - serverless: schema.boolean({ defaultValue: false }), - }), - logsUIEnabled: offeringBasedSchema({ - traditional: schema.boolean({ defaultValue: true }), - serverless: schema.boolean({ defaultValue: false }), - }), - metricsExplorerEnabled: offeringBasedSchema({ - traditional: schema.boolean({ defaultValue: true }), - serverless: schema.boolean({ defaultValue: false }), - }), - osqueryEnabled: schema.boolean({ defaultValue: true }), - inventoryThresholdAlertRuleEnabled: offeringBasedSchema({ - traditional: schema.boolean({ defaultValue: true }), - serverless: schema.boolean({ defaultValue: true }), - }), - metricThresholdAlertRuleEnabled: offeringBasedSchema({ - traditional: schema.boolean({ defaultValue: true }), - serverless: schema.boolean({ defaultValue: false }), - }), - logThresholdAlertRuleEnabled: offeringBasedSchema({ - traditional: schema.boolean({ defaultValue: true }), - serverless: schema.boolean({ defaultValue: false }), - }), - alertsAndRulesDropdownEnabled: offeringBasedSchema({ - traditional: schema.boolean({ defaultValue: true }), - serverless: schema.boolean({ defaultValue: true }), - }), - /** - * Depends on optional "profilingDataAccess" and "profiling" - * plugins. Enable both with `xpack.profiling.enabled: true` before - * enabling this feature flag. - */ - profilingEnabled: schema.boolean({ defaultValue: false }), - ruleFormV2Enabled: schema.boolean({ defaultValue: false }), - }), - }), - exposeToBrowser: publicConfigKeys, -}; - -export type { InfraConfig }; - export interface KbnServer extends Server { usage: any; } diff --git a/x-pack/plugins/observability_solution/infra/tsconfig.json b/x-pack/plugins/observability_solution/infra/tsconfig.json index efd8be77b688c..7dc5548ed4601 100644 --- a/x-pack/plugins/observability_solution/infra/tsconfig.json +++ b/x-pack/plugins/observability_solution/infra/tsconfig.json @@ -116,7 +116,9 @@ "@kbn/entityManager-plugin", "@kbn/entities-schema", "@kbn/zod", - "@kbn/observability-utils-server" + "@kbn/observability-utils-server", + "@kbn/core-plugins-server", + "@kbn/config", ], "exclude": ["target/**/*"] } diff --git a/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/events_timeline/events_timeline.tsx b/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/events_timeline/events_timeline.tsx index c3f92139bd936..45b245f68b4b0 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/events_timeline/events_timeline.tsx +++ b/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/events_timeline/events_timeline.tsx @@ -11,9 +11,6 @@ import { Chart, Axis, AreaSeries, Position, ScaleType, Settings } from '@elastic import { useActiveCursor } from '@kbn/charts-plugin/public'; import { EuiSkeletonText } from '@elastic/eui'; import { getBrushData } from '@kbn/observability-utils-browser/chart/utils'; -import { Group } from '@kbn/observability-alerting-rule-utils'; -import { ALERT_GROUP } from '@kbn/rule-data-utils'; -import { SERVICE_NAME } from '@kbn/observability-shared-plugin/common'; import { AnnotationEvent } from './annotation_event'; import { TIME_LINE_THEME } from './timeline_theme'; import { useFetchEvents } from '../../../../hooks/use_fetch_events'; @@ -27,19 +24,10 @@ export const EventsTimeLine = () => { const baseTheme = dependencies.start.charts.theme.useChartsBaseTheme(); const { globalParams, updateInvestigationParams } = useInvestigation(); - const { alert } = useInvestigation(); - - const filter = useMemo(() => { - const group = (alert?.[ALERT_GROUP] as unknown as Group[])?.find( - ({ field }) => field === SERVICE_NAME - ); - return group ? `{"${SERVICE_NAME}":"${alert?.[SERVICE_NAME]}"}` : ''; - }, [alert]); const { data: events, isLoading } = useFetchEvents({ rangeFrom: globalParams.timeRange.from, rangeTo: globalParams.timeRange.to, - filter, }); const chartRef = useRef(null); diff --git a/x-pack/plugins/observability_solution/investigate_app/tsconfig.json b/x-pack/plugins/observability_solution/investigate_app/tsconfig.json index 0851a13367091..bc67b591a57b8 100644 --- a/x-pack/plugins/observability_solution/investigate_app/tsconfig.json +++ b/x-pack/plugins/observability_solution/investigate_app/tsconfig.json @@ -66,7 +66,6 @@ "@kbn/calculate-auto", "@kbn/ml-random-sampler-utils", "@kbn/charts-plugin", - "@kbn/observability-alerting-rule-utils", "@kbn/observability-utils-browser", "@kbn/usage-collection-plugin", "@kbn/inference-common", diff --git a/x-pack/plugins/observability_solution/logs_explorer/common/ui_settings.ts b/x-pack/plugins/observability_solution/logs_explorer/common/ui_settings.ts index 65983c3993488..cc9f7f76a3765 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/common/ui_settings.ts +++ b/x-pack/plugins/observability_solution/logs_explorer/common/ui_settings.ts @@ -25,6 +25,13 @@ export const uiSettings: Record = { defaultMessage: 'A list of base patterns to match and explore data views in Logs Explorer. Remote clusters will be automatically matched for the provided base patterns.', }), + deprecation: { + message: i18n.translate('xpack.logsExplorer.allowedDataViewsDeprecationWarning', { + defaultMessage: + 'Logs Explorer is deprecated, and this setting will be removed in Kibana 9.0.', + }), + docLinksKey: 'generalSettings', + }, type: 'array', schema: schema.arrayOf(schema.string()), requiresPageReload: true, diff --git a/x-pack/plugins/observability_solution/logs_shared/server/config.ts b/x-pack/plugins/observability_solution/logs_shared/server/config.ts index c144b8422826f..9dff5723e7633 100644 --- a/x-pack/plugins/observability_solution/logs_shared/server/config.ts +++ b/x-pack/plugins/observability_solution/logs_shared/server/config.ts @@ -26,4 +26,5 @@ export const configSchema = schema.object({ export const config: PluginConfigDescriptor = { schema: configSchema, + deprecations: ({ unused }) => [unused('savedObjects.logView.enabled', { level: 'warning' })], }; diff --git a/x-pack/plugins/observability_solution/logs_shared/server/lib/logs_shared_types.ts b/x-pack/plugins/observability_solution/logs_shared/server/lib/logs_shared_types.ts index d2108bdd6ce9e..4f368c4f64e46 100644 --- a/x-pack/plugins/observability_solution/logs_shared/server/lib/logs_shared_types.ts +++ b/x-pack/plugins/observability_solution/logs_shared/server/lib/logs_shared_types.ts @@ -23,4 +23,5 @@ export interface LogsSharedBackendLibs extends LogsSharedDomainLibs { getStartServices: LogsSharedPluginStartServicesAccessor; getUsageCollector: () => UsageCollector; logger: Logger; + isServerless: boolean; } diff --git a/x-pack/plugins/observability_solution/logs_shared/server/plugin.ts b/x-pack/plugins/observability_solution/logs_shared/server/plugin.ts index d1f6399104fc2..a0a29cebee9b5 100644 --- a/x-pack/plugins/observability_solution/logs_shared/server/plugin.ts +++ b/x-pack/plugins/observability_solution/logs_shared/server/plugin.ts @@ -42,7 +42,7 @@ export class LogsSharedPlugin private logViews: LogViewsService; private usageCollector: UsageCollector; - constructor(context: PluginInitializerContext) { + constructor(private readonly context: PluginInitializerContext) { this.config = context.config.get(); this.logger = context.logger.get(); this.usageCollector = {}; @@ -51,11 +51,13 @@ export class LogsSharedPlugin } public setup(core: LogsSharedPluginCoreSetup, plugins: LogsSharedServerPluginSetupDeps) { + const isServerless = this.context.env.packageInfo.buildFlavor === 'serverless'; + const framework = new KibanaFramework(core, plugins); const logViews = this.logViews.setup(); - if (this.config.savedObjects.logView.enabled) { + if (!isServerless) { // Conditionally register log view saved objects core.savedObjects.registerType(logViewSavedObjectType); } else { @@ -78,6 +80,7 @@ export class LogsSharedPlugin getStartServices: () => core.getStartServices(), getUsageCollector: () => this.usageCollector, logger: this.logger, + isServerless, }; // Register server side APIs diff --git a/x-pack/plugins/observability_solution/logs_shared/server/routes/log_views/get_log_view.ts b/x-pack/plugins/observability_solution/logs_shared/server/routes/log_views/get_log_view.ts index 4e46a06bcfe3d..9370c18243b51 100644 --- a/x-pack/plugins/observability_solution/logs_shared/server/routes/log_views/get_log_view.ts +++ b/x-pack/plugins/observability_solution/logs_shared/server/routes/log_views/get_log_view.ts @@ -14,6 +14,7 @@ export const initGetLogViewRoute = ({ config, framework, getStartServices, + isServerless, }: LogsSharedBackendLibs) => { framework .registerVersionedRoute({ @@ -41,7 +42,7 @@ export const initGetLogViewRoute = ({ * - if the log view saved object is correctly registered, perform a lookup for retrieving it * - else, skip the saved object lookup and immediately get the internal log view if exists. */ - const logView = config.savedObjects.logView.enabled + const logView = !isServerless ? await logViewsClient.getLogView(logViewId) : await logViewsClient.getInternalLogView(logViewId); diff --git a/x-pack/plugins/observability_solution/logs_shared/server/routes/log_views/index.ts b/x-pack/plugins/observability_solution/logs_shared/server/routes/log_views/index.ts index 42c23637fa1b7..4bcaa8d53ad0a 100644 --- a/x-pack/plugins/observability_solution/logs_shared/server/routes/log_views/index.ts +++ b/x-pack/plugins/observability_solution/logs_shared/server/routes/log_views/index.ts @@ -12,7 +12,7 @@ export const initLogViewRoutes = (libs: LogsSharedBackendLibs) => { initGetLogViewRoute(libs); // Register the log view update endpoint only when the Saved object is correctly registered - if (libs.config.savedObjects.logView.enabled) { + if (!libs.isServerless) { initPutLogViewRoute(libs); } }; diff --git a/x-pack/plugins/observability_solution/observability/public/pages/alerts/alerts.test.tsx b/x-pack/plugins/observability_solution/observability/public/pages/alerts/alerts.test.tsx index a5067c2968bb5..33dc64f35ee31 100644 --- a/x-pack/plugins/observability_solution/observability/public/pages/alerts/alerts.test.tsx +++ b/x-pack/plugins/observability_solution/observability/public/pages/alerts/alerts.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { ThemeProvider } from '@emotion/react'; +import { EuiThemeProvider as ThemeProvider } from '@elastic/eui'; import { MAINTENANCE_WINDOW_FEATURE_ID } from '@kbn/alerting-plugin/common/maintenance_window'; import { fetchActiveMaintenanceWindows } from '@kbn/alerts-ui-shared/src/maintenance_window_callout/api'; import { RUNNING_MAINTENANCE_WINDOW_1 } from '@kbn/alerts-ui-shared/src/maintenance_window_callout/mock'; @@ -14,7 +14,6 @@ import { TimeBuckets } from '@kbn/data-plugin/common'; import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { observabilityAIAssistantPluginMock } from '@kbn/observability-ai-assistant-plugin/public/mock'; import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; -import { euiDarkVars } from '@kbn/ui-theme'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, waitFor } from '@testing-library/react'; import React from 'react'; @@ -121,9 +120,7 @@ const queryClient = new QueryClient({ }); function AllTheProviders({ children }: { children: any }) { return ( - ({ eui: { ...euiDarkVars, euiColorLightShade: '#ece' }, darkMode: true })} - > + {children} diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/client/operators/with_langtrace_chat_complete_span.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/client/operators/with_langtrace_chat_complete_span.ts index 9e32ba4b57bfe..767121928622a 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/client/operators/with_langtrace_chat_complete_span.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/client/operators/with_langtrace_chat_complete_span.ts @@ -41,7 +41,10 @@ export function withLangtraceChatCompleteSpan({ 'http.max.retries': 0, // dummy URL 'url.full': 'http://localhost:3000/chat/completions', + 'url.path': '/chat/completions', 'http.timeout': 120 * 1000, + 'gen_ai.operation.name': 'chat_completion', + 'gen_ai.request.model': model, 'llm.prompts': JSON.stringify( messages.map((message) => ({ role: message.message.role, diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/util/eventsource_stream_into_observable.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/util/eventsource_stream_into_observable.ts index 5ff332128f8ac..b2426d8e4eb5d 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/util/eventsource_stream_into_observable.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/util/eventsource_stream_into_observable.ts @@ -14,10 +14,10 @@ import { Observable } from 'rxjs'; export function eventsourceStreamIntoObservable(readable: Readable) { return new Observable((subscriber) => { - const parser = createParser((event) => { - if (event.type === 'event') { + const parser = createParser({ + onEvent: (event) => { subscriber.next(event.data); - } + }, }); async function processStream() { diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/nav_control/index.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/nav_control/index.tsx index b6095ac595cea..fd198c42fda08 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/nav_control/index.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/nav_control/index.tsx @@ -6,7 +6,7 @@ */ import React, { useEffect, useRef, useState } from 'react'; import { AssistantAvatar, useAbortableAsync } from '@kbn/observability-ai-assistant-plugin/public'; -import { EuiButton, EuiLoadingSpinner, EuiToolTip } from '@elastic/eui'; +import { EuiButton, EuiButtonEmpty, EuiLoadingSpinner, EuiToolTip } from '@elastic/eui'; import { css } from '@emotion/react'; import { v4 } from 'uuid'; import useObservable from 'react-use/lib/useObservable'; @@ -24,12 +24,14 @@ interface NavControlWithProviderDeps { appService: AIAssistantAppService; coreStart: CoreStart; pluginsStart: ObservabilityAIAssistantAppPluginStartDependencies; + isServerless?: boolean; } export const NavControlWithProvider = ({ appService, coreStart, pluginsStart, + isServerless, }: NavControlWithProviderDeps) => { return ( - + ); }; -export function NavControl() { +export function NavControl({ isServerless }: { isServerless?: boolean }) { const service = useAIAssistantAppService(); const { @@ -140,22 +142,41 @@ export function NavControl() { return ( <> - { - service.conversations.openNewConversation({ - messages: [], - }); - }} - color="primary" - size="s" - fullWidth={false} - minWidth={0} - > - {chatService.loading ? : } - + {isServerless ? ( + { + service.conversations.openNewConversation({ + messages: [], + }); + }} + color="primary" + size="s" + > + {chatService.loading ? : } + + ) : ( + { + service.conversations.openNewConversation({ + messages: [], + }); + }} + color="primary" + size="s" + fullWidth={false} + minWidth={0} + > + {chatService.loading ? : } + + )} {chatService.value ? ( diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/nav_control/lazy_nav_control.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/nav_control/lazy_nav_control.tsx index adef91ceea53e..9a6fd2f30d918 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/nav_control/lazy_nav_control.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/nav_control/lazy_nav_control.tsx @@ -20,12 +20,14 @@ interface NavControlInitiatorProps { appService: AIAssistantAppService; coreStart: CoreStart; pluginsStart: ObservabilityAIAssistantAppPluginStartDependencies; + isServerless?: boolean; } export const NavControlInitiator = ({ appService, coreStart, pluginsStart, + isServerless, }: NavControlInitiatorProps) => { const { isVisible } = useIsNavControlVisible({ coreStart, pluginsStart }); @@ -38,6 +40,7 @@ export const NavControlInitiator = ({ appService={appService} coreStart={coreStart} pluginsStart={pluginsStart} + isServerless={isServerless} /> ); }; diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/plugin.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/plugin.tsx index 1904eebffb2a8..cd1285b0017ce 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/plugin.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/plugin.tsx @@ -41,10 +41,13 @@ export class ObservabilityAIAssistantAppPlugin { logger: Logger; appService: AIAssistantAppService | undefined; + isServerless: boolean; constructor(context: PluginInitializerContext) { this.logger = context.logger.get(); + this.isServerless = context.env.packageInfo.buildFlavor === 'serverless'; } + setup( coreSetup: CoreSetup, _: ObservabilityAIAssistantAppPluginSetupDependencies @@ -111,6 +114,7 @@ export class ObservabilityAIAssistantAppPlugin appService={appService} coreStart={coreStart} pluginsStart={pluginsStart} + isServerless={this.isServerless} />, element, () => {} diff --git a/x-pack/plugins/observability_solution/observability_logs_explorer/public/plugin.ts b/x-pack/plugins/observability_solution/observability_logs_explorer/public/plugin.ts index 2e6ab0aeeaa0f..843f21b48ad47 100644 --- a/x-pack/plugins/observability_solution/observability_logs_explorer/public/plugin.ts +++ b/x-pack/plugins/observability_solution/observability_logs_explorer/public/plugin.ts @@ -36,13 +36,10 @@ import type { export class ObservabilityLogsExplorerPlugin implements Plugin { - private config: ObservabilityLogsExplorerConfig; private locators?: ObservabilityLogsExplorerLocators; private appStateUpdater = new BehaviorSubject(() => ({})); - constructor(context: PluginInitializerContext) { - this.config = context.config.get(); - } + constructor(context: PluginInitializerContext) {} public setup( core: CoreSetup, @@ -56,9 +53,7 @@ export class ObservabilityLogsExplorerPlugin title: logsExplorerAppTitle, category: DEFAULT_APP_CATEGORIES.observability, euiIconType: 'logoLogging', - visibleIn: this.config.navigation.showAppLink - ? ['globalSearch', 'sideNav'] - : ['globalSearch'], + visibleIn: ['globalSearch'], keywords: ['logs', 'log', 'explorer', 'logs explorer'], updater$: this.appStateUpdater, mount: async (appMountParams: ObservabilityLogsExplorerAppMountParameters) => { diff --git a/x-pack/plugins/observability_solution/observability_logs_explorer/server/config.ts b/x-pack/plugins/observability_solution/observability_logs_explorer/server/config.ts index aa89b9aa273f1..bab368c0eb65c 100644 --- a/x-pack/plugins/observability_solution/observability_logs_explorer/server/config.ts +++ b/x-pack/plugins/observability_solution/observability_logs_explorer/server/config.ts @@ -25,7 +25,7 @@ export const configSchema = schema.object({ export const config: PluginConfigDescriptor = { schema: configSchema, - deprecations: ({ renameFromRoot }) => [ + deprecations: ({ renameFromRoot, unused }) => [ renameFromRoot( 'xpack.discoverLogExplorer.featureFlags.deepLinkVisible', 'xpack.observabilityLogsExplorer.navigation.showAppLink', @@ -41,6 +41,7 @@ export const config: PluginConfigDescriptor = { 'xpack.observabilityLogsExplorer.enabled', { level: 'warning' } ), + unused('navigation.showAppLink', { level: 'warning' }), ], exposeToBrowser: { navigation: { diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/application/shared/header_action_menu.tsx b/x-pack/plugins/observability_solution/observability_onboarding/public/application/shared/header_action_menu.tsx index 1864b8ced7f8b..22af649a635a5 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/public/application/shared/header_action_menu.tsx +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/application/shared/header_action_menu.tsx @@ -22,15 +22,14 @@ interface Props { export function ObservabilityOnboardingHeaderActionMenu({ setHeaderActionMenu, theme$ }: Props) { const { - services: { config }, + services: { context }, } = useKibana(); const location = useLocation(); const normalizedPathname = location.pathname.replace(/\/$/, ''); const isRootPage = normalizedPathname === ''; - const isServerless = config.serverless.enabled; - if (!isServerless && !isRootPage) { + if (!context.isServerless && !isRootPage) { return ( (); - const { - ui: { enabled: isObservabilityOnboardingUiEnabled }, - } = config; const isServerlessBuild = this.ctx.env.packageInfo.buildFlavor === 'serverless'; const isDevEnvironment = this.ctx.env.mode.dev; const pluginSetupDeps = plugins; - // set xpack.observability_onboarding.ui.enabled: true - // and go to /app/observabilityOnboarding - if (isObservabilityOnboardingUiEnabled) { - core.application.register({ - id: PLUGIN_ID, - title: 'Observability Onboarding', - order: 8500, - euiIconType: 'logoObservability', - category: DEFAULT_APP_CATEGORIES.observability, - keywords: [], - async mount(appMountParameters: AppMountParameters) { - // Load application bundle and Get start service - const [{ renderApp }, [coreStart, corePlugins]] = await Promise.all([ - import('./application/app'), - core.getStartServices(), - ]); + core.application.register({ + id: PLUGIN_ID, + title: 'Observability Onboarding', + order: 8500, + euiIconType: 'logoObservability', + category: DEFAULT_APP_CATEGORIES.observability, + keywords: [], + async mount(appMountParameters: AppMountParameters) { + // Load application bundle and Get start service + const [{ renderApp }, [coreStart, corePlugins]] = await Promise.all([ + import('./application/app'), + core.getStartServices(), + ]); - const { createCallApi } = await import('./services/rest/create_call_api'); + const { createCallApi } = await import('./services/rest/create_call_api'); - createCallApi(core); + createCallApi(core); - return renderApp({ - core: coreStart, - deps: pluginSetupDeps, - appMountParameters, - corePlugins: corePlugins as ObservabilityOnboardingPluginStartDeps, - config, - context: { - isDev: isDevEnvironment, - isCloud: Boolean(pluginSetupDeps.cloud?.isCloudEnabled), - isServerless: - Boolean(pluginSetupDeps.cloud?.isServerlessEnabled) || isServerlessBuild, - stackVersion, - cloudServiceProvider: pluginSetupDeps.cloud?.csp, - }, - }); - }, - visibleIn: [], - }); - } + return renderApp({ + core: coreStart, + deps: pluginSetupDeps, + appMountParameters, + corePlugins: corePlugins as ObservabilityOnboardingPluginStartDeps, + config, + context: { + isDev: isDevEnvironment, + isCloud: Boolean(pluginSetupDeps.cloud?.isCloudEnabled), + isServerless: Boolean(pluginSetupDeps.cloud?.isServerlessEnabled) || isServerlessBuild, + stackVersion, + cloudServiceProvider: pluginSetupDeps.cloud?.csp, + }, + }); + }, + visibleIn: [], + }); this.locators = { onboarding: plugins.share.url.locators.create(new ObservabilityOnboardingLocatorDefinition()), diff --git a/x-pack/plugins/observability_solution/observability_onboarding/server/config.ts b/x-pack/plugins/observability_solution/observability_onboarding/server/config.ts new file mode 100644 index 0000000000000..dcbc070e29047 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_onboarding/server/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 { TypeOf, offeringBasedSchema, schema } from '@kbn/config-schema'; +import { PluginConfigDescriptor } from '@kbn/core-plugins-server'; + +const configSchema = schema.object({ + ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + serverless: schema.object({ + enabled: offeringBasedSchema({ + serverless: schema.literal(true), + options: { defaultValue: schema.contextRef('serverless') }, + }), + }), +}); + +export type ObservabilityOnboardingConfig = TypeOf; + +// plugin config +export const config: PluginConfigDescriptor = { + exposeToBrowser: { + ui: true, + serverless: true, + }, + schema: configSchema, + deprecations: ({ unused }) => [ + unused('ui.enabled', { level: 'warning' }), + unused('serverless.enabled', { level: 'warning' }), + ], +}; diff --git a/x-pack/plugins/observability_solution/observability_onboarding/server/index.ts b/x-pack/plugins/observability_solution/observability_onboarding/server/index.ts index a985045d68d7a..b8675eadb3f9a 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/server/index.ts +++ b/x-pack/plugins/observability_solution/observability_onboarding/server/index.ts @@ -5,31 +5,9 @@ * 2.0. */ -import { offeringBasedSchema, schema, TypeOf } from '@kbn/config-schema'; -import { PluginConfigDescriptor, PluginInitializerContext } from '@kbn/core/server'; +import { PluginInitializerContext } from '@kbn/core/server'; -const configSchema = schema.object({ - ui: schema.object({ - enabled: schema.boolean({ defaultValue: true }), - }), - serverless: schema.object({ - enabled: offeringBasedSchema({ - serverless: schema.literal(true), - options: { defaultValue: schema.contextRef('serverless') }, - }), - }), -}); - -export type ObservabilityOnboardingConfig = TypeOf; - -// plugin config -export const config: PluginConfigDescriptor = { - exposeToBrowser: { - ui: true, - serverless: true, - }, - schema: configSchema, -}; +export { config, type ObservabilityOnboardingConfig } from './config'; export async function plugin(initializerContext: PluginInitializerContext) { const { ObservabilityOnboardingPlugin } = await import('./plugin'); diff --git a/x-pack/plugins/observability_solution/observability_onboarding/server/plugin.ts b/x-pack/plugins/observability_solution/observability_onboarding/server/plugin.ts index 30aaaf2588388..60b33eb3dd601 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/server/plugin.ts +++ b/x-pack/plugins/observability_solution/observability_onboarding/server/plugin.ts @@ -23,9 +23,9 @@ import { ObservabilityOnboardingPluginStart, ObservabilityOnboardingPluginStartDependencies, } from './types'; -import { ObservabilityOnboardingConfig } from '.'; import { observabilityOnboardingFlow } from './saved_objects/observability_onboarding_status'; import { EsLegacyConfigService } from './services/es_legacy_config_service'; +import { ObservabilityOnboardingConfig } from './config'; export class ObservabilityOnboardingPlugin implements diff --git a/x-pack/plugins/observability_solution/observability_onboarding/server/routes/types.ts b/x-pack/plugins/observability_solution/observability_onboarding/server/routes/types.ts index 689ab14739818..1d30cf05ab255 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/server/routes/types.ts +++ b/x-pack/plugins/observability_solution/observability_onboarding/server/routes/types.ts @@ -13,13 +13,13 @@ import { } from '@kbn/core/server'; import * as t from 'io-ts'; import { ObservabilityOnboardingServerRouteRepository } from '.'; -import { ObservabilityOnboardingConfig } from '..'; import { EsLegacyConfigService } from '../services/es_legacy_config_service'; import { ObservabilityOnboardingPluginSetupDependencies, ObservabilityOnboardingPluginStartDependencies, ObservabilityOnboardingRequestHandlerContext, } from '../types'; +import { ObservabilityOnboardingConfig } from '../config'; export type { ObservabilityOnboardingServerRouteRepository }; diff --git a/x-pack/plugins/observability_solution/observability_onboarding/tsconfig.json b/x-pack/plugins/observability_solution/observability_onboarding/tsconfig.json index 243cfff47210b..4dd1bb3653499 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/tsconfig.json +++ b/x-pack/plugins/observability_solution/observability_onboarding/tsconfig.json @@ -43,7 +43,8 @@ "@kbn/deeplinks-analytics", "@kbn/custom-integrations-plugin", "@kbn/server-route-repository-utils", - "@kbn/core-application-browser" + "@kbn/core-application-browser", + "@kbn/core-plugins-server" ], "exclude": [ "target/**/*" diff --git a/x-pack/plugins/observability_solution/synthetics/common/rules/alert_actions.test.ts b/x-pack/plugins/observability_solution/synthetics/common/rules/alert_actions.test.ts index dd7ad03b9ac32..3bb2c9a4f5aef 100644 --- a/x-pack/plugins/observability_solution/synthetics/common/rules/alert_actions.test.ts +++ b/x-pack/plugins/observability_solution/synthetics/common/rules/alert_actions.test.ts @@ -291,4 +291,77 @@ describe('Alert Actions factory', () => { }, ]); }); + + it('generate expected action for email opsgenie connector', async () => { + const resp = populateAlertActions({ + groupId: SYNTHETICS_MONITOR_STATUS.id, + defaultActions: [ + { + frequency: { + notifyWhen: 'onActionGroupChange', + summary: false, + throttle: null, + }, + actionTypeId: '.opsgenie', + group: 'xpack.synthetics.alerts.actionGroups.monitorStatus', + params: {}, + id: 'f2a3b195-ed76-499a-805d-82d24d4eeba9', + }, + ] as unknown as ActionConnector[], + defaultEmail: { + to: ['test@email.com'], + }, + translations: { + defaultActionMessage: SyntheticsMonitorStatusTranslations.defaultActionMessage, + defaultRecoveryMessage: SyntheticsMonitorStatusTranslations.defaultRecoveryMessage, + defaultSubjectMessage: SyntheticsMonitorStatusTranslations.defaultSubjectMessage, + defaultRecoverySubjectMessage: + SyntheticsMonitorStatusTranslations.defaultRecoverySubjectMessage, + }, + }); + expect(resp).toEqual([ + { + frequency: { + notifyWhen: 'onActionGroupChange', + summary: false, + throttle: null, + }, + group: 'recovered', + id: 'f2a3b195-ed76-499a-805d-82d24d4eeba9', + params: { + subAction: 'closeAlert', + subActionParams: { + alias: '{{rule.id}}:{{alert.id}}', + description: + 'The alert for monitor "{{context.monitorName}}" from {{context.locationNames}} is no longer active: {{context.recoveryReason}}. - Elastic Synthetics\n\nDetails:\n\n- Monitor name: {{context.monitorName}} \n- {{context.monitorUrlLabel}}: {{{context.monitorUrl}}} \n- Monitor type: {{context.monitorType}} \n- From: {{context.locationNames}} \n- Last error received: {{{context.lastErrorMessage}}} \n{{{context.linkMessage}}}', + message: + 'Monitor "{{context.monitorName}}" ({{context.locationNames}}) {{context.recoveryStatus}} - Elastic Synthetics', + priority: 'P2', + tags: ['{{rule.tags}}'], + }, + }, + }, + { + frequency: { + notifyWhen: 'onActionGroupChange', + summary: false, + throttle: null, + }, + group: 'xpack.synthetics.alerts.actionGroups.monitorStatus', + id: 'f2a3b195-ed76-499a-805d-82d24d4eeba9', + params: { + subAction: 'createAlert', + subActionParams: { + alias: '{{rule.id}}:{{alert.id}}', + description: + 'Monitor "{{context.monitorName}}" is {{{context.status}}} from {{context.locationNames}}.{{{context.pendingLastRunAt}}} - Elastic Synthetics\n\nDetails:\n\n- Monitor name: {{context.monitorName}} \n- {{context.monitorUrlLabel}}: {{{context.monitorUrl}}} \n- Monitor type: {{context.monitorType}} \n- Checked at: {{context.checkedAt}} \n- From: {{context.locationNames}} \n- Reason: {{{context.reason}}} \n- Error received: {{{context.lastErrorMessage}}} \n{{{context.linkMessage}}}', + message: + 'Monitor "{{context.monitorName}}" ({{context.locationNames}}) is down - Elastic Synthetics', + priority: 'P2', + tags: ['{{rule.tags}}'], + }, + }, + }, + ]); + }); }); diff --git a/x-pack/plugins/observability_solution/synthetics/common/rules/alert_actions.ts b/x-pack/plugins/observability_solution/synthetics/common/rules/alert_actions.ts index 9b6d9d0992baa..a9981cd257ee6 100644 --- a/x-pack/plugins/observability_solution/synthetics/common/rules/alert_actions.ts +++ b/x-pack/plugins/observability_solution/synthetics/common/rules/alert_actions.ts @@ -14,10 +14,12 @@ import type { WebhookActionParams, EmailActionParams, SlackApiActionParams, + OpsgenieActionParams, } from '@kbn/stack-connectors-plugin/server/connector_types'; import { RuleAction as RuleActionOrig } from '@kbn/alerting-plugin/common'; import { v4 as uuidv4 } from 'uuid'; +import { OpsgenieSubActions } from '@kbn/stack-connectors-plugin/common'; import { ActionConnector, ActionTypeId } from './types'; import { DefaultEmail } from '../runtime_types'; @@ -31,6 +33,7 @@ export const SERVICE_NOW_ACTION_ID: ActionTypeId = '.servicenow'; export const JIRA_ACTION_ID: ActionTypeId = '.jira'; export const WEBHOOK_ACTION_ID: ActionTypeId = '.webhook'; export const EMAIL_ACTION_ID: ActionTypeId = '.email'; +export const OPSGENIE_ACTION_ID: ActionTypeId = '.opsgenie'; export type RuleAction = Omit; @@ -128,6 +131,14 @@ export function populateAlertActions({ actions.push(recoveredAction); } break; + case OPSGENIE_ACTION_ID: + // @ts-expect-error + action.params = getOpsgenieActionParams(translations); + // @ts-expect-error + recoveredAction.params = getOpsgenieActionParams(translations, true); + actions.push(recoveredAction); + break; + default: action.params = { message: translations.defaultActionMessage, @@ -293,3 +304,24 @@ function getEmailActionParams( }, }; } + +function getOpsgenieActionParams( + { + defaultActionMessage, + defaultSubjectMessage, + defaultRecoverySubjectMessage, + defaultRecoveryMessage, + }: Translations, + isRecovery?: boolean +): OpsgenieActionParams { + return { + subAction: isRecovery ? OpsgenieSubActions.CloseAlert : OpsgenieSubActions.CreateAlert, + subActionParams: { + alias: '{{rule.id}}:{{alert.id}}', + tags: ['{{rule.tags}}'], + message: isRecovery ? defaultRecoverySubjectMessage : defaultSubjectMessage, + description: isRecovery ? defaultRecoveryMessage : defaultActionMessage, + priority: 'P2', + }, + } as OpsgenieActionParams; +} diff --git a/x-pack/plugins/observability_solution/synthetics/common/rules/types.ts b/x-pack/plugins/observability_solution/synthetics/common/rules/types.ts index a1888a100c178..0186bc8dc5252 100644 --- a/x-pack/plugins/observability_solution/synthetics/common/rules/types.ts +++ b/x-pack/plugins/observability_solution/synthetics/common/rules/types.ts @@ -16,6 +16,7 @@ import type { TeamsConnectorTypeId, WebhookConnectorTypeId, EmailConnectorTypeId, + OpsgenieConnectorTypeId, } from '@kbn/stack-connectors-plugin/server/connector_types'; import type { ActionConnector as RawActionConnector } from '@kbn/triggers-actions-ui-plugin/public'; @@ -31,6 +32,7 @@ export type ActionTypeId = | typeof ServiceNowConnectorTypeId | typeof JiraConnectorTypeId | typeof WebhookConnectorTypeId - | typeof EmailConnectorTypeId; + | typeof EmailConnectorTypeId + | typeof OpsgenieConnectorTypeId; export type ActionConnector = Omit & { config?: SlackApiConfig }; diff --git a/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/management_list.journey.ts b/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/management_list.journey.ts index c81fe084194d5..7794099093c5e 100644 --- a/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/management_list.journey.ts +++ b/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/management_list.journey.ts @@ -69,6 +69,7 @@ journey(`MonitorManagementList`, async ({ page, params }) => { await page.click('span >> text="Journey / Page"'); await page.click('[aria-label="Apply the selected filters for Type"]'); expect(page.url()).toBe(`${pageBaseUrl}?monitorTypes=%5B%22browser%22%5D`); + await page.waitForTimeout(5000); await page.click('[placeholder="Search by name, URL, host, tag, project or location"]'); await Promise.all([ page.waitForNavigation({ diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/overview_trends/overview_trends.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/overview_trends/overview_trends.ts index d68fc71c59b11..66be7171b2fe4 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/overview_trends/overview_trends.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/overview_trends/overview_trends.ts @@ -53,6 +53,7 @@ export async function fetchTrends( export const createOverviewTrendsRoute: SyntheticsRestApiRouteFactory = () => ({ method: 'POST', + writeAccess: false, path: SYNTHETICS_API_URLS.OVERVIEW_TRENDS, validate: { body: schema.arrayOf( diff --git a/x-pack/plugins/osquery/tsconfig.json b/x-pack/plugins/osquery/tsconfig.json index 2c3426ad033cd..141683d7e500f 100644 --- a/x-pack/plugins/osquery/tsconfig.json +++ b/x-pack/plugins/osquery/tsconfig.json @@ -13,9 +13,7 @@ "server/**/*", "../../../typings/**/*", // ECS and Osquery schema files - "public/common/schemas/*/**.json", - // Emotion theme typing - "./emotion.d.ts" + "public/common/schemas/*/**.json" ], "kbn_references": [ "@kbn/core", diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.tsx index ad6415a1f4fec..b9f4fd8e474a4 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.tsx @@ -226,6 +226,7 @@ export class SimplePrivilegeSection extends Component { privilegeIndex={this.props.role.kibana.findIndex((k) => isGlobalPrivilegeDefinition(k) )} + showAdditionalPermissionsMessage={true} canCustomizeSubFeaturePrivileges={this.props.canCustomizeSubFeaturePrivileges} allSpacesSelected disabled={!this.props.editable} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.test.tsx index fb472191b561d..751db03939dda 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.test.tsx @@ -19,7 +19,6 @@ import type { Space } from '@kbn/spaces-plugin/public'; import { findTestSubject, mountWithIntl } from '@kbn/test-jest-helpers'; import { PrivilegeSpaceForm } from './privilege_space_form'; -import { SpaceSelector } from './space_selector'; import type { Role } from '../../../../../../../common'; const createRole = (kibana: Role['kibana'] = []): Role => { @@ -57,7 +56,7 @@ const renderComponent = (props: React.ComponentProps) }; describe('PrivilegeSpaceForm', () => { - it('renders an empty form when the role contains no Kibana privileges', () => { + it('renders no form when no role is selected', () => { const role = createRole(); const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); @@ -71,40 +70,9 @@ describe('PrivilegeSpaceForm', () => { onCancel: jest.fn(), }); - expect( - wrapper.find(EuiButtonGroup).filter('[name="basePrivilegeButtonGroup"]').props().idSelected - ).toEqual(`basePrivilege_custom`); - expect(wrapper.find(FeatureTable).props().disabled).toEqual(true); - expect(getDisplayedFeaturePrivileges(wrapper)).toMatchInlineSnapshot(` - Object { - "excluded_from_base": Object { - "primaryFeaturePrivilege": "none", - "subFeaturePrivileges": Array [], - }, - "no_sub_features": Object { - "primaryFeaturePrivilege": "none", - "subFeaturePrivileges": Array [], - }, - "with_excluded_sub_features": Object { - "primaryFeaturePrivilege": "none", - "subFeaturePrivileges": Array [], - }, - "with_require_all_spaces_for_feature_and_sub_features": Object { - "primaryFeaturePrivilege": "none", - "subFeaturePrivileges": Array [], - }, - "with_require_all_spaces_sub_features": Object { - "primaryFeaturePrivilege": "none", - "subFeaturePrivileges": Array [], - }, - "with_sub_features": Object { - "primaryFeaturePrivilege": "none", - "subFeaturePrivileges": Array [], - }, - } - `); - - expect(findTestSubject(wrapper, 'spaceFormGlobalPermissionsSupersedeWarning')).toHaveLength(0); + expect(wrapper.find(EuiButtonGroup).filter('[name="basePrivilegeButtonGroup"]')).toHaveLength( + 0 + ); }); it('renders when a base privilege is selected', () => { @@ -232,43 +200,6 @@ describe('PrivilegeSpaceForm', () => { expect(findTestSubject(wrapper, 'spaceFormGlobalPermissionsSupersedeWarning')).toHaveLength(0); }); - it('renders a warning when configuring a global privilege after space privileges are already defined', () => { - const role = createRole([ - { - base: [], - feature: { - with_sub_features: ['read'], - }, - spaces: ['foo'], - }, - { - base: [], - feature: { - with_sub_features: ['all'], - }, - spaces: ['*'], - }, - ]); - - const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); - - const wrapper = renderComponent({ - role, - spaces: displaySpaces, - kibanaPrivileges, - privilegeIndex: -1, - canCustomizeSubFeaturePrivileges: true, - onChange: jest.fn(), - onCancel: jest.fn(), - }); - - wrapper.find(SpaceSelector).props().onChange(['*']); - - wrapper.update(); - - expect(findTestSubject(wrapper, 'globalPrivilegeWarning')).toHaveLength(1); - }); - it('renders a warning when space privileges are less permissive than configured global privileges', () => { const role = createRole([ { diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx index 8275a7b1203ab..5946197d35d69 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import type { EuiButtonColor } from '@elastic/eui'; import { EuiButton, EuiButtonEmpty, @@ -21,7 +20,6 @@ import { EuiForm, EuiFormRow, EuiSpacer, - EuiText, EuiTitle, } from '@elastic/eui'; import { remove } from 'lodash'; @@ -105,20 +103,19 @@ export class PrivilegeSpaceForm extends Component {

- + {this.state.mode === 'create' ? ( + + ) : ( + + )}

- -

- -

-
{this.getForm()} @@ -180,14 +177,13 @@ export class PrivilegeSpaceForm extends Component { label={i18n.translate( 'xpack.security.management.editRole.spacePrivilegeForm.spaceSelectorFormLabel', { - defaultMessage: 'Spaces', + defaultMessage: 'Select spaces', } )} helpText={i18n.translate( 'xpack.security.management.editRole.spacePrivilegeForm.spaceSelectorFormHelpText', { - defaultMessage: - 'Select one or more Kibana spaces to which you wish to assign privileges.', + defaultMessage: 'Users assigned to this role will gain access to selected spaces.', } )} > @@ -198,99 +194,85 @@ export class PrivilegeSpaceForm extends Component { />
- {this.getPrivilegeCallout()} - - - - - - - - -

{this.getFeatureListLabel(this.state.selectedBasePrivilege.length > 0)}

-
- - - - -

{this.getFeatureListDescription(this.state.selectedBasePrivilege.length > 0)}

-
- - - - 0 || !hasSelectedSpaces} - allSpacesSelected={this.state.selectedSpaceIds.includes(ALL_SPACES_ID)} - /> - - {this.requiresGlobalPrivilegeWarning() && ( - - - - } + {Boolean(this.state.selectedSpaceIds.length) && ( + <> + + + + + + + + + 0 || !hasSelectedSpaces} + allSpacesSelected={this.state.selectedSpaceIds.includes(ALL_SPACES_ID)} /> - + )} ); @@ -298,58 +280,34 @@ export class PrivilegeSpaceForm extends Component { private getSaveButton = () => { const { mode } = this.state; - const isGlobal = this.isDefiningGlobalPrivilege(); let buttonText; switch (mode) { case 'create': - if (isGlobal) { - buttonText = ( - - ); - } else { - buttonText = ( - - ); - } + buttonText = ( + + ); break; case 'update': - if (isGlobal) { - buttonText = ( - - ); - } else { - buttonText = ( - - ); - } + buttonText = ( + + ); break; default: throw new Error(`Unsupported mode: ${mode}`); } - let buttonColor: EuiButtonColor = 'primary'; - if (this.requiresGlobalPrivilegeWarning()) { - buttonColor = 'warning'; - } - return ( {buttonText} @@ -357,65 +315,6 @@ export class PrivilegeSpaceForm extends Component { ); }; - private getFeatureListLabel = (disabled: boolean) => { - if (disabled) { - return i18n.translate( - 'xpack.security.management.editRole.spacePrivilegeForm.summaryOfFeaturePrivileges', - { - defaultMessage: 'Summary of feature privileges', - } - ); - } else { - return i18n.translate( - 'xpack.security.management.editRole.spacePrivilegeForm.customizeFeaturePrivileges', - { - defaultMessage: 'Customize by feature', - } - ); - } - }; - - private getFeatureListDescription = (disabled: boolean) => { - if (disabled) { - return i18n.translate( - 'xpack.security.management.editRole.spacePrivilegeForm.featurePrivilegeSummaryDescription', - { - defaultMessage: - 'Some features might be hidden by the space or affected by a global space privilege.', - } - ); - } else { - return i18n.translate( - 'xpack.security.management.editRole.spacePrivilegeForm.customizeFeaturePrivilegeDescription', - { - defaultMessage: - 'Increase privilege levels on a per feature basis. Some features might be hidden by the space or affected by a global space privilege.', - } - ); - } - }; - - private getPrivilegeCallout = () => { - if (this.isDefiningGlobalPrivilege()) { - return ( - - - - ); - } - - return null; - }; - private closeFlyout = () => { this.props.onCancel(); }; @@ -594,13 +493,4 @@ export class PrivilegeSpaceForm extends Component { }; private isDefiningGlobalPrivilege = () => this.state.selectedSpaceIds.includes('*'); - - private requiresGlobalPrivilegeWarning = () => { - const hasOtherSpacePrivilegesDefined = this.props.role.kibana.length > 0; - return ( - this.state.mode === 'create' && - this.isDefiningGlobalPrivilege() && - hasOtherSpacePrivilegesDefined - ); - }; } diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_selector.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_selector.tsx index 99e9edb48d556..d7edbdfa59e8b 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_selector.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_selector.tsx @@ -57,6 +57,9 @@ export class SpaceSelector extends Component { aria-label={i18n.translate('xpack.security.management.editRole.spaceSelectorLabel', { defaultMessage: 'Spaces', })} + placeholder={i18n.translate('xpack.security.management.editRole.spaceSelectorPlaceholder', { + defaultMessage: 'Add spaces...', + })} fullWidth options={this.getOptions()} renderOption={renderOption} diff --git a/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts b/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts index c545474b9eed3..eba4c4124337d 100644 --- a/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts +++ b/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts @@ -374,6 +374,7 @@ import type { ResolveTimelineResponse, } from './timeline/resolve_timeline/resolve_timeline_route.gen'; import type { + CreateRuleMigrationRequestParamsInput, CreateRuleMigrationRequestBodyInput, CreateRuleMigrationResponse, GetAllStatsRuleMigrationResponse, @@ -696,7 +697,7 @@ Migrations are initiated per index. While the process is neither destructive nor this.log.info(`${new Date().toISOString()} Calling API CreateRuleMigration`); return this.kbnClient .request({ - path: '/internal/siem_migrations/rules', + path: replaceParams('/internal/siem_migrations/rules/{migration_id}', props.params), headers: { [ELASTIC_HTTP_VERSION_HEADER]: '1', }, @@ -2290,6 +2291,7 @@ export interface CreateRuleProps { body: CreateRuleRequestBodyInput; } export interface CreateRuleMigrationProps { + params: CreateRuleMigrationRequestParamsInput; body: CreateRuleMigrationRequestBodyInput; } export interface CreateTimelinesProps { diff --git a/x-pack/plugins/security_solution/common/siem_migrations/constants.ts b/x-pack/plugins/security_solution/common/siem_migrations/constants.ts index 789947150a67e..e04130e7f44d7 100644 --- a/x-pack/plugins/security_solution/common/siem_migrations/constants.ts +++ b/x-pack/plugins/security_solution/common/siem_migrations/constants.ts @@ -11,6 +11,8 @@ export const SIEM_MIGRATIONS_PATH = '/internal/siem_migrations' as const; export const SIEM_RULE_MIGRATIONS_PATH = `${SIEM_MIGRATIONS_PATH}/rules` as const; export const SIEM_RULE_MIGRATIONS_ALL_STATS_PATH = `${SIEM_RULE_MIGRATIONS_PATH}/stats` as const; +export const SIEM_RULE_MIGRATION_CREATE_PATH = + `${SIEM_RULE_MIGRATIONS_PATH}/{migration_id?}` as const; export const SIEM_RULE_MIGRATION_PATH = `${SIEM_RULE_MIGRATIONS_PATH}/{migration_id}` as const; export const SIEM_RULE_MIGRATION_START_PATH = `${SIEM_RULE_MIGRATION_PATH}/start` as const; export const SIEM_RULE_MIGRATION_RETRY_PATH = `${SIEM_RULE_MIGRATION_PATH}/retry` as const; diff --git a/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.gen.ts b/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.gen.ts index aa69f3b3c27f0..8a549e8e11817 100644 --- a/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.gen.ts +++ b/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.gen.ts @@ -17,19 +17,28 @@ import { z } from '@kbn/zod'; import { ArrayFromString } from '@kbn/zod-helpers'; +import { NonEmptyString } from '../../../../api/model/primitives.gen'; import { - OriginalRule, ElasticRulePartial, RuleMigrationTranslationResult, RuleMigrationComments, RuleMigrationTaskStats, + OriginalRule, RuleMigration, RuleMigrationTranslationStats, RuleMigrationResourceData, RuleMigrationResourceType, RuleMigrationResource, } from '../../rule_migration.gen'; -import { NonEmptyString, ConnectorId, LangSmithOptions } from '../../common.gen'; +import { ConnectorId, LangSmithOptions } from '../../common.gen'; + +export type CreateRuleMigrationRequestParams = z.infer; +export const CreateRuleMigrationRequestParams = z.object({ + migration_id: NonEmptyString.optional(), +}); +export type CreateRuleMigrationRequestParamsInput = z.input< + typeof CreateRuleMigrationRequestParams +>; export type CreateRuleMigrationRequestBody = z.infer; export const CreateRuleMigrationRequestBody = z.array(OriginalRule); diff --git a/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.schema.yaml b/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.schema.yaml index a062b75d41699..8b9d264cf4104 100644 --- a/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.schema.yaml +++ b/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.schema.yaml @@ -4,38 +4,7 @@ info: version: '1' paths: # Rule migrations APIs - /internal/siem_migrations/rules: - post: - summary: Creates a new rule migration - operationId: CreateRuleMigration - x-codegen-enabled: true - x-internal: true - description: Creates a new SIEM rules migration using the original vendor rules provided - tags: - - SIEM Rule Migrations - requestBody: - required: true - content: - application/json: - schema: - type: array - items: - $ref: '../../rule_migration.schema.yaml#/components/schemas/OriginalRule' - responses: - 200: - description: Indicates migration have been created correctly. - content: - application/json: - schema: - type: object - required: - - migration_id - properties: - migration_id: - description: The migration id created. - $ref: '../../common.schema.yaml#/components/schemas/NonEmptyString' - put: summary: Updates rules migrations operationId: UpdateRuleMigration @@ -57,7 +26,7 @@ paths: properties: id: description: The rule migration id - $ref: '../../common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' elastic_rule: description: The migrated elastic rule attributes to update. $ref: '../../rule_migration.schema.yaml#/components/schemas/ElasticRulePartial' @@ -81,95 +50,64 @@ paths: type: boolean description: Indicates rules migrations have been updated. - /internal/siem_migrations/rules/{migration_id}/install: - post: - summary: Installs translated migration rules - operationId: InstallMigrationRules + /internal/siem_migrations/rules/stats: + get: + summary: Retrieves the stats for all rule migrations + operationId: GetAllStatsRuleMigration x-codegen-enabled: true - description: Installs migration rules + x-internal: true + description: Retrieves the rule migrations stats for all migrations stored in the system tags: - SIEM Rule Migrations - parameters: - - name: migration_id - in: path - required: true - schema: - description: The migration id to isnstall rules for - $ref: '../../common.schema.yaml#/components/schemas/NonEmptyString' - requestBody: - required: true - content: - application/json: - schema: - type: array - items: - description: The rule migration id - $ref: '../../common.schema.yaml#/components/schemas/NonEmptyString' responses: 200: - description: Indicates rules migrations have been installed correctly. + description: Indicates rule migrations have been retrieved correctly. content: application/json: schema: - type: object - required: - - installed - properties: - installed: - type: boolean - description: Indicates rules migrations have been installed. + type: array + items: + $ref: '../../rule_migration.schema.yaml#/components/schemas/RuleMigrationTaskStats' - /internal/siem_migrations/rules/{migration_id}/install_translated: + ## Specific rule migration APIs + + /internal/siem_migrations/rules/{migration_id}: post: - summary: Installs all translated migration rules - operationId: InstallTranslatedMigrationRules + summary: Creates a new rule migration + operationId: CreateRuleMigration x-codegen-enabled: true - description: Installs all translated migration rules + x-internal: true + description: Creates a new SIEM rules migration using the original vendor rules provided tags: - SIEM Rule Migrations parameters: - name: migration_id in: path - required: true + required: false schema: - description: The migration id to install translated rules for - $ref: '../../common.schema.yaml#/components/schemas/NonEmptyString' + description: The migration id to create rules for + $ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + $ref: '../../rule_migration.schema.yaml#/components/schemas/OriginalRule' responses: 200: - description: Indicates rules migrations have been installed correctly. + description: Indicates migration have been created correctly. content: application/json: schema: type: object required: - - installed + - migration_id properties: - installed: - type: boolean - description: Indicates rules migrations have been installed. - - /internal/siem_migrations/rules/stats: - get: - summary: Retrieves the stats for all rule migrations - operationId: GetAllStatsRuleMigration - x-codegen-enabled: true - x-internal: true - description: Retrieves the rule migrations stats for all migrations stored in the system - tags: - - SIEM Rule Migrations - responses: - 200: - description: Indicates rule migrations have been retrieved correctly. - content: - application/json: - schema: - type: array - items: - $ref: '../../rule_migration.schema.yaml#/components/schemas/RuleMigrationTaskStats' - - ## Specific rule migration APIs - - /internal/siem_migrations/rules/{migration_id}: + migration_id: + description: The migration id created. + $ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' get: summary: Retrieves all the rules of a migration operationId: GetRuleMigration @@ -184,7 +122,7 @@ paths: required: true schema: description: The migration id to start - $ref: '../../common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' - name: page in: query required: false @@ -222,6 +160,73 @@ paths: 204: description: Indicates the migration id was not found. + /internal/siem_migrations/rules/{migration_id}/install: + post: + summary: Installs translated migration rules + operationId: InstallMigrationRules + x-codegen-enabled: true + description: Installs migration rules + tags: + - SIEM Rule Migrations + parameters: + - name: migration_id + in: path + required: true + schema: + description: The migration id to install rules for + $ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + description: The rule migration id + $ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' + responses: + 200: + description: Indicates rules migrations have been installed correctly. + content: + application/json: + schema: + type: object + required: + - installed + properties: + installed: + type: boolean + description: Indicates rules migrations have been installed. + + /internal/siem_migrations/rules/{migration_id}/install_translated: + post: + summary: Installs all translated migration rules + operationId: InstallTranslatedMigrationRules + x-codegen-enabled: true + description: Installs all translated migration rules + tags: + - SIEM Rule Migrations + parameters: + - name: migration_id + in: path + required: true + schema: + description: The migration id to install translated rules for + $ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' + responses: + 200: + description: Indicates rules migrations have been installed correctly. + content: + application/json: + schema: + type: object + required: + - installed + properties: + installed: + type: boolean + description: Indicates rules migrations have been installed. + /internal/siem_migrations/rules/{migration_id}/start: put: summary: Starts a rule migration @@ -237,7 +242,7 @@ paths: required: true schema: description: The migration id to start - $ref: '../../common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' requestBody: required: true content: @@ -282,7 +287,7 @@ paths: required: true schema: description: The migration id to fetch stats for - $ref: '../../common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' responses: 200: description: Indicates the migration stats has been retrieved correctly. @@ -307,7 +312,7 @@ paths: required: true schema: description: The migration id to fetch translation stats for - $ref: '../../common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' responses: 200: description: Indicates the migration stats has been retrieved correctly. @@ -333,7 +338,7 @@ paths: required: true schema: description: The migration id to stop - $ref: '../../common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' responses: 200: description: Indicates migration task stop has been processed successfully. @@ -368,7 +373,7 @@ paths: required: true schema: description: The migration id to attach the resources - $ref: '../../common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' requestBody: required: true content: @@ -406,7 +411,7 @@ paths: required: true schema: description: The migration id to attach the resources - $ref: '../../common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' - name: type in: query required: false diff --git a/x-pack/plugins/security_solution/common/siem_migrations/model/common.gen.ts b/x-pack/plugins/security_solution/common/siem_migrations/model/common.gen.ts index 9b1d0756c3a3b..c6d0959cc10cf 100644 --- a/x-pack/plugins/security_solution/common/siem_migrations/model/common.gen.ts +++ b/x-pack/plugins/security_solution/common/siem_migrations/model/common.gen.ts @@ -16,15 +16,6 @@ import { z } from '@kbn/zod'; -/** - * A string that is not empty and does not contain only whitespace - */ -export type NonEmptyString = z.infer; -export const NonEmptyString = z - .string() - .min(1) - .regex(/^(?! *$).+$/); - /** * The GenAI connector id to use. */ diff --git a/x-pack/plugins/security_solution/common/siem_migrations/model/common.schema.yaml b/x-pack/plugins/security_solution/common/siem_migrations/model/common.schema.yaml index a50225df778ad..14a5160427f8a 100644 --- a/x-pack/plugins/security_solution/common/siem_migrations/model/common.schema.yaml +++ b/x-pack/plugins/security_solution/common/siem_migrations/model/common.schema.yaml @@ -6,11 +6,6 @@ paths: {} components: x-codegen-enabled: true schemas: - NonEmptyString: - type: string - pattern: ^(?! *$).+$ - minLength: 1 - description: A string that is not empty and does not contain only whitespace ConnectorId: type: string description: The GenAI connector id to use. diff --git a/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts b/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts index 61706077d9549..b52cdb1c91f19 100644 --- a/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts +++ b/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts @@ -16,7 +16,7 @@ import { z } from '@kbn/zod'; -import { NonEmptyString } from './common.gen'; +import { NonEmptyString } from '../../api/model/primitives.gen'; /** * The original rule vendor identifier. @@ -24,6 +24,19 @@ import { NonEmptyString } from './common.gen'; export type OriginalRuleVendor = z.infer; export const OriginalRuleVendor = z.literal('splunk'); +/** + * The original rule annotations containing additional information. + */ +export type OriginalRuleAnnotations = z.infer; +export const OriginalRuleAnnotations = z + .object({ + /** + * The original rule Mitre Attack IDs. + */ + mitre_attack: z.array(z.string()).optional(), + }) + .catchall(z.unknown()); + /** * The original rule to migrate. */ @@ -40,7 +53,7 @@ export const OriginalRule = z.object({ /** * The original rule name. */ - title: z.string(), + title: NonEmptyString, /** * The original rule description. */ @@ -48,15 +61,15 @@ export const OriginalRule = z.object({ /** * The original rule query. */ - query: z.string(), + query: z.string().min(1), /** * The original rule query language. */ query_language: z.string(), /** - * The original rule Mitre Attack technique IDs. + * The original rule annotations containing additional information. */ - mitre_attack_ids: z.array(z.string()).optional(), + annotations: OriginalRuleAnnotations.optional(), }); /** diff --git a/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml b/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml index fdcbb7b04515a..4c88c66fc604d 100644 --- a/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml +++ b/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml @@ -12,6 +12,17 @@ components: enum: - splunk + OriginalRuleAnnotations: + type: object + description: The original rule annotations containing additional information. + additionalProperties: true + properties: + mitre_attack: + type: array + description: The original rule Mitre Attack IDs. + items: + type: string + OriginalRule: type: object description: The original rule to migrate. @@ -25,27 +36,26 @@ components: properties: id: description: The original rule id. - $ref: './common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' vendor: description: The original rule vendor identifier. $ref: '#/components/schemas/OriginalRuleVendor' title: - type: string description: The original rule name. + $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' description: type: string description: The original rule description. query: type: string + minLength: 1 description: The original rule query. query_language: type: string description: The original rule query language. - mitre_attack_ids: - type: array - items: - type: string - description: The original rule Mitre Attack technique IDs. + annotations: + description: The original rule annotations containing additional information. + $ref: '#/components/schemas/OriginalRuleAnnotations' ElasticRule: type: object @@ -72,7 +82,7 @@ components: - esql prebuilt_rule_id: description: The Elastic prebuilt rule id matched. - $ref: './common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' integration_ids: type: array items: @@ -80,7 +90,7 @@ components: description: The Elastic integration IDs related to the rule. id: description: The Elastic rule id installed as a result. - $ref: './common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' ElasticRulePartial: description: The partial version of the migrated elastic rule. @@ -96,7 +106,7 @@ components: properties: id: description: The rule migration id - $ref: './common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' - $ref: '#/components/schemas/RuleMigrationData' RuleMigrationData: @@ -114,10 +124,10 @@ components: description: The moment of creation migration_id: description: The migration id. - $ref: './common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' created_by: description: The username of the user who created the migration. - $ref: './common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' original_rule: description: The original rule to migrate. $ref: '#/components/schemas/OriginalRule' @@ -153,7 +163,7 @@ components: properties: id: description: The migration id - $ref: './common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' status: description: Indicates if the migration task status. $ref: '#/components/schemas/RuleMigrationTaskStatus' @@ -207,7 +217,7 @@ components: properties: id: description: The migration id - $ref: './common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' rules: type: object description: The rules migration translation stats. @@ -293,10 +303,10 @@ components: properties: id: description: The rule resource migration id - $ref: './common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' migration_id: description: The migration id - $ref: './common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' updated_at: type: string description: The moment of the last update diff --git a/x-pack/plugins/security_solution/public/cloud_security_posture/components/alerts/alerts_preview.tsx b/x-pack/plugins/security_solution/public/cloud_security_posture/components/alerts/alerts_preview.tsx index 8edd0b7981936..8592ed61abe33 100644 --- a/x-pack/plugins/security_solution/public/cloud_security_posture/components/alerts/alerts_preview.tsx +++ b/x-pack/plugins/security_solution/public/cloud_security_posture/components/alerts/alerts_preview.tsx @@ -93,7 +93,7 @@ export const AlertsPreview = ({ const { goToEntityInsightTab } = useNavigateEntityInsight({ field, value, - queryIdExtension: 'ALERTS_PREVIEW', + queryIdExtension: isPreviewMode ? 'ALERTS_PREVIEW_TRUE' : 'ALERTS_PREVIEW_FALSE', subTab: CspInsightLeftPanelSubTab.ALERTS, }); const link = useMemo( diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/use_insight_data_providers.test.ts b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/use_insight_data_providers.test.ts index 4d1807b91b718..f8b0e1bff9441 100644 --- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/use_insight_data_providers.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/use_insight_data_providers.test.ts @@ -4,13 +4,11 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { renderHook } from '@testing-library/react-hooks'; -import type { UseInsightDataProvidersProps, Provider } from './use_insight_data_providers'; + +import { renderHook } from '@testing-library/react'; +import type { Provider } from './use_insight_data_providers'; import type { TimelineEventsDetailsItem } from '../../../../../../common/search_strategy'; -import { - useInsightDataProviders, - type UseInsightDataProvidersResult, -} from './use_insight_data_providers'; +import { useInsightDataProviders } from './use_insight_data_providers'; import { mockAlertDetailsData } from '../../../event_details/mocks'; const mockAlertDetailsDataWithIsObject = mockAlertDetailsData.map((detail) => { @@ -103,7 +101,7 @@ const providerWithRange: Provider[][] = [ describe('useInsightDataProviders', () => { it('should return 2 data providers, 1 with a nested provider ANDed to it', () => { - const { result } = renderHook(() => + const { result } = renderHook(() => useInsightDataProviders({ providers: nestedAndProvider, alertData: mockAlertDetailsDataWithIsObject, @@ -117,7 +115,7 @@ describe('useInsightDataProviders', () => { }); it('should return 3 data providers without any containing nested ANDs', () => { - const { result } = renderHook(() => + const { result } = renderHook(() => useInsightDataProviders({ providers: topLevelOnly, alertData: mockAlertDetailsDataWithIsObject, @@ -130,7 +128,7 @@ describe('useInsightDataProviders', () => { }); it('should use the string literal if no field in the alert matches a bracketed value', () => { - const { result } = renderHook(() => + const { result } = renderHook(() => useInsightDataProviders({ providers: nonExistantField, alertData: mockAlertDetailsDataWithIsObject, @@ -145,7 +143,7 @@ describe('useInsightDataProviders', () => { }); it('should use template data providers when called without alertData', () => { - const { result } = renderHook(() => + const { result } = renderHook(() => useInsightDataProviders({ providers: nestedAndProvider, }) @@ -159,7 +157,7 @@ describe('useInsightDataProviders', () => { }); it('should return an empty array of dataProviders and populated filters if a provider contains a range type', () => { - const { result } = renderHook(() => + const { result } = renderHook(() => useInsightDataProviders({ providers: providerWithRange, }) diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/use_insight_query.test.ts b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/use_insight_query.test.ts index a162c625c7adc..98d5e2f3f7df4 100644 --- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/use_insight_query.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/use_insight_query.test.ts @@ -4,13 +4,11 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type React from 'react'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import type { QueryOperator } from '@kbn/timelines-plugin/common'; import { DataProviderTypeEnum } from '../../../../../../common/api/timeline'; import { useInsightQuery } from './use_insight_query'; import { TestProviders } from '../../../../mock'; -import type { UseInsightQuery, UseInsightQueryResult } from './use_insight_query'; import { IS_OPERATOR } from '../../../../../timelines/components/timeline/data_providers/data_provider'; const mockProvider = { @@ -30,7 +28,7 @@ const mockProvider = { describe('useInsightQuery', () => { it('should return renderable defaults', () => { - const { result } = renderHook, UseInsightQueryResult>( + const { result } = renderHook( () => useInsightQuery({ dataProviders: [mockProvider], diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/esql_query_edit/validators/esql_query_validator_factory.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/esql_query_edit/validators/esql_query_validator_factory.ts index 90cdaff14cc9b..c5b54db172a18 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/esql_query_edit/validators/esql_query_validator_factory.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/esql_query_edit/validators/esql_query_validator_factory.ts @@ -7,10 +7,7 @@ import type { QueryClient } from '@tanstack/react-query'; import type { DatatableColumn } from '@kbn/expressions-plugin/common'; -import type { ESQLAstQueryExpression, ESQLCommandOption } from '@kbn/esql-ast'; -import { parse } from '@kbn/esql-ast'; -import { isAggregatingQuery } from '@kbn/securitysolution-utils'; -import { isColumnItem, isOptionItem } from '@kbn/esql-validation-autocomplete'; +import { parseEsqlQuery } from '@kbn/securitysolution-utils'; import type { FormData, ValidationError, ValidationFunc } from '../../../../../shared_imports'; import type { FieldValueQueryBar } from '../../../../rule_creation_ui/components/query_bar_field'; import { fetchEsqlQueryColumns } from '../../../logic/esql_query_columns'; @@ -79,59 +76,6 @@ function hasIdColumn(columns: DatatableColumn[]): boolean { return columns.some(({ id }) => '_id' === id); } -/** - * check if esql query valid for Security rule: - * - if it's non aggregation query it must have metadata operator - */ -function parseEsqlQuery(query: string) { - const { root, errors } = parse(query); - const isEsqlQueryAggregating = isAggregatingQuery(root); - - return { - errors, - isEsqlQueryAggregating, - hasMetadataOperator: computeHasMetadataOperator(root), - }; -} - -/** - * checks whether query has metadata _id operator - */ -function computeHasMetadataOperator(astExpression: ESQLAstQueryExpression): boolean { - // Check whether the `from` command has `metadata` operator - const metadataOption = getMetadataOption(astExpression); - if (!metadataOption) { - return false; - } - - // Check whether the `metadata` operator has `_id` argument - const idColumnItem = metadataOption.args.find( - (fromArg) => isColumnItem(fromArg) && fromArg.name === '_id' - ); - if (!idColumnItem) { - return false; - } - - return true; -} - -function getMetadataOption(astExpression: ESQLAstQueryExpression): ESQLCommandOption | undefined { - const fromCommand = astExpression.commands.find((x) => x.name === 'from'); - - if (!fromCommand?.args) { - return undefined; - } - - // Check whether the `from` command has `metadata` operator - for (const fromArg of fromCommand.args) { - if (isOptionItem(fromArg) && fromArg.name === 'metadata') { - return fromArg; - } - } - - return undefined; -} - function constructSyntaxError(error: Error): ValidationError { return { code: ESQL_ERROR_CODES.INVALID_SYNTAX, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_about_rule/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_about_rule/index.tsx index ac5c91aa8a25a..2d2ef8c8930d6 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_about_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_about_rule/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiAccordion, EuiFlexItem, EuiSpacer, EuiFormRow } from '@elastic/eui'; +import { EuiAccordion, EuiFlexItem, EuiSpacer, EuiFormRow, EuiToolTip } from '@elastic/eui'; import type { FC } from 'react'; import React, { memo, useCallback, useEffect, useState, useMemo } from 'react'; import styled from 'styled-components'; @@ -13,6 +13,7 @@ import styled from 'styled-components'; import type { DataViewBase } from '@kbn/es-query'; import type { Severity, Type } from '@kbn/securitysolution-io-ts-alerting-types'; +import type { RuleSource } from '../../../../../common/api/detection_engine'; import { isThreatMatchRule, isEsqlRule } from '../../../../../common/detection_engine/utils'; import type { RuleStepProps, @@ -55,6 +56,7 @@ interface StepAboutRuleProps extends RuleStepProps { timestampOverride: string; form: FormHook; esqlQuery?: string | undefined; + ruleSource?: RuleSource; } interface StepAboutRuleReadOnlyProps { @@ -85,6 +87,7 @@ const StepAboutRuleComponent: FC = ({ isLoading, form, esqlQuery, + ruleSource, }) => { const { data } = useKibana().services; @@ -280,31 +283,51 @@ const StepAboutRuleComponent: FC = ({ }} /> - + + + - + + + = ({ rule }) => { form={aboutStepForm} esqlQuery={esqlQueryForAboutStep} key="aboutStep" + ruleSource={rule.rule_source} /> )} @@ -343,6 +344,7 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => { [ isPrebuiltRulesCustomizationEnabled, rule?.immutable, + rule.rule_source, rule?.id, activeStep, loading, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_bulk_action_mutation.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_bulk_action_mutation.ts index 7d8f42adad31b..376b26684d4a5 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_bulk_action_mutation.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_bulk_action_mutation.ts @@ -101,6 +101,7 @@ export const useBulkActionMutation = ( invalidateFetchRuleByIdQuery(); invalidateFetchRuleManagementFilters(); invalidateFetchCoverageOverviewQuery(); + invalidateFetchPrebuiltRulesUpgradeReviewQuery(); break; } diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_update_rule_mutation.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_update_rule_mutation.ts index 5eedf122a6ac6..812b97b669378 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_update_rule_mutation.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_update_rule_mutation.ts @@ -17,6 +17,7 @@ import { useInvalidateFindRulesQuery } from './use_find_rules_query'; import { useUpdateRuleByIdCache } from './use_fetch_rule_by_id_query'; import { useInvalidateFetchRuleManagementFiltersQuery } from './use_fetch_rule_management_filters_query'; import { useInvalidateFetchCoverageOverviewQuery } from './use_fetch_coverage_overview_query'; +import { useInvalidateFetchPrebuiltRulesUpgradeReviewQuery } from './prebuilt_rules/use_fetch_prebuilt_rules_upgrade_review_query'; export const UPDATE_RULE_MUTATION_KEY = ['PUT', DETECTION_ENGINE_RULES_URL]; @@ -26,6 +27,7 @@ export const useUpdateRuleMutation = ( const invalidateFindRulesQuery = useInvalidateFindRulesQuery(); const invalidateFetchRuleManagementFilters = useInvalidateFetchRuleManagementFiltersQuery(); const invalidateFetchCoverageOverviewQuery = useInvalidateFetchCoverageOverviewQuery(); + const invalidatePrebuiltRulesUpdateReview = useInvalidateFetchPrebuiltRulesUpgradeReviewQuery(); const updateRuleCache = useUpdateRuleByIdCache(); return useMutation( @@ -37,6 +39,7 @@ export const useUpdateRuleMutation = ( invalidateFindRulesQuery(); invalidateFetchRuleManagementFilters(); invalidateFetchCoverageOverviewQuery(); + invalidatePrebuiltRulesUpdateReview(); const [response] = args; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data.test.tsx index 74e04a61ae5bd..e34d8be795ba8 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data.test.tsx @@ -5,11 +5,10 @@ * 2.0. */ -import { renderHook } from '@testing-library/react-hooks'; -import type { PropsWithChildren } from 'react'; +import { renderHook } from '@testing-library/react'; import { TestProviders } from '../../../../common/mock'; import { ALERTS_QUERY_NAMES } from '../../../containers/detection_engine/alerts/constants'; -import type { UseAlerts, UseAlertsQueryProps } from './use_summary_chart_data'; +import type { UseAlertsQueryProps } from './use_summary_chart_data'; import { useSummaryChartData, getAlertsQuery } from './use_summary_chart_data'; import * as aggregations from './aggregations'; import * as severityMock from '../severity_level_panel/mock_data'; @@ -76,7 +75,7 @@ describe('getAlertsQuery', () => { // helper function to render the hook const renderUseSummaryChartData = (props: Partial = {}) => - renderHook, ReturnType>( + renderHook( () => useSummaryChartData({ aggregations: aggregations.severityAggregations, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/chart_panels/alerts_local_storage/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/chart_panels/alerts_local_storage/index.test.tsx index 303b85a40e6ee..82e8456e28571 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/chart_panels/alerts_local_storage/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/chart_panels/alerts_local_storage/index.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import React from 'react'; import { useAlertsLocalStorage } from '.'; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/hooks.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/hooks.test.tsx index c89626b9edcec..f7fd2eced3d3d 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/hooks.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/hooks.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import type { FieldSpec } from '@kbn/data-plugin/common'; import type { GetAggregatableFields, UseInspectButtonParams } from './hooks'; @@ -120,7 +120,7 @@ describe('hooks', () => { jest.clearAllMocks(); }); it('returns only aggregateable fields', () => { - const wrapper = ({ children }: { children: JSX.Element }) => ( + const wrapper = ({ children }: React.PropsWithChildren) => ( {children} ); const { result, unmount } = renderHook(() => useStackByFields(), { wrapper }); @@ -137,7 +137,7 @@ describe('hooks', () => { browserFields: { base: mockBrowserFields.base }, }); - const wrapper = ({ children }: { children: JSX.Element }) => ( + const wrapper = ({ children }: React.PropsWithChildren) => ( {children} ); const useLensCompatibleFields = true; @@ -155,7 +155,7 @@ describe('hooks', () => { browserFields: { nestedField: mockBrowserFields.nestedField }, }); - const wrapper = ({ children }: { children: JSX.Element }) => ( + const wrapper = ({ children }: React.PropsWithChildren) => ( {children} ); const useLensCompatibleFields = true; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_resolver.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_resolver.test.tsx index cb1fa0b4ef86e..fe3648c426bc0 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_resolver.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_resolver.test.tsx @@ -7,7 +7,7 @@ import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; import { useIsInvestigateInResolverActionEnabled } from './investigate_in_resolver'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { TestProviders } from '../../../../common/mock'; describe('InvestigateInResolverAction', () => { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.test.tsx index b60a7a5644b52..d22520f9f9553 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.test.tsx @@ -7,8 +7,7 @@ import React from 'react'; import { EuiContextMenu, EuiPopover } from '@elastic/eui'; -import { act, renderHook } from '@testing-library/react-hooks'; -import { render, screen } from '@testing-library/react'; +import { render, screen, renderHook, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useAddToCaseActions } from './use_add_to_case_actions'; import { TestProviders } from '../../../../common/mock'; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.test.tsx index a7bde416b7ca0..b3173bcff3770 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.test.tsx @@ -5,12 +5,11 @@ * 2.0. */ -import { renderHook } from '@testing-library/react-hooks'; import type { UseAlertAssigneesActionsProps } from './use_alert_assignees_actions'; import { useAlertAssigneesActions } from './use_alert_assignees_actions'; import { useAlertsPrivileges } from '../../../containers/detection_engine/alerts/use_alerts_privileges'; import type { AlertTableContextMenuItem } from '../types'; -import { render } from '@testing-library/react'; +import { render, renderHook } from '@testing-library/react'; import React from 'react'; import type { EuiContextMenuPanelDescriptor } from '@elastic/eui'; import { EuiPopover, EuiContextMenu } from '@elastic/eui'; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_tags_actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_tags_actions.test.tsx index 4e037467597c9..64461725b8622 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_tags_actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_tags_actions.test.tsx @@ -5,12 +5,11 @@ * 2.0. */ -import { renderHook } from '@testing-library/react-hooks'; import type { UseAlertTagsActionsProps } from './use_alert_tags_actions'; import { useAlertTagsActions } from './use_alert_tags_actions'; import { useAlertsPrivileges } from '../../../containers/detection_engine/alerts/use_alerts_privileges'; import type { AlertTableContextMenuItem } from '../types'; -import { render } from '@testing-library/react'; +import { render, renderHook } from '@testing-library/react'; import React from 'react'; import type { EuiContextMenuPanelDescriptor } from '@elastic/eui'; import { EuiPopover, EuiContextMenu } from '@elastic/eui'; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.test.tsx index 6d20ef3973337..9c688816f59ea 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.test.tsx @@ -4,8 +4,8 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { renderHook, act } from '@testing-library/react-hooks'; -import { fireEvent, render, waitFor } from '@testing-library/react'; + +import { fireEvent, render, waitFor, renderHook, act } from '@testing-library/react'; import { of } from 'rxjs'; import { TestProviders } from '../../../../common/mock'; import { useKibana } from '../../../../common/lib/kibana'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/hooks/use_fetch_alerts.test.ts b/x-pack/plugins/security_solution/public/flyout/document_details/left/hooks/use_fetch_alerts.test.ts index c4910f5daa42a..9f9d031cc0bf2 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/hooks/use_fetch_alerts.test.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/hooks/use_fetch_alerts.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook, waitFor } from '@testing-library/react'; import { useKibana } from '../../../../common/lib/kibana'; import { createFindAlerts } from '../services/find_alerts'; import { useFetchAlerts, type UseAlertsQueryParams } from './use_fetch_alerts'; @@ -41,15 +41,14 @@ describe('useFetchAlerts', () => { sort: [{ '@timestamp': 'desc' }], }; - const { result, waitFor } = renderHook(() => useFetchAlerts(params), { + const { result } = renderHook(() => useFetchAlerts(params), { wrapper: createReactQueryWrapper(), }); expect(result.current.loading).toBe(true); - await waitFor(() => !result.current.loading); + await waitFor(() => expect(result.current.loading).toBe(false)); - expect(result.current.loading).toBe(false); expect(result.current.error).toBe(false); expect(result.current.totalItemCount).toBe(10); expect(result.current.data).toEqual(['alert1', 'alert2', 'alert3']); @@ -70,13 +69,13 @@ describe('useFetchAlerts', () => { sort: [{ '@timestamp': 'desc' }], }; - const { result, waitFor } = renderHook(() => useFetchAlerts(params), { + const { result } = renderHook(() => useFetchAlerts(params), { wrapper: createReactQueryWrapper(), }); expect(result.current.loading).toBe(true); - await waitFor(() => !result.current.loading); + await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.loading).toBe(false); expect(result.current.error).toBe(true); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/hooks/use_response_actions_view.test.ts b/x-pack/plugins/security_solution/public/flyout/document_details/left/hooks/use_response_actions_view.test.ts index cafac9f3a0b9f..5e6469e44a048 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/hooks/use_response_actions_view.test.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/hooks/use_response_actions_view.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { useResponseActionsView } from './use_response_actions_view'; import { mockSearchHit } from '../../shared/mocks/mock_search_hit'; import { mockDataAsNestedObject } from '../../shared/mocks/mock_data_as_nested_object'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/hooks/use_threat_intelligence_details.test.ts b/x-pack/plugins/security_solution/public/flyout/document_details/left/hooks/use_threat_intelligence_details.test.ts index e40cd74709cfd..522fd74765fd8 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/hooks/use_threat_intelligence_details.test.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/hooks/use_threat_intelligence_details.test.ts @@ -6,7 +6,7 @@ */ import { useThreatIntelligenceDetails } from './use_threat_intelligence_details'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { SecurityPageName } from '@kbn/deeplinks-security'; import { useTimelineEventsDetails } from '../../../../timelines/containers/details'; import { useSourcererDataView } from '../../../../sourcerer/containers'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_accordion_state.test.ts b/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_accordion_state.test.ts index ac3c9c8a6be0d..80ac41b539fa1 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_accordion_state.test.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_accordion_state.test.ts @@ -7,14 +7,14 @@ import type { ToggleReducerAction, UseAccordionStateValue } from './use_accordion_state'; import { useAccordionState, toggleReducer } from './use_accordion_state'; -import type { RenderHookResult } from '@testing-library/react-hooks'; -import { renderHook } from '@testing-library/react-hooks'; +import type { RenderHookResult } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import { FLYOUT_STORAGE_KEYS } from '../../shared/constants/local_storage'; const mockSet = jest.fn(); describe('useAccordionState', () => { - let hookResult: RenderHookResult; + let hookResult: RenderHookResult; it('should return initial value', () => { hookResult = renderHook((props: boolean) => useAccordionState(props), { diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_assistant.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_assistant.test.tsx index 9ca0d9fd18e7d..1871c4aad5b8d 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_assistant.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_assistant.test.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import type { RenderHookResult } from '@testing-library/react-hooks'; -import { renderHook } from '@testing-library/react-hooks'; +import type { RenderHookResult } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import type { UseAssistantParams, UseAssistantResult } from './use_assistant'; import { useAssistant } from './use_assistant'; import { mockDataFormattedForFieldBrowser } from '../../shared/mocks/mock_data_formatted_for_field_browser'; @@ -25,7 +25,7 @@ const renderUseAssistant = () => }); describe('useAssistant', () => { - let hookResult: RenderHookResult; + let hookResult: RenderHookResult; it(`should return showAssistant true and a value for promptContextId`, () => { jest.mocked(useAssistantAvailability).mockReturnValue({ diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_expand_section.test.ts b/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_expand_section.test.ts index 998f56312b0f0..26c9b36a728aa 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_expand_section.test.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_expand_section.test.ts @@ -5,8 +5,8 @@ * 2.0. */ -import type { RenderHookResult } from '@testing-library/react-hooks'; -import { renderHook } from '@testing-library/react-hooks'; +import type { RenderHookResult } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import type { UseExpandSectionParams } from './use_expand_section'; import { useExpandSection } from './use_expand_section'; import { useKibana } from '../../../../common/lib/kibana'; @@ -14,7 +14,7 @@ import { useKibana } from '../../../../common/lib/kibana'; jest.mock('../../../../common/lib/kibana'); describe('useExpandSection', () => { - let hookResult: RenderHookResult; + let hookResult: RenderHookResult; it('should return default value if nothing in localStorage', () => { (useKibana as jest.Mock).mockReturnValue({ diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_fetch_threat_intelligence.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_fetch_threat_intelligence.test.tsx index e778552dff613..74672356f0762 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_fetch_threat_intelligence.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_fetch_threat_intelligence.test.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import type { RenderHookResult } from '@testing-library/react-hooks'; -import { renderHook } from '@testing-library/react-hooks'; +import type { RenderHookResult } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import type { UseThreatIntelligenceParams, UseThreatIntelligenceResult, @@ -41,7 +41,7 @@ const dataFormattedForFieldBrowser = [ ]; describe('useFetchThreatIntelligence', () => { - let hookResult: RenderHookResult; + let hookResult: RenderHookResult; it('return render 1 match detected and 1 field enriched', () => { (useInvestigationTimeEnrichment as jest.Mock).mockReturnValue({ diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_flyout_is_expandable.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_flyout_is_expandable.test.tsx index a67bb675a373a..3611ab6282644 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_flyout_is_expandable.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_flyout_is_expandable.test.tsx @@ -6,7 +6,7 @@ */ import { useFlyoutIsExpandable } from './use_flyout_is_expandable'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_get_flyout_link.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_get_flyout_link.test.tsx index 2db21334e59f3..71b24ab891e78 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_get_flyout_link.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_get_flyout_link.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { useGetFlyoutLink } from './use_get_flyout_link'; import { useGetAppUrl } from '@kbn/security-solution-navigation'; import { ALERT_DETAILS_REDIRECT_PATH } from '../../../../../common/constants'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_graph_preview.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_graph_preview.test.tsx index d12154a390abf..7fa0741a85118 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_graph_preview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_graph_preview.test.tsx @@ -5,15 +5,15 @@ * 2.0. */ -import type { RenderHookResult } from '@testing-library/react-hooks'; -import { renderHook } from '@testing-library/react-hooks'; +import type { RenderHookResult } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import type { UseGraphPreviewParams, UseGraphPreviewResult } from './use_graph_preview'; import { useGraphPreview } from './use_graph_preview'; import type { GetFieldsData } from '../../shared/hooks/use_get_fields_data'; import { mockFieldData } from '../../shared/mocks/mock_get_fields_data'; describe('useGraphPreview', () => { - let hookResult: RenderHookResult; + let hookResult: RenderHookResult; it(`should return false when missing actor`, () => { const getFieldsData: GetFieldsData = (field: string) => { diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_process_data.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_process_data.test.tsx index 31eb78975d195..6f6085c13ee6f 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_process_data.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_process_data.test.tsx @@ -6,7 +6,7 @@ */ import { getUserDisplayName, useProcessData } from './use_process_data'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import type { FC, PropsWithChildren } from 'react'; import { DocumentDetailsContext } from '../../shared/context'; import React from 'react'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_session_preview.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_session_preview.test.tsx index 0f6f233772626..4e2e19c6b54fa 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_session_preview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_session_preview.test.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import type { RenderHookResult } from '@testing-library/react-hooks'; -import { renderHook } from '@testing-library/react-hooks'; +import type { RenderHookResult } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import type { UseSessionPreviewParams } from './use_session_preview'; import { useSessionPreview } from './use_session_preview'; import type { SessionViewConfig } from '@kbn/securitysolution-data-table/common/types'; @@ -15,7 +15,7 @@ import { mockDataFormattedForFieldBrowser } from '../../shared/mocks/mock_data_f import { mockFieldData, mockGetFieldsData } from '../../shared/mocks/mock_get_fields_data'; describe('useSessionPreview', () => { - let hookResult: RenderHookResult; + let hookResult: RenderHookResult; it(`should return a session view config object if alert ancestor index is available`, () => { const getFieldsData: GetFieldsData = (field: string) => { diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_tabs.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_tabs.test.tsx index 87a23a06b7ce1..a8a7f61bac26c 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_tabs.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_tabs.test.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import type { RenderHookResult } from '@testing-library/react-hooks'; -import { renderHook } from '@testing-library/react-hooks'; +import type { RenderHookResult } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import type { UseTabsParams, UseTabsResult } from './use_tabs'; import { allThreeTabs, twoTabs, useTabs } from './use_tabs'; @@ -26,7 +26,7 @@ jest.mock('../../../../common/lib/kibana', () => { }); describe('useTabs', () => { - let hookResult: RenderHookResult; + let hookResult: RenderHookResult; it('should return 3 tabs to render and the one from path as selected', () => { const initialProps: UseTabsParams = { diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_alert_document_analyzer_schema.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_alert_document_analyzer_schema.test.tsx index 3c31720b53f99..8d9cbd958e432 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_alert_document_analyzer_schema.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_alert_document_analyzer_schema.test.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import type { RenderHookResult } from '@testing-library/react-hooks'; -import { renderHook } from '@testing-library/react-hooks'; +import type { RenderHookResult } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import { useQuery } from '@tanstack/react-query'; import type { UseAlertDocumentAnalyzerSchemaParams, @@ -20,8 +20,8 @@ jest.mock('@tanstack/react-query'); describe('useAlertPrevalenceFromProcessTree', () => { let hookResult: RenderHookResult< - UseAlertDocumentAnalyzerSchemaParams, - UseAlertDocumentAnalyzerSchemaResult + UseAlertDocumentAnalyzerSchemaResult, + UseAlertDocumentAnalyzerSchemaParams >; beforeEach(() => { diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_alert_prevalence.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_alert_prevalence.test.tsx index 231e0e5419441..27b83255443b5 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_alert_prevalence.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_alert_prevalence.test.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import type { RenderHookResult } from '@testing-library/react-hooks'; -import { renderHook } from '@testing-library/react-hooks'; +import type { RenderHookResult } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import { ALERT_PREVALENCE_AGG, useAlertPrevalence } from './use_alert_prevalence'; import type { UseAlertPrevalenceParams, UserAlertPrevalenceResult } from './use_alert_prevalence'; import { useGlobalTime } from '../../../../common/containers/use_global_time'; @@ -18,7 +18,7 @@ jest.mock('../../../../common/hooks/use_selector'); jest.mock('../../../../detections/containers/detection_engine/alerts/use_query'); describe('useAlertPrevalence', () => { - let hookResult: RenderHookResult; + let hookResult: RenderHookResult; beforeEach(() => { (useDeepEqualSelector as jest.Mock).mockReturnValue({ diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_alert_prevalence_from_process_tree.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_alert_prevalence_from_process_tree.test.tsx index 94b7cfa623507..668f233e65710 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_alert_prevalence_from_process_tree.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_alert_prevalence_from_process_tree.test.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import type { RenderHookResult } from '@testing-library/react-hooks'; -import { renderHook } from '@testing-library/react-hooks'; +import type { RenderHookResult } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import type { UseAlertPrevalenceFromProcessTreeParams, UserAlertPrevalenceFromProcessTreeResult, @@ -25,8 +25,8 @@ jest.mock('@tanstack/react-query'); describe('useAlertPrevalenceFromProcessTree', () => { let hookResult: RenderHookResult< - UseAlertPrevalenceFromProcessTreeParams, - UserAlertPrevalenceFromProcessTreeResult + UserAlertPrevalenceFromProcessTreeResult, + UseAlertPrevalenceFromProcessTreeParams >; beforeEach(() => { diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_basic_data_from_details_data.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_basic_data_from_details_data.test.tsx index b4cd7c35824a1..2894cb0d21276 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_basic_data_from_details_data.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_basic_data_from_details_data.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { useBasicDataFromDetailsData } from './use_basic_data_from_details_data'; import { mockDataFormattedForFieldBrowser } from '../mocks/mock_data_formatted_for_field_browser'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_event_details.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_event_details.test.tsx index de1020bac4d00..6e4cab20f013c 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_event_details.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_event_details.test.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import type { RenderHookResult } from '@testing-library/react-hooks'; -import { renderHook } from '@testing-library/react-hooks'; +import type { RenderHookResult } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import type { UseEventDetailsParams, UseEventDetailsResult } from './use_event_details'; import { getAlertIndexAlias, useEventDetails } from './use_event_details'; import { useSpaceId } from '../../../../common/hooks/use_space_id'; @@ -45,7 +45,7 @@ describe('getAlertIndexAlias', () => { }); describe('useEventDetails', () => { - let hookResult: RenderHookResult; + let hookResult: RenderHookResult; it('should return all properties', () => { jest.mocked(useSpaceId).mockReturnValue('default'); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_ancestry.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_ancestry.test.tsx index 4d65339c6b41a..7630c260f4c21 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_ancestry.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_ancestry.test.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import type { RenderHookResult } from '@testing-library/react-hooks'; -import { renderHook } from '@testing-library/react-hooks'; +import type { RenderHookResult } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import type { UseFetchRelatedAlertsByAncestryParams, UseFetchRelatedAlertsByAncestryResult, @@ -22,8 +22,8 @@ const scopeId = 'scopeId'; describe('useFetchRelatedAlertsByAncestry', () => { let hookResult: RenderHookResult< - UseFetchRelatedAlertsByAncestryParams, - UseFetchRelatedAlertsByAncestryResult + UseFetchRelatedAlertsByAncestryResult, + UseFetchRelatedAlertsByAncestryParams >; it('should return loading true while data is loading', () => { diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_same_source_event.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_same_source_event.test.tsx index ff74774068adf..9da01a94d51dc 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_same_source_event.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_same_source_event.test.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import type { RenderHookResult } from '@testing-library/react-hooks'; -import { renderHook } from '@testing-library/react-hooks'; +import type { RenderHookResult } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import type { UseFetchRelatedAlertsBySameSourceEventParams, UseFetchRelatedAlertsBySameSourceEventResult, @@ -21,8 +21,8 @@ const scopeId = 'scopeId'; describe('useFetchRelatedAlertsBySameSourceEvent', () => { let hookResult: RenderHookResult< - UseFetchRelatedAlertsBySameSourceEventParams, - UseFetchRelatedAlertsBySameSourceEventResult + UseFetchRelatedAlertsBySameSourceEventResult, + UseFetchRelatedAlertsBySameSourceEventParams >; it('should return loading true while data is loading', () => { diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_session.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_session.test.tsx index b38ef44178f9f..27f030cc11e2d 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_session.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_session.test.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import type { RenderHookResult } from '@testing-library/react-hooks'; -import { renderHook } from '@testing-library/react-hooks'; +import type { RenderHookResult } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import type { UseFetchRelatedAlertsBySessionParams, @@ -22,8 +22,8 @@ const scopeId = 'scopeId'; describe('useFetchRelatedAlertsBySession', () => { let hookResult: RenderHookResult< - UseFetchRelatedAlertsBySessionParams, - UseFetchRelatedAlertsBySessionResult + UseFetchRelatedAlertsBySessionResult, + UseFetchRelatedAlertsBySessionParams >; it('should return loading true while data is loading', () => { diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_cases.test.ts b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_cases.test.ts index 6ebdc2bc4b7c7..9e494ef8e05d0 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_cases.test.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_cases.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { createReactQueryWrapper } from '../../../../common/mock'; import { useFetchRelatedCases } from './use_fetch_related_cases'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_get_fields_data.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_get_fields_data.test.tsx index fcf370b4bca1a..a91d14c7a84ee 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_get_fields_data.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_get_fields_data.test.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import type { RenderHookResult } from '@testing-library/react-hooks'; -import { renderHook } from '@testing-library/react-hooks'; +import type { RenderHookResult } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import { mockSearchHit } from '../mocks/mock_search_hit'; import type { UseGetFieldsDataParams, UseGetFieldsDataResult } from './use_get_fields_data'; import { useGetFieldsData } from './use_get_fields_data'; @@ -17,7 +17,7 @@ const fieldsData = { }; describe('useGetFieldsData', () => { - let hookResult: RenderHookResult; + let hookResult: RenderHookResult; it('should return the value for a field', () => { hookResult = renderHook(() => useGetFieldsData({ fieldsData })); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_highlighted_fields.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_highlighted_fields.test.tsx index 6eb8c242c79fe..6bffb7c58ae3f 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_highlighted_fields.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_highlighted_fields.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { mockDataFormattedForFieldBrowser, diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_investigation_enrichment.test.ts b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_investigation_enrichment.test.ts index 0e1cdbc845b38..c79e1ec07ce15 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_investigation_enrichment.test.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_investigation_enrichment.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { useInvestigationTimeEnrichment } from './use_investigation_enrichment'; import { DEFAULT_EVENT_ENRICHMENT_FROM, diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_investigation_guide.test.ts b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_investigation_guide.test.ts index f7e3a40e60c40..39522df675486 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_investigation_guide.test.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_investigation_guide.test.ts @@ -5,8 +5,8 @@ * 2.0. */ -import type { RenderHookResult } from '@testing-library/react-hooks'; -import { renderHook } from '@testing-library/react-hooks'; +import type { RenderHookResult } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import type { UseInvestigationGuideParams, UseInvestigationGuideResult, @@ -22,7 +22,7 @@ jest.mock('../../../../detection_engine/rule_management/logic/use_rule_with_fall const dataFormattedForFieldBrowser = mockDataFormattedForFieldBrowser; describe('useInvestigationGuide', () => { - let hookResult: RenderHookResult; + let hookResult: RenderHookResult; it('should return loading true', () => { (useBasicDataFromDetailsData as jest.Mock).mockReturnValue({ ruleId: 'ruleId' }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_analyzer.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_analyzer.test.tsx index 6f578c7cdb95c..334bf5f08721e 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_analyzer.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_analyzer.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { useNavigateToAnalyzer } from './use_navigate_to_analyzer'; import { mockFlyoutApi } from '../mocks/mock_flyout_context'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_session_view.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_session_view.test.tsx index c0f85e07377df..ac624ce11ce56 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_session_view.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_session_view.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { useNavigateToSessionView } from './use_navigate_to_session_view'; import { mockFlyoutApi } from '../mocks/mock_flyout_context'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_prevalence.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_prevalence.test.tsx index a9c12adfc84ca..d4acc0eb0c7f6 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_prevalence.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_prevalence.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { usePrevalence } from './use_prevalence'; import { mockDataFormattedForFieldBrowser } from '../mocks/mock_data_formatted_for_field_browser'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_rule_details_link.test.ts b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_rule_details_link.test.ts index f77b82af80065..ca748433c1931 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_rule_details_link.test.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_rule_details_link.test.ts @@ -5,8 +5,8 @@ * 2.0. */ -import type { RenderHookResult } from '@testing-library/react-hooks'; -import { renderHook } from '@testing-library/react-hooks'; +import type { RenderHookResult } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import type { UseRuleDetailsLinkParams } from './use_rule_details_link'; import { useRuleDetailsLink } from './use_rule_details_link'; @@ -24,7 +24,7 @@ jest.mock('../../../../common/components/link_to', () => ({ })); describe('useRuleDetailsLink', () => { - let hookResult: RenderHookResult; + let hookResult: RenderHookResult; it('should return null if the ruleId prop is null', () => { const initialProps: UseRuleDetailsLinkParams = { diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_show_related_alerts_by_ancestry.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_show_related_alerts_by_ancestry.test.tsx index f4be536d5419c..938930496d803 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_show_related_alerts_by_ancestry.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_show_related_alerts_by_ancestry.test.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import type { RenderHookResult } from '@testing-library/react-hooks'; -import { renderHook } from '@testing-library/react-hooks'; +import type { RenderHookResult } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import type { UseShowRelatedAlertsByAncestryParams, UseShowRelatedAlertsByAncestryResult, @@ -36,8 +36,8 @@ const dataAsNestedObject = mockDataAsNestedObject; describe('useShowRelatedAlertsByAncestry', () => { let hookResult: RenderHookResult< - UseShowRelatedAlertsByAncestryParams, - UseShowRelatedAlertsByAncestryResult + UseShowRelatedAlertsByAncestryResult, + UseShowRelatedAlertsByAncestryParams >; it('should return false if Process Entity Info is not available', () => { diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_show_related_alerts_by_same_source_event.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_show_related_alerts_by_same_source_event.test.tsx index dfbfeeccc655a..71f681c64e802 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_show_related_alerts_by_same_source_event.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_show_related_alerts_by_same_source_event.test.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import type { RenderHookResult } from '@testing-library/react-hooks'; -import { renderHook } from '@testing-library/react-hooks'; +import type { RenderHookResult } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import type { ShowRelatedAlertsBySameSourceEventParams, @@ -18,8 +18,8 @@ const eventId = 'eventId'; describe('useShowRelatedAlertsBySameSourceEvent', () => { let hookResult: RenderHookResult< - ShowRelatedAlertsBySameSourceEventParams, - ShowRelatedAlertsBySameSourceEventResult + ShowRelatedAlertsBySameSourceEventResult, + ShowRelatedAlertsBySameSourceEventParams >; it('should return eventId if getFieldsData returns null', () => { diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_show_related_alerts_by_session.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_show_related_alerts_by_session.test.tsx index 32595a4c27c6d..92d9c076ab64f 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_show_related_alerts_by_session.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_show_related_alerts_by_session.test.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import type { RenderHookResult } from '@testing-library/react-hooks'; -import { renderHook } from '@testing-library/react-hooks'; +import type { RenderHookResult } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import type { UseShowRelatedAlertsBySessionParams, @@ -16,8 +16,8 @@ import { useShowRelatedAlertsBySession } from './use_show_related_alerts_by_sess describe('useShowRelatedAlertsBySession', () => { let hookResult: RenderHookResult< - UseShowRelatedAlertsBySessionParams, - UseShowRelatedAlertsBySessionResult + UseShowRelatedAlertsBySessionResult, + UseShowRelatedAlertsBySessionParams >; it('should return false if getFieldsData returns null', () => { diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_show_related_cases.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_show_related_cases.test.tsx index cfa9b87d8fcfc..6245e480a35db 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_show_related_cases.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_show_related_cases.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { useKibana as mockUseKibana } from '../../../../common/lib/kibana/__mocks__'; import { useShowRelatedCases } from './use_show_related_cases'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_show_suppressed_alerts.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_show_suppressed_alerts.test.tsx index 622f37a0997cf..b10e154fed3d5 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_show_suppressed_alerts.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_show_suppressed_alerts.test.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import type { RenderHookResult } from '@testing-library/react-hooks'; -import { renderHook } from '@testing-library/react-hooks'; +import type { RenderHookResult } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import type { ShowSuppressedAlertsParams, ShowSuppressedAlertsResult, @@ -14,7 +14,7 @@ import type { import { useShowSuppressedAlerts } from './use_show_suppressed_alerts'; describe('useShowSuppressedAlerts', () => { - let hookResult: RenderHookResult; + let hookResult: RenderHookResult; it('should return false if getFieldsData returns null', () => { const getFieldsData = () => null; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_which_flyout.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_which_flyout.test.tsx index 76277b8da889b..033fb53a5c029 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_which_flyout.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_which_flyout.test.tsx @@ -5,13 +5,13 @@ * 2.0. */ -import type { RenderHookResult } from '@testing-library/react-hooks'; -import { renderHook } from '@testing-library/react-hooks'; +import type { RenderHookResult } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import { useWhichFlyout } from './use_which_flyout'; import { Flyouts } from '../constants/flyouts'; describe('useWhichFlyout', () => { - let hookResult: RenderHookResult<{}, string | null>; + let hookResult: RenderHookResult; beforeEach(() => { jest.clearAllMocks(); diff --git a/x-pack/plugins/security_solution/public/flyout/shared/components/expandable_panel.test.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/expandable_panel.test.tsx index 87592b608613f..2be968669057c 100644 --- a/x-pack/plugins/security_solution/public/flyout/shared/components/expandable_panel.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/shared/components/expandable_panel.test.tsx @@ -14,11 +14,9 @@ import { EXPANDABLE_PANEL_HEADER_TITLE_ICON_TEST_ID, EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID, } from './test_ids'; -import { ThemeProvider } from '@emotion/react'; +import { EuiThemeProvider as ThemeProvider } from '@elastic/eui'; import { ExpandablePanel } from './expandable_panel'; -const mockTheme = { eui: { euiColorMediumShade: '#ece' } }; - const TEST_ID = 'test-id'; const defaultProps = { header: { @@ -33,7 +31,7 @@ describe('', () => { describe('panel is not expandable by default', () => { it('should render non-expandable panel by default', () => { const { getByTestId, queryByTestId } = render( - + {children} ); @@ -54,7 +52,7 @@ describe('', () => { }, }; const { getByTestId, queryByTestId } = render( - + {children} ); @@ -70,7 +68,7 @@ describe('', () => { it('should only render left section of panel header when headerContent is not passed', () => { const { getByTestId, queryByTestId } = render( - + {children} ); @@ -88,7 +86,7 @@ describe('', () => { header: { ...defaultProps.header, headerContent: <>{'test header content'} }, }; const { getByTestId } = render( - + {children} ); @@ -105,7 +103,7 @@ describe('', () => { it('should not render content when content is null', () => { const { queryByTestId } = render( - + ); @@ -123,7 +121,7 @@ describe('', () => { it('should render panel with toggle and collapsed by default', () => { const { getByTestId, queryByTestId } = render( - + {children} ); @@ -135,7 +133,7 @@ describe('', () => { it('click toggle button should expand the panel', () => { const { getByTestId } = render( - + {children} ); @@ -152,7 +150,7 @@ describe('', () => { it('should not render toggle or content when content is null', () => { const { queryByTestId } = render( - + ); @@ -169,7 +167,7 @@ describe('', () => { it('should render header and content', () => { const { getByTestId } = render( - + {children} ); @@ -184,7 +182,7 @@ describe('', () => { it('click toggle button should collapse the panel', () => { const { getByTestId, queryByTestId } = render( - + {children} ); @@ -200,7 +198,7 @@ describe('', () => { it('should not render content when content is null', () => { const { queryByTestId } = render( - + ); diff --git a/x-pack/plugins/security_solution/public/flyout/shared/hooks/use_on_expandable_flyout_close.test.tsx b/x-pack/plugins/security_solution/public/flyout/shared/hooks/use_on_expandable_flyout_close.test.tsx index 308c1bcfc6cfc..2a4e5576e24bc 100644 --- a/x-pack/plugins/security_solution/public/flyout/shared/hooks/use_on_expandable_flyout_close.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/shared/hooks/use_on_expandable_flyout_close.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { useWhichFlyout } from '../../document_details/shared/hooks/use_which_flyout'; import { useOnExpandableFlyoutClose } from './use_on_expandable_flyout_close'; import { Flyouts } from '../../document_details/shared/constants/flyouts'; diff --git a/x-pack/plugins/security_solution/public/management/cypress/common/constants.ts b/x-pack/plugins/security_solution/public/management/cypress/common/constants.ts index 41f08f438e3f8..0266914a17182 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/common/constants.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/common/constants.ts @@ -18,4 +18,5 @@ export const KIBANA_KNOWN_DEFAULT_ACCOUNTS = { elastic: 'elastic', elastic_serverless: 'elastic_serverless', system_indices_superuser: 'system_indices_superuser', + admin: 'admin', } as const; diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/rbac/endpoint_role_rbac.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/rbac/endpoint_role_rbac.cy.ts index 64779bb2ba27e..2c5ea11329f31 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/rbac/endpoint_role_rbac.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/rbac/endpoint_role_rbac.cy.ts @@ -5,9 +5,15 @@ * 2.0. */ +import { + expandEndpointSecurityFeaturePrivileges, + expandSecuritySolutionCategoryKibanaPrivileges, + navigateToRolePage, + openKibanaFeaturePrivilegesFlyout, + setKibanaPrivilegeSpace, +} from '../../screens/stack_management/role_page'; import { closeAllToasts } from '../../tasks/toasts'; import { login, ROLE } from '../../tasks/login'; -import { loadPage } from '../../tasks/common'; describe('When defining a kibana role for Endpoint security access', { tags: '@ess' }, () => { const getAllSubFeatureRows = (): Cypress.Chainable> => { @@ -19,11 +25,13 @@ describe('When defining a kibana role for Endpoint security access', { tags: '@e beforeEach(() => { login(ROLE.system_indices_superuser); - loadPage('/app/management/security/roles/edit'); + navigateToRolePage(); closeAllToasts(); - cy.getByTestSubj('addSpacePrivilegeButton').click(); - cy.getByTestSubj('featureCategoryButton_securitySolution').closest('button').click(); - cy.get('.featurePrivilegeName:contains("Security")').closest('button').click(); + + openKibanaFeaturePrivilegesFlyout(); + setKibanaPrivilegeSpace('default'); + expandSecuritySolutionCategoryKibanaPrivileges(); + expandEndpointSecurityFeaturePrivileges(); }); it('should display RBAC entries with expected controls', () => { diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/rbac/endpoint_role_rbac_with_space_awareness.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/rbac/endpoint_role_rbac_with_space_awareness.cy.ts index 424b3fc954c57..41f6613be88be 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/rbac/endpoint_role_rbac_with_space_awareness.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/rbac/endpoint_role_rbac_with_space_awareness.cy.ts @@ -26,7 +26,7 @@ import { describe( 'When defining a kibana role for Endpoint security access with space awareness enabled', { - // TODO:PR Remove `'@skipInServerlessMKI` once PR merges to `main` + // TODO:PR Remove `'@skipInServerlessMKI` once PR merges to `main` and feature flag is enabled in prod. tags: ['@ess', '@serverless', '@serverlessMKI', '@skipInServerlessMKI'], env: { ftrConfig: { @@ -43,11 +43,13 @@ describe( }, }, () => { - let spaceId: string = ''; + // In Serverless MKI we use `admin` for the login user... other deployments use system indices superuser + const loginUser = Cypress.env('CLOUD_SERVERLESS') ? ROLE.admin : ROLE.system_indices_superuser; const roleName = `test_${Math.random().toString().substring(2, 6)}`; + let spaceId: string = ''; before(() => { - login(ROLE.system_indices_superuser); + login(loginUser); createSpace(`foo_${Math.random().toString().substring(2, 6)}`).then((response) => { spaceId = response.body.id; }); @@ -61,16 +63,16 @@ describe( }); beforeEach(() => { - login(ROLE.system_indices_superuser); + login(loginUser); navigateToRolePage(); setRoleName(roleName); openKibanaFeaturePrivilegesFlyout(); + setKibanaPrivilegeSpace(spaceId); expandSecuritySolutionCategoryKibanaPrivileges(); expandEndpointSecurityFeaturePrivileges(); }); it('should allow configuration per-space', () => { - setKibanaPrivilegeSpace(spaceId); setSecuritySolutionEndpointGroupPrivilege('all'); clickEndpointSubFeaturePrivilegesCustomization(); setEndpointSubFeaturePrivilege('endpoint_list', 'all'); diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/alerts_response_console.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/alerts_response_console.cy.ts index d741c3a7f0e59..eed5970fbc9d0 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/alerts_response_console.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/alerts_response_console.cy.ts @@ -85,6 +85,15 @@ describe( } }); + afterEach(function () { + if (Cypress.env('IS_CI') && this.currentTest?.isFailed() && createdHost) { + cy.task('captureHostVmAgentDiagnostics', { + hostname: createdHost.hostname, + fileNamePrefix: this.currentTest?.fullTitle(), + }); + } + }); + it('should open responder from alert details flyout', () => { waitForEndpointListPageToBeLoaded(createdHost.hostname); toggleRuleOffAndOn(ruleName); diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/response_console/execute.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/response_console/execute.cy.ts index 042031b301185..d5f3bd7d956af 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/response_console/execute.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/response_console/execute.cy.ts @@ -63,6 +63,15 @@ describe('Response console', { tags: ['@ess', '@serverless'] }, () => { } }); + afterEach(function () { + if (Cypress.env('IS_CI') && this.currentTest?.isFailed() && createdHost) { + cy.task('captureHostVmAgentDiagnostics', { + hostname: createdHost.hostname, + fileNamePrefix: this.currentTest?.fullTitle(), + }); + } + }); + it('"execute --command" - should execute a command', () => { waitForEndpointListPageToBeLoaded(createdHost.hostname); openResponseConsoleFromEndpointList(); diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/response_console/isolate.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/response_console/isolate.cy.ts index f89f2a6f62ecf..b08dcd0eea492 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/response_console/isolate.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/response_console/isolate.cy.ts @@ -61,6 +61,15 @@ describe('Response console', { tags: ['@ess', '@serverless'] }, () => { } }); + afterEach(function () { + if (Cypress.env('IS_CI') && this.currentTest?.isFailed() && createdHost) { + cy.task('captureHostVmAgentDiagnostics', { + hostname: createdHost.hostname, + fileNamePrefix: this.currentTest?.fullTitle(), + }); + } + }); + describe('Host Isolation:', () => { beforeEach(() => { login(); diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/response_console/process_operations.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/response_console/process_operations.cy.ts index e09aa8dc9fc85..f28c2b3d6dee4 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/response_console/process_operations.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/response_console/process_operations.cy.ts @@ -66,6 +66,15 @@ describe('Response console', { tags: ['@ess', '@serverless', '@skipInServerlessM } }); + afterEach(function () { + if (Cypress.env('IS_CI') && this.currentTest?.isFailed() && createdHost) { + cy.task('captureHostVmAgentDiagnostics', { + hostname: createdHost.hostname, + fileNamePrefix: this.currentTest?.fullTitle(), + }); + } + }); + it('"processes" - should obtain a list of processes', () => { waitForEndpointListPageToBeLoaded(createdHost.hostname); openResponseConsoleFromEndpointList(); diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/response_console/release.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/response_console/release.cy.ts index d11b7210713a8..4f45522a76ecf 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/response_console/release.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/response_console/release.cy.ts @@ -62,6 +62,15 @@ describe('Response console', { tags: ['@ess', '@serverless'] }, () => { } }); + afterEach(function () { + if (Cypress.env('IS_CI') && this.currentTest?.isFailed() && createdHost) { + cy.task('captureHostVmAgentDiagnostics', { + hostname: createdHost.hostname, + fileNamePrefix: this.currentTest?.fullTitle(), + }); + } + }); + describe('Host Isolation:', () => { beforeEach(() => { login(); diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/response_console/scan.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/response_console/scan.cy.ts index 04630647ed35f..e9ca6a7ee4229 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/response_console/scan.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/response_console/scan.cy.ts @@ -81,6 +81,15 @@ describe( } }); + afterEach(function () { + if (Cypress.env('IS_CI') && this.currentTest?.isFailed() && createdHost) { + cy.task('captureHostVmAgentDiagnostics', { + hostname: createdHost.hostname, + fileNamePrefix: this.currentTest?.fullTitle(), + }); + } + }); + [ ['file', filePath], ['folder', homeFilePath], diff --git a/x-pack/plugins/security_solution/public/management/cypress/screens/stack_management/role_page.ts b/x-pack/plugins/security_solution/public/management/cypress/screens/stack_management/role_page.ts index fb9b798b93d6e..a3e7bbc7e4e89 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/screens/stack_management/role_page.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/screens/stack_management/role_page.ts @@ -85,6 +85,13 @@ export const setKibanaPrivilegeSpace = (spaceId: string) => { cy.getByTestSubj('comboBoxOptionsList spaceSelectorComboBox-optionsList') .find(`button#spaceOption_${spaceId}`) .click(); + + // Wait for the selection to be added to the list of selected spaces + cy.getByTestSubj('spaceSelectorComboBox').find(`#spaceOption_${spaceId}`); + + // This `click()` just ensures that the combox in the UI is "closed" after the + // selection and mouse focus is moved away from that field. + getKibanaFeaturePrivilegesFlyout().click(); }; /** diff --git a/x-pack/plugins/security_solution/public/management/cypress/tasks/login.ts b/x-pack/plugins/security_solution/public/management/cypress/tasks/login.ts index fc95d174c4dd7..0a2cee2b31fe5 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/tasks/login.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/tasks/login.ts @@ -9,13 +9,12 @@ import type { LoginState } from '@kbn/security-plugin/common/login_state'; import type { Role } from '@kbn/security-plugin/common'; import { ENDPOINT_SECURITY_ROLE_NAMES } from '../../../../scripts/endpoint/common/roles_users'; import type { SecurityTestUser } from '../common/constants'; +import { KIBANA_KNOWN_DEFAULT_ACCOUNTS } from '../common/constants'; import { COMMON_API_HEADERS, request } from './common'; export const ROLE = Object.freeze>({ ...ENDPOINT_SECURITY_ROLE_NAMES, - elastic: 'elastic', - elastic_serverless: 'elastic_serverless', - system_indices_superuser: 'system_indices_superuser', + ...KIBANA_KNOWN_DEFAULT_ACCOUNTS, }); interface CyLoginTask { diff --git a/x-pack/plugins/security_solution/public/notes/hooks/use_fetch_notes.test.ts b/x-pack/plugins/security_solution/public/notes/hooks/use_fetch_notes.test.ts index 7498a6f40c6b6..b7fa3ab3fc519 100644 --- a/x-pack/plugins/security_solution/public/notes/hooks/use_fetch_notes.test.ts +++ b/x-pack/plugins/security_solution/public/notes/hooks/use_fetch_notes.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { useDispatch } from 'react-redux'; import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; import { fetchNotesByDocumentIds } from '../store/notes.slice'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/body_config.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/body_config.ts index 93690f98b48e8..59d11314171a6 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/body_config.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/body_config.ts @@ -13,6 +13,7 @@ import { rulesCardConfig } from './cards/rules'; import { alertsCardConfig } from './cards/alerts'; import { assistantCardConfig } from './cards/assistant'; import { aiConnectorCardConfig } from './cards/siem_migrations/ai_connector'; +import { startMigrationCardConfig } from './cards/siem_migrations/start_migration'; export const defaultBodyConfig: OnboardingGroupConfig[] = [ { @@ -43,4 +44,10 @@ export const siemMigrationsBodyConfig: OnboardingGroupConfig[] = [ }), cards: [aiConnectorCardConfig], }, + { + title: i18n.translate('xpack.securitySolution.onboarding.migrate.title', { + defaultMessage: 'Migrate rules & add data', + }), + cards: [startMigrationCardConfig], + }, ]; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_content_panel.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_content_panel.tsx index be1b01fc77081..7f3ba00593fc0 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_content_panel.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_content_panel.tsx @@ -17,7 +17,7 @@ export const OnboardingCardContentPanel = React.memo - + {children} diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/ai_connector_card.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/ai_connector_card.tsx index 127e6b4d57ebd..e42834e85d488 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/ai_connector_card.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/ai_connector_card.tsx @@ -6,19 +6,14 @@ */ import React, { useCallback } from 'react'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiText, - useEuiTheme, - COLOR_MODES_STANDARD, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { useKibana } from '../../../../../../common/lib/kibana/kibana_react'; import { useDefinedLocalStorage } from '../../../../hooks/use_stored_state'; import type { OnboardingCardComponent } from '../../../../../types'; import * as i18n from './translations'; import { OnboardingCardContentPanel } from '../../common/card_content_panel'; import { ConnectorCards } from '../../common/connectors/connector_cards'; +import { CardSubduedText } from '../../common/card_subdued_text'; import type { AIConnectorCardMetadata } from './types'; import { MissingPrivilegesCallOut } from '../../common/connectors/missing_privileges'; @@ -28,9 +23,6 @@ export const AIConnectorCard: OnboardingCardComponent = setComplete, }) => { const { siemMigrations } = useKibana().services; - const { euiTheme, colorMode } = useEuiTheme(); - const isDarkMode = colorMode === COLOR_MODES_STANDARD.dark; - const [storedConnectorId, setStoredConnectorId] = useDefinedLocalStorage( siemMigrations.rules.connectorIdStorage.key, null @@ -48,18 +40,11 @@ export const AIConnectorCard: OnboardingCardComponent = const canCreateConnectors = checkCompleteMetadata?.canCreateConnectors; return ( - + {canExecuteConnectors ? ( - - {i18n.AI_CONNECTOR_CARD_DESCRIPTION} - + {i18n.AI_CONNECTOR_CARD_DESCRIPTION} = async ({ http, application, siemMigrations }) => { let isComplete = false; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/index.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/index.ts index 45080123889d5..d0b32eb1bd638 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/index.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/index.ts @@ -6,11 +6,11 @@ */ import React from 'react'; -import { AssistantAvatar } from '@kbn/elastic-assistant'; +import { AssistantAvatar } from '@kbn/elastic-assistant/impl/assistant/assistant_avatar/assistant_avatar'; import type { OnboardingCardConfig } from '../../../../../types'; import { OnboardingCardId } from '../../../../../constants'; import { AI_CONNECTOR_CARD_TITLE } from './translations'; -import { checkAssistantCardComplete } from './connectors_check_complete'; +import { checkAiConnectorsCardComplete } from './connectors_check_complete'; import type { AIConnectorCardMetadata } from './types'; export const aiConnectorCardConfig: OnboardingCardConfig = { @@ -24,6 +24,6 @@ export const aiConnectorCardConfig: OnboardingCardConfig void; + closeFlyout: () => void; +} + +const StartMigrationContext = createContext(null); + +export const StartMigrationContextProvider: React.FC< + PropsWithChildren +> = React.memo(({ children, openFlyout, closeFlyout }) => { + const value = useMemo( + () => ({ openFlyout, closeFlyout }), + [openFlyout, closeFlyout] + ); + return {children}; +}); +StartMigrationContextProvider.displayName = 'StartMigrationContextProvider'; + +export const useStartMigrationContext = (): StartMigrationContextValue => { + const context = useContext(StartMigrationContext); + if (context == null) { + throw new Error('useStartMigrationContext must be used within a StartMigrationContextProvider'); + } + return context; +}; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/images/card_header_icon.png b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/images/card_header_icon.png new file mode 100644 index 0000000000000..b2b4848e0be1d Binary files /dev/null and b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/images/card_header_icon.png differ diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/index.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/index.ts new file mode 100644 index 0000000000000..fcf950e0840e9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { OnboardingCardConfig } from '../../../../../types'; +import { OnboardingCardId } from '../../../../../constants'; +import { START_MIGRATION_CARD_TITLE } from './translations'; +import cardIcon from './images/card_header_icon.png'; +import { checkStartMigrationCardComplete } from './start_migration_check_complete'; + +export const startMigrationCardConfig: OnboardingCardConfig = { + id: OnboardingCardId.siemMigrationsStart, + title: START_MIGRATION_CARD_TITLE, + icon: cardIcon, + Component: React.lazy( + () => + import( + /* webpackChunkName: "onboarding_siem_migrations_start_migration_card" */ + './start_migration_card' + ) + ), + checkComplete: checkStartMigrationCardComplete, + licenseTypeRequired: 'enterprise', +}; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/missing_ai_connector_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/missing_ai_connector_callout.tsx new file mode 100644 index 0000000000000..324dd405d5141 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/missing_ai_connector_callout.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 React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLink } from '@elastic/eui'; +import { OnboardingCardContentPanel } from '../../common/card_content_panel'; +import { CardCallOut } from '../../common/card_callout'; +import * as i18n from './translations'; + +interface MissingAIConnectorCalloutProps { + onExpandAiConnectorsCard: () => void; +} + +export const MissingAIConnectorCallout = React.memo( + ({ onExpandAiConnectorsCard }) => ( + + + + {i18n.START_MIGRATION_CARD_CONNECTOR_MISSING_BUTTON} + + + + + + } + /> + + ) +); +MissingAIConnectorCallout.displayName = 'MissingAIConnectorCallout'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/migration_progress_panel.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/migration_progress_panel.tsx new file mode 100644 index 0000000000000..0527e1cfbdf17 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/migration_progress_panel.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiPanel, EuiProgress } from '@elastic/eui'; +import type { RuleMigrationStats } from '../../../../../../../siem_migrations/rules/types'; +import * as i18n from '../translations'; +import { TITLE_CLASS_NAME } from '../start_migration_card.styles'; + +export interface MigrationProgressPanelProps { + migrationStats: RuleMigrationStats; +} +export const MigrationProgressPanel = React.memo( + ({ migrationStats }) => { + const progressValue = useMemo(() => { + const finished = migrationStats.rules.completed + migrationStats.rules.failed; + return (finished / migrationStats.rules.total) * 100; + }, [migrationStats.rules]); + + return ( + + + + +

{i18n.START_MIGRATION_CARD_MIGRATION_TITLE(migrationStats.number)}

+
+
+ + +

{i18n.START_MIGRATION_CARD_PROGRESS_DESCRIPTION}

+
+
+ + + +
+
+ ); + } +); +MigrationProgressPanel.displayName = 'MigrationProgressPanel'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/migration_ready_panel.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/migration_ready_panel.tsx new file mode 100644 index 0000000000000..8603511fa2d6f --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/migration_ready_panel.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiButton, + EuiButtonEmpty, + EuiPanel, +} from '@elastic/eui'; +import { useStartMigration } from '../../../../../../../siem_migrations/rules/service/hooks/use_start_migration'; +import type { RuleMigrationStats } from '../../../../../../../siem_migrations/rules/types'; +import * as i18n from '../translations'; +import { useStartMigrationContext } from '../context'; +import { TITLE_CLASS_NAME } from '../start_migration_card.styles'; + +export interface MigrationReadyPanelProps { + migrationStats: RuleMigrationStats; +} +export const MigrationReadyPanel = React.memo(({ migrationStats }) => { + const { openFlyout } = useStartMigrationContext(); + const onOpenFlyout = useCallback(() => { + openFlyout(migrationStats); + }, [openFlyout, migrationStats]); + + const { startMigration, isLoading } = useStartMigration(); + const onStartMigration = useCallback(() => { + startMigration(migrationStats.id); + }, [migrationStats.id, startMigration]); + + return ( + + + + + + +

{i18n.START_MIGRATION_CARD_MIGRATION_TITLE(migrationStats.number)}

+
+
+ + +

{i18n.START_MIGRATION_CARD_MIGRATION_READY_DESCRIPTION}

+
+
+
+
+ + + {i18n.START_MIGRATION_CARD_TRANSLATE_BUTTON} + + + + + {i18n.START_MIGRATION_CARD_UPLOAD_MACROS_BUTTON} + + +
+
+ ); +}); +MigrationReadyPanel.displayName = 'MigrationReadyPanel'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/migration_result_panel.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/migration_result_panel.tsx new file mode 100644 index 0000000000000..b73b3cc8b4921 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/migration_result_panel.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import moment from 'moment'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiPanel, + EuiHorizontalRule, + EuiIcon, +} from '@elastic/eui'; +import { SecurityPageName } from '@kbn/security-solution-navigation'; +import { AssistantAvatar } from '@kbn/elastic-assistant/impl/assistant/assistant_avatar/assistant_avatar'; +import { SecuritySolutionLinkButton } from '../../../../../../../common/components/links'; +import type { RuleMigrationStats } from '../../../../../../../siem_migrations/rules/types'; +import * as i18n from '../translations'; +import { TITLE_CLASS_NAME } from '../start_migration_card.styles'; + +export interface MigrationResultPanelProps { + migrationStats: RuleMigrationStats; +} +export const MigrationResultPanel = React.memo(({ migrationStats }) => { + return ( + + + + + +

{i18n.START_MIGRATION_CARD_RESULT_TITLE(migrationStats.number)}

+
+
+ + +

+ {i18n.START_MIGRATION_CARD_RESULT_DESCRIPTION( + moment(migrationStats.created_at).format('MMMM Do YYYY, h:mm:ss a'), + moment(migrationStats.last_updated_at).fromNow() + )} +

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

{i18n.VIEW_TRANSLATED_RULES_TITLE}

+
+
+
+
+ + + + +

{'TODO: chart'}

+
+ + + {i18n.VIEW_TRANSLATED_RULES_BUTTON} + + +
+
+
+
+
+
+ ); +}); +MigrationResultPanel.displayName = 'MigrationResultPanel'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/upload_rules_panel.styles.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/upload_rules_panel.styles.ts new file mode 100644 index 0000000000000..0aef40dfeb442 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/upload_rules_panel.styles.ts @@ -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 { css } from '@emotion/css'; + +export const useStyles = (compressed: boolean) => { + const logoSize = compressed ? '32px' : '88px'; + return css` + .siemMigrationsIcon { + width: ${logoSize}; + block-size: ${logoSize}; + inline-size: ${logoSize}; + } + `; +}; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/upload_rules_panel.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/upload_rules_panel.tsx new file mode 100644 index 0000000000000..edcff3646c5aa --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/upload_rules_panel.tsx @@ -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 React, { useCallback } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiIcon, + EuiButton, + EuiButtonEmpty, + EuiPanel, +} from '@elastic/eui'; +import { SiemMigrationsIcon } from '../../../../../../../siem_migrations/common/icon'; +import * as i18n from '../translations'; +import { useStartMigrationContext } from '../context'; +import { TITLE_CLASS_NAME } from '../start_migration_card.styles'; +import { useStyles } from './upload_rules_panel.styles'; + +export interface UploadRulesPanelProps { + isUploadMore?: boolean; +} +export const UploadRulesPanel = React.memo(({ isUploadMore = false }) => { + const styles = useStyles(isUploadMore); + const { openFlyout } = useStartMigrationContext(); + const onOpenFlyout = useCallback(() => { + openFlyout(); + }, [openFlyout]); + + return ( + + + + + + + {isUploadMore ? ( + +

{i18n.START_MIGRATION_CARD_UPLOAD_MORE_TITLE}

+
+ ) : ( + + + +

{i18n.START_MIGRATION_CARD_UPLOAD_TITLE}

+
+
+ + +

{i18n.START_MIGRATION_CARD_UPLOAD_DESCRIPTION}

+
+
+ + +

{i18n.START_MIGRATION_CARD_UPLOAD_READ_MORE}

+
+
+
+ )} +
+ + {isUploadMore ? ( + + {i18n.START_MIGRATION_CARD_UPLOAD_MORE_BUTTON} + + ) : ( + + {i18n.START_MIGRATION_CARD_UPLOAD_BUTTON} + + )} + +
+
+ ); +}); +UploadRulesPanel.displayName = 'UploadRulesPanel'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/start_migration_card.styles.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/start_migration_card.styles.ts new file mode 100644 index 0000000000000..82446ba308402 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/start_migration_card.styles.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 { css } from '@emotion/css'; +import { useEuiTheme } from '@elastic/eui'; + +export const TITLE_CLASS_NAME = 'siemMigrationsStartTitle'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + return css` + .${TITLE_CLASS_NAME} { + font-weight: ${euiTheme.font.weight.semiBold}; + } + `; +}; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/start_migration_card.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/start_migration_card.tsx new file mode 100644 index 0000000000000..c1e7539c8e101 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/start_migration_card.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useState } from 'react'; +import { EuiSpacer, EuiText } from '@elastic/eui'; +import { OnboardingCardId } from '../../../../../constants'; +import type { RuleMigrationTaskStats } from '../../../../../../../common/siem_migrations/model/rule_migration.gen'; +import { useLatestStats } from '../../../../../../siem_migrations/rules/service/hooks/use_latest_stats'; +import { MigrationDataInputFlyout } from '../../../../../../siem_migrations/rules/components/data_input_flyout'; +import { CenteredLoadingSpinner } from '../../../../../../common/components/centered_loading_spinner'; +import type { OnboardingCardComponent } from '../../../../../types'; +import { OnboardingCardContentPanel } from '../../common/card_content_panel'; +import { UploadRulesPanels } from './upload_rules_panels'; +import { StartMigrationContextProvider } from './context'; +import { useStyles } from './start_migration_card.styles'; +import * as i18n from './translations'; +import { MissingAIConnectorCallout } from './missing_ai_connector_callout'; + +export const StartMigrationCard: OnboardingCardComponent = React.memo( + ({ checkComplete, isCardComplete, setExpandedCardId }) => { + const styles = useStyles(); + const { data: migrationsStats, isLoading } = useLatestStats(); + + const [isFlyoutOpen, setIsFlyoutOpen] = useState(); + const [flyoutMigrationStats, setFlyoutMigrationStats] = useState< + RuleMigrationTaskStats | undefined + >(); + + const closeFlyout = useCallback(() => { + setIsFlyoutOpen(false); + setFlyoutMigrationStats(undefined); + if (!isCardComplete(OnboardingCardId.siemMigrationsStart)) { + checkComplete(); + } + }, [checkComplete, isCardComplete]); + + const openFlyout = useCallback((migrationStats?: RuleMigrationTaskStats) => { + setFlyoutMigrationStats(migrationStats); + setIsFlyoutOpen(true); + }, []); + + if (!isCardComplete(OnboardingCardId.siemMigrationsAiConnectors)) { + return ( + + setExpandedCardId(OnboardingCardId.siemMigrationsAiConnectors) + } + /> + ); + } + + return ( + + + {isLoading ? ( + + ) : ( + + )} + + +

{i18n.START_MIGRATION_CARD_FOOTER_NOTE}

+
+
+ {isFlyoutOpen && ( + + )} +
+ ); + } +); +StartMigrationCard.displayName = 'StartMigrationCard'; + +// eslint-disable-next-line import/no-default-export +export default StartMigrationCard; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/start_migration_check_complete.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/start_migration_check_complete.ts new file mode 100644 index 0000000000000..41e65352d4bc3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/start_migration_check_complete.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { OnboardingCardCheckComplete } from '../../../../../types'; + +export const checkStartMigrationCardComplete: OnboardingCardCheckComplete = async ({ + siemMigrations, +}) => { + const migrationsStats = await siemMigrations.rules.getRuleMigrationsStats(); + const isComplete = migrationsStats.length > 0; + return isComplete; +}; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/translations.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/translations.ts new file mode 100644 index 0000000000000..bdb3f31842549 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/translations.ts @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const START_MIGRATION_CARD_TITLE = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.title', + { defaultMessage: 'Translate your existing SIEM Rules to Elastic' } +); +export const START_MIGRATION_CARD_FOOTER_NOTE = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.footerNote', + { + defaultMessage: + 'Splunk and related marks are trademarks or registered trademarks of Splunk LLC in the United States and other countries.', + } +); +export const START_MIGRATION_CARD_CONNECTOR_MISSING_TEXT = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.connectorMissingText', + { + defaultMessage: 'Rule migrations require an AI connector to be configured.', + } +); +export const START_MIGRATION_CARD_CONNECTOR_MISSING_BUTTON = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.connectorMissingText', + { defaultMessage: 'AI provider step' } +); + +export const START_MIGRATION_CARD_UPLOAD_TITLE = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.upload.title', + { defaultMessage: 'Export your Splunk® SIEM rules to start translation.' } +); + +export const START_MIGRATION_CARD_UPLOAD_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.upload.description', + { + defaultMessage: + 'Upload your rules before importing data to identify the integrations, data streams, and available details of your SIEM rules. Click “Upload Rules” to view step-by-step instructions to export and uploading the rules.', + } +); + +export const START_MIGRATION_CARD_UPLOAD_BUTTON = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.upload.button', + { defaultMessage: 'Upload rules' } +); + +export const START_MIGRATION_CARD_UPLOAD_MORE_TITLE = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.uploadMore.title', + { defaultMessage: 'Need to migrate more rules?' } +); +export const START_MIGRATION_CARD_UPLOAD_MORE_BUTTON = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.uploadMore.button', + { defaultMessage: 'Upload more rules' } +); + +export const START_MIGRATION_CARD_UPLOAD_READ_MORE = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.upload.readMore', + { defaultMessage: 'Read more about our AI powered translations and other features.' } +); + +export const START_MIGRATION_CARD_UPLOAD_READ_DOCS = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.upload.readAiDocsLink', + { defaultMessage: 'Read AI docs' } +); + +export const START_MIGRATION_CARD_MIGRATION_READY_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.ready.description', + { + defaultMessage: + 'Migration is created and ready but the translation has not started yet. You can either upload macros & lookups or start the translation process', + } +); +export const START_MIGRATION_CARD_TRANSLATE_BUTTON = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.translate.button', + { defaultMessage: 'Start translation' } +); +export const START_MIGRATION_CARD_UPLOAD_MACROS_BUTTON = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.uploadMacros.button', + { defaultMessage: 'Upload macros' } +); + +export const START_MIGRATION_CARD_MIGRATION_TITLE = (number: number) => + i18n.translate('xpack.securitySolution.onboarding.startMigration.migrationTitle', { + defaultMessage: 'SIEM rules migration #{number}', + values: { number }, + }); + +export const START_MIGRATION_CARD_PROGRESS_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.progress.description', + { + defaultMessage: `This may take a few minutes & the task will work in the background. Just stay logged in and we'll notify you when done.`, + } +); + +export const START_MIGRATION_CARD_RESULT_TITLE = (number: number) => + i18n.translate('xpack.securitySolution.onboarding.startMigration.result.title', { + defaultMessage: 'SIEM rules migration #{number} complete', + values: { number }, + }); + +export const START_MIGRATION_CARD_RESULT_DESCRIPTION = (createdAt: string, finishedAt: string) => + i18n.translate('xpack.securitySolution.onboarding.startMigration.result.description', { + defaultMessage: 'Export uploaded on {createdAt} and translation finished {finishedAt}.', + values: { createdAt, finishedAt }, + }); + +export const VIEW_TRANSLATED_RULES_TITLE = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.result.translatedRules.title', + { defaultMessage: 'Translation Summary' } +); + +export const VIEW_TRANSLATED_RULES_BUTTON = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.result.translatedRules.button', + { defaultMessage: 'View translated rules' } +); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/upload_rules_panels.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/upload_rules_panels.tsx new file mode 100644 index 0000000000000..95b53d921fd1f --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/upload_rules_panels.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { SiemMigrationTaskStatus } from '../../../../../../../common/siem_migrations/constants'; +import type { RuleMigrationStats } from '../../../../../../siem_migrations/rules/types'; +import { UploadRulesPanel } from './panels/upload_rules_panel'; +import { MigrationProgressPanel } from './panels/migration_progress_panel'; +import { MigrationResultPanel } from './panels/migration_result_panel'; +import { MigrationReadyPanel } from './panels/migration_ready_panel'; + +export interface UploadRulesPanelsProps { + migrationsStats: RuleMigrationStats[]; +} +export const UploadRulesPanels = React.memo(({ migrationsStats }) => { + if (migrationsStats.length === 0) { + return ; + } + + return ( + + + + + {migrationsStats.map((migrationStats) => ( + + {migrationStats.status === SiemMigrationTaskStatus.READY && ( + + )} + {migrationStats.status === SiemMigrationTaskStatus.RUNNING && ( + + )} + {migrationStats.status === SiemMigrationTaskStatus.FINISHED && ( + + )} + + ))} + + ); +}); +UploadRulesPanels.displayName = 'UploadRulesPanels'; diff --git a/x-pack/plugins/security_solution/public/onboarding/constants.ts b/x-pack/plugins/security_solution/public/onboarding/constants.ts index e360e4591bb37..94b87721513bc 100644 --- a/x-pack/plugins/security_solution/public/onboarding/constants.ts +++ b/x-pack/plugins/security_solution/public/onboarding/constants.ts @@ -21,4 +21,5 @@ export enum OnboardingCardId { // siem_migrations topic cards siemMigrationsAiConnectors = 'ai_connectors', + siemMigrationsStart = 'start', } diff --git a/x-pack/plugins/security_solution/public/siem_migrations/common/icon/index.tsx b/x-pack/plugins/security_solution/public/siem_migrations/common/icon/index.tsx new file mode 100644 index 0000000000000..c0528a9a04afe --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/common/icon/index.tsx @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import SiemMigrationsIconSVG from './siem_migrations.svg'; +export const SiemMigrationsIcon = SiemMigrationsIconSVG; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/common/icon/siem_migrations.svg b/x-pack/plugins/security_solution/public/siem_migrations/common/icon/siem_migrations.svg new file mode 100644 index 0000000000000..e8568a943f70c --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/common/icon/siem_migrations.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/api/index.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/api/index.ts index 592b93f438197..db6f0117d4a77 100644 --- a/x-pack/plugins/security_solution/public/siem_migrations/rules/api/index.ts +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/api/index.ts @@ -7,161 +7,188 @@ import { replaceParams } from '@kbn/openapi-common/shared'; +import type { LangSmithOptions } from '../../../../common/siem_migrations/model/common.gen'; import { KibanaServices } from '../../../common/lib/kibana'; import { + SIEM_RULE_MIGRATIONS_PATH, SIEM_RULE_MIGRATIONS_ALL_STATS_PATH, SIEM_RULE_MIGRATION_INSTALL_TRANSLATED_PATH, SIEM_RULE_MIGRATION_INSTALL_PATH, SIEM_RULE_MIGRATION_PATH, SIEM_RULE_MIGRATION_START_PATH, + SIEM_RULE_MIGRATION_STATS_PATH, SIEM_RULE_MIGRATION_TRANSLATION_STATS_PATH, } from '../../../../common/siem_migrations/constants'; import type { + CreateRuleMigrationRequestBody, + CreateRuleMigrationResponse, GetAllStatsRuleMigrationResponse, GetRuleMigrationResponse, GetRuleMigrationTranslationStatsResponse, InstallTranslatedMigrationRulesResponse, InstallMigrationRulesResponse, StartRuleMigrationRequestBody, + GetRuleMigrationStatsResponse, } from '../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; -/** - * Retrieves the stats for all the existing migrations, aggregated by `migration_id`. - * - * @param signal AbortSignal for cancelling request - * - * @throws An error if response is not OK - */ +export interface GetRuleMigrationStatsParams { + /** `id` of the migration to get stats for */ + migrationId: string; + /** Optional AbortSignal for cancelling request */ + signal?: AbortSignal; +} +/** Retrieves the stats for all the existing migrations, aggregated by `migration_id`. */ +export const getRuleMigrationStats = async ({ + migrationId, + signal, +}: GetRuleMigrationStatsParams): Promise => { + return KibanaServices.get().http.get( + replaceParams(SIEM_RULE_MIGRATION_STATS_PATH, { migration_id: migrationId }), + { version: '1', signal } + ); +}; + +export interface GetRuleMigrationsStatsAllParams { + /** Optional AbortSignal for cancelling request */ + signal?: AbortSignal; +} +/** Retrieves the stats for all the existing migrations, aggregated by `migration_id`. */ export const getRuleMigrationsStatsAll = async ({ signal, -}: { - signal: AbortSignal | undefined; -}): Promise => { - return KibanaServices.get().http.fetch( +}: GetRuleMigrationsStatsAllParams = {}): Promise => { + return KibanaServices.get().http.get( SIEM_RULE_MIGRATIONS_ALL_STATS_PATH, - { method: 'GET', version: '1', signal } + { version: '1', signal } ); }; -/** - * Starts a new migration with the provided rules. - * - * @param migrationId `id` of the migration to start - * @param body The body containing the `connectorId` to use for the migration - * @param signal AbortSignal for cancelling request - * - * @throws An error if response is not OK - */ -export const startRuleMigration = async ({ +export interface CreateRuleMigrationParams { + /** Optional `id` of migration to add the rules to. + * The id is necessary only for batching the migration creation in multiple requests */ + migrationId?: string; + /** The body containing the `connectorId` to use for the migration */ + body: CreateRuleMigrationRequestBody; + /** Optional AbortSignal for cancelling request */ + signal?: AbortSignal; +} +/** Starts a new migration with the provided rules. */ +export const createRuleMigration = async ({ migrationId, body, signal, -}: { - migrationId: string; - body: StartRuleMigrationRequestBody; - signal: AbortSignal | undefined; -}): Promise => { - return KibanaServices.get().http.put( - replaceParams(SIEM_RULE_MIGRATION_START_PATH, { migration_id: migrationId }), +}: CreateRuleMigrationParams): Promise => { + return KibanaServices.get().http.post( + `${SIEM_RULE_MIGRATIONS_PATH}${migrationId ? `/${migrationId}` : ''}`, { body: JSON.stringify(body), version: '1', signal } ); }; -/** - * Retrieves the translation stats for the migraion. - * - * @param migrationId `id` of the migration to retrieve translation stats for - * @param signal AbortSignal for cancelling request - * - * @throws An error if response is not OK - */ -export const getRuleMigrationTranslationStats = async ({ +export interface StartRuleMigrationParams { + /** `id` of the migration to start */ + migrationId: string; + /** The connector id to use for the migration */ + connectorId: string; + /** Optional LangSmithOptions to use for the for the migration */ + langSmithOptions?: LangSmithOptions; + /** Optional AbortSignal for cancelling request */ + signal?: AbortSignal; +} +/** Starts a new migration with the provided rules. */ +export const startRuleMigration = async ({ migrationId, + connectorId, + langSmithOptions, signal, -}: { - migrationId: string; - signal: AbortSignal | undefined; -}): Promise => { - return KibanaServices.get().http.fetch( - replaceParams(SIEM_RULE_MIGRATION_TRANSLATION_STATS_PATH, { migration_id: migrationId }), - { - method: 'GET', - version: '1', - signal, - } +}: StartRuleMigrationParams): Promise => { + const body: StartRuleMigrationRequestBody = { connector_id: connectorId }; + if (langSmithOptions) { + body.langsmith_options = langSmithOptions; + } + return KibanaServices.get().http.put( + replaceParams(SIEM_RULE_MIGRATION_START_PATH, { migration_id: migrationId }), + { body: JSON.stringify(body), version: '1', signal } ); }; -/** - * Retrieves all the migration rule documents of a specific migration. - * - * @param migrationId `id` of the migration to retrieve rule documents for - * @param signal AbortSignal for cancelling request - * - * @throws An error if response is not OK - */ +export interface GetRuleMigrationParams { + /** `id` of the migration to get rules documents for */ + migrationId: string; + /** Optional page number to retrieve */ + page?: number; + /** Optional number of documents per page to retrieve */ + perPage?: number; + /** Optional search term to filter documents */ + searchTerm?: string; + /** Optional AbortSignal for cancelling request */ + signal?: AbortSignal; +} +/** Retrieves all the migration rule documents of a specific migration. */ export const getRuleMigrations = async ({ migrationId, page, perPage, searchTerm, signal, -}: { - migrationId: string; - page?: number; - perPage?: number; - searchTerm?: string; - signal: AbortSignal | undefined; -}): Promise => { - return KibanaServices.get().http.fetch( +}: GetRuleMigrationParams): Promise => { + return KibanaServices.get().http.get( replaceParams(SIEM_RULE_MIGRATION_PATH, { migration_id: migrationId }), - { - method: 'GET', - version: '1', - query: { - page, - per_page: perPage, - search_term: searchTerm, - }, - signal, - } + { version: '1', query: { page, per_page: perPage, search_term: searchTerm }, signal } ); }; -export const installMigrationRules = async ({ +export interface GetRuleMigrationTranslationStatsParams { + /** `id` of the migration to get translation stats for */ + migrationId: string; + /** Optional AbortSignal for cancelling request */ + signal?: AbortSignal; +} +/** + * Retrieves the translation stats for the migration. + */ +export const getRuleMigrationTranslationStats = async ({ migrationId, - ids, signal, -}: { +}: GetRuleMigrationTranslationStatsParams): Promise => { + return KibanaServices.get().http.get( + replaceParams(SIEM_RULE_MIGRATION_TRANSLATION_STATS_PATH, { migration_id: migrationId }), + { version: '1', signal } + ); +}; + +export interface InstallRulesParams { + /** `id` of the migration to install rules for */ migrationId: string; + /** The rule ids to install */ ids: string[]; + /** Optional AbortSignal for cancelling request */ signal?: AbortSignal; -}): Promise => { - return KibanaServices.get().http.fetch( +} +/** Installs the provided rule ids for a specific migration. */ +export const installMigrationRules = async ({ + migrationId, + ids, + signal, +}: InstallRulesParams): Promise => { + return KibanaServices.get().http.post( replaceParams(SIEM_RULE_MIGRATION_INSTALL_PATH, { migration_id: migrationId }), - { - method: 'POST', - version: '1', - body: JSON.stringify(ids), - signal, - } + { version: '1', body: JSON.stringify(ids), signal } ); }; +export interface InstallTranslatedRulesParams { + /** `id` of the migration to install rules for */ + migrationId: string; + /** Optional AbortSignal for cancelling request */ + signal?: AbortSignal; +} +/** Installs all the translated rules for a specific migration. */ export const installTranslatedMigrationRules = async ({ migrationId, signal, -}: { - migrationId: string; - signal?: AbortSignal; -}): Promise => { - return KibanaServices.get().http.fetch( +}: InstallTranslatedRulesParams): Promise => { + return KibanaServices.get().http.post( replaceParams(SIEM_RULE_MIGRATION_INSTALL_TRANSLATED_PATH, { migration_id: migrationId }), - { - method: 'POST', - version: '1', - signal, - } + { version: '1', signal } ); }; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/constants.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/constants.ts new file mode 100644 index 0000000000000..aa331bf17c832 --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/constants.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export enum DataInputStep { + rules = 'rules', + macros = 'macros', + lookups = 'lookups', +} + +export const SPL_RULES_COLUMNS = [ + 'id', + 'title', + 'search', + 'description', + 'action.escu.eli5', + 'action.correlationsearch.annotations', +] as const; + +export const RULES_SPLUNK_QUERY = `| rest /servicesNS/-/-/saved/searches +| search action.correlationsearch.enabled = "1" OR (eai:acl.app = "Splunk_Security_Essentials" AND is_scheduled=1) +| where disabled=0 +| table ${SPL_RULES_COLUMNS.join(', ')}`; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/data_input_flyout.tsx b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/data_input_flyout.tsx new file mode 100644 index 0000000000000..6a4916a5e54b3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/data_input_flyout.tsx @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useState } from 'react'; +import { + EuiFlyoutResizable, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButton, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import type { RuleMigrationTaskStats } from '../../../../../common/siem_migrations/model/rule_migration.gen'; +import { DataInputStep } from './constants'; +import { RulesDataInput } from './steps/rules/rules_data_input'; +import { useStartMigration } from '../../service/hooks/use_start_migration'; + +export interface MigrationDataInputFlyoutProps { + onClose: () => void; + migrationStats?: RuleMigrationTaskStats; +} +export const MigrationDataInputFlyout = React.memo( + ({ onClose, migrationStats: initialMigrationSats }) => { + const [migrationStats, setMigrationStats] = useState( + initialMigrationSats + ); + + const { startMigration, isLoading: isStartLoading } = useStartMigration(onClose); + const onStartMigration = useCallback(() => { + if (migrationStats?.id) { + startMigration(migrationStats.id); + } + }, [migrationStats, startMigration]); + + const [dataInputStep, setDataInputStep] = useState(() => { + if (migrationStats) { + return DataInputStep.macros; + } + return DataInputStep.rules; + }); + + const onMigrationCreated = useCallback( + (createdMigrationStats: RuleMigrationTaskStats) => { + if (createdMigrationStats) { + setMigrationStats(createdMigrationStats); + setDataInputStep(DataInputStep.macros); + } + }, + [setDataInputStep] + ); + + return ( + + + +

+ +

+
+
+ + + + + + + + + + + + + + + + + +
+ ); + } +); +MigrationDataInputFlyout.displayName = 'MigrationDataInputFlyout'; diff --git a/x-pack/packages/ml/data_grid/emotion.d.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/index.ts similarity index 52% rename from x-pack/packages/ml/data_grid/emotion.d.ts rename to x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/index.ts index 213178080e536..709623f992f72 100644 --- a/x-pack/packages/ml/data_grid/emotion.d.ts +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/index.ts @@ -5,10 +5,4 @@ * 2.0. */ -import '@emotion/react'; -import type { UseEuiTheme } from '@elastic/eui'; - -declare module '@emotion/react' { - // eslint-disable-next-line @typescript-eslint/no-empty-interface - export interface Theme extends UseEuiTheme {} -} +export { MigrationDataInputFlyout, type MigrationDataInputFlyoutProps } from './data_input_flyout'; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/common/sub_step_wrapper.tsx b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/common/sub_step_wrapper.tsx new file mode 100644 index 0000000000000..438134b0ad99a --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/common/sub_step_wrapper.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiPanel } from '@elastic/eui'; +import { css } from '@emotion/css'; +import type { PropsWithChildren } from 'react'; +import React from 'react'; + +const style = css` + .euiStep__title { + font-size: 14px; + } +`; + +export const SubStepWrapper = React.memo>(({ children }) => { + return ( + + {children} + + ); +}); +SubStepWrapper.displayName = 'SubStepWrapper'; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/rules_data_input.tsx b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/rules_data_input.tsx new file mode 100644 index 0000000000000..2b20dcda0cea7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/rules_data_input.tsx @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EuiStepProps, EuiStepStatus } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiStepNumber, + EuiSteps, + EuiTitle, +} from '@elastic/eui'; +import React, { useMemo, useState } from 'react'; +import { SubStepWrapper } from '../common/sub_step_wrapper'; +import type { OnMigrationCreated } from '../../types'; +import { useCopyExportQueryStep } from './sub_steps/copy_export_query'; +import { useRulesFileUploadStep } from './sub_steps/rules_file_upload'; +import * as i18n from './translations'; +import { useCheckResourcesStep } from './sub_steps/check_resources'; + +type Step = 1 | 2 | 3 | 4; +const getStatus = (step: Step, currentStep: Step): EuiStepStatus => { + if (step === currentStep) { + return 'current'; + } + if (step < currentStep) { + return 'complete'; + } + return 'incomplete'; +}; + +interface RulesDataInputProps { + selected: boolean; + onMigrationCreated: OnMigrationCreated; +} + +export const RulesDataInput = React.memo( + ({ selected, onMigrationCreated }) => { + const [step, setStep] = useState(1); + + const copyStep = useCopyExportQueryStep({ + status: getStatus(1, step), + onCopied: () => setStep(2), + }); + + const uploadStep = useRulesFileUploadStep({ + status: getStatus(2, step), + onMigrationCreated: (stats) => { + onMigrationCreated(stats); + setStep(3); + }, + }); + + const resourcesStep = useCheckResourcesStep({ + status: getStatus(3, step), + onComplete: () => { + setStep(4); + }, + }); + + const steps = useMemo( + () => [copyStep, uploadStep, resourcesStep], + [copyStep, uploadStep, resourcesStep] + ); + + return ( + + + + + + + + + + {i18n.RULES_DATA_INPUT_TITLE} + + + + + + + + + + + + ); + } +); +RulesDataInput.displayName = 'RulesDataInput'; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/check_resources/index.tsx b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/check_resources/index.tsx new file mode 100644 index 0000000000000..3b081eb203267 --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/check_resources/index.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiText, type EuiStepProps, type EuiStepStatus } from '@elastic/eui'; +import * as i18n from './translations'; + +export interface CheckResourcesStepProps { + status: EuiStepStatus; + onComplete: () => void; +} +export const useCheckResourcesStep = ({ + status, + onComplete, +}: CheckResourcesStepProps): EuiStepProps => { + // onComplete(); // TODO: check the resources + return { + title: i18n.RULES_DATA_INPUT_CHECK_RESOURCES_TITLE, + status, + children: ( + + {i18n.RULES_DATA_INPUT_CHECK_RESOURCES_DESCRIPTION} + + ), + }; +}; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/check_resources/translations.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/check_resources/translations.ts new file mode 100644 index 0000000000000..159b4033fafd6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/check_resources/translations.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 { i18n } from '@kbn/i18n'; + +export const RULES_DATA_INPUT_CHECK_RESOURCES_TITLE = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.checkResources.title', + { defaultMessage: 'Check for macros and lookups' } +); + +export const RULES_DATA_INPUT_CHECK_RESOURCES_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.checkResources.description', + { + defaultMessage: `For best translation results, we will automatically review your rules for macros and lookups and ask you to upload them. Once uploaded, we'll be able to deliver a more complete rule translation for all rules using those macros or lookups.`, + } +); diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/copy_export_query/copy_export_query.tsx b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/copy_export_query/copy_export_query.tsx new file mode 100644 index 0000000000000..11fb88a1cade2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/copy_export_query/copy_export_query.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback } from 'react'; +import { EuiCodeBlock, EuiSpacer, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { RULES_SPLUNK_QUERY } from '../../../../constants'; +import * as i18n from './translations'; + +interface CopyExportQueryProps { + onCopied: () => void; +} +export const CopyExportQuery = React.memo(({ onCopied }) => { + const onClick: React.MouseEventHandler = useCallback( + (ev) => { + // The only button inside the element is the "copy" button. + if ((ev.target as Element).tagName === 'BUTTON') { + onCopied(); + } + }, + [onCopied] + ); + + return ( + <> + {/* The click event is also dispatched when using the keyboard actions (space or enter) for "copy" button. + No need to use keyboard specific events, disabling the a11y lint rule:*/} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */} +
+ {/* onCopy react event is dispatched when the user copies text manually */} + + {RULES_SPLUNK_QUERY} + +
+ + + {i18n.RULES_DATA_INPUT_COPY_DESCRIPTION_SECTION}, + format: {'JSON'}, + }} + /> + + + ); +}); +CopyExportQuery.displayName = 'CopyExportQuery'; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/copy_export_query/index.tsx b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/copy_export_query/index.tsx new file mode 100644 index 0000000000000..3d2adcc78857b --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/copy_export_query/index.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { EuiStepProps, EuiStepStatus } from '@elastic/eui'; +import { CopyExportQuery } from './copy_export_query'; +import * as i18n from './translations'; + +export interface CopyExportQueryStepProps { + status: EuiStepStatus; + onCopied: () => void; +} +export const useCopyExportQueryStep = ({ + status, + onCopied, +}: CopyExportQueryStepProps): EuiStepProps => { + return { + title: i18n.RULES_DATA_INPUT_COPY_TITLE, + status, + children: , + }; +}; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/copy_export_query/translations.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/copy_export_query/translations.ts new file mode 100644 index 0000000000000..d76eb71f2e378 --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/copy_export_query/translations.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. + */ + +import { i18n } from '@kbn/i18n'; + +export const RULES_DATA_INPUT_COPY_TITLE = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.copyExportQuery.title', + { defaultMessage: 'Copy and export query' } +); + +export const RULES_DATA_INPUT_COPY_DESCRIPTION_SECTION = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.copyExportQuery.description.section', + { defaultMessage: 'Search and Reporting' } +); diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/rules_file_upload/index.tsx b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/rules_file_upload/index.tsx new file mode 100644 index 0000000000000..ab7838b28908b --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/rules_file_upload/index.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo, useState } from 'react'; +import type { EuiStepProps, EuiStepStatus } from '@elastic/eui'; +import type { OnMigrationCreated } from '../../../../types'; +import { RulesFileUpload } from './rules_file_upload'; +import { + useCreateMigration, + type OnSuccess, +} from '../../../../../../service/hooks/use_create_migration'; +import * as i18n from './translations'; + +export interface RulesFileUploadStepProps { + status: EuiStepStatus; + onMigrationCreated: OnMigrationCreated; +} +export const useRulesFileUploadStep = ({ + status, + onMigrationCreated, +}: RulesFileUploadStepProps): EuiStepProps => { + const [isCreated, setIsCreated] = useState(false); + const onSuccess = useCallback( + (stats) => { + setIsCreated(true); + onMigrationCreated(stats); + }, + [onMigrationCreated] + ); + const { createMigration, isLoading, error } = useCreateMigration(onSuccess); + + const uploadStepStatus = useMemo(() => { + if (isLoading) { + return 'loading'; + } + if (error) { + return 'danger'; + } + return status; + }, [isLoading, error, status]); + + return { + title: i18n.RULES_DATA_INPUT_FILE_UPLOAD_TITLE, + status: uploadStepStatus, + children: ( + + ), + }; +}; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/rules_file_upload/parse_rules_file.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/rules_file_upload/parse_rules_file.ts new file mode 100644 index 0000000000000..3d5dbb32ccde8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/rules_file_upload/parse_rules_file.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 { isPlainObject } from 'lodash/fp'; +import type { OriginalRule } from '../../../../../../../../../common/siem_migrations/model/rule_migration.gen'; +import type { SPL_RULES_COLUMNS } from '../../../../constants'; +import * as i18n from './translations'; + +type SplunkResult = Partial>; +interface SplunkRow { + result: SplunkResult; +} + +export const parseContent = (fileContent: string): OriginalRule[] => { + const trimmedContent = fileContent.trim(); + let arrayContent: SplunkRow[]; + if (trimmedContent.startsWith('[')) { + arrayContent = parseJSONArray(trimmedContent); + } else { + arrayContent = parseNDJSON(trimmedContent); + } + if (arrayContent.length === 0) { + throw new Error(i18n.RULES_DATA_INPUT_FILE_UPLOAD_ERROR.EMPTY); + } + return arrayContent.map(convertFormat); +}; + +const parseNDJSON = (fileContent: string): SplunkRow[] => { + return fileContent + .split(/\n(?=\{)/) // split at newline followed by '{'. + .filter((entry) => entry.trim() !== '') // Remove empty entries. + .map(parseJSON); // Parse each entry as JSON. +}; + +const parseJSONArray = (fileContent: string): SplunkRow[] => { + const parsedContent = parseJSON(fileContent); + if (!Array.isArray(parsedContent)) { + throw new Error(i18n.RULES_DATA_INPUT_FILE_UPLOAD_ERROR.NOT_ARRAY); + } + return parsedContent; +}; + +const parseJSON = (fileContent: string) => { + try { + return JSON.parse(fileContent); + } catch (error) { + if (error instanceof RangeError) { + throw new Error(i18n.RULES_DATA_INPUT_FILE_UPLOAD_ERROR.TOO_LARGE_TO_PARSE); + } + throw new Error(i18n.RULES_DATA_INPUT_FILE_UPLOAD_ERROR.CAN_NOT_PARSE); + } +}; + +const convertFormat = (row: SplunkRow): OriginalRule => { + if (!isPlainObject(row) || !isPlainObject(row.result)) { + throw new Error(i18n.RULES_DATA_INPUT_FILE_UPLOAD_ERROR.NOT_OBJECT); + } + const originalRule: Partial = { + id: row.result.id, + vendor: 'splunk', + title: row.result.title, + query: row.result.search, + query_language: 'spl', + description: row.result['action.escu.eli5']?.trim() || row.result.description, + }; + + if (row.result['action.correlationsearch.annotations']) { + try { + originalRule.annotations = JSON.parse(row.result['action.correlationsearch.annotations']); + } catch (error) { + delete originalRule.annotations; + } + } + return originalRule as OriginalRule; +}; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/rules_file_upload/rules_file_upload.tsx b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/rules_file_upload/rules_file_upload.tsx new file mode 100644 index 0000000000000..0f9787a4ddf68 --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/rules_file_upload/rules_file_upload.tsx @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo, useState } from 'react'; +import { EuiFilePicker, EuiFormRow, EuiText } from '@elastic/eui'; +import type { OriginalRule } from '../../../../../../../../../common/siem_migrations/model/rule_migration.gen'; +import type { CreateMigration } from '../../../../../../service/hooks/use_create_migration'; +import { parseContent } from './parse_rules_file'; +import * as i18n from './translations'; + +export interface RulesFileUploadProps { + createMigration: CreateMigration; + apiError?: string; + isLoading?: boolean; + isCreated?: boolean; +} +export const RulesFileUpload = React.memo( + ({ createMigration, apiError, isLoading, isCreated }) => { + const [isParsing, setIsParsing] = useState(false); + const [fileError, setFileError] = useState(); + + const onChangeFile = useCallback( + (files: FileList | null) => { + if (!files) { + return; + } + + setFileError(undefined); + + const rulesFile = files[0]; + const reader = new FileReader(); + + reader.onloadstart = () => setIsParsing(true); + reader.onloadend = () => setIsParsing(false); + + reader.onload = function (e) { + // We can safely cast to string since we call `readAsText` to load the file. + const fileContent = e.target?.result as string | undefined; + + if (fileContent == null) { + setFileError(i18n.RULES_DATA_INPUT_FILE_UPLOAD_ERROR.CAN_NOT_READ); + return; + } + + if (fileContent === '' && e.loaded > 100000) { + // V8-based browsers can't handle large files and return an empty string + // instead of an error; see https://stackoverflow.com/a/61316641 + setFileError(i18n.RULES_DATA_INPUT_FILE_UPLOAD_ERROR.TOO_LARGE_TO_PARSE); + return; + } + + let data: OriginalRule[]; + try { + data = parseContent(fileContent); + createMigration(data); + } catch (err) { + setFileError(err.message); + } + }; + + const handleReaderError = function () { + const message = reader.error?.message; + if (message) { + setFileError(i18n.RULES_DATA_INPUT_FILE_UPLOAD_ERROR.CAN_NOT_READ_WITH_REASON(message)); + } else { + setFileError(i18n.RULES_DATA_INPUT_FILE_UPLOAD_ERROR.CAN_NOT_READ); + } + }; + + reader.onerror = handleReaderError; + reader.onabort = handleReaderError; + + reader.readAsText(rulesFile); + }, + [createMigration] + ); + + const error = useMemo(() => { + if (apiError) { + return apiError; + } + return fileError; + }, [apiError, fileError]); + + return ( + + {error} + + } + isInvalid={error != null} + fullWidth + > + + + {i18n.RULES_DATA_INPUT_FILE_UPLOAD_PROMPT} + + + } + accept="application/json" + onChange={onChangeFile} + display="large" + aria-label="Upload logs sample file" + isLoading={isParsing || isLoading} + disabled={isLoading || isCreated} + data-test-subj="rulesFilePicker" + data-loading={isParsing} + /> + + ); + } +); +RulesFileUpload.displayName = 'RulesFileUpload'; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/rules_file_upload/translations.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/rules_file_upload/translations.ts new file mode 100644 index 0000000000000..675eed61f4973 --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/rules_file_upload/translations.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const RULES_DATA_INPUT_FILE_UPLOAD_TITLE = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.rulesFileUpload.title', + { defaultMessage: 'Update your rule export' } +); +export const RULES_DATA_INPUT_FILE_UPLOAD_PROMPT = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.rulesFileUpload.prompt', + { defaultMessage: 'Select or drag and drop the exported JSON file' } +); + +export const RULES_DATA_INPUT_FILE_UPLOAD_ERROR = { + CAN_NOT_READ: i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.rulesFileUpload.error.canNotRead', + { defaultMessage: 'Failed to read the rules export file' } + ), + CAN_NOT_READ_WITH_REASON: (reason: string) => + i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.rulesFileUpload.error.canNotReadWithReason', + { + defaultMessage: 'An error occurred when reading rules export file: {reason}', + values: { reason }, + } + ), + CAN_NOT_PARSE: i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.rulesFileUpload.error.canNotParse', + { defaultMessage: 'Cannot parse the rules export file as either a JSON file' } + ), + TOO_LARGE_TO_PARSE: i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.rulesFileUpload.error.tooLargeToParse', + { defaultMessage: 'This rules export file is too large to parse' } + ), + NOT_ARRAY: i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.rulesFileUpload.error.notArray', + { defaultMessage: 'The rules export file is not an array' } + ), + EMPTY: i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.rulesFileUpload.error.empty', + { defaultMessage: 'The rules export file is empty' } + ), + NOT_OBJECT: i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.rulesFileUpload.error.notObject', + { defaultMessage: 'The rules export file contains non-object entries' } + ), + WRONG_FORMAT: (formatError: string) => { + return i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.rulesFileUpload.error.wrongFormat', + { + defaultMessage: 'The rules export file has wrong format: {formatError}', + values: { formatError }, + } + ); + }, +}; + +export const RULES_DATA_INPUT_CREATE_MIGRATION_SUCCESS = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.rulesFileUpload.createSuccess', + { defaultMessage: 'Rules uploaded successfully' } +); +export const RULES_DATA_INPUT_CREATE_MIGRATION_ERROR = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.rulesFileUpload.createError', + { defaultMessage: 'Failed to upload rules file' } +); diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/translations.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/translations.ts new file mode 100644 index 0000000000000..5446180d03a75 --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/translations.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const RULES_DATA_INPUT_TITLE = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.title', + { defaultMessage: 'Upload rule export and check for macros and lookups' } +); diff --git a/x-pack/plugins/osquery/emotion.d.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/types.ts similarity index 53% rename from x-pack/plugins/osquery/emotion.d.ts rename to x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/types.ts index 213178080e536..16d8f60043bcb 100644 --- a/x-pack/plugins/osquery/emotion.d.ts +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/types.ts @@ -5,10 +5,6 @@ * 2.0. */ -import '@emotion/react'; -import type { UseEuiTheme } from '@elastic/eui'; +import type { RuleMigrationTaskStats } from '../../../../../common/siem_migrations/model/rule_migration.gen'; -declare module '@emotion/react' { - // eslint-disable-next-line @typescript-eslint/no-empty-interface - export interface Theme extends UseEuiTheme {} -} +export type OnMigrationCreated = (migrationStats: RuleMigrationTaskStats) => void; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/header_buttons/index.tsx b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/header_buttons/index.tsx index 728873f046d2e..3f255a49f87c2 100644 --- a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/header_buttons/index.tsx +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/header_buttons/index.tsx @@ -10,13 +10,13 @@ import React, { useMemo } from 'react'; import type { EuiComboBoxOptionOption } from '@elastic/eui'; import { EuiComboBox, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import * as i18n from './translations'; -import type { RuleMigrationTask } from '../../types'; +import type { RuleMigrationStats } from '../../types'; export interface HeaderButtonsProps { /** * Available rule migrations stats */ - ruleMigrationsStats: RuleMigrationTask[]; + ruleMigrationsStats: RuleMigrationStats[]; /** * Selected rule migration id diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/pages/index.tsx b/x-pack/plugins/security_solution/public/siem_migrations/rules/pages/index.tsx index 018aa5d77559e..3877a6f46cbe7 100644 --- a/x-pack/plugins/security_solution/public/siem_migrations/rules/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/pages/index.tsx @@ -21,7 +21,7 @@ import { NeedAdminForUpdateRulesCallOut } from '../../../detections/components/c import { MissingPrivilegesCallOut } from '../../../detections/components/callouts/missing_privileges_callout'; import { HeaderButtons } from '../components/header_buttons'; import { UnknownMigration } from '../components/unknown_migration'; -import { useLatestStats } from '../hooks/use_latest_stats'; +import { useLatestStats } from '../service/hooks/use_latest_stats'; type MigrationRulesPageProps = RouteComponentProps<{ migrationId?: string }>; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/common/api_request_reducer.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/common/api_request_reducer.ts new file mode 100644 index 0000000000000..a68432d48bf9c --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/common/api_request_reducer.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. + */ + +export interface State { + loading: boolean; + error: Error | null; +} +export type Action = { type: 'start' } | { type: 'error'; error: Error } | { type: 'success' }; + +export const initialState: State = { loading: false, error: null }; +export const reducer = (state: State, action: Action) => { + switch (action.type) { + case 'start': + return { loading: true, error: null }; + case 'error': + return { loading: false, error: action.error }; + case 'success': + return { loading: false, error: null }; + default: + return state; + } +}; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/translations.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/translations.ts new file mode 100644 index 0000000000000..936bc07e6576e --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/translations.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const RULES_DATA_INPUT_CREATE_MIGRATION_SUCCESS = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.rulesFileUpload.createSuccess', + { defaultMessage: 'Rules uploaded successfully' } +); +export const RULES_DATA_INPUT_CREATE_MIGRATION_ERROR = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.rulesFileUpload.createError', + { defaultMessage: 'Failed to upload rules file' } +); diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_create_migration.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_create_migration.ts new file mode 100644 index 0000000000000..94082cf59d359 --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_create_migration.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 { useCallback, useReducer } from 'react'; +import { i18n } from '@kbn/i18n'; +import type { RuleMigrationTaskStats } from '../../../../../common/siem_migrations/model/rule_migration.gen'; +import type { CreateRuleMigrationRequestBody } from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; +import { useKibana } from '../../../../common/lib/kibana/kibana_react'; +import { reducer, initialState } from './common/api_request_reducer'; + +export const RULES_DATA_INPUT_CREATE_MIGRATION_SUCCESS = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.service.createRuleSuccess', + { defaultMessage: 'Rules uploaded successfully' } +); +export const RULES_DATA_INPUT_CREATE_MIGRATION_ERROR = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.service.createRuleError', + { defaultMessage: 'Failed to upload rules file' } +); + +export type CreateMigration = (data: CreateRuleMigrationRequestBody) => void; +export type OnSuccess = (migrationStats: RuleMigrationTaskStats) => void; + +export const useCreateMigration = (onSuccess: OnSuccess) => { + const { siemMigrations, notifications } = useKibana().services; + const [state, dispatch] = useReducer(reducer, initialState); + + const createMigration = useCallback( + (data) => { + (async () => { + try { + dispatch({ type: 'start' }); + const migrationId = await siemMigrations.rules.createRuleMigration(data); + const stats = await siemMigrations.rules.getRuleMigrationStats(migrationId); + + notifications.toasts.addSuccess(RULES_DATA_INPUT_CREATE_MIGRATION_SUCCESS); + onSuccess(stats); + dispatch({ type: 'success' }); + } catch (err) { + const apiError = err.body ?? err; + notifications.toasts.addError(apiError, { + title: RULES_DATA_INPUT_CREATE_MIGRATION_ERROR, + }); + dispatch({ type: 'error', error: apiError }); + } + })(); + }, + [siemMigrations.rules, notifications.toasts, onSuccess] + ); + + return { isLoading: state.loading, error: state.error, createMigration }; +}; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/hooks/use_latest_stats.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_latest_stats.ts similarity index 91% rename from x-pack/plugins/security_solution/public/siem_migrations/rules/hooks/use_latest_stats.ts rename to x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_latest_stats.ts index c681af0d2a21c..8b692f07eb3cb 100644 --- a/x-pack/plugins/security_solution/public/siem_migrations/rules/hooks/use_latest_stats.ts +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_latest_stats.ts @@ -7,7 +7,7 @@ import useObservable from 'react-use/lib/useObservable'; import { useEffect, useMemo } from 'react'; -import { useKibana } from '../../../common/lib/kibana'; +import { useKibana } from '../../../../common/lib/kibana/kibana_react'; export const useLatestStats = () => { const { siemMigrations } = useKibana().services; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_start_migration.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_start_migration.ts new file mode 100644 index 0000000000000..6794439d1298e --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_start_migration.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useReducer } from 'react'; +import { i18n } from '@kbn/i18n'; +import { useKibana } from '../../../../common/lib/kibana/kibana_react'; +import { reducer, initialState } from './common/api_request_reducer'; + +export const RULES_DATA_INPUT_START_MIGRATION_SUCCESS = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.service.startMigrationSuccess', + { defaultMessage: 'Migration started successfully.' } +); +export const RULES_DATA_INPUT_START_MIGRATION_ERROR = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.service.startMigrationError', + { defaultMessage: 'Error starting migration.' } +); + +export type StartMigration = (migrationId: string) => void; +export type OnSuccess = () => void; + +export const useStartMigration = (onSuccess?: OnSuccess) => { + const { siemMigrations, notifications } = useKibana().services; + const [state, dispatch] = useReducer(reducer, initialState); + + const startMigration = useCallback( + (migrationId) => { + (async () => { + try { + dispatch({ type: 'start' }); + await siemMigrations.rules.startRuleMigration(migrationId); + + notifications.toasts.addSuccess(RULES_DATA_INPUT_START_MIGRATION_SUCCESS); + dispatch({ type: 'success' }); + onSuccess?.(); + } catch (err) { + const apiError = err.body ?? err; + notifications.toasts.addError(apiError, { + title: RULES_DATA_INPUT_START_MIGRATION_ERROR, + }); + dispatch({ type: 'error', error: apiError }); + } + })(); + }, + [siemMigrations.rules, notifications.toasts, onSuccess] + ); + + return { isLoading: state.loading, error: state.error, startMigration }; +}; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/service/rule_migrations_service.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/rule_migrations_service.ts index 3162cc3d58e63..c13b0606d771d 100644 --- a/x-pack/plugins/security_solution/public/siem_migrations/rules/service/rule_migrations_service.ts +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/rule_migrations_service.ts @@ -7,28 +7,55 @@ import { BehaviorSubject, type Observable } from 'rxjs'; import type { CoreStart } from '@kbn/core/public'; -import { i18n } from '@kbn/i18n'; +import type { TraceOptions } from '@kbn/elastic-assistant/impl/assistant/types'; +import { + DEFAULT_ASSISTANT_NAMESPACE, + TRACE_OPTIONS_SESSION_STORAGE_KEY, +} from '@kbn/elastic-assistant/impl/assistant_context/constants'; +import type { LangSmithOptions } from '../../../../common/siem_migrations/model/common.gen'; +import type { RuleMigrationTaskStats } from '../../../../common/siem_migrations/model/rule_migration.gen'; +import type { + CreateRuleMigrationRequestBody, + GetAllStatsRuleMigrationResponse, + GetRuleMigrationStatsResponse, +} from '../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; import { SiemMigrationTaskStatus } from '../../../../common/siem_migrations/constants'; import type { StartPluginsDependencies } from '../../../types'; import { ExperimentalFeaturesService } from '../../../common/experimental_features_service'; import { licenseService } from '../../../common/hooks/use_license'; -import { getRuleMigrationsStatsAll, startRuleMigration } from '../api'; -import type { RuleMigrationTask } from '../types'; +import { + createRuleMigration, + getRuleMigrationStats, + getRuleMigrationsStatsAll, + startRuleMigration, + type GetRuleMigrationsStatsAllParams, +} from '../api'; +import type { RuleMigrationStats } from '../types'; import { getSuccessToast } from './success_notification'; import { RuleMigrationsStorage } from './storage'; +import * as i18n from './translations'; + +// use the default assistant namespace since it's the only one we use +const NAMESPACE_TRACE_OPTIONS_SESSION_STORAGE_KEY = + `${DEFAULT_ASSISTANT_NAMESPACE}.${TRACE_OPTIONS_SESSION_STORAGE_KEY}` as const; + +const REQUEST_POLLING_INTERVAL_MS = 5000 as const; +const CREATE_MIGRATION_BODY_BATCH_SIZE = 50 as const; export class SiemRulesMigrationsService { - private readonly pollingInterval = 5000; - private readonly latestStats$: BehaviorSubject; - private readonly signal = new AbortController().signal; + private readonly latestStats$: BehaviorSubject; private isPolling = false; - public connectorIdStorage = new RuleMigrationsStorage('connectorId'); + public connectorIdStorage = new RuleMigrationsStorage('connectorId'); + public traceOptionsStorage = new RuleMigrationsStorage('traceOptions', { + customKey: NAMESPACE_TRACE_OPTIONS_SESSION_STORAGE_KEY, + storageType: 'session', + }); constructor( private readonly core: CoreStart, private readonly plugins: StartPluginsDependencies ) { - this.latestStats$ = new BehaviorSubject([]); + this.latestStats$ = new BehaviorSubject([]); this.plugins.spaces.getActiveSpace().then((space) => { this.connectorIdStorage.setSpaceId(space.id); @@ -36,7 +63,7 @@ export class SiemRulesMigrationsService { }); } - public getLatestStats$(): Observable { + public getLatestStats$(): Observable { return this.latestStats$.asObservable(); } @@ -48,27 +75,92 @@ export class SiemRulesMigrationsService { if (this.isPolling || !this.isAvailable()) { return; } - this.isPolling = true; - this.startStatsPolling() + this.startTaskStatsPolling() .catch((e) => { - this.core.notifications.toasts.addError(e, { - title: i18n.translate( - 'xpack.securitySolution.siemMigrations.rulesService.polling.errorTitle', - { defaultMessage: 'Error fetching rule migrations' } - ), - }); + this.core.notifications.toasts.addError(e, { title: i18n.POLLING_ERROR }); }) .finally(() => { this.isPolling = false; }); } - private async startStatsPolling(): Promise { + public async createRuleMigration(body: CreateRuleMigrationRequestBody): Promise { + if (body.length === 0) { + throw new Error(i18n.EMPTY_RULES_ERROR); + } + // Batching creation to avoid hitting the max payload size limit of the API + let migrationId: string | undefined; + for (let i = 0; i < body.length; i += CREATE_MIGRATION_BODY_BATCH_SIZE) { + const bodyBatch = body.slice(i, i + CREATE_MIGRATION_BODY_BATCH_SIZE); + const response = await createRuleMigration({ migrationId, body: bodyBatch }); + migrationId = response.migration_id; + } + return migrationId as string; + } + + public async startRuleMigration(migrationId: string): Promise { + const connectorId = this.connectorIdStorage.get(); + if (!connectorId) { + throw new Error(i18n.MISSING_CONNECTOR_ERROR); + } + + const langSmithSettings = this.traceOptionsStorage.get(); + let langSmithOptions: LangSmithOptions | undefined; + if (langSmithSettings) { + langSmithOptions = { + project_name: langSmithSettings.langSmithProject, + api_key: langSmithSettings.langSmithApiKey, + }; + } + + const result = await startRuleMigration({ migrationId, connectorId, langSmithOptions }); + this.startPolling(); + return result; + } + + public async getRuleMigrationStats(migrationId: string): Promise { + return getRuleMigrationStats({ migrationId }); + } + + public async getRuleMigrationsStats( + params: GetRuleMigrationsStatsAllParams = {} + ): Promise { + const allStats = await this.getRuleMigrationsStatsWithRetry(params); + const results = allStats.map( + // the array order (by creation) is guaranteed by the API + (stats, index) => ({ ...stats, number: index + 1 } as RuleMigrationStats) // needs cast because of the `status` enum override + ); + this.latestStats$.next(results); // Always update the latest stats + return results; + } + + private async getRuleMigrationsStatsWithRetry( + params: GetRuleMigrationsStatsAllParams = {}, + sleepSecs?: number + ): Promise { + if (sleepSecs) { + await new Promise((resolve) => setTimeout(resolve, sleepSecs * 1000)); + } + + return getRuleMigrationsStatsAll(params).catch((e) => { + // Retry only on network errors (no status) and 503s, otherwise throw + if (e.status && e.status !== 503) { + throw e; + } + const nextSleepSecs = sleepSecs ? sleepSecs * 2 : 1; // Exponential backoff + if (nextSleepSecs > 60) { + // Wait for a minutes max (two minutes total) for the API to be available again + throw e; + } + return this.getRuleMigrationsStatsWithRetry(params, nextSleepSecs); + }); + } + + private async startTaskStatsPolling(): Promise { let pendingMigrationIds: string[] = []; do { - const results = await this.fetchRuleMigrationTasksStats(); - this.latestStats$.next(results); + const results = await this.getRuleMigrationsStats(); if (pendingMigrationIds.length > 0) { // send notifications for finished migrations @@ -91,22 +183,13 @@ export class SiemRulesMigrationsService { const connectorId = this.connectorIdStorage.get(); if (connectorId) { // automatically resume stopped migrations when connector is available - await startRuleMigration({ - migrationId: result.id, - body: { connector_id: connectorId }, - signal: this.signal, - }); + await startRuleMigration({ migrationId: result.id, connectorId }); pendingMigrationIds.push(result.id); } } } - await new Promise((resolve) => setTimeout(resolve, this.pollingInterval)); + await new Promise((resolve) => setTimeout(resolve, REQUEST_POLLING_INTERVAL_MS)); } while (pendingMigrationIds.length > 0); } - - private async fetchRuleMigrationTasksStats(): Promise { - const stats = await getRuleMigrationsStatsAll({ signal: this.signal }); - return stats.map((stat, index) => ({ ...stat, number: index + 1 })); // the array order (by creation) is guaranteed by the API - } } diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/service/storage.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/storage.ts index bbf53ec3a5404..874f1b05dfab6 100644 --- a/x-pack/plugins/security_solution/public/siem_migrations/rules/service/storage.ts +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/storage.ts @@ -7,23 +7,37 @@ import { Storage } from '@kbn/kibana-utils-plugin/public'; -export class RuleMigrationsStorage { - private readonly storage = new Storage(localStorage); +const storages = { + local: new Storage(localStorage), + session: new Storage(sessionStorage), +} as const; + +interface Options { + customKey?: string; + storageType?: keyof typeof storages; +} + +export class RuleMigrationsStorage { + private readonly storage: Storage; public key: string; - constructor(private readonly objectName: string, spaceId?: string) { - this.key = this.getStorageKey(spaceId); + constructor(private readonly objectName: string, private readonly options?: Options) { + this.storage = storages[this.options?.storageType ?? 'local']; + this.key = this.getKey(); } - private getStorageKey(spaceId: string = 'default') { + private getKey(spaceId: string = 'default'): string { + if (this.options?.customKey) { + return this.options.customKey; + } return `siem_migrations.rules.${this.objectName}.${spaceId}`; } public setSpaceId(spaceId: string) { - this.key = this.getStorageKey(spaceId); + this.key = this.getKey(spaceId); } - public get = () => this.storage.get(this.key); - public set = (value: string) => this.storage.set(this.key, value); + public get = (): T | undefined => this.storage.get(this.key); + public set = (value: T) => this.storage.set(this.key, value); public remove = () => this.storage.remove(this.key); } diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/service/success_notification.tsx b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/success_notification.tsx index 830e3c5f4a531..f87755943f830 100644 --- a/x-pack/plugins/security_solution/public/siem_migrations/rules/service/success_notification.tsx +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/success_notification.tsx @@ -17,9 +17,9 @@ import type { ToastInput } from '@kbn/core-notifications-browser'; import { toMountPoint } from '@kbn/react-kibana-mount'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import type { RuleMigrationTask } from '../types'; +import type { RuleMigrationStats } from '../types'; -export const getSuccessToast = (migration: RuleMigrationTask, core: CoreStart): ToastInput => ({ +export const getSuccessToast = (migration: RuleMigrationStats, core: CoreStart): ToastInput => ({ color: 'success', iconType: 'check', toastLifeTimeMs: 1000 * 60 * 30, // 30 minutes @@ -34,7 +34,7 @@ export const getSuccessToast = (migration: RuleMigrationTask, core: CoreStart): ), }); -const SuccessToastContent: React.FC<{ migration: RuleMigrationTask }> = ({ migration }) => { +const SuccessToastContent: React.FC<{ migration: RuleMigrationStats }> = ({ migration }) => { const navigation = { deepLinkId: SecurityPageName.siemMigrationsRules, path: migration.id }; const { navigateTo, getAppUrl } = useNavigation(); diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/service/translations.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/translations.ts new file mode 100644 index 0000000000000..41a897a56e9df --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/translations.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const POLLING_ERROR = i18n.translate( + 'xpack.securitySolution.siemMigrations.rulesService.pollingError', + { defaultMessage: 'Error fetching rule migrations' } +); + +export const MISSING_CONNECTOR_ERROR = i18n.translate( + 'xpack.securitySolution.siemMigrations.rulesService.create.missingConnectorError', + { defaultMessage: 'Connector not defined. Please set a connector ID first.' } +); + +export const EMPTY_RULES_ERROR = i18n.translate( + 'xpack.securitySolution.siemMigrations.rulesService.create.emptyRulesError', + { defaultMessage: 'Can not create a migration without rules' } +); diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/types.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/types.ts index 4c704e97179c0..bcc11327d1051 100644 --- a/x-pack/plugins/security_solution/public/siem_migrations/rules/types.ts +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/types.ts @@ -5,9 +5,11 @@ * 2.0. */ +import type { SiemMigrationTaskStatus } from '../../../common/siem_migrations/constants'; import type { RuleMigrationTaskStats } from '../../../common/siem_migrations/model/rule_migration.gen'; -export interface RuleMigrationTask extends RuleMigrationTaskStats { +export interface RuleMigrationStats extends RuleMigrationTaskStats { + status: SiemMigrationTaskStatus; /** The sequential number of the migration */ number: number; } diff --git a/x-pack/plugins/security_solution/public/sourcerer/components/use_get_sourcerer_data_view.test.ts b/x-pack/plugins/security_solution/public/sourcerer/components/use_get_sourcerer_data_view.test.ts index 18e34ba2067a1..977ee35310031 100644 --- a/x-pack/plugins/security_solution/public/sourcerer/components/use_get_sourcerer_data_view.test.ts +++ b/x-pack/plugins/security_solution/public/sourcerer/components/use_get_sourcerer_data_view.test.ts @@ -6,7 +6,7 @@ */ import { DataView } from '@kbn/data-views-plugin/common'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { useSourcererDataView } from '../containers'; import { mockSourcererScope } from '../containers/mocks'; import { SourcererScopeName } from '../store/model'; @@ -14,14 +14,11 @@ import type { UseGetScopedSourcererDataViewArgs } from './use_get_sourcerer_data import { useGetScopedSourcererDataView } from './use_get_sourcerer_data_view'; const renderHookCustom = (args: UseGetScopedSourcererDataViewArgs) => { - return renderHook( - ({ sourcererScope }) => useGetScopedSourcererDataView({ sourcererScope }), - { - initialProps: { - ...args, - }, - } - ); + return renderHook(({ sourcererScope }) => useGetScopedSourcererDataView({ sourcererScope }), { + initialProps: { + ...args, + }, + }); }; jest.mock('../containers'); diff --git a/x-pack/plugins/security_solution/public/sourcerer/components/use_update_data_view.test.tsx b/x-pack/plugins/security_solution/public/sourcerer/components/use_update_data_view.test.tsx index b37565f3eb912..bb4d935dbdc99 100644 --- a/x-pack/plugins/security_solution/public/sourcerer/components/use_update_data_view.test.tsx +++ b/x-pack/plugins/security_solution/public/sourcerer/components/use_update_data_view.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { useUpdateDataView } from './use_update_data_view'; import { useKibana as mockUseKibana } from '../../common/lib/kibana/__mocks__'; import * as i18n from './translations'; diff --git a/x-pack/plugins/security_solution/public/sourcerer/containers/hooks.test.tsx b/x-pack/plugins/security_solution/public/sourcerer/containers/hooks.test.tsx index 8b0150efa6126..230a7d2fee1e0 100644 --- a/x-pack/plugins/security_solution/public/sourcerer/containers/hooks.test.tsx +++ b/x-pack/plugins/security_solution/public/sourcerer/containers/hooks.test.tsx @@ -6,8 +6,7 @@ */ import React from 'react'; -import { act, renderHook } from '@testing-library/react-hooks'; -import { waitFor } from '@testing-library/react'; +import { act, waitFor, renderHook } from '@testing-library/react'; import { Provider } from 'react-redux'; import { useSourcererDataView } from '.'; @@ -137,8 +136,12 @@ jest.mock('../../common/lib/kibana', () => ({ type: 'keyword', }, }), + toSpec: () => ({ + id: dataViewId, + }), }) ), + getExistingIndices: jest.fn(() => [] as string[]), }, indexPatterns: { getTitles: jest.fn().mockImplementation(() => Promise.resolve(mockPatterns)), @@ -153,34 +156,35 @@ jest.mock('../../common/lib/kibana', () => ({ describe('Sourcerer Hooks', () => { let store = createMockStore(); + const StoreProvider: React.FC = ({ children }) => ( + {children} + ); + const Wrapper: React.FC = ({ children }) => ( + {children} + ); + beforeEach(() => { jest.clearAllMocks(); store = createMockStore(); mockUseUserInfo.mockImplementation(() => userInfoState); }); it('initializes loading default and timeline index patterns', async () => { - await act(async () => { - const { rerender, waitForNextUpdate } = renderHook, void>( - () => useInitSourcerer(), - { - wrapper: ({ children }) => {children}, - } - ); - await waitForNextUpdate(); - rerender(); - expect(mockDispatch).toBeCalledTimes(3); - expect(mockDispatch.mock.calls[0][0]).toEqual({ - type: 'x-pack/security_solution/local/sourcerer/SET_DATA_VIEW_LOADING', - payload: { id: 'security-solution', loading: true }, - }); - expect(mockDispatch.mock.calls[1][0]).toEqual({ - type: 'x-pack/security_solution/local/sourcerer/SET_SELECTED_DATA_VIEW', - payload: { - id: 'timeline', - selectedDataViewId: 'security-solution', - selectedPatterns: ['.siem-signals-spacename', ...DEFAULT_INDEX_PATTERN], - }, - }); + const { rerender } = renderHook(() => useInitSourcerer(), { + wrapper: StoreProvider, + }); + await waitFor(() => new Promise((resolve) => resolve(null))); + rerender(); + expect(mockDispatch.mock.calls[0][0]).toEqual({ + type: 'x-pack/security_solution/local/sourcerer/SET_DATA_VIEW_LOADING', + payload: { id: 'security-solution', loading: true }, + }); + expect(mockDispatch.mock.calls[1][0]).toEqual({ + type: 'x-pack/security_solution/local/sourcerer/SET_SELECTED_DATA_VIEW', + payload: { + id: 'timeline', + selectedDataViewId: 'security-solution', + selectedPatterns: ['.siem-signals-spacename', ...DEFAULT_INDEX_PATTERN], + }, }); }); it('sets signal index name', async () => { @@ -202,47 +206,42 @@ describe('Sourcerer Hooks', () => { }, }, }); - await act(async () => { - mockUseUserInfo.mockImplementation(() => ({ - ...userInfoState, - loading: false, - signalIndexName: mockSourcererState.signalIndexName, - })); - const { rerender, waitForNextUpdate } = renderHook, void>( - () => useInitSourcerer(), - { - wrapper: ({ children }) => {children}, - } - ); - await waitForNextUpdate(); - rerender(); - await waitFor(() => { - expect(mockDispatch.mock.calls[3][0]).toEqual({ - type: 'x-pack/security_solution/local/sourcerer/SET_SOURCERER_SCOPE_LOADING', - payload: { loading: true }, - }); - expect(mockDispatch.mock.calls[4][0]).toEqual({ - type: 'x-pack/security_solution/local/sourcerer/SET_SIGNAL_INDEX_NAME', - payload: { signalIndexName: mockSourcererState.signalIndexName }, - }); - expect(mockDispatch.mock.calls[5][0]).toEqual({ - type: 'x-pack/security_solution/local/sourcerer/SET_DATA_VIEW_LOADING', - payload: { - id: mockSourcererState.defaultDataView.id, - loading: true, - }, - }); - expect(mockDispatch.mock.calls[6][0]).toEqual({ - type: 'x-pack/security_solution/local/sourcerer/SET_SOURCERER_DATA_VIEWS', - payload: mockNewDataViews, - }); - expect(mockDispatch.mock.calls[7][0]).toEqual({ - type: 'x-pack/security_solution/local/sourcerer/SET_SOURCERER_SCOPE_LOADING', - payload: { loading: false }, - }); - expect(mockDispatch).toHaveBeenCalledTimes(8); - expect(mockSearch).toHaveBeenCalledTimes(2); + mockUseUserInfo.mockImplementation(() => ({ + ...userInfoState, + loading: false, + signalIndexName: mockSourcererState.signalIndexName, + })); + const { rerender } = renderHook(useInitSourcerer, { + wrapper: StoreProvider, + }); + await waitFor(() => new Promise((resolve) => resolve(null))); + rerender(); + await waitFor(() => { + expect(mockDispatch.mock.calls[3][0]).toEqual({ + type: 'x-pack/security_solution/local/sourcerer/SET_SOURCERER_SCOPE_LOADING', + payload: { loading: true }, + }); + expect(mockDispatch.mock.calls[4][0]).toEqual({ + type: 'x-pack/security_solution/local/sourcerer/SET_SIGNAL_INDEX_NAME', + payload: { signalIndexName: mockSourcererState.signalIndexName }, + }); + expect(mockDispatch.mock.calls[5][0]).toEqual({ + type: 'x-pack/security_solution/local/sourcerer/SET_DATA_VIEW_LOADING', + payload: { + id: mockSourcererState.defaultDataView.id, + loading: true, + }, + }); + expect(mockDispatch.mock.calls[6][0]).toEqual({ + type: 'x-pack/security_solution/local/sourcerer/SET_SOURCERER_DATA_VIEWS', + payload: mockNewDataViews, }); + expect(mockDispatch.mock.calls[7][0]).toEqual({ + type: 'x-pack/security_solution/local/sourcerer/SET_SOURCERER_SCOPE_LOADING', + payload: { loading: false }, + }); + + expect(mockSearch).toHaveBeenCalledTimes(2); }); }); @@ -258,8 +257,8 @@ describe('Sourcerer Hooks', () => { }) ); - renderHook, void>(() => useInitSourcerer(), { - wrapper: ({ children }) => {children}, + renderHook(() => useInitSourcerer(), { + wrapper: Wrapper, }); expect(mockDispatch).toHaveBeenCalledWith( @@ -278,8 +277,8 @@ describe('Sourcerer Hooks', () => { onInitialize(null) ); - renderHook, void>(() => useInitSourcerer(), { - wrapper: ({ children }) => {children}, + renderHook(() => useInitSourcerer(), { + wrapper: Wrapper, }); expect(updateUrlParam).toHaveBeenCalledWith({ @@ -302,16 +301,14 @@ describe('Sourcerer Hooks', () => { }, }, }); - await act(async () => { - renderHook, void>(() => useInitSourcerer(), { - wrapper: ({ children }) => {children}, - }); + renderHook(() => useInitSourcerer(), { + wrapper: StoreProvider, + }); - await waitFor(() => { - expect(mockAddWarning).toHaveBeenNthCalledWith(1, { - text: 'Users with write permission need to access the Elastic Security app to initialize the app source data.', - title: 'Write role required to generate data', - }); + await waitFor(() => { + expect(mockAddWarning).toHaveBeenNthCalledWith(1, { + text: 'Users with write permission need to access the Elastic Security app to initialize the app source data.', + title: 'Write role required to generate data', }); }); }); @@ -333,27 +330,25 @@ describe('Sourcerer Hooks', () => { }, }, }); - await act(async () => { - mockUseUserInfo.mockImplementation(() => ({ - ...userInfoState, - loading: false, - signalIndexName: mockSourcererState.signalIndexName, - })); - const { rerender, waitForNextUpdate } = renderHook, void>( - () => useInitSourcerer(), - { - wrapper: ({ children }) => {children}, - } - ); - await waitForNextUpdate(); - rerender(); + mockUseUserInfo.mockImplementation(() => ({ + ...userInfoState, + loading: false, + signalIndexName: mockSourcererState.signalIndexName, + })); - await waitFor(() => { - expect(mockCreateSourcererDataView).toHaveBeenCalled(); - expect(mockAddError).not.toHaveBeenCalled(); - }); + const { rerender } = renderHook(() => useInitSourcerer(), { + wrapper: StoreProvider, }); + + await waitFor(() => new Promise((resolve) => resolve(null))); + + rerender(); + + await waitFor(() => new Promise((resolve) => resolve(null))); + + expect(mockCreateSourcererDataView).toHaveBeenCalled(); + expect(mockAddError).not.toHaveBeenCalled(); }); it('does call addError if updateSourcererDataView receives a non-abort error', async () => { @@ -375,66 +370,51 @@ describe('Sourcerer Hooks', () => { }, }, }); - await act(async () => { - mockUseUserInfo.mockImplementation(() => ({ - ...userInfoState, - loading: false, - signalIndexName: mockSourcererState.signalIndexName, - })); - const { rerender, waitForNextUpdate } = renderHook, void>( - () => useInitSourcerer(), - { - wrapper: ({ children }) => {children}, - } - ); + mockUseUserInfo.mockImplementation(() => ({ + ...userInfoState, + loading: false, + signalIndexName: mockSourcererState.signalIndexName, + })); + const { rerender } = renderHook(() => useInitSourcerer(), { + wrapper: StoreProvider, + }); - await waitForNextUpdate(); - rerender(); + await waitFor(() => new Promise((resolve) => resolve(null))); + rerender(); - await waitFor(() => { - expect(mockAddError).toHaveBeenCalled(); - }); + await waitFor(() => { + expect(mockAddError).toHaveBeenCalled(); }); }); it('handles detections page', async () => { - await act(async () => { - mockUseUserInfo.mockImplementation(() => ({ - ...userInfoState, - signalIndexName: mockSourcererState.signalIndexName, - isSignalIndexExists: true, - })); - const { rerender, waitForNextUpdate } = renderHook, void>( - () => useInitSourcerer(SourcererScopeName.detections), - { - wrapper: ({ children }) => {children}, - } - ); - await waitForNextUpdate(); - rerender(); - expect(mockDispatch.mock.calls[3][0]).toEqual({ - type: 'x-pack/security_solution/local/sourcerer/SET_SELECTED_DATA_VIEW', - payload: { - id: 'detections', - selectedDataViewId: mockSourcererState.defaultDataView.id, - selectedPatterns: [mockSourcererState.signalIndexName], - }, - }); + mockUseUserInfo.mockImplementation(() => ({ + ...userInfoState, + signalIndexName: mockSourcererState.signalIndexName, + isSignalIndexExists: true, + })); + const { rerender } = renderHook(() => useInitSourcerer(SourcererScopeName.detections), { + wrapper: StoreProvider, + }); + await waitFor(() => new Promise((resolve) => resolve(null))); + rerender(); + expect(mockDispatch.mock.calls[3][0]).toEqual({ + type: 'x-pack/security_solution/local/sourcerer/SET_SELECTED_DATA_VIEW', + payload: { + id: 'detections', + selectedDataViewId: mockSourcererState.defaultDataView.id, + selectedPatterns: [mockSourcererState.signalIndexName], + }, }); }); it('index field search is not repeated when default and timeline have same dataViewId', async () => { - await act(async () => { - const { rerender, waitForNextUpdate } = renderHook, void>( - () => useInitSourcerer(), - { - wrapper: ({ children }) => {children}, - } - ); - await waitForNextUpdate(); - rerender(); - await waitFor(() => { - expect(mockSearch).toHaveBeenCalledTimes(1); - }); + const { rerender } = renderHook(() => useInitSourcerer(), { + wrapper: StoreProvider, + }); + await waitFor(() => new Promise((resolve) => resolve(null))); + rerender(); + await waitFor(() => { + expect(mockSearch).toHaveBeenCalledTimes(1); }); }); it('index field search called twice when default and timeline have different dataViewId', async () => { @@ -451,18 +431,13 @@ describe('Sourcerer Hooks', () => { }, }, }); - await act(async () => { - const { rerender, waitForNextUpdate } = renderHook, void>( - () => useInitSourcerer(), - { - wrapper: ({ children }) => {children}, - } - ); - await waitForNextUpdate(); - rerender(); - await waitFor(() => { - expect(mockSearch).toHaveBeenCalledTimes(2); - }); + const { rerender } = renderHook(() => useInitSourcerer(), { + wrapper: StoreProvider, + }); + await waitFor(() => new Promise((resolve) => resolve(null))); + rerender(); + await waitFor(() => { + expect(mockSearch).toHaveBeenCalledTimes(2); }); }); describe('initialization settings', () => { @@ -476,21 +451,16 @@ describe('Sourcerer Hooks', () => { })); }); it('does not needToBeInit if scope is default and selectedPatterns/missingPatterns have values', async () => { - await act(async () => { - const { rerender, waitForNextUpdate } = renderHook, void>( - () => useInitSourcerer(), - { - wrapper: ({ children }) => {children}, - } - ); - await waitForNextUpdate(); - rerender(); - await waitFor(() => { - expect(mockIndexFieldsSearch).toHaveBeenCalledWith({ - dataViewId: mockSourcererState.defaultDataView.id, - needToBeInit: false, - scopeId: SourcererScopeName.default, - }); + const { rerender } = renderHook(() => useInitSourcerer(), { + wrapper: StoreProvider, + }); + await waitFor(() => new Promise((resolve) => resolve(null))); + rerender(); + await waitFor(() => { + expect(mockIndexFieldsSearch).toHaveBeenCalledWith({ + dataViewId: mockSourcererState.defaultDataView.id, + needToBeInit: false, + scopeId: SourcererScopeName.default, }); }); }); @@ -510,21 +480,16 @@ describe('Sourcerer Hooks', () => { }, }, }); - await act(async () => { - const { rerender, waitForNextUpdate } = renderHook, void>( - () => useInitSourcerer(), - { - wrapper: ({ children }) => {children}, - } - ); - await waitForNextUpdate(); - rerender(); - await waitFor(() => { - expect(mockIndexFieldsSearch).toHaveBeenCalledWith({ - dataViewId: mockSourcererState.defaultDataView.id, - needToBeInit: true, - scopeId: SourcererScopeName.default, - }); + const { rerender } = renderHook(() => useInitSourcerer(), { + wrapper: StoreProvider, + }); + await waitFor(() => new Promise((resolve) => resolve(null))); + rerender(); + await waitFor(() => { + expect(mockIndexFieldsSearch).toHaveBeenCalledWith({ + dataViewId: mockSourcererState.defaultDataView.id, + needToBeInit: true, + scopeId: SourcererScopeName.default, }); }); }); @@ -549,22 +514,17 @@ describe('Sourcerer Hooks', () => { }, }, }); - await act(async () => { - const { rerender, waitForNextUpdate } = renderHook, void>( - () => useInitSourcerer(), - { - wrapper: ({ children }) => {children}, - } - ); - await waitForNextUpdate(); - rerender(); - await waitFor(() => { - expect(mockIndexFieldsSearch).toHaveBeenNthCalledWith(2, { - dataViewId: 'something-weird', - needToBeInit: true, - scopeId: SourcererScopeName.timeline, - skipScopeUpdate: false, - }); + const { rerender } = renderHook(() => useInitSourcerer(), { + wrapper: StoreProvider, + }); + await waitFor(() => new Promise((resolve) => resolve(null))); + rerender(); + await waitFor(() => { + expect(mockIndexFieldsSearch).toHaveBeenNthCalledWith(2, { + dataViewId: 'something-weird', + needToBeInit: true, + scopeId: SourcererScopeName.timeline, + skipScopeUpdate: false, }); }); }); @@ -589,22 +549,17 @@ describe('Sourcerer Hooks', () => { }, }, }); - await act(async () => { - const { rerender, waitForNextUpdate } = renderHook, void>( - () => useInitSourcerer(), - { - wrapper: ({ children }) => {children}, - } - ); - await waitForNextUpdate(); - rerender(); - await waitFor(() => { - expect(mockIndexFieldsSearch).toHaveBeenNthCalledWith(2, { - dataViewId: 'something-weird', - needToBeInit: true, - scopeId: SourcererScopeName.timeline, - skipScopeUpdate: true, - }); + const { rerender } = renderHook(() => useInitSourcerer(), { + wrapper: StoreProvider, + }); + await waitFor(() => new Promise((resolve) => resolve(null))); + rerender(); + await waitFor(() => { + expect(mockIndexFieldsSearch).toHaveBeenNthCalledWith(2, { + dataViewId: 'something-weird', + needToBeInit: true, + scopeId: SourcererScopeName.timeline, + skipScopeUpdate: true, }); }); }); @@ -633,21 +588,16 @@ describe('Sourcerer Hooks', () => { }, }, }); - await act(async () => { - const { rerender, waitForNextUpdate } = renderHook, void>( - () => useInitSourcerer(), - { - wrapper: ({ children }) => {children}, - } - ); - await waitForNextUpdate(); - rerender(); - await waitFor(() => { - expect(mockIndexFieldsSearch).toHaveBeenNthCalledWith(2, { - dataViewId: 'something-weird', - needToBeInit: false, - scopeId: SourcererScopeName.timeline, - }); + const { rerender } = renderHook(() => useInitSourcerer(), { + wrapper: StoreProvider, + }); + await waitFor(() => new Promise((resolve) => resolve(null))); + rerender(); + await waitFor(() => { + expect(mockIndexFieldsSearch).toHaveBeenNthCalledWith(2, { + dataViewId: 'something-weird', + needToBeInit: false, + scopeId: SourcererScopeName.timeline, }); }); }); @@ -655,38 +605,39 @@ describe('Sourcerer Hooks', () => { describe('useSourcererDataView', () => { it('Should put any excludes in the index pattern at the end of the pattern list, and sort both the includes and excludes', async () => { - await act(async () => { - store = createMockStore({ - ...mockGlobalState, - sourcerer: { - ...mockGlobalState.sourcerer, - sourcererScopes: { - ...mockGlobalState.sourcerer.sourcererScopes, - [SourcererScopeName.default]: { - ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.default], - selectedPatterns: [ - '-packetbeat-*', - 'endgame-*', - 'auditbeat-*', - 'filebeat-*', - 'winlogbeat-*', - '-filebeat-*', - 'packetbeat-*', - 'traces-apm*', - 'apm-*-transaction*', - ], - }, + store = createMockStore({ + ...mockGlobalState, + sourcerer: { + ...mockGlobalState.sourcerer, + sourcererScopes: { + ...mockGlobalState.sourcerer.sourcererScopes, + [SourcererScopeName.default]: { + ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.default], + selectedPatterns: [ + '-packetbeat-*', + 'endgame-*', + 'auditbeat-*', + 'filebeat-*', + 'winlogbeat-*', + '-filebeat-*', + 'packetbeat-*', + 'traces-apm*', + 'apm-*-transaction*', + ], }, }, - }); - const { result, rerender, waitForNextUpdate } = renderHook< - React.PropsWithChildren<{}>, - SelectedDataView - >(() => useSourcererDataView(), { - wrapper: ({ children }) => {children}, - }); - await waitForNextUpdate(); - rerender(); + }, + }); + + const { result, rerender } = renderHook( + useSourcererDataView, + { + wrapper: StoreProvider, + } + ); + await waitFor(() => new Promise((resolve) => resolve(null))); + rerender(); + await waitFor(() => { expect(result.current.selectedPatterns).toEqual([ 'apm-*-transaction*', 'auditbeat-*', @@ -703,12 +654,9 @@ describe('Sourcerer Hooks', () => { }); it('should update the title and name of the data view according to the selected patterns', async () => { - const { result, rerender } = renderHook, SelectedDataView>( - () => useSourcererDataView(), - { - wrapper: ({ children }) => {children}, - } - ); + const { result, rerender } = renderHook(() => useSourcererDataView(), { + wrapper: StoreProvider, + }); expect(result.current.sourcererDataView?.title).toBe( 'apm-*-transaction*,auditbeat-*,endgame-*,filebeat-*,logs-*,packetbeat-*,traces-apm*,winlogbeat-*,-*elastic-cloud-logs-*' @@ -725,7 +673,7 @@ describe('Sourcerer Hooks', () => { ); }); - await rerender(); + rerender(); expect(result.current.sourcererDataView?.title).toBe(testPatterns.join(',')); expect(result.current.sourcererDataView?.name).toBe(testPatterns.join(',')); diff --git a/x-pack/plugins/security_solution/public/sourcerer/containers/use_signal_helpers.test.tsx b/x-pack/plugins/security_solution/public/sourcerer/containers/use_signal_helpers.test.tsx index 5bb0cc11ebffb..43f6f5d8a0a1f 100644 --- a/x-pack/plugins/security_solution/public/sourcerer/containers/use_signal_helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/sourcerer/containers/use_signal_helpers.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { createMockStore, mockGlobalState, TestProviders } from '../../common/mock'; -import { act, renderHook } from '@testing-library/react-hooks'; +import { waitFor, renderHook } from '@testing-library/react'; import { useSignalHelpers } from './use_signal_helpers'; import type { State } from '../../common/store'; import { createSourcererDataView } from './create_sourcerer_data_view'; @@ -41,14 +41,12 @@ describe('useSignalHelpers', () => { ); test('Default state, does not need init and does not need poll', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => useSignalHelpers(), { - wrapper: wrapperContainer, - }); - await waitForNextUpdate(); - expect(result.current.signalIndexNeedsInit).toEqual(false); - expect(result.current.pollForSignalIndex).toEqual(undefined); + const { result } = renderHook(() => useSignalHelpers(), { + wrapper: wrapperContainer, }); + await waitFor(() => new Promise((resolve) => resolve(null))); + expect(result.current.signalIndexNeedsInit).toEqual(false); + expect(result.current.pollForSignalIndex).toEqual(undefined); }); test('Needs init and does not need poll when signal index is not yet in default data view', async () => { const state: State = { @@ -70,16 +68,14 @@ describe('useSignalHelpers', () => { }, }; const store = createMockStore(state); - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => useSignalHelpers(), { - wrapper: ({ children }: React.PropsWithChildren<{}>) => ( - {children} - ), - }); - await waitForNextUpdate(); - expect(result.current.signalIndexNeedsInit).toEqual(true); - expect(result.current.pollForSignalIndex).toEqual(undefined); + const { result } = renderHook(() => useSignalHelpers(), { + wrapper: ({ children }: React.PropsWithChildren<{}>) => ( + {children} + ), }); + await waitFor(() => new Promise((resolve) => resolve(null))); + expect(result.current.signalIndexNeedsInit).toEqual(true); + expect(result.current.pollForSignalIndex).toEqual(undefined); }); test('Init happened and signal index does not have data yet, poll function becomes available', async () => { const state: State = { @@ -101,16 +97,14 @@ describe('useSignalHelpers', () => { }, }; const store = createMockStore(state); - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => useSignalHelpers(), { - wrapper: ({ children }: React.PropsWithChildren<{}>) => ( - {children} - ), - }); - await waitForNextUpdate(); - expect(result.current.signalIndexNeedsInit).toEqual(false); - expect(result.current.pollForSignalIndex).not.toEqual(undefined); + const { result } = renderHook(() => useSignalHelpers(), { + wrapper: ({ children }: React.PropsWithChildren<{}>) => ( + {children} + ), }); + await waitFor(() => new Promise((resolve) => resolve(null))); + expect(result.current.signalIndexNeedsInit).toEqual(false); + expect(result.current.pollForSignalIndex).not.toEqual(undefined); }); test('Init happened and signal index does not have data yet, poll function becomes available but createSourcererDataView throws an abort error', async () => { @@ -134,17 +128,15 @@ describe('useSignalHelpers', () => { }, }; const store = createMockStore(state); - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => useSignalHelpers(), { - wrapper: ({ children }: React.PropsWithChildren<{}>) => ( - {children} - ), - }); - await waitForNextUpdate(); - expect(result.current.signalIndexNeedsInit).toEqual(false); - expect(result.current.pollForSignalIndex).not.toEqual(undefined); - expect(mockAddError).not.toHaveBeenCalled(); + const { result } = renderHook(() => useSignalHelpers(), { + wrapper: ({ children }: React.PropsWithChildren<{}>) => ( + {children} + ), }); + await waitFor(() => new Promise((resolve) => resolve(null))); + expect(result.current.signalIndexNeedsInit).toEqual(false); + expect(result.current.pollForSignalIndex).not.toEqual(undefined); + expect(mockAddError).not.toHaveBeenCalled(); }); test('Init happened and signal index does not have data yet, poll function becomes available but createSourcererDataView throws a non-abort error', async () => { @@ -170,17 +162,15 @@ describe('useSignalHelpers', () => { }, }; const store = createMockStore(state); - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => useSignalHelpers(), { - wrapper: ({ children }: React.PropsWithChildren<{}>) => ( - {children} - ), - }); - await waitForNextUpdate(); - expect(result.current.signalIndexNeedsInit).toEqual(false); - expect(result.current.pollForSignalIndex).not.toEqual(undefined); - result.current.pollForSignalIndex?.(); - expect(mockAddError).toHaveBeenCalled(); + const { result } = renderHook(() => useSignalHelpers(), { + wrapper: ({ children }: React.PropsWithChildren<{}>) => ( + {children} + ), }); + await waitFor(() => new Promise((resolve) => resolve(null))); + expect(result.current.signalIndexNeedsInit).toEqual(false); + expect(result.current.pollForSignalIndex).not.toEqual(undefined); + result.current.pollForSignalIndex?.(); + expect(mockAddError).toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/create_field_button/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/create_field_button/index.test.tsx index 4c1c72500ae8b..01c9dd701292a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/create_field_button/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/create_field_button/index.test.tsx @@ -5,19 +5,18 @@ * 2.0. */ -import { render } from '@testing-library/react'; +import { render, renderHook } from '@testing-library/react'; import React from 'react'; -import type { UseCreateFieldButton, UseCreateFieldButtonProps } from '.'; +import type { UseCreateFieldButtonProps } from '.'; import { useCreateFieldButton } from '.'; import { TestProviders } from '../../../../common/mock'; -import { renderHook } from '@testing-library/react-hooks'; const mockOpenFieldEditor = jest.fn(); const mockOnHide = jest.fn(); const renderUseCreateFieldButton = (props: Partial = {}) => - renderHook, ReturnType>( + renderHook( () => useCreateFieldButton({ isAllowed: true, diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_table_columns/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_table_columns/index.test.tsx index 8d82c3402af7c..a565a31ef67a7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_table_columns/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_table_columns/index.test.tsx @@ -6,12 +6,11 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; -import type { UseFieldTableColumnsProps, UseFieldTableColumns } from '.'; +import { render, renderHook } from '@testing-library/react'; +import type { UseFieldTableColumnsProps } from '.'; import { useFieldTableColumns } from '.'; import { TestProviders } from '../../../../common/mock'; -import { renderHook } from '@testing-library/react-hooks'; import { EuiInMemoryTable } from '@elastic/eui'; import type { BrowserFieldItem } from '@kbn/triggers-actions-ui-plugin/public/types'; @@ -21,7 +20,7 @@ const mockOpenDeleteFieldModal = jest.fn(); // helper function to render the hook const renderUseFieldTableColumns = (props: Partial = {}) => - renderHook, ReturnType>( + renderHook( () => useFieldTableColumns({ hasFieldEditPermission: true, diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.test.tsx index 67094a18cf327..7b67fb6614adf 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.test.tsx @@ -6,7 +6,8 @@ */ import React from 'react'; -import { render, act } from '@testing-library/react'; +import type { RenderHookResult } from '@testing-library/react'; +import { render, act, waitFor, renderHook } from '@testing-library/react'; import type { Store } from 'redux'; import type { UseFieldBrowserOptionsProps, UseFieldBrowserOptions, FieldEditorActionsRef } from '.'; import { useFieldBrowserOptions } from '.'; @@ -16,8 +17,6 @@ import { indexPatternFieldEditorPluginMock } from '@kbn/data-view-field-editor-p import { TestProviders } from '../../../common/mock'; import { useKibana } from '../../../common/lib/kibana'; import type { DataView, DataViewField } from '@kbn/data-plugin/common'; -import type { RenderHookResult } from '@testing-library/react-hooks'; -import { renderHook } from '@testing-library/react-hooks'; import { SourcererScopeName } from '../../../sourcerer/store/model'; import { defaultColumnHeaderType } from '../timeline/body/column_headers/default_headers'; import { DEFAULT_COLUMN_MIN_WIDTH } from '../timeline/body/constants'; @@ -42,12 +41,13 @@ const mockOnHide = jest.fn(); const runAllPromises = () => new Promise(setImmediate); // helper function to render the hook -const renderUseFieldBrowserOptions = ( - props: Partial = {} -) => +const renderUseFieldBrowserOptions = ({ + store, + ...props +}: Partial = {}) => renderHook< - React.PropsWithChildren, - ReturnType + ReturnType, + React.PropsWithChildren >( () => useFieldBrowserOptions({ @@ -57,7 +57,7 @@ const renderUseFieldBrowserOptions = ( ...props, }), { - wrapper: ({ children, store }) => { + wrapper: ({ children }) => { if (store) { return {children}; } @@ -71,12 +71,12 @@ const renderUpdatedUseFieldBrowserOptions = async ( props: Partial = {} ) => { let renderHookResult: RenderHookResult< - UseFieldBrowserOptionsProps, - ReturnType + ReturnType, + UseFieldBrowserOptionsProps > | null = null; await act(async () => { renderHookResult = renderUseFieldBrowserOptions(props); - await renderHookResult.waitForNextUpdate(); + await waitFor(() => new Promise((resolve) => resolve(null))); }); return renderHookResult!; }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts index 525d8bba3d909..14fed472e0e1f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts @@ -6,8 +6,7 @@ */ import { cloneDeep, getOr, omit } from 'lodash/fp'; -import { renderHook } from '@testing-library/react-hooks'; -import { waitFor } from '@testing-library/react'; +import { waitFor, renderHook } from '@testing-library/react'; import { mockTimelineResults, mockGetOneTimelineResult } from '../../../common/mock'; import { timelineDefaults } from '../../store/defaults'; 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 da56cb12b4a00..5a3ba28a82767 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 @@ -6,9 +6,8 @@ */ import React from 'react'; -import { renderHook } from '@testing-library/react-hooks'; import { mount } from 'enzyme'; -import { fireEvent, render, waitFor } from '@testing-library/react'; +import { fireEvent, render, waitFor, renderHook } from '@testing-library/react'; import { useHistory, useParams } from 'react-router-dom'; import '../../../common/mock/formatted_relative'; @@ -30,7 +29,6 @@ import { NotePreviews } from './note_previews'; import { OPEN_TIMELINE_CLASS_NAME } from './helpers'; import { StatefulOpenTimeline } from '.'; import { TimelineTabsStyle } from './types'; -import type { UseTimelineTypesArgs, UseTimelineTypesResult } from './use_timeline_types'; import { useTimelineTypes } from './use_timeline_types'; import { deleteTimelinesByIds } from '../../containers/api'; import { useUserPrivileges } from '../../../common/components/user_privileges'; @@ -165,12 +163,12 @@ describe('StatefulOpenTimeline', () => { describe("Template timelines' tab", () => { test("should land on correct timelines' tab with url timelines/default", () => { - const { result } = renderHook< - React.PropsWithChildren, - UseTimelineTypesResult - >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 0 }), { - wrapper: ({ children }) => {children}, - }); + const { result } = renderHook( + () => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 0 }), + { + wrapper: ({ children }) => {children}, + } + ); expect(result.current.timelineType).toBe(TimelineTypeEnum.default); }); @@ -181,12 +179,12 @@ describe('StatefulOpenTimeline', () => { pageName: SecurityPageName.timelines, }); - const { result } = renderHook< - React.PropsWithChildren, - UseTimelineTypesResult - >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 0 }), { - wrapper: ({ children }) => {children}, - }); + const { result } = renderHook( + () => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 0 }), + { + wrapper: ({ children }) => {children}, + } + ); expect(result.current.timelineType).toBe(TimelineTypeEnum.template); }); @@ -223,12 +221,12 @@ describe('StatefulOpenTimeline', () => { pageName: SecurityPageName.case, }); - const { result } = renderHook< - React.PropsWithChildren, - UseTimelineTypesResult - >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 0 }), { - wrapper: ({ children }) => {children}, - }); + const { result } = renderHook( + () => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 0 }), + { + wrapper: ({ children }) => {children}, + } + ); expect(result.current.timelineType).toBe(TimelineTypeEnum.default); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/hooks/use_delete_note.test.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/hooks/use_delete_note.test.ts index b2c88364a8209..d9dd243357d5b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/hooks/use_delete_note.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/hooks/use_delete_note.test.ts @@ -4,7 +4,8 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { renderHook, act } from '@testing-library/react-hooks'; + +import { renderHook, act } from '@testing-library/react'; import { useKibana } from '../../../../../common/lib/kibana'; import { useAppToasts } from '../../../../../common/hooks/use_app_toasts'; import { appActions } from '../../../../../common/store/app'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.test.tsx index 66d3a41bc0d48..22e03955afbc2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.test.tsx @@ -6,9 +6,7 @@ */ import React from 'react'; -import { fireEvent, render } from '@testing-library/react'; -import { renderHook, act } from '@testing-library/react-hooks'; -import type { UseTimelineTypesArgs, UseTimelineTypesResult } from './use_timeline_types'; +import { fireEvent, render, waitFor, screen, renderHook } from '@testing-library/react'; import { useTimelineTypes } from './use_timeline_types'; import { TestProviders } from '../../../common/mock'; @@ -31,12 +29,14 @@ jest.mock('../../../common/components/link_to', () => { }; }); +const mockNavigateToUrl = jest.fn(); + jest.mock('@kbn/kibana-react-plugin/public', () => { const originalModule = jest.requireActual('@kbn/kibana-react-plugin/public'); const useKibana = jest.fn().mockImplementation(() => ({ services: { application: { - navigateToUrl: jest.fn(), + navigateToUrl: mockNavigateToUrl, }, }, })); @@ -48,92 +48,83 @@ jest.mock('@kbn/kibana-react-plugin/public', () => { }); describe('useTimelineTypes', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + it('init', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook< - React.PropsWithChildren, - UseTimelineTypesResult - >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), { + const { result } = renderHook( + () => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), + { wrapper: TestProviders, - }); - await waitForNextUpdate(); - expect(result.current).toEqual({ - timelineType: 'default', - timelineTabs: result.current.timelineTabs, - timelineFilters: result.current.timelineFilters, - }); + } + ); + + expect(result.current).toEqual({ + timelineType: 'default', + timelineTabs: result.current.timelineTabs, + timelineFilters: result.current.timelineFilters, }); }); describe('timelineTabs', () => { it('render timelineTabs', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook< - React.PropsWithChildren, - UseTimelineTypesResult - >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), { + const { result } = renderHook( + () => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), + { wrapper: TestProviders, - }); - await waitForNextUpdate(); - - const { container } = render(result.current.timelineTabs); - expect( - container.querySelector('[data-test-subj="timeline-tab-default"]') - ).toHaveTextContent('Timelines'); - expect( - container.querySelector('[data-test-subj="timeline-tab-template"]') - ).toHaveTextContent('Templates'); - }); + } + ); + await waitFor(() => new Promise((resolve) => resolve(null))); + + render(result.current.timelineTabs); + expect(screen.getByTestId('timeline-tab-default')).toHaveTextContent('Timelines'); + expect(screen.getByTestId('timeline-tab-template')).toHaveTextContent('Templates'); }); it('set timelineTypes correctly', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook< - React.PropsWithChildren, - UseTimelineTypesResult - >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), { + const { result } = renderHook( + () => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), + { wrapper: TestProviders, - }); - await waitForNextUpdate(); + } + ); - const { container } = render(result.current.timelineTabs); + await waitFor(() => expect(result.current.timelineTabs).toBeDefined()); - fireEvent( - container.querySelector('[data-test-subj="timeline-tab-template"]')!, - new MouseEvent('click', { - bubbles: true, - cancelable: true, - }) - ); + const { container } = render(result.current.timelineTabs); - expect(result.current).toEqual({ - timelineType: 'template', - timelineTabs: result.current.timelineTabs, - timelineFilters: result.current.timelineFilters, - }); - }); + fireEvent( + container.querySelector('[data-test-subj="timeline-tab-template"]')!, + new MouseEvent('click', { + bubbles: true, + cancelable: true, + }) + ); + + expect(mockNavigateToUrl).toHaveBeenCalled(); }); it('stays in the same tab if clicking again on current tab', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook< - React.PropsWithChildren, - UseTimelineTypesResult - >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), { + const { result } = renderHook( + () => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), + { wrapper: TestProviders, - }); - await waitForNextUpdate(); + } + ); + await waitFor(() => new Promise((resolve) => resolve(null))); - const { container } = render(result.current.timelineTabs); + render(result.current.timelineTabs); - fireEvent( - container.querySelector('[data-test-subj="timeline-tab-default"]')!, - new MouseEvent('click', { - bubbles: true, - cancelable: true, - }) - ); + fireEvent( + screen.getByTestId('timeline-tab-default'), + new MouseEvent('click', { + bubbles: true, + cancelable: true, + }) + ); + await waitFor(() => { expect(result.current).toEqual({ timelineType: 'default', timelineTabs: result.current.timelineTabs, @@ -145,79 +136,77 @@ describe('useTimelineTypes', () => { describe('timelineFilters', () => { it('render timelineFilters', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook< - React.PropsWithChildren, - UseTimelineTypesResult - >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), { + const { result } = renderHook( + () => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), + { wrapper: TestProviders, - }); - await waitForNextUpdate(); - - const { container } = render(<>{result.current.timelineFilters}); - expect( - container.querySelector('[data-test-subj="open-timeline-modal-body-filter-default"]') - ).toHaveTextContent('Timelines'); - expect( - container.querySelector('[data-test-subj="open-timeline-modal-body-filter-template"]') - ).toHaveTextContent('Templates'); - }); + } + ); + await waitFor(() => new Promise((resolve) => resolve(null))); + + const { container } = render(<>{result.current.timelineFilters}); + + expect( + container.querySelector('[data-test-subj="open-timeline-modal-body-filter-default"]') + ).toHaveTextContent('Timelines'); + expect( + container.querySelector('[data-test-subj="open-timeline-modal-body-filter-template"]') + ).toHaveTextContent('Templates'); }); it('set timelineTypes correctly', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook< - React.PropsWithChildren, - UseTimelineTypesResult - >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), { + const { result } = renderHook( + () => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), + { wrapper: TestProviders, - }); - await waitForNextUpdate(); + } + ); - const { container } = render(<>{result.current.timelineFilters}); + await waitFor(() => expect(result.current.timelineFilters).toBeDefined()); - fireEvent( - container.querySelector('[data-test-subj="open-timeline-modal-body-filter-template"]')!, - new MouseEvent('click', { - bubbles: true, - cancelable: true, - }) - ); + render(<>{result.current.timelineFilters}); - expect(result.current).toEqual({ - timelineType: 'template', + await waitFor(() => new Promise((resolve) => resolve(null))); + + fireEvent.click(screen.getByTestId('open-timeline-modal-body-filter-template')); + + await waitFor(() => expect(result.current.timelineType).toEqual('template')); + + expect(result.current).toEqual( + expect.objectContaining({ timelineTabs: result.current.timelineTabs, timelineFilters: result.current.timelineFilters, - }); - }); + }) + ); }); it('stays in the same tab if clicking again on current tab', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook< - React.PropsWithChildren, - UseTimelineTypesResult - >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), { + const { result } = renderHook( + () => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), + { wrapper: TestProviders, - }); - await waitForNextUpdate(); + } + ); - const { container } = render(<>{result.current.timelineFilters}); + await waitFor(() => new Promise((resolve) => resolve(null))); - fireEvent( - container.querySelector('[data-test-subj="open-timeline-modal-body-filter-default"]')!, - new MouseEvent('click', { - bubbles: true, - cancelable: true, - }) - ); + const { container } = render(<>{result.current.timelineFilters}); + fireEvent( + container.querySelector('[data-test-subj="open-timeline-modal-body-filter-default"]')!, + new MouseEvent('click', { + bubbles: true, + cancelable: true, + }) + ); + + await waitFor(() => expect(result.current).toEqual({ timelineType: 'default', timelineTabs: result.current.timelineTabs, timelineFilters: result.current.timelineFilters, - }); - }); + }) + ); }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_update_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_update_timeline.test.tsx index b2c990b12eced..6d052a3f8b2d2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_update_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_update_timeline.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { renderHook, act } from '@testing-library/react-hooks'; +import { act, waitFor, renderHook } from '@testing-library/react'; import { mockTimelineModel, TestProviders } from '../../../common/mock'; import { setTimelineRangeDatePicker as dispatchSetTimelineRangeDatePicker } from '../../../common/store/inputs/actions'; import { @@ -79,7 +79,10 @@ describe('dispatchUpdateTimeline', () => { beforeEach(() => { jest.clearAllMocks(); - clock = sinon.useFakeTimers(unix); + clock = sinon.useFakeTimers({ + now: unix, + toFake: ['Date'], + }); }); afterEach(function () { @@ -87,128 +90,134 @@ describe('dispatchUpdateTimeline', () => { }); it('it invokes date range picker dispatch', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => useUpdateTimeline(), { - wrapper: TestProviders, - }); - await waitForNextUpdate(); + const { result } = renderHook(() => useUpdateTimeline(), { + wrapper: TestProviders, + }); + await waitFor(() => new Promise((resolve) => resolve(null))); + + act(() => { result.current(defaultArgs); + }); - expect(dispatchSetTimelineRangeDatePicker).toHaveBeenCalledWith({ - from: '2020-03-26T14:35:56.356Z', - to: '2020-03-26T14:41:56.356Z', - }); + expect(dispatchSetTimelineRangeDatePicker).toHaveBeenCalledWith({ + from: '2020-03-26T14:35:56.356Z', + to: '2020-03-26T14:41:56.356Z', }); }); it('it invokes add timeline dispatch', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => useUpdateTimeline(), { - wrapper: TestProviders, - }); - await waitForNextUpdate(); + const { result } = renderHook(() => useUpdateTimeline(), { + wrapper: TestProviders, + }); + await waitFor(() => new Promise((resolve) => resolve(null))); + + act(() => { result.current(defaultArgs); + }); - expect(dispatchAddTimeline).toHaveBeenCalledWith({ - id: TimelineId.active, - savedTimeline: true, - timeline: { - ...mockTimelineModel, - version: null, - updated: undefined, - changed: true, - }, - }); + expect(dispatchAddTimeline).toHaveBeenCalledWith({ + id: TimelineId.active, + savedTimeline: true, + timeline: { + ...mockTimelineModel, + version: null, + updated: undefined, + changed: true, + }, }); }); it('it does not invoke kql filter query dispatches if timeline.kqlQuery.filterQuery is null', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => useUpdateTimeline(), { - wrapper: TestProviders, - }); - await waitForNextUpdate(); - result.current(defaultArgs); + const { result } = renderHook(() => useUpdateTimeline(), { + wrapper: TestProviders, + }); + await waitFor(() => new Promise((resolve) => resolve(null))); - expect(dispatchApplyKqlFilterQuery).not.toHaveBeenCalled(); + act(() => { + result.current(defaultArgs); }); + + expect(dispatchApplyKqlFilterQuery).not.toHaveBeenCalled(); }); it('it does not invoke notes dispatch if duplicate is true', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => useUpdateTimeline(), { - wrapper: TestProviders, - }); - await waitForNextUpdate(); + const { result } = renderHook(() => useUpdateTimeline(), { + wrapper: TestProviders, + }); + await waitFor(() => new Promise((resolve) => resolve(null))); + act(() => { result.current(defaultArgs); - - expect(dispatchAddNotes).not.toHaveBeenCalled(); }); + + expect(dispatchAddNotes).not.toHaveBeenCalled(); }); it('it does not invoke kql filter query dispatches if timeline.kqlQuery.kuery is null', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => useUpdateTimeline(), { - wrapper: TestProviders, - }); - await waitForNextUpdate(); - const mockTimeline = { - ...mockTimelineModel, - kqlQuery: { - filterQuery: { - kuery: null, - serializedQuery: 'some-serialized-query', - }, + const { result } = renderHook(() => useUpdateTimeline(), { + wrapper: TestProviders, + }); + await waitFor(() => new Promise((resolve) => resolve(null))); + const mockTimeline = { + ...mockTimelineModel, + kqlQuery: { + filterQuery: { + kuery: null, + serializedQuery: 'some-serialized-query', }, - }; + }, + }; + + act(() => { result.current({ ...defaultArgs, timeline: mockTimeline, }); - - expect(dispatchApplyKqlFilterQuery).not.toHaveBeenCalled(); }); + + expect(dispatchApplyKqlFilterQuery).not.toHaveBeenCalled(); }); it('it invokes kql filter query dispatches if timeline.kqlQuery.filterQuery.kuery is not null', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => useUpdateTimeline(), { - wrapper: TestProviders, - }); - await waitForNextUpdate(); - const mockTimeline = { - ...mockTimelineModel, - kqlQuery: { - filterQuery: { - kuery: { expression: 'expression', kind: 'kuery' as KueryFilterQueryKind }, - serializedQuery: 'some-serialized-query', - }, + const { result } = renderHook(() => useUpdateTimeline(), { + wrapper: TestProviders, + }); + await waitFor(() => new Promise((resolve) => resolve(null))); + const mockTimeline = { + ...mockTimelineModel, + kqlQuery: { + filterQuery: { + kuery: { expression: 'expression', kind: 'kuery' as KueryFilterQueryKind }, + serializedQuery: 'some-serialized-query', }, - }; + }, + }; + + act(() => { result.current({ ...defaultArgs, timeline: mockTimeline, }); + }); - expect(dispatchApplyKqlFilterQuery).toHaveBeenCalledWith({ - id: TimelineId.active, - filterQuery: { - kuery: { - kind: 'kuery', - expression: 'expression', - }, - serializedQuery: 'some-serialized-query', + expect(dispatchApplyKqlFilterQuery).toHaveBeenCalledWith({ + id: TimelineId.active, + filterQuery: { + kuery: { + kind: 'kuery', + expression: 'expression', }, - }); + serializedQuery: 'some-serialized-query', + }, }); }); it('it invokes dispatchAddNotes if duplicate is false', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => useUpdateTimeline(), { - wrapper: TestProviders, - }); - await waitForNextUpdate(); + const { result } = renderHook(() => useUpdateTimeline(), { + wrapper: TestProviders, + }); + await waitFor(() => new Promise((resolve) => resolve(null))); + + act(() => { result.current({ ...defaultArgs, duplicate: false, @@ -223,53 +232,55 @@ describe('dispatchUpdateTimeline', () => { }, ], }); + }); - expect(dispatchAddGlobalTimelineNote).not.toHaveBeenCalled(); - expect(dispatchUpdateNote).not.toHaveBeenCalled(); - expect(dispatchAddNotes).toHaveBeenCalledWith({ - notes: [ - { - created: new Date('2020-03-26T14:35:56.356Z'), - eventId: null, - id: 'note-id', - lastEdit: new Date('2020-03-26T14:35:56.356Z'), - note: 'I am a note', - user: 'unknown', - saveObjectId: 'note-id', - timelineId: 'abc', - version: 'testVersion', - }, - ], - }); + expect(dispatchAddGlobalTimelineNote).not.toHaveBeenCalled(); + expect(dispatchUpdateNote).not.toHaveBeenCalled(); + expect(dispatchAddNotes).toHaveBeenCalledWith({ + notes: [ + { + created: new Date('2020-03-26T14:35:56.356Z'), + eventId: null, + id: 'note-id', + lastEdit: new Date('2020-03-26T14:35:56.356Z'), + note: 'I am a note', + user: 'unknown', + saveObjectId: 'note-id', + timelineId: 'abc', + version: 'testVersion', + }, + ], }); }); it('it invokes dispatch to create a timeline note if duplicate is true and ruleNote exists', async () => { + const { result } = renderHook(() => useUpdateTimeline(), { + wrapper: TestProviders, + }); + await waitFor(() => new Promise((resolve) => resolve(null))); + await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => useUpdateTimeline(), { - wrapper: TestProviders, - }); - await waitForNextUpdate(); result.current({ ...defaultArgs, ruleNote: '# this would be some markdown', }); - const expectedNote: Note = { - created: new Date(anchor), - id: 'uuidv4()', - lastEdit: null, - note: '# this would be some markdown', - saveObjectId: null, - user: 'elastic', - version: null, - }; + }); - expect(dispatchAddNotes).not.toHaveBeenCalled(); - expect(dispatchUpdateNote).toHaveBeenCalledWith({ note: expectedNote }); - expect(dispatchAddGlobalTimelineNote).toHaveBeenLastCalledWith({ - id: TimelineId.active, - noteId: 'uuidv4()', - }); + const expectedNote: Note = { + created: new Date(anchor), + id: 'uuidv4()', + lastEdit: null, + note: '# this would be some markdown', + saveObjectId: null, + user: 'elastic', + version: null, + }; + + expect(dispatchAddNotes).not.toHaveBeenCalled(); + expect(dispatchUpdateNote).toHaveBeenCalledWith({ note: expectedNote }); + expect(dispatchAddGlobalTimelineNote).toHaveBeenLastCalledWith({ + id: TimelineId.active, + noteId: 'uuidv4()', }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_notes_in_flyout.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_notes_in_flyout.test.tsx index 32b5b8bc3c129..fd50be53c6a85 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_notes_in_flyout.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_notes_in_flyout.test.tsx @@ -7,11 +7,10 @@ import React from 'react'; import { TimelineId, TimelineTabs } from '../../../../../common/types'; -import { renderHook, act } from '@testing-library/react-hooks/dom'; +import { act, waitFor, renderHook } from '@testing-library/react'; import { createMockStore, mockGlobalState, TestProviders } from '../../../../common/mock'; import type { UseNotesInFlyoutArgs } from './use_notes_in_flyout'; import { useNotesInFlyout } from './use_notes_in_flyout'; -import { waitFor } from '@testing-library/react'; import { useDispatch } from 'react-redux'; jest.mock('react-redux', () => ({ @@ -202,8 +201,8 @@ describe('useNotesInFlyout', () => { expect(result.current.isNotesFlyoutVisible).toBe(false); }); - it('should close the flyout when activeTab is changed', () => { - const { result, rerender, waitForNextUpdate } = renderTestHook(); + it('should close the flyout when activeTab is changed', async () => { + const { result, rerender } = renderTestHook(); act(() => { result.current.setNotesEventId('event-1'); @@ -226,8 +225,6 @@ describe('useNotesInFlyout', () => { rerender({ activeTab: TimelineTabs.eql }); }); - waitForNextUpdate(); - - expect(result.current.isNotesFlyoutVisible).toBe(false); + await waitFor(() => expect(result.current.isNotesFlyoutVisible).toBe(false)); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/esql/customizations/use_histogram_customizations.test.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/esql/customizations/use_histogram_customizations.test.ts index d40b417b95218..10dfa97f35bdc 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/esql/customizations/use_histogram_customizations.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/esql/customizations/use_histogram_customizations.test.ts @@ -11,7 +11,7 @@ import type { ClickTriggerEvent, MultiClickTriggerEvent, } from '@kbn/charts-plugin/public'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import type { DiscoverStateContainer, UnifiedHistogramCustomization, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/esql/use_get_stateful_query_bar.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/esql/use_get_stateful_query_bar.test.tsx index 17b018ffea99d..fe26d8780a903 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/esql/use_get_stateful_query_bar.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/esql/use_get_stateful_query_bar.test.tsx @@ -6,7 +6,7 @@ */ import { TestProviders } from '../../../../../common/mock'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { useGetStatefulQueryBar } from './use_get_stateful_query_bar'; describe('useGetStatefulQueryBar', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/session/use_session_view.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/session/use_session_view.test.tsx index 9c4b135b5e774..54155a493da64 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/session/use_session_view.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/session/use_session_view.test.tsx @@ -8,8 +8,7 @@ import type { PropsWithChildren } from 'react'; import React, { memo } from 'react'; -import { render } from '@testing-library/react'; -import { renderHook } from '@testing-library/react-hooks'; +import { render, renderHook } from '@testing-library/react'; import { TimelineId, TimelineTabs } from '../../../../../../common/types/timeline'; import { mockTimelineModel, TestProviders } from '../../../../../common/mock'; import { useKibana } from '../../../../../common/lib/kibana'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_columns.test.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_columns.test.ts index 926082ff9ed41..66162fe82e458 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_columns.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_columns.test.ts @@ -6,7 +6,7 @@ */ import { TestProviders } from '../../../../../common/mock'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { useTimelineColumns } from './use_timeline_columns'; import { defaultUdtHeaders } from '../../body/column_headers/default_headers'; import type { ColumnHeaderOptions } from '../../../../../../common/types/timeline/columns'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_control_columns.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_control_columns.test.tsx index efbe954250037..8168742d4e08d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_control_columns.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_control_columns.test.tsx @@ -6,7 +6,7 @@ */ import type { EuiDataGridControlColumn } from '@elastic/eui'; import { TestProviders } from '../../../../../common/mock'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { useLicense } from '../../../../../common/hooks/use_license'; import { useTimelineControlColumn } from './use_timeline_control_columns'; import type { ColumnHeaderOptions } from '../../../../../../common/types/timeline/columns'; diff --git a/x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx index ba0151ff77b59..40c6e89478e2d 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx @@ -6,7 +6,7 @@ */ import { DataLoadingState } from '@kbn/unified-data-table'; -import { renderHook, act } from '@testing-library/react-hooks'; +import { act, waitFor, renderHook } from '@testing-library/react'; import type { TimelineArgs, UseTimelineEventsProps } from '.'; import { initSortDefault, useTimelineEvents } from '.'; import { SecurityPageName } from '../../../common/constants'; @@ -15,7 +15,6 @@ import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experime import { mockTimelineData } from '../../common/mock'; import { useRouteSpy } from '../../common/utils/route/use_route_spy'; import { useFetchNotes } from '../../notes/hooks/use_fetch_notes'; -import { waitFor } from '@testing-library/dom'; const mockDispatch = jest.fn(); jest.mock('react-redux', () => { @@ -142,136 +141,105 @@ describe('useTimelineEvents', () => { }; test('init', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook< - UseTimelineEventsProps, - [DataLoadingState, TimelineArgs] - >((args) => useTimelineEvents(args), { - initialProps: { ...props }, - }); - - // useEffect on params request - await waitForNextUpdate(); + const { result } = renderHook((args) => useTimelineEvents(args), { + initialProps: props, + }); + + expect(result.current).toEqual([ + DataLoadingState.loading, + { + events: [], + id: TimelineId.active, + inspect: expect.objectContaining({ dsl: [], response: [] }), + loadPage: expect.any(Function), + pageInfo: expect.objectContaining({ + activePage: 0, + querySize: 0, + }), + refetch: expect.any(Function), + totalCount: -1, + refreshedAt: 0, + }, + ]); + }); + + test('happy path query', async () => { + const { result, rerender } = renderHook< + [DataLoadingState, TimelineArgs], + UseTimelineEventsProps + >((args) => useTimelineEvents(args), { + initialProps: { ...props, startDate: '', endDate: '' }, + }); + + // useEffect on params request + await waitFor(() => new Promise((resolve) => resolve(null))); + rerender({ ...props, startDate, endDate }); + // useEffect on params request + await waitFor(() => { + expect(mockSearch).toHaveBeenCalledTimes(2); expect(result.current).toEqual([ DataLoadingState.loaded, { - events: [], + events: mockEvents, id: TimelineId.active, inspect: result.current[1].inspect, loadPage: result.current[1].loadPage, pageInfo: result.current[1].pageInfo, refetch: result.current[1].refetch, - totalCount: -1, - refreshedAt: 0, + totalCount: 32, + refreshedAt: result.current[1].refreshedAt, }, ]); }); }); - test('happy path query', async () => { - await act(async () => { - const { result, waitForNextUpdate, rerender } = renderHook< - UseTimelineEventsProps, - [DataLoadingState, TimelineArgs] - >((args) => useTimelineEvents(args), { - initialProps: { ...props, startDate: '', endDate: '' }, - }); - - // useEffect on params request - await waitForNextUpdate(); - rerender({ ...props, startDate, endDate }); - // useEffect on params request - await waitForNextUpdate(); - - await waitFor(() => { - expect(mockSearch).toHaveBeenCalledTimes(2); - expect(result.current).toEqual([ - DataLoadingState.loaded, - { - events: mockEvents, - id: TimelineId.active, - inspect: result.current[1].inspect, - loadPage: result.current[1].loadPage, - pageInfo: result.current[1].pageInfo, - refetch: result.current[1].refetch, - totalCount: 32, - refreshedAt: result.current[1].refreshedAt, - }, - ]); - }); + test('Mock cache for active timeline when switching page', async () => { + const { result, rerender } = renderHook< + [DataLoadingState, TimelineArgs], + UseTimelineEventsProps + >((args) => useTimelineEvents(args), { + initialProps: { ...props, startDate: '', endDate: '' }, }); - }); - test('Mock cache for active timeline when switching page', async () => { - await act(async () => { - const { result, waitForNextUpdate, rerender } = renderHook< - UseTimelineEventsProps, - [DataLoadingState, TimelineArgs] - >((args) => useTimelineEvents(args), { - initialProps: { ...props, startDate: '', endDate: '' }, - }); - - // useEffect on params request - await waitForNextUpdate(); - rerender({ ...props, startDate, endDate }); - // useEffect on params request - await waitForNextUpdate(); - - mockUseRouteSpy.mockReturnValue([ + // useEffect on params request + await waitFor(() => new Promise((resolve) => resolve(null))); + rerender({ ...props, startDate, endDate }); + + mockUseRouteSpy.mockReturnValue([ + { + pageName: SecurityPageName.timelines, + detailName: undefined, + tabName: undefined, + search: '', + pathName: '/timelines', + }, + ]); + + await waitFor(() => { + expect(mockSearch).toHaveBeenCalledTimes(2); + expect(result.current).toEqual([ + DataLoadingState.loaded, { - pageName: SecurityPageName.timelines, - detailName: undefined, - tabName: undefined, - search: '', - pathName: '/timelines', + events: mockEvents, + id: TimelineId.active, + inspect: result.current[1].inspect, + loadPage: result.current[1].loadPage, + pageInfo: result.current[1].pageInfo, + refetch: result.current[1].refetch, + totalCount: 32, + refreshedAt: result.current[1].refreshedAt, }, ]); - - await waitFor(() => { - expect(mockSearch).toHaveBeenCalledTimes(2); - - expect(result.current).toEqual([ - DataLoadingState.loaded, - { - events: mockEvents, - id: TimelineId.active, - inspect: result.current[1].inspect, - loadPage: result.current[1].loadPage, - pageInfo: result.current[1].pageInfo, - refetch: result.current[1].refetch, - totalCount: 32, - refreshedAt: result.current[1].refreshedAt, - }, - ]); - }); }); }); test('Correlation pagination is calling search strategy when switching page', async () => { - await act(async () => { - const { result, waitForNextUpdate, rerender } = renderHook< - UseTimelineEventsProps, - [DataLoadingState, TimelineArgs] - >((args) => useTimelineEvents(args), { - initialProps: { - ...props, - language: 'eql', - eqlOptions: { - eventCategoryField: 'category', - tiebreakerField: '', - timestampField: '@timestamp', - query: 'find it EQL', - size: 100, - }, - }, - }); - - // useEffect on params request - await waitForNextUpdate(); - rerender({ + const { result, rerender } = renderHook< + [DataLoadingState, TimelineArgs], + UseTimelineEventsProps + >((args) => useTimelineEvents(args), { + initialProps: { ...props, - startDate, - endDate, language: 'eql', eqlOptions: { eventCategoryField: 'category', @@ -280,119 +248,121 @@ describe('useTimelineEvents', () => { query: 'find it EQL', size: 100, }, - }); - // useEffect on params request - await waitForNextUpdate(); - mockSearch.mockReset(); + }, + }); + + // useEffect on params request + await waitFor(() => new Promise((resolve) => resolve(null))); + rerender({ + ...props, + startDate, + endDate, + language: 'eql', + eqlOptions: { + eventCategoryField: 'category', + tiebreakerField: '', + timestampField: '@timestamp', + query: 'find it EQL', + size: 100, + }, + }); + // useEffect on params request + await waitFor(() => new Promise((resolve) => resolve(null))); + mockSearch.mockReset(); + act(() => { result.current[1].loadPage(4); - await waitForNextUpdate(); - expect(mockSearch).toHaveBeenCalledTimes(1); }); + await waitFor(() => expect(mockSearch).toHaveBeenCalledTimes(1)); }); test('should query again when a new field is added', async () => { - await act(async () => { - const { waitForNextUpdate, rerender } = renderHook< - UseTimelineEventsProps, - [DataLoadingState, TimelineArgs] - >((args) => useTimelineEvents(args), { - initialProps: { ...props, startDate: '', endDate: '' }, - }); - - // useEffect on params request - await waitForNextUpdate(); - rerender({ ...props, startDate, endDate }); - // useEffect on params request - await waitForNextUpdate(); + const { rerender } = renderHook((args) => useTimelineEvents(args), { + initialProps: { ...props, startDate: '', endDate: '' }, + }); - expect(mockSearch).toHaveBeenCalledTimes(2); - mockSearch.mockClear(); + // useEffect on params request + await waitFor(() => new Promise((resolve) => resolve(null))); + rerender({ ...props, startDate, endDate }); + // useEffect on params request + await waitFor(() => new Promise((resolve) => resolve(null))); - rerender({ - ...props, - startDate, - endDate, - fields: ['@timestamp', 'event.kind', 'event.category'], - }); - - await waitFor(() => { - expect(mockSearch).toHaveBeenCalledTimes(1); - }); + expect(mockSearch).toHaveBeenCalledTimes(2); + mockSearch.mockClear(); + + rerender({ + ...props, + startDate, + endDate, + fields: ['@timestamp', 'event.kind', 'event.category'], }); + + await waitFor(() => expect(mockSearch).toHaveBeenCalledTimes(1)); }); test('should not query again when a field is removed', async () => { - await act(async () => { - const { waitForNextUpdate, rerender } = renderHook< - UseTimelineEventsProps, - [DataLoadingState, TimelineArgs] - >((args) => useTimelineEvents(args), { - initialProps: { ...props, startDate: '', endDate: '' }, - }); - - // useEffect on params request - await waitForNextUpdate(); - rerender({ ...props, startDate, endDate }); - // useEffect on params request - await waitForNextUpdate(); + const { rerender } = renderHook((args) => useTimelineEvents(args), { + initialProps: { ...props, startDate: '', endDate: '' }, + }); - expect(mockSearch).toHaveBeenCalledTimes(2); - mockSearch.mockClear(); + // useEffect on params request + await waitFor(() => new Promise((resolve) => resolve(null))); + rerender({ ...props, startDate, endDate }); + // useEffect on params request + await waitFor(() => new Promise((resolve) => resolve(null))); - rerender({ ...props, startDate, endDate, fields: ['@timestamp'] }); + expect(mockSearch).toHaveBeenCalledTimes(2); + mockSearch.mockClear(); - expect(mockSearch).toHaveBeenCalledTimes(0); - }); + rerender({ ...props, startDate, endDate, fields: ['@timestamp'] }); + + await waitFor(() => expect(mockSearch).toHaveBeenCalledTimes(0)); }); test('should not query again when a removed field is added back', async () => { - await act(async () => { - const { waitForNextUpdate, rerender } = renderHook< - UseTimelineEventsProps, - [DataLoadingState, TimelineArgs] - >((args) => useTimelineEvents(args), { - initialProps: { ...props, startDate: '', endDate: '' }, - }); - - // useEffect on params request - await waitForNextUpdate(); - rerender({ ...props, startDate, endDate }); - // useEffect on params request - await waitForNextUpdate(); + const { rerender } = renderHook((args) => useTimelineEvents(args), { + initialProps: { ...props, startDate: '', endDate: '' }, + }); - expect(mockSearch).toHaveBeenCalledTimes(2); - mockSearch.mockClear(); + // useEffect on params request + await waitFor(() => new Promise((resolve) => resolve(null))); + rerender({ ...props, startDate, endDate }); + // useEffect on params request + await waitFor(() => new Promise((resolve) => resolve(null))); - // remove `event.kind` from default fields - rerender({ ...props, startDate, endDate, fields: ['@timestamp'] }); + expect(mockSearch).toHaveBeenCalledTimes(2); + mockSearch.mockClear(); - expect(mockSearch).toHaveBeenCalledTimes(0); + // remove `event.kind` from default fields + rerender({ ...props, startDate, endDate, fields: ['@timestamp'] }); - // request default Fields - rerender({ ...props, startDate, endDate }); + await waitFor(() => new Promise((resolve) => resolve(null))); - expect(mockSearch).toHaveBeenCalledTimes(0); - }); + expect(mockSearch).toHaveBeenCalledTimes(0); + + // request default Fields + rerender({ ...props, startDate, endDate }); + + // since there is no new update in useEffect, it should throw an timeout error + // await expect(waitFor(() => null)).rejects.toThrowError(); + await waitFor(() => expect(mockSearch).toHaveBeenCalledTimes(0)); }); test('should return the combined list of events for all the pages when multiple pages are queried', async () => { - await act(async () => { - const { result } = renderHook((args) => useTimelineEvents(args), { - initialProps: { ...props }, - }); - await waitFor(() => { - expect(result.current[1].events).toHaveLength(10); - }); - - result.current[1].loadPage(1); - - await waitFor(() => { - expect(result.current[0]).toEqual(DataLoadingState.loadingMore); - }); - - await waitFor(() => { - expect(result.current[1].events).toHaveLength(20); - }); + const { result } = renderHook((args) => useTimelineEvents(args), { + initialProps: { ...props }, + }); + await waitFor(() => { + expect(result.current[1].events).toHaveLength(10); + }); + + result.current[1].loadPage(1); + + await waitFor(() => { + expect(result.current[0]).toEqual(DataLoadingState.loadingMore); + }); + + await waitFor(() => { + expect(result.current[1].events).toHaveLength(20); }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/containers/use_timeline_data_filters.test.tsx b/x-pack/plugins/security_solution/public/timelines/containers/use_timeline_data_filters.test.tsx index 9142aca78424c..db9413df30595 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/use_timeline_data_filters.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/use_timeline_data_filters.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { mockGlobalState, TestProviders, createMockStore } from '../../common/mock'; import { useTimelineDataFilters } from './use_timeline_data_filters'; import React from 'react'; diff --git a/x-pack/plugins/security_solution/public/timelines/hooks/use_create_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/hooks/use_create_timeline.test.tsx index a4c054371a316..0864c2bb024bd 100644 --- a/x-pack/plugins/security_solution/public/timelines/hooks/use_create_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/hooks/use_create_timeline.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ import React from 'react'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { useCreateTimeline } from './use_create_timeline'; import type { TimeRange } from '../../common/store/inputs/model'; import { RowRendererCount, TimelineTypeEnum } from '../../../common/api/timeline'; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.ts index 724226b7f8d82..4dcc84659fa10 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.ts @@ -133,7 +133,7 @@ export class EntityStoreDataClient { this.esClient = clusterClient.asCurrentUser; this.entityClient = new EntityClient({ - esClient: this.esClient, + clusterClient, soClient, logger, }); diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/create.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/create.ts index 21a17bb7834a1..7c94631be6b65 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/create.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/create.ts @@ -8,9 +8,10 @@ import type { IKibanaResponse, Logger } from '@kbn/core/server'; import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; import { v4 as uuidV4 } from 'uuid'; -import { SIEM_RULE_MIGRATIONS_PATH } from '../../../../../common/siem_migrations/constants'; +import { SIEM_RULE_MIGRATION_CREATE_PATH } from '../../../../../common/siem_migrations/constants'; import { CreateRuleMigrationRequestBody, + CreateRuleMigrationRequestParams, type CreateRuleMigrationResponse, } from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; import type { SecuritySolutionPluginRouter } from '../../../../types'; @@ -23,7 +24,7 @@ export const registerSiemRuleMigrationsCreateRoute = ( ) => { router.versioned .post({ - path: SIEM_RULE_MIGRATIONS_PATH, + path: SIEM_RULE_MIGRATION_CREATE_PATH, access: 'internal', security: { authz: { requiredPrivileges: ['securitySolution'] } }, }) @@ -31,18 +32,20 @@ export const registerSiemRuleMigrationsCreateRoute = ( { version: '1', validate: { - request: { body: buildRouteValidationWithZod(CreateRuleMigrationRequestBody) }, + request: { + body: buildRouteValidationWithZod(CreateRuleMigrationRequestBody), + params: buildRouteValidationWithZod(CreateRuleMigrationRequestParams), + }, }, }, withLicense( async (context, req, res): Promise> => { const originalRules = req.body; + const migrationId = req.params.migration_id ?? uuidV4(); try { const ctx = await context.resolve(['securitySolution']); const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient(); - const migrationId = uuidV4(); - const ruleMigrations = originalRules.map((originalRule) => ({ migration_id: migrationId, original_rule: originalRule, diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/get.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/get.ts index b9645a3de374e..dd13a75cdf83a 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/get.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/get.ts @@ -14,6 +14,7 @@ import { } from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; import { SIEM_RULE_MIGRATION_PATH } from '../../../../../common/siem_migrations/constants'; import type { SecuritySolutionPluginRouter } from '../../../../types'; +import type { RuleMigrationGetOptions } from '../data/rule_migrations_data_rules_client'; import { withLicense } from './util/with_license'; export const registerSiemRuleMigrationsGetRoute = ( @@ -43,20 +44,13 @@ export const registerSiemRuleMigrationsGetRoute = ( const ctx = await context.resolve(['securitySolution']); const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient(); - let from = 0; - if (page && perPage) { - from = page * perPage; - } - const size = perPage; + const options: RuleMigrationGetOptions = { + filters: { searchTerm }, + size: perPage, + from: page && perPage ? page * perPage : 0, + }; - const result = await ruleMigrationsClient.data.rules.get( - { - migrationId, - searchTerm, - }, - from, - size - ); + const result = await ruleMigrationsClient.data.rules.get(migrationId, options); return res.ok({ body: result }); } catch (err) { diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/util/installation.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/util/installation.ts index df86a1f953656..2fce95be9dafe 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/util/installation.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/util/installation.ts @@ -177,10 +177,8 @@ export const installTranslated = async ({ const detectionRulesClient = securitySolutionContext.getDetectionRulesClient(); const ruleMigrationsClient = securitySolutionContext.getSiemRuleMigrationsClient(); - const { data: rulesToInstall } = await ruleMigrationsClient.data.rules.get({ - migrationId, - ids, - installable: true, + const { data: rulesToInstall } = await ruleMigrationsClient.data.rules.get(migrationId, { + filters: { ids, installable: true }, }); const { customRulesToInstall, prebuiltRulesToInstall } = rulesToInstall.reduce( diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts index 209f2e4416e16..716d19ce16cdf 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts @@ -38,32 +38,23 @@ export type UpdateRuleMigrationInput = { elastic_rule?: Partial } & export type RuleMigrationDataStats = Omit; export type RuleMigrationAllDataStats = RuleMigrationDataStats[]; -export interface RuleMigrationFilterOptions { - migrationId: string; +export interface RuleMigrationFilters { status?: SiemMigrationStatus | SiemMigrationStatus[]; ids?: string[]; installable?: boolean; searchTerm?: string; } +export interface RuleMigrationGetOptions { + filters?: RuleMigrationFilters; + from?: number; + size?: number; +} /* BULK_MAX_SIZE defines the number to break down the bulk operations by. * The 500 number was chosen as a reasonable number to avoid large payloads. It can be adjusted if needed. */ const BULK_MAX_SIZE = 500 as const; - -const getInstallableConditions = (): QueryDslQueryContainer[] => { - return [ - { term: { translation_result: SiemMigrationRuleTranslationResult.FULL } }, - { - nested: { - path: 'elastic_rule', - query: { - bool: { must_not: { exists: { field: 'elastic_rule.id' } } }, - }, - }, - }, - ]; -}; +/* The default number of rule migrations to retrieve in a single GET request. */ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient { /** Indexes an array of rule migrations to be processed */ @@ -128,12 +119,11 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient /** Retrieves an array of rule documents of a specific migrations */ async get( - filters: RuleMigrationFilterOptions, - from?: number, - size?: number + migrationId: string, + { filters = {}, from, size }: RuleMigrationGetOptions = {} ): Promise<{ total: number; data: StoredRuleMigration[] }> { const index = await this.getIndexName(); - const query = this.getFilterQuery(filters); + const query = this.getFilterQuery(migrationId, { ...filters }); const result = await this.esClient .search({ index, query, sort: '_doc', from, size }) @@ -155,7 +145,7 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient */ async takePending(migrationId: string, size: number): Promise { const index = await this.getIndexName(); - const query = this.getFilterQuery({ migrationId, status: SiemMigrationStatus.PENDING }); + const query = this.getFilterQuery(migrationId, { status: SiemMigrationStatus.PENDING }); const storedRuleMigrations = await this.esClient .search({ index, query, sort: '_doc', size }) @@ -234,7 +224,7 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient { refresh = false }: { refresh?: boolean } = {} ): Promise { const index = await this.getIndexName(); - const query = this.getFilterQuery({ migrationId, status: statusToQuery }); + const query = this.getFilterQuery(migrationId, { status: statusToQuery }); const script = { source: `ctx._source['status'] = '${statusToUpdate}'` }; await this.esClient.updateByQuery({ index, query, script, refresh }).catch((error) => { this.logger.error(`Error updating rule migrations status: ${error.message}`); @@ -245,24 +235,11 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient /** Retrieves the translation stats for the rule migrations with the provided id */ async getTranslationStats(migrationId: string): Promise { const index = await this.getIndexName(); - const query = this.getFilterQuery({ migrationId }); + const query = this.getFilterQuery(migrationId); const aggregations = { - prebuilt: { - filter: { - nested: { - path: 'elastic_rule', - query: { exists: { field: 'elastic_rule.prebuilt_rule_id' } }, - }, - }, - }, - installable: { - filter: { - bool: { - must: getInstallableConditions(), - }, - }, - }, + prebuilt: { filter: conditions.isPrebuilt() }, + installable: { filter: { bool: { must: conditions.isInstallable() } } }, }; const result = await this.esClient .search({ index, query, aggregations, _source: false }) @@ -288,7 +265,7 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient /** Retrieves the stats for the rule migrations with the provided id */ async getStats(migrationId: string): Promise { const index = await this.getIndexName(); - const query = this.getFilterQuery({ migrationId }); + const query = this.getFilterQuery(migrationId); const aggregations = { pending: { filter: { term: { status: SiemMigrationStatus.PENDING } } }, processing: { filter: { term: { status: SiemMigrationStatus.PROCESSING } } }, @@ -358,13 +335,10 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient })); } - private getFilterQuery({ - migrationId, - status, - ids, - installable, - searchTerm, - }: RuleMigrationFilterOptions): QueryDslQueryContainer { + private getFilterQuery( + migrationId: string, + { status, ids, installable, searchTerm }: RuleMigrationFilters = {} + ): QueryDslQueryContainer { const filter: QueryDslQueryContainer[] = [{ term: { migration_id: migrationId } }]; if (status) { if (Array.isArray(status)) { @@ -377,16 +351,44 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient filter.push({ terms: { _id: ids } }); } if (installable) { - filter.push(...getInstallableConditions()); + filter.push(...conditions.isInstallable()); } if (searchTerm?.length) { - filter.push({ - nested: { - path: 'elastic_rule', - query: { match: { 'elastic_rule.title': searchTerm } }, - }, - }); + filter.push(conditions.matchTitle(searchTerm)); } return { bool: { filter } }; } } + +const conditions = { + isFullyTranslated(): QueryDslQueryContainer { + return { term: { translation_result: SiemMigrationRuleTranslationResult.FULL } }; + }, + isNotInstalled(): QueryDslQueryContainer { + return { + nested: { + path: 'elastic_rule', + query: { bool: { must_not: { exists: { field: 'elastic_rule.id' } } } }, + }, + }; + }, + isPrebuilt(): QueryDslQueryContainer { + return { + nested: { + path: 'elastic_rule', + query: { exists: { field: 'elastic_rule.prebuilt_rule_id' } }, + }, + }; + }, + matchTitle(title: string): QueryDslQueryContainer { + return { + nested: { + path: 'elastic_rule', + query: { match: { 'elastic_rule.title': title } }, + }, + }; + }, + isInstallable(): QueryDslQueryContainer[] { + return [this.isFullyTranslated(), this.isNotInstalled()]; + }, +}; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_field_maps.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_field_maps.ts index 09a4bef34c279..f63953192844b 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_field_maps.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_field_maps.ts @@ -23,7 +23,8 @@ export const ruleMigrationsFieldMap: FieldMap async (state) => { - const mitreAttackIds = state.original_rule.mitre_attack_ids; + const mitreAttackIds = state.original_rule.annotations?.mitre_attack; if (!mitreAttackIds?.length) { return {}; } diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/validation/validation.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/validation/validation.ts index 272aedfe4793d..6f97678a04558 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/validation/validation.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/validation/validation.ts @@ -7,8 +7,8 @@ import type { Logger } from '@kbn/core/server'; import { isEmpty } from 'lodash/fp'; +import { parseEsqlQuery } from '@kbn/securitysolution-utils'; import type { GraphNode } from '../../types'; -import { parseEsqlQuery } from './esql_query'; interface GetValidationNodeParams { logger: Logger; diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json index 91951c667fa45..c4e658eacd44f 100644 --- a/x-pack/plugins/security_solution/tsconfig.json +++ b/x-pack/plugins/security_solution/tsconfig.json @@ -205,8 +205,6 @@ "@kbn/core-theme-browser", "@kbn/integration-assistant-plugin", "@kbn/avc-banner", - "@kbn/esql-ast", - "@kbn/esql-validation-autocomplete", "@kbn/config", "@kbn/openapi-common", "@kbn/securitysolution-lists-common", diff --git a/x-pack/plugins/spaces/public/management/components/solution_view/solution_view.tsx b/x-pack/plugins/spaces/public/management/components/solution_view/solution_view.tsx index 65fa1731b2ee9..7d8500b4d0cb4 100644 --- a/x-pack/plugins/spaces/public/management/components/solution_view/solution_view.tsx +++ b/x-pack/plugins/spaces/public/management/components/solution_view/solution_view.tsx @@ -8,7 +8,6 @@ import type { EuiSuperSelectOption, EuiThemeComputed } from '@elastic/eui'; import { EuiBetaBadge, - EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiFormRow, @@ -180,21 +179,6 @@ export const SolutionView: FunctionComponent = ({ isInvalid={validator.validateSolutionView(space, isEditing).isInvalid} />
- - {showClassicDefaultViewCallout && ( - <> - - - - )} diff --git a/x-pack/plugins/spaces/public/management/edit_space/edit_space_roles_tab.tsx b/x-pack/plugins/spaces/public/management/edit_space/edit_space_roles_tab.tsx index 2e3d40527dbd7..18e11110d7564 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/edit_space_roles_tab.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/edit_space_roles_tab.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { EuiConfirmModal, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import type { FC } from 'react'; -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import type { KibanaFeature } from '@kbn/features-plugin/common'; import { i18n } from '@kbn/i18n'; @@ -40,6 +40,8 @@ export const EditSpaceAssignedRolesTab: FC = ({ space, features, isReadOn invokeClient, } = services; + const [removeRoleConfirm, setRemoveRoleConfirm] = useState(null); + // Roles are already loaded in app state, refresh them when user navigates to this tab useEffect(() => { const getRoles = async () => { @@ -175,7 +177,7 @@ export const EditSpaceAssignedRolesTab: FC = ({ space, features, isReadOn ); return ( - + <> @@ -194,8 +196,8 @@ export const EditSpaceAssignedRolesTab: FC = ({ space, features, isReadOn onClickBulkRemove={async (selectedRoles) => { await removeRole(selectedRoles); }} - onClickRowRemoveAction={async (rowRecord) => { - await removeRole([rowRecord]); + onClickRemoveRoleConfirm={async (rowRecord) => { + setRemoveRoleConfirm(rowRecord); }} onClickAssignNewRole={async () => { showRolesPrivilegeEditor(); @@ -203,6 +205,36 @@ export const EditSpaceAssignedRolesTab: FC = ({ space, features, isReadOn /> - + {removeRoleConfirm && ( + setRemoveRoleConfirm(null)} + onConfirm={() => { + removeRole([removeRoleConfirm]); + setRemoveRoleConfirm(null); + }} + > +

+ +

+
+ )} + ); }; diff --git a/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.tsx b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.tsx index 74f2b2fde4667..84859631cdb77 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.tsx @@ -20,7 +20,6 @@ import { EuiFormRow, EuiLink, EuiLoadingSpinner, - EuiSpacer, EuiText, EuiTitle, useGeneratedHtmlId, @@ -31,7 +30,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { KibanaFeature, KibanaFeatureConfig } from '@kbn/features-plugin/common'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; import type { RawKibanaPrivileges, Role, @@ -157,7 +155,7 @@ export const PrivilegesRolesForm: FC = (props) => { const [roleSpacePrivilege, setRoleSpacePrivilege] = useState( !selectedRoles.length || !selectedRolesCombinedPrivileges.length - ? FEATURE_PRIVILEGES_ALL + ? FEATURE_PRIVILEGES_CUSTOM : selectedRolesCombinedPrivileges[0] ); @@ -378,17 +376,19 @@ export const PrivilegesRolesForm: FC = (props) => { { defaultMessage: 'Select roles' } )} labelAppend={ - - {i18n.translate( - 'xpack.spaces.management.spaceDetails.roles.selectRolesFormRowLabelAnchor', - { defaultMessage: 'Manage roles' } - )} - + + + {i18n.translate( + 'xpack.spaces.management.spaceDetails.roles.selectRolesFormRowLabelAnchor', + { defaultMessage: 'Manage roles' } + )} + + } helpText={i18n.translate( 'xpack.spaces.management.spaceDetails.roles.selectRolesHelp', @@ -409,7 +409,7 @@ export const PrivilegesRolesForm: FC = (props) => { )} placeholder={i18n.translate( 'xpack.spaces.management.spaceDetails.roles.selectRolesPlaceholder', - { defaultMessage: 'Add a role...' } + { defaultMessage: 'Add roles...' } )} isLoading={fetchingDataDeps} options={createRolesComboBoxOptions(spaceUnallocatedRoles)} @@ -452,9 +452,9 @@ export const PrivilegesRolesForm: FC = (props) => { iconType="iInCircle" data-test-subj="privilege-info-callout" title={i18n.translate( - 'xpack.spaces.management.spaceDetails.roles.assign.privilegeConflictMsg.title', + 'xpack.spaces.management.spaceDetails.roles.assign.privilegeCombinationMsg.title', { - defaultMessage: 'Privileges will apply only to this space.', + defaultMessage: `The user's resulting access depends on a combination of their role's global space privileges and specific privileges applied to this space.`, } )} /> @@ -464,7 +464,14 @@ export const PrivilegesRolesForm: FC = (props) => { label={i18n.translate( 'xpack.spaces.management.spaceDetails.roles.assign.privilegesLabelText', { - defaultMessage: 'Define role privileges', + defaultMessage: 'Define privileges', + } + )} + helpText={i18n.translate( + 'xpack.spaces.management.spaceDetails.roles.assign.privilegesHelpText', + { + defaultMessage: + 'Assign the privilege level you wish to grant to all present and future features across this space.', } )} > @@ -518,7 +525,6 @@ export const PrivilegesRolesForm: FC = (props) => { ) : ( = (props) => { canCustomizeSubFeaturePrivileges={ license?.getFeatures().allowSubFeaturePrivileges ?? false } + showAdditionalPermissionsMessage={false} /> )} @@ -643,10 +650,10 @@ export const PrivilegesRolesForm: FC = (props) => { > {isEditOperation.current ? i18n.translate('xpack.spaces.management.spaceDetails.roles.updateRoleButton', { - defaultMessage: 'Update', + defaultMessage: 'Update role privileges', }) : i18n.translate('xpack.spaces.management.spaceDetails.roles.assignRoleButton', { - defaultMessage: 'Assign', + defaultMessage: 'Assign roles', })}
); @@ -659,7 +666,7 @@ export const PrivilegesRolesForm: FC = (props) => {

{isEditOperation.current ? i18n.translate('xpack.spaces.management.spaceDetails.roles.assignRoleButton', { - defaultMessage: 'Edit role privileges', + defaultMessage: 'Edit role privileges for space', }) : i18n.translate( 'xpack.spaces.management.spaceDetails.roles.assign.privileges.custom', @@ -669,15 +676,6 @@ export const PrivilegesRolesForm: FC = (props) => { )}

- - -

- -

-
{getForm()} diff --git a/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assigned_roles_table.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assigned_roles_table.test.tsx index f909dba415c41..0ddb633cd1f5c 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assigned_roles_table.test.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assigned_roles_table.test.tsx @@ -18,7 +18,7 @@ const defaultProps: Pick< | 'onClickAssignNewRole' | 'onClickBulkRemove' | 'onClickRowEditAction' - | 'onClickRowRemoveAction' + | 'onClickRemoveRoleConfirm' | 'currentSpace' > = { currentSpace: { @@ -29,7 +29,7 @@ const defaultProps: Pick< onClickBulkRemove: jest.fn(), onClickRowEditAction: jest.fn(), onClickAssignNewRole: jest.fn(), - onClickRowRemoveAction: jest.fn(), + onClickRemoveRoleConfirm: jest.fn(), }; const renderTestComponent = ( diff --git a/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assigned_roles_table.tsx b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assigned_roles_table.tsx index ffe7ecba85ec0..f59bd00561671 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assigned_roles_table.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assigned_roles_table.tsx @@ -41,7 +41,7 @@ interface ISpaceAssignedRolesTableProps { assignedRoles: Map; onClickAssignNewRole: () => Promise; onClickRowEditAction: (role: Role) => void; - onClickRowRemoveAction: (role: Role) => void; + onClickRemoveRoleConfirm: (role: Role) => void; supportsBulkAction?: boolean; onClickBulkRemove?: (selectedRoles: Role[]) => void; } @@ -67,10 +67,10 @@ const getTableColumns = ({ isReadOnly, currentSpace, onClickRowEditAction, - onClickRowRemoveAction, + onClickRemoveRoleConfirm, }: Pick< ISpaceAssignedRolesTableProps, - 'isReadOnly' | 'onClickRowEditAction' | 'onClickRowRemoveAction' | 'currentSpace' + 'isReadOnly' | 'onClickRowEditAction' | 'onClickRemoveRoleConfirm' | 'currentSpace' >) => { const columns: Array> = [ { @@ -205,7 +205,7 @@ const getTableColumns = ({ { defaultMessage: 'Click this action to remove the user from this space.' } ), available: (rowRecord) => isEditableRole(rowRecord), - onClick: onClickRowRemoveAction, + onClick: onClickRemoveRoleConfirm, }, ], }); @@ -237,14 +237,19 @@ export const SpaceAssignedRolesTable = ({ onClickAssignNewRole, onClickBulkRemove, onClickRowEditAction, - onClickRowRemoveAction, + onClickRemoveRoleConfirm, isReadOnly = false, supportsBulkAction = false, }: ISpaceAssignedRolesTableProps) => { const tableColumns = useMemo( () => - getTableColumns({ isReadOnly, onClickRowEditAction, onClickRowRemoveAction, currentSpace }), - [currentSpace, isReadOnly, onClickRowEditAction, onClickRowRemoveAction] + getTableColumns({ + isReadOnly, + onClickRowEditAction, + onClickRemoveRoleConfirm, + currentSpace, + }), + [currentSpace, isReadOnly, onClickRowEditAction, onClickRemoveRoleConfirm] ); const [rolesInView, setRolesInView] = useState([]); const [selectedRoles, setSelectedRoles] = useState([]); @@ -262,14 +267,17 @@ export const SpaceAssignedRolesTable = ({ const onSearchQueryChange = useCallback>>( ({ query }) => { - const _assignedRolesTransformed = Array.from(assignedRoles.values()); + const assignedRolesTransformed = Array.from(assignedRoles.values()); + const sortedAssignedRolesTransformed = assignedRolesTransformed.sort(sortRolesForListing); if (query?.text) { setRolesInView( - _assignedRolesTransformed.filter((role) => role.name.includes(query.text.toLowerCase())) + sortedAssignedRolesTransformed.filter((role) => + role.name.includes(query.text.toLowerCase()) + ) ); } else { - setRolesInView(_assignedRolesTransformed); + setRolesInView(sortedAssignedRolesTransformed); } }, [assignedRoles] diff --git a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.test.tsx b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.test.tsx index 95629248e2cbe..73c793d64d823 100644 --- a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.test.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.test.tsx @@ -153,12 +153,21 @@ describe('SpacesGridPage', () => { wrapper.update(); expect(wrapper.find('EuiInMemoryTable').prop('items')).toBe(spacesWithSolution); - expect(wrapper.find('EuiInMemoryTable').prop('columns')).toContainEqual({ - field: 'solution', - name: 'Solution view', - sortable: true, - render: expect.any(Function), - }); + expect(wrapper.find('EuiInMemoryTable').prop('columns')).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: '', field: 'initials' }), + expect.objectContaining({ name: 'Space', field: 'name' }), + expect.objectContaining({ name: 'Description', field: 'description' }), + expect.objectContaining({ name: 'Solution view', field: 'solution' }), + expect.objectContaining({ + actions: expect.arrayContaining([ + expect.objectContaining({ name: 'Edit', icon: 'pencil' }), + expect.objectContaining({ name: 'Switch', icon: 'merge' }), + expect.objectContaining({ name: 'Delete', icon: 'trash' }), + ]), + }), + ]) + ); }); it('renders a "current" badge for the current space', async () => { @@ -410,4 +419,42 @@ describe('SpacesGridPage', () => { title: 'Error loading spaces', }); }); + + it(`does not render the 'Features visible' column when serverless`, async () => { + const httpStart = httpServiceMock.createStartContract(); + httpStart.get.mockResolvedValue([]); + + const error = new Error('something awful happened'); + + const notifications = notificationServiceMock.createStartContract(); + + const wrapper = shallowWithIntl( + Promise.reject(error)} + notifications={notifications} + getUrlForApp={getUrlForApp} + history={history} + capabilities={{ + navLinks: {}, + management: {}, + catalogue: {}, + spaces: { manage: true }, + }} + allowSolutionVisibility + {...spacesGridCommonProps} + /> + ); + + // allow spacesManager to load spaces and lazy-load SpaceAvatar + await act(async () => {}); + wrapper.update(); + + expect(wrapper.find('EuiInMemoryTable').prop('columns')).not.toContainEqual( + expect.objectContaining({ + field: 'disabledFeatures', + name: 'Features visible', + }) + ); + }); }); diff --git a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx index 479b2fff19964..f11f80ab20a1c 100644 --- a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx @@ -18,8 +18,6 @@ import { EuiPageHeader, EuiPageSection, EuiSpacer, - EuiText, - useIsWithinBreakpoints, } from '@elastic/eui'; import React, { Component, lazy, Suspense } from 'react'; @@ -36,17 +34,12 @@ import { reactRouterNavigate } from '@kbn/kibana-react-plugin/public'; import { addSpaceIdToPath, type Space } from '../../../common'; import { isReservedSpace } from '../../../common'; -import { - DEFAULT_SPACE_ID, - ENTER_SPACE_PATH, - SOLUTION_VIEW_CLASSIC, -} from '../../../common/constants'; +import { DEFAULT_SPACE_ID, ENTER_SPACE_PATH } from '../../../common/constants'; import { getSpacesFeatureDescription } from '../../constants'; import { getSpaceAvatarComponent } from '../../space_avatar'; import { SpaceSolutionBadge } from '../../space_solution_badge'; import type { SpacesManager } from '../../spaces_manager'; import { ConfirmDeleteModal, UnauthorizedPrompt } from '../components'; -import { getEnabledFeatures } from '../lib/feature_utils'; // No need to wrap LazySpaceAvatar in an error boundary, because it is one of the first chunks loaded when opening Kibana. const LazySpaceAvatar = lazy(() => @@ -254,8 +247,7 @@ export class SpacesGridPage extends Component { }; public getColumnConfig() { - const { activeSpace, features } = this.state; - const { solution: activeSolution } = activeSpace ?? {}; + const { activeSpace } = this.state; const config: Array> = [ { @@ -283,15 +275,8 @@ export class SpacesGridPage extends Component { render: (value: string, rowRecord: Space) => { const SpaceName = () => { const isCurrent = this.state.activeSpace?.id === rowRecord.id; - const isWide = useIsWithinBreakpoints(['xl']); - const gridColumns = isCurrent && isWide ? 2 : 1; return ( - + { return ; }, 'data-test-subj': 'spacesListTableRowNameCell', - width: '15%', + width: '20%', }, { field: 'description', @@ -331,54 +316,10 @@ export class SpacesGridPage extends Component { }), sortable: true, truncateText: true, - width: '45%', + width: '40%', }, ]; - const shouldShowFeaturesColumn = !activeSolution || activeSolution === SOLUTION_VIEW_CLASSIC; - if (shouldShowFeaturesColumn) { - config.push({ - field: 'disabledFeatures', - name: i18n.translate('xpack.spaces.management.spacesGridPage.featuresColumnName', { - defaultMessage: 'Features visible', - }), - sortable: (space: Space) => { - return getEnabledFeatures(features, space).length; - }, - render: (_disabledFeatures: string[], rowRecord: Space) => { - const enabledFeatureCount = getEnabledFeatures(features, rowRecord).length; - if (enabledFeatureCount === features.length) { - return ( - - ); - } - if (enabledFeatureCount === 0) { - return ( - - - - ); - } - return ( - - ); - }, - }); - } - config.push({ field: 'id', name: i18n.translate('xpack.spaces.management.spacesGridPage.identifierColumnName', { @@ -403,6 +344,7 @@ export class SpacesGridPage extends Component { render: (solution: Space['solution'], record: Space) => ( ), + width: '10%', }); } diff --git a/x-pack/plugins/stack_connectors/server/connector_types/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/index.ts index a156547cc2fa6..08b0331f1cf2f 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/index.ts @@ -63,6 +63,7 @@ export { ConnectorTypeId as WebhookConnectorTypeId } from './webhook'; export type { ActionParamsType as WebhookActionParams } from './webhook/types'; export { ConnectorTypeId as XmattersConnectorTypeId } from './xmatters'; export type { ActionParamsType as XmattersActionParams } from './xmatters'; +export { OpsgenieConnectorTypeId } from './opsgenie'; export type { OpsgenieActionConfig, diff --git a/x-pack/plugins/stack_connectors/server/connector_types/opsgenie/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/opsgenie/index.ts index 7e92a3b7f3332..9570e0033f531 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/opsgenie/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/opsgenie/index.ts @@ -39,6 +39,8 @@ export const getOpsgenieConnectorType = (): SubActionConnectorType 500)', + result: + '(ctx.http?.response?.status_code !== null && ((ctx.http?.response?.status_code instanceof String && Float.parseFloat(ctx.http?.response?.status_code) > 500) || ctx.http?.response?.status_code > 500))', }, { condition: { field: 'http.response.status_code', operator: 'gte' as const, value: 500 }, - result: '(ctx.http?.response?.status_code !== null && ctx.http?.response?.status_code >= 500)', + result: + '(ctx.http?.response?.status_code !== null && ((ctx.http?.response?.status_code instanceof String && Float.parseFloat(ctx.http?.response?.status_code) >= 500) || ctx.http?.response?.status_code >= 500))', }, { condition: { field: 'log.logger', operator: 'startsWith' as const, value: 'nginx' }, - result: '(ctx.log?.logger !== null && ctx.log?.logger.startsWith("nginx"))', + result: + '(ctx.log?.logger !== null && ((ctx.log?.logger instanceof Number && ctx.log?.logger.toString().startsWith("nginx")) || ctx.log?.logger.startsWith("nginx")))', }, { condition: { field: 'log.logger', operator: 'endsWith' as const, value: 'proxy' }, - result: '(ctx.log?.logger !== null && ctx.log?.logger.endsWith("proxy"))', + result: + '(ctx.log?.logger !== null && ((ctx.log?.logger instanceof Number && ctx.log?.logger.toString().endsWith("proxy")) || ctx.log?.logger.endsWith("proxy")))', }, { condition: { field: 'log.logger', operator: 'contains' as const, value: 'proxy' }, - result: '(ctx.log?.logger !== null && ctx.log?.logger.contains("proxy"))', + result: + '(ctx.log?.logger !== null && ((ctx.log?.logger instanceof Number && ctx.log?.logger.toString().contains("proxy")) || ctx.log?.logger.contains("proxy")))', }, { condition: { field: 'log.logger', operator: 'exists' as const }, @@ -55,87 +64,152 @@ const operatorConditionAndResults = [ ]; describe('conditionToPainless', () => { - describe('operators', () => { - operatorConditionAndResults.forEach((setup) => { - test(`${setup.condition.operator}`, () => { - expect(conditionToPainless(setup.condition)).toEqual(setup.result); + describe('conditionToStatement', () => { + describe('operators', () => { + operatorConditionAndResults.forEach((setup) => { + test(`${setup.condition.operator}`, () => { + expect(conditionToStatement(setup.condition)).toEqual(setup.result); + }); }); - }); - }); - describe('and', () => { - test('simple', () => { - const condition = { - and: [ - { field: 'log.logger', operator: 'eq' as const, value: 'nginx_proxy' }, - { field: 'log.level', operator: 'eq' as const, value: 'error' }, - ], - }; - expect( - expect(conditionToPainless(condition)).toEqual( - '(ctx.log?.logger !== null && ctx.log?.logger == "nginx_proxy") && (ctx.log?.level !== null && ctx.log?.level == "error")' - ) - ); + test('ensure number comparasion works with string values', () => { + const condition = { + field: 'http.response.status_code', + operator: 'gt' as const, + value: '500', + }; + expect(conditionToStatement(condition)).toEqual( + '(ctx.http?.response?.status_code !== null && ((ctx.http?.response?.status_code instanceof String && Float.parseFloat(ctx.http?.response?.status_code) > 500) || ctx.http?.response?.status_code > 500))' + ); + }); + test('ensure string comparasion works with number values', () => { + const condition = { + field: 'message', + operator: 'contains' as const, + value: 500, + }; + expect(conditionToStatement(condition)).toEqual( + '(ctx.message !== null && ((ctx.message instanceof Number && ctx.message.toString().contains("500")) || ctx.message.contains("500")))' + ); + }); }); - }); - describe('or', () => { - test('simple', () => { - const condition = { - or: [ - { field: 'log.logger', operator: 'eq' as const, value: 'nginx_proxy' }, - { field: 'log.level', operator: 'eq' as const, value: 'error' }, - ], - }; - expect( - expect(conditionToPainless(condition)).toEqual( - '(ctx.log?.logger !== null && ctx.log?.logger == "nginx_proxy") || (ctx.log?.level !== null && ctx.log?.level == "error")' - ) - ); + describe('and', () => { + test('simple', () => { + const condition = { + and: [ + { field: 'log.logger', operator: 'eq' as const, value: 'nginx_proxy' }, + { field: 'log.level', operator: 'eq' as const, value: 'error' }, + ], + }; + expect( + expect(conditionToStatement(condition)).toEqual( + '(ctx.log?.logger !== null && ((ctx.log?.logger instanceof Number && ctx.log?.logger.toString() == "nginx_proxy") || ctx.log?.logger == "nginx_proxy")) && (ctx.log?.level !== null && ((ctx.log?.level instanceof Number && ctx.log?.level.toString() == "error") || ctx.log?.level == "error"))' + ) + ); + }); }); - }); - describe('nested', () => { - test('and with a filter and or with 2 filters', () => { - const condition = { - and: [ - { field: 'log.logger', operator: 'eq' as const, value: 'nginx_proxy' }, - { - or: [ - { field: 'log.level', operator: 'eq' as const, value: 'error' }, - { field: 'log.level', operator: 'eq' as const, value: 'ERROR' }, - ], - }, - ], - }; - expect( - expect(conditionToPainless(condition)).toEqual( - '(ctx.log?.logger !== null && ctx.log?.logger == "nginx_proxy") && ((ctx.log?.level !== null && ctx.log?.level == "error") || (ctx.log?.level !== null && ctx.log?.level == "ERROR"))' - ) - ); + describe('or', () => { + test('simple', () => { + const condition = { + or: [ + { field: 'log.logger', operator: 'eq' as const, value: 'nginx_proxy' }, + { field: 'log.level', operator: 'eq' as const, value: 'error' }, + ], + }; + expect( + expect(conditionToStatement(condition)).toEqual( + '(ctx.log?.logger !== null && ((ctx.log?.logger instanceof Number && ctx.log?.logger.toString() == "nginx_proxy") || ctx.log?.logger == "nginx_proxy")) || (ctx.log?.level !== null && ((ctx.log?.level instanceof Number && ctx.log?.level.toString() == "error") || ctx.log?.level == "error"))' + ) + ); + }); }); - test('and with 2 or with filters', () => { - const condition = { - and: [ - { - or: [ - { field: 'log.logger', operator: 'eq' as const, value: 'nginx_proxy' }, - { field: 'service.name', operator: 'eq' as const, value: 'nginx' }, - ], - }, - { - or: [ - { field: 'log.level', operator: 'eq' as const, value: 'error' }, - { field: 'log.level', operator: 'eq' as const, value: 'ERROR' }, - ], - }, - ], - }; - expect( - expect(conditionToPainless(condition)).toEqual( - '((ctx.log?.logger !== null && ctx.log?.logger == "nginx_proxy") || (ctx.service?.name !== null && ctx.service?.name == "nginx")) && ((ctx.log?.level !== null && ctx.log?.level == "error") || (ctx.log?.level !== null && ctx.log?.level == "ERROR"))' - ) - ); + + describe('nested', () => { + test('and with a filter and or with 2 filters', () => { + const condition = { + and: [ + { field: 'log.logger', operator: 'eq' as const, value: 'nginx_proxy' }, + { + or: [ + { field: 'log.level', operator: 'eq' as const, value: 'error' }, + { field: 'log.level', operator: 'eq' as const, value: 'ERROR' }, + ], + }, + ], + }; + expect( + expect(conditionToStatement(condition)).toEqual( + '(ctx.log?.logger !== null && ((ctx.log?.logger instanceof Number && ctx.log?.logger.toString() == "nginx_proxy") || ctx.log?.logger == "nginx_proxy")) && ((ctx.log?.level !== null && ((ctx.log?.level instanceof Number && ctx.log?.level.toString() == "error") || ctx.log?.level == "error")) || (ctx.log?.level !== null && ((ctx.log?.level instanceof Number && ctx.log?.level.toString() == "ERROR") || ctx.log?.level == "ERROR")))' + ) + ); + }); + test('and with 2 or with filters', () => { + const condition = { + and: [ + { + or: [ + { field: 'log.logger', operator: 'eq' as const, value: 'nginx_proxy' }, + { field: 'service.name', operator: 'eq' as const, value: 'nginx' }, + ], + }, + { + or: [ + { field: 'log.level', operator: 'eq' as const, value: 'error' }, + { field: 'log.level', operator: 'eq' as const, value: 'ERROR' }, + ], + }, + ], + }; + expect( + expect(conditionToStatement(condition)).toEqual( + '((ctx.log?.logger !== null && ((ctx.log?.logger instanceof Number && ctx.log?.logger.toString() == "nginx_proxy") || ctx.log?.logger == "nginx_proxy")) || (ctx.service?.name !== null && ((ctx.service?.name instanceof Number && ctx.service?.name.toString() == "nginx") || ctx.service?.name == "nginx"))) && ((ctx.log?.level !== null && ((ctx.log?.level instanceof Number && ctx.log?.level.toString() == "error") || ctx.log?.level == "error")) || (ctx.log?.level !== null && ((ctx.log?.level instanceof Number && ctx.log?.level.toString() == "ERROR") || ctx.log?.level == "ERROR")))' + ) + ); + }); }); }); + + test('wrapped with type checks for uinary conditions', () => { + const condition = { field: 'log', operator: 'exists' as const }; + expect(conditionToPainless(condition)).toEqual(`try { + if (ctx.log !== null) { + return true; + } + return false; +} catch (Exception e) { + return false; +} +`); + }); + + test('wrapped with typechecks and try/catch', () => { + const condition = { + and: [ + { field: 'log.logger', operator: 'eq' as const, value: 'nginx_proxy' }, + { + or: [ + { field: 'log.level', operator: 'eq' as const, value: 'error' }, + { field: 'log.level', operator: 'eq' as const, value: 'ERROR' }, + ], + }, + ], + }; + expect( + expect(conditionToPainless(condition)) + .toEqual(`if (ctx.log?.logger instanceof Map || ctx.log?.level instanceof Map) { + return false; +} +try { + if ((ctx.log?.logger !== null && ((ctx.log?.logger instanceof Number && ctx.log?.logger.toString() == "nginx_proxy") || ctx.log?.logger == "nginx_proxy")) && ((ctx.log?.level !== null && ((ctx.log?.level instanceof Number && ctx.log?.level.toString() == "error") || ctx.log?.level == "error")) || (ctx.log?.level !== null && ((ctx.log?.level instanceof Number && ctx.log?.level.toString() == "ERROR") || ctx.log?.level == "ERROR")))) { + return true; + } + return false; +} catch (Exception e) { + return false; +} +`) + ); + }); }); diff --git a/x-pack/plugins/streams/server/lib/streams/helpers/condition_to_painless.ts b/x-pack/plugins/streams/server/lib/streams/helpers/condition_to_painless.ts index dccc15b2ec8fc..3585d009ee090 100644 --- a/x-pack/plugins/streams/server/lib/streams/helpers/condition_to_painless.ts +++ b/x-pack/plugins/streams/server/lib/streams/helpers/condition_to_painless.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { isBoolean, isString } from 'lodash'; +import { isBoolean, isString, uniq } from 'lodash'; import { AndCondition, BinaryFilterCondition, @@ -32,8 +32,11 @@ function isOrCondition(subject: any): subject is RerouteOrCondition { return result.success && subject.or != null; } -function safePainlessField(condition: FilterCondition) { - return `ctx.${condition.field.split('.').join('?.')}`; +function safePainlessField(conditionOrField: FilterCondition | string) { + if (isFilterCondition(conditionOrField)) { + return `ctx.${conditionOrField.field.split('.').join('?.')}`; + } + return `ctx.${conditionOrField.split('.').join('?.')}`; } function encodeValue(value: string | number | boolean) { @@ -49,23 +52,59 @@ function encodeValue(value: string | number | boolean) { function binaryToPainless(condition: BinaryFilterCondition) { switch (condition.operator) { case 'neq': - return `${safePainlessField(condition)} != ${encodeValue(condition.value)}`; + return `((${safePainlessField(condition)} instanceof Number && ${safePainlessField( + condition + )}.toString() != ${encodeValue(String(condition.value))}) || ${safePainlessField( + condition + )} != ${encodeValue(String(condition.value))})`; case 'lt': - return `${safePainlessField(condition)} < ${encodeValue(condition.value)}`; + return `((${safePainlessField( + condition + )} instanceof String && Float.parseFloat(${safePainlessField(condition)}) < ${encodeValue( + Number(condition.value) + )}) || ${safePainlessField(condition)} < ${encodeValue(Number(condition.value))})`; case 'lte': - return `${safePainlessField(condition)} <= ${encodeValue(condition.value)}`; + return `((${safePainlessField( + condition + )} instanceof String && Float.parseFloat(${safePainlessField(condition)}) <= ${encodeValue( + Number(condition.value) + )}) || ${safePainlessField(condition)} <= ${encodeValue(Number(condition.value))})`; case 'gt': - return `${safePainlessField(condition)} > ${encodeValue(condition.value)}`; + return `((${safePainlessField( + condition + )} instanceof String && Float.parseFloat(${safePainlessField(condition)}) > ${encodeValue( + Number(condition.value) + )}) || ${safePainlessField(condition)} > ${encodeValue(Number(condition.value))})`; case 'gte': - return `${safePainlessField(condition)} >= ${encodeValue(condition.value)}`; + return `((${safePainlessField( + condition + )} instanceof String && Float.parseFloat(${safePainlessField(condition)}) >= ${encodeValue( + Number(condition.value) + )}) || ${safePainlessField(condition)} >= ${encodeValue(Number(condition.value))})`; case 'startsWith': - return `${safePainlessField(condition)}.startsWith(${encodeValue(condition.value)})`; + return `((${safePainlessField(condition)} instanceof Number && ${safePainlessField( + condition + )}.toString().startsWith(${encodeValue(String(condition.value))})) || ${safePainlessField( + condition + )}.startsWith(${encodeValue(String(condition.value))}))`; case 'endsWith': - return `${safePainlessField(condition)}.endsWith(${encodeValue(condition.value)})`; + return `((${safePainlessField(condition)} instanceof Number && ${safePainlessField( + condition + )}.toString().endsWith(${encodeValue(String(condition.value))})) || ${safePainlessField( + condition + )}.endsWith(${encodeValue(String(condition.value))}))`; case 'contains': - return `${safePainlessField(condition)}.contains(${encodeValue(condition.value)})`; + return `((${safePainlessField(condition)} instanceof Number && ${safePainlessField( + condition + )}.toString().contains(${encodeValue(String(condition.value))})) || ${safePainlessField( + condition + )}.contains(${encodeValue(String(condition.value))}))`; default: - return `${safePainlessField(condition)} == ${encodeValue(condition.value)}`; + return `((${safePainlessField(condition)} instanceof Number && ${safePainlessField( + condition + )}.toString() == ${encodeValue(String(condition.value))}) || ${safePainlessField( + condition + )} == ${encodeValue(String(condition.value))})`; } } @@ -82,7 +121,18 @@ function isUnaryFilterCondition(subject: FilterCondition): subject is UnaryFilte return !('value' in subject); } -export function conditionToPainless(condition: Condition, nested = false): string { +function extractAllFields(condition: Condition, fields: string[] = []): string[] { + if (isFilterCondition(condition) && !isUnaryFilterCondition(condition)) { + return uniq([...fields, condition.field]); + } else if (isAndCondition(condition)) { + return uniq(condition.and.map((cond) => extractAllFields(cond, fields)).flat()); + } else if (isOrCondition(condition)) { + return uniq(condition.or.map((cond) => extractAllFields(cond, fields)).flat()); + } + return uniq(fields); +} + +export function conditionToStatement(condition: Condition, nested = false): string { if (isFilterCondition(condition)) { if (isUnaryFilterCondition(condition)) { return unaryToPainless(condition); @@ -90,12 +140,34 @@ export function conditionToPainless(condition: Condition, nested = false): strin return `(${safePainlessField(condition)} !== null && ${binaryToPainless(condition)})`; } if (isAndCondition(condition)) { - const and = condition.and.map((filter) => conditionToPainless(filter, true)).join(' && '); + const and = condition.and.map((filter) => conditionToStatement(filter, true)).join(' && '); return nested ? `(${and})` : and; } if (isOrCondition(condition)) { - const or = condition.or.map((filter) => conditionToPainless(filter, true)).join(' || '); + const or = condition.or.map((filter) => conditionToStatement(filter, true)).join(' || '); return nested ? `(${or})` : or; } return 'false'; } + +export function conditionToPainless(condition: Condition): string { + const fields = extractAllFields(condition); + let fieldCheck = ''; + if (fields.length !== 0) { + fieldCheck = `if (${fields + .map((field) => `${safePainlessField(field)} instanceof Map`) + .join(' || ')}) { + return false; +} +`; + } + return `${fieldCheck}try { + if (${conditionToStatement(condition)}) { + return true; + } + return false; +} catch (Exception e) { + return false; +} +`; +} diff --git a/x-pack/plugins/threat_intelligence/public/hooks/use_integrations.test.tsx b/x-pack/plugins/threat_intelligence/public/hooks/use_integrations.test.tsx index 2df9bea337ba5..e13fb396808d2 100644 --- a/x-pack/plugins/threat_intelligence/public/hooks/use_integrations.test.tsx +++ b/x-pack/plugins/threat_intelligence/public/hooks/use_integrations.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { renderHook } from '@testing-library/react-hooks'; +import { waitFor, renderHook } from '@testing-library/react'; import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'; import { INSTALLATION_STATUS, THREAT_INTELLIGENCE_CATEGORY } from '../utils/filter_integrations'; @@ -25,9 +25,7 @@ const renderUseQuery = (result: { items: any[] }) => describe('useIntegrations', () => { it('should have undefined data during loading state', async () => { const mockIntegrations = { items: [] }; - const { result, waitFor } = renderUseQuery(mockIntegrations); - - await waitFor(() => result.current.isLoading); + const { result } = renderUseQuery(mockIntegrations); expect(result.current.isLoading).toBeTruthy(); expect(result.current.data).toBeUndefined(); @@ -43,9 +41,9 @@ describe('useIntegrations', () => { }, ], }; - const { result, waitFor } = renderUseQuery(mockIntegrations); + const { result } = renderUseQuery(mockIntegrations); - await waitFor(() => result.current.isSuccess); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.isLoading).toBeFalsy(); expect(result.current.data).toEqual(mockIntegrations); diff --git a/x-pack/plugins/threat_intelligence/public/modules/block_list/hooks/use_policies.test.tsx b/x-pack/plugins/threat_intelligence/public/modules/block_list/hooks/use_policies.test.tsx index a01b7da64b684..4a1af74a100a3 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/block_list/hooks/use_policies.test.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/block_list/hooks/use_policies.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { renderHook } from '@testing-library/react-hooks'; +import { waitFor, renderHook } from '@testing-library/react'; import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'; const createWrapper = () => { @@ -24,9 +24,7 @@ const renderUseQuery = (result: { items: any[] }) => describe('usePolicies', () => { it('should have undefined data during loading state', async () => { const mockPolicies = { items: [] }; - const { result, waitFor } = renderUseQuery(mockPolicies); - - await waitFor(() => result.current.isLoading); + const { result } = renderUseQuery(mockPolicies); expect(result.current.isLoading).toBeTruthy(); expect(result.current.data).toBeUndefined(); @@ -41,9 +39,9 @@ describe('usePolicies', () => { }, ], }; - const { result, waitFor } = renderUseQuery(mockPolicies); + const { result } = renderUseQuery(mockPolicies); - await waitFor(() => result.current.isSuccess); + await waitFor(() => expect(result.current.isSuccess).toBeTruthy()); expect(result.current.isLoading).toBeFalsy(); expect(result.current.data).toEqual(mockPolicies); diff --git a/x-pack/plugins/threat_intelligence/public/modules/cases/hooks/use_case_permission.test.tsx b/x-pack/plugins/threat_intelligence/public/modules/cases/hooks/use_case_permission.test.tsx index 8e2f5d3d96a25..a38b25fce0f61 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/cases/hooks/use_case_permission.test.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/cases/hooks/use_case_permission.test.tsx @@ -6,7 +6,7 @@ */ import React, { FC, ReactNode } from 'react'; -import { Renderer, renderHook, RenderHookResult } from '@testing-library/react-hooks'; +import { renderHook, RenderHookResult } from '@testing-library/react'; import { casesPluginMock } from '@kbn/cases-plugin/public/mocks'; import { KibanaContext } from '../../../hooks/use_kibana'; import { useCaseDisabled } from './use_case_permission'; @@ -27,7 +27,7 @@ const getProviderComponent = ); describe('useCasePermission', () => { - let hookResult: RenderHookResult<{}, boolean, Renderer>; + let hookResult: RenderHookResult; it('should return false if user has correct permissions and indicator has a name', () => { const mockedServices = { diff --git a/x-pack/plugins/threat_intelligence/public/modules/cases/hooks/use_indicator_by_id.test.tsx b/x-pack/plugins/threat_intelligence/public/modules/cases/hooks/use_indicator_by_id.test.tsx index 0c23962ccaa46..e9f43c316190a 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/cases/hooks/use_indicator_by_id.test.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/cases/hooks/use_indicator_by_id.test.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import { renderHook } from '@testing-library/react-hooks'; -import { useIndicatorById, UseIndicatorByIdValue } from './use_indicator_by_id'; +import { waitFor, renderHook } from '@testing-library/react'; +import { useIndicatorById } from './use_indicator_by_id'; import { TestProvidersComponent } from '../../../mocks/test_providers'; import { createFetchIndicatorById } from '../services/fetch_indicator_by_id'; import { Indicator } from '../../../../common/types/indicator'; @@ -16,13 +16,10 @@ jest.mock('../services/fetch_indicator_by_id'); const indicatorByIdQueryResult = { _id: 'testId' } as unknown as Indicator; const renderUseIndicatorById = (initialProps = { indicatorId: 'testId' }) => - renderHook<{ indicatorId: string }, UseIndicatorByIdValue>( - (props) => useIndicatorById(props.indicatorId), - { - initialProps, - wrapper: TestProvidersComponent, - } - ); + renderHook((props) => useIndicatorById(props.indicatorId), { + initialProps, + wrapper: TestProvidersComponent, + }); describe('useIndicatorById()', () => { type MockedCreateFetchIndicators = jest.MockedFunction; @@ -49,8 +46,7 @@ describe('useIndicatorById()', () => { expect(indicatorsQuery).toHaveBeenCalledTimes(1); // isLoading should turn to false eventually - await hookResult.waitFor(() => !hookResult.result.current.isLoading); - expect(hookResult.result.current.isLoading).toEqual(false); + await waitFor(() => expect(hookResult.result.current.isLoading).toBe(false)); }); }); }); diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/use_aggregated_indicators.test.tsx b/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/use_aggregated_indicators.test.tsx index 11e1d03a32b51..72935990ef71f 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/use_aggregated_indicators.test.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/use_aggregated_indicators.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { act, renderHook } from '@testing-library/react-hooks'; +import { act, waitFor, renderHook } from '@testing-library/react'; import { useAggregatedIndicators, UseAggregatedIndicatorsParam } from './use_aggregated_indicators'; import { mockedTimefilterService, TestProvidersComponent } from '../../../mocks/test_providers'; import { createFetchAggregatedIndicators } from '../services/fetch_aggregated_indicators'; @@ -47,7 +47,7 @@ describe('useAggregatedIndicators()', () => { it('should create and call the aggregatedIndicatorsQuery correctly', async () => { aggregatedIndicatorsQuery.mockResolvedValue([]); - const { result, rerender, waitFor } = renderUseAggregatedIndicators(); + const { result, rerender } = renderUseAggregatedIndicators(); // indicators service and the query should be called just once expect( @@ -81,7 +81,7 @@ describe('useAggregatedIndicators()', () => { expect.any(AbortSignal) ); - await waitFor(() => !result.current.isLoading); + await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current).toMatchInlineSnapshot(` Object { diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/use_column_settings.test.ts b/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/use_column_settings.test.ts index 74b76030bca37..cbba1c3043f3e 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/use_column_settings.test.ts +++ b/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/use_column_settings.test.ts @@ -6,7 +6,7 @@ */ import { mockedServices, TestProvidersComponent } from '../../../mocks/test_providers'; -import { act, renderHook } from '@testing-library/react-hooks'; +import { renderHook, act } from '@testing-library/react'; import { useColumnSettings } from './use_column_settings'; const renderUseColumnSettings = () => diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/use_indicators.test.tsx b/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/use_indicators.test.tsx index be710e771b040..2276d1cfcf635 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/use_indicators.test.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/use_indicators.test.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import { act, renderHook } from '@testing-library/react-hooks'; -import { useIndicators, UseIndicatorsParams, UseIndicatorsValue } from './use_indicators'; +import { act, waitFor, renderHook } from '@testing-library/react'; +import { useIndicators, UseIndicatorsParams } from './use_indicators'; import { TestProvidersComponent } from '../../../mocks/test_providers'; import { createFetchIndicators } from '../services/fetch_indicators'; import { mockTimeRange } from '../../../mocks/mock_indicators_filters_context'; @@ -23,7 +23,7 @@ const useIndicatorsParams: UseIndicatorsParams = { const indicatorsQueryResult = { indicators: [], total: 0 }; const renderUseIndicators = (initialProps = useIndicatorsParams) => - renderHook((props) => useIndicators(props), { + renderHook(useIndicators, { initialProps, wrapper: TestProvidersComponent, }); @@ -53,7 +53,7 @@ describe('useIndicators()', () => { expect(indicatorsQuery).toHaveBeenCalledTimes(1); // isLoading should turn to false eventually - await hookResult.waitFor(() => !hookResult.result.current.isLoading); + await waitFor(() => expect(hookResult.result.current.isLoading).toBe(false)); expect(hookResult.result.current.isLoading).toEqual(false); }); }); @@ -77,7 +77,7 @@ describe('useIndicators()', () => { // Change page size await act(async () => hookResult.result.current.onChangeItemsPerPage(50)); - expect(indicatorsQuery).toHaveBeenCalledTimes(3); + await waitFor(() => expect(indicatorsQuery).toHaveBeenCalledTimes(3)); expect(indicatorsQuery).toHaveBeenLastCalledWith( expect.objectContaining({ pagination: expect.objectContaining({ pageIndex: 0, pageSize: 50 }), @@ -101,7 +101,7 @@ describe('useIndicators()', () => { expect.any(AbortSignal) ); - await hookResult.waitFor(() => !hookResult.result.current.isLoading); + await waitFor(() => expect(hookResult.result.current.isLoading).toBe(false)); expect(hookResult.result.current).toMatchInlineSnapshot(` Object { diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/use_toolbar_options.test.tsx b/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/use_toolbar_options.test.tsx index 7af7320af0988..9a69b71fd3f75 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/use_toolbar_options.test.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/use_toolbar_options.test.tsx @@ -6,7 +6,7 @@ */ import { TestProvidersComponent } from '../../../mocks/test_providers'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { useToolbarOptions } from './use_toolbar_options'; describe('useToolbarOptions()', () => { diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/use_total_count.test.tsx b/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/use_total_count.test.tsx index 893367b42dbd3..8735817747f12 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/use_total_count.test.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/use_total_count.test.tsx @@ -6,7 +6,7 @@ */ import { mockedSearchService, TestProvidersComponent } from '../../../mocks/test_providers'; -import { act, renderHook } from '@testing-library/react-hooks'; +import { renderHook, act } from '@testing-library/react'; import { BehaviorSubject } from 'rxjs'; import { useIndicatorsTotalCount } from './use_total_count'; diff --git a/x-pack/plugins/threat_intelligence/public/modules/query_bar/hooks/use_filter_in_out.test.ts b/x-pack/plugins/threat_intelligence/public/modules/query_bar/hooks/use_filter_in_out.test.ts index 7e435d944145b..18c8fccd573f1 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/query_bar/hooks/use_filter_in_out.test.ts +++ b/x-pack/plugins/threat_intelligence/public/modules/query_bar/hooks/use_filter_in_out.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { Renderer, renderHook, RenderHookResult } from '@testing-library/react-hooks'; +import { renderHook, RenderHookResult } from '@testing-library/react'; import { generateMockIndicator, generateMockUrlIndicator, @@ -19,7 +19,7 @@ import { updateFiltersArray } from '../utils/filter'; jest.mock('../utils/filter', () => ({ updateFiltersArray: jest.fn() })); describe('useFilterInOut()', () => { - let hookResult: RenderHookResult<{}, UseFilterInValue, Renderer>; + let hookResult: RenderHookResult; it('should return empty object if Indicator is incorrect', () => { const indicator: Indicator = generateMockIndicator(); diff --git a/x-pack/plugins/threat_intelligence/public/modules/timeline/hooks/use_add_to_timeline.test.tsx b/x-pack/plugins/threat_intelligence/public/modules/timeline/hooks/use_add_to_timeline.test.tsx index 18d96282d98b6..0b5cc302ef0ea 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/timeline/hooks/use_add_to_timeline.test.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/timeline/hooks/use_add_to_timeline.test.tsx @@ -6,7 +6,7 @@ */ import { EMPTY_VALUE } from '../../../constants/common'; -import { Renderer, renderHook, RenderHookResult } from '@testing-library/react-hooks'; +import { renderHook, RenderHookResult } from '@testing-library/react'; import { generateMockIndicator, generateMockUrlIndicator, @@ -16,7 +16,7 @@ import { TestProvidersComponent } from '../../../mocks/test_providers'; import { useAddToTimeline, UseAddToTimelineValue } from './use_add_to_timeline'; describe('useInvestigateInTimeline()', () => { - let hookResult: RenderHookResult<{}, UseAddToTimelineValue, Renderer>; + let hookResult: RenderHookResult; xit('should return empty object if Indicator is incorrect', () => { const indicator: Indicator = generateMockIndicator(); diff --git a/x-pack/plugins/threat_intelligence/public/modules/timeline/hooks/use_investigate_in_timeline.test.tsx b/x-pack/plugins/threat_intelligence/public/modules/timeline/hooks/use_investigate_in_timeline.test.tsx index 5d416c5ff56c7..dc9b3a4a00296 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/timeline/hooks/use_investigate_in_timeline.test.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/timeline/hooks/use_investigate_in_timeline.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { Renderer, renderHook, RenderHookResult } from '@testing-library/react-hooks'; +import { renderHook, RenderHookResult } from '@testing-library/react'; import { useInvestigateInTimeline, UseInvestigateInTimelineValue, @@ -18,7 +18,7 @@ import { import { TestProvidersComponent } from '../../../mocks/test_providers'; describe('useInvestigateInTimeline()', () => { - let hookResult: RenderHookResult<{}, UseInvestigateInTimelineValue, Renderer>; + let hookResult: RenderHookResult; it('should return empty object if Indicator is incorrect', () => { const indicator: Indicator = generateMockIndicator(); diff --git a/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx b/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx index cdb159c158d10..49bd43232c9f5 100644 --- a/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx +++ b/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx @@ -67,7 +67,14 @@ dataStart.search.search = jest.fn(({ params }: IKibanaSearchRequest) => { }) as ISearchGeneric; // Replace mock to support tests for `use_index_data`. -coreSetup.http.post = jest.fn().mockResolvedValue([]); +coreSetup.http.post = jest.fn().mockImplementation((endpoint) => { + if (endpoint === '/internal/transform/transforms/_preview') { + return Promise.resolve({ + generated_dest_index: { mappings: { properties: {} } }, + preview: [], + }); + } +}); const appDependencies: AppDependencies = { analytics: coreStart.analytics, diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx index 5b6e314e951aa..7c08a2462f65a 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx @@ -20,9 +20,7 @@ import { StepDefineSummary } from './step_define_summary'; jest.mock('../../../../app_dependencies'); -// Failing: https://github.com/elastic/kibana/issues/195992 -describe.skip('Transform: ', () => { - // Using the async/await wait()/done() pattern to avoid act() errors. +describe('Transform: ', () => { test('Minimal initialization', async () => { // Arrange const queryClient = new QueryClient(); diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 7719aef64c4d5..5f6c9203803dd 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -2643,7 +2643,6 @@ "discover.docViews.logsOverview.title": "Aperçu du log", "discover.docViews.table.scoreSortWarningTooltip": "Filtrez sur _score pour pouvoir récupérer les valeurs correspondantes.", "discover.dropZoneTableLabel": "Abandonner la zone pour ajouter un champ en tant que colonne dans la table", - "discover.embeddable.inspectorRequestDataTitle": "Données", "discover.embeddable.inspectorRequestDescription": "Cette requête interroge Elasticsearch afin de récupérer les données pour la recherche.", "discover.embeddable.search.dataViewError": "Vue de données {indexPatternId} manquante", "discover.embeddable.search.displayName": "rechercher", @@ -2932,6 +2931,8 @@ "esqlEditor.query.EnableWordWrapLabel": "Ajouter des sauts de ligne aux barres verticales", "esqlEditor.query.errorCount": "{count} {count, plural, one {erreur} other {erreurs}}", "esqlEditor.query.errorsTitle": "Erreurs", + "esqlEditor.query.esqlQueriesCopy": "Copier la requête dans le presse-papier", + "esqlEditor.query.esqlQueriesListRun": "Exécuter la requête", "esqlEditor.query.expandLabel": "Développer", "esqlEditor.query.feedback": "Commentaires", "esqlEditor.query.hideQueriesLabel": "Masquer les recherches récentes", @@ -2941,8 +2942,6 @@ "esqlEditor.query.lineNumber": "Ligne {lineNumber}", "esqlEditor.query.querieshistory.error": "La requête a échouée", "esqlEditor.query.querieshistory.success": "La requête a été exécuté avec succès", - "esqlEditor.query.esqlQueriesCopy": "Copier la requête dans le presse-papier", - "esqlEditor.query.esqlQueriesListRun": "Exécuter la requête", "esqlEditor.query.querieshistoryTable": "Tableau d'historique des recherches", "esqlEditor.query.recentQueriesColumnLabel": "Recherches récentes", "esqlEditor.query.refreshLabel": "Actualiser", @@ -31628,7 +31627,6 @@ "xpack.ml.trainedModels.modelsList.expandRow": "Développer", "xpack.ml.trainedModels.modelsList.fetchDeletionErrorMessage": "La suppression {modelsCount, plural, one {du modèle} other {des modèles}} a échoué", "xpack.ml.trainedModels.modelsList.fetchFailedErrorMessage": "Erreur lors du chargement des modèles entraînés", - "xpack.ml.trainedModels.modelsList.fetchModelStatsErrorMessage": "Erreur lors du chargement des statistiques des modèles entraînés", "xpack.ml.trainedModels.modelsList.forceStopDialog.cancelText": "Annuler", "xpack.ml.trainedModels.modelsList.forceStopDialog.confirmText": "Arrêt", "xpack.ml.trainedModels.modelsList.forceStopDialog.hasInferenceServicesWarning": "Ce modèle est utilisé par l'API _inference", @@ -36435,7 +36433,6 @@ "xpack.security.management.editRole.featureTable.cannotCustomizeSubFeaturesTooltip": "La personnalisation des privilèges de sous-fonctionnalité est une fonctionnalité soumise à abonnement.", "xpack.security.management.editRole.featureTable.customizeSubFeaturePrivilegesSwitchLabel": "Personnaliser les privilèges des sous-fonctionnalités", "xpack.security.management.editRole.featureTable.featureAccordionSwitchLabel": "{grantedCount}/{featureCount} {featureCount, plural, one {fonctionnalité accordée} other {fonctionnalités accordées}}", - "xpack.security.management.editRole.featureTable.featureVisibilityTitle": "Personnaliser les privilèges des fonctionnalités", "xpack.security.management.editRole.featureTable.managementCategoryHelpText": "Des autorisations de gestion de suite supplémentaires sont disponibles en dehors de ce menu, dans les privilèges d'index et de cluster.", "xpack.security.management.editRole.featureTable.privilegeCustomizationTooltip": "La fonctionnalité possède des privilèges de sous-fonctionnalités personnalisés. Développez cette ligne pour en savoir plus.", "xpack.security.management.editRole.indexPrivilegeForm.clustersFormRowLabel": "Clusters distants", @@ -36496,18 +36493,10 @@ "xpack.security.management.editRole.spaceAwarePrivilegeForm.kibanaAdminTitle": "kibana_admin", "xpack.security.management.editRole.spacePrivilegeForm.basePrivilegeControlLegend": "Privilèges pour toutes les fonctionnalités", "xpack.security.management.editRole.spacePrivilegeForm.cancelButton": "Annuler", - "xpack.security.management.editRole.spacePrivilegeForm.customizeFeaturePrivilegeDescription": "Augmentez les niveaux de privilèges sur la base de chaque fonctionnalité. Certaines fonctionnalités peuvent être masquées par l'espace ou concernées par un privilège d'espace global.", - "xpack.security.management.editRole.spacePrivilegeForm.customizeFeaturePrivileges": "Personnaliser par fonctionnalité", - "xpack.security.management.editRole.spacePrivilegeForm.featurePrivilegeSummaryDescription": "Certaines fonctionnalités peuvent être masquées par l'espace ou concernées par un privilège d'espace global.", - "xpack.security.management.editRole.spacePrivilegeForm.globalPrivilegeNotice": "Ces privilèges s'appliqueront à tous les espaces, actuels et futurs.", - "xpack.security.management.editRole.spacePrivilegeForm.globalPrivilegeWarning": "La création d'un privilège global peut impacter vos autres privilèges liés aux espaces.", - "xpack.security.management.editRole.spacePrivilegeForm.modalHeadline": "Ce rôle aura accès aux espaces suivants", - "xpack.security.management.editRole.spacePrivilegeForm.modalTitle": "Affecter un rôle à l'espace", "xpack.security.management.editRole.spacePrivilegeForm.privilegeSelectorFormHelpText": "Affectez le niveau de privilège que vous souhaitez accorder à toutes les fonctionnalités présentes et futures de cet espace.", "xpack.security.management.editRole.spacePrivilegeForm.privilegeSelectorFormLabel": "Privilèges pour toutes les fonctionnalités", "xpack.security.management.editRole.spacePrivilegeForm.spaceSelectorFormHelpText": "Sélectionnez un ou plusieurs espaces Kibana auxquels vous souhaitez affecter des privilèges.", "xpack.security.management.editRole.spacePrivilegeForm.spaceSelectorFormLabel": "Espaces", - "xpack.security.management.editRole.spacePrivilegeForm.summaryOfFeaturePrivileges": "Résumé des privilèges des fonctionnalités", "xpack.security.management.editRole.spacePrivilegeForm.supersededWarning": "Les privilèges déclarés sont moins flexibles que les privilèges globaux configurés. Affichez le résumé des privilèges pour voir les privilèges effectifs.", "xpack.security.management.editRole.spacePrivilegeForm.supersededWarningTitle": "Remplacé par les privilèges globaux", "xpack.security.management.editRole.spacePrivilegeMatrix.globalSpaceName": "Tous les espaces", @@ -36640,9 +36629,7 @@ "xpack.security.management.editRoles.indexPrivilegeForm.grantedFieldsFormRowHelpText": "Si aucun champ n'est accordé, les utilisateurs affectés à ce rôle ne pourront voir aucune donnée pour cet index.", "xpack.security.management.editRoles.indexPrivilegeForm.grantedFieldsFormRowLabel": "Champs accordés", "xpack.security.management.editRoles.indexPrivilegeForm.grantFieldPrivilegesLabel": "Accorder l'accès aux champs spécifiques", - "xpack.security.management.editRolespacePrivilegeForm.createGlobalPrivilegeButton": "Créer un privilège global", "xpack.security.management.editRolespacePrivilegeForm.createPrivilegeButton": "Ajouter un privilège Kibana", - "xpack.security.management.editRolespacePrivilegeForm.updateGlobalPrivilegeButton": "Mettre à jour le privilège global", "xpack.security.management.editRolespacePrivilegeForm.updatePrivilegeButton": "Mettre à jour le privilège d'espace", "xpack.security.management.enabledBadge": "Activé", "xpack.security.management.readonlyBadge.text": "Lecture seule", @@ -44747,7 +44734,6 @@ "xpack.spaces.management.spaceDetails.footerActions.updateSpace": "Appliquer les modifications", "xpack.spaces.management.spaceDetails.keepEditingButton": "Enregistrer avant de quitter", "xpack.spaces.management.spaceDetails.leavePageButton": "Quitter", - "xpack.spaces.management.spaceDetails.privilegeForm.heading": "Définissez les privilèges qu'un rôle donné devrait avoir dans cet espace.", "xpack.spaces.management.spaceDetails.roles.assign": "Attribuer de nouveaux rôles", "xpack.spaces.management.spaceDetails.roles.assign.privilegeConflictMsg.description": "La mise à jour en groupe des paramètres ici remplacera les paramètres individuels actuels.", "xpack.spaces.management.spaceDetails.roles.assign.privilegeConflictMsg.title": "Les privilèges s'appliqueront uniquement à cet espace.", @@ -44798,7 +44784,6 @@ "xpack.spaces.management.spaceIdentifier.kibanaURLForSpaceIdentifierDescription": "Vous ne pouvez pas modifier l'identifiant d'URL après sa création.", "xpack.spaces.management.spaceIdentifier.urlIdentifierTitle": "Identifiant d'URL", "xpack.spaces.management.spacesGridPage.actionsColumnName": "Actions", - "xpack.spaces.management.spacesGridPage.allFeaturesEnabled": "Toutes les fonctionnalités", "xpack.spaces.management.spacesGridPage.createSpaceButtonLabel": "Créer l'espace", "xpack.spaces.management.spacesGridPage.currentSpaceMarkerText": "actuel", "xpack.spaces.management.spacesGridPage.deleteActionDescription": "Supprimer {spaceName}", @@ -44808,13 +44793,10 @@ "xpack.spaces.management.spacesGridPage.editSpaceActionDescription": "Modifier {spaceName}.", "xpack.spaces.management.spacesGridPage.editSpaceActionName": "Modifier", "xpack.spaces.management.spacesGridPage.errorTitle": "Erreur lors du chargement des espaces", - "xpack.spaces.management.spacesGridPage.featuresColumnName": "Fonctionnalités visibles", "xpack.spaces.management.spacesGridPage.identifierColumnName": "Identificateur", "xpack.spaces.management.spacesGridPage.loadingTitle": "chargement…", - "xpack.spaces.management.spacesGridPage.noFeaturesEnabled": "Aucune fonctionnalité visible", "xpack.spaces.management.spacesGridPage.searchPlaceholder": "Recherche", "xpack.spaces.management.spacesGridPage.solutionColumnName": "Afficher la solution", - "xpack.spaces.management.spacesGridPage.someFeaturesEnabled": "{enabledFeatureCount}/{totalFeatureCount}", "xpack.spaces.management.spacesGridPage.spaceColumnName": "Espace", "xpack.spaces.management.spacesGridPage.spacesTitle": "Espaces", "xpack.spaces.management.spacesGridPage.switchSpaceActionDescription": "Basculer vers {spaceName}", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 155bdc6698228..065e3c1bdd6cb 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -2641,7 +2641,6 @@ "discover.docViews.logsOverview.title": "ログ概要", "discover.docViews.table.scoreSortWarningTooltip": "_scoreの値を取得するには、並べ替える必要があります。", "discover.dropZoneTableLabel": "フィールドを列として表に追加するには、ゾーンをドロップします", - "discover.embeddable.inspectorRequestDataTitle": "データ", "discover.embeddable.inspectorRequestDescription": "このリクエストはElasticsearchにクエリーをかけ、検索データを取得します。", "discover.embeddable.search.dataViewError": "データビュー{indexPatternId}が見つかりません", "discover.embeddable.search.displayName": "検索", @@ -2926,6 +2925,8 @@ "esqlEditor.query.EnableWordWrapLabel": "パイプの改行を追加", "esqlEditor.query.errorCount": "{count} {count, plural, other {# 件のエラー}}", "esqlEditor.query.errorsTitle": "エラー", + "esqlEditor.query.esqlQueriesCopy": "クエリをクリップボードにコピー", + "esqlEditor.query.esqlQueriesListRun": "クエリーを実行", "esqlEditor.query.expandLabel": "拡張", "esqlEditor.query.feedback": "フィードバック", "esqlEditor.query.hideQueriesLabel": "最近のクエリーを非表示", @@ -2935,8 +2936,6 @@ "esqlEditor.query.lineNumber": "行{lineNumber}", "esqlEditor.query.querieshistory.error": "クエリ失敗", "esqlEditor.query.querieshistory.success": "クエリは正常に実行されました", - "esqlEditor.query.esqlQueriesCopy": "クエリをクリップボードにコピー", - "esqlEditor.query.esqlQueriesListRun": "クエリーを実行", "esqlEditor.query.querieshistoryTable": "クエリ履歴テーブル", "esqlEditor.query.recentQueriesColumnLabel": "最近のクエリー", "esqlEditor.query.refreshLabel": "更新", @@ -31488,7 +31487,6 @@ "xpack.ml.trainedModels.modelsList.expandRow": "拡張", "xpack.ml.trainedModels.modelsList.fetchDeletionErrorMessage": "{modelsCount, plural, other {モデル}}を削除できませんでした", "xpack.ml.trainedModels.modelsList.fetchFailedErrorMessage": "学習済みモデルの読み込みエラー", - "xpack.ml.trainedModels.modelsList.fetchModelStatsErrorMessage": "学習済みモデル統計情報の読み込みエラー", "xpack.ml.trainedModels.modelsList.forceStopDialog.cancelText": "キャンセル", "xpack.ml.trainedModels.modelsList.forceStopDialog.confirmText": "終了", "xpack.ml.trainedModels.modelsList.forceStopDialog.hasInferenceServicesWarning": "モデルは_inference APIによって使用されます。", @@ -36292,7 +36290,6 @@ "xpack.security.management.editRole.featureTable.cannotCustomizeSubFeaturesTooltip": "サブ機能権限のカスタマイズはサブスクリプション機能です。", "xpack.security.management.editRole.featureTable.customizeSubFeaturePrivilegesSwitchLabel": "サブ機能権限をカスタマイズする", "xpack.security.management.editRole.featureTable.featureAccordionSwitchLabel": "{grantedCount} / {featureCount} {featureCount, plural, other {機能}}が付与されました", - "xpack.security.management.editRole.featureTable.featureVisibilityTitle": "機能権限をカスタマイズ", "xpack.security.management.editRole.featureTable.managementCategoryHelpText": "追加のスタック管理権限は、このメニューの外にあるインデックス権限とクラスター権限をご覧ください。", "xpack.security.management.editRole.featureTable.privilegeCustomizationTooltip": "機能でサブ機能の権限がカスタマイズされています。この行を展開すると詳細が表示されます。", "xpack.security.management.editRole.indexPrivilegeForm.clustersFormRowLabel": "リモートクラスター", @@ -36353,18 +36350,10 @@ "xpack.security.management.editRole.spaceAwarePrivilegeForm.kibanaAdminTitle": "kibana_admin", "xpack.security.management.editRole.spacePrivilegeForm.basePrivilegeControlLegend": "すべての機能の権限", "xpack.security.management.editRole.spacePrivilegeForm.cancelButton": "キャンセル", - "xpack.security.management.editRole.spacePrivilegeForm.customizeFeaturePrivilegeDescription": "機能ごとに権限のレベルを上げます。機能によってはスペースごとに非表示になっているか、グローバルスペース権限による影響を受けているものもあります。", - "xpack.security.management.editRole.spacePrivilegeForm.customizeFeaturePrivileges": "機能ごとにカスタマイズ", - "xpack.security.management.editRole.spacePrivilegeForm.featurePrivilegeSummaryDescription": "機能によってはスペースごとに非表示になっているか、グローバルスペース権限による影響を受けているものもあります。", - "xpack.security.management.editRole.spacePrivilegeForm.globalPrivilegeNotice": "これらの権限はすべての現在および未来のスペースに適用されます。", - "xpack.security.management.editRole.spacePrivilegeForm.globalPrivilegeWarning": "グローバル権限の作成は他のスペース権限に影響を与える可能性があります。", - "xpack.security.management.editRole.spacePrivilegeForm.modalHeadline": "このロールには、次のスペースへのアクセス権が付与されます", - "xpack.security.management.editRole.spacePrivilegeForm.modalTitle": "ロールをスペースに割り当て", "xpack.security.management.editRole.spacePrivilegeForm.privilegeSelectorFormHelpText": "このスペース全体の現在と将来のすべての機能に対して、付与する権限レベルを割り当てます。", "xpack.security.management.editRole.spacePrivilegeForm.privilegeSelectorFormLabel": "すべての機能の権限", "xpack.security.management.editRole.spacePrivilegeForm.spaceSelectorFormHelpText": "権限を割り当てる1つ以上のKibanaスペースを選択します。", "xpack.security.management.editRole.spacePrivilegeForm.spaceSelectorFormLabel": "スペース", - "xpack.security.management.editRole.spacePrivilegeForm.summaryOfFeaturePrivileges": "機能権限のサマリー", "xpack.security.management.editRole.spacePrivilegeForm.supersededWarning": "宣言された権限は、構成済みグローバル権限よりも許容度が低くなります。権限サマリーを表示すると有効な権限がわかります。", "xpack.security.management.editRole.spacePrivilegeForm.supersededWarningTitle": "グローバル権限に置き換え", "xpack.security.management.editRole.spacePrivilegeMatrix.globalSpaceName": "すべてのスペース", @@ -36497,9 +36486,7 @@ "xpack.security.management.editRoles.indexPrivilegeForm.grantedFieldsFormRowHelpText": "フィールドが提供されていない場合、このロールのユーザーはこのインデックスのデータを表示できません。", "xpack.security.management.editRoles.indexPrivilegeForm.grantedFieldsFormRowLabel": "許可されたフィールド", "xpack.security.management.editRoles.indexPrivilegeForm.grantFieldPrivilegesLabel": "特定のフィールドへのアクセスを許可", - "xpack.security.management.editRolespacePrivilegeForm.createGlobalPrivilegeButton": "グローバル権限を作成", "xpack.security.management.editRolespacePrivilegeForm.createPrivilegeButton": "Kibanaの権限を追加", - "xpack.security.management.editRolespacePrivilegeForm.updateGlobalPrivilegeButton": "グローバル特権を更新", "xpack.security.management.editRolespacePrivilegeForm.updatePrivilegeButton": "スペース権限を更新", "xpack.security.management.enabledBadge": "有効", "xpack.security.management.readonlyBadge.text": "読み取り専用", @@ -44596,7 +44583,6 @@ "xpack.spaces.management.spaceDetails.footerActions.updateSpace": "変更を適用", "xpack.spaces.management.spaceDetails.keepEditingButton": "移動する前に保存", "xpack.spaces.management.spaceDetails.leavePageButton": "移動", - "xpack.spaces.management.spaceDetails.privilegeForm.heading": "このスペースで特定のロールに割り当てる権限を定義します。", "xpack.spaces.management.spaceDetails.roles.assign": "新しいロールを割り当て", "xpack.spaces.management.spaceDetails.roles.assign.privilegeConflictMsg.description": "ここで設定を一括更新すると、現在の個別の設定が上書きされます。", "xpack.spaces.management.spaceDetails.roles.assign.privilegeConflictMsg.title": "権限はこのスペースにのみ適用されます。", @@ -44647,7 +44633,6 @@ "xpack.spaces.management.spaceIdentifier.kibanaURLForSpaceIdentifierDescription": "作成した後はURL識別子を変更できません。", "xpack.spaces.management.spaceIdentifier.urlIdentifierTitle": "URL 識別子", "xpack.spaces.management.spacesGridPage.actionsColumnName": "アクション", - "xpack.spaces.management.spacesGridPage.allFeaturesEnabled": "すべての機能", "xpack.spaces.management.spacesGridPage.createSpaceButtonLabel": "スペースを作成", "xpack.spaces.management.spacesGridPage.currentSpaceMarkerText": "現在", "xpack.spaces.management.spacesGridPage.deleteActionDescription": "{spaceName}を削除", @@ -44657,13 +44642,10 @@ "xpack.spaces.management.spacesGridPage.editSpaceActionDescription": "{spaceName} を編集。", "xpack.spaces.management.spacesGridPage.editSpaceActionName": "編集", "xpack.spaces.management.spacesGridPage.errorTitle": "スペースの読み込みエラー", - "xpack.spaces.management.spacesGridPage.featuresColumnName": "表示される機能", "xpack.spaces.management.spacesGridPage.identifierColumnName": "識別子", "xpack.spaces.management.spacesGridPage.loadingTitle": "読み込み中…", - "xpack.spaces.management.spacesGridPage.noFeaturesEnabled": "表示されている機能がありません", "xpack.spaces.management.spacesGridPage.searchPlaceholder": "検索", "xpack.spaces.management.spacesGridPage.solutionColumnName": "ソリューションビュー", - "xpack.spaces.management.spacesGridPage.someFeaturesEnabled": "{enabledFeatureCount} / {totalFeatureCount}", "xpack.spaces.management.spacesGridPage.spaceColumnName": "スペース", "xpack.spaces.management.spacesGridPage.spacesTitle": "スペース", "xpack.spaces.management.spacesGridPage.switchSpaceActionDescription": "{spaceName}に切り替える", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index f81cada85ceaa..04822b6a4519a 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -2672,7 +2672,6 @@ "discover.docViews.logsOverview.title": "日志概览", "discover.docViews.table.scoreSortWarningTooltip": "要检索 _score 的值,必须按其筛选。", "discover.dropZoneTableLabel": "放置区域以将字段作为列添加到表中", - "discover.embeddable.inspectorRequestDataTitle": "数据", "discover.embeddable.inspectorRequestDescription": "此请求将查询 Elasticsearch 以获取搜索的数据。", "discover.embeddable.search.dataViewError": "缺少数据视图 {indexPatternId}", "discover.embeddable.search.displayName": "搜索", @@ -2961,6 +2960,8 @@ "esqlEditor.query.EnableWordWrapLabel": "在管道符上添加换行符", "esqlEditor.query.errorCount": "{count} 个{count, plural, other {错误}}", "esqlEditor.query.errorsTitle": "错误", + "esqlEditor.query.esqlQueriesCopy": "复制查询到剪贴板", + "esqlEditor.query.esqlQueriesListRun": "运行查询", "esqlEditor.query.expandLabel": "展开", "esqlEditor.query.feedback": "反馈", "esqlEditor.query.hideQueriesLabel": "隐藏最近查询", @@ -2970,8 +2971,6 @@ "esqlEditor.query.lineNumber": "第 {lineNumber} 行", "esqlEditor.query.querieshistory.error": "查询失败", "esqlEditor.query.querieshistory.success": "已成功运行查询", - "esqlEditor.query.esqlQueriesCopy": "复制查询到剪贴板", - "esqlEditor.query.esqlQueriesListRun": "运行查询", "esqlEditor.query.querieshistoryTable": "查询历史记录表", "esqlEditor.query.recentQueriesColumnLabel": "最近查询", "esqlEditor.query.refreshLabel": "刷新", @@ -31574,7 +31573,6 @@ "xpack.ml.trainedModels.modelsList.expandRow": "展开", "xpack.ml.trainedModels.modelsList.fetchDeletionErrorMessage": "{modelsCount, plural, other {# 个模型}}删除失败", "xpack.ml.trainedModels.modelsList.fetchFailedErrorMessage": "加载已训练模型时出错", - "xpack.ml.trainedModels.modelsList.fetchModelStatsErrorMessage": "加载已训练模型统计信息时出错", "xpack.ml.trainedModels.modelsList.forceStopDialog.cancelText": "取消", "xpack.ml.trainedModels.modelsList.forceStopDialog.confirmText": "停止点", "xpack.ml.trainedModels.modelsList.forceStopDialog.hasInferenceServicesWarning": "此模型由 _inference API 使用", @@ -36382,7 +36380,6 @@ "xpack.security.management.editRole.featureTable.cannotCustomizeSubFeaturesTooltip": "定制子功能权限为订阅功能。", "xpack.security.management.editRole.featureTable.customizeSubFeaturePrivilegesSwitchLabel": "定制子功能权限", "xpack.security.management.editRole.featureTable.featureAccordionSwitchLabel": "{grantedCount} / {featureCount} 项{featureCount, plural, other {功能}}已授予", - "xpack.security.management.editRole.featureTable.featureVisibilityTitle": "定制功能权限", "xpack.security.management.editRole.featureTable.managementCategoryHelpText": "可以在此菜单以外、在索引和集群权限中找到其他堆栈管理权限。", "xpack.security.management.editRole.featureTable.privilegeCustomizationTooltip": "功能已定制子功能权限。展开此行以了解更多信息。", "xpack.security.management.editRole.indexPrivilegeForm.clustersFormRowLabel": "远程集群", @@ -36443,18 +36440,10 @@ "xpack.security.management.editRole.spaceAwarePrivilegeForm.kibanaAdminTitle": "kibana_admin", "xpack.security.management.editRole.spacePrivilegeForm.basePrivilegeControlLegend": "所有功能的权限", "xpack.security.management.editRole.spacePrivilegeForm.cancelButton": "取消", - "xpack.security.management.editRole.spacePrivilegeForm.customizeFeaturePrivilegeDescription": "按功能提高权限级别。某些功能可能被工作区隐藏或受全局工作区权限影响。", - "xpack.security.management.editRole.spacePrivilegeForm.customizeFeaturePrivileges": "按功能定制", - "xpack.security.management.editRole.spacePrivilegeForm.featurePrivilegeSummaryDescription": "某些功能可能被工作区隐藏或受全局工作区权限影响。", - "xpack.security.management.editRole.spacePrivilegeForm.globalPrivilegeNotice": "这些权限将应用到所有当前和未来工作区。", - "xpack.security.management.editRole.spacePrivilegeForm.globalPrivilegeWarning": "创建全局权限可能会影响您的其他工作区权限。", - "xpack.security.management.editRole.spacePrivilegeForm.modalHeadline": "必须向此角色授权以下工作区的访问权限", - "xpack.security.management.editRole.spacePrivilegeForm.modalTitle": "将角色分配给工作区", "xpack.security.management.editRole.spacePrivilegeForm.privilegeSelectorFormHelpText": "分配您希望向此工作区的所有现有和未来功能授予的权限级别。", "xpack.security.management.editRole.spacePrivilegeForm.privilegeSelectorFormLabel": "所有功能的权限", "xpack.security.management.editRole.spacePrivilegeForm.spaceSelectorFormHelpText": "选择一个或多个希望分配权限的 Kibana 工作区。", "xpack.security.management.editRole.spacePrivilegeForm.spaceSelectorFormLabel": "工作区", - "xpack.security.management.editRole.spacePrivilegeForm.summaryOfFeaturePrivileges": "功能权限的摘要", "xpack.security.management.editRole.spacePrivilegeForm.supersededWarning": "声明的权限相对配置的全局权限有较小的宽容度。查看权限摘要以查看有效的权限。", "xpack.security.management.editRole.spacePrivilegeForm.supersededWarningTitle": "已由全局权限取代", "xpack.security.management.editRole.spacePrivilegeMatrix.globalSpaceName": "所有工作区", @@ -36587,9 +36576,7 @@ "xpack.security.management.editRoles.indexPrivilegeForm.grantedFieldsFormRowHelpText": "如果未授权任何字段,则分配到此角色的用户将无法查看此索引的任何数据。", "xpack.security.management.editRoles.indexPrivilegeForm.grantedFieldsFormRowLabel": "已授权字段", "xpack.security.management.editRoles.indexPrivilegeForm.grantFieldPrivilegesLabel": "授予对特定字段的访问权限", - "xpack.security.management.editRolespacePrivilegeForm.createGlobalPrivilegeButton": "创建全局权限", "xpack.security.management.editRolespacePrivilegeForm.createPrivilegeButton": "添加 Kibana 权限", - "xpack.security.management.editRolespacePrivilegeForm.updateGlobalPrivilegeButton": "更新全局权限", "xpack.security.management.editRolespacePrivilegeForm.updatePrivilegeButton": "更新工作区权限", "xpack.security.management.enabledBadge": "已启用", "xpack.security.management.readonlyBadge.text": "只读", @@ -44694,7 +44681,6 @@ "xpack.spaces.management.spaceDetails.footerActions.updateSpace": "应用更改", "xpack.spaces.management.spaceDetails.keepEditingButton": "保存然后离开", "xpack.spaces.management.spaceDetails.leavePageButton": "离开", - "xpack.spaces.management.spaceDetails.privilegeForm.heading": "定义给定角色在此工作区中应具有的权限。", "xpack.spaces.management.spaceDetails.roles.assign": "分配新角色", "xpack.spaces.management.spaceDetails.roles.assign.privilegeConflictMsg.description": "在此批量更新设置会覆盖当前的单个设置。", "xpack.spaces.management.spaceDetails.roles.assign.privilegeConflictMsg.title": "权限将仅适用于此工作区。", @@ -44745,7 +44731,6 @@ "xpack.spaces.management.spaceIdentifier.kibanaURLForSpaceIdentifierDescription": "创建后,将无法更改 URL 标识符。", "xpack.spaces.management.spaceIdentifier.urlIdentifierTitle": "URL 标识符", "xpack.spaces.management.spacesGridPage.actionsColumnName": "操作", - "xpack.spaces.management.spacesGridPage.allFeaturesEnabled": "所有功能", "xpack.spaces.management.spacesGridPage.createSpaceButtonLabel": "创建工作区", "xpack.spaces.management.spacesGridPage.currentSpaceMarkerText": "当前", "xpack.spaces.management.spacesGridPage.deleteActionDescription": "删除 {spaceName}", @@ -44755,13 +44740,10 @@ "xpack.spaces.management.spacesGridPage.editSpaceActionDescription": "编辑 {spaceName}。", "xpack.spaces.management.spacesGridPage.editSpaceActionName": "编辑", "xpack.spaces.management.spacesGridPage.errorTitle": "加载工作区时出错", - "xpack.spaces.management.spacesGridPage.featuresColumnName": "功能可见", "xpack.spaces.management.spacesGridPage.identifierColumnName": "标识符", "xpack.spaces.management.spacesGridPage.loadingTitle": "正在加载……", - "xpack.spaces.management.spacesGridPage.noFeaturesEnabled": "没有可见功能", "xpack.spaces.management.spacesGridPage.searchPlaceholder": "搜索", "xpack.spaces.management.spacesGridPage.solutionColumnName": "解决方案视图", - "xpack.spaces.management.spacesGridPage.someFeaturesEnabled": "{enabledFeatureCount}/{totalFeatureCount}", "xpack.spaces.management.spacesGridPage.spaceColumnName": "工作区", "xpack.spaces.management.spacesGridPage.spacesTitle": "工作区", "xpack.spaces.management.spacesGridPage.switchSpaceActionDescription": "切换到 {spaceName}", diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/migrate_system_indices/migrate_system_indices.test.tsx b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/migrate_system_indices/migrate_system_indices.test.tsx index ae3e184f9c96b..eee988e2ebb13 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/migrate_system_indices/migrate_system_indices.test.tsx +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/migrate_system_indices/migrate_system_indices.test.tsx @@ -107,6 +107,38 @@ describe('Overview - Migrate system indices', () => { expect(exists('viewSystemIndicesStateButton')).toBe(true); }); + test('handles confirmModal submission', async () => { + httpRequestsMockHelpers.setLoadSystemIndicesMigrationStatus({ + migration_status: 'MIGRATION_NEEDED', + }); + + testBed = await setupOverviewPage(httpSetup); + + const { exists, component, find } = testBed; + + component.update(); + + expect(exists('startSystemIndicesMigrationButton')).toBe(true); + await act(async () => { + find('startSystemIndicesMigrationButton').simulate('click'); + }); + component.update(); + + expect(exists('migrationConfirmModal')).toBe(true); + + const modal = document.body.querySelector('[data-test-subj="migrationConfirmModal"]'); + const confirmButton: HTMLButtonElement | null = modal!.querySelector( + '[data-test-subj="confirmModalConfirmButton"]' + ); + + await act(async () => { + confirmButton!.click(); + }); + component.update(); + + expect(exists('migrationConfirmModal')).toBe(false); + }); + test('Handles errors when migrating', async () => { httpRequestsMockHelpers.setLoadSystemIndicesMigrationStatus({ migration_status: 'MIGRATION_NEEDED', @@ -126,6 +158,16 @@ describe('Overview - Migrate system indices', () => { component.update(); + const modal = document.body.querySelector('[data-test-subj="migrationConfirmModal"]'); + const confirmButton: HTMLButtonElement | null = modal!.querySelector( + '[data-test-subj="confirmModalConfirmButton"]' + ); + + await act(async () => { + confirmButton!.click(); + }); + component.update(); + // Error is displayed expect(exists('startSystemIndicesMigrationCalloutError')).toBe(true); // CTA is enabled diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/overview/migrate_system_indices/migrate_system_indices.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/overview/migrate_system_indices/migrate_system_indices.tsx index 47ca25bda31ac..3d2599e26bde2 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/overview/migrate_system_indices/migrate_system_indices.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/overview/migrate_system_indices/migrate_system_indices.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { FunctionComponent, useEffect } from 'react'; +import React, { FunctionComponent, useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -20,6 +20,7 @@ import { EuiFlexItem, EuiCode, EuiLink, + EuiConfirmModal, } from '@elastic/eui'; import type { EuiStepProps } from '@elastic/eui/src/components/steps/step'; @@ -123,10 +124,55 @@ const i18nTexts = { }, }; +const ConfirmModal: React.FC<{ + onCancel: () => void; + onConfirm: () => void; +}> = ({ onCancel, onConfirm }) => ( + + {i18n.translate('xpack.upgradeAssistant.overview.systemIndices.confirmModal.description', { + defaultMessage: 'Migrating system indices may lead to downtime while they are reindexed.', + })} + +); + const MigrateSystemIndicesStep: FunctionComponent = ({ setIsComplete }) => { const { beginSystemIndicesMigration, startMigrationStatus, migrationStatus, setShowFlyout } = useMigrateSystemIndices(); + const [isModalVisible, setIsModalVisible] = useState(false); + + const openMigrationModal = () => { + setIsModalVisible(true); + }; + const onCancel = () => { + setIsModalVisible(false); + }; + + const confirmMigrationAction = () => { + beginSystemIndicesMigration(); + setIsModalVisible(false); + }; + useEffect(() => { setIsComplete(migrationStatus.data?.migration_status === 'NO_MIGRATION_NEEDED'); // Depending upon setIsComplete would create an infinite loop. @@ -206,12 +252,14 @@ const MigrateSystemIndicesStep: FunctionComponent = ({ setIsComplete }) = )} + {isModalVisible && } + {isMigrating ? i18nTexts.inProgressButtonLabel : i18nTexts.startButtonLabel} diff --git a/x-pack/plugins/upgrade_assistant/server/lib/__fixtures__/fake_deprecations.json b/x-pack/plugins/upgrade_assistant/server/lib/__fixtures__/fake_deprecations.json index f53e4176096cc..953223902159d 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/__fixtures__/fake_deprecations.json +++ b/x-pack/plugins/upgrade_assistant/server/lib/__fixtures__/fake_deprecations.json @@ -147,6 +147,15 @@ "details": "[[type: tweet, field: liked]]", "resolve_during_rolling_upgrade": false } + ], + "myindex": [ + { + "level": "critical", + "message": "Old index with a compatibility version < 8.0", + "url": "https://www.elastic.co/guide/en/elasticsearch/reference/master/breaking-changes-9.0.html", + "details": "This index has version: 7.17.25", + "resolve_during_rolling_upgrade": false + } ] }, "data_streams": { diff --git a/x-pack/plugins/upgrade_assistant/server/lib/es_deprecations_status/__snapshots__/index.test.ts.snap b/x-pack/plugins/upgrade_assistant/server/lib/es_deprecations_status/__snapshots__/index.test.ts.snap index b0d5d99a37497..7f6b7826da8a8 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/es_deprecations_status/__snapshots__/index.test.ts.snap +++ b/x-pack/plugins/upgrade_assistant/server/lib/es_deprecations_status/__snapshots__/index.test.ts.snap @@ -158,6 +158,19 @@ Object { "type": "index_settings", "url": "https://www.elastic.co/guide/en/elasticsearch/reference/6.0/breaking_60_mappings_changes.html#_coercion_of_boolean_fields", }, + Object { + "correctiveAction": Object { + "blockerForReindexing": undefined, + "type": "reindex", + }, + "details": "This index has version: 7.17.25", + "index": "myindex", + "isCritical": true, + "message": "Old index with a compatibility version < 8.0", + "resolveDuringUpgrade": false, + "type": "index_settings", + "url": "https://www.elastic.co/guide/en/elasticsearch/reference/master/breaking-changes-9.0.html", + }, Object { "correctiveAction": undefined, "details": "This data stream has backing indices that were created before Elasticsearch 8.0.0", @@ -169,6 +182,6 @@ Object { "url": "https://www.elastic.co/guide/en/elasticsearch/reference/master/breaking-changes-9.0.html", }, ], - "totalCriticalDeprecations": 5, + "totalCriticalDeprecations": 6, } `; diff --git a/x-pack/plugins/upgrade_assistant/server/lib/es_deprecations_status/get_corrective_actions.ts b/x-pack/plugins/upgrade_assistant/server/lib/es_deprecations_status/get_corrective_actions.ts index be696acb18095..d971625c818ea 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/es_deprecations_status/get_corrective_actions.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/es_deprecations_status/get_corrective_actions.ts @@ -20,6 +20,12 @@ type EsMetadata = Actions & { [key: string]: string; }; +// TODO(jloleysens): Replace these regexes once this issue is addressed https://github.com/elastic/elasticsearch/issues/118062 +const ES_INDEX_MESSAGES_REQIURING_REINDEX = [ + /Index created before/, + /index with a compatibility version \ action.action_type === 'remove_settings' && typeof indexName === 'undefined' ); - const requiresReindexAction = /Index created before/.test(message); + const requiresReindexAction = ES_INDEX_MESSAGES_REQIURING_REINDEX.some((regexp) => + regexp.test(message) + ); const requiresIndexSettingsAction = Boolean(indexSettingDeprecation); const requiresClusterSettingsAction = Boolean(clusterSettingDeprecation); const requiresMlAction = /[Mm]odel snapshot/.test(message); diff --git a/x-pack/test/accessibility/apps/group1/roles.ts b/x-pack/test/accessibility/apps/group1/roles.ts index cf798bcb853f5..8a9fe187ba35b 100644 --- a/x-pack/test/accessibility/apps/group1/roles.ts +++ b/x-pack/test/accessibility/apps/group1/roles.ts @@ -14,6 +14,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const a11y = getService('a11y'); const esArchiver = getService('esArchiver'); const testSubjects = getService('testSubjects'); + const find = getService('find'); const retry = getService('retry'); const kibanaServer = getService('kibanaServer'); @@ -82,6 +83,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('a11y test for customize feature privilege', async () => { + await testSubjects.click('spaceSelectorComboBox'); + const globalSpaceOption = await find.byCssSelector(`#spaceOption_\\*`); + await globalSpaceOption.click(); await testSubjects.click('featureCategory_kibana'); await a11y.testAppSnapshot(); await testSubjects.click('cancelSpacePrivilegeButton'); diff --git a/x-pack/test/alerting_api_integration/observability/synthetics/custom_status_rule.ts b/x-pack/test/alerting_api_integration/observability/synthetics/custom_status_rule.ts index 6600054e03ab9..ccc6d37112f01 100644 --- a/x-pack/test/alerting_api_integration/observability/synthetics/custom_status_rule.ts +++ b/x-pack/test/alerting_api_integration/observability/synthetics/custom_status_rule.ts @@ -22,7 +22,8 @@ export default function ({ getService }: FtrProviderContext) { const esDeleteAllIndices = getService('esDeleteAllIndices'); const supertest = getService('supertest'); - describe('SyntheticsCustomStatusRule', () => { + // Failing: See https://github.com/elastic/kibana/issues/202337 + describe.skip('SyntheticsCustomStatusRule', () => { const SYNTHETICS_RULE_ALERT_INDEX = '.alerts-observability.uptime.alerts-default'; before(async () => { diff --git a/x-pack/test/api_integration/apis/features/features/features.ts b/x-pack/test/api_integration/apis/features/features/features.ts index 4ded1782c9086..7541b2171b2cb 100644 --- a/x-pack/test/api_integration/apis/features/features/features.ts +++ b/x-pack/test/api_integration/apis/features/features/features.ts @@ -136,6 +136,7 @@ export default function ({ getService }: FtrProviderContext) { 'securitySolutionCasesV2', 'fleet', 'fleetv2', + 'entityManager', ].sort() ); }); @@ -186,6 +187,7 @@ export default function ({ getService }: FtrProviderContext) { 'securitySolutionCasesV2', 'fleet', 'fleetv2', + 'entityManager', ]; const features = body.filter( 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 a3953a87b82b2..654f55c7e1254 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 @@ -16,61 +16,27 @@ export default ({ getService }: FtrProviderContext) => { const esDeleteAllIndices = getService('esDeleteAllIndices'); describe('GET trained_models', () => { - let testModelIds: string[] = []; - before(async () => { await ml.api.initSavedObjects(); await ml.testResources.setKibanaTimeZoneToUTC(); - testModelIds = await ml.api.createTestTrainedModels('regression', 5, true); - await ml.api.createModelAlias('dfa_regression_model_n_0', 'dfa_regression_model_alias'); - await ml.api.createIngestPipeline('dfa_regression_model_alias'); - - // Creating an indices that are tied to modelId: dfa_regression_model_n_1 - await ml.api.createIndex(`user-index_dfa_regression_model_n_1`, undefined, { - index: { default_pipeline: `pipeline_dfa_regression_model_n_1` }, - }); + await ml.api.createTestTrainedModels('regression', 5, true); }); after(async () => { 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.testResources.cleanMLSavedObjects(); await ml.api.cleanMlIndices(); }); - it('returns all trained models with associated pipelines including aliases', async () => { + it('returns all trained models', async () => { const { body, status } = await supertest - .get(`/internal/ml/trained_models?with_pipelines=true`) + .get(`/internal/ml/trained_models`) .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) .set(getCommonRequestHeader('1')); ml.api.assertResponseStatusCode(200, status, body); // Created models + system model expect(body.length).to.eql(6); - - const sampleModel = body.find((v: any) => v.model_id === 'dfa_regression_model_n_0'); - - expect(Object.keys(sampleModel.pipelines).length).to.eql(2); - }); - - it('returns models without pipeline in case user does not have required permission', async () => { - const { body, status } = await supertest - .get(`/internal/ml/trained_models?with_pipelines=true&with_indices=true`) - .auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER)) - .set(getCommonRequestHeader('1')); - ml.api.assertResponseStatusCode(200, status, body); - - // Created models + system model - expect(body.length).to.eql(6); - const sampleModel = body.find((v: any) => v.model_id === 'dfa_regression_model_n_0'); - - expect(sampleModel.pipelines).to.eql(undefined); }); it('returns trained model by id', async () => { @@ -84,58 +50,6 @@ export default ({ getService }: FtrProviderContext) => { const sampleModel = body[0]; expect(sampleModel.model_id).to.eql('dfa_regression_model_n_1'); - expect(sampleModel.pipelines).to.eql(undefined); - expect(sampleModel.indices).to.eql(undefined); - }); - - it('returns trained model by id with_pipelines=true,with_indices=false', async () => { - const { body, status } = await supertest - .get( - `/internal/ml/trained_models/dfa_regression_model_n_1?with_pipelines=true&with_indices=false` - ) - .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) - .set(getCommonRequestHeader('1')); - ml.api.assertResponseStatusCode(200, status, body); - - expect(body.length).to.eql(1); - const sampleModel = body[0]; - - expect(sampleModel.model_id).to.eql('dfa_regression_model_n_1'); - expect(Object.keys(sampleModel.pipelines).length).to.eql( - 1, - `Expected number of pipelines for dfa_regression_model_n_1 to be ${1} (got ${ - Object.keys(sampleModel.pipelines).length - })` - ); - expect(sampleModel.indices).to.eql( - undefined, - `Expected indices for dfa_regression_model_n_1 to be undefined (got ${sampleModel.indices})` - ); - }); - - it('returns trained model by id with_pipelines=true,with_indices=true', async () => { - const { body, status } = await supertest - .get( - `/internal/ml/trained_models/dfa_regression_model_n_1?with_pipelines=true&with_indices=true` - ) - .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) - .set(getCommonRequestHeader('1')); - ml.api.assertResponseStatusCode(200, status, body); - - const sampleModel = body[0]; - expect(sampleModel.model_id).to.eql('dfa_regression_model_n_1'); - expect(Object.keys(sampleModel.pipelines).length).to.eql( - 1, - `Expected number of pipelines for dfa_regression_model_n_1 to be ${1} (got ${ - Object.keys(sampleModel.pipelines).length - })` - ); - expect(sampleModel.indices.length).to.eql( - 1, - `Expected number of indices for dfa_regression_model_n_1 to be ${1} (got ${ - sampleModel.indices.length - })` - ); }); it('returns 404 if requested trained model does not exist', async () => { diff --git a/x-pack/test/api_integration/apis/ml/trained_models/index.ts b/x-pack/test/api_integration/apis/ml/trained_models/index.ts index c9bf98545e2b4..319899ec9a693 100644 --- a/x-pack/test/api_integration/apis/ml/trained_models/index.ts +++ b/x-pack/test/api_integration/apis/ml/trained_models/index.ts @@ -9,6 +9,7 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('trained models', function () { + loadTestFile(require.resolve('./trained_models_list')); loadTestFile(require.resolve('./get_models')); loadTestFile(require.resolve('./get_model_stats')); loadTestFile(require.resolve('./get_model_pipelines')); diff --git a/x-pack/test/api_integration/apis/ml/trained_models/trained_models_list.ts b/x-pack/test/api_integration/apis/ml/trained_models/trained_models_list.ts new file mode 100644 index 0000000000000..1feac44b13ca8 --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/trained_models/trained_models_list.ts @@ -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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { USER } from '../../../../functional/services/ml/security_common'; +import { getCommonRequestHeader } from '../../../../functional/services/ml/common_api'; + +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertestWithoutAuth'); + const ml = getService('ml'); + const esDeleteAllIndices = getService('esDeleteAllIndices'); + + describe('GET trained_models_list', () => { + let testModelIds: string[] = []; + + before(async () => { + await ml.api.initSavedObjects(); + await ml.testResources.setKibanaTimeZoneToUTC(); + testModelIds = await ml.api.createTestTrainedModels('regression', 5, true); + await ml.api.createModelAlias('dfa_regression_model_n_0', 'dfa_regression_model_alias'); + await ml.api.createIngestPipeline('dfa_regression_model_alias'); + + // Creating an index that is tied to modelId: dfa_regression_model_n_1 + await ml.api.createIndex(`user-index_dfa_regression_model_n_1`, undefined, { + index: { default_pipeline: `pipeline_dfa_regression_model_n_1` }, + }); + }); + + after(async () => { + 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.testResources.cleanMLSavedObjects(); + await ml.api.cleanMlIndices(); + }); + + it('returns a formatted list of trained model with stats, associated pipelines and indices', async () => { + const { body, status } = await supertest + .get(`/internal/ml/trained_models_list`) + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(getCommonRequestHeader('1')); + ml.api.assertResponseStatusCode(200, status, body); + + // Created models + system model + model downloads + expect(body.length).to.eql(10); + + const dfaRegressionN0 = body.find((v: any) => v.model_id === 'dfa_regression_model_n_0'); + + expect(Object.keys(dfaRegressionN0.pipelines).length).to.eql(2); + + const dfaRegressionN1 = body.find((v: any) => v.model_id === 'dfa_regression_model_n_1'); + expect(Object.keys(dfaRegressionN1.pipelines).length).to.eql( + 1, + `Expected number of pipelines for dfa_regression_model_n_1 to be ${1} (got ${ + Object.keys(dfaRegressionN1.pipelines).length + })` + ); + expect(dfaRegressionN1.indices.length).to.eql( + 1, + `Expected number of indices for dfa_regression_model_n_1 to be ${1} (got ${ + dfaRegressionN1.indices.length + })` + ); + }); + + it('returns models without pipeline in case user does not have required permission', async () => { + const { body, status } = await supertest + .get(`/internal/ml/trained_models_list`) + .auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER)) + .set(getCommonRequestHeader('1')); + ml.api.assertResponseStatusCode(200, status, body); + + expect(body.length).to.eql(10); + const sampleModel = body.find((v: any) => v.model_id === 'dfa_regression_model_n_0'); + expect(sampleModel.pipelines).to.eql(undefined); + }); + + it('returns an error for unauthorized user', async () => { + const { body, status } = await supertest + .get(`/internal/ml/trained_models_list`) + .auth(USER.ML_UNAUTHORIZED, ml.securityCommon.getPasswordForUser(USER.ML_UNAUTHORIZED)) + .set(getCommonRequestHeader('1')); + ml.api.assertResponseStatusCode(403, status, body); + }); + }); +}; diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index dc84f6c33d200..0d34480910a25 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -180,6 +180,7 @@ export default function ({ getService }: FtrProviderContext) { guidedOnboardingFeature: ['all', 'read', 'minimal_all', 'minimal_read'], aiAssistantManagementSelection: ['all', 'read', 'minimal_all', 'minimal_read'], inventory: ['all', 'read', 'minimal_all', 'minimal_read'], + entityManager: ['all', 'read', 'minimal_all', 'minimal_read'], }, reserved: ['fleet-setup', 'ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'], }; diff --git a/x-pack/test/api_integration/apis/security/privileges_basic.ts b/x-pack/test/api_integration/apis/security/privileges_basic.ts index 2bbd70fcf730d..94cfe21cbbd02 100644 --- a/x-pack/test/api_integration/apis/security/privileges_basic.ts +++ b/x-pack/test/api_integration/apis/security/privileges_basic.ts @@ -63,6 +63,7 @@ export default function ({ getService }: FtrProviderContext) { aiAssistantManagementSelection: ['all', 'read', 'minimal_all', 'minimal_read'], inventory: ['all', 'read', 'minimal_all', 'minimal_read'], dataQuality: ['all', 'read', 'minimal_all', 'minimal_read'], + entityManager: ['all', 'read', 'minimal_all', 'minimal_read'], }, global: ['all', 'read'], space: ['all', 'read'], @@ -265,6 +266,7 @@ export default function ({ getService }: FtrProviderContext) { guidedOnboardingFeature: ['all', 'read', 'minimal_all', 'minimal_read'], aiAssistantManagementSelection: ['all', 'read', 'minimal_all', 'minimal_read'], inventory: ['all', 'read', 'minimal_all', 'minimal_read'], + entityManager: ['all', 'read', 'minimal_all', 'minimal_read'], }, reserved: ['fleet-setup', 'ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'], }; diff --git a/x-pack/test/api_integration/apis/streams/full_flow.ts b/x-pack/test/api_integration/apis/streams/full_flow.ts index 03c0cc9e0e219..aad931ab11816 100644 --- a/x-pack/test/api_integration/apis/streams/full_flow.ts +++ b/x-pack/test/api_integration/apis/streams/full_flow.ts @@ -135,6 +135,137 @@ export default function ({ getService }: FtrProviderContext) { log: { level: 'info', logger: 'nginx' }, }); }); + + it('Fork logs to logs.nginx.error with invalid condition', async () => { + const body = { + stream: { + id: 'logs.nginx.error', + fields: [], + processing: [], + }, + condition: { field: 'log', operator: 'eq', value: 'error' }, + }; + const response = await forkStream(supertest, 'logs.nginx', body); + expect(response).to.have.property('acknowledged', true); + }); + + it('Index an Nginx error log message, should goto logs.nginx.error but fails', async () => { + const doc = { + '@timestamp': '2024-01-01T00:00:20.000Z', + message: JSON.stringify({ + 'log.level': 'error', + 'log.logger': 'nginx', + message: 'test', + }), + }; + const response = await indexDocument(esClient, 'logs', doc); + expect(response.result).to.eql('created'); + + await waitForDocumentInIndex({ + esClient, + indexName: 'logs.nginx', + retryService, + logger, + docCountTarget: 2, + }); + + const result = await fetchDocument(esClient, 'logs.nginx', response._id); + expect(result._index).to.match(/^\.ds\-logs.nginx-.*/); + expect(result._source).to.eql({ + '@timestamp': '2024-01-01T00:00:20.000Z', + message: 'test', + log: { level: 'error', logger: 'nginx' }, + }); + }); + + it('Fork logs to logs.number-test', async () => { + const body = { + stream: { + id: 'logs.number-test', + fields: [], + processing: [], + }, + condition: { field: 'code', operator: 'gte', value: '500' }, + }; + const response = await forkStream(supertest, 'logs', body); + expect(response).to.have.property('acknowledged', true); + }); + + it('Index documents with numbers and strings for logs.number-test condition', async () => { + const doc1 = { + '@timestamp': '2024-01-01T00:00:20.000Z', + message: JSON.stringify({ + code: '500', + message: 'test', + }), + }; + const doc2 = { + '@timestamp': '2024-01-01T00:00:20.000Z', + message: JSON.stringify({ + code: 500, + message: 'test', + }), + }; + const response1 = await indexDocument(esClient, 'logs', doc1); + expect(response1.result).to.eql('created'); + const response2 = await indexDocument(esClient, 'logs', doc2); + expect(response2.result).to.eql('created'); + + await waitForDocumentInIndex({ + esClient, + indexName: 'logs.number-test', + retryService, + logger, + docCountTarget: 2, + retries: 20, + }); + }); + + it('Fork logs to logs.string-test', async () => { + const body = { + stream: { + id: 'logs.string-test', + fields: [], + processing: [], + }, + condition: { + or: [ + { field: 'message', operator: 'contains', value: '500' }, + { field: 'message', operator: 'contains', value: 400 }, + ], + }, + }; + const response = await forkStream(supertest, 'logs', body); + expect(response).to.have.property('acknowledged', true); + }); + + it('Index documents with numbers and strings for logs.string-test condition', async () => { + const doc1 = { + '@timestamp': '2024-01-01T00:00:20.000Z', + message: JSON.stringify({ + message: 'status_code: 500', + }), + }; + const doc2 = { + '@timestamp': '2024-01-01T00:00:20.000Z', + message: JSON.stringify({ + message: 'status_code: 400', + }), + }; + const response1 = await indexDocument(esClient, 'logs', doc1); + expect(response1.result).to.eql('created'); + const response2 = await indexDocument(esClient, 'logs', doc2); + expect(response2.result).to.eql('created'); + + await waitForDocumentInIndex({ + esClient, + indexName: 'logs.string-test', + retryService, + logger, + docCountTarget: 2, + retries: 20, + }); + }); }); }); } diff --git a/x-pack/test/api_integration/services/security_solution_api.gen.ts b/x-pack/test/api_integration/services/security_solution_api.gen.ts index 17163bcdc38f2..a67ba14c2a8f2 100644 --- a/x-pack/test/api_integration/services/security_solution_api.gen.ts +++ b/x-pack/test/api_integration/services/security_solution_api.gen.ts @@ -32,7 +32,10 @@ import { CopyTimelineRequestBodyInput } from '@kbn/security-solution-plugin/comm import { CreateAlertsMigrationRequestBodyInput } from '@kbn/security-solution-plugin/common/api/detection_engine/signals_migration/create_signals_migration/create_signals_migration.gen'; import { CreateAssetCriticalityRecordRequestBodyInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/asset_criticality/create_asset_criticality.gen'; import { CreateRuleRequestBodyInput } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_management/crud/create_rule/create_rule_route.gen'; -import { CreateRuleMigrationRequestBodyInput } from '@kbn/security-solution-plugin/common/siem_migrations/model/api/rules/rule_migration.gen'; +import { + CreateRuleMigrationRequestParamsInput, + CreateRuleMigrationRequestBodyInput, +} from '@kbn/security-solution-plugin/common/siem_migrations/model/api/rules/rule_migration.gen'; import { CreateTimelinesRequestBodyInput } from '@kbn/security-solution-plugin/common/api/timeline/create_timelines/create_timelines_route.gen'; import { CreateUpdateProtectionUpdatesNoteRequestParamsInput, @@ -369,7 +372,12 @@ Migrations are initiated per index. While the process is neither destructive nor */ createRuleMigration(props: CreateRuleMigrationProps, kibanaSpace: string = 'default') { return supertest - .post(routeWithNamespace('/internal/siem_migrations/rules', kibanaSpace)) + .post( + routeWithNamespace( + replaceParams('/internal/siem_migrations/rules/{migration_id}', props.params), + kibanaSpace + ) + ) .set('kbn-xsrf', 'true') .set(ELASTIC_HTTP_VERSION_HEADER, '1') .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') @@ -1590,6 +1598,7 @@ export interface CreateRuleProps { body: CreateRuleRequestBodyInput; } export interface CreateRuleMigrationProps { + params: CreateRuleMigrationRequestParamsInput; body: CreateRuleMigrationRequestBodyInput; } export interface CreateTimelinesProps { diff --git a/x-pack/test/apm_api_integration/tests/transactions/__snapshots__/breakdown.spec.snap b/x-pack/test/apm_api_integration/tests/transactions/__snapshots__/breakdown.spec.snap index d8e3d13525cc9..68495907b3c7f 100644 --- a/x-pack/test/apm_api_integration/tests/transactions/__snapshots__/breakdown.spec.snap +++ b/x-pack/test/apm_api_integration/tests/transactions/__snapshots__/breakdown.spec.snap @@ -20,39 +20,39 @@ Object { }, Object { "x": 1627973490000, - "y": 0.0833333333333333, + "y": 0.08333333, }, Object { "x": 1627973520000, - "y": 0.628571428571429, + "y": 0.62857143, }, Object { "x": 1627973550000, - "y": 0.733333333333333, + "y": 0.73333333, }, Object { "x": 1627973580000, - "y": 0.490909090909091, + "y": 0.49090909, }, Object { "x": 1627973610000, - "y": 0.135593220338983, + "y": 0.13559322, }, Object { "x": 1627973640000, - "y": 0.692307692307692, + "y": 0.69230769, }, Object { "x": 1627973670000, - "y": 0.097165991902834, + "y": 0.09716599, }, Object { "x": 1627973700000, - "y": 0.303030303030303, + "y": 0.3030303, }, Object { "x": 1627973730000, - "y": 0.153846153846154, + "y": 0.15384615, }, Object { "x": 1627973760000, @@ -60,11 +60,11 @@ Object { }, Object { "x": 1627973790000, - "y": 0.0857142857142857, + "y": 0.08571429, }, Object { "x": 1627973820000, - "y": 0.155172413793103, + "y": 0.15517241, }, Object { "x": 1627973850000, @@ -76,31 +76,31 @@ Object { }, Object { "x": 1627973910000, - "y": 0.142857142857143, + "y": 0.14285714, }, Object { "x": 1627973940000, - "y": 0.0810810810810811, + "y": 0.08108108, }, Object { "x": 1627973970000, - "y": 0.473684210526316, + "y": 0.47368421, }, Object { "x": 1627974000000, - "y": 0.633333333333333, + "y": 0.63333333, }, Object { "x": 1627974030000, - "y": 0.111111111111111, + "y": 0.11111111, }, Object { "x": 1627974060000, - "y": 0.467741935483871, + "y": 0.46774194, }, Object { "x": 1627974090000, - "y": 0.129496402877698, + "y": 0.1294964, }, Object { "x": 1627974120000, @@ -108,19 +108,19 @@ Object { }, Object { "x": 1627974150000, - "y": 0.30188679245283, + "y": 0.30188679, }, Object { "x": 1627974180000, - "y": 0.464285714285714, + "y": 0.46428571, }, Object { "x": 1627974210000, - "y": 0.123674911660777, + "y": 0.12367491, }, Object { "x": 1627974240000, - "y": 0.384615384615385, + "y": 0.38461538, }, Object { "x": 1627974270000, @@ -128,19 +128,19 @@ Object { }, Object { "x": 1627974300000, - "y": 0.222222222222222, + "y": 0.22222222, }, Object { "x": 1627974330000, - "y": 0.00248447204968944, + "y": 0.00248447, }, Object { "x": 1627974360000, - "y": 0.0645161290322581, + "y": 0.06451613, }, Object { "x": 1627974390000, - "y": 0.569444444444444, + "y": 0.56944444, }, Object { "x": 1627974420000, @@ -152,19 +152,19 @@ Object { }, Object { "x": 1627974480000, - "y": 0.143646408839779, + "y": 0.14364641, }, Object { "x": 1627974510000, - "y": 0.298850574712644, + "y": 0.29885057, }, Object { "x": 1627974540000, - "y": 0.111111111111111, + "y": 0.11111111, }, Object { "x": 1627974570000, - "y": 0.267857142857143, + "y": 0.26785714, }, Object { "x": 1627974600000, @@ -172,11 +172,11 @@ Object { }, Object { "x": 1627974630000, - "y": 0.185185185185185, + "y": 0.18518519, }, Object { "x": 1627974660000, - "y": 0.0973451327433628, + "y": 0.09734513, }, Object { "x": 1627974690000, @@ -184,15 +184,15 @@ Object { }, Object { "x": 1627974720000, - "y": 0.480769230769231, + "y": 0.48076923, }, Object { "x": 1627974750000, - "y": 0.383458646616541, + "y": 0.38345865, }, Object { "x": 1627974780000, - "y": 0.153061224489796, + "y": 0.15306122, }, Object { "x": 1627974810000, @@ -200,23 +200,23 @@ Object { }, Object { "x": 1627974840000, - "y": 0.739130434782609, + "y": 0.73913043, }, Object { "x": 1627974870000, - "y": 0.260869565217391, + "y": 0.26086957, }, Object { "x": 1627974900000, - "y": 0.605633802816901, + "y": 0.6056338, }, Object { "x": 1627974930000, - "y": 0.326923076923077, + "y": 0.32692308, }, Object { "x": 1627974960000, - "y": 0.103448275862069, + "y": 0.10344828, }, Object { "x": 1627974990000, @@ -228,19 +228,19 @@ Object { }, Object { "x": 1627975050000, - "y": 0.114285714285714, + "y": 0.11428571, }, Object { "x": 1627975080000, - "y": 0.238636363636364, + "y": 0.23863636, }, Object { "x": 1627975110000, - "y": 0.684210526315789, + "y": 0.68421053, }, Object { "x": 1627975140000, - "y": 0.368421052631579, + "y": 0.36842105, }, Object { "x": 1627975170000, @@ -248,7 +248,7 @@ Object { }, Object { "x": 1627975200000, - "y": 0.235294117647059, + "y": 0.23529412, }, ], "hideLegend": false, @@ -265,7 +265,7 @@ Object { }, Object { "x": 1627973430000, - "y": 0.790322580645161, + "y": 0.79032258, }, Object { "x": 1627973460000, @@ -273,11 +273,11 @@ Object { }, Object { "x": 1627973490000, - "y": 0.876543209876543, + "y": 0.87654321, }, Object { "x": 1627973520000, - "y": 0.114285714285714, + "y": 0.11428571, }, Object { "x": 1627973550000, @@ -285,11 +285,11 @@ Object { }, Object { "x": 1627973580000, - "y": 0.309090909090909, + "y": 0.30909091, }, Object { "x": 1627973610000, - "y": 0.864406779661017, + "y": 0.86440678, }, Object { "x": 1627973640000, @@ -297,15 +297,15 @@ Object { }, Object { "x": 1627973670000, - "y": 0.862348178137652, + "y": 0.86234818, }, Object { "x": 1627973700000, - "y": 0.636363636363636, + "y": 0.63636364, }, Object { "x": 1627973730000, - "y": 0.846153846153846, + "y": 0.84615385, }, Object { "x": 1627973760000, @@ -313,11 +313,11 @@ Object { }, Object { "x": 1627973790000, - "y": 0.914285714285714, + "y": 0.91428571, }, Object { "x": 1627973820000, - "y": 0.844827586206897, + "y": 0.84482759, }, Object { "x": 1627973850000, @@ -329,11 +329,11 @@ Object { }, Object { "x": 1627973910000, - "y": 0.857142857142857, + "y": 0.85714286, }, Object { "x": 1627973940000, - "y": 0.918918918918919, + "y": 0.91891892, }, Object { "x": 1627973970000, @@ -345,15 +345,15 @@ Object { }, Object { "x": 1627974030000, - "y": 0.872222222222222, + "y": 0.87222222, }, Object { "x": 1627974060000, - "y": 0.290322580645161, + "y": 0.29032258, }, Object { "x": 1627974090000, - "y": 0.848920863309353, + "y": 0.84892086, }, Object { "x": 1627974120000, @@ -361,19 +361,19 @@ Object { }, Object { "x": 1627974150000, - "y": 0.613207547169811, + "y": 0.61320755, }, Object { "x": 1627974180000, - "y": 0.339285714285714, + "y": 0.33928571, }, Object { "x": 1627974210000, - "y": 0.787985865724382, + "y": 0.78798587, }, Object { "x": 1627974240000, - "y": 0.557692307692308, + "y": 0.55769231, }, Object { "x": 1627974270000, @@ -381,15 +381,15 @@ Object { }, Object { "x": 1627974300000, - "y": 0.685990338164251, + "y": 0.68599034, }, Object { "x": 1627974330000, - "y": 0.995807453416149, + "y": 0.99580745, }, Object { "x": 1627974360000, - "y": 0.935483870967742, + "y": 0.93548387, }, Object { "x": 1627974390000, @@ -405,15 +405,15 @@ Object { }, Object { "x": 1627974480000, - "y": 0.831491712707182, + "y": 0.83149171, }, Object { "x": 1627974510000, - "y": 0.505747126436782, + "y": 0.50574713, }, Object { "x": 1627974540000, - "y": 0.888888888888889, + "y": 0.88888889, }, Object { "x": 1627974570000, @@ -425,11 +425,11 @@ Object { }, Object { "x": 1627974630000, - "y": 0.71957671957672, + "y": 0.71957672, }, Object { "x": 1627974660000, - "y": 0.867256637168142, + "y": 0.86725664, }, Object { "x": 1627974690000, @@ -437,15 +437,15 @@ Object { }, Object { "x": 1627974720000, - "y": 0.288461538461538, + "y": 0.28846154, }, Object { "x": 1627974750000, - "y": 0.406015037593985, + "y": 0.40601504, }, Object { "x": 1627974780000, - "y": 0.775510204081633, + "y": 0.7755102, }, Object { "x": 1627974810000, @@ -457,7 +457,7 @@ Object { }, Object { "x": 1627974870000, - "y": 0.623188405797101, + "y": 0.62318841, }, Object { "x": 1627974900000, @@ -465,11 +465,11 @@ Object { }, Object { "x": 1627974930000, - "y": 0.423076923076923, + "y": 0.42307692, }, Object { "x": 1627974960000, - "y": 0.896551724137931, + "y": 0.89655172, }, Object { "x": 1627974990000, @@ -481,11 +481,11 @@ Object { }, Object { "x": 1627975050000, - "y": 0.885714285714286, + "y": 0.88571429, }, Object { "x": 1627975080000, - "y": 0.681818181818182, + "y": 0.68181818, }, Object { "x": 1627975110000, @@ -493,7 +493,7 @@ Object { }, Object { "x": 1627975140000, - "y": 0.456140350877193, + "y": 0.45614035, }, Object { "x": 1627975170000, @@ -501,7 +501,7 @@ Object { }, Object { "x": 1627975200000, - "y": 0.541176470588235, + "y": 0.54117647, }, ], "hideLegend": false, @@ -518,7 +518,7 @@ Object { }, Object { "x": 1627973430000, - "y": 0.0806451612903226, + "y": 0.08064516, }, Object { "x": 1627973460000, @@ -526,19 +526,19 @@ Object { }, Object { "x": 1627973490000, - "y": 0.0277777777777778, + "y": 0.02777778, }, Object { "x": 1627973520000, - "y": 0.171428571428571, + "y": 0.17142857, }, Object { "x": 1627973550000, - "y": 0.266666666666667, + "y": 0.26666667, }, Object { "x": 1627973580000, - "y": 0.181818181818182, + "y": 0.18181818, }, Object { "x": 1627973610000, @@ -546,15 +546,15 @@ Object { }, Object { "x": 1627973640000, - "y": 0.307692307692308, + "y": 0.30769231, }, Object { "x": 1627973670000, - "y": 0.0364372469635627, + "y": 0.03643725, }, Object { "x": 1627973700000, - "y": 0.0454545454545455, + "y": 0.04545455, }, Object { "x": 1627973730000, @@ -562,7 +562,7 @@ Object { }, Object { "x": 1627973760000, - "y": 0.0416666666666667, + "y": 0.04166667, }, Object { "x": 1627973790000, @@ -590,7 +590,7 @@ Object { }, Object { "x": 1627973970000, - "y": 0.526315789473684, + "y": 0.52631579, }, Object { "x": 1627974000000, @@ -598,15 +598,15 @@ Object { }, Object { "x": 1627974030000, - "y": 0.0166666666666667, + "y": 0.01666667, }, Object { "x": 1627974060000, - "y": 0.129032258064516, + "y": 0.12903226, }, Object { "x": 1627974090000, - "y": 0.0143884892086331, + "y": 0.01438849, }, Object { "x": 1627974120000, @@ -614,19 +614,19 @@ Object { }, Object { "x": 1627974150000, - "y": 0.0471698113207547, + "y": 0.04716981, }, Object { "x": 1627974180000, - "y": 0.160714285714286, + "y": 0.16071429, }, Object { "x": 1627974210000, - "y": 0.0565371024734982, + "y": 0.0565371, }, Object { "x": 1627974240000, - "y": 0.0384615384615385, + "y": 0.03846154, }, Object { "x": 1627974270000, @@ -634,11 +634,11 @@ Object { }, Object { "x": 1627974300000, - "y": 0.0579710144927536, + "y": 0.05797101, }, Object { "x": 1627974330000, - "y": 0.00108695652173913, + "y": 0.00108696, }, Object { "x": 1627974360000, @@ -646,7 +646,7 @@ Object { }, Object { "x": 1627974390000, - "y": 0.263888888888889, + "y": 0.26388889, }, Object { "x": 1627974420000, @@ -658,11 +658,11 @@ Object { }, Object { "x": 1627974480000, - "y": 0.0193370165745856, + "y": 0.01933702, }, Object { "x": 1627974510000, - "y": 0.183908045977011, + "y": 0.18390805, }, Object { "x": 1627974540000, @@ -670,7 +670,7 @@ Object { }, Object { "x": 1627974570000, - "y": 0.0357142857142857, + "y": 0.03571429, }, Object { "x": 1627974600000, @@ -678,11 +678,11 @@ Object { }, Object { "x": 1627974630000, - "y": 0.0793650793650794, + "y": 0.07936508, }, Object { "x": 1627974660000, - "y": 0.0265486725663717, + "y": 0.02654867, }, Object { "x": 1627974690000, @@ -690,15 +690,15 @@ Object { }, Object { "x": 1627974720000, - "y": 0.192307692307692, + "y": 0.19230769, }, Object { "x": 1627974750000, - "y": 0.150375939849624, + "y": 0.15037594, }, Object { "x": 1627974780000, - "y": 0.0561224489795918, + "y": 0.05612245, }, Object { "x": 1627974810000, @@ -706,15 +706,15 @@ Object { }, Object { "x": 1627974840000, - "y": 0.217391304347826, + "y": 0.2173913, }, Object { "x": 1627974870000, - "y": 0.115942028985507, + "y": 0.11594203, }, Object { "x": 1627974900000, - "y": 0.380281690140845, + "y": 0.38028169, }, Object { "x": 1627974930000, @@ -738,15 +738,15 @@ Object { }, Object { "x": 1627975080000, - "y": 0.0738636363636364, + "y": 0.07386364, }, Object { "x": 1627975110000, - "y": 0.157894736842105, + "y": 0.15789474, }, Object { "x": 1627975140000, - "y": 0.175438596491228, + "y": 0.1754386, }, Object { "x": 1627975170000, @@ -754,7 +754,7 @@ Object { }, Object { "x": 1627975200000, - "y": 0.211764705882353, + "y": 0.21176471, }, ], "hideLegend": false, @@ -771,7 +771,7 @@ Object { }, Object { "x": 1627973430000, - "y": 0.00403225806451613, + "y": 0.00403226, }, Object { "x": 1627973460000, @@ -779,11 +779,11 @@ Object { }, Object { "x": 1627973490000, - "y": 0.0123456790123457, + "y": 0.01234568, }, Object { "x": 1627973520000, - "y": 0.0857142857142857, + "y": 0.08571429, }, Object { "x": 1627973550000, @@ -791,7 +791,7 @@ Object { }, Object { "x": 1627973580000, - "y": 0.0181818181818182, + "y": 0.01818182, }, Object { "x": 1627973610000, @@ -803,11 +803,11 @@ Object { }, Object { "x": 1627973670000, - "y": 0.00404858299595142, + "y": 0.00404858, }, Object { "x": 1627973700000, - "y": 0.0151515151515152, + "y": 0.01515152, }, Object { "x": 1627973730000, @@ -815,7 +815,7 @@ Object { }, Object { "x": 1627973760000, - "y": 0.00520833333333333, + "y": 0.00520833, }, Object { "x": 1627973790000, @@ -847,7 +847,7 @@ Object { }, Object { "x": 1627974000000, - "y": 0.0166666666666667, + "y": 0.01666667, }, Object { "x": 1627974030000, @@ -855,11 +855,11 @@ Object { }, Object { "x": 1627974060000, - "y": 0.112903225806452, + "y": 0.11290323, }, Object { "x": 1627974090000, - "y": 0.00719424460431655, + "y": 0.00719424, }, Object { "x": 1627974120000, @@ -867,19 +867,19 @@ Object { }, Object { "x": 1627974150000, - "y": 0.0377358490566038, + "y": 0.03773585, }, Object { "x": 1627974180000, - "y": 0.0357142857142857, + "y": 0.03571429, }, Object { "x": 1627974210000, - "y": 0.0318021201413428, + "y": 0.03180212, }, Object { "x": 1627974240000, - "y": 0.0192307692307692, + "y": 0.01923077, }, Object { "x": 1627974270000, @@ -887,11 +887,11 @@ Object { }, Object { "x": 1627974300000, - "y": 0.0338164251207729, + "y": 0.03381643, }, Object { "x": 1627974330000, - "y": 0.00062111801242236, + "y": 0.00062112, }, Object { "x": 1627974360000, @@ -899,7 +899,7 @@ Object { }, Object { "x": 1627974390000, - "y": 0.0416666666666667, + "y": 0.04166667, }, Object { "x": 1627974420000, @@ -911,11 +911,11 @@ Object { }, Object { "x": 1627974480000, - "y": 0.00552486187845304, + "y": 0.00552486, }, Object { "x": 1627974510000, - "y": 0.0114942528735632, + "y": 0.01149425, }, Object { "x": 1627974540000, @@ -923,7 +923,7 @@ Object { }, Object { "x": 1627974570000, - "y": 0.0714285714285714, + "y": 0.07142857, }, Object { "x": 1627974600000, @@ -931,11 +931,11 @@ Object { }, Object { "x": 1627974630000, - "y": 0.0158730158730159, + "y": 0.01587302, }, Object { "x": 1627974660000, - "y": 0.00884955752212389, + "y": 0.00884956, }, Object { "x": 1627974690000, @@ -943,15 +943,15 @@ Object { }, Object { "x": 1627974720000, - "y": 0.0384615384615385, + "y": 0.03846154, }, Object { "x": 1627974750000, - "y": 0.0601503759398496, + "y": 0.06015038, }, Object { "x": 1627974780000, - "y": 0.0153061224489796, + "y": 0.01530612, }, Object { "x": 1627974810000, @@ -959,7 +959,7 @@ Object { }, Object { "x": 1627974840000, - "y": 0.0434782608695652, + "y": 0.04347826, }, Object { "x": 1627974870000, @@ -967,7 +967,7 @@ Object { }, Object { "x": 1627974900000, - "y": 0.0140845070422535, + "y": 0.01408451, }, Object { "x": 1627974930000, @@ -991,11 +991,11 @@ Object { }, Object { "x": 1627975080000, - "y": 0.00568181818181818, + "y": 0.00568182, }, Object { "x": 1627975110000, - "y": 0.157894736842105, + "y": 0.15789474, }, Object { "x": 1627975140000, @@ -1007,7 +1007,7 @@ Object { }, Object { "x": 1627975200000, - "y": 0.0117647058823529, + "y": 0.01176471, }, ], "hideLegend": false, diff --git a/x-pack/test/functional/apps/ml/short_tests/model_management/model_list.ts b/x-pack/test/functional/apps/ml/short_tests/model_management/model_list.ts index 7977f17bf5f65..c0d4af068832e 100644 --- a/x-pack/test/functional/apps/ml/short_tests/model_management/model_list.ts +++ b/x-pack/test/functional/apps/ml/short_tests/model_management/model_list.ts @@ -17,6 +17,8 @@ export default function ({ getService }: FtrProviderContext) { id: model.name, })); + const modelAllSpaces = SUPPORTED_TRAINED_MODELS.TINY_ELSER; + describe('trained models', function () { // 'Created at' will be different on each run, // so we will just assert that the value is in the expected timestamp format. @@ -91,6 +93,10 @@ export default function ({ getService }: FtrProviderContext) { await ml.api.importTrainedModel(model.id, model.name); } + // Assign model to all spaces + await ml.api.updateTrainedModelSpaces(modelAllSpaces.name, ['*'], ['default']); + await ml.api.assertTrainedModelSpaces(modelAllSpaces.name, ['*']); + await ml.api.createTestTrainedModels('classification', 15, true); await ml.api.createTestTrainedModels('regression', 15); @@ -173,9 +179,10 @@ export default function ({ getService }: FtrProviderContext) { await ml.securityUI.logout(); }); - it('should not be able to delete a model assigned to all spaces, and show a warning copy explaining the situation', async () => { - await ml.testExecution.logTestStep('should select the model named elser_model_2'); - await ml.trainedModels.selectModel('.elser_model_2'); + it.skip('should not be able to delete a model assigned to all spaces, and show a warning copy explaining the situation', async () => { + await ml.testExecution.logTestStep('should select a model'); + await ml.trainedModelsTable.filterWithSearchString(modelAllSpaces.name, 1); + await ml.trainedModels.selectModel(modelAllSpaces.name); await ml.testExecution.logTestStep('should attempt to delete the model'); await ml.trainedModels.clickBulkDelete(); @@ -493,6 +500,11 @@ export default function ({ getService }: FtrProviderContext) { await ml.trainedModelsTable.assertStatsTabContent(); await ml.trainedModelsTable.assertPipelinesTabContent(false); }); + } + + describe('supports actions for an imported model', function () { + // It's enough to test the actions for one model + const model = trainedModels[trainedModels.length - 1]; it(`starts deployment of the imported model ${model.id}`, async () => { await ml.trainedModelsTable.startDeploymentWithParams(model.id, { @@ -513,7 +525,7 @@ export default function ({ getService }: FtrProviderContext) { it(`deletes the imported model ${model.id}`, async () => { await ml.trainedModelsTable.deleteModel(model.id); }); - } + }); }); }); diff --git a/x-pack/test/functional_gen_ai/inference/config.ts b/x-pack/test/functional_gen_ai/inference/config.ts new file mode 100644 index 0000000000000..b7f1429dc38a0 --- /dev/null +++ b/x-pack/test/functional_gen_ai/inference/config.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FtrConfigProviderContext } from '@kbn/test'; +import { getPreconfiguredConnectorConfig } from '@kbn/gen-ai-functional-testing'; +import { services } from './ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const xpackFunctionalConfig = await readConfigFile( + require.resolve('../../functional/config.base.js') + ); + + const preconfiguredConnectors = getPreconfiguredConnectorConfig(); + + return { + ...xpackFunctionalConfig.getAll(), + services, + testFiles: [require.resolve('./tests')], + kbnTestServer: { + ...xpackFunctionalConfig.get('kbnTestServer'), + serverArgs: [ + ...xpackFunctionalConfig.get('kbnTestServer.serverArgs'), + `--xpack.actions.preconfigured=${JSON.stringify(preconfiguredConnectors)}`, + ], + }, + }; +} diff --git a/x-pack/test/functional_gen_ai/inference/ftr_provider_context.ts b/x-pack/test/functional_gen_ai/inference/ftr_provider_context.ts new file mode 100644 index 0000000000000..de0b9d3f8118f --- /dev/null +++ b/x-pack/test/functional_gen_ai/inference/ftr_provider_context.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { GenericFtrProviderContext } from '@kbn/test'; +import { services } from '../../functional/services'; +import { pageObjects } from '../../functional/page_objects'; + +export type FtrProviderContext = GenericFtrProviderContext; + +export { services, pageObjects }; diff --git a/x-pack/test/functional_gen_ai/inference/tests/chat_complete.ts b/x-pack/test/functional_gen_ai/inference/tests/chat_complete.ts new file mode 100644 index 0000000000000..35bf7bf2b3e07 --- /dev/null +++ b/x-pack/test/functional_gen_ai/inference/tests/chat_complete.ts @@ -0,0 +1,263 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { lastValueFrom, toArray } from 'rxjs'; +import expect from '@kbn/expect'; +import { supertestToObservable } from '@kbn/sse-utils-server'; +import type { AvailableConnectorWithId } from '@kbn/gen-ai-functional-testing'; +import type { FtrProviderContext } from '../ftr_provider_context'; + +export const chatCompleteSuite = ( + { id: connectorId, actionTypeId: connectorType }: AvailableConnectorWithId, + { getService }: FtrProviderContext +) => { + const supertest = getService('supertest'); + + describe('chatComplete API', () => { + describe('streaming disabled', () => { + it('returns a chat completion message for a simple prompt', async () => { + const response = await supertest + .post(`/internal/inference/chat_complete`) + .set('kbn-xsrf', 'kibana') + .send({ + connectorId, + system: 'Please answer the user question', + messages: [{ role: 'user', content: '2+2 ?' }], + }) + .expect(200); + + const message = response.body; + + expect(message.toolCalls.length).to.eql(0); + expect(message.content).to.contain('4'); + }); + + it('executes a tool with native function calling', async () => { + const response = await supertest + .post(`/internal/inference/chat_complete`) + .set('kbn-xsrf', 'kibana') + .send({ + connectorId, + system: + 'Please answer the user question. You can use the available tools if you think it can help', + messages: [{ role: 'user', content: 'What is the result of 2*4*6*8*10*123 ?' }], + toolChoice: 'required', + tools: { + calculator: { + description: 'The calculator can be used to resolve mathematical calculations', + schema: { + type: 'object', + properties: { + formula: { + type: 'string', + description: `The input for the calculator, in plain text, e.g. "2+(4*8)"`, + }, + }, + }, + }, + }, + }) + .expect(200); + + const message = response.body; + + expect(message.toolCalls.length).to.eql(1); + expect(message.toolCalls[0].function.name).to.eql('calculator'); + expect(message.toolCalls[0].function.arguments.formula).to.contain('123'); + }); + + // simulated FC is only for openAI + if (connectorType === '.gen-ai') { + it('executes a tool with simulated function calling', async () => { + const response = await supertest + .post(`/internal/inference/chat_complete`) + .set('kbn-xsrf', 'kibana') + .send({ + connectorId, + system: + 'Please answer the user question. You can use the available tools if you think it can help', + messages: [{ role: 'user', content: 'What is the result of 2*4*6*8*10*123 ?' }], + functionCalling: 'simulated', + toolChoice: 'required', + tools: { + calculator: { + description: 'The calculator can be used to resolve mathematical calculations', + schema: { + type: 'object', + properties: { + formula: { + type: 'string', + description: `The input for the calculator, in plain text, e.g. "2+(4*8)"`, + }, + }, + }, + }, + }, + }) + .expect(200); + + const message = response.body; + + expect(message.toolCalls.length).to.eql(1); + expect(message.toolCalls[0].function.name).to.eql('calculator'); + expect(message.toolCalls[0].function.arguments.formula).to.contain('123'); + }); + } + + it('returns token counts', async () => { + const response = await supertest + .post(`/internal/inference/chat_complete`) + .set('kbn-xsrf', 'kibana') + .send({ + connectorId, + system: 'Please answer the user question', + messages: [{ role: 'user', content: '2+2 ?' }], + }) + .expect(200); + + const { tokens } = response.body; + + expect(tokens.prompt).to.be.greaterThan(0); + expect(tokens.completion).to.be.greaterThan(0); + expect(tokens.total).eql(tokens.prompt + tokens.completion); + }); + + it('returns an error with the expected shape in case of error', async () => { + const response = await supertest + .post(`/internal/inference/chat_complete`) + .set('kbn-xsrf', 'kibana') + .send({ + connectorId: 'do-not-exist', + system: 'Please answer the user question', + messages: [{ role: 'user', content: '2+2 ?' }], + }) + .expect(400); + + const message = response.body; + + expect(message).to.eql({ + type: 'error', + code: 'requestError', + message: "No connector found for id 'do-not-exist'", + }); + }); + }); + + describe('streaming enabled', () => { + it('returns a chat completion message for a simple prompt', async () => { + const response = supertest + .post(`/internal/inference/chat_complete/stream`) + .set('kbn-xsrf', 'kibana') + .send({ + connectorId, + system: 'Please answer the user question', + messages: [{ role: 'user', content: '2+2 ?' }], + }) + .expect(200); + + const observable = supertestToObservable(response); + + const message = await lastValueFrom(observable); + + expect({ + ...message, + content: '', + }).to.eql({ type: 'chatCompletionMessage', content: '', toolCalls: [] }); + expect(message.content).to.contain('4'); + }); + + it('executes a tool when explicitly requested', async () => { + const response = supertest + .post(`/internal/inference/chat_complete/stream`) + .set('kbn-xsrf', 'kibana') + .send({ + connectorId, + system: + 'Please answer the user question. You can use the available tools if you think it can help', + messages: [{ role: 'user', content: 'What is the result of 2*4*6*8*10*123 ?' }], + toolChoice: 'required', + tools: { + calculator: { + description: 'The calculator can be used to resolve mathematical calculations', + schema: { + type: 'object', + properties: { + formula: { + type: 'string', + description: `The input for the calculator, in plain text, e.g. "2+(4*8)"`, + }, + }, + }, + }, + }, + }) + .expect(200); + + const observable = supertestToObservable(response); + + const message = await lastValueFrom(observable); + + expect(message.toolCalls.length).to.eql(1); + expect(message.toolCalls[0].function.name).to.eql('calculator'); + expect(message.toolCalls[0].function.arguments.formula).to.contain('123'); + }); + + it('returns a token count event', async () => { + const response = supertest + .post(`/internal/inference/chat_complete/stream`) + .set('kbn-xsrf', 'kibana') + .send({ + connectorId, + system: 'Please answer the user question', + messages: [{ role: 'user', content: '2+2 ?' }], + }) + .expect(200); + + const observable = supertestToObservable(response); + + const events = await lastValueFrom(observable.pipe(toArray())); + const tokenEvent = events[events.length - 2]; + + expect(tokenEvent.type).to.eql('chatCompletionTokenCount'); + expect(tokenEvent.tokens.prompt).to.be.greaterThan(0); + expect(tokenEvent.tokens.completion).to.be.greaterThan(0); + expect(tokenEvent.tokens.total).to.be( + tokenEvent.tokens.prompt + tokenEvent.tokens.completion + ); + }); + + it('returns an error with the expected shape in case of error', async () => { + const response = supertest + .post(`/internal/inference/chat_complete/stream`) + .set('kbn-xsrf', 'kibana') + .send({ + connectorId: 'do-not-exist', + system: 'Please answer the user question', + messages: [{ role: 'user', content: '2+2 ?' }], + }) + .expect(200); + + const observable = supertestToObservable(response); + + const events = await lastValueFrom(observable.pipe(toArray())); + + expect(events).to.eql([ + { + type: 'error', + error: { + code: 'requestError', + message: "No connector found for id 'do-not-exist'", + meta: { + status: 400, + }, + }, + }, + ]); + }); + }); + }); +}; diff --git a/x-pack/test/functional_gen_ai/inference/tests/index.ts b/x-pack/test/functional_gen_ai/inference/tests/index.ts new file mode 100644 index 0000000000000..36cf2bbaffa14 --- /dev/null +++ b/x-pack/test/functional_gen_ai/inference/tests/index.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 { getAvailableConnectors } from '@kbn/gen-ai-functional-testing'; +import { FtrProviderContext } from '../ftr_provider_context'; +import { chatCompleteSuite } from './chat_complete'; + +// eslint-disable-next-line import/no-default-export +export default function (providerContext: FtrProviderContext) { + describe('Inference plugin - API integration tests', async () => { + getAvailableConnectors().forEach((connector) => { + describe(`Connector ${connector.id}`, () => { + chatCompleteSuite(connector, providerContext); + }); + }); + }); +} diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts index 07e6aa841f8e2..d187672a3ada0 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts @@ -120,7 +120,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { } // Failing: See https://github.com/elastic/kibana/issues/196153 - describe('create alert', function () { + // Failing: See https://github.com/elastic/kibana/issues/202328 + describe.skip('create alert', function () { let apmSynthtraceEsClient: ApmSynthtraceEsClient; const webhookConnectorName = 'webhook-test'; before(async () => { diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/eql/trial_license_complete_tier/eql_alert_suppression.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/eql/trial_license_complete_tier/eql_alert_suppression.ts index f8331a2d6bf31..1ba24e60adc6a 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/eql/trial_license_complete_tier/eql_alert_suppression.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/eql/trial_license_complete_tier/eql_alert_suppression.ts @@ -2875,7 +2875,8 @@ export default ({ getService }: FtrProviderContext) => { }); }); - describe('@skipInServerless sequence queries with suppression duration', () => { + // FLAKY: https://github.com/elastic/kibana/issues/202940 + describe.skip('@skipInServerless sequence queries with suppression duration', () => { it('suppresses alerts across two rule executions when the suppression duration exceeds the rule interval', async () => { const id = uuidv4(); const firstTimestamp = new Date(Date.now() - 1000).toISOString(); diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/entity_store.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/entity_store.ts index ffa5a6b3fe778..84c99e0bc6272 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/entity_store.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/entity_store.ts @@ -14,7 +14,8 @@ export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); const utils = EntityStoreUtils(getService); - describe('@ess @skipInServerlessMKI Entity Store APIs', () => { + // Failing: See https://github.com/elastic/kibana/issues/200758 + describe.skip('@ess @skipInServerlessMKI Entity Store APIs', () => { const dataView = dataViewRouteHelpersFactory(supertest); before(async () => { diff --git a/x-pack/test/spaces_api_integration/spaces_only/telemetry/telemetry.ts b/x-pack/test/spaces_api_integration/spaces_only/telemetry/telemetry.ts index 4a43c3831627c..a74fbf6b75383 100644 --- a/x-pack/test/spaces_api_integration/spaces_only/telemetry/telemetry.ts +++ b/x-pack/test/spaces_api_integration/spaces_only/telemetry/telemetry.ts @@ -100,6 +100,7 @@ export default function ({ getService }: FtrProviderContext) { savedObjectsManagement: 1, savedQueryManagement: 0, dataQuality: 0, + entityManager: 0, }); }); diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index 350ac68698acc..95178eca83172 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -188,6 +188,8 @@ "@kbn/ai-assistant-common", "@kbn/core-deprecations-common", "@kbn/usage-collection-plugin", + "@kbn/sse-utils-server", + "@kbn/gen-ai-functional-testing", "@kbn/integration-assistant-plugin" ] } diff --git a/x-pack/test_serverless/api_integration/test_suites/observability/platform_security/authorization.ts b/x-pack/test_serverless/api_integration/test_suites/observability/platform_security/authorization.ts index b6dbceedfe65e..91812dbecb027 100644 --- a/x-pack/test_serverless/api_integration/test_suites/observability/platform_security/authorization.ts +++ b/x-pack/test_serverless/api_integration/test_suites/observability/platform_security/authorization.ts @@ -4235,6 +4235,8 @@ export default function ({ getService }: FtrProviderContext) { "login:", "api:bulkGetUserProfiles", "api:dashboardUsageStats", + "api:savedQuery:manage", + "api:savedQuery:read", "api:store_search_session", "api:generateReport", "api:downloadCsv", @@ -4418,6 +4420,8 @@ export default function ({ getService }: FtrProviderContext) { "login:", "api:bulkGetUserProfiles", "api:dashboardUsageStats", + "api:savedQuery:manage", + "api:savedQuery:read", "app:dashboards", "app:kibana", "ui:catalogue/dashboard", @@ -4570,6 +4574,7 @@ export default function ({ getService }: FtrProviderContext) { "login:", "api:bulkGetUserProfiles", "api:dashboardUsageStats", + "api:savedQuery:read", "app:dashboards", "app:kibana", "ui:catalogue/dashboard", @@ -4669,6 +4674,7 @@ export default function ({ getService }: FtrProviderContext) { "login:", "api:bulkGetUserProfiles", "api:dashboardUsageStats", + "api:savedQuery:read", "app:dashboards", "app:kibana", "ui:catalogue/dashboard", @@ -4804,6 +4810,8 @@ export default function ({ getService }: FtrProviderContext) { "all": Array [ "login:", "api:fileUpload:analyzeFile", + "api:savedQuery:manage", + "api:savedQuery:read", "api:store_search_session", "api:generateReport", "app:discover", @@ -6032,6 +6040,8 @@ export default function ({ getService }: FtrProviderContext) { "minimal_all": Array [ "login:", "api:fileUpload:analyzeFile", + "api:savedQuery:manage", + "api:savedQuery:read", "app:discover", "app:kibana", "ui:catalogue/discover", @@ -7227,6 +7237,7 @@ export default function ({ getService }: FtrProviderContext) { ], "minimal_read": Array [ "login:", + "api:savedQuery:read", "app:discover", "app:kibana", "ui:catalogue/discover", @@ -7718,6 +7729,7 @@ export default function ({ getService }: FtrProviderContext) { ], "read": Array [ "login:", + "api:savedQuery:read", "app:discover", "app:kibana", "ui:catalogue/discover", diff --git a/x-pack/test_serverless/api_integration/test_suites/search/platform_security/authorization.ts b/x-pack/test_serverless/api_integration/test_suites/search/platform_security/authorization.ts index a30b8aca571ea..ed9fdd30cbdae 100644 --- a/x-pack/test_serverless/api_integration/test_suites/search/platform_security/authorization.ts +++ b/x-pack/test_serverless/api_integration/test_suites/search/platform_security/authorization.ts @@ -42,6 +42,8 @@ export default function ({ getService }: FtrProviderContext) { "login:", "api:bulkGetUserProfiles", "api:dashboardUsageStats", + "api:savedQuery:manage", + "api:savedQuery:read", "api:store_search_session", "api:generateReport", "api:downloadCsv", @@ -225,6 +227,8 @@ export default function ({ getService }: FtrProviderContext) { "login:", "api:bulkGetUserProfiles", "api:dashboardUsageStats", + "api:savedQuery:manage", + "api:savedQuery:read", "app:dashboards", "app:kibana", "ui:catalogue/dashboard", @@ -377,6 +381,7 @@ export default function ({ getService }: FtrProviderContext) { "login:", "api:bulkGetUserProfiles", "api:dashboardUsageStats", + "api:savedQuery:read", "app:dashboards", "app:kibana", "ui:catalogue/dashboard", @@ -476,6 +481,7 @@ export default function ({ getService }: FtrProviderContext) { "login:", "api:bulkGetUserProfiles", "api:dashboardUsageStats", + "api:savedQuery:read", "app:dashboards", "app:kibana", "ui:catalogue/dashboard", @@ -611,6 +617,8 @@ export default function ({ getService }: FtrProviderContext) { "all": Array [ "login:", "api:fileUpload:analyzeFile", + "api:savedQuery:manage", + "api:savedQuery:read", "api:store_search_session", "api:generateReport", "app:discover", @@ -711,6 +719,8 @@ export default function ({ getService }: FtrProviderContext) { "minimal_all": Array [ "login:", "api:fileUpload:analyzeFile", + "api:savedQuery:manage", + "api:savedQuery:read", "app:discover", "app:kibana", "ui:catalogue/discover", @@ -778,6 +788,7 @@ export default function ({ getService }: FtrProviderContext) { ], "minimal_read": Array [ "login:", + "api:savedQuery:read", "app:discover", "app:kibana", "ui:catalogue/discover", @@ -822,6 +833,7 @@ export default function ({ getService }: FtrProviderContext) { ], "read": Array [ "login:", + "api:savedQuery:read", "app:discover", "app:kibana", "ui:catalogue/discover", diff --git a/x-pack/test_serverless/api_integration/test_suites/security/platform_security/authorization.ts b/x-pack/test_serverless/api_integration/test_suites/security/platform_security/authorization.ts index c3b37539946ff..1f9a7f74fd572 100644 --- a/x-pack/test_serverless/api_integration/test_suites/security/platform_security/authorization.ts +++ b/x-pack/test_serverless/api_integration/test_suites/security/platform_security/authorization.ts @@ -742,6 +742,8 @@ export default function ({ getService }: FtrProviderContext) { "alerting:siem.newTermsRule/siem/alert/getAlertSummary", "alerting:siem.newTermsRule/siem/alert/update", "api:fileUpload:analyzeFile", + "api:savedQuery:manage", + "api:savedQuery:read", "api:store_search_session", "api:generateReport", "app:discover", @@ -1597,6 +1599,8 @@ export default function ({ getService }: FtrProviderContext) { "alerting:siem.newTermsRule/siem/alert/getAlertSummary", "alerting:siem.newTermsRule/siem/alert/update", "api:fileUpload:analyzeFile", + "api:savedQuery:manage", + "api:savedQuery:read", "api:store_search_session", "api:generateReport", "app:discover", @@ -2003,6 +2007,7 @@ export default function ({ getService }: FtrProviderContext) { "alerting:siem.newTermsRule/siem/alert/getAuthorizedAlertsIndices", "alerting:siem.newTermsRule/siem/alert/getAlertSummary", "alerting:siem.newTermsRule/siem/alert/update", + "api:savedQuery:read", "app:discover", "ui:catalogue/discover", "ui:navLinks/discover", @@ -2370,6 +2375,7 @@ export default function ({ getService }: FtrProviderContext) { "alerting:siem.newTermsRule/siem/alert/getAuthorizedAlertsIndices", "alerting:siem.newTermsRule/siem/alert/getAlertSummary", "alerting:siem.newTermsRule/siem/alert/update", + "api:savedQuery:read", "app:discover", "ui:catalogue/discover", "ui:navLinks/discover", diff --git a/yarn.lock b/yarn.lock index 95219693b46b8..3d990d03d59f8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -44,6 +44,15 @@ call-me-maybe "^1.0.1" js-yaml "^3.13.1" +"@apidevtools/json-schema-ref-parser@^11.5.5": + version "11.7.2" + resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.7.2.tgz#cdf3e0aded21492364a70e193b45b7cf4177f031" + integrity sha512-4gY54eEGEstClvEkGnwVkTkrx0sqwemEFG5OSRRn3tD91XH0+Q8XIkYIfo7IwEWPpJZwILb9GUXeShtplRc/eA== + dependencies: + "@jsdevtools/ono" "^7.1.3" + "@types/json-schema" "^7.0.15" + js-yaml "^4.1.0" + "@apidevtools/json-schema-ref-parser@^9.0.6": version "9.0.9" resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.0.9.tgz#d720f9256e3609621280584f2b47ae165359268b" @@ -5758,6 +5767,10 @@ version "0.0.0" uid "" +"@kbn/gen-ai-functional-testing@link:packages/kbn-gen-ai-functional-testing": + version "0.0.0" + uid "" + "@kbn/gen-ai-streaming-response-example-plugin@link:x-pack/examples/gen_ai_streaming_response_example": version "0.0.0" uid "" @@ -8044,12 +8057,15 @@ "@langchain/core" ">0.1.0 <0.3.0" js-tiktoken "^1.0.12" -"@langtrase/trace-attributes@^3.0.8": - version "3.0.8" - resolved "https://registry.yarnpkg.com/@langtrase/trace-attributes/-/trace-attributes-3.0.8.tgz#ff6ae44cfc048a9da10a7949664b2060a71b6304" - integrity sha512-GXUH+a0EiO8YgrZR2fkqM0B/xrf3Db1OHDlJUOGVuwacC+LJp89MbJQBlmeu3BBJLMMHG0+q4ERYEt8enCHjHw== +"@langtrase/trace-attributes@^7.5.0": + version "7.5.0" + resolved "https://registry.yarnpkg.com/@langtrase/trace-attributes/-/trace-attributes-7.5.0.tgz#b1a523d90f62f233f282fa3949c5da397d3aa0e9" + integrity sha512-U+2dMRJaRv0R6i9qrzh1hXREkrqg01EboCDfrovXeXySFsVGCuvSD8iavXzheOSWVLy9XbiM6gJjekdUFhAhQg== dependencies: + js-tiktoken "^1.0.14" + json-schema-to-typescript "^14.1.0" ncp "^2.0.0" + typescript "^5.5.3" "@launchdarkly/js-sdk-common@2.12.0": version "2.12.0" @@ -9346,13 +9362,13 @@ "@smithy/url-parser" "^3.0.8" tslib "^2.6.2" -"@smithy/eventstream-codec@^3.1.1", "@smithy/eventstream-codec@^3.1.7": - version "3.1.7" - resolved "https://registry.yarnpkg.com/@smithy/eventstream-codec/-/eventstream-codec-3.1.7.tgz#5bfaffbc83ae374ffd85a755a8200ba3c7aed016" - integrity sha512-kVSXScIiRN7q+s1x7BrQtZ1Aa9hvvP9FeCqCdBxv37GimIHgBCOnZ5Ip80HLt0DhnAKpiobFdGqTFgbaJNrazA== +"@smithy/eventstream-codec@^3.1.1", "@smithy/eventstream-codec@^3.1.9": + version "3.1.9" + resolved "https://registry.yarnpkg.com/@smithy/eventstream-codec/-/eventstream-codec-3.1.9.tgz#4271354e75e57d30771fca307da403896c657430" + integrity sha512-F574nX0hhlNOjBnP+noLtsPFqXnWh2L0+nZKCwcu7P7J8k+k+rdIDs+RMnrMwrzhUE4mwMgyN0cYnEn0G8yrnQ== dependencies: "@aws-crypto/crc32" "5.2.0" - "@smithy/types" "^3.6.0" + "@smithy/types" "^3.7.1" "@smithy/util-hex-encoding" "^3.0.0" tslib "^2.6.2" @@ -9373,22 +9389,22 @@ "@smithy/types" "^3.6.0" tslib "^2.6.2" -"@smithy/eventstream-serde-node@^3.0.10", "@smithy/eventstream-serde-node@^3.0.3": - version "3.0.10" - resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-node/-/eventstream-serde-node-3.0.10.tgz#da40b872001390bb47807186855faba8172b3b5b" - integrity sha512-hjpU1tIsJ9qpcoZq9zGHBJPBOeBGYt+n8vfhDwnITPhEre6APrvqq/y3XMDEGUT2cWQ4ramNqBPRbx3qn55rhw== +"@smithy/eventstream-serde-node@^3.0.10", "@smithy/eventstream-serde-node@^3.0.12": + version "3.0.12" + resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-node/-/eventstream-serde-node-3.0.12.tgz#7312383e821b5807abf2fe12316c2a8967d022f0" + integrity sha512-kiZymxXvZ4tnuYsPSMUHe+MMfc4FTeFWJIc0Q5wygJoUQM4rVHNghvd48y7ppuulNMbuYt95ah71pYc2+o4JOA== dependencies: - "@smithy/eventstream-serde-universal" "^3.0.10" - "@smithy/types" "^3.6.0" + "@smithy/eventstream-serde-universal" "^3.0.12" + "@smithy/types" "^3.7.1" tslib "^2.6.2" -"@smithy/eventstream-serde-universal@^3.0.10": - version "3.0.10" - resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-3.0.10.tgz#b24e66fec9ec003eb0a1d6733fa22ded43129281" - integrity sha512-ewG1GHbbqsFZ4asaq40KmxCmXO+AFSM1b+DcO2C03dyJj/ZH71CiTg853FSE/3SHK9q3jiYQIFjlGSwfxQ9kww== +"@smithy/eventstream-serde-universal@^3.0.10", "@smithy/eventstream-serde-universal@^3.0.12": + version "3.0.12" + resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-3.0.12.tgz#803d7beb29a3de4a64e91af97331a4654741c35f" + integrity sha512-1i8ifhLJrOZ+pEifTlF0EfZzMLUGQggYQ6WmZ4d5g77zEKf7oZ0kvh1yKWHPjofvOwqrkwRDVuxuYC8wVd662A== dependencies: - "@smithy/eventstream-codec" "^3.1.7" - "@smithy/types" "^3.6.0" + "@smithy/eventstream-codec" "^3.1.9" + "@smithy/types" "^3.7.1" tslib "^2.6.2" "@smithy/fetch-http-handler@^4.0.0": @@ -9557,29 +9573,16 @@ "@smithy/types" "^3.6.0" tslib "^2.6.2" -"@smithy/signature-v4@^3.1.1": - version "3.1.1" - resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-3.1.1.tgz#4882aacb3260a47b8279b2ffc6a135e03e225260" - integrity sha512-2/vlG86Sr489XX8TA/F+VDA+P04ESef04pSz0wRtlQBExcSPjqO08rvrkcas2zLnJ51i+7ukOURCkgqixBYjSQ== - dependencies: - "@smithy/is-array-buffer" "^3.0.0" - "@smithy/types" "^3.2.0" - "@smithy/util-hex-encoding" "^3.0.0" - "@smithy/util-middleware" "^3.0.2" - "@smithy/util-uri-escape" "^3.0.0" - "@smithy/util-utf8" "^3.0.0" - tslib "^2.6.2" - -"@smithy/signature-v4@^4.2.0": - version "4.2.1" - resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-4.2.1.tgz#a918fd7d99af9f60aa07617506fa54be408126ee" - integrity sha512-NsV1jF4EvmO5wqmaSzlnTVetemBS3FZHdyc5CExbDljcyJCEEkJr8ANu2JvtNbVg/9MvKAWV44kTrGS+Pi4INg== +"@smithy/signature-v4@^4.2.0", "@smithy/signature-v4@^4.2.3": + version "4.2.3" + resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-4.2.3.tgz#abbca5e5fe9158422b3125b2956791a325a27f22" + integrity sha512-pPSQQ2v2vu9vc8iew7sszLd0O09I5TRc5zhY71KA+Ao0xYazIG+uLeHbTJfIWGO3BGVLiXjUr3EEeCcEQLjpWQ== dependencies: "@smithy/is-array-buffer" "^3.0.0" - "@smithy/protocol-http" "^4.1.5" - "@smithy/types" "^3.6.0" + "@smithy/protocol-http" "^4.1.7" + "@smithy/types" "^3.7.1" "@smithy/util-hex-encoding" "^3.0.0" - "@smithy/util-middleware" "^3.0.8" + "@smithy/util-middleware" "^3.0.10" "@smithy/util-uri-escape" "^3.0.0" "@smithy/util-utf8" "^3.0.0" tslib "^2.6.2" @@ -9597,7 +9600,7 @@ "@smithy/util-stream" "^3.2.1" tslib "^2.6.2" -"@smithy/types@^3.2.0", "@smithy/types@^3.6.0", "@smithy/types@^3.7.1": +"@smithy/types@^3.6.0", "@smithy/types@^3.7.1": version "3.7.1" resolved "https://registry.yarnpkg.com/@smithy/types/-/types-3.7.1.tgz#4af54c4e28351e9101996785a33f2fdbf93debe7" integrity sha512-XKLcLXZY7sUQgvvWyeaL/qwNPp6V3dWcUjqrQKjSb+tzYiCy340R/c64LV5j+Tnb2GhmunEX0eou+L+m2hJNYA== @@ -9699,12 +9702,12 @@ dependencies: tslib "^2.6.2" -"@smithy/util-middleware@^3.0.2", "@smithy/util-middleware@^3.0.8": - version "3.0.8" - resolved "https://registry.yarnpkg.com/@smithy/util-middleware/-/util-middleware-3.0.8.tgz#372bc7a2845408ad69da039d277fc23c2734d0c6" - integrity sha512-p7iYAPaQjoeM+AKABpYWeDdtwQNxasr4aXQEA/OmbOaug9V0odRVDy3Wx4ci8soljE/JXQo+abV0qZpW8NX0yA== +"@smithy/util-middleware@^3.0.10", "@smithy/util-middleware@^3.0.8": + version "3.0.10" + resolved "https://registry.yarnpkg.com/@smithy/util-middleware/-/util-middleware-3.0.10.tgz#ab8be99f1aaafe5a5490c344f27a264b72b7592f" + integrity sha512-eJO+/+RsrG2RpmY68jZdwQtnfsxjmPxzMlQpnHKjFPwrYqvlcT+fHdT+ZVwcjlWSrByOhGr9Ff2GG17efc192A== dependencies: - "@smithy/types" "^3.6.0" + "@smithy/types" "^3.7.1" tslib "^2.6.2" "@smithy/util-retry@^3.0.8": @@ -11695,7 +11698,7 @@ "@types/tough-cookie" "*" parse5 "^7.0.0" -"@types/json-schema@*", "@types/json-schema@^7", "@types/json-schema@^7.0.0", "@types/json-schema@^7.0.4", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.6", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": +"@types/json-schema@*", "@types/json-schema@^7.0.0", "@types/json-schema@^7.0.4", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.6", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": version "7.0.11" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3" integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ== @@ -11764,10 +11767,10 @@ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.0.tgz#d774355e41f372d5350a4d0714abb48194a489c3" integrity sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA== -"@types/lodash@^4.17.10": - version "4.17.10" - resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.10.tgz#64f3edf656af2fe59e7278b73d3e62404144a6e6" - integrity sha512-YpS0zzoduEhuOWjAotS6A5AVCva7X4lVlYLF0FYHAY9sdraBfnatttHItlWeZdGhuEkf+OzMNg2ZYAx8t+52uQ== +"@types/lodash@^4.17.0", "@types/lodash@^4.17.10": + version "4.17.13" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.13.tgz#786e2d67cfd95e32862143abe7463a7f90c300eb" + integrity sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg== "@types/long@^4.0.1": version "4.0.2" @@ -13329,7 +13332,7 @@ ajv@^6.1.0, ajv@^6.10.2, ajv@^6.12.2, ajv@^6.12.4, ajv@^6.12.5: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ajv@^8.0.0, ajv@^8.0.1, ajv@^8.12.0, ajv@^8.17.1, ajv@^8.6.3, ajv@^8.8.0: +ajv@^8.0.0, ajv@^8.0.1, ajv@^8.17.1, ajv@^8.6.3, ajv@^8.8.0: version "8.17.1" resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.17.1.tgz#37d9a5c776af6bc92d7f4f9510eba4c0a60d11a6" integrity sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g== @@ -13404,10 +13407,10 @@ ansi-regex@^5.0.0, ansi-regex@^5.0.1: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== -ansi-regex@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a" - integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA== +ansi-regex@^6.0.1, ansi-regex@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.1.0.tgz#95ec409c69619d6cb1b8b34f14b660ef28ebd654" + integrity sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA== ansi-styles@^2.2.1: version "2.2.1" @@ -15259,6 +15262,17 @@ cli-boxes@^2.2.1: resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.1.tgz#ddd5035d25094fce220e9cab40a45840a440318f" integrity sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw== +cli-color@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/cli-color/-/cli-color-2.0.4.tgz#d658080290968816b322248b7306fad2346fb2c8" + integrity sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA== + dependencies: + d "^1.0.1" + es5-ext "^0.10.64" + es6-iterator "^2.0.3" + memoizee "^0.4.15" + timers-ext "^0.1.7" + cli-cursor@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" @@ -16610,6 +16624,14 @@ d3@3.5.17, d3@^3.5.6: resolved "https://registry.yarnpkg.com/d3/-/d3-3.5.17.tgz#bc46748004378b21a360c9fc7cf5231790762fb8" integrity sha1-vEZ0gAQ3iyGjYMn8fPUjF5B2L7g= +d@1, d@^1.0.1, d@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/d/-/d-1.0.2.tgz#2aefd554b81981e7dccf72d6842ae725cb17e5de" + integrity sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw== + dependencies: + es5-ext "^0.10.64" + type "^2.7.2" + dagre@^0.8.2: version "0.8.5" resolved "https://registry.yarnpkg.com/dagre/-/dagre-0.8.5.tgz#ba30b0055dac12b6c1fcc247817442777d06afee" @@ -17990,6 +18012,16 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" +es5-ext@^0.10.35, es5-ext@^0.10.46, es5-ext@^0.10.62, es5-ext@^0.10.64, es5-ext@~0.10.14, es5-ext@~0.10.2: + version "0.10.64" + resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.64.tgz#12e4ffb48f1ba2ea777f1fcdd1918ef73ea21714" + integrity sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg== + dependencies: + es6-iterator "^2.0.3" + es6-symbol "^3.1.3" + esniff "^2.0.1" + next-tick "^1.1.0" + es5-shim@^4.5.13: version "4.5.14" resolved "https://registry.yarnpkg.com/es5-shim/-/es5-shim-4.5.14.tgz#90009e1019d0ea327447cb523deaff8fe45697ef" @@ -18000,6 +18032,15 @@ es6-error@^4.0.1: resolved "https://registry.yarnpkg.com/es6-error/-/es6-error-4.1.1.tgz#9e3af407459deed47e9a91f9b885a84eb05c561d" integrity sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg== +es6-iterator@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.3.tgz#a7de889141a05a94b0854403b2d0a0fbfa98f3b7" + integrity sha1-p96IkUGgWpSwhUQDstCg+/qY87c= + dependencies: + d "1" + es5-ext "^0.10.35" + es6-symbol "^3.1.1" + es6-promise@^3.2.1: version "3.3.1" resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.3.1.tgz#a08cdde84ccdbf34d027a1451bc91d4bcd28a613" @@ -18015,6 +18056,24 @@ es6-shim@^0.35.5: resolved "https://registry.yarnpkg.com/es6-shim/-/es6-shim-0.35.5.tgz#46f59dc0a84a1c5029e8ff1166ca0a902077a9ab" integrity sha512-E9kK/bjtCQRpN1K28Xh4BlmP8egvZBGJJ+9GtnzOwt7mdqtrjHFuVGr7QJfdjBIKqrlU5duPf3pCBoDrkjVYFg== +es6-symbol@^3.1.1, es6-symbol@^3.1.3: + version "3.1.4" + resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.4.tgz#f4e7d28013770b4208ecbf3e0bf14d3bcb557b8c" + integrity sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg== + dependencies: + d "^1.0.2" + ext "^1.7.0" + +es6-weak-map@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/es6-weak-map/-/es6-weak-map-2.0.3.tgz#b6da1f16cc2cc0d9be43e6bdbfc5e7dfcdf31d53" + integrity sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA== + dependencies: + d "1" + es5-ext "^0.10.46" + es6-iterator "^2.0.3" + es6-symbol "^3.1.1" + esbuild@^0.19.11: version "0.19.12" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.19.12.tgz#dc82ee5dc79e82f5a5c3b4323a2a641827db3e04" @@ -18406,6 +18465,16 @@ eslint@^8.57.0: strip-ansi "^6.0.1" text-table "^0.2.0" +esniff@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/esniff/-/esniff-2.0.1.tgz#a4d4b43a5c71c7ec51c51098c1d8a29081f9b308" + integrity sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg== + dependencies: + d "^1.0.1" + es5-ext "^0.10.62" + event-emitter "^0.3.5" + type "^2.7.2" + espree@^9.6.0, espree@^9.6.1: version "9.6.1" resolved "https://registry.yarnpkg.com/espree/-/espree-9.6.1.tgz#a2a17b8e434690a5432f2f8018ce71d331a48c6f" @@ -18454,6 +18523,14 @@ etag@~1.8.1: resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= +event-emitter@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39" + integrity sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk= + dependencies: + d "1" + es5-ext "~0.10.14" + event-target-shim@^5.0.0: version "5.0.1" resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" @@ -18494,10 +18571,10 @@ eventsource-parser@1.0.0: resolved "https://registry.yarnpkg.com/eventsource-parser/-/eventsource-parser-1.0.0.tgz#6332e37fd5512e3c8d9df05773b2bf9e152ccc04" integrity sha512-9jgfSCa3dmEme2ES3mPByGXfgZ87VbP97tng1G2nWwWx6bV2nYxm2AWCrbQjXToSe+yYlqaZNtxffR9IeQr95g== -eventsource-parser@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/eventsource-parser/-/eventsource-parser-1.1.1.tgz#576f8bcf391c5e5ccdea817abd9ead36d1754247" - integrity sha512-3Ej2iLj6ZnX+5CMxqyUb8syl9yVZwcwm8IIMrOJlF7I51zxOOrRlU3zxSb/6hFbl03ts1ZxHAGJdWLZOLyKG7w== +eventsource-parser@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/eventsource-parser/-/eventsource-parser-3.0.0.tgz#9303e303ef807d279ee210a17ce80f16300d9f57" + integrity sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA== evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: version "1.0.3" @@ -18682,6 +18759,13 @@ express@^4.17.1, express@^4.17.3, express@^4.18.2, express@^4.21.0: utils-merge "1.0.1" vary "~1.1.2" +ext@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/ext/-/ext-1.7.0.tgz#0ea4383c0103d60e70be99e9a7f11027a33c4f5f" + integrity sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw== + dependencies: + type "^2.7.2" + extend-shallow@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" @@ -19836,7 +19920,7 @@ glob@8.1.0: minimatch "^5.0.1" once "^1.3.0" -glob@^10.0.0, glob@^10.3.7: +glob@^10.0.0, glob@^10.3.12, glob@^10.3.7: version "10.4.5" resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== @@ -20057,12 +20141,10 @@ got@^11.8.2: p-cancelable "^2.0.0" responselike "^2.0.0" -gpt-tokenizer@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/gpt-tokenizer/-/gpt-tokenizer-2.1.2.tgz#14f7ce424cf2309fb5be66e112d1836080c2791a" - integrity sha512-HSuI5d6uey+c7x/VzQlPfCoGrfLyAc28vxWofKbjR9PJHm0AjQGSWkKw/OJnb+8S1g7nzgRsf0WH3dK+NNWYbg== - dependencies: - rfc4648 "^1.5.2" +gpt-tokenizer@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/gpt-tokenizer/-/gpt-tokenizer-2.6.2.tgz#90e6932c7b5f73df7c13d360802edb43a2776586" + integrity sha512-OznIET3z069FiwbLtLFXJ9pVESYAa8EnX0BMogs6YJ4Fn2FIcyeZYEbxsp2grPiK0DVaqP1f+0JR/8t9R7/jlg== graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.1.9, graceful-fs@^4.2.0, graceful-fs@^4.2.10, graceful-fs@^4.2.11, graceful-fs@^4.2.4, graceful-fs@^4.2.6, graceful-fs@^4.2.8, graceful-fs@^4.2.9: version "4.2.11" @@ -21572,10 +21654,10 @@ is-primitive@^3.0.1: resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-3.0.1.tgz#98c4db1abff185485a657fc2905052b940524d05" integrity sha512-GljRxhWvlCNRfZyORiH77FwdFwGcMO620o37EOYC0ORWdq+WYNVqW0w2Juzew4M+L81l6/QS3t5gkkihyRqv9w== -is-promise@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa" - integrity sha1-eaKp7OfwlugPNtKy87wWwf9L8/o= +is-promise@^2.1.0, is-promise@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1" + integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ== is-regex@^1.0.4, is-regex@^1.0.5, is-regex@^1.1.2, is-regex@^1.1.4: version "1.1.4" @@ -22543,10 +22625,10 @@ js-string-escape@^1.0.1: resolved "https://registry.yarnpkg.com/js-string-escape/-/js-string-escape-1.0.1.tgz#e2625badbc0d67c7533e9edc1068c587ae4137ef" integrity sha1-4mJbrbwNZ8dTPp7cEGjFh65BN+8= -js-tiktoken@^1.0.12: - version "1.0.12" - resolved "https://registry.yarnpkg.com/js-tiktoken/-/js-tiktoken-1.0.12.tgz#af0f5cf58e5e7318240d050c8413234019424211" - integrity sha512-L7wURW1fH9Qaext0VzaUDpFGVQgjkdE3Dgsy9/+yXyGEpBKnylTd0mU0bfbNkKDlXRb6TEsZkwuflu1B8uQbJQ== +js-tiktoken@^1.0.12, js-tiktoken@^1.0.14: + version "1.0.15" + resolved "https://registry.yarnpkg.com/js-tiktoken/-/js-tiktoken-1.0.15.tgz#92a7d829f6950c2cfb35cc52555502e3d6e2ebac" + integrity sha512-65ruOWWXDEZHHbAo7EjOcNxOGasQKbL4Fq3jEr2xsCqSsoOo6VVSqzWQb6PRIqypFSDcma4jO90YP0w5X8qVXQ== dependencies: base64-js "^1.5.1" @@ -22660,6 +22742,24 @@ json-schema-to-ts@^2.9.1: "@types/json-schema" "^7.0.9" ts-algebra "^1.2.0" +json-schema-to-typescript@^14.1.0: + version "14.1.0" + resolved "https://registry.yarnpkg.com/json-schema-to-typescript/-/json-schema-to-typescript-14.1.0.tgz#31160d9cf13bf8f948a7ccefdf97e995bc183591" + integrity sha512-VIeAFQkn88gFh26MSHWG4uX7TjK/arTw0NVLMZn6vX1WrSF+P6xu5MyEdovu+9PJ0uiS5gm0wzwQvYW9eSq1uw== + dependencies: + "@apidevtools/json-schema-ref-parser" "^11.5.5" + "@types/json-schema" "^7.0.15" + "@types/lodash" "^4.17.0" + cli-color "^2.0.4" + glob "^10.3.12" + is-glob "^4.0.3" + js-yaml "^4.1.0" + lodash "^4.17.21" + minimist "^1.2.8" + mkdirp "^3.0.1" + node-fetch "^3.3.2" + prettier "^3.2.5" + json-schema-traverse@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" @@ -22968,10 +23068,10 @@ kuler@^2.0.0: zod "^3.22.4" zod-to-json-schema "^3.22.3" -langsmith@^0.2.0, langsmith@^0.2.3: - version "0.2.3" - resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.2.3.tgz#91c5b4e3d5b030d8a995e9acaac43dba4b9b225c" - integrity sha512-SPMYPVqR9kwXZVmJ2PXC61HeBnXIFHrjfjDxQ14H0+n5p4gqjLzgSHIQyxBlFeWQUQzArJxe65Ap+s+Xo1cZog== +langsmith@^0.2.0, langsmith@^0.2.5: + version "0.2.5" + resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.2.5.tgz#0e58900bcdfb47409222dd6c8c122c50f5015337" + integrity sha512-dA+l7ZEh1Q9Q9FcE39PUSSEMfsFo73R2V81fRo5KSlGNcypOEhoQvv6lbjyZP7MHmt3/9pPcfpuRd5Y4RbFYqQ== dependencies: "@types/uuid" "^10.0.0" commander "^10.0.1" @@ -23519,6 +23619,13 @@ lru-cache@^7.14.1: resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89" integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== +lru-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/lru-queue/-/lru-queue-0.1.0.tgz#2738bd9f0d3cf4f84490c5736c48699ac632cda3" + integrity sha1-Jzi9nw089PhEkMVzbEhpmsYyzaM= + dependencies: + es5-ext "~0.10.2" + lunr@^2.3.9: version "2.3.9" resolved "https://registry.yarnpkg.com/lunr/-/lunr-2.3.9.tgz#18b123142832337dd6e964df1a5a7707b25d35e1" @@ -23898,6 +24005,20 @@ memoize@^10.0.0: dependencies: mimic-function "^5.0.0" +memoizee@^0.4.15: + version "0.4.17" + resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.17.tgz#942a5f8acee281fa6fb9c620bddc57e3b7382949" + integrity sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA== + dependencies: + d "^1.0.2" + es5-ext "^0.10.64" + es6-weak-map "^2.0.3" + event-emitter "^0.3.5" + is-promise "^2.2.2" + lru-queue "^0.1.0" + next-tick "^1.1.0" + timers-ext "^0.1.7" + memoizerific@^1.11.3: version "1.11.3" resolved "https://registry.yarnpkg.com/memoizerific/-/memoizerific-1.11.3.tgz#7c87a4646444c32d75438570905f2dbd1b1a805a" @@ -24801,6 +24922,11 @@ next-line@^1.1.0: resolved "https://registry.yarnpkg.com/next-line/-/next-line-1.1.0.tgz#fcae57853052b6a9bae8208e40dd7d3c2d304603" integrity sha1-/K5XhTBStqm66CCOQN19PC0wRgM= +next-tick@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb" + integrity sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ== + nice-napi@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/nice-napi/-/nice-napi-1.0.2.tgz#dc0ab5a1eac20ce548802fc5686eaa6bc654927b" @@ -25440,10 +25566,10 @@ open@^8.0.9, open@^8.4.0, open@~8.4.0: is-docker "^2.1.1" is-wsl "^2.2.0" -openai@^4.68.0: - version "4.69.0" - resolved "https://registry.yarnpkg.com/openai/-/openai-4.69.0.tgz#ac2463719280987e506e4bd62dd477337e2406a1" - integrity sha512-S3hOHSkk609KqwgH+7dwFrSvO3Gm3Nk0YWGyPHNscoMH/Y2tH1qunMi7gtZnLbUv4/N1elqCp6bDior2401kCQ== +openai@^4.68.0, openai@^4.72.0: + version "4.72.0" + resolved "https://registry.yarnpkg.com/openai/-/openai-4.72.0.tgz#61630553157a0c9bb1c304b1dd4d2f90e9ec4cf7" + integrity sha512-hFqG9BWCs7L7ifrhJXw7mJXmUBr7d9N6If3J9563o0jfwVA4wFANFDDaOIWFdgDdwgCXg5emf0Q+LoLCGszQYA== dependencies: "@types/node" "^18.11.18" "@types/node-fetch" "^2.6.4" @@ -26722,6 +26848,11 @@ prettier@^2.0.0, prettier@^2.8.8: resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da" integrity sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q== +prettier@^3.2.5: + version "3.3.3" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.3.tgz#30c54fe0be0d8d12e6ae61dbb10109ea00d53105" + integrity sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew== + pretty-bytes@^5.6.0: version "5.6.0" resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb" @@ -28649,11 +28780,6 @@ reusify@^1.0.4: resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== -rfc4648@^1.5.2: - version "1.5.2" - resolved "https://registry.yarnpkg.com/rfc4648/-/rfc4648-1.5.2.tgz#cf5dac417dd83e7f4debf52e3797a723c1373383" - integrity sha512-tLOizhR6YGovrEBLatX1sdcuhoSCXddw3mqNVAcKxGJ+J0hFeJ+SjeWCv5UPA/WU3YzWPPuCVYgXBKZUPGpKtg== - rfdc@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.4.1.tgz#778f76c4fb731d93414e8f925fbecf64cce7f6ca" @@ -30774,10 +30900,10 @@ tabbable@^5.3.3: resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-5.3.3.tgz#aac0ff88c73b22d6c3c5a50b1586310006b47fbf" integrity sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA== -table@^6.8.0, table@^6.8.1: - version "6.8.1" - resolved "https://registry.yarnpkg.com/table/-/table-6.8.1.tgz#ea2b71359fe03b017a5fbc296204471158080bdf" - integrity sha512-Y4X9zqrCftUhMeH2EptSSERdVKt/nEdijTOacGD/97EKjhQ/Qs8RTlEGABSJNNN8lac9kheH+af7yAkEWlgneA== +table@^6.8.0, table@^6.8.2: + version "6.8.2" + resolved "https://registry.yarnpkg.com/table/-/table-6.8.2.tgz#c5504ccf201213fa227248bdc8c5569716ac6c58" + integrity sha512-w2sfv80nrAh2VCbqR5AK27wswXhqcck2AhfnNW76beQXskGZ1V12GwS//yYVa3d3fcvAip2OUnbDAjW2k3v9fA== dependencies: ajv "^8.0.1" lodash.truncate "^4.4.2" @@ -31108,6 +31234,14 @@ timers-browserify@^2.0.4: dependencies: setimmediate "^1.0.4" +timers-ext@^0.1.7: + version "0.1.8" + resolved "https://registry.yarnpkg.com/timers-ext/-/timers-ext-0.1.8.tgz#b4e442f10b7624a29dd2aa42c295e257150cf16c" + integrity sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww== + dependencies: + es5-ext "^0.10.64" + next-tick "^1.1.0" + tiny-inflate@^1.0.0, tiny-inflate@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.3.tgz#122715494913a1805166aaf7c93467933eea26c4" @@ -31550,6 +31684,11 @@ type-is@~1.6.18: media-typer "0.3.0" mime-types "~2.1.24" +type@^2.7.2: + version "2.7.3" + resolved "https://registry.yarnpkg.com/type/-/type-2.7.3.tgz#436981652129285cc3ba94f392886c2637ea0486" + integrity sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ== + typed-array-buffer@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz#1867c5d83b20fcb5ccf32649e5e2fc7424474ff3" @@ -31640,7 +31779,7 @@ typescript-tuple@^2.2.1: dependencies: typescript-compare "^0.0.2" -typescript@5, typescript@5.1.6, typescript@^3.3.3333, typescript@^5.0.4: +typescript@5, typescript@5.1.6, typescript@^3.3.3333, typescript@^5.0.4, typescript@^5.5.3: version "5.1.6" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.1.6.tgz#02f8ac202b6dad2c0dd5e0913745b47a37998274" integrity sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==