diff --git a/.buildkite/ftr_oblt_stateful_configs.yml b/.buildkite/ftr_oblt_stateful_configs.yml index 6f0cb38be3a62..7655ce6de38cf 100644 --- a/.buildkite/ftr_oblt_stateful_configs.yml +++ b/.buildkite/ftr_oblt_stateful_configs.yml @@ -30,7 +30,6 @@ enabled: - x-pack/test/api_integration/apis/metrics_ui/config.ts - x-pack/test/api_integration/apis/osquery/config.ts - x-pack/test/api_integration/apis/synthetics/config.ts - - x-pack/test/api_integration/apis/slos/config.ts - x-pack/test/api_integration/apis/uptime/config.ts - x-pack/test/api_integration/apis/entity_manager/config.ts - x-pack/test/apm_api_integration/basic/config.ts diff --git a/.buildkite/ftr_security_serverless_configs.yml b/.buildkite/ftr_security_serverless_configs.yml index c3532be5a8b47..834db3ce9849e 100644 --- a/.buildkite/ftr_security_serverless_configs.yml +++ b/.buildkite/ftr_security_serverless_configs.yml @@ -1,17 +1,17 @@ disabled: # Base config files, only necessary to inform config finding script - - x-pack/test_serverless/functional/test_suites/security/cypress/security_config.base.ts - - x-pack/test_serverless/functional/test_suites/security/cypress/cypress.config.ts - x-pack/test/security_solution_api_integration/config/serverless/config.base.ts - x-pack/test/security_solution_api_integration/config/serverless/config.base.essentials.ts - x-pack/test/security_solution_api_integration/config/serverless/config.base.edr_workflows.ts + - x-pack/test/defend_workflows_cypress/serverless_config.base.ts + - x-pack/test/osquery_cypress/serverless_config.base.ts # Cypress configs, for now these are still run manually - x-pack/test/defend_workflows_cypress/serverless_config.ts - x-pack/test/osquery_cypress/serverless_cli_config.ts - - x-pack/test_serverless/functional/test_suites/security/cypress/security_config.ts - x-pack/test/security_solution_cypress/serverless_config.ts + # Playwright - x-pack/test/security_solution_playwright/serverless_config.ts @@ -42,13 +42,23 @@ disabled: - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/actions/trial_license_complete_tier/configs/serverless.config.ts - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/alerts/basic_license_essentials_tier/configs/serverless.config.ts - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/alerts/trial_license_complete_tier/configs/serverless.config.ts - - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/operators_data_types/date_numeric_types/basic_license_essentials_tier/configs/serverless.config.ts + - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/operators_data_types/date_types/basic_license_essentials_tier/configs/serverless.config.ts + - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/operators_data_types/float/basic_license_essentials_tier/configs/serverless.config.ts + - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/operators_data_types/integer/basic_license_essentials_tier/configs/serverless.config.ts + - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/operators_data_types/double/basic_license_essentials_tier/configs/serverless.config.ts - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/operators_data_types/ips/basic_license_essentials_tier/configs/serverless.config.ts - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/operators_data_types/keyword/basic_license_essentials_tier/configs/serverless.config.ts - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/operators_data_types/long/basic_license_essentials_tier/configs/serverless.config.ts - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/operators_data_types/text/basic_license_essentials_tier/configs/serverless.config.ts - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/workflows/basic_license_essentials_tier/configs/serverless.config.ts - - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/configs/serverless.config.ts + - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/eql/trial_license_complete_tier/configs/serverless.config.ts + - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/esql/trial_license_complete_tier/configs/serverless.config.ts + - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/general_logic/trial_license_complete_tier/configs/serverless.config.ts + - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/indicator_match/trial_license_complete_tier/configs/serverless.config.ts + - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/machine_learning/trial_license_complete_tier/configs/serverless.config.ts + - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/new_terms/trial_license_complete_tier/configs/serverless.config.ts + - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/query/trial_license_complete_tier/configs/serverless.config.ts + - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/threshold/trial_license_complete_tier/configs/serverless.config.ts - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_gaps/trial_license_complete_tier/configs/serverless.config.ts - x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_creation/basic_license_essentials_tier/configs/serverless.config.ts - x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_creation/trial_license_complete_tier/configs/serverless.config.ts @@ -80,6 +90,9 @@ disabled: - x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/basic_license_essentials_tier/configs/serverless.config.ts - x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/configs/serverless.config.ts - x-pack/test/security_solution_api_integration/test_suites/lists_and_exception_lists/exception_lists_items/trial_license_complete_tier/configs/serverless.config.ts + - x-pack/test/security_solution_api_integration/test_suites/lists_and_exception_lists/authorization/exceptions/lists/essentials_tier/configs/serverless.config.ts + - x-pack/test/security_solution_api_integration/test_suites/lists_and_exception_lists/authorization/exceptions/common/essentials_tier/configs/serverless.config.ts + - x-pack/test/security_solution_api_integration/test_suites/lists_and_exception_lists/authorization/exceptions/items/essentials_tier/configs/serverless.config.ts - x-pack/test/security_solution_api_integration/test_suites/lists_and_exception_lists/lists_items/trial_license_complete_tier/configs/serverless.config.ts - x-pack/test/security_solution_api_integration/test_suites/explore/hosts/trial_license_complete_tier/configs/serverless.config.ts - x-pack/test/security_solution_api_integration/test_suites/explore/network/trial_license_complete_tier/configs/serverless.config.ts diff --git a/.buildkite/ftr_security_stateful_configs.yml b/.buildkite/ftr_security_stateful_configs.yml index aa37c6f52fb8c..8f780e081b11f 100644 --- a/.buildkite/ftr_security_stateful_configs.yml +++ b/.buildkite/ftr_security_stateful_configs.yml @@ -30,13 +30,23 @@ enabled: - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/actions/trial_license_complete_tier/configs/ess.config.ts - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/alerts/basic_license_essentials_tier/configs/ess.config.ts - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/alerts/trial_license_complete_tier/configs/ess.config.ts - - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/operators_data_types/date_numeric_types/basic_license_essentials_tier/configs/ess.config.ts + - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/operators_data_types/date_types/basic_license_essentials_tier/configs/ess.config.ts + - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/operators_data_types/float/basic_license_essentials_tier/configs/ess.config.ts + - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/operators_data_types/integer/basic_license_essentials_tier/configs/ess.config.ts + - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/operators_data_types/double/basic_license_essentials_tier/configs/ess.config.ts - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/operators_data_types/ips/basic_license_essentials_tier/configs/ess.config.ts - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/operators_data_types/keyword/basic_license_essentials_tier/configs/ess.config.ts - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/operators_data_types/long/basic_license_essentials_tier/configs/ess.config.ts - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/operators_data_types/text/basic_license_essentials_tier/configs/ess.config.ts - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/workflows/basic_license_essentials_tier/configs/ess.config.ts - - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/configs/ess.config.ts + - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/eql/trial_license_complete_tier/configs/ess.config.ts + - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/esql/trial_license_complete_tier/configs/ess.config.ts + - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/general_logic/trial_license_complete_tier/configs/ess.config.ts + - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/indicator_match/trial_license_complete_tier/configs/ess.config.ts + - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/machine_learning/trial_license_complete_tier/configs/ess.config.ts + - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/new_terms/trial_license_complete_tier/configs/ess.config.ts + - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/query/trial_license_complete_tier/configs/ess.config.ts + - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/threshold/trial_license_complete_tier/configs/ess.config.ts - x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_gaps/trial_license_complete_tier/configs/ess.config.ts - x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_creation/basic_license_essentials_tier/configs/ess.config.ts - x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_creation/trial_license_complete_tier/configs/ess.config.ts diff --git a/.buildkite/pipeline-utils/agent_images.ts b/.buildkite/pipeline-utils/agent_images.ts index cfe93ba67a1f4..c99b46f4bfd4e 100644 --- a/.buildkite/pipeline-utils/agent_images.ts +++ b/.buildkite/pipeline-utils/agent_images.ts @@ -59,7 +59,7 @@ const expandAgentQueue = (queueName: string = 'n2-4-spot') => { const additionalProps = { spot: { preemptible: true }, - virt: { localSsdInterface: 'nvme', enableNestedVirtualization: true, localSsds: 1 }, + virt: { enableNestedVirtualization: true }, }[addition] || {}; return { diff --git a/.buildkite/pipelines/artifacts.yml b/.buildkite/pipelines/artifacts.yml index b6f873ad2bd14..4765077287615 100644 --- a/.buildkite/pipelines/artifacts.yml +++ b/.buildkite/pipelines/artifacts.yml @@ -21,8 +21,6 @@ steps: imageProject: elastic-images-prod provider: gcp enableNestedVirtualization: true - localSsds: 1 - localSsdInterface: nvme machineType: n2-standard-4 timeout_in_minutes: 30 retry: @@ -37,8 +35,6 @@ steps: imageProject: elastic-images-prod provider: gcp enableNestedVirtualization: true - localSsds: 1 - localSsdInterface: nvme machineType: n2-standard-4 timeout_in_minutes: 30 retry: @@ -53,8 +49,6 @@ steps: imageProject: elastic-images-prod provider: gcp enableNestedVirtualization: true - localSsds: 1 - localSsdInterface: nvme machineType: n2-standard-4 timeout_in_minutes: 30 retry: @@ -68,8 +62,6 @@ steps: image: family/kibana-ubuntu-2004 imageProject: elastic-images-prod provider: gcp - localSsds: 1 - localSsdInterface: nvme machineType: n2-standard-2 timeout_in_minutes: 30 retry: @@ -83,8 +75,6 @@ steps: image: family/kibana-ubuntu-2004 imageProject: elastic-images-prod provider: gcp - localSsds: 1 - localSsdInterface: nvme machineType: n2-standard-2 timeout_in_minutes: 30 retry: @@ -111,8 +101,6 @@ steps: image: family/kibana-ubuntu-2004 imageProject: elastic-images-prod provider: gcp - localSsds: 1 - localSsdInterface: nvme machineType: n2-standard-2 timeout_in_minutes: 30 retry: @@ -129,8 +117,6 @@ steps: image: family/kibana-ubuntu-2004 imageProject: elastic-images-prod provider: gcp - localSsds: 1 - localSsdInterface: nvme machineType: n2-standard-2 timeout_in_minutes: 60 if: "build.env('RELEASE_BUILD') == null || build.env('RELEASE_BUILD') == '' || build.env('RELEASE_BUILD') == 'false'" @@ -156,7 +142,5 @@ steps: image: family/kibana-ubuntu-2004 imageProject: elastic-images-prod provider: gcp - localSsds: 1 - localSsdInterface: nvme machineType: n2-standard-2 timeout_in_minutes: 30 diff --git a/.buildkite/pipelines/es_serverless/verify_es_serverless_image.yml b/.buildkite/pipelines/es_serverless/verify_es_serverless_image.yml index 8a2ec890d5e9f..85ce702580dbb 100644 --- a/.buildkite/pipelines/es_serverless/verify_es_serverless_image.yml +++ b/.buildkite/pipelines/es_serverless/verify_es_serverless_image.yml @@ -206,8 +206,6 @@ steps: imageProject: elastic-images-prod provider: gcp enableNestedVirtualization: true - localSsds: 1 - localSsdInterface: nvme machineType: n2-standard-4 depends_on: build timeout_in_minutes: 60 diff --git a/.buildkite/pipelines/on_merge.yml b/.buildkite/pipelines/on_merge.yml index 3705a2c902642..a7825e01a3b22 100644 --- a/.buildkite/pipelines/on_merge.yml +++ b/.buildkite/pipelines/on_merge.yml @@ -39,7 +39,20 @@ steps: provider: gcp machineType: n2-highcpu-8 preemptible: true - key: quick_checks + timeout_in_minutes: 60 + retry: + automatic: + - exit_status: '-1' + limit: 3 + + - command: .buildkite/scripts/steps/checks.sh + label: 'Checks' + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-2 + preemptible: true timeout_in_minutes: 60 retry: automatic: @@ -54,7 +67,6 @@ steps: provider: gcp machineType: n2-standard-16 preemptible: true - key: linting timeout_in_minutes: 60 retry: automatic: @@ -69,8 +81,37 @@ steps: provider: gcp machineType: n2-standard-32 preemptible: true - key: linting_with_types - timeout_in_minutes: 90 + timeout_in_minutes: 60 + retry: + automatic: + - exit_status: '-1' + limit: 3 + + - command: .buildkite/scripts/steps/check_types.sh + label: 'Check Types' + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: c4-standard-4 + diskType: 'hyperdisk-balanced' + preemptible: true + spotZones: us-central1-a,us-central1-b,us-central1-c + timeout_in_minutes: 60 + retry: + automatic: + - exit_status: '-1' + limit: 3 + + - command: .buildkite/scripts/steps/checks/capture_oas_snapshot.sh + label: 'Check OAS Snapshot' + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + preemptible: true + timeout_in_minutes: 60 retry: automatic: - exit_status: '-1' @@ -136,11 +177,6 @@ steps: provider: gcp machineType: n2-standard-4 preemptible: true - depends_on: - - build - - quick_checks - - linting - - linting_with_types timeout_in_minutes: 60 parallelism: 2 retry: @@ -156,11 +192,6 @@ steps: provider: gcp machineType: n2-standard-4 preemptible: true - depends_on: - - build - - quick_checks - - linting - - linting_with_types timeout_in_minutes: 60 parallelism: 4 retry: @@ -176,11 +207,6 @@ steps: provider: gcp machineType: n2-standard-4 preemptible: true - depends_on: - - build - - quick_checks - - linting - - linting_with_types timeout_in_minutes: 60 parallelism: 6 retry: @@ -196,11 +222,6 @@ steps: provider: gcp machineType: n2-standard-4 preemptible: true - depends_on: - - build - - quick_checks - - linting - - linting_with_types timeout_in_minutes: 60 parallelism: 5 retry: @@ -216,11 +237,6 @@ steps: provider: gcp machineType: n2-standard-4 preemptible: true - depends_on: - - build - - quick_checks - - linting - - linting_with_types timeout_in_minutes: 60 parallelism: 6 retry: @@ -236,11 +252,6 @@ steps: provider: gcp machineType: n2-standard-4 preemptible: true - depends_on: - - build - - quick_checks - - linting - - linting_with_types timeout_in_minutes: 60 parallelism: 1 retry: @@ -256,11 +267,6 @@ steps: provider: gcp machineType: n2-standard-4 preemptible: true - depends_on: - - build - - quick_checks - - linting - - linting_with_types timeout_in_minutes: 60 parallelism: 2 retry: @@ -276,11 +282,6 @@ steps: provider: gcp machineType: n2-standard-4 preemptible: true - depends_on: - - build - - quick_checks - - linting - - linting_with_types timeout_in_minutes: 60 parallelism: 2 retry: @@ -296,11 +297,6 @@ steps: provider: gcp machineType: n2-standard-4 preemptible: true - depends_on: - - build - - quick_checks - - linting - - linting_with_types timeout_in_minutes: 60 parallelism: 6 retry: @@ -316,11 +312,6 @@ steps: provider: gcp machineType: n2-standard-4 preemptible: true - depends_on: - - build - - quick_checks - - linting - - linting_with_types timeout_in_minutes: 60 parallelism: 8 retry: @@ -335,14 +326,7 @@ steps: imageProject: elastic-images-prod provider: gcp enableNestedVirtualization: true - localSsds: 1 - localSsdInterface: nvme machineType: n2-standard-4 - depends_on: - - build - - quick_checks - - linting - - linting_with_types timeout_in_minutes: 60 parallelism: 20 retry: @@ -353,59 +337,12 @@ steps: - command: '.buildkite/scripts/steps/functional/on_merge_unsupported_ftrs.sh' label: Trigger unsupported ftr tests timeout_in_minutes: 10 - depends_on: - - build - - quick_checks - - linting - - linting_with_types agents: image: family/kibana-ubuntu-2004 imageProject: elastic-images-prod provider: gcp machineType: n2-standard-2 - - command: .buildkite/scripts/steps/checks.sh - label: 'Checks' - agents: - image: family/kibana-ubuntu-2004 - imageProject: elastic-images-prod - provider: gcp - machineType: n2-standard-2 - preemptible: true - timeout_in_minutes: 60 - retry: - automatic: - - exit_status: '-1' - limit: 3 - - - command: .buildkite/scripts/steps/check_types.sh - label: 'Check Types' - agents: - image: family/kibana-ubuntu-2004 - imageProject: elastic-images-prod - provider: gcp - machineType: n2-standard-4 - preemptible: true - timeout_in_minutes: 70 - retry: - automatic: - - exit_status: '-1' - limit: 3 - - - command: .buildkite/scripts/steps/checks/capture_oas_snapshot.sh - label: 'Check OAS Snapshot' - agents: - image: family/kibana-ubuntu-2004 - imageProject: elastic-images-prod - provider: gcp - machineType: n2-standard-2 - preemptible: true - timeout_in_minutes: 60 - retry: - automatic: - - exit_status: '-1' - limit: 3 - - command: .buildkite/scripts/steps/storybooks/build_and_upload.sh label: 'Build Storybooks' agents: diff --git a/.buildkite/pipelines/pointer_compression.yml b/.buildkite/pipelines/pointer_compression.yml index 41598b3faed1f..29f0b75ca4184 100644 --- a/.buildkite/pipelines/pointer_compression.yml +++ b/.buildkite/pipelines/pointer_compression.yml @@ -362,8 +362,6 @@ steps: imageProject: elastic-images-prod provider: gcp enableNestedVirtualization: true - localSsds: 1 - localSsdInterface: nvme machineType: n2-standard-4 depends_on: - build @@ -381,8 +379,6 @@ steps: imageProject: elastic-images-prod provider: gcp enableNestedVirtualization: true - localSsds: 1 - localSsdInterface: nvme machineType: n2-standard-4 depends_on: - build diff --git a/.buildkite/pipelines/pull_request/apm_cypress.yml b/.buildkite/pipelines/pull_request/apm_cypress.yml index 9d2cca6d9d452..97935cc3489d1 100644 --- a/.buildkite/pipelines/pull_request/apm_cypress.yml +++ b/.buildkite/pipelines/pull_request/apm_cypress.yml @@ -7,8 +7,11 @@ steps: depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 120 parallelism: 1 # TODO: Set parallelism when apm_cypress handles it retry: diff --git a/.buildkite/pipelines/pull_request/base.yml b/.buildkite/pipelines/pull_request/base.yml index fdc80e6cb8595..e7b593f464b54 100644 --- a/.buildkite/pipelines/pull_request/base.yml +++ b/.buildkite/pipelines/pull_request/base.yml @@ -32,6 +32,18 @@ steps: - exit_status: '-1' limit: 3 + - command: .buildkite/scripts/steps/checks.sh + label: 'Checks' + key: checks + agents: + machineType: n2-standard-2 + preemptible: true + timeout_in_minutes: 60 + retry: + automatic: + - exit_status: '-1' + limit: 3 + - command: .buildkite/scripts/steps/lint.sh label: 'Linting' agents: @@ -50,7 +62,33 @@ steps: machineType: n2-standard-32 preemptible: true key: linting_with_types - timeout_in_minutes: 90 + timeout_in_minutes: 60 + retry: + automatic: + - exit_status: '-1' + limit: 3 + + - command: .buildkite/scripts/steps/checks/capture_oas_snapshot.sh + label: 'Check OAS Snapshot' + agents: + machineType: n2-standard-4 + preemptible: true + key: check_oas_snapshot + timeout_in_minutes: 60 + retry: + automatic: + - exit_status: '-1' + limit: 3 + + - command: .buildkite/scripts/steps/check_types.sh + label: 'Check Types' + agents: + machineType: c4-standard-4 + diskType: 'hyperdisk-balanced' + preemptible: true + spotZones: us-central1-a,us-central1-b,us-central1-c + key: check_types + timeout_in_minutes: 60 retry: automatic: - exit_status: '-1' @@ -85,41 +123,6 @@ steps: - exit_status: '*' limit: 1 - - command: .buildkite/scripts/steps/check_types.sh - label: 'Check Types' - agents: - machineType: n2-standard-4 - preemptible: true - key: check_types - timeout_in_minutes: 70 - retry: - automatic: - - exit_status: '-1' - limit: 3 - - - command: .buildkite/scripts/steps/checks.sh - label: 'Checks' - key: checks - agents: - machineType: n2-standard-2 - preemptible: true - timeout_in_minutes: 60 - retry: - automatic: - - exit_status: '-1' - limit: 3 - - - command: .buildkite/scripts/steps/checks/capture_oas_snapshot.sh - label: 'Check OAS Snapshot' - agents: - machineType: n2-standard-2 - preemptible: true - timeout_in_minutes: 60 - retry: - automatic: - - exit_status: '-1' - limit: 3 - - command: .buildkite/scripts/steps/api_docs/build_api_docs.sh label: 'Build API Docs' agents: diff --git a/.buildkite/pipelines/pull_request/deploy_cloud.yml b/.buildkite/pipelines/pull_request/deploy_cloud.yml index e82d1ef2e494c..565c5af3bb0c1 100644 --- a/.buildkite/pipelines/pull_request/deploy_cloud.yml +++ b/.buildkite/pipelines/pull_request/deploy_cloud.yml @@ -7,8 +7,11 @@ steps: depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 30 soft_fail: true retry: diff --git a/.buildkite/pipelines/pull_request/exploratory_view_plugin.yml b/.buildkite/pipelines/pull_request/exploratory_view_plugin.yml index 42aaf59b1c1f2..05fc218080cd4 100644 --- a/.buildkite/pipelines/pull_request/exploratory_view_plugin.yml +++ b/.buildkite/pipelines/pull_request/exploratory_view_plugin.yml @@ -7,8 +7,11 @@ steps: depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 60 artifact_paths: - 'x-pack/plugins/observability_solution/exploratory_view/e2e/.journeys/**/*' diff --git a/.buildkite/pipelines/pull_request/fips.yml b/.buildkite/pipelines/pull_request/fips.yml index 3fa0ed9bd2062..4f906e4420c8f 100644 --- a/.buildkite/pipelines/pull_request/fips.yml +++ b/.buildkite/pipelines/pull_request/fips.yml @@ -7,8 +7,11 @@ steps: depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 60 soft_fail: true retry: diff --git a/.buildkite/pipelines/pull_request/fleet_cypress.yml b/.buildkite/pipelines/pull_request/fleet_cypress.yml index 071106209caaa..50912cc16e5a8 100644 --- a/.buildkite/pipelines/pull_request/fleet_cypress.yml +++ b/.buildkite/pipelines/pull_request/fleet_cypress.yml @@ -7,8 +7,11 @@ steps: depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 50 parallelism: 6 retry: diff --git a/.buildkite/pipelines/pull_request/inventory_cypress.yml b/.buildkite/pipelines/pull_request/inventory_cypress.yml index b1a8b999f09f2..3cd96de506288 100644 --- a/.buildkite/pipelines/pull_request/inventory_cypress.yml +++ b/.buildkite/pipelines/pull_request/inventory_cypress.yml @@ -7,8 +7,11 @@ steps: depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 120 parallelism: 1 retry: diff --git a/.buildkite/pipelines/pull_request/kbn_handlebars.yml b/.buildkite/pipelines/pull_request/kbn_handlebars.yml index ad338ec425a04..187058d238682 100644 --- a/.buildkite/pipelines/pull_request/kbn_handlebars.yml +++ b/.buildkite/pipelines/pull_request/kbn_handlebars.yml @@ -7,8 +7,11 @@ steps: depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 5 retry: automatic: diff --git a/.buildkite/pipelines/pull_request/observability_onboarding_cypress.yml b/.buildkite/pipelines/pull_request/observability_onboarding_cypress.yml index d0afe1cd138da..b0d438064d51e 100644 --- a/.buildkite/pipelines/pull_request/observability_onboarding_cypress.yml +++ b/.buildkite/pipelines/pull_request/observability_onboarding_cypress.yml @@ -7,8 +7,11 @@ steps: depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 120 retry: automatic: diff --git a/.buildkite/pipelines/pull_request/profiling_cypress.yml b/.buildkite/pipelines/pull_request/profiling_cypress.yml index 2b86cffe75fa6..8ed98a4fbc736 100644 --- a/.buildkite/pipelines/pull_request/profiling_cypress.yml +++ b/.buildkite/pipelines/pull_request/profiling_cypress.yml @@ -7,8 +7,11 @@ steps: depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 120 parallelism: 2 retry: diff --git a/.buildkite/pipelines/pull_request/response_ops.yml b/.buildkite/pipelines/pull_request/response_ops.yml index a5c9b27ee7ecf..9ac20e86f6660 100644 --- a/.buildkite/pipelines/pull_request/response_ops.yml +++ b/.buildkite/pipelines/pull_request/response_ops.yml @@ -7,8 +7,11 @@ steps: depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 120 parallelism: 9 retry: diff --git a/.buildkite/pipelines/pull_request/response_ops_cases.yml b/.buildkite/pipelines/pull_request/response_ops_cases.yml index 994fbb6c4963a..27289c864e2c1 100644 --- a/.buildkite/pipelines/pull_request/response_ops_cases.yml +++ b/.buildkite/pipelines/pull_request/response_ops_cases.yml @@ -7,8 +7,11 @@ steps: depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 120 retry: automatic: diff --git a/.buildkite/pipelines/pull_request/security_solution/ai_assistant.yml b/.buildkite/pipelines/pull_request/security_solution/ai_assistant.yml index 6b87f41d585f8..9f9b606dced1d 100644 --- a/.buildkite/pipelines/pull_request/security_solution/ai_assistant.yml +++ b/.buildkite/pipelines/pull_request/security_solution/ai_assistant.yml @@ -7,8 +7,11 @@ steps: depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 60 parallelism: 1 retry: diff --git a/.buildkite/pipelines/pull_request/security_solution/cloud_security_posture.yml b/.buildkite/pipelines/pull_request/security_solution/cloud_security_posture.yml index 93fad6eecf167..e1ba84bf4f661 100644 --- a/.buildkite/pipelines/pull_request/security_solution/cloud_security_posture.yml +++ b/.buildkite/pipelines/pull_request/security_solution/cloud_security_posture.yml @@ -7,8 +7,11 @@ steps: depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 60 parallelism: 1 retry: @@ -24,8 +27,11 @@ steps: depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 60 parallelism: 1 retry: diff --git a/.buildkite/pipelines/pull_request/security_solution/cypress_burn.yml b/.buildkite/pipelines/pull_request/security_solution/cypress_burn.yml index 1ba22d058e6c1..14d81aa9d3520 100644 --- a/.buildkite/pipelines/pull_request/security_solution/cypress_burn.yml +++ b/.buildkite/pipelines/pull_request/security_solution/cypress_burn.yml @@ -3,14 +3,15 @@ steps: label: '[Soft fail] Defend Workflows Cypress Tests, burning changed specs' agents: enableNestedVirtualization: true - localSsds: 1 - localSsdInterface: nvme machineType: n2-standard-4 depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 60 soft_fail: true parallelism: 1 @@ -25,8 +26,11 @@ steps: depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 60 parallelism: 1 retry: @@ -41,8 +45,11 @@ steps: depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 50 soft_fail: true retry: diff --git a/.buildkite/pipelines/pull_request/security_solution/defend_workflows.yml b/.buildkite/pipelines/pull_request/security_solution/defend_workflows.yml index ea7613fd81cba..28cc4f2812b5a 100644 --- a/.buildkite/pipelines/pull_request/security_solution/defend_workflows.yml +++ b/.buildkite/pipelines/pull_request/security_solution/defend_workflows.yml @@ -3,14 +3,15 @@ steps: label: 'Defend Workflows Cypress Tests' agents: enableNestedVirtualization: true - localSsds: 1 - localSsdInterface: nvme machineType: n2-standard-4 depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 60 parallelism: 20 retry: diff --git a/.buildkite/pipelines/pull_request/security_solution/detection_engine.yml b/.buildkite/pipelines/pull_request/security_solution/detection_engine.yml index f18d187aab9e7..8f3ed2dc2ff82 100644 --- a/.buildkite/pipelines/pull_request/security_solution/detection_engine.yml +++ b/.buildkite/pipelines/pull_request/security_solution/detection_engine.yml @@ -7,8 +7,11 @@ steps: depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 60 parallelism: 5 retry: @@ -24,8 +27,11 @@ steps: depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 60 parallelism: 2 retry: diff --git a/.buildkite/pipelines/pull_request/security_solution/entity_analytics.yml b/.buildkite/pipelines/pull_request/security_solution/entity_analytics.yml index 16e1860a1453c..1ff0999d33afd 100644 --- a/.buildkite/pipelines/pull_request/security_solution/entity_analytics.yml +++ b/.buildkite/pipelines/pull_request/security_solution/entity_analytics.yml @@ -7,8 +7,11 @@ steps: depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 60 parallelism: 2 retry: diff --git a/.buildkite/pipelines/pull_request/security_solution/explore.yml b/.buildkite/pipelines/pull_request/security_solution/explore.yml index 5fa4229e7dbde..749b480c5be50 100644 --- a/.buildkite/pipelines/pull_request/security_solution/explore.yml +++ b/.buildkite/pipelines/pull_request/security_solution/explore.yml @@ -7,8 +7,11 @@ steps: depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 60 parallelism: 2 retry: diff --git a/.buildkite/pipelines/pull_request/security_solution/investigations.yml b/.buildkite/pipelines/pull_request/security_solution/investigations.yml index 469f6d7a2c159..3126aa00d0327 100644 --- a/.buildkite/pipelines/pull_request/security_solution/investigations.yml +++ b/.buildkite/pipelines/pull_request/security_solution/investigations.yml @@ -7,12 +7,14 @@ steps: depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 60 parallelism: 7 retry: automatic: - exit_status: '-1' limit: 1 - diff --git a/.buildkite/pipelines/pull_request/security_solution/osquery_cypress.yml b/.buildkite/pipelines/pull_request/security_solution/osquery_cypress.yml index cb04f1559f3b1..55e5b7842f4f1 100644 --- a/.buildkite/pipelines/pull_request/security_solution/osquery_cypress.yml +++ b/.buildkite/pipelines/pull_request/security_solution/osquery_cypress.yml @@ -7,8 +7,11 @@ steps: depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 60 parallelism: 8 retry: diff --git a/.buildkite/pipelines/pull_request/security_solution/playwright.yml b/.buildkite/pipelines/pull_request/security_solution/playwright.yml index 98a939570b1be..af29f1e545ca2 100644 --- a/.buildkite/pipelines/pull_request/security_solution/playwright.yml +++ b/.buildkite/pipelines/pull_request/security_solution/playwright.yml @@ -7,8 +7,11 @@ steps: depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 60 parallelism: 1 retry: diff --git a/.buildkite/pipelines/pull_request/security_solution/rule_management.yml b/.buildkite/pipelines/pull_request/security_solution/rule_management.yml index 887e1c8597af7..085df0f65eeb5 100644 --- a/.buildkite/pipelines/pull_request/security_solution/rule_management.yml +++ b/.buildkite/pipelines/pull_request/security_solution/rule_management.yml @@ -7,8 +7,11 @@ steps: depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 60 parallelism: 4 retry: @@ -24,8 +27,11 @@ steps: depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 60 parallelism: 2 retry: diff --git a/.buildkite/pipelines/pull_request/slo_plugin_e2e.yml b/.buildkite/pipelines/pull_request/slo_plugin_e2e.yml index 025c80809ab35..2cf1126cf1f5d 100644 --- a/.buildkite/pipelines/pull_request/slo_plugin_e2e.yml +++ b/.buildkite/pipelines/pull_request/slo_plugin_e2e.yml @@ -7,8 +7,11 @@ steps: depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 30 artifact_paths: - 'x-pack/plugins/observability_solution/slo/e2e/.journeys/**/*' diff --git a/.buildkite/pipelines/pull_request/synthetics_plugin.yml b/.buildkite/pipelines/pull_request/synthetics_plugin.yml index 0707650aa7c01..b4079b9fac307 100644 --- a/.buildkite/pipelines/pull_request/synthetics_plugin.yml +++ b/.buildkite/pipelines/pull_request/synthetics_plugin.yml @@ -7,8 +7,11 @@ steps: depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 60 artifact_paths: - 'x-pack/plugins/observability_solution/synthetics/e2e/.journeys/**/*' diff --git a/.buildkite/pipelines/pull_request/uptime_plugin.yml b/.buildkite/pipelines/pull_request/uptime_plugin.yml index 33a529739ae6f..4c1e05d7476fd 100644 --- a/.buildkite/pipelines/pull_request/uptime_plugin.yml +++ b/.buildkite/pipelines/pull_request/uptime_plugin.yml @@ -7,8 +7,11 @@ steps: depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 60 artifact_paths: - 'x-pack/plugins/observability_solution/synthetics/e2e/.journeys/**/*' diff --git a/.buildkite/pipelines/pull_request/ux_plugin_e2e.yml b/.buildkite/pipelines/pull_request/ux_plugin_e2e.yml index 977701cc99485..4bade14464f35 100644 --- a/.buildkite/pipelines/pull_request/ux_plugin_e2e.yml +++ b/.buildkite/pipelines/pull_request/ux_plugin_e2e.yml @@ -7,8 +7,11 @@ steps: depends_on: - build - quick_checks + - checks - linting - linting_with_types + - check_types + - check_oas_snapshot timeout_in_minutes: 60 artifact_paths: - 'x-pack/plugins/observability_solution/ux/e2e/.journeys/**/*' diff --git a/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_defend_workflows.yml b/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_defend_workflows.yml index 5795c8f61f30f..5106d619f95e5 100644 --- a/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_defend_workflows.yml +++ b/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_defend_workflows.yml @@ -10,8 +10,6 @@ steps: imageProject: elastic-images-prod provider: gcp enableNestedVirtualization: true - localSsds: 1 - localSsdInterface: nvme machineType: n2-standard-4 preemptible: true timeout_in_minutes: 300 @@ -32,8 +30,6 @@ steps: # imageProject: elastic-images-prod # provider: gcp # enableNestedVirtualization: true -# localSsds: 1 -# localSsdInterface: nvme # machineType: n2-standard-4 # timeout_in_minutes: 120 # retry: @@ -49,8 +45,6 @@ steps: # imageProject: elastic-images-prod # provider: gcp # enableNestedVirtualization: true -# localSsds: 1 -# localSsdInterface: nvme # machineType: n2-standard-4 # timeout_in_minutes: 120 # retry: @@ -66,8 +60,6 @@ steps: # imageProject: elastic-images-prod # provider: gcp # enableNestedVirtualization: true -# localSsds: 1 -# localSsdInterface: nvme # machineType: n2-standard-4 # timeout_in_minutes: 120 # retry: @@ -83,8 +75,6 @@ steps: # imageProject: elastic-images-prod # provider: gcp # enableNestedVirtualization: true -# localSsds: 1 -# localSsdInterface: nvme # machineType: n2-standard-4 # timeout_in_minutes: 120 # retry: @@ -100,8 +90,6 @@ steps: imageProject: elastic-images-prod provider: gcp enableNestedVirtualization: true - localSsds: 1 - localSsdInterface: nvme machineType: n2-standard-4 preemptible: true timeout_in_minutes: 120 @@ -118,8 +106,6 @@ steps: imageProject: elastic-images-prod provider: gcp enableNestedVirtualization: true - localSsds: 1 - localSsdInterface: nvme machineType: n2-standard-4 preemptible: true timeout_in_minutes: 120 @@ -136,8 +122,6 @@ steps: imageProject: elastic-images-prod provider: gcp enableNestedVirtualization: true - localSsds: 1 - localSsdInterface: nvme machineType: n2-standard-4 preemptible: true timeout_in_minutes: 120 @@ -157,8 +141,6 @@ steps: imageProject: elastic-images-prod provider: gcp enableNestedVirtualization: true - localSsds: 1 - localSsdInterface: nvme machineType: n2-standard-4 preemptible: true timeout_in_minutes: 300 diff --git a/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_detection_engine.yml b/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_detection_engine.yml index e25c6dfef0e4b..2f6e329524c5d 100644 --- a/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_detection_engine.yml +++ b/.buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_detection_engine.yml @@ -1,12 +1,12 @@ steps: - - group: "Cypress MKI - Detection Engine" + - group: 'Cypress MKI - Detection Engine' key: cypress_test_detections_engine steps: - command: .buildkite/scripts/pipelines/security_solution_quality_gate/security_solution_cypress/mki_security_solution_cypress.sh cypress:run:qa:serverless:detection_engine - label: "Cypress MKI - Detection Engine" + label: 'Cypress MKI - Detection Engine' key: test_detection_engine env: - BK_TEST_SUITE_KEY: "serverless-cypress-detection-engine" + BK_TEST_SUITE_KEY: 'serverless-cypress-detection-engine' agents: image: family/kibana-ubuntu-2004 imageProject: elastic-images-prod @@ -18,10 +18,10 @@ steps: parallelism: 8 - command: .buildkite/scripts/pipelines/security_solution_quality_gate/security_solution_cypress/mki_security_solution_cypress.sh cypress:run:qa:serverless:detection_engine:exceptions - label: "Cypress MKI - Detection Engine - Exceptions" + label: 'Cypress MKI - Detection Engine - Exceptions' key: test_detection_engine_exceptions env: - BK_TEST_SUITE_KEY: "serverless-cypress-detection-engine" + BK_TEST_SUITE_KEY: 'serverless-cypress-detection-engine' agents: image: family/kibana-ubuntu-2004 imageProject: elastic-images-prod @@ -32,7 +32,7 @@ steps: timeout_in_minutes: 300 parallelism: 6 - - group: "API MKI - Detection Engine - " + - group: 'API MKI - Detection Engine - ' key: api_test_detections_engine steps: - label: Running exception_lists_items:qa:serverless @@ -47,7 +47,52 @@ steps: timeout_in_minutes: 120 retry: automatic: - - exit_status: "1" + - exit_status: '1' + limit: 2 + + - label: Running exception_lists:auth:lists:qa:serverless + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh exception_lists:auth:lists:qa:serverless + key: exception_lists:auth:lists:qa:serverless + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + preemptible: true + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: '1' + limit: 2 + + - label: Running exception_lists:auth:common:qa:serverless + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh exception_lists:auth:common:qa:serverless + key: exception_lists:auth:common:qa:serverless + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + preemptible: true + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: '1' + limit: 2 + + - label: Running exception_lists:auth:items:qa:serverless + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh exception_lists:auth:items:qa:serverless + key: exception_lists:auth:items:qa:serverless + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + preemptible: true + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: '1' limit: 2 - label: Running lists_items:qa:serverless @@ -62,7 +107,7 @@ steps: timeout_in_minutes: 120 retry: automatic: - - exit_status: "1" + - exit_status: '1' limit: 2 - label: Running user_roles:qa:serverless @@ -77,7 +122,7 @@ steps: timeout_in_minutes: 120 retry: automatic: - - exit_status: "1" + - exit_status: '1' limit: 2 - label: Running telemetry:qa:serverless @@ -92,7 +137,7 @@ steps: timeout_in_minutes: 120 retry: automatic: - - exit_status: "1" + - exit_status: '1' limit: 2 - label: Running exception_workflows:essentials:qa:serverless @@ -107,12 +152,57 @@ steps: timeout_in_minutes: 120 retry: automatic: - - exit_status: "1" + - exit_status: '1' + limit: 2 + + - label: Running exception_operators_date_types:essentials:qa:serverless + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh exception_operators_date_types:essentials:qa:serverless + key: exception_operators_date_types:essentials:qa:serverless + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + preemptible: true + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: '1' + limit: 2 + + - label: Running exception_operators_double:essentials:qa:serverless + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh exception_operators_double:essentials:qa:serverless + key: exception_operators_double:essentials:qa:serverless + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + preemptible: true + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: '1' + limit: 2 + + - label: Running exception_operators_float:essentials:qa:serverless + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh exception_operators_float:essentials:qa:serverless + key: exception_operators_float:essentials:qa:serverless + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + preemptible: true + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: '1' limit: 2 - - label: Running exception_operators_date_numeric_types:essentials:qa:serverless - command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh exception_operators_date_numeric_types:essentials:qa:serverless - key: exception_operators_date_numeric_types:essentials:qa:serverless + - label: Running exception_operators_integer:essentials:qa:serverless + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh exception_operators_integer:essentials:qa:serverless + key: exception_operators_integer:essentials:qa:serverless agents: image: family/kibana-ubuntu-2004 imageProject: elastic-images-prod @@ -122,7 +212,7 @@ steps: timeout_in_minutes: 120 retry: automatic: - - exit_status: "1" + - exit_status: '1' limit: 2 - label: Running exception_operators_keyword:essentials:qa:serverless @@ -137,7 +227,7 @@ steps: timeout_in_minutes: 120 retry: automatic: - - exit_status: "1" + - exit_status: '1' limit: 2 - label: Running exception_operators_ips:essentials:qa:serverless @@ -152,7 +242,7 @@ steps: timeout_in_minutes: 120 retry: automatic: - - exit_status: "1" + - exit_status: '1' limit: 2 - label: Running exception_operators_long:essentials:qa:serverless @@ -167,7 +257,7 @@ steps: timeout_in_minutes: 120 retry: automatic: - - exit_status: "1" + - exit_status: '1' limit: 2 - label: Running exception_operators_text:essentials:qa:serverless @@ -182,7 +272,7 @@ steps: timeout_in_minutes: 120 retry: automatic: - - exit_status: "1" + - exit_status: '1' limit: 2 - label: Running actions:qa:serverless @@ -197,7 +287,7 @@ steps: timeout_in_minutes: 120 retry: automatic: - - exit_status: "1" + - exit_status: '1' limit: 2 - label: Running alerts:qa:serverless @@ -212,7 +302,7 @@ steps: timeout_in_minutes: 120 retry: automatic: - - exit_status: "1" + - exit_status: '1' limit: 2 - label: Running alerts:essentials:qa:serverless @@ -227,12 +317,117 @@ steps: timeout_in_minutes: 120 retry: automatic: - - exit_status: "1" + - exit_status: '1' + limit: 2 + + - label: Running rule_execution_logic:eql:qa:serverless + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh rule_execution_logic:eql:qa:serverless + key: rule_execution_logic:eql:qa:serverless + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + preemptible: true + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: '1' + limit: 2 + + - label: Running rule_execution_logic:esql:qa:serverless + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh rule_execution_logic:esql:qa:serverless + key: rule_execution_logic:esql:qa:serverless + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + preemptible: true + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: '1' + limit: 2 + + - label: Running rule_execution_logic:general_logic:qa:serverless + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh rule_execution_logic:general_logic:qa:serverless + key: rule_execution_logic:general_logic:qa:serverless + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + preemptible: true + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: '1' + limit: 2 + + - label: Running rule_execution_logic:indicator_match:qa:serverless + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh rule_execution_logic:indicator_match:qa:serverless + key: rule_execution_logic:indicator_match:qa:serverless + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + preemptible: true + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: '1' + limit: 2 + + - label: Running rule_execution_logic:machine_learning:qa:serverless + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh rule_execution_logic:machine_learning:qa:serverless + key: rule_execution_logic:machine_learning:qa:serverless + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + preemptible: true + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: '1' + limit: 2 + + - label: Running rule_execution_logic:new_terms:qa:serverless + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh rule_execution_logic:new_terms:qa:serverless + key: rule_execution_logic:new_terms:qa:serverless + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + preemptible: true + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: '1' + limit: 2 + + - label: Running rule_execution_logic:query:qa:serverless + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh rule_execution_logic:query:qa:serverless + key: rule_execution_logic:query:qa:serverless + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + preemptible: true + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: '1' limit: 2 - - label: Running rule_execution_logic:qa:serverless - command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh rule_execution_logic:qa:serverless - key: rule_execution_logic:qa:serverless + - label: Running rule_execution_logic:threshold:qa:serverless + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh rule_execution_logic:threshold:qa:serverless + key: rule_execution_logic:threshold:qa:serverless agents: image: family/kibana-ubuntu-2004 imageProject: elastic-images-prod @@ -242,5 +437,5 @@ steps: timeout_in_minutes: 120 retry: automatic: - - exit_status: "1" + - exit_status: '1' limit: 2 diff --git a/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_defend_workflows.yml b/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_defend_workflows.yml index e59ca507e4003..3d30e78583409 100644 --- a/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_defend_workflows.yml +++ b/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_defend_workflows.yml @@ -7,8 +7,6 @@ steps: imageProject: elastic-images-prod provider: gcp enableNestedVirtualization: true - localSsds: 1 - localSsdInterface: nvme machineType: n2-standard-4 timeout_in_minutes: 300 parallelism: 1 diff --git a/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_detection_engine.yml b/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_detection_engine.yml index 90c90ae8a3a36..985a3e796d8fc 100644 --- a/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_detection_engine.yml +++ b/.buildkite/pipelines/security_solution_quality_gate/mki_quality_gate/mki_quality_gate_detection_engine.yml @@ -1,12 +1,12 @@ steps: - - group: "Cypress MKI - Detection Engine" + - group: 'Cypress MKI - Detection Engine' key: cypress_test_detections_engine steps: - command: .buildkite/scripts/pipelines/security_solution_quality_gate/security_solution_cypress/mki_security_solution_cypress.sh cypress:run:qa:serverless:detection_engine - label: "Cypress MKI - Detection Engine" + label: 'Cypress MKI - Detection Engine' key: test_detection_engine env: - BK_TEST_SUITE_KEY: "serverless-cypress-detection-engine" + BK_TEST_SUITE_KEY: 'serverless-cypress-detection-engine' agents: image: family/kibana-ubuntu-2004 imageProject: elastic-images-prod @@ -17,10 +17,10 @@ steps: parallelism: 1 - command: .buildkite/scripts/pipelines/security_solution_quality_gate/security_solution_cypress/mki_security_solution_cypress.sh cypress:run:qa:serverless:detection_engine:exceptions - label: "Cypress MKI - Detection Engine - Exceptions" + label: 'Cypress MKI - Detection Engine - Exceptions' key: test_detection_engine_exceptions env: - BK_TEST_SUITE_KEY: "serverless-cypress-detection-engine" + BK_TEST_SUITE_KEY: 'serverless-cypress-detection-engine' agents: image: family/kibana-ubuntu-2004 imageProject: elastic-images-prod @@ -30,7 +30,7 @@ steps: timeout_in_minutes: 300 parallelism: 1 - - group: "API MKI - Detection Engine" + - group: 'API MKI - Detection Engine' key: api_test_detections_engine steps: - label: Running exception_lists_items:qa:serverless:release @@ -44,7 +44,49 @@ steps: timeout_in_minutes: 120 retry: automatic: - - exit_status: "1" + - exit_status: '1' + limit: 2 + + - label: Running exception_lists:auth:lists:qa:serverless:release + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh exception_lists:auth:lists:qa:serverless:release + key: exception_lists:auth:lists:qa:serverless:release + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: '1' + limit: 2 + + - label: Running exception_lists:auth:common:qa:serverless:release + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh exception_lists:auth:common:qa:serverless:release + key: exception_lists:auth:common:qa:serverless:release + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: '1' + limit: 2 + + - label: Running exception_lists:auth:items:qa:serverless + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh exception_lists:auth:items:qa:serverless + key: exception_lists:auth:items:qa:serverless + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: '1' limit: 2 - label: Running lists_items:qa:serverless:release @@ -58,7 +100,7 @@ steps: timeout_in_minutes: 120 retry: automatic: - - exit_status: "1" + - exit_status: '1' limit: 2 - label: Running user_roles:qa:serverless:release @@ -72,7 +114,7 @@ steps: timeout_in_minutes: 120 retry: automatic: - - exit_status: "1" + - exit_status: '1' limit: 2 - label: Running telemetry:qa:serverless:release @@ -86,7 +128,7 @@ steps: timeout_in_minutes: 120 retry: automatic: - - exit_status: "1" + - exit_status: '1' limit: 2 - label: Running exception_workflows:essentials:qa:serverless:release @@ -98,14 +140,42 @@ steps: provider: gcp machineType: n2-standard-4 timeout_in_minutes: 120 + retry: + automatic: + - exit_status: '1' + limit: 2 + + - label: Running exception_operators_date_types:essentials:qa:serverless:release + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh exception_operators_date_types:essentials:qa:serverless:release + key: exception_operators_date_types:essentials:qa:serverless:release + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: "1" + limit: 2 + + - label: Running exception_operators_double:essentials:qa:serverless:release + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh exception_operators_double:essentials:qa:serverless:release + key: exception_operators_double:essentials:qa:serverless:release + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + timeout_in_minutes: 120 retry: automatic: - exit_status: "1" limit: 2 - - label: Running exception_operators_date_numeric_types:essentials:qa:serverless:release - command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh exception_operators_date_numeric_types:essentials:qa:serverless:release - key: exception_operators_date_numeric_types:essentials:qa:serverless:release + - label: Running exception_operators_float:essentials:qa:serverless:release + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh exception_operators_float:essentials:qa:serverless:release + key: exception_operators_float:essentials:qa:serverless:release agents: image: family/kibana-ubuntu-2004 imageProject: elastic-images-prod @@ -117,6 +187,20 @@ steps: - exit_status: "1" limit: 2 + - label: Running exception_operators_integer:essentials:qa:serverless:release + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh exception_operators_integer:essentials:qa:serverless:release + key: exception_operators_integer:essentials:qa:serverless:release + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: '1' + limit: 2 + - label: Running exception_operators_keyword:essentials:qa:serverless:release command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh exception_operators_keyword:essentials:qa:serverless:release key: exception_operators_keyword:essentials:qa:serverless:release @@ -128,7 +212,7 @@ steps: timeout_in_minutes: 120 retry: automatic: - - exit_status: "1" + - exit_status: '1' limit: 2 - label: Running exception_operators_ips:essentials:qa:serverless:release @@ -142,7 +226,7 @@ steps: timeout_in_minutes: 120 retry: automatic: - - exit_status: "1" + - exit_status: '1' limit: 2 - label: Running exception_operators_long:essentials:qa:serverless:release @@ -156,7 +240,7 @@ steps: timeout_in_minutes: 120 retry: automatic: - - exit_status: "1" + - exit_status: '1' limit: 2 - label: Running exception_operators_text:essentials:qa:serverless:release @@ -170,7 +254,7 @@ steps: timeout_in_minutes: 120 retry: automatic: - - exit_status: "1" + - exit_status: '1' limit: 2 - label: Running actions:qa:serverless:release @@ -184,7 +268,7 @@ steps: timeout_in_minutes: 120 retry: automatic: - - exit_status: "1" + - exit_status: '1' limit: 2 - label: Running alerts:qa:serverless:release @@ -198,7 +282,7 @@ steps: timeout_in_minutes: 120 retry: automatic: - - exit_status: "1" + - exit_status: '1' limit: 2 - label: Running alerts:essentials:qa:serverless:release @@ -210,14 +294,42 @@ steps: provider: gcp machineType: n2-standard-4 timeout_in_minutes: 120 + retry: + automatic: + - exit_status: '1' + limit: 2 + + - label: Running rule_execution_logic:eql:qa:serverless:release + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh rule_execution_logic:eql:qa:serverless:release + key: rule_execution_logic:eql:qa:serverless:release + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: "1" + limit: 2 + +- label: Running rule_execution_logic:esql:qa:serverless:release + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh rule_execution_logic:esql:qa:serverless:release + key: rule_execution_logic:esql:qa:serverless:release + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + timeout_in_minutes: 120 retry: automatic: - exit_status: "1" limit: 2 - - label: Running rule_execution_logic:qa:serverless:release - command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh rule_execution_logic:qa:serverless:release - key: rule_execution_logic:qa:serverless:release +- label: Running rule_execution_logic:general_logic:qa:serverless:release + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh rule_execution_logic:general_logic:qa:serverless:release + key: rule_execution_logic:general_logic:qa:serverless:release agents: image: family/kibana-ubuntu-2004 imageProject: elastic-images-prod @@ -228,3 +340,73 @@ steps: automatic: - exit_status: "1" limit: 2 + +- label: Running rule_execution_logic:indicator_match:qa:serverless:release + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh rule_execution_logic:indicator_match:qa:serverless:release + key: rule_execution_logic:indicator_match:qa:serverless:release + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: "1" + limit: 2 + +- label: Running rule_execution_logic:machine_learning:qa:serverless:release + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh rule_execution_logic:machine_learning:qa:serverless:release + key: rule_execution_logic:machine_learning:qa:serverless:release + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: "1" + limit: 2 + +- label: Running rule_execution_logic:new_terms:qa:serverless:release + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh rule_execution_logic:new_terms:qa:serverless:release + key: rule_execution_logic:new_terms:qa:serverless:release + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: "1" + limit: 2 + +- label: Running rule_execution_logic:query:qa:serverless:release + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh rule_execution_logic:query:qa:serverless:release + key: rule_execution_logic:query:qa:serverless:release + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: "1" + limit: 2 + +- label: Running rule_execution_logic:threshold:qa:serverless:release + command: .buildkite/scripts/pipelines/security_solution_quality_gate/api_integration/api-integration-tests.sh rule_execution_logic:threshold:qa:serverless:release + key: rule_execution_logic:threshold:qa:serverless:release + agents: + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-prod + provider: gcp + machineType: n2-standard-4 + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: '1' + limit: 2 diff --git a/.buildkite/scripts/bootstrap.sh b/.buildkite/scripts/bootstrap.sh index b7576dda72f24..a126ad9132696 100755 --- a/.buildkite/scripts/bootstrap.sh +++ b/.buildkite/scripts/bootstrap.sh @@ -12,12 +12,18 @@ if [[ "${BOOTSTRAP_ALWAYS_FORCE_INSTALL:-}" ]]; then BOOTSTRAP_PARAMS+=(--force-install) fi -# Use the node_modules that is baked into the agent image, if it exists, as a cache +# Use the packages that are baked into the agent image, if they exist, as a cache # But only for agents not mounting the workspace on a local ssd or in memory # It actually ends up being slower to move all of the tiny files between the disks vs extracting archives from the yarn cache -if [[ -d ~/.kibana/node_modules && "$(pwd)" != *"/local-ssd/"* && "$(pwd)" != "/dev/shm"* ]]; then - echo "Using ~/.kibana/node_modules as a starting point" - mv ~/.kibana/node_modules ./ +if [[ "$(pwd)" != *"/local-ssd/"* && "$(pwd)" != "/dev/shm"* ]]; then + if [[ -d ~/.kibana/node_modules ]]; then + echo "Using ~/.kibana/node_modules as a starting point" + mv ~/.kibana/node_modules ./ + fi + if [[ -d ~/.kibana/.yarn-local-mirror ]]; then + echo "Using ~/.kibana/.yarn-local-mirror as a starting point" + mv ~/.kibana/.yarn-local-mirror ./ + fi fi if ! yarn kbn bootstrap "${BOOTSTRAP_PARAMS[@]}"; then diff --git a/.buildkite/scripts/common/setup_node.sh b/.buildkite/scripts/common/setup_node.sh index c6fbfeaee51bc..aac3d26702691 100755 --- a/.buildkite/scripts/common/setup_node.sh +++ b/.buildkite/scripts/common/setup_node.sh @@ -10,7 +10,6 @@ NODE_VERSION="$(cat "$KIBANA_DIR/.node-version")" export NODE_VERSION export NODE_DIR="$CACHE_DIR/node/$NODE_VERSION" export NODE_BIN_DIR="$NODE_DIR/bin" -export YARN_OFFLINE_CACHE="$CACHE_DIR/yarn-offline-cache" ## Install node for whatever the current os/arch are hostArch="$(command uname -m)" @@ -77,8 +76,6 @@ if [[ ! $(which yarn) || $(yarn --version) != "$YARN_VERSION" ]]; then npm_install_global yarn "^$YARN_VERSION" fi -yarn config set yarn-offline-mirror "$YARN_OFFLINE_CACHE" - YARN_GLOBAL_BIN=$(yarn global bin) export YARN_GLOBAL_BIN export PATH="$PATH:$YARN_GLOBAL_BIN" diff --git a/.buildkite/scripts/pipelines/pull_request/pipeline.ts b/.buildkite/scripts/pipelines/pull_request/pipeline.ts index 2a5370ae07893..ca4ca228b0c91 100644 --- a/.buildkite/scripts/pipelines/pull_request/pipeline.ts +++ b/.buildkite/scripts/pipelines/pull_request/pipeline.ts @@ -255,18 +255,43 @@ const getPipeline = (filename: string, removeSteps = true) => { if ( (await doAnyChangesMatch([ /^package.json/, + /^packages\/kbn-discover-utils/, /^packages\/kbn-doc-links/, + /^packages\/kbn-dom-drag-drop/, /^packages\/kbn-es-query/, - /^packages\/kbn-i18n-react/, /^packages\/kbn-i18n/, + /^packages\/kbn-i18n-react/, /^packages\/kbn-expandable-flyout/, + /^packages\/kbn-grouping/, + /^packages\/kbn-resizable-layout/, + /^packages\/kbn-rison/, + /^packages\/kbn-rule-data-utils/, + /^packages\/kbn-safer-lodash-set/, + /^packages\/kbn-search-types/, /^packages\/kbn-securitysolution-.*/, + /^packages\/kbn-securitysolution-ecs/, + /^packages\/kbn-securitysolution-io-ts-alerting-types/, /^packages\/kbn-securitysolution-io-ts-list-types/, + /^packages\/kbn-securitysolution-list-hooks/, + /^packages\/kbn-securitysolution-t-grid/, + /^packages\/kbn-ui-theme/, + /^packages\/kbn-utility-types/, + /^packages\/react/, /^packages\/shared-ux/, /^src\/core/, + /^src\/plugins\/charts/, + /^src\/plugins\/controls/, /^src\/plugins\/data/, - /^src\/plugins\/kibana_utils/, + /^src\/plugins\/data_views/, + /^src\/plugins\/discover/, + /^src\/plugins\/field_formats/, /^src\/plugins\/inspector/, + /^src\/plugins\/kibana_react/, + /^src\/plugins\/kibana_utils/, + /^src\/plugins\/saved_search/, + /^src\/plugins\/ui_actions/, + /^src\/plugins\/unified_histogram/, + /^src\/plugins\/unified_search/, /^x-pack\/packages\/kbn-elastic-assistant/, /^x-pack\/packages\/kbn-elastic-assistant-common/, /^x-pack\/packages\/security-solution/, @@ -282,7 +307,7 @@ const getPipeline = (filename: string, removeSteps = true) => { /^x-pack\/plugins\/task_manager/, /^x-pack\/plugins\/threat_intelligence/, /^x-pack\/plugins\/timelines/, - /^x-pack\/plugins\/triggers_actions_ui\/public\/application\/sections\/alerts_table/, + /^x-pack\/plugins\/triggers_actions_ui/, /^x-pack\/plugins\/usage_collection\/public/, /^x-pack\/test\/functional\/es_archives\/security_solution/, /^x-pack\/test\/security_solution_cypress/, diff --git a/.buildkite/scripts/post_build_kibana.sh b/.buildkite/scripts/post_build_kibana.sh index 2da629e29c158..f6af5b3c20e83 100755 --- a/.buildkite/scripts/post_build_kibana.sh +++ b/.buildkite/scripts/post_build_kibana.sh @@ -9,7 +9,7 @@ if [[ ! "${DISABLE_CI_STATS_SHIPPING:-}" ]]; then "--metrics" "build/kibana/node_modules/@kbn/ui-shared-deps-src/shared_built_assets/metrics.json" ) - if [ "$BUILDKITE_PIPELINE_SLUG" == "kibana-on-merge" ]; then + if [[ "$BUILDKITE_PIPELINE_SLUG" == "kibana-on-merge" ]] || [[ "$BUILDKITE_PIPELINE_SLUG" == "kibana-pull-request" ]]; then cmd+=("--validate") fi diff --git a/.buildkite/scripts/steps/checks/i18n.sh b/.buildkite/scripts/steps/checks/i18n.sh index 090512e391d7c..f23ed2d63759e 100755 --- a/.buildkite/scripts/steps/checks/i18n.sh +++ b/.buildkite/scripts/steps/checks/i18n.sh @@ -5,4 +5,4 @@ set -euo pipefail source .buildkite/scripts/common/util.sh echo --- Check i18n -node scripts/i18n_check +node scripts/i18n_check --quiet diff --git a/.buildkite/scripts/steps/cloud/deploy.json b/.buildkite/scripts/steps/cloud/deploy.json index d139af131f0a9..33ce6b752ad9d 100644 --- a/.buildkite/scripts/steps/cloud/deploy.json +++ b/.buildkite/scripts/steps/cloud/deploy.json @@ -7,7 +7,7 @@ "plan": { "cluster_topology": [ { - "instance_configuration_id": "gcp.integrationsserver.1", + "instance_configuration_id": "gcp.integrationsserver.n2.68x32x45", "zone_count": 1, "size": { "resource": "memory", @@ -32,7 +32,7 @@ "cluster_topology": [ { "zone_count": 1, - "instance_configuration_id": "gcp.coordinating.1", + "instance_configuration_id": "gcp.es.coordinating.n2.68x16x45", "node_roles": [ "ingest", "remote_cluster_client" @@ -50,7 +50,7 @@ "data": "hot" } }, - "instance_configuration_id": "gcp.data.highio.1", + "instance_configuration_id": "gcp.es.datahot.n2.68x32x45", "node_roles": [ "master", "ingest", @@ -72,7 +72,7 @@ "data": "warm" } }, - "instance_configuration_id": "gcp.data.highstorage.1", + "instance_configuration_id": "gcp.es.datawarm.n2.68x10x190", "node_roles": [ "data_warm", "remote_cluster_client" @@ -90,7 +90,7 @@ "data": "cold" } }, - "instance_configuration_id": "gcp.data.highstorage.1", + "instance_configuration_id": "gcp.es.datacold.n2.68x10x190", "node_roles": [ "data_cold", "remote_cluster_client" @@ -108,7 +108,7 @@ "data": "frozen" } }, - "instance_configuration_id": "gcp.es.datafrozen.n1.64x10x95", + "instance_configuration_id": "gcp.es.datafrozen.n2.68x10x90", "node_roles": [ "data_frozen" ], @@ -120,7 +120,7 @@ }, { "zone_count": 1, - "instance_configuration_id": "gcp.master.1", + "instance_configuration_id": "gcp.es.master.n2.68x32x45", "node_roles": [ "master", "remote_cluster_client" @@ -142,7 +142,7 @@ }, "autoscaling_tier_override": true, "id": "ml", - "instance_configuration_id": "gcp.ml.1", + "instance_configuration_id": "gcp.es.ml.n2.68x32x45", "node_roles": [ "ml", "remote_cluster_client" @@ -155,7 +155,7 @@ "enabled_built_in_plugins": [] }, "deployment_template": { - "id": "gcp-io-optimized-v2" + "id": "gcp-cpu-optimized" } }, "ref_id": "main-elasticsearch" @@ -173,7 +173,7 @@ "appserver": true, "worker": true }, - "instance_configuration_id": "gcp.enterprisesearch.1", + "instance_configuration_id": "gcp.enterprisesearch.n2.68x32x45", "zone_count": 1, "size": { "resource": "memory", @@ -195,7 +195,7 @@ "plan": { "cluster_topology": [ { - "instance_configuration_id": "gcp.kibana.1", + "instance_configuration_id": "gcp.kibana.n2.68x32x45", "zone_count": 1, "size": { "value": 2048, diff --git a/.buildkite/scripts/steps/functional/security_serverless_defend_workflows.sh b/.buildkite/scripts/steps/functional/security_serverless_defend_workflows.sh deleted file mode 100644 index 7b16afa214fed..0000000000000 --- a/.buildkite/scripts/steps/functional/security_serverless_defend_workflows.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -source .buildkite/scripts/steps/functional/common.sh - -export JOB=kibana-serverless-security-cypress -export KIBANA_INSTALL_DIR=${KIBANA_BUILD_LOCATION} - -echo "--- Security Defend Workflows Serverless Cypress" - -yarn --cwd x-pack/test_serverless/functional/test_suites/security/cypress cypress:run diff --git a/.eslintrc.js b/.eslintrc.js index 3c67594513c0e..730c9599f23f9 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1026,7 +1026,9 @@ module.exports = { */ { files: ['x-pack/plugins/fleet/**/*.{js,mjs,ts,tsx}'], + plugins: ['testing-library'], rules: { + 'testing-library/await-async-utils': 'error', '@typescript-eslint/consistent-type-imports': 'error', 'import/order': [ 'warn', @@ -1954,6 +1956,16 @@ module.exports = { }, }, + /** + * Cloud Security Team overrides + */ + { + files: ['x-pack/plugins/cloud_security_posture/**/*.{js,mjs,ts,tsx}'], + plugins: ['testing-library'], + rules: { + 'testing-library/await-async-utils': 'error', + }, + }, /** * Code inside .buildkite runs separately from everything else in CI, before bootstrap, with ts-node. It needs a few tweaks because of this. */ @@ -1978,6 +1990,34 @@ module.exports = { 'max-classes-per-file': 'off', }, }, + { + files: [ + // logsShared depends on o11y/private plugins, but platform plugins depend on it + 'x-pack/plugins/observability_solution/logs_shared/**', + + // this plugin depends on visTypeTimeseries plugin (for TSVB viz) which is platform/private ATM + 'x-pack/plugins/observability_solution/infra/**', + + // TODO @kibana/operations + 'scripts/create_observability_rules.js', // is importing "@kbn/observability-alerting-test-data" (observability/private) + 'src/cli_setup/**', // is importing "@kbn/interactive-setup-plugin" (platform/private) + 'src/dev/build/tasks/install_chromium.ts', // is importing "@kbn/screenshotting-plugin" (platform/private) + + // @kbn/osquery-plugin could be categorised as Security, but @kbn/infra-plugin (observability) depends on it! + 'x-pack/plugins/osquery/**', + + // For now, we keep the exception to let tests depend on anythying. + // Ideally, we need to classify the solution specific ones to reduce CI times + 'test/**', + 'x-pack/test_serverless/**', + 'x-pack/test/**', + 'x-pack/test/plugin_functional/plugins/resolver_test/**', + ], + rules: { + '@kbn/imports/no_group_crossing_manifests': 'warn', + '@kbn/imports/no_group_crossing_imports': 'warn', + }, + }, ], }; diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index ddc5535d29008..0000000000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1,1948 +0,0 @@ -#### -## Everything at the top of the codeowners file is auto generated based on the -## "owner" fields in the kibana.jsonc files at the root of each package. This -## file is automatically updated by CI or can be updated locally by running -## `node scripts/generate codeowners`. -#### - -x-pack/test/alerting_api_integration/common/plugins/aad @elastic/response-ops -x-pack/plugins/actions @elastic/response-ops -x-pack/test/alerting_api_integration/common/plugins/actions_simulators @elastic/response-ops -packages/kbn-actions-types @elastic/response-ops -src/plugins/advanced_settings @elastic/appex-sharedux @elastic/kibana-management -x-pack/packages/kbn-ai-assistant @elastic/search-kibana -x-pack/packages/kbn-ai-assistant-common @elastic/search-kibana -src/plugins/ai_assistant_management/selection @elastic/obs-knowledge-team -x-pack/packages/ml/aiops_change_point_detection @elastic/ml-ui -x-pack/packages/ml/aiops_common @elastic/ml-ui -x-pack/packages/ml/aiops_components @elastic/ml-ui -x-pack/packages/ml/aiops_log_pattern_analysis @elastic/ml-ui -x-pack/packages/ml/aiops_log_rate_analysis @elastic/ml-ui -x-pack/plugins/aiops @elastic/ml-ui -x-pack/packages/ml/aiops_test_utils @elastic/ml-ui -x-pack/test/alerting_api_integration/packages/helpers @elastic/response-ops -x-pack/test/alerting_api_integration/common/plugins/alerts @elastic/response-ops -x-pack/packages/kbn-alerting-comparators @elastic/response-ops -x-pack/examples/alerting_example @elastic/response-ops -x-pack/test/functional_with_es_ssl/plugins/alerts @elastic/response-ops -x-pack/plugins/alerting @elastic/response-ops -x-pack/packages/kbn-alerting-state-types @elastic/response-ops -packages/kbn-alerting-types @elastic/response-ops -packages/kbn-alerts-as-data-utils @elastic/response-ops -packages/kbn-alerts-grouping @elastic/response-ops -x-pack/test/alerting_api_integration/common/plugins/alerts_restricted @elastic/response-ops -packages/kbn-alerts-ui-shared @elastic/response-ops -packages/kbn-ambient-common-types @elastic/kibana-operations -packages/kbn-ambient-ftr-types @elastic/kibana-operations @elastic/appex-qa -packages/kbn-ambient-storybook-types @elastic/kibana-operations -packages/kbn-ambient-ui-types @elastic/kibana-operations -packages/kbn-analytics @elastic/kibana-core -packages/analytics/utils/analytics_collection_utils @elastic/kibana-core -test/analytics/plugins/analytics_ftr_helpers @elastic/kibana-core -test/analytics/plugins/analytics_plugin_a @elastic/kibana-core -packages/kbn-apm-config-loader @elastic/kibana-core @vigneshshanmugam -x-pack/plugins/observability_solution/apm_data_access @elastic/obs-knowledge-team @elastic/obs-ux-infra_services-team -packages/kbn-apm-data-view @elastic/obs-ux-infra_services-team -x-pack/plugins/observability_solution/apm/ftr_e2e @elastic/obs-ux-infra_services-team -x-pack/plugins/observability_solution/apm @elastic/obs-ux-infra_services-team -packages/kbn-apm-synthtrace @elastic/obs-ux-infra_services-team @elastic/obs-ux-logs-team -packages/kbn-apm-synthtrace-client @elastic/obs-ux-infra_services-team @elastic/obs-ux-logs-team -packages/kbn-apm-types @elastic/obs-ux-infra_services-team -packages/kbn-apm-utils @elastic/obs-ux-infra_services-team -test/plugin_functional/plugins/app_link_test @elastic/kibana-core -x-pack/test/usage_collection/plugins/application_usage_test @elastic/kibana-core -x-pack/test/security_api_integration/plugins/audit_log @elastic/kibana-security -packages/kbn-avc-banner @elastic/security-defend-workflows -packages/kbn-axe-config @elastic/kibana-qa -packages/kbn-babel-preset @elastic/kibana-operations -packages/kbn-babel-register @elastic/kibana-operations -packages/kbn-babel-transform @elastic/kibana-operations -x-pack/plugins/banners @elastic/appex-sharedux -packages/kbn-bazel-runner @elastic/kibana-operations -packages/kbn-bfetch-error @elastic/appex-sharedux -examples/bfetch_explorer @elastic/appex-sharedux -src/plugins/bfetch @elastic/appex-sharedux -packages/kbn-calculate-auto @elastic/obs-ux-management-team -packages/kbn-calculate-width-from-char-count @elastic/kibana-visualizations -x-pack/plugins/canvas @elastic/kibana-presentation -packages/kbn-capture-oas-snapshot-cli @elastic/kibana-core -x-pack/test/cases_api_integration/common/plugins/cases @elastic/response-ops -packages/kbn-cases-components @elastic/response-ops -x-pack/plugins/cases @elastic/response-ops -packages/kbn-cbor @elastic/kibana-operations -packages/kbn-cell-actions @elastic/security-threat-hunting-explore -src/plugins/chart_expressions/common @elastic/kibana-visualizations -packages/kbn-chart-icons @elastic/kibana-visualizations -src/plugins/charts @elastic/kibana-visualizations -packages/kbn-check-mappings-update-cli @elastic/kibana-core -packages/kbn-check-prod-native-modules-cli @elastic/kibana-operations -packages/kbn-ci-stats-core @elastic/kibana-operations -packages/kbn-ci-stats-performance-metrics @elastic/kibana-operations -packages/kbn-ci-stats-reporter @elastic/kibana-operations -packages/kbn-ci-stats-shipper-cli @elastic/kibana-operations -packages/kbn-cli-dev-mode @elastic/kibana-operations -packages/cloud @elastic/kibana-core -x-pack/plugins/cloud_integrations/cloud_chat @elastic/kibana-core -x-pack/plugins/cloud_integrations/cloud_data_migration @elastic/kibana-management -x-pack/plugins/cloud_defend @elastic/kibana-cloud-security-posture -x-pack/plugins/cloud_integrations/cloud_experiments @elastic/kibana-core -x-pack/plugins/cloud_integrations/cloud_full_story @elastic/kibana-core -x-pack/test/cloud_integration/plugins/saml_provider @elastic/kibana-core -x-pack/plugins/cloud_integrations/cloud_links @elastic/kibana-core -x-pack/plugins/cloud @elastic/kibana-core -x-pack/packages/kbn-cloud-security-posture/public @elastic/kibana-cloud-security-posture -x-pack/packages/kbn-cloud-security-posture/common @elastic/kibana-cloud-security-posture -x-pack/packages/kbn-cloud-security-posture/graph @elastic/kibana-cloud-security-posture -x-pack/plugins/cloud_security_posture @elastic/kibana-cloud-security-posture -packages/shared-ux/code_editor/impl @elastic/appex-sharedux -packages/shared-ux/code_editor/mocks @elastic/appex-sharedux -packages/kbn-code-owners @elastic/appex-qa -packages/kbn-coloring @elastic/kibana-visualizations -packages/kbn-config @elastic/kibana-core -packages/kbn-config-mocks @elastic/kibana-core -packages/kbn-config-schema @elastic/kibana-core -src/plugins/console @elastic/kibana-management -packages/content-management/content_editor @elastic/appex-sharedux -packages/content-management/content_insights/content_insights_public @elastic/appex-sharedux -packages/content-management/content_insights/content_insights_server @elastic/appex-sharedux -examples/content_management_examples @elastic/appex-sharedux -packages/content-management/favorites/favorites_public @elastic/appex-sharedux -packages/content-management/favorites/favorites_server @elastic/appex-sharedux -src/plugins/content_management @elastic/appex-sharedux -packages/content-management/tabbed_table_list_view @elastic/appex-sharedux -packages/content-management/table_list_view @elastic/appex-sharedux -packages/content-management/table_list_view_common @elastic/appex-sharedux -packages/content-management/table_list_view_table @elastic/appex-sharedux -packages/content-management/user_profiles @elastic/appex-sharedux -packages/kbn-content-management-utils @elastic/kibana-data-discovery -examples/controls_example @elastic/kibana-presentation -src/plugins/controls @elastic/kibana-presentation -src/core @elastic/kibana-core -packages/core/analytics/core-analytics-browser @elastic/kibana-core -packages/core/analytics/core-analytics-browser-internal @elastic/kibana-core -packages/core/analytics/core-analytics-browser-mocks @elastic/kibana-core -packages/core/analytics/core-analytics-server @elastic/kibana-core -packages/core/analytics/core-analytics-server-internal @elastic/kibana-core -packages/core/analytics/core-analytics-server-mocks @elastic/kibana-core -test/plugin_functional/plugins/core_app_status @elastic/kibana-core -packages/core/application/core-application-browser @elastic/kibana-core -packages/core/application/core-application-browser-internal @elastic/kibana-core -packages/core/application/core-application-browser-mocks @elastic/kibana-core -packages/core/application/core-application-common @elastic/kibana-core -packages/core/apps/core-apps-browser-internal @elastic/kibana-core -packages/core/apps/core-apps-browser-mocks @elastic/kibana-core -packages/core/apps/core-apps-server-internal @elastic/kibana-core -packages/core/base/core-base-browser-internal @elastic/kibana-core -packages/core/base/core-base-browser-mocks @elastic/kibana-core -packages/core/base/core-base-common @elastic/kibana-core -packages/core/base/core-base-common-internal @elastic/kibana-core -packages/core/base/core-base-server-internal @elastic/kibana-core -packages/core/base/core-base-server-mocks @elastic/kibana-core -packages/core/capabilities/core-capabilities-browser-internal @elastic/kibana-core -packages/core/capabilities/core-capabilities-browser-mocks @elastic/kibana-core -packages/core/capabilities/core-capabilities-common @elastic/kibana-core -packages/core/capabilities/core-capabilities-server @elastic/kibana-core -packages/core/capabilities/core-capabilities-server-internal @elastic/kibana-core -packages/core/capabilities/core-capabilities-server-mocks @elastic/kibana-core -packages/core/chrome/core-chrome-browser @elastic/appex-sharedux -packages/core/chrome/core-chrome-browser-internal @elastic/appex-sharedux -packages/core/chrome/core-chrome-browser-mocks @elastic/appex-sharedux -packages/core/config/core-config-server-internal @elastic/kibana-core -packages/core/custom-branding/core-custom-branding-browser @elastic/appex-sharedux -packages/core/custom-branding/core-custom-branding-browser-internal @elastic/appex-sharedux -packages/core/custom-branding/core-custom-branding-browser-mocks @elastic/appex-sharedux -packages/core/custom-branding/core-custom-branding-common @elastic/appex-sharedux -packages/core/custom-branding/core-custom-branding-server @elastic/appex-sharedux -packages/core/custom-branding/core-custom-branding-server-internal @elastic/appex-sharedux -packages/core/custom-branding/core-custom-branding-server-mocks @elastic/appex-sharedux -packages/core/deprecations/core-deprecations-browser @elastic/kibana-core -packages/core/deprecations/core-deprecations-browser-internal @elastic/kibana-core -packages/core/deprecations/core-deprecations-browser-mocks @elastic/kibana-core -packages/core/deprecations/core-deprecations-common @elastic/kibana-core -packages/core/deprecations/core-deprecations-server @elastic/kibana-core -packages/core/deprecations/core-deprecations-server-internal @elastic/kibana-core -packages/core/deprecations/core-deprecations-server-mocks @elastic/kibana-core -packages/core/doc-links/core-doc-links-browser @elastic/kibana-core -packages/core/doc-links/core-doc-links-browser-internal @elastic/kibana-core -packages/core/doc-links/core-doc-links-browser-mocks @elastic/kibana-core -packages/core/doc-links/core-doc-links-server @elastic/kibana-core -packages/core/doc-links/core-doc-links-server-internal @elastic/kibana-core -packages/core/doc-links/core-doc-links-server-mocks @elastic/kibana-core -packages/core/elasticsearch/core-elasticsearch-client-server-internal @elastic/kibana-core -packages/core/elasticsearch/core-elasticsearch-client-server-mocks @elastic/kibana-core -packages/core/elasticsearch/core-elasticsearch-server @elastic/kibana-core -packages/core/elasticsearch/core-elasticsearch-server-internal @elastic/kibana-core -packages/core/elasticsearch/core-elasticsearch-server-mocks @elastic/kibana-core -packages/core/environment/core-environment-server-internal @elastic/kibana-core -packages/core/environment/core-environment-server-mocks @elastic/kibana-core -packages/core/execution-context/core-execution-context-browser @elastic/kibana-core -packages/core/execution-context/core-execution-context-browser-internal @elastic/kibana-core -packages/core/execution-context/core-execution-context-browser-mocks @elastic/kibana-core -packages/core/execution-context/core-execution-context-common @elastic/kibana-core -packages/core/execution-context/core-execution-context-server @elastic/kibana-core -packages/core/execution-context/core-execution-context-server-internal @elastic/kibana-core -packages/core/execution-context/core-execution-context-server-mocks @elastic/kibana-core -packages/core/fatal-errors/core-fatal-errors-browser @elastic/kibana-core -packages/core/fatal-errors/core-fatal-errors-browser-internal @elastic/kibana-core -packages/core/fatal-errors/core-fatal-errors-browser-mocks @elastic/kibana-core -packages/core/feature-flags/core-feature-flags-browser @elastic/kibana-core -packages/core/feature-flags/core-feature-flags-browser-internal @elastic/kibana-core -packages/core/feature-flags/core-feature-flags-browser-mocks @elastic/kibana-core -packages/core/feature-flags/core-feature-flags-server @elastic/kibana-core -packages/core/feature-flags/core-feature-flags-server-internal @elastic/kibana-core -packages/core/feature-flags/core-feature-flags-server-mocks @elastic/kibana-core -test/plugin_functional/plugins/core_history_block @elastic/kibana-core -packages/core/http/core-http-browser @elastic/kibana-core -packages/core/http/core-http-browser-internal @elastic/kibana-core -packages/core/http/core-http-browser-mocks @elastic/kibana-core -packages/core/http/core-http-common @elastic/kibana-core -packages/core/http/core-http-context-server-internal @elastic/kibana-core -packages/core/http/core-http-context-server-mocks @elastic/kibana-core -test/plugin_functional/plugins/core_http @elastic/kibana-core -packages/core/http/core-http-request-handler-context-server @elastic/kibana-core -packages/core/http/core-http-request-handler-context-server-internal @elastic/kibana-core -packages/core/http/core-http-resources-server @elastic/kibana-core -packages/core/http/core-http-resources-server-internal @elastic/kibana-core -packages/core/http/core-http-resources-server-mocks @elastic/kibana-core -packages/core/http/core-http-router-server-internal @elastic/kibana-core -packages/core/http/core-http-router-server-mocks @elastic/kibana-core -packages/core/http/core-http-server @elastic/kibana-core -packages/core/http/core-http-server-internal @elastic/kibana-core -packages/core/http/core-http-server-mocks @elastic/kibana-core -packages/core/i18n/core-i18n-browser @elastic/kibana-core -packages/core/i18n/core-i18n-browser-internal @elastic/kibana-core -packages/core/i18n/core-i18n-browser-mocks @elastic/kibana-core -packages/core/i18n/core-i18n-server @elastic/kibana-core -packages/core/i18n/core-i18n-server-internal @elastic/kibana-core -packages/core/i18n/core-i18n-server-mocks @elastic/kibana-core -packages/core/injected-metadata/core-injected-metadata-browser-internal @elastic/kibana-core -packages/core/injected-metadata/core-injected-metadata-browser-mocks @elastic/kibana-core -packages/core/injected-metadata/core-injected-metadata-common-internal @elastic/kibana-core -packages/core/integrations/core-integrations-browser-internal @elastic/kibana-core -packages/core/integrations/core-integrations-browser-mocks @elastic/kibana-core -packages/core/lifecycle/core-lifecycle-browser @elastic/kibana-core -packages/core/lifecycle/core-lifecycle-browser-internal @elastic/kibana-core -packages/core/lifecycle/core-lifecycle-browser-mocks @elastic/kibana-core -packages/core/lifecycle/core-lifecycle-server @elastic/kibana-core -packages/core/lifecycle/core-lifecycle-server-internal @elastic/kibana-core -packages/core/lifecycle/core-lifecycle-server-mocks @elastic/kibana-core -packages/core/logging/core-logging-browser-internal @elastic/kibana-core -packages/core/logging/core-logging-browser-mocks @elastic/kibana-core -packages/core/logging/core-logging-common-internal @elastic/kibana-core -packages/core/logging/core-logging-server @elastic/kibana-core -packages/core/logging/core-logging-server-internal @elastic/kibana-core -packages/core/logging/core-logging-server-mocks @elastic/kibana-core -packages/core/metrics/core-metrics-collectors-server-internal @elastic/kibana-core -packages/core/metrics/core-metrics-collectors-server-mocks @elastic/kibana-core -packages/core/metrics/core-metrics-server @elastic/kibana-core -packages/core/metrics/core-metrics-server-internal @elastic/kibana-core -packages/core/metrics/core-metrics-server-mocks @elastic/kibana-core -packages/core/mount-utils/core-mount-utils-browser @elastic/kibana-core -packages/core/mount-utils/core-mount-utils-browser-internal @elastic/kibana-core -packages/core/node/core-node-server @elastic/kibana-core -packages/core/node/core-node-server-internal @elastic/kibana-core -packages/core/node/core-node-server-mocks @elastic/kibana-core -packages/core/notifications/core-notifications-browser @elastic/kibana-core -packages/core/notifications/core-notifications-browser-internal @elastic/kibana-core -packages/core/notifications/core-notifications-browser-mocks @elastic/kibana-core -packages/core/overlays/core-overlays-browser @elastic/kibana-core -packages/core/overlays/core-overlays-browser-internal @elastic/kibana-core -packages/core/overlays/core-overlays-browser-mocks @elastic/kibana-core -test/plugin_functional/plugins/core_plugin_a @elastic/kibana-core -test/plugin_functional/plugins/core_plugin_appleave @elastic/kibana-core -test/plugin_functional/plugins/core_plugin_b @elastic/kibana-core -test/plugin_functional/plugins/core_plugin_chromeless @elastic/kibana-core -test/plugin_functional/plugins/core_plugin_deep_links @elastic/kibana-core -test/plugin_functional/plugins/core_plugin_deprecations @elastic/kibana-core -test/plugin_functional/plugins/core_dynamic_resolving_a @elastic/kibana-core -test/plugin_functional/plugins/core_dynamic_resolving_b @elastic/kibana-core -test/plugin_functional/plugins/core_plugin_execution_context @elastic/kibana-core -test/plugin_functional/plugins/core_plugin_helpmenu @elastic/kibana-core -test/node_roles_functional/plugins/core_plugin_initializer_context @elastic/kibana-core -test/plugin_functional/plugins/core_plugin_route_timeouts @elastic/kibana-core -test/plugin_functional/plugins/core_plugin_static_assets @elastic/kibana-core -packages/core/plugins/core-plugins-base-server-internal @elastic/kibana-core -packages/core/plugins/core-plugins-browser @elastic/kibana-core -packages/core/plugins/core-plugins-browser-internal @elastic/kibana-core -packages/core/plugins/core-plugins-browser-mocks @elastic/kibana-core -packages/core/plugins/core-plugins-contracts-browser @elastic/kibana-core -packages/core/plugins/core-plugins-contracts-server @elastic/kibana-core -packages/core/plugins/core-plugins-server @elastic/kibana-core -packages/core/plugins/core-plugins-server-internal @elastic/kibana-core -packages/core/plugins/core-plugins-server-mocks @elastic/kibana-core -packages/core/preboot/core-preboot-server @elastic/kibana-core -packages/core/preboot/core-preboot-server-internal @elastic/kibana-core -packages/core/preboot/core-preboot-server-mocks @elastic/kibana-core -test/plugin_functional/plugins/core_provider_plugin @elastic/kibana-core -packages/core/rendering/core-rendering-browser-internal @elastic/kibana-core -packages/core/rendering/core-rendering-browser-mocks @elastic/kibana-core -packages/core/rendering/core-rendering-server-internal @elastic/kibana-core -packages/core/rendering/core-rendering-server-mocks @elastic/kibana-core -packages/core/root/core-root-browser-internal @elastic/kibana-core -packages/core/root/core-root-server-internal @elastic/kibana-core -packages/core/saved-objects/core-saved-objects-api-browser @elastic/kibana-core -packages/core/saved-objects/core-saved-objects-api-server @elastic/kibana-core -packages/core/saved-objects/core-saved-objects-api-server-internal @elastic/kibana-core -packages/core/saved-objects/core-saved-objects-api-server-mocks @elastic/kibana-core -packages/core/saved-objects/core-saved-objects-base-server-internal @elastic/kibana-core -packages/core/saved-objects/core-saved-objects-base-server-mocks @elastic/kibana-core -packages/core/saved-objects/core-saved-objects-browser @elastic/kibana-core -packages/core/saved-objects/core-saved-objects-browser-internal @elastic/kibana-core -packages/core/saved-objects/core-saved-objects-browser-mocks @elastic/kibana-core -packages/core/saved-objects/core-saved-objects-common @elastic/kibana-core -packages/core/saved-objects/core-saved-objects-import-export-server-internal @elastic/kibana-core -packages/core/saved-objects/core-saved-objects-import-export-server-mocks @elastic/kibana-core -packages/core/saved-objects/core-saved-objects-migration-server-internal @elastic/kibana-core -packages/core/saved-objects/core-saved-objects-migration-server-mocks @elastic/kibana-core -packages/core/saved-objects/core-saved-objects-server @elastic/kibana-core -packages/core/saved-objects/core-saved-objects-server-internal @elastic/kibana-core -packages/core/saved-objects/core-saved-objects-server-mocks @elastic/kibana-core -packages/core/saved-objects/core-saved-objects-utils-server @elastic/kibana-core -packages/core/security/core-security-browser @elastic/kibana-core -packages/core/security/core-security-browser-internal @elastic/kibana-core -packages/core/security/core-security-browser-mocks @elastic/kibana-core -packages/core/security/core-security-common @elastic/kibana-core @elastic/kibana-security -packages/core/security/core-security-server @elastic/kibana-core -packages/core/security/core-security-server-internal @elastic/kibana-core -packages/core/security/core-security-server-mocks @elastic/kibana-core -packages/core/status/core-status-common @elastic/kibana-core -packages/core/status/core-status-common-internal @elastic/kibana-core -packages/core/status/core-status-server @elastic/kibana-core -packages/core/status/core-status-server-internal @elastic/kibana-core -packages/core/status/core-status-server-mocks @elastic/kibana-core -packages/core/test-helpers/core-test-helpers-deprecations-getters @elastic/kibana-core -packages/core/test-helpers/core-test-helpers-http-setup-browser @elastic/kibana-core -packages/core/test-helpers/core-test-helpers-kbn-server @elastic/kibana-core -packages/core/test-helpers/core-test-helpers-model-versions @elastic/kibana-core -packages/core/test-helpers/core-test-helpers-so-type-serializer @elastic/kibana-core -packages/core/test-helpers/core-test-helpers-test-utils @elastic/kibana-core -packages/core/theme/core-theme-browser @elastic/kibana-core -packages/core/theme/core-theme-browser-internal @elastic/kibana-core -packages/core/theme/core-theme-browser-mocks @elastic/kibana-core -packages/core/ui-settings/core-ui-settings-browser @elastic/appex-sharedux -packages/core/ui-settings/core-ui-settings-browser-internal @elastic/appex-sharedux -packages/core/ui-settings/core-ui-settings-browser-mocks @elastic/appex-sharedux -packages/core/ui-settings/core-ui-settings-common @elastic/appex-sharedux -packages/core/ui-settings/core-ui-settings-server @elastic/appex-sharedux -packages/core/ui-settings/core-ui-settings-server-internal @elastic/appex-sharedux -packages/core/ui-settings/core-ui-settings-server-mocks @elastic/appex-sharedux -packages/core/usage-data/core-usage-data-base-server-internal @elastic/kibana-core -packages/core/usage-data/core-usage-data-server @elastic/kibana-core -packages/core/usage-data/core-usage-data-server-internal @elastic/kibana-core -packages/core/usage-data/core-usage-data-server-mocks @elastic/kibana-core -packages/core/user-profile/core-user-profile-browser @elastic/kibana-core -packages/core/user-profile/core-user-profile-browser-internal @elastic/kibana-core -packages/core/user-profile/core-user-profile-browser-mocks @elastic/kibana-core -packages/core/user-profile/core-user-profile-common @elastic/kibana-core -packages/core/user-profile/core-user-profile-server @elastic/kibana-core -packages/core/user-profile/core-user-profile-server-internal @elastic/kibana-core -packages/core/user-profile/core-user-profile-server-mocks @elastic/kibana-core -packages/core/user-settings/core-user-settings-server @elastic/kibana-security -packages/core/user-settings/core-user-settings-server-internal @elastic/kibana-security -packages/core/user-settings/core-user-settings-server-mocks @elastic/kibana-security -x-pack/plugins/cross_cluster_replication @elastic/kibana-management -packages/kbn-crypto @elastic/kibana-security -packages/kbn-crypto-browser @elastic/kibana-core -x-pack/plugins/custom_branding @elastic/appex-sharedux -packages/kbn-custom-icons @elastic/obs-ux-logs-team -packages/kbn-custom-integrations @elastic/obs-ux-logs-team -src/plugins/custom_integrations @elastic/fleet -packages/kbn-cypress-config @elastic/kibana-operations -x-pack/plugins/dashboard_enhanced @elastic/kibana-presentation -src/plugins/dashboard @elastic/kibana-presentation -x-pack/packages/kbn-data-forge @elastic/obs-ux-management-team -src/plugins/data @elastic/kibana-visualizations @elastic/kibana-data-discovery -x-pack/plugins/data_quality @elastic/obs-ux-logs-team -test/plugin_functional/plugins/data_search @elastic/kibana-data-discovery -packages/kbn-data-service @elastic/kibana-visualizations @elastic/kibana-data-discovery -packages/kbn-data-stream-adapter @elastic/security-threat-hunting-explore -x-pack/plugins/data_usage @elastic/obs-ai-assistant @elastic/security-solution -src/plugins/data_view_editor @elastic/kibana-data-discovery -examples/data_view_field_editor_example @elastic/kibana-data-discovery -src/plugins/data_view_field_editor @elastic/kibana-data-discovery -src/plugins/data_view_management @elastic/kibana-data-discovery -packages/kbn-data-view-utils @elastic/kibana-data-discovery -src/plugins/data_views @elastic/kibana-data-discovery -x-pack/plugins/data_visualizer @elastic/ml-ui -x-pack/plugins/observability_solution/dataset_quality @elastic/obs-ux-logs-team -packages/kbn-datemath @elastic/kibana-data-discovery -packages/deeplinks/analytics @elastic/kibana-data-discovery @elastic/kibana-presentation @elastic/kibana-visualizations -packages/deeplinks/devtools @elastic/kibana-management -packages/deeplinks/fleet @elastic/fleet -packages/deeplinks/management @elastic/kibana-management -packages/deeplinks/ml @elastic/ml-ui -packages/deeplinks/observability @elastic/obs-ux-management-team -packages/deeplinks/search @elastic/search-kibana -packages/deeplinks/security @elastic/security-solution -packages/deeplinks/shared @elastic/appex-sharedux -packages/default-nav/analytics @elastic/kibana-data-discovery @elastic/kibana-presentation @elastic/kibana-visualizations -packages/default-nav/devtools @elastic/kibana-management -packages/default-nav/management @elastic/kibana-management -packages/default-nav/ml @elastic/ml-ui -packages/kbn-dev-cli-errors @elastic/kibana-operations -packages/kbn-dev-cli-runner @elastic/kibana-operations -packages/kbn-dev-proc-runner @elastic/kibana-operations -src/plugins/dev_tools @elastic/kibana-management -packages/kbn-dev-utils @elastic/kibana-operations -examples/developer_examples @elastic/appex-sharedux -packages/kbn-discover-contextual-components @elastic/obs-ux-logs-team @elastic/kibana-data-discovery -examples/discover_customization_examples @elastic/kibana-data-discovery -x-pack/plugins/discover_enhanced @elastic/kibana-data-discovery -src/plugins/discover @elastic/kibana-data-discovery -src/plugins/discover_shared @elastic/kibana-data-discovery @elastic/obs-ux-logs-team -packages/kbn-discover-utils @elastic/kibana-data-discovery -packages/kbn-doc-links @elastic/docs -packages/kbn-docs-utils @elastic/kibana-operations -packages/kbn-dom-drag-drop @elastic/kibana-visualizations @elastic/kibana-data-discovery -packages/kbn-ebt-tools @elastic/kibana-core -x-pack/packages/security-solution/ecs_data_quality_dashboard @elastic/security-threat-hunting-explore -x-pack/plugins/ecs_data_quality_dashboard @elastic/security-threat-hunting-explore -packages/kbn-elastic-agent-utils @elastic/obs-ux-logs-team -x-pack/packages/kbn-elastic-assistant @elastic/security-generative-ai -x-pack/packages/kbn-elastic-assistant-common @elastic/security-generative-ai -x-pack/plugins/elastic_assistant @elastic/security-generative-ai -test/plugin_functional/plugins/elasticsearch_client_plugin @elastic/kibana-core -x-pack/test/plugin_api_integration/plugins/elasticsearch_client @elastic/kibana-core -x-pack/plugins/embeddable_enhanced @elastic/kibana-presentation -examples/embeddable_examples @elastic/kibana-presentation -src/plugins/embeddable @elastic/kibana-presentation -x-pack/examples/embedded_lens_example @elastic/kibana-visualizations -x-pack/plugins/encrypted_saved_objects @elastic/kibana-security -x-pack/plugins/enterprise_search @elastic/search-kibana -x-pack/plugins/observability_solution/entities_data_access @elastic/obs-entities -x-pack/packages/kbn-entities-schema @elastic/obs-entities -x-pack/test/api_integration/apis/entity_manager/fixture_plugin @elastic/obs-entities -x-pack/plugins/entity_manager @elastic/obs-entities -examples/error_boundary @elastic/appex-sharedux -packages/kbn-es @elastic/kibana-operations -packages/kbn-es-archiver @elastic/kibana-operations @elastic/appex-qa -packages/kbn-es-errors @elastic/kibana-core -packages/kbn-es-query @elastic/kibana-data-discovery -packages/kbn-es-types @elastic/kibana-core @elastic/obs-knowledge-team -src/plugins/es_ui_shared @elastic/kibana-management -packages/kbn-eslint-config @elastic/kibana-operations -packages/kbn-eslint-plugin-disable @elastic/kibana-operations -packages/kbn-eslint-plugin-eslint @elastic/kibana-operations -packages/kbn-eslint-plugin-i18n @elastic/obs-knowledge-team @elastic/kibana-operations -packages/kbn-eslint-plugin-imports @elastic/kibana-operations -packages/kbn-eslint-plugin-telemetry @elastic/obs-knowledge-team -examples/eso_model_version_example @elastic/kibana-security -x-pack/test/encrypted_saved_objects_api_integration/plugins/api_consumer_plugin @elastic/kibana-security -src/plugins/esql @elastic/kibana-esql -packages/kbn-esql-ast @elastic/kibana-esql -examples/esql_ast_inspector @elastic/kibana-esql -src/plugins/esql_datagrid @elastic/kibana-esql -packages/kbn-esql-editor @elastic/kibana-esql -packages/kbn-esql-utils @elastic/kibana-esql -packages/kbn-esql-validation-autocomplete @elastic/kibana-esql -examples/esql_validation_example @elastic/kibana-esql -test/plugin_functional/plugins/eui_provider_dev_warning @elastic/appex-sharedux -packages/kbn-event-annotation-common @elastic/kibana-visualizations -packages/kbn-event-annotation-components @elastic/kibana-visualizations -src/plugins/event_annotation_listing @elastic/kibana-visualizations -src/plugins/event_annotation @elastic/kibana-visualizations -x-pack/test/plugin_api_integration/plugins/event_log @elastic/response-ops -x-pack/plugins/event_log @elastic/response-ops -packages/kbn-expandable-flyout @elastic/security-threat-hunting-investigations -packages/kbn-expect @elastic/kibana-operations @elastic/appex-qa -x-pack/examples/exploratory_view_example @elastic/obs-ux-infra_services-team -x-pack/plugins/observability_solution/exploratory_view @elastic/obs-ux-management-team -src/plugins/expression_error @elastic/kibana-presentation -src/plugins/chart_expressions/expression_gauge @elastic/kibana-visualizations -src/plugins/chart_expressions/expression_heatmap @elastic/kibana-visualizations -src/plugins/expression_image @elastic/kibana-presentation -src/plugins/chart_expressions/expression_legacy_metric @elastic/kibana-visualizations -src/plugins/expression_metric @elastic/kibana-presentation -src/plugins/chart_expressions/expression_metric @elastic/kibana-visualizations -src/plugins/chart_expressions/expression_partition_vis @elastic/kibana-visualizations -src/plugins/expression_repeat_image @elastic/kibana-presentation -src/plugins/expression_reveal_image @elastic/kibana-presentation -src/plugins/expression_shape @elastic/kibana-presentation -src/plugins/chart_expressions/expression_tagcloud @elastic/kibana-visualizations -src/plugins/chart_expressions/expression_xy @elastic/kibana-visualizations -examples/expressions_explorer @elastic/kibana-visualizations -src/plugins/expressions @elastic/kibana-visualizations -packages/kbn-failed-test-reporter-cli @elastic/kibana-operations @elastic/appex-qa -examples/feature_control_examples @elastic/kibana-security -examples/feature_flags_example @elastic/kibana-core -x-pack/test/plugin_api_integration/plugins/feature_usage_test @elastic/kibana-security -x-pack/plugins/features @elastic/kibana-core -x-pack/test/security_api_integration/plugins/features_provider @elastic/kibana-security -x-pack/test/functional_execution_context/plugins/alerts @elastic/kibana-core -examples/field_formats_example @elastic/kibana-data-discovery -src/plugins/field_formats @elastic/kibana-data-discovery -packages/kbn-field-types @elastic/kibana-data-discovery -packages/kbn-field-utils @elastic/kibana-data-discovery -x-pack/plugins/fields_metadata @elastic/obs-ux-logs-team -x-pack/plugins/file_upload @elastic/kibana-gis @elastic/ml-ui -examples/files_example @elastic/appex-sharedux -src/plugins/files_management @elastic/appex-sharedux -src/plugins/files @elastic/appex-sharedux -packages/kbn-find-used-node-modules @elastic/kibana-operations -x-pack/plugins/fleet @elastic/fleet -packages/kbn-flot-charts @elastic/kibana-operations -x-pack/test/ui_capabilities/common/plugins/foo_plugin @elastic/kibana-security -packages/kbn-formatters @elastic/obs-ux-logs-team -src/plugins/ftr_apis @elastic/kibana-core -packages/kbn-ftr-common-functional-services @elastic/kibana-operations @elastic/appex-qa -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 -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 -packages/kbn-generate-csv @elastic/appex-sharedux -packages/kbn-get-repo-files @elastic/kibana-operations -x-pack/plugins/global_search_bar @elastic/appex-sharedux -x-pack/plugins/global_search @elastic/appex-sharedux -x-pack/plugins/global_search_providers @elastic/appex-sharedux -x-pack/test/plugin_functional/plugins/global_search_test @elastic/kibana-core -x-pack/plugins/graph @elastic/kibana-visualizations -examples/grid_example @elastic/kibana-presentation -packages/kbn-grid-layout @elastic/kibana-presentation -x-pack/plugins/grokdebugger @elastic/kibana-management -packages/kbn-grouping @elastic/response-ops -packages/kbn-guided-onboarding @elastic/appex-sharedux -examples/guided_onboarding_example @elastic/appex-sharedux -src/plugins/guided_onboarding @elastic/appex-sharedux -packages/kbn-handlebars @elastic/kibana-security -packages/kbn-hapi-mocks @elastic/kibana-core -test/plugin_functional/plugins/hardening @elastic/kibana-security -packages/kbn-health-gateway-server @elastic/kibana-core -examples/hello_world @elastic/kibana-core -src/plugins/home @elastic/kibana-core -packages/home/sample_data_card @elastic/appex-sharedux -packages/home/sample_data_tab @elastic/appex-sharedux -packages/home/sample_data_types @elastic/appex-sharedux -packages/kbn-i18n @elastic/kibana-core -packages/kbn-i18n-react @elastic/kibana-core -x-pack/test/functional_embedded/plugins/iframe_embedded @elastic/kibana-core -src/plugins/image_embeddable @elastic/appex-sharedux -packages/kbn-import-locator @elastic/kibana-operations -packages/kbn-import-resolver @elastic/kibana-operations -x-pack/plugins/index_lifecycle_management @elastic/kibana-management -x-pack/plugins/index_management @elastic/kibana-management -x-pack/packages/index-management/index_management_shared_types @elastic/kibana-management -test/plugin_functional/plugins/index_patterns @elastic/kibana-data-discovery -x-pack/packages/ml/inference_integration_flyout @elastic/ml-ui -x-pack/plugins/inference @elastic/appex-ai-infra -x-pack/packages/kbn-infra-forge @elastic/obs-ux-management-team -x-pack/plugins/observability_solution/infra @elastic/obs-ux-logs-team @elastic/obs-ux-infra_services-team -x-pack/plugins/ingest_pipelines @elastic/kibana-management -src/plugins/input_control_vis @elastic/kibana-presentation -src/plugins/inspector @elastic/kibana-presentation -x-pack/plugins/integration_assistant @elastic/security-scalability -src/plugins/interactive_setup @elastic/kibana-security -test/interactive_setup_api_integration/plugins/test_endpoints @elastic/kibana-security -packages/kbn-interpreter @elastic/kibana-visualizations -x-pack/plugins/observability_solution/inventory/e2e @elastic/obs-ux-infra_services-team -x-pack/plugins/observability_solution/inventory @elastic/obs-ux-infra_services-team -x-pack/plugins/observability_solution/investigate_app @elastic/obs-ux-management-team -x-pack/plugins/observability_solution/investigate @elastic/obs-ux-management-team -packages/kbn-investigation-shared @elastic/obs-ux-management-team -packages/kbn-io-ts-utils @elastic/obs-knowledge-team -packages/kbn-ipynb @elastic/search-kibana -packages/kbn-jest-serializers @elastic/kibana-operations -packages/kbn-journeys @elastic/kibana-operations @elastic/appex-qa -packages/kbn-json-ast @elastic/kibana-operations -x-pack/packages/ml/json_schemas @elastic/ml-ui -test/health_gateway/plugins/status @elastic/kibana-core -test/plugin_functional/plugins/kbn_sample_panel_action @elastic/appex-sharedux -test/plugin_functional/plugins/kbn_top_nav @elastic/kibana-core -test/plugin_functional/plugins/kbn_tp_custom_visualizations @elastic/kibana-visualizations -test/interpreter_functional/plugins/kbn_tp_run_pipeline @elastic/kibana-core -x-pack/test/functional_cors/plugins/kibana_cors_test @elastic/kibana-security -packages/kbn-kibana-manifest-schema @elastic/kibana-operations -src/plugins/kibana_overview @elastic/appex-sharedux -src/plugins/kibana_react @elastic/appex-sharedux -src/plugins/kibana_usage_collection @elastic/kibana-core -src/plugins/kibana_utils @elastic/appex-sharedux -x-pack/plugins/kubernetes_security @elastic/kibana-cloud-security-posture -x-pack/packages/kbn-langchain @elastic/security-generative-ai -packages/kbn-language-documentation @elastic/kibana-esql -x-pack/examples/lens_config_builder_example @elastic/kibana-visualizations -packages/kbn-lens-embeddable-utils @elastic/obs-ux-infra_services-team @elastic/kibana-visualizations -packages/kbn-lens-formula-docs @elastic/kibana-visualizations -x-pack/examples/lens_embeddable_inline_editing_example @elastic/kibana-visualizations -x-pack/plugins/lens @elastic/kibana-visualizations -x-pack/plugins/license_api_guard @elastic/kibana-management -x-pack/plugins/license_management @elastic/kibana-management -x-pack/plugins/licensing @elastic/kibana-core -src/plugins/links @elastic/kibana-presentation -packages/kbn-lint-packages-cli @elastic/kibana-operations -packages/kbn-lint-ts-projects-cli @elastic/kibana-operations -x-pack/plugins/lists @elastic/security-detection-engine -examples/locator_examples @elastic/appex-sharedux -examples/locator_explorer @elastic/appex-sharedux -packages/kbn-logging @elastic/kibana-core -packages/kbn-logging-mocks @elastic/kibana-core -x-pack/plugins/observability_solution/logs_data_access @elastic/obs-knowledge-team @elastic/obs-ux-logs-team -x-pack/plugins/observability_solution/logs_explorer @elastic/obs-ux-logs-team -x-pack/plugins/observability_solution/logs_shared @elastic/obs-ux-logs-team -x-pack/plugins/logstash @elastic/logstash -packages/kbn-managed-content-badge @elastic/kibana-visualizations -packages/kbn-managed-vscode-config @elastic/kibana-operations -packages/kbn-managed-vscode-config-cli @elastic/kibana-operations -packages/kbn-management/cards_navigation @elastic/kibana-management -src/plugins/management @elastic/kibana-management -packages/kbn-management/settings/application @elastic/kibana-management -packages/kbn-management/settings/components/field_category @elastic/kibana-management -packages/kbn-management/settings/components/field_input @elastic/kibana-management -packages/kbn-management/settings/components/field_row @elastic/kibana-management -packages/kbn-management/settings/components/form @elastic/kibana-management -packages/kbn-management/settings/field_definition @elastic/kibana-management -packages/kbn-management/settings/setting_ids @elastic/appex-sharedux @elastic/kibana-management -packages/kbn-management/settings/section_registry @elastic/appex-sharedux @elastic/kibana-management -packages/kbn-management/settings/types @elastic/kibana-management -packages/kbn-management/settings/utilities @elastic/kibana-management -packages/kbn-management/storybook/config @elastic/kibana-management -test/plugin_functional/plugins/management_test_plugin @elastic/kibana-management -packages/kbn-manifest @elastic/kibana-core -packages/kbn-mapbox-gl @elastic/kibana-gis -x-pack/examples/third_party_maps_source_example @elastic/kibana-gis -src/plugins/maps_ems @elastic/kibana-gis -x-pack/plugins/maps @elastic/kibana-gis -x-pack/packages/maps/vector_tile_utils @elastic/kibana-gis -x-pack/plugins/observability_solution/metrics_data_access @elastic/obs-knowledge-team @elastic/obs-ux-infra_services-team -x-pack/packages/ml/agg_utils @elastic/ml-ui -x-pack/packages/ml/anomaly_utils @elastic/ml-ui -x-pack/packages/ml/cancellable_search @elastic/ml-ui -x-pack/packages/ml/category_validator @elastic/ml-ui -x-pack/packages/ml/chi2test @elastic/ml-ui -x-pack/packages/ml/creation_wizard_utils @elastic/ml-ui -x-pack/packages/ml/data_frame_analytics_utils @elastic/ml-ui -x-pack/packages/ml/data_grid @elastic/ml-ui -x-pack/packages/ml/data_view_utils @elastic/ml-ui -x-pack/packages/ml/date_picker @elastic/ml-ui -x-pack/packages/ml/date_utils @elastic/ml-ui -x-pack/packages/ml/error_utils @elastic/ml-ui -x-pack/packages/ml/field_stats_flyout @elastic/ml-ui -x-pack/packages/ml/in_memory_table @elastic/ml-ui -x-pack/packages/ml/is_defined @elastic/ml-ui -x-pack/packages/ml/is_populated_object @elastic/ml-ui -x-pack/packages/ml/kibana_theme @elastic/ml-ui -x-pack/packages/ml/local_storage @elastic/ml-ui -x-pack/packages/ml/nested_property @elastic/ml-ui -x-pack/packages/ml/number_utils @elastic/ml-ui -x-pack/packages/ml/parse_interval @elastic/ml-ui -x-pack/plugins/ml @elastic/ml-ui -x-pack/packages/ml/query_utils @elastic/ml-ui -x-pack/packages/ml/random_sampler_utils @elastic/ml-ui -x-pack/packages/ml/response_stream @elastic/ml-ui -x-pack/packages/ml/route_utils @elastic/ml-ui -x-pack/packages/ml/runtime_field_utils @elastic/ml-ui -x-pack/packages/ml/string_hash @elastic/ml-ui -x-pack/packages/ml/time_buckets @elastic/ml-ui -x-pack/packages/ml/trained_models_utils @elastic/ml-ui -x-pack/packages/ml/ui_actions @elastic/ml-ui -x-pack/packages/ml/url_state @elastic/ml-ui -x-pack/packages/ml/validators @elastic/ml-ui -packages/kbn-mock-idp-plugin @elastic/kibana-security -packages/kbn-mock-idp-utils @elastic/kibana-security -packages/kbn-monaco @elastic/appex-sharedux -x-pack/plugins/monitoring_collection @elastic/stack-monitoring -x-pack/plugins/monitoring @elastic/stack-monitoring -src/plugins/navigation @elastic/appex-sharedux -src/plugins/newsfeed @elastic/kibana-core -test/common/plugins/newsfeed @elastic/kibana-core -src/plugins/no_data_page @elastic/appex-sharedux -x-pack/plugins/notifications @elastic/appex-sharedux -packages/kbn-object-versioning @elastic/appex-sharedux -packages/kbn-object-versioning-utils @elastic/appex-sharedux -x-pack/plugins/observability_solution/observability_ai_assistant_app @elastic/obs-ai-assistant -x-pack/plugins/observability_solution/observability_ai_assistant_management @elastic/obs-ai-assistant -x-pack/plugins/observability_solution/observability_ai_assistant @elastic/obs-ai-assistant -x-pack/packages/observability/alert_details @elastic/obs-ux-management-team -x-pack/packages/observability/alerting_rule_utils @elastic/obs-ux-management-team -x-pack/packages/observability/alerting_test_data @elastic/obs-ux-management-team -x-pack/test/cases_api_integration/common/plugins/observability @elastic/response-ops -x-pack/packages/observability/get_padded_alert_time_range_util @elastic/obs-ux-management-team -x-pack/plugins/observability_solution/observability_logs_explorer @elastic/obs-ux-logs-team -x-pack/packages/observability/logs_overview @elastic/obs-ux-logs-team -x-pack/plugins/observability_solution/observability_onboarding/e2e @elastic/obs-ux-logs-team @elastic/obs-ux-onboarding-team -x-pack/plugins/observability_solution/observability_onboarding @elastic/obs-ux-logs-team @elastic/obs-ux-onboarding-team -x-pack/plugins/observability_solution/observability @elastic/obs-ux-management-team -x-pack/plugins/observability_solution/observability_shared @elastic/observability-ui -x-pack/packages/observability/synthetics_test_data @elastic/obs-ux-management-team -x-pack/packages/observability/observability_utils @elastic/observability-ui -x-pack/test/security_api_integration/plugins/oidc_provider @elastic/kibana-security -test/common/plugins/otel_metrics @elastic/obs-ux-infra_services-team -packages/kbn-openapi-bundler @elastic/security-detection-rule-management -packages/kbn-openapi-common @elastic/security-detection-rule-management -packages/kbn-openapi-generator @elastic/security-detection-rule-management -packages/kbn-optimizer @elastic/kibana-operations -packages/kbn-optimizer-webpack-helpers @elastic/kibana-operations -packages/kbn-osquery-io-ts-types @elastic/security-asset-management -x-pack/plugins/osquery @elastic/security-defend-workflows -examples/partial_results_example @elastic/kibana-data-discovery -x-pack/plugins/painless_lab @elastic/kibana-management -packages/kbn-panel-loader @elastic/kibana-presentation -packages/kbn-peggy @elastic/kibana-operations -packages/kbn-peggy-loader @elastic/kibana-operations -packages/kbn-performance-testing-dataset-extractor @elastic/kibana-performance-testing -packages/kbn-picomatcher @elastic/kibana-operations -packages/kbn-plugin-check @elastic/appex-sharedux -packages/kbn-plugin-generator @elastic/kibana-operations -packages/kbn-plugin-helpers @elastic/kibana-operations -examples/portable_dashboards_example @elastic/kibana-presentation -examples/preboot_example @elastic/kibana-security @elastic/kibana-core -packages/presentation/presentation_containers @elastic/kibana-presentation -src/plugins/presentation_panel @elastic/kibana-presentation -packages/presentation/presentation_publishing @elastic/kibana-presentation -src/plugins/presentation_util @elastic/kibana-presentation -x-pack/packages/ai-infra/product-doc-artifact-builder @elastic/appex-ai-infra -x-pack/plugins/observability_solution/profiling_data_access @elastic/obs-ux-infra_services-team -x-pack/plugins/observability_solution/profiling @elastic/obs-ux-infra_services-team -packages/kbn-profiling-utils @elastic/obs-ux-infra_services-team -x-pack/packages/kbn-random-sampling @elastic/kibana-visualizations -packages/kbn-react-field @elastic/kibana-data-discovery -packages/kbn-react-hooks @elastic/obs-ux-logs-team -packages/react/kibana_context/common @elastic/appex-sharedux -packages/react/kibana_context/render @elastic/appex-sharedux -packages/react/kibana_context/root @elastic/appex-sharedux -packages/react/kibana_context/styled @elastic/appex-sharedux -packages/react/kibana_context/theme @elastic/appex-sharedux -packages/react/kibana_mount @elastic/appex-sharedux -packages/kbn-recently-accessed @elastic/appex-sharedux -x-pack/plugins/remote_clusters @elastic/kibana-management -test/plugin_functional/plugins/rendering_plugin @elastic/kibana-core -packages/kbn-repo-file-maps @elastic/kibana-operations -packages/kbn-repo-info @elastic/kibana-operations -packages/kbn-repo-linter @elastic/kibana-operations -packages/kbn-repo-packages @elastic/kibana-operations -packages/kbn-repo-path @elastic/kibana-operations -packages/kbn-repo-source-classifier @elastic/kibana-operations -packages/kbn-repo-source-classifier-cli @elastic/kibana-operations -packages/kbn-reporting/common @elastic/appex-sharedux -packages/kbn-reporting/get_csv_panel_actions @elastic/appex-sharedux -packages/kbn-reporting/export_types/csv @elastic/appex-sharedux -packages/kbn-reporting/export_types/csv_common @elastic/appex-sharedux -packages/kbn-reporting/export_types/pdf @elastic/appex-sharedux -packages/kbn-reporting/export_types/pdf_common @elastic/appex-sharedux -packages/kbn-reporting/export_types/png @elastic/appex-sharedux -packages/kbn-reporting/export_types/png_common @elastic/appex-sharedux -packages/kbn-reporting/mocks_server @elastic/appex-sharedux -x-pack/plugins/reporting @elastic/appex-sharedux -packages/kbn-reporting/public @elastic/appex-sharedux -packages/kbn-reporting/server @elastic/appex-sharedux -packages/kbn-resizable-layout @elastic/kibana-data-discovery -examples/resizable_layout_examples @elastic/kibana-data-discovery -x-pack/test/plugin_functional/plugins/resolver_test @elastic/security-solution -packages/response-ops/feature_flag_service @elastic/response-ops -packages/response-ops/rule_params @elastic/response-ops -examples/response_stream @elastic/ml-ui -packages/kbn-rison @elastic/kibana-operations -x-pack/packages/rollup @elastic/kibana-management -x-pack/plugins/rollup @elastic/kibana-management -packages/kbn-router-to-openapispec @elastic/kibana-core -packages/kbn-router-utils @elastic/obs-ux-logs-team -examples/routing_example @elastic/kibana-core -packages/kbn-rrule @elastic/response-ops -packages/kbn-rule-data-utils @elastic/security-detections-response @elastic/response-ops @elastic/obs-ux-management-team -x-pack/plugins/rule_registry @elastic/response-ops @elastic/obs-ux-management-team -x-pack/plugins/runtime_fields @elastic/kibana-management -packages/kbn-safer-lodash-set @elastic/kibana-security -x-pack/test/security_api_integration/plugins/saml_provider @elastic/kibana-security -x-pack/test/plugin_api_integration/plugins/sample_task_plugin @elastic/response-ops -x-pack/test/task_manager_claimer_update_by_query/plugins/sample_task_plugin_mget @elastic/response-ops -test/plugin_functional/plugins/saved_object_export_transforms @elastic/kibana-core -test/plugin_functional/plugins/saved_object_import_warnings @elastic/kibana-core -x-pack/test/saved_object_api_integration/common/plugins/saved_object_test_plugin @elastic/kibana-security -src/plugins/saved_objects_finder @elastic/kibana-data-discovery -test/plugin_functional/plugins/saved_objects_hidden_from_http_apis_type @elastic/kibana-core -test/plugin_functional/plugins/saved_objects_hidden_type @elastic/kibana-core -src/plugins/saved_objects_management @elastic/kibana-core -src/plugins/saved_objects @elastic/kibana-core -packages/kbn-saved-objects-settings @elastic/appex-sharedux -src/plugins/saved_objects_tagging_oss @elastic/appex-sharedux -x-pack/plugins/saved_objects_tagging @elastic/appex-sharedux -src/plugins/saved_search @elastic/kibana-data-discovery -examples/screenshot_mode_example @elastic/appex-sharedux -src/plugins/screenshot_mode @elastic/appex-sharedux -x-pack/examples/screenshotting_example @elastic/appex-sharedux -x-pack/plugins/screenshotting @elastic/kibana-reporting-services -packages/kbn-screenshotting-server @elastic/appex-sharedux -packages/kbn-search-api-keys-components @elastic/search-kibana -packages/kbn-search-api-keys-server @elastic/search-kibana -packages/kbn-search-api-panels @elastic/search-kibana -x-pack/plugins/search_assistant @elastic/search-kibana -packages/kbn-search-connectors @elastic/search-kibana -x-pack/plugins/search_connectors @elastic/search-kibana -packages/kbn-search-errors @elastic/kibana-data-discovery -examples/search_examples @elastic/kibana-data-discovery -x-pack/plugins/search_homepage @elastic/search-kibana -packages/kbn-search-index-documents @elastic/search-kibana -x-pack/plugins/search_indices @elastic/search-kibana -x-pack/plugins/search_inference_endpoints @elastic/search-kibana -x-pack/plugins/search_notebooks @elastic/search-kibana -x-pack/plugins/search_playground @elastic/search-kibana -packages/kbn-search-response-warnings @elastic/kibana-data-discovery -x-pack/packages/search/shared_ui @elastic/search-kibana -packages/kbn-search-types @elastic/kibana-data-discovery -x-pack/plugins/searchprofiler @elastic/kibana-management -x-pack/test/security_api_integration/packages/helpers @elastic/kibana-security -x-pack/packages/security/api_key_management @elastic/kibana-security -x-pack/packages/security/authorization_core @elastic/kibana-security -x-pack/packages/security/authorization_core_common @elastic/kibana-security -x-pack/packages/security/form_components @elastic/kibana-security -packages/kbn-security-hardening @elastic/kibana-security -x-pack/plugins/security @elastic/kibana-security -x-pack/packages/security/plugin_types_common @elastic/kibana-security -x-pack/packages/security/plugin_types_public @elastic/kibana-security -x-pack/packages/security/plugin_types_server @elastic/kibana-security -x-pack/packages/security/role_management_model @elastic/kibana-security -x-pack/packages/security-solution/common @elastic/security-threat-hunting-investigations -x-pack/packages/security-solution/distribution_bar @elastic/kibana-cloud-security-posture -x-pack/plugins/security_solution_ess @elastic/security-solution -x-pack/packages/security-solution/features @elastic/security-threat-hunting-explore -x-pack/test/cases_api_integration/common/plugins/security_solution @elastic/response-ops -x-pack/packages/security-solution/navigation @elastic/security-threat-hunting-explore -x-pack/plugins/security_solution @elastic/security-solution -x-pack/plugins/security_solution_serverless @elastic/security-solution -x-pack/packages/security-solution/side_nav @elastic/security-threat-hunting-explore -x-pack/packages/security-solution/storybook/config @elastic/security-threat-hunting-explore -x-pack/packages/security-solution/upselling @elastic/security-threat-hunting-explore -x-pack/test/security_functional/plugins/test_endpoints @elastic/kibana-security -x-pack/packages/security/ui_components @elastic/kibana-security -packages/kbn-securitysolution-autocomplete @elastic/security-detection-engine -x-pack/packages/security-solution/data_table @elastic/security-threat-hunting-investigations -packages/kbn-securitysolution-ecs @elastic/security-threat-hunting-explore -packages/kbn-securitysolution-endpoint-exceptions-common @elastic/security-detection-engine -packages/kbn-securitysolution-es-utils @elastic/security-detection-engine -packages/kbn-securitysolution-exception-list-components @elastic/security-detection-engine -packages/kbn-securitysolution-exceptions-common @elastic/security-detection-engine -packages/kbn-securitysolution-hook-utils @elastic/security-detection-engine -packages/kbn-securitysolution-io-ts-alerting-types @elastic/security-detection-engine -packages/kbn-securitysolution-io-ts-list-types @elastic/security-detection-engine -packages/kbn-securitysolution-io-ts-types @elastic/security-detection-engine -packages/kbn-securitysolution-io-ts-utils @elastic/security-detection-engine -packages/kbn-securitysolution-list-api @elastic/security-detection-engine -packages/kbn-securitysolution-list-constants @elastic/security-detection-engine -packages/kbn-securitysolution-list-hooks @elastic/security-detection-engine -packages/kbn-securitysolution-list-utils @elastic/security-detection-engine -packages/kbn-securitysolution-lists-common @elastic/security-detection-engine -packages/kbn-securitysolution-rules @elastic/security-detection-engine -packages/kbn-securitysolution-t-grid @elastic/security-detection-engine -packages/kbn-securitysolution-utils @elastic/security-detection-engine -packages/kbn-server-http-tools @elastic/kibana-core -packages/kbn-server-route-repository @elastic/obs-knowledge-team -packages/kbn-server-route-repository-client @elastic/obs-knowledge-team -packages/kbn-server-route-repository-utils @elastic/obs-knowledge-team -x-pack/plugins/serverless @elastic/appex-sharedux -packages/serverless/settings/common @elastic/appex-sharedux @elastic/kibana-management -x-pack/plugins/serverless_observability @elastic/obs-ux-management-team -packages/serverless/settings/observability_project @elastic/appex-sharedux @elastic/kibana-management @elastic/obs-ux-management-team -packages/serverless/project_switcher @elastic/appex-sharedux -x-pack/plugins/serverless_search @elastic/search-kibana -packages/serverless/settings/search_project @elastic/search-kibana @elastic/kibana-management -packages/serverless/settings/security_project @elastic/security-solution @elastic/kibana-management -packages/serverless/storybook/config @elastic/appex-sharedux -packages/serverless/types @elastic/appex-sharedux -test/plugin_functional/plugins/session_notifications @elastic/kibana-core -x-pack/plugins/session_view @elastic/kibana-cloud-security-posture -packages/kbn-set-map @elastic/kibana-operations -examples/share_examples @elastic/appex-sharedux -src/plugins/share @elastic/appex-sharedux -packages/kbn-shared-svg @elastic/obs-ux-infra_services-team -packages/shared-ux/avatar/solution @elastic/appex-sharedux -packages/shared-ux/button/exit_full_screen @elastic/appex-sharedux -packages/shared-ux/button_toolbar @elastic/appex-sharedux -packages/shared-ux/card/no_data/impl @elastic/appex-sharedux -packages/shared-ux/card/no_data/mocks @elastic/appex-sharedux -packages/shared-ux/card/no_data/types @elastic/appex-sharedux -packages/shared-ux/chrome/navigation @elastic/appex-sharedux -packages/shared-ux/error_boundary @elastic/appex-sharedux -packages/shared-ux/file/context @elastic/appex-sharedux -packages/shared-ux/file/image/impl @elastic/appex-sharedux -packages/shared-ux/file/image/mocks @elastic/appex-sharedux -packages/shared-ux/file/mocks @elastic/appex-sharedux -packages/shared-ux/file/file_picker/impl @elastic/appex-sharedux -packages/shared-ux/file/types @elastic/appex-sharedux -packages/shared-ux/file/file_upload/impl @elastic/appex-sharedux -packages/shared-ux/file/util @elastic/appex-sharedux -packages/shared-ux/link/redirect_app/impl @elastic/appex-sharedux -packages/shared-ux/link/redirect_app/mocks @elastic/appex-sharedux -packages/shared-ux/link/redirect_app/types @elastic/appex-sharedux -packages/shared-ux/markdown/impl @elastic/appex-sharedux -packages/shared-ux/markdown/mocks @elastic/appex-sharedux -packages/shared-ux/markdown/types @elastic/appex-sharedux -packages/shared-ux/page/analytics_no_data/impl @elastic/appex-sharedux -packages/shared-ux/page/analytics_no_data/mocks @elastic/appex-sharedux -packages/shared-ux/page/analytics_no_data/types @elastic/appex-sharedux -packages/shared-ux/page/kibana_no_data/impl @elastic/appex-sharedux -packages/shared-ux/page/kibana_no_data/mocks @elastic/appex-sharedux -packages/shared-ux/page/kibana_no_data/types @elastic/appex-sharedux -packages/shared-ux/page/kibana_template/impl @elastic/appex-sharedux -packages/shared-ux/page/kibana_template/mocks @elastic/appex-sharedux -packages/shared-ux/page/kibana_template/types @elastic/appex-sharedux -packages/shared-ux/page/no_data/impl @elastic/appex-sharedux -packages/shared-ux/page/no_data_config/impl @elastic/appex-sharedux -packages/shared-ux/page/no_data_config/mocks @elastic/appex-sharedux -packages/shared-ux/page/no_data_config/types @elastic/appex-sharedux -packages/shared-ux/page/no_data/mocks @elastic/appex-sharedux -packages/shared-ux/page/no_data/types @elastic/appex-sharedux -packages/shared-ux/page/solution_nav @elastic/appex-sharedux -packages/shared-ux/prompt/no_data_views/impl @elastic/appex-sharedux -packages/shared-ux/prompt/no_data_views/mocks @elastic/appex-sharedux -packages/shared-ux/prompt/no_data_views/types @elastic/appex-sharedux -packages/shared-ux/prompt/not_found @elastic/appex-sharedux -packages/shared-ux/router/impl @elastic/appex-sharedux -packages/shared-ux/router/mocks @elastic/appex-sharedux -packages/shared-ux/router/types @elastic/appex-sharedux -packages/shared-ux/storybook/config @elastic/appex-sharedux -packages/shared-ux/storybook/mock @elastic/appex-sharedux -packages/shared-ux/modal/tabbed @elastic/appex-sharedux -packages/shared-ux/table_persist @elastic/appex-sharedux -packages/kbn-shared-ux-utility @elastic/appex-sharedux -x-pack/plugins/observability_solution/slo @elastic/obs-ux-management-team -x-pack/packages/kbn-slo-schema @elastic/obs-ux-management-team -x-pack/plugins/snapshot_restore @elastic/kibana-management -packages/kbn-some-dev-log @elastic/kibana-operations -packages/kbn-sort-package-json @elastic/kibana-operations -packages/kbn-sort-predicates @elastic/kibana-visualizations -x-pack/plugins/spaces @elastic/kibana-security -x-pack/test/spaces_api_integration/common/plugins/spaces_test_plugin @elastic/kibana-security -packages/kbn-spec-to-console @elastic/kibana-management -packages/kbn-sse-utils @elastic/obs-knowledge-team -packages/kbn-sse-utils-client @elastic/obs-knowledge-team -packages/kbn-sse-utils-server @elastic/obs-knowledge-team -x-pack/plugins/stack_alerts @elastic/response-ops -x-pack/plugins/stack_connectors @elastic/response-ops -x-pack/test/usage_collection/plugins/stack_management_usage_test @elastic/kibana-management -examples/state_containers_examples @elastic/appex-sharedux -test/server_integration/plugins/status_plugin_a @elastic/kibana-core -test/server_integration/plugins/status_plugin_b @elastic/kibana-core -packages/kbn-std @elastic/kibana-core -packages/kbn-stdio-dev-helpers @elastic/kibana-operations -packages/kbn-storybook @elastic/kibana-operations -x-pack/plugins/observability_solution/synthetics/e2e @elastic/obs-ux-management-team -x-pack/plugins/observability_solution/synthetics @elastic/obs-ux-management-team -x-pack/packages/kbn-synthetics-private-location @elastic/obs-ux-management-team -x-pack/test/alerting_api_integration/common/plugins/task_manager_fixture @elastic/response-ops -x-pack/test/plugin_api_perf/plugins/task_manager_performance @elastic/response-ops -x-pack/plugins/task_manager @elastic/response-ops -src/plugins/telemetry_collection_manager @elastic/kibana-core -x-pack/plugins/telemetry_collection_xpack @elastic/kibana-core -src/plugins/telemetry_management_section @elastic/kibana-core -src/plugins/telemetry @elastic/kibana-core -test/plugin_functional/plugins/telemetry @elastic/kibana-core -packages/kbn-telemetry-tools @elastic/kibana-core -packages/kbn-test @elastic/kibana-operations @elastic/appex-qa -packages/kbn-test-eui-helpers @elastic/kibana-visualizations -x-pack/test/licensing_plugin/plugins/test_feature_usage @elastic/kibana-security -packages/kbn-test-jest-helpers @elastic/kibana-operations @elastic/appex-qa -packages/kbn-test-subj-selector @elastic/kibana-operations @elastic/appex-qa -x-pack/test_serverless -test -x-pack/test -x-pack/performance @elastic/appex-qa -x-pack/examples/testing_embedded_lens @elastic/kibana-visualizations -x-pack/examples/third_party_lens_navigation_prompt @elastic/kibana-visualizations -x-pack/examples/third_party_vis_lens_example @elastic/kibana-visualizations -x-pack/plugins/threat_intelligence @elastic/security-threat-hunting-investigations -x-pack/plugins/timelines @elastic/security-threat-hunting-investigations -packages/kbn-timelion-grammar @elastic/kibana-visualizations -packages/kbn-timerange @elastic/obs-ux-logs-team -packages/kbn-tinymath @elastic/kibana-visualizations -packages/kbn-tooling-log @elastic/kibana-operations -x-pack/plugins/transform @elastic/ml-ui -x-pack/plugins/translations @elastic/kibana-localization -packages/kbn-transpose-utils @elastic/kibana-visualizations -x-pack/examples/triggers_actions_ui_example @elastic/response-ops -x-pack/plugins/triggers_actions_ui @elastic/response-ops -packages/kbn-triggers-actions-ui-types @elastic/response-ops -packages/kbn-try-in-console @elastic/search-kibana -packages/kbn-ts-projects @elastic/kibana-operations -packages/kbn-ts-type-check-cli @elastic/kibana-operations -packages/kbn-typed-react-router-config @elastic/obs-knowledge-team @elastic/obs-ux-management-team -packages/kbn-ui-actions-browser @elastic/appex-sharedux -x-pack/examples/ui_actions_enhanced_examples @elastic/appex-sharedux -src/plugins/ui_actions_enhanced @elastic/appex-sharedux -examples/ui_action_examples @elastic/appex-sharedux -examples/ui_actions_explorer @elastic/appex-sharedux -src/plugins/ui_actions @elastic/appex-sharedux -test/plugin_functional/plugins/ui_settings_plugin @elastic/kibana-core -packages/kbn-ui-shared-deps-npm @elastic/kibana-operations -packages/kbn-ui-shared-deps-src @elastic/kibana-operations -packages/kbn-ui-theme @elastic/kibana-operations -packages/kbn-unified-data-table @elastic/kibana-data-discovery @elastic/security-threat-hunting-investigations -packages/kbn-unified-doc-viewer @elastic/kibana-data-discovery -examples/unified_doc_viewer @elastic/kibana-core -src/plugins/unified_doc_viewer @elastic/kibana-data-discovery -packages/kbn-unified-field-list @elastic/kibana-data-discovery -examples/unified_field_list_examples @elastic/kibana-data-discovery -src/plugins/unified_histogram @elastic/kibana-data-discovery -src/plugins/unified_search @elastic/kibana-visualizations -packages/kbn-unsaved-changes-badge @elastic/kibana-data-discovery -packages/kbn-unsaved-changes-prompt @elastic/kibana-management -x-pack/plugins/upgrade_assistant @elastic/kibana-management -x-pack/plugins/observability_solution/uptime @elastic/obs-ux-management-team -x-pack/plugins/drilldowns/url_drilldown @elastic/appex-sharedux -src/plugins/url_forwarding @elastic/kibana-visualizations -src/plugins/usage_collection @elastic/kibana-core -test/plugin_functional/plugins/usage_collection @elastic/kibana-core -packages/kbn-use-tracked-promise @elastic/obs-ux-logs-team -packages/kbn-user-profile-components @elastic/kibana-security -examples/user_profile_examples @elastic/kibana-security -x-pack/test/security_api_integration/plugins/user_profiles_consumer @elastic/kibana-security -packages/kbn-utility-types @elastic/kibana-core -packages/kbn-utility-types-jest @elastic/kibana-operations -packages/kbn-utils @elastic/kibana-operations -x-pack/plugins/observability_solution/ux @elastic/obs-ux-infra_services-team -examples/v8_profiler_examples @elastic/response-ops -packages/kbn-validate-next-docs-cli @elastic/kibana-operations -src/plugins/vis_default_editor @elastic/kibana-visualizations -src/plugins/vis_types/gauge @elastic/kibana-visualizations -src/plugins/vis_types/heatmap @elastic/kibana-visualizations -src/plugins/vis_type_markdown @elastic/kibana-presentation -src/plugins/vis_types/metric @elastic/kibana-visualizations -src/plugins/vis_types/pie @elastic/kibana-visualizations -src/plugins/vis_types/table @elastic/kibana-visualizations -src/plugins/vis_types/tagcloud @elastic/kibana-visualizations -src/plugins/vis_types/timelion @elastic/kibana-visualizations -src/plugins/vis_types/timeseries @elastic/kibana-visualizations -src/plugins/vis_types/vega @elastic/kibana-visualizations -src/plugins/vis_types/vislib @elastic/kibana-visualizations -src/plugins/vis_types/xy @elastic/kibana-visualizations -packages/kbn-visualization-ui-components @elastic/kibana-visualizations -packages/kbn-visualization-utils @elastic/kibana-visualizations -src/plugins/visualizations @elastic/kibana-visualizations -x-pack/plugins/watcher @elastic/kibana-management -packages/kbn-web-worker-stub @elastic/kibana-operations -packages/kbn-whereis-pkg-cli @elastic/kibana-operations -packages/kbn-xstate-utils @elastic/obs-ux-logs-team -packages/kbn-yarn-lock-validator @elastic/kibana-operations -packages/kbn-zod @elastic/kibana-core -packages/kbn-zod-helpers @elastic/security-detection-rule-management -#### -## Everything below this line overrides the default assignments for each package. -## Items lower in the file have higher precedence: -## https://help.github.com/articles/about-codeowners/ -#### - -# The #CC# prefix delineates Code Coverage, -# used for the 'team' designator within Kibana Stats - -x-pack/test_serverless/api_integration/test_suites/common/platform_security @elastic/kibana-security - -# Data Discovery -/x-pack/test_serverless/functional/es_archives/pre_calculated_histogram @elastic/kibana-data-discovery -/x-pack/test_serverless/functional/es_archives/kibana_sample_data_flights_index_pattern @elastic/kibana-data-discovery -/x-pack/test_serverless/functional/test_suites/security/config.examples.ts @elastic/kibana-data-discovery -/x-pack/test_serverless/functional/test_suites/security/config.context_awareness.ts @elastic/kibana-data-discovery -/test/accessibility/apps/discover.ts @elastic/kibana-data-discovery -/test/api_integration/apis/data_views @elastic/kibana-data-discovery -/test/api_integration/apis/data_view_field_editor @elastic/kibana-data-discovery -/test/api_integration/apis/kql_telemetry @elastic/kibana-data-discovery -/test/api_integration/apis/scripts @elastic/kibana-data-discovery -/test/api_integration/apis/search @elastic/kibana-data-discovery -/test/examples/data_view_field_editor_example @elastic/kibana-data-discovery -/test/examples/discover_customization_examples @elastic/kibana-data-discovery -/test/examples/field_formats @elastic/kibana-data-discovery -/test/examples/partial_results @elastic/kibana-data-discovery -/test/examples/search @elastic/kibana-data-discovery -/test/examples/unified_field_list_examples @elastic/kibana-data-discovery -/test/functional/apps/context @elastic/kibana-data-discovery -/test/functional/apps/discover @elastic/kibana-data-discovery -/test/functional/apps/management/ccs_compatibility/_data_views_ccs.ts @elastic/kibana-data-discovery -/test/functional/apps/management/data_views @elastic/kibana-data-discovery -/test/plugin_functional/test_suites/data_plugin @elastic/kibana-data-discovery -/x-pack/test/accessibility/apps/group3/search_sessions.ts @elastic/kibana-data-discovery -/x-pack/test/api_integration/apis/management/rollup/index_patterns_extensions.js @elastic/kibana-data-discovery -/x-pack/test/api_integration/apis/search @elastic/kibana-data-discovery -/x-pack/test/examples/search_examples @elastic/kibana-data-discovery -/x-pack/test/functional/apps/data_views @elastic/kibana-data-discovery -/x-pack/test/functional/apps/discover @elastic/kibana-data-discovery -/x-pack/test/functional/apps/saved_query_management @elastic/kibana-data-discovery -/x-pack/test/functional_with_es_ssl/apps/discover_ml_uptime/discover @elastic/kibana-data-discovery -/x-pack/test/search_sessions_integration @elastic/kibana-data-discovery -/x-pack/test/stack_functional_integration/apps/ccs/ccs_discover.js @elastic/kibana-data-discovery -/x-pack/test/stack_functional_integration/apps/management/_index_pattern_create.js @elastic/kibana-data-discovery -/x-pack/test/upgrade/apps/discover @elastic/kibana-data-discovery -/x-pack/test_serverless/api_integration/test_suites/common/data_views @elastic/kibana-data-discovery -/x-pack/test_serverless/api_integration/test_suites/common/data_view_field_editor @elastic/kibana-data-discovery -/x-pack/test_serverless/api_integration/test_suites/common/kql_telemetry @elastic/kibana-data-discovery -/x-pack/test_serverless/api_integration/test_suites/common/scripts_tests @elastic/kibana-data-discovery -/x-pack/test_serverless/api_integration/test_suites/common/search_oss @elastic/kibana-data-discovery -/x-pack/test_serverless/api_integration/test_suites/common/search_xpack @elastic/kibana-data-discovery -/x-pack/test_serverless/functional/test_suites/common/context @elastic/kibana-data-discovery -/x-pack/test_serverless/functional/test_suites/common/discover @elastic/kibana-data-discovery -/x-pack/test_serverless/functional/test_suites/common/discover_ml_uptime/discover @elastic/kibana-data-discovery -/x-pack/test_serverless/functional/test_suites/common/examples/data_view_field_editor_example @elastic/kibana-data-discovery -/x-pack/test_serverless/functional/test_suites/common/examples/discover_customization_examples @elastic/kibana-data-discovery -/x-pack/test_serverless/functional/test_suites/common/examples/field_formats @elastic/kibana-data-discovery -/x-pack/test_serverless/functional/test_suites/common/examples/partial_results @elastic/kibana-data-discovery -/x-pack/test_serverless/functional/test_suites/common/examples/search @elastic/kibana-data-discovery -/x-pack/test_serverless/functional/test_suites/common/examples/search_examples @elastic/kibana-data-discovery -/x-pack/test_serverless/functional/test_suites/common/examples/unified_field_list_examples @elastic/kibana-data-discovery -/x-pack/test_serverless/functional/test_suites/common/management/data_views @elastic/kibana-data-discovery -src/plugins/discover/public/context_awareness/profile_providers/security @elastic/kibana-data-discovery @elastic/security-threat-hunting-investigations - -# Platform Docs -/x-pack/test_serverless/functional/test_suites/security/screenshot_creation/index.ts @elastic/platform-docs -/x-pack/test_serverless/functional/test_suites/security/config.screenshots.ts @elastic/platform-docs - -# Visualizations -/src/plugins/visualize/ @elastic/kibana-visualizations -/x-pack/test/functional/apps/lens @elastic/kibana-visualizations -/x-pack/test/api_integration/apis/lens/ @elastic/kibana-visualizations -/test/functional/apps/visualize/ @elastic/kibana-visualizations -/x-pack/test/functional/apps/graph @elastic/kibana-visualizations -/test/api_integration/apis/event_annotations @elastic/kibana-visualizations -/x-pack/test_serverless/functional/test_suites/common/visualizations/ @elastic/kibana-visualizations -/x-pack/test_serverless/functional/fixtures/kbn_archiver/lens/ @elastic/kibana-visualizations -packages/kbn-monaco/src/esql @elastic/kibana-esql - -# Global Experience - -### Global Experience Reporting -/x-pack/test/functional/apps/dashboard/reporting/ @elastic/appex-sharedux -/x-pack/test/functional/apps/reporting/ @elastic/appex-sharedux -/x-pack/test/functional/apps/reporting_management/ @elastic/appex-sharedux -/x-pack/test/examples/screenshotting/ @elastic/appex-sharedux -/x-pack/test/functional/es_archives/lens/reporting/ @elastic/appex-sharedux -/x-pack/test/functional/es_archives/reporting/ @elastic/appex-sharedux -/x-pack/test/functional/fixtures/kbn_archiver/reporting/ @elastic/appex-sharedux -/x-pack/test/reporting_api_integration/ @elastic/appex-sharedux -/x-pack/test/reporting_functional/ @elastic/appex-sharedux -/x-pack/test/stack_functional_integration/apps/reporting/ @elastic/appex-sharedux -/docs/user/reporting @elastic/appex-sharedux -/docs/settings/reporting-settings.asciidoc @elastic/appex-sharedux -/docs/setup/configuring-reporting.asciidoc @elastic/appex-sharedux -/x-pack/test_serverless/**/test_suites/common/reporting/ @elastic/appex-sharedux - -### Global Experience Tagging -/x-pack/test/saved_object_tagging/ @elastic/appex-sharedux - -### Kibana React (to be deprecated) -/src/plugins/kibana_react/public/@elastic/appex-sharedux @elastic/kibana-presentation - -### Home Plugin and Packages -/src/plugins/home/public @elastic/appex-sharedux -/src/plugins/home/server/*.ts @elastic/appex-sharedux -/src/plugins/home/server/services/ @elastic/appex-sharedux - -### Code Coverage -#CC# /src/plugins/home/public @elastic/appex-sharedux -#CC# /src/plugins/home/server/services/ @elastic/appex-sharedux -#CC# /src/plugins/home/ @elastic/appex-sharedux -#CC# /x-pack/plugins/reporting/ @elastic/appex-sharedux -#CC# /x-pack/plugins/security_solution_serverless/ @elastic/appex-sharedux - -### Observability Plugins - - -# Observability AI Assistant -x-pack/test/observability_ai_assistant_api_integration @elastic/obs-ai-assistant -x-pack/test/observability_ai_assistant_functional @elastic/obs-ai-assistant -x-pack/test_serverless/**/test_suites/observability/ai_assistant @elastic/obs-ai-assistant - -# Infra Monitoring -## This plugin mostly contains the codebase for the infra services, but also includes some code for the Logs UI app. -## To keep @elastic/obs-ux-logs-team as codeowner of the plugin manifest without requiring a review for all the other code changes -## the priority on codeownership will be as follow: -## - infra -> both teams (automatically generated by script) -## - infra/{common,docs,public,server}/{sub-folders}/ -> @elastic/obs-ux-infra_services-team -## - Logs UI code exceptions -> @elastic/obs-ux-logs-team -## This should allow the infra team to work without dependencies on the @elastic/obs-ux-logs-team, which will maintain ownership of the Logs UI code only. - -## infra/{common,docs,public,server}/{sub-folders}/ -> @elastic/obs-ux-infra_services-team -/x-pack/plugins/observability_solution/infra/common @elastic/obs-ux-infra_services-team -/x-pack/plugins/observability_solution/infra/docs @elastic/obs-ux-infra_services-team -/x-pack/plugins/observability_solution/infra/public/alerting @elastic/obs-ux-infra_services-team -/x-pack/plugins/observability_solution/infra/public/apps @elastic/obs-ux-infra_services-team -/x-pack/plugins/observability_solution/infra/public/common @elastic/obs-ux-infra_services-team -/x-pack/plugins/observability_solution/infra/public/components @elastic/obs-ux-infra_services-team -/x-pack/plugins/observability_solution/infra/public/containers @elastic/obs-ux-infra_services-team -/x-pack/plugins/observability_solution/infra/public/hooks @elastic/obs-ux-infra_services-team -/x-pack/plugins/observability_solution/infra/public/images @elastic/obs-ux-infra_services-team -/x-pack/plugins/observability_solution/infra/public/lib @elastic/obs-ux-infra_services-team -/x-pack/plugins/observability_solution/infra/public/pages @elastic/obs-ux-infra_services-team -/x-pack/plugins/observability_solution/infra/public/services @elastic/obs-ux-infra_services-team -/x-pack/plugins/observability_solution/infra/public/test_utils @elastic/obs-ux-infra_services-team -/x-pack/plugins/observability_solution/infra/public/utils @elastic/obs-ux-infra_services-team -/x-pack/plugins/observability_solution/infra/server/lib @elastic/obs-ux-infra_services-team -/x-pack/plugins/observability_solution/infra/server/routes @elastic/obs-ux-infra_services-team -/x-pack/plugins/observability_solution/infra/server/saved_objects @elastic/obs-ux-infra_services-team -/x-pack/plugins/observability_solution/infra/server/services @elastic/obs-ux-infra_services-team -/x-pack/plugins/observability_solution/infra/server/usage @elastic/obs-ux-infra_services-team -/x-pack/plugins/observability_solution/infra/server/utils @elastic/obs-ux-infra_services-team - -## Logs UI code exceptions -> @elastic/obs-ux-logs-team -/x-pack/test_serverless/functional/page_objects/svl_oblt_onboarding_stream_log_file.ts @elastic/obs-ux-logs-team -/x-pack/test_serverless/functional/page_objects/svl_oblt_onboarding_page.ts @elastic/obs-ux-logs-team -/x-pack/plugins/observability_solution/infra/common/http_api/log_alerts @elastic/obs-ux-logs-team -/x-pack/plugins/observability_solution/infra/common/http_api/log_analysis @elastic/obs-ux-logs-team -/x-pack/plugins/observability_solution/infra/common/log_analysis @elastic/obs-ux-logs-team -/x-pack/plugins/observability_solution/infra/common/log_search_result @elastic/obs-ux-logs-team -/x-pack/plugins/observability_solution/infra/common/log_search_summary @elastic/obs-ux-logs-team -/x-pack/plugins/observability_solution/infra/common/log_text_scale @elastic/obs-ux-logs-team -/x-pack/plugins/observability_solution/infra/common/performance_tracing.ts @elastic/obs-ux-logs-team -/x-pack/plugins/observability_solution/infra/common/search_strategies/log_entries @elastic/obs-ux-logs-team -/x-pack/plugins/observability_solution/infra/docs/state_machines @elastic/obs-ux-logs-team -/x-pack/plugins/observability_solution/infra/public/apps/logs_app.tsx @elastic/obs-ux-logs-team -/x-pack/plugins/observability_solution/infra/public/components/log_stream @elastic/obs-ux-logs-team -/x-pack/plugins/observability_solution/infra/public/components/logging @elastic/obs-ux-logs-team -/x-pack/plugins/observability_solution/infra/public/containers/logs @elastic/obs-ux-logs-team -/x-pack/plugins/observability_solution/infra/public/observability_logs @elastic/obs-ux-logs-team -/x-pack/plugins/observability_solution/infra/public/pages/logs @elastic/obs-ux-logs-team -/x-pack/plugins/observability_solution/infra/server/lib/log_analysis @elastic/obs-ux-logs-team -/x-pack/plugins/observability_solution/infra/server/routes/log_alerts @elastic/obs-ux-logs-team -/x-pack/plugins/observability_solution/infra/server/routes/log_analysis @elastic/obs-ux-logs-team -/x-pack/plugins/observability_solution/infra/server/services/rules @elastic/obs-ux-infra_services-team @elastic/obs-ux-logs-team -# Infra Monitoring tests -/x-pack/test/api_integration/apis/infra @elastic/obs-ux-infra_services-team -/x-pack/test/functional/apps/infra @elastic/obs-ux-infra_services-team -/x-pack/test/functional/apps/infra/logs @elastic/obs-ux-logs-team - -# Observability UX management team -/x-pack/packages/observability/alert_details @elastic/obs-ux-management-team -/x-pack/test/observability_functional @elastic/obs-ux-management-team -/x-pack/plugins/observability_solution/infra/public/alerting @elastic/obs-ux-management-team -/x-pack/plugins/observability_solution/infra/server/lib/alerting @elastic/obs-ux-management-team -/x-pack/test_serverless/**/test_suites/observability/custom_threshold_rule/ @elastic/obs-ux-management-team -/x-pack/test_serverless/**/test_suites/observability/slos/ @elastic/obs-ux-management-team -/x-pack/test_serverless/api_integration/test_suites/observability/es_query_rule @elastic/obs-ux-management-team -/x-pack/test/api_integration/deployment_agnostic/apis/observability/alerting/burn_rate_rule @elastic/obs-ux-management-team -/x-pack/test/api_integration/deployment_agnostic/services/alerting_api @elastic/obs-ux-management-team -/x-pack/test/api_integration/deployment_agnostic/services/slo_api @elastic/obs-ux-management-team -/x-pack/test_serverless/**/test_suites/observability/infra/ @elastic/obs-ux-infra_services-team - -# Elastic Stack Monitoring -/x-pack/test/functional/apps/monitoring @elastic/stack-monitoring -/x-pack/test/api_integration/apis/monitoring @elastic/stack-monitoring -/x-pack/test/api_integration/apis/monitoring_collection @elastic/stack-monitoring - -# Fleet -/x-pack/test/fleet_api_integration @elastic/fleet -/x-pack/test/fleet_cypress @elastic/fleet -/x-pack/test/fleet_functional @elastic/fleet -/src/dev/build/tasks/bundle_fleet_packages.ts @elastic/fleet @elastic/kibana-operations -/x-pack/plugins/fleet/server/services/elastic_agent_manifest.ts @elastic/fleet @elastic/obs-cloudnative-monitoring -/x-pack/test_serverless/**/test_suites/**/fleet/ @elastic/fleet - -# APM -/x-pack/test/functional/apps/apm/ @elastic/obs-ux-infra_services-team -/x-pack/test/apm_api_integration/ @elastic/obs-ux-infra_services-team -/src/apm.js @elastic/kibana-core @vigneshshanmugam -/packages/kbn-utility-types/src/dot.ts @dgieselaar -/packages/kbn-utility-types/src/dot_test.ts @dgieselaar -/x-pack/test_serverless/api_integration/test_suites/observability/apm_api_integration/ @elastic/obs-ux-infra_services-team -#CC# /src/plugins/apm_oss/ @elastic/apm-ui -#CC# /x-pack/plugins/observability_solution/observability/ @elastic/apm-ui - -# Uptime -/x-pack/test/functional_with_es_ssl/apps/discover_ml_uptime/uptime/ @elastic/obs-ux-management-team -/x-pack/test/functional/apps/uptime @elastic/obs-ux-management-team -/x-pack/test/functional/es_archives/uptime @elastic/obs-ux-management-team -/x-pack/test/functional/services/uptime @elastic/obs-ux-management-team -/x-pack/test/api_integration/apis/uptime @elastic/obs-ux-management-team -/x-pack/test/api_integration/apis/synthetics @elastic/obs-ux-management-team -/x-pack/test/alerting_api_integration/observability/synthetics_rule.ts @elastic/obs-ux-management-team -/x-pack/test/alerting_api_integration/observability/index.ts @elastic/obs-ux-management-team -/x-pack/test_serverless/api_integration/test_suites/observability/synthetics @elastic/obs-ux-management-team - -# Logs -/x-pack/test_serverless/api_integration/test_suites/observability/index.feature_flags.ts @elastic/obs-ux-logs-team -/x-pack/test/api_integration/apis/logs_ui @elastic/obs-ux-logs-team -/x-pack/test/dataset_quality_api_integration @elastic/obs-ux-logs-team -/x-pack/test_serverless/api_integration/test_suites/observability/dataset_quality_api_integration @elastic/obs-ux-logs-team -/x-pack/test/functional/apps/observability_logs_explorer @elastic/obs-ux-logs-team -/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer @elastic/obs-ux-logs-team -/x-pack/test/functional/apps/dataset_quality @elastic/obs-ux-logs-team -/x-pack/test_serverless/functional/test_suites/observability/dataset_quality @elastic/obs-ux-logs-team -/x-pack/test_serverless/functional/test_suites/observability/ @elastic/obs-ux-logs-team -/src/plugins/unified_doc_viewer/public/components/doc_viewer_logs_overview @elastic/obs-ux-logs-team - -# Observability onboarding tour -/x-pack/plugins/observability_solution/observability_shared/public/components/tour @elastic/appex-sharedux -/x-pack/test/functional/apps/infra/tour.ts @elastic/appex-sharedux - -# Observability settings -/x-pack/plugins/observability_solution/observability/server/ui_settings.ts @elastic/obs-docs - -### END Observability Plugins - -# Presentation -/test/functional/apps/dashboard/ @elastic/kibana-presentation -/test/functional/apps/dashboard_elements/ @elastic/kibana-presentation -/test/functional/services/dashboard/ @elastic/kibana-presentation -/x-pack/test/functional/apps/canvas/ @elastic/kibana-presentation -/x-pack/test_serverless/functional/test_suites/search/dashboards/ @elastic/kibana-presentation -/test/plugin_functional/test_suites/panel_actions @elastic/kibana-presentation -/x-pack/test/functional/es_archives/canvas/logstash_lens @elastic/kibana-presentation -#CC# /src/plugins/kibana_react/public/code_editor/ @elastic/kibana-presentation - -# Machine Learning -/x-pack/test/accessibility/apps/group2/ml.ts @elastic/ml-ui -/x-pack/test/accessibility/apps/group3/ml_embeddables_in_dashboard.ts @elastic/ml-ui -/x-pack/test/api_integration/apis/ml/ @elastic/ml-ui -/x-pack/test/api_integration_basic/apis/ml/ @elastic/ml-ui -/x-pack/test/functional/apps/ml/ @elastic/ml-ui -/x-pack/test/functional/es_archives/ml/ @elastic/ml-ui -/x-pack/test/functional/services/ml/ @elastic/ml-ui -/x-pack/test/functional_basic/apps/ml/ @elastic/ml-ui -/x-pack/test/functional_with_es_ssl/apps/discover_ml_uptime/ml/ @elastic/ml-ui -/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/ml_rule_types/ @elastic/ml-ui -/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/transform_rule_types/ @elastic/ml-ui -/x-pack/test/screenshot_creation/apps/ml_docs @elastic/ml-ui -/x-pack/test/screenshot_creation/services/ml_screenshots.ts @elastic/ml-ui -/x-pack/test_serverless/**/test_suites/**/ml/ @elastic/ml-ui -/x-pack/test_serverless/**/test_suites/common/management/transforms/ @elastic/ml-ui - -# Additional plugins and packages maintained by the ML team. -/x-pack/test/accessibility/apps/group2/transform.ts @elastic/ml-ui -/x-pack/test/api_integration/apis/aiops/ @elastic/ml-ui -/x-pack/test/api_integration/apis/transform/ @elastic/ml-ui -/x-pack/test/api_integration_basic/apis/transform/ @elastic/ml-ui -/x-pack/test/functional/apps/transform/ @elastic/ml-ui -/x-pack/test/functional/services/transform/ @elastic/ml-ui -/x-pack/test/functional_basic/apps/transform/ @elastic/ml-ui - -# Maps -#CC# /x-pack/plugins/maps/ @elastic/kibana-gis -/x-pack/test/api_integration/apis/maps/ @elastic/kibana-gis -/x-pack/test/functional/apps/maps/ @elastic/kibana-gis -/x-pack/test/functional/es_archives/maps/ @elastic/kibana-gis -/x-pack/plugins/stack_alerts/server/rule_types/geo_containment @elastic/kibana-gis -/x-pack/plugins/stack_alerts/public/rule_types/geo_containment @elastic/kibana-gis -#CC# /x-pack/plugins/file_upload @elastic/kibana-gis - -# Operations -/src/dev/license_checker/config.ts @elastic/kibana-operations -/src/dev/ @elastic/kibana-operations -/src/setup_node_env/ @elastic/kibana-operations -/src/cli/keystore/ @elastic/kibana-operations -/src/cli/serve/ @elastic/kibana-operations -/src/cli_keystore/ @elastic/kibana-operations -/.github/workflows/ @elastic/kibana-operations -/vars/ @elastic/kibana-operations -/.bazelignore @elastic/kibana-operations -/.bazeliskversion @elastic/kibana-operations -/.bazelrc @elastic/kibana-operations -/.bazelrc.common @elastic/kibana-operations -/.bazelversion @elastic/kibana-operations -/WORKSPACE.bazel @elastic/kibana-operations -/.buildkite/ @elastic/kibana-operations -/.buildkite/scripts/steps/esql_grammar_sync.sh @elastic/kibana-esql -/.buildkite/scripts/steps/esql_generate_function_metadata.sh @elastic/kibana-esql -/.buildkite/pipelines/esql_grammar_sync.yml @elastic/kibana-esql -/.buildkite/scripts/steps/code_generation/security_solution_codegen.sh @elastic/security-detection-rule-management -/kbn_pm/ @elastic/kibana-operations -/x-pack/dev-tools @elastic/kibana-operations -/catalog-info.yaml @elastic/kibana-operations @elastic/kibana-tech-leads -/.devcontainer/ @elastic/kibana-operations -/.eslintrc.js @elastic/kibana-operations -/.eslintignore @elastic/kibana-operations - -# Appex QA -/x-pack/test_serverless/tsconfig.json @elastic/appex-qa -/x-pack/test_serverless/kibana.jsonc @elastic/appex-qa -/x-pack/test_serverless/functional/test_suites/common/README.md @elastic/appex-qa -/x-pack/test_serverless/functional/page_objects/index.ts @elastic/appex-qa -/x-pack/test_serverless/functional/ftr_provider_context.d.ts @elastic/appex-qa -/x-pack/test_serverless/functional/test_suites/common/management/index.ts @elastic/appex-qa -/x-pack/test_serverless/functional/test_suites/common/examples/index.ts @elastic/appex-qa -/x-pack/test_serverless/functional/page_objects/svl_common_page.ts @elastic/appex-qa -/x-pack/test_serverless/README.md @elastic/appex-qa -/x-pack/test_serverless/api_integration/ftr_provider_context.d.ts @elastic/appex-qa -/x-pack/test_serverless/api_integration/test_suites/common/README.md @elastic/appex-qa -/src/dev/code_coverage @elastic/appex-qa -/test/functional/services/common @elastic/appex-qa -/test/functional/services/lib @elastic/appex-qa -/test/functional/services/remote @elastic/appex-qa -/test/visual_regression @elastic/appex-qa -/x-pack/test/visual_regression @elastic/appex-qa -/packages/kbn-test/src/functional_test_runner @elastic/appex-qa -/packages/kbn-performance-testing-dataset-extractor @elastic/appex-qa -/x-pack/test_serverless/**/*config.base.ts @elastic/appex-qa -/x-pack/test_serverless/**/deployment_agnostic_services.ts @elastic/appex-qa -/x-pack/test_serverless/shared/ @elastic/appex-qa -/x-pack/test_serverless/**/test_suites/**/common_configs/ @elastic/appex-qa -/x-pack/test_serverless/api_integration/test_suites/common/elasticsearch_api @elastic/appex-qa -/x-pack/test_serverless/functional/test_suites/security/ftr/ @elastic/appex-qa -/x-pack/test_serverless/functional/test_suites/common/home_page/ @elastic/appex-qa -/x-pack/test_serverless/**/services/ @elastic/appex-qa -/packages/kbn-es/src/stateful_resources/roles.yml @elastic/appex-qa -x-pack/test/api_integration/deployment_agnostic/default_configs/ @elastic/appex-qa -x-pack/test/api_integration/deployment_agnostic/services/ @elastic/appex-qa -x-pack/test/**/deployment_agnostic/ @elastic/appex-qa #temporarily to monitor tests migration - -# Core -/x-pack/test_serverless/functional/test_suites/security/config.saved_objects_management.ts @elastic/kibana-core -/config/ @elastic/kibana-core -/config/serverless.yml @elastic/kibana-core @elastic/kibana-security -/config/serverless.es.yml @elastic/kibana-core @elastic/kibana-security -/config/serverless.oblt.yml @elastic/kibana-core @elastic/kibana-security -/config/serverless.security.yml @elastic/kibana-core @elastic/kibana-security -/typings/ @elastic/kibana-core -/test/analytics @elastic/kibana-core -/packages/kbn-test/src/jest/setup/mocks.kbn_i18n_react.js @elastic/kibana-core -/x-pack/test/saved_objects_field_count/ @elastic/kibana-core -/x-pack/test_serverless/**/test_suites/common/saved_objects_management/ @elastic/kibana-core -/x-pack/test_serverless/api_integration/test_suites/common/core/ @elastic/kibana-core -/x-pack/test_serverless/api_integration/test_suites/**/telemetry/ @elastic/kibana-core -/x-pack/test/functional/es_archives/cases/migrations/8.8.0 @elastic/response-ops - -#CC# /src/core/server/csp/ @elastic/kibana-core -#CC# /src/plugins/saved_objects/ @elastic/kibana-core -#CC# /x-pack/plugins/cloud/ @elastic/kibana-core -#CC# /x-pack/plugins/features/ @elastic/kibana-core -#CC# /x-pack/plugins/global_search/ @elastic/kibana-core -#CC# /src/plugins/newsfeed @elastic/kibana-core -#CC# /x-pack/plugins/global_search_providers/ @elastic/kibana-core - -# AppEx AI Infra -/x-pack/plugins/inference @elastic/appex-ai-infra @elastic/obs-ai-assistant @elastic/security-generative-ai - -# AppEx Platform Services Security -x-pack/test_serverless/api_integration/test_suites/common/security_response_headers.ts @elastic/kibana-security - -# Kibana Telemetry -/.telemetryrc.json @elastic/kibana-core -/x-pack/.telemetryrc.json @elastic/kibana-core -/src/plugins/telemetry/schema/ @elastic/kibana-core -/x-pack/plugins/telemetry_collection_xpack/schema/ @elastic/kibana-core -x-pack/plugins/cloud_integrations/cloud_full_story/server/config.ts @elastic/kibana-core @shahinakmal - -# Kibana Localization -/src/dev/i18n_tools/ @elastic/kibana-localization @elastic/kibana-core -/src/core/public/i18n/ @elastic/kibana-localization @elastic/kibana-core -#CC# /x-pack/plugins/translations/ @elastic/kibana-localization @elastic/kibana-core - -# Kibana Platform Security -/.github/codeql @elastic/kibana-security -/.github/workflows/codeql.yml @elastic/kibana-security -/.github/workflows/codeql-stats.yml @elastic/kibana-security -/src/dev/eslint/security_eslint_rule_tests.ts @elastic/kibana-security -/src/core/server/integration_tests/config/check_dynamic_config.test.ts @elastic/kibana-security -/src/plugins/telemetry/server/config/telemetry_labels.ts @elastic/kibana-security -/packages/kbn-std/src/is_internal_url.test.ts @elastic/kibana-core @elastic/kibana-security -/packages/kbn-std/src/is_internal_url.ts @elastic/kibana-core @elastic/kibana-security -/packages/kbn-std/src/parse_next_url.test.ts @elastic/kibana-core @elastic/kibana-security -/packages/kbn-std/src/parse_next_url.ts @elastic/kibana-core @elastic/kibana-security -/test/interactive_setup_api_integration/ @elastic/kibana-security -/test/interactive_setup_functional/ @elastic/kibana-security -/test/plugin_functional/plugins/hardening @elastic/kibana-security -/test/plugin_functional/test_suites/core_plugins/rendering.ts @elastic/kibana-security -/test/plugin_functional/test_suites/hardening @elastic/kibana-security -/x-pack/test/accessibility/apps/group1/login_page.ts @elastic/kibana-security -/x-pack/test/accessibility/apps/group1/roles.ts @elastic/kibana-security -/x-pack/test/accessibility/apps/group1/spaces.ts @elastic/kibana-security -/x-pack/test/accessibility/apps/group1/users.ts @elastic/kibana-security -/x-pack/test/api_integration/apis/security/ @elastic/kibana-security -/x-pack/test/api_integration/apis/spaces/ @elastic/kibana-security -/x-pack/test/ui_capabilities/ @elastic/kibana-security -/x-pack/test/encrypted_saved_objects_api_integration/ @elastic/kibana-security -/x-pack/test/functional/apps/security/ @elastic/kibana-security -/x-pack/test/functional/apps/spaces/ @elastic/kibana-security -/x-pack/test/security_api_integration/ @elastic/kibana-security -/x-pack/test/security_functional/ @elastic/kibana-security -/x-pack/test/spaces_api_integration/ @elastic/kibana-security -/x-pack/test/saved_object_api_integration/ @elastic/kibana-security -/x-pack/test_serverless/**/test_suites/common/platform_security/ @elastic/kibana-security -/x-pack/test_serverless/**/test_suites/search/platform_security/ @elastic/kibana-security -/x-pack/test_serverless/**/test_suites/security/platform_security/ @elastic/kibana-security -/x-pack/test_serverless/**/test_suites/observability/platform_security/ @elastic/kibana-security -/packages/core/http/core-http-server-internal/src/cdn_config/ @elastic/kibana-security @elastic/kibana-core -#CC# /x-pack/plugins/security/ @elastic/kibana-security - -# Response Ops team -/x-pack/test/functional/es_archives/cases/default @elastic/response-ops -/x-pack/test_serverless/api_integration/test_suites/observability/config.ts @elastic/response-ops -/x-pack/test_serverless/api_integration/test_suites/observability/index.ts @elastic/response-ops -/x-pack/test_serverless/functional/page_objects/svl_triggers_actions_ui_page.ts @elastic/response-ops -/x-pack/test_serverless/functional/page_objects/svl_rule_details_ui_page.ts @elastic/response-ops -/x-pack/test_serverless/functional/page_objects/svl_oblt_overview_page.ts @elastic/response-ops -/x-pack/test/alerting_api_integration/ @elastic/response-ops -/x-pack/test/alerting_api_integration/observability @elastic/obs-ux-management-team -/x-pack/test/plugin_api_integration/test_suites/task_manager/ @elastic/response-ops -/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/ @elastic/response-ops -/x-pack/test/task_manager_claimer_update_by_query/ @elastic/response-ops -/docs/user/alerting/ @elastic/response-ops -/docs/management/connectors/ @elastic/response-ops -/x-pack/test/cases_api_integration/ @elastic/response-ops -/x-pack/test/functional/services/cases/ @elastic/response-ops -/x-pack/test/functional_with_es_ssl/apps/cases/ @elastic/response-ops -/x-pack/test/api_integration/apis/cases/ @elastic/response-ops -/x-pack/test_serverless/functional/test_suites/observability/cases @elastic/response-ops -/x-pack/test_serverless/functional/test_suites/search/cases/ @elastic/response-ops -/x-pack/test_serverless/functional/test_suites/security/ftr/cases/ @elastic/response-ops -/x-pack/test_serverless/api_integration/test_suites/search/cases/ @elastic/response-ops -/x-pack/test_serverless/api_integration/test_suites/observability/cases/ @elastic/response-ops -/x-pack/test_serverless/api_integration/test_suites/security/cases/ @elastic/response-ops -/x-pack/test_serverless/functional/test_suites/search/screenshot_creation/response_ops_docs @elastic/response-ops -/x-pack/test_serverless/functional/test_suites/security/screenshot_creation/response_ops_docs @elastic/response-ops -/x-pack/test_serverless/functional/test_suites/observability/screenshot_creation/response_ops_docs @elastic/response-ops -/x-pack/test_serverless/api_integration/test_suites/common/alerting/ @elastic/response-ops -/x-pack/test/functional/es_archives/action_task_params @elastic/response-ops -/x-pack/test/functional/es_archives/actions @elastic/response-ops -/x-pack/test/functional/es_archives/alerting @elastic/response-ops -/x-pack/test/functional/es_archives/alerts @elastic/response-ops -/x-pack/test/functional/es_archives/alerts_legacy @elastic/response-ops -/x-pack/test/functional/es_archives/observability/alerts @elastic/response-ops -/x-pack/test/functional/es_archives/actions @elastic/response-ops -/x-pack/test/functional/es_archives/rules_scheduled_task_id @elastic/response-ops -/x-pack/test/functional/es_archives/alerting/8_2_0 @elastic/response-ops -/x-pack/test/functional/es_archives/cases/signals/default @elastic/response-ops -/x-pack/test/functional/es_archives/cases/signals/hosts_users @elastic/response-ops - -# Enterprise Search -/x-pack/test_serverless/functional/page_objects/svl_ingest_pipelines.ts @elastic/search-kibana -/x-pack/test/functional/apps/dev_tools/embedded_console.ts @elastic/search-kibana -/x-pack/test/functional/apps/ingest_pipelines/feature_controls/ingest_pipelines_security.ts @elastic/search-kibana -/x-pack/test/functional/page_objects/embedded_console.ts @elastic/search-kibana -/x-pack/test/functional_enterprise_search/ @elastic/search-kibana -/x-pack/plugins/enterprise_search/public/applications/shared/doc_links @elastic/platform-docs -/x-pack/test_serverless/api_integration/test_suites/search/serverless_search @elastic/search-kibana -/x-pack/test_serverless/functional/test_suites/search/ @elastic/search-kibana -/x-pack/test_serverless/functional/test_suites/search/config.ts @elastic/search-kibana @elastic/appex-qa -x-pack/test/api_integration/apis/management/index_management/inference_endpoints.ts @elastic/search-kibana -/x-pack/test_serverless/api_integration/test_suites/search @elastic/search-kibana -/x-pack/test_serverless/functional/page_objects/svl_api_keys.ts @elastic/search-kibana -/x-pack/test_serverless/functional/page_objects/svl_search_* @elastic/search-kibana -/x-pack/test/functional_search/ @elastic/search-kibana - -# Management Experience - Deployment Management -/x-pack/test_serverless/**/test_suites/common/index_management/ @elastic/kibana-management -/x-pack/test_serverless/**/test_suites/common/management/index_management/ @elastic/kibana-management -/x-pack/test_serverless/**/test_suites/common/painless_lab/ @elastic/kibana-management -/x-pack/test_serverless/**/test_suites/common/console/ @elastic/kibana-management -/x-pack/test_serverless/api_integration/test_suites/common/management/ @elastic/kibana-management -/x-pack/test_serverless/api_integration/test_suites/common/search_profiler/ @elastic/kibana-management -/x-pack/test_serverless/functional/test_suites/**/advanced_settings.ts @elastic/kibana-management -/x-pack/test_serverless/functional/test_suites/common/management/disabled_uis.ts @elastic/kibana-management -/x-pack/test_serverless/functional/test_suites/common/management/ingest_pipelines.ts @elastic/kibana-management -/x-pack/test_serverless/functional/test_suites/common/management/landing_page.ts @elastic/kibana-management -/x-pack/test_serverless/functional/test_suites/common/dev_tools/ @elastic/kibana-management -/x-pack/test_serverless/**/test_suites/common/grok_debugger/ @elastic/kibana-management -/x-pack/test/api_integration/apis/management/ @elastic/kibana-management -/x-pack/test/functional/apps/rollup_job/ @elastic/kibana-management - -#CC# /x-pack/plugins/cross_cluster_replication/ @elastic/kibana-management - -# Security Solution -/x-pack/test_serverless/functional/test_suites/security/config.ts @elastic/security-solution @elastic/appex-qa -/x-pack/test_serverless/functional/test_suites/security/config.feature_flags.ts @elastic/security-solution -/x-pack/test_serverless/api_integration/test_suites/observability/config.feature_flags.ts @elastic/security-solution -/x-pack/test_serverless/functional/test_suites/common/spaces/multiple_spaces_enabled.ts @elastic/security-solution -/x-pack/test/functional/es_archives/endpoint/ @elastic/security-solution -/x-pack/test/plugin_functional/test_suites/resolver/ @elastic/security-solution -/x-pack/test/security_solution_api_integration @elastic/security-solution -/x-pack/test/api_integration/apis/security_solution @elastic/security-solution -/x-pack/test/functional/es_archives/auditbeat/default @elastic/security-solution -/x-pack/test/functional/es_archives/auditbeat/hosts @elastic/security-solution -/x-pack/test_serverless/functional/page_objects/svl_management_page.ts @elastic/security-solution -/x-pack/test_serverless/api_integration/test_suites/security @elastic/security-solution - -/x-pack/test_serverless/functional/test_suites/security/cypress @elastic/security-solution -/x-pack/test_serverless/functional/test_suites/security/index.feature_flags.ts @elastic/security-solution -/x-pack/test_serverless/functional/test_suites/security/index.ts @elastic/security-solution -#CC# /x-pack/plugins/security_solution/ @elastic/security-solution -/x-pack/test/functional/es_archives/cases/signals/duplicate_ids @elastic/response-ops - -# Security Solution OpenAPI bundles -/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_detections_api_* @elastic/security-detection-rule-management -/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_endpoint_management_api_* @elastic/security-defend-workflows -/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_entity_analytics_api_* @elastic/security-entity-analytics -/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_timeline_api_* @elastic/security-threat-hunting-investigations -/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_detections_api_* @elastic/security-detection-rule-management -/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_endpoint_management_api_* @elastic/security-defend-workflows -/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_entity_analytics_api_* @elastic/security-entity-analytics -/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_timeline_api_* @elastic/security-threat-hunting-investigations - -# Security Solution Offering plugins -# TODO: assign sub directories to sub teams -/x-pack/plugins/security_solution_ess/ @elastic/security-solution -/x-pack/plugins/security_solution_serverless/ @elastic/security-solution - -# GenAI in Security Solution -/x-pack/plugins/security_solution/public/assistant @elastic/security-generative-ai -/x-pack/plugins/security_solution/public/attack_discovery @elastic/security-generative-ai -/x-pack/test/security_solution_cypress/cypress/e2e/ai_assistant @elastic/security-generative-ai - -# Security Solution cross teams ownership -/x-pack/test/security_solution_cypress/cypress/fixtures @elastic/security-detections-response @elastic/security-threat-hunting -/x-pack/test/security_solution_cypress/cypress/helpers @elastic/security-detections-response @elastic/security-threat-hunting -/x-pack/test/security_solution_cypress/cypress/objects @elastic/security-detections-response @elastic/security-threat-hunting -/x-pack/test/security_solution_cypress/cypress/plugins @elastic/security-detections-response @elastic/security-threat-hunting -/x-pack/test/security_solution_cypress/cypress/screens/common @elastic/security-detections-response @elastic/security-threat-hunting -/x-pack/test/security_solution_cypress/cypress/support @elastic/security-detections-response @elastic/security-threat-hunting -/x-pack/test/security_solution_cypress/cypress/urls @elastic/security-threat-hunting-investigations @elastic/security-detection-engine - -/x-pack/plugins/security_solution/common/ecs @elastic/security-threat-hunting-investigations -/x-pack/plugins/security_solution/common/test @elastic/security-detections-response @elastic/security-threat-hunting - -/x-pack/plugins/security_solution/public/common/components/callouts @elastic/security-detections-response -/x-pack/plugins/security_solution/public/common/components/hover_actions @elastic/security-threat-hunting-explore @elastic/security-threat-hunting-investigations - -/x-pack/plugins/security_solution/server/routes @elastic/security-detections-response @elastic/security-threat-hunting -/x-pack/plugins/security_solution/server/utils @elastic/security-detections-response @elastic/security-threat-hunting -x-pack/test/security_solution_api_integration/test_suites/detections_response/utils @elastic/security-detections-response -x-pack/test/security_solution_api_integration/test_suites/detections_response/telemetry @elastic/security-detections-response -x-pack/test/security_solution_api_integration/test_suites/detections_response/user_roles @elastic/security-detections-response -x-pack/test/security_solution_api_integration/test_suites/explore @elastic/security-threat-hunting-explore -x-pack/test/security_solution_api_integration/test_suites/investigations @elastic/security-threat-hunting-investigations -x-pack/test/security_solution_api_integration/test_suites/sources @elastic/security-detections-response - -# Security Solution sub teams - -## Security Solution sub teams - security-engineering-productivity -## NOTE: It's important to keep this above other teams' sections because test automation doesn't process -## the CODEOWNERS file correctly. See https://github.com/elastic/kibana/issues/173307#issuecomment-1855858929 -/x-pack/test/security_solution_cypress/* @elastic/security-engineering-productivity -/x-pack/test/security_solution_cypress/cypress/* @elastic/security-engineering-productivity -/x-pack/test/security_solution_cypress/cypress/tasks/login.ts @elastic/security-engineering-productivity -/x-pack/test/security_solution_cypress/es_archives @elastic/security-engineering-productivity -/x-pack/test/security_solution_playwright @elastic/security-engineering-productivity -/x-pack/plugins/security_solution/scripts/run_cypress @MadameSheema @patrykkopycinski @maximpn @banderror - -## Security Solution sub teams - Threat Hunting - -/x-pack/plugins/security_solution/server/lib/siem_migrations @elastic/security-threat-hunting -/x-pack/plugins/security_solution/common/siem_migrations @elastic/security-threat-hunting - -## Security Solution Threat Hunting areas - Threat Hunting Investigations - -/x-pack/plugins/security_solution/common/api/timeline @elastic/security-threat-hunting-investigations -/x-pack/plugins/security_solution/common/search_strategy/timeline @elastic/security-threat-hunting-investigations -/x-pack/plugins/security_solution/common/types/timeline @elastic/security-threat-hunting-investigations - -/x-pack/test/security_solution_cypress/cypress/e2e/investigations @elastic/security-threat-hunting-investigations -/x-pack/test/security_solution_cypress/cypress/e2e/sourcerer/sourcerer_timeline.cy.ts @elastic/security-threat-hunting-investigations - -x-pack/test/security_solution_cypress/cypress/screens/expandable_flyout @elastic/security-threat-hunting-investigations -x-pack/test/security_solution_cypress/cypress/tasks/expandable_flyout @elastic/security-threat-hunting-investigations - -/x-pack/plugins/security_solution/common/timelines @elastic/security-threat-hunting-investigations -/x-pack/plugins/security_solution/public/common/components/alerts_viewer @elastic/security-threat-hunting-investigations -/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_action @elastic/security-threat-hunting-investigations -/x-pack/plugins/security_solution/public/common/components/event_details @elastic/security-threat-hunting-investigations -/x-pack/plugins/security_solution/public/common/components/events_viewer @elastic/security-threat-hunting-investigations -/x-pack/plugins/security_solution/public/common/components/markdown_editor @elastic/security-threat-hunting-investigations -/x-pack/plugins/security_solution/public/detections/components/alerts_kpis @elastic/security-threat-hunting-investigations -/x-pack/plugins/security_solution/public/detections/components/alerts_table @elastic/security-threat-hunting-investigations -/x-pack/plugins/security_solution/public/detections/components/alerts_info @elastic/security-threat-hunting-investigations -/x-pack/plugins/security_solution/public/flyout/document_details @elastic/security-threat-hunting-investigations -/x-pack/plugins/security_solution/public/flyout/shared @elastic/security-threat-hunting-investigations -/x-pack/plugins/security_solution/public/notes @elastic/security-threat-hunting-investigations -/x-pack/plugins/security_solution/public/resolver @elastic/security-threat-hunting-investigations -/x-pack/plugins/security_solution/public/threat_intelligence @elastic/security-threat-hunting-investigations -/x-pack/plugins/security_solution/public/timelines @elastic/security-threat-hunting-investigations - -/x-pack/plugins/security_solution/server/lib/timeline @elastic/security-threat-hunting-investigations - -## Security Solution Threat Hunting areas - Threat Hunting Explore -/x-pack/plugins/security_solution/common/api/tags @elastic/security-threat-hunting-explore -/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts @elastic/security-threat-hunting-explore -/x-pack/plugins/security_solution/common/search_strategy/security_solution/matrix_histogram @elastic/security-threat-hunting-explore -/x-pack/plugins/security_solution/common/search_strategy/security_solution/network @elastic/security-threat-hunting-explore -/x-pack/plugins/security_solution/common/search_strategy/security_solution/user @elastic/security-threat-hunting-explore - -/x-pack/test/security_solution_cypress/cypress/e2e/explore @elastic/security-threat-hunting-explore -/x-pack/test/security_solution_cypress/cypress/screens/hosts @elastic/security-threat-hunting-explore -/x-pack/test/security_solution_cypress/cypress/screens/network @elastic/security-threat-hunting-explore -/x-pack/test/security_solution_cypress/cypress/tasks/hosts @elastic/security-threat-hunting-explore -/x-pack/test/security_solution_cypress/cypress/tasks/network @elastic/security-threat-hunting-explore - -/x-pack/plugins/security_solution/public/app/actions @elastic/security-threat-hunting-explore -/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour @elastic/security-threat-hunting-explore -/x-pack/plugins/security_solution/public/common/components/charts @elastic/security-threat-hunting-explore -/x-pack/plugins/security_solution/public/detections/components/alerts_table/grouping_settings @elastic/security-threat-hunting-explore -/x-pack/plugins/security_solution/public/common/components/header_page @elastic/security-threat-hunting-explore -/x-pack/plugins/security_solution/public/common/components/header_section @elastic/security-threat-hunting-explore -/x-pack/plugins/security_solution/public/common/components/inspect @elastic/security-threat-hunting-explore -/x-pack/plugins/security_solution/public/common/components/last_event_time @elastic/security-threat-hunting-explore -/x-pack/plugins/security_solution/public/common/components/links @elastic/security-threat-hunting-explore -/x-pack/plugins/security_solution/public/common/components/matrix_histogram @elastic/security-threat-hunting-explore -/x-pack/plugins/security_solution/public/common/components/navigation @elastic/security-threat-hunting-explore -/x-pack/plugins/security_solution/public/common/components/news_feed @elastic/security-threat-hunting-explore -/x-pack/plugins/security_solution/public/common/components/overview_description_list @elastic/security-threat-hunting-explore -/x-pack/plugins/security_solution/public/common/components/page @elastic/security-threat-hunting-explore -/x-pack/plugins/security_solution/public/common/components/sidebar_header @elastic/security-threat-hunting-explore -/x-pack/plugins/security_solution/public/common/components/tables @elastic/security-threat-hunting-explore -/x-pack/plugins/security_solution/public/common/components/top_n @elastic/security-threat-hunting-explore -/x-pack/plugins/security_solution/public/common/components/with_hover_actions @elastic/security-threat-hunting-explore -/x-pack/plugins/security_solution/public/common/containers/matrix_histogram @elastic/security-threat-hunting-explore -/x-pack/plugins/security_solution/public/common/lib/cell_actions @elastic/security-threat-hunting-explore -/x-pack/plugins/security_solution/public/cases @elastic/security-threat-hunting-explore -/x-pack/plugins/security_solution/public/explore @elastic/security-threat-hunting-explore -/x-pack/plugins/security_solution/public/overview @elastic/security-threat-hunting-explore -/x-pack/plugins/security_solution/public/dashboards @elastic/security-threat-hunting-explore - -/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts @elastic/security-threat-hunting-explore -/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram @elastic/security-threat-hunting-explore -/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network @elastic/security-threat-hunting-explore -/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users @elastic/security-threat-hunting-explore - -/x-pack/test/functional/es_archives/auditbeat/overview @elastic/security-threat-hunting-explore -/x-pack/test/functional/es_archives/auditbeat/users @elastic/security-threat-hunting-explore - -/x-pack/test/functional/es_archives/auditbeat/uncommon_processes @elastic/security-threat-hunting-explore - -## Generative AI owner connectors -# OpenAI -/x-pack/plugins/stack_connectors/public/connector_types/openai @elastic/security-generative-ai @elastic/obs-ai-assistant @elastic/appex-ai-infra -/x-pack/plugins/stack_connectors/server/connector_types/openai @elastic/security-generative-ai @elastic/obs-ai-assistant @elastic/appex-ai-infra -/x-pack/plugins/stack_connectors/common/openai @elastic/security-generative-ai @elastic/obs-ai-assistant @elastic/appex-ai-infra -# Bedrock -/x-pack/plugins/stack_connectors/public/connector_types/bedrock @elastic/security-generative-ai @elastic/obs-ai-assistant @elastic/appex-ai-infra -/x-pack/plugins/stack_connectors/server/connector_types/bedrock @elastic/security-generative-ai @elastic/obs-ai-assistant @elastic/appex-ai-infra -/x-pack/plugins/stack_connectors/common/bedrock @elastic/security-generative-ai @elastic/obs-ai-assistant @elastic/appex-ai-infra - -# Gemini -/x-pack/plugins/stack_connectors/public/connector_types/gemini @elastic/security-generative-ai @elastic/obs-ai-assistant @elastic/appex-ai-infra -/x-pack/plugins/stack_connectors/server/connector_types/gemini @elastic/security-generative-ai @elastic/obs-ai-assistant @elastic/appex-ai-infra -/x-pack/plugins/stack_connectors/common/gemini @elastic/security-generative-ai @elastic/obs-ai-assistant @elastic/appex-ai-infra - -# Inference API -/x-pack/plugins/stack_connectors/public/connector_types/inference @elastic/appex-ai-infra @elastic/security-generative-ai @elastic/obs-ai-assistant -/x-pack/plugins/stack_connectors/server/connector_types/inference @elastic/appex-ai-infra @elastic/security-generative-ai @elastic/obs-ai-assistant -/x-pack/plugins/stack_connectors/common/inference @elastic/appex-ai-infra @elastic/security-generative-ai @elastic/obs-ai-assistant - -## Defend Workflows owner connectors -/x-pack/plugins/stack_connectors/public/connector_types/sentinelone @elastic/security-defend-workflows -/x-pack/plugins/stack_connectors/server/connector_types/sentinelone @elastic/security-defend-workflows -/x-pack/plugins/stack_connectors/common/sentinelone @elastic/security-defend-workflows -/x-pack/plugins/stack_connectors/server/connector_types/crowdstrike @elastic/security-defend-workflows -/x-pack/plugins/stack_connectors/common/crowdstrike @elastic/security-defend-workflows - -## Security Solution shared OAS schemas -/x-pack/plugins/security_solution/common/api/model @elastic/security-detection-rule-management @elastic/security-detection-engine - -## Security Solution sub teams - Detection Rule Management -/x-pack/plugins/security_solution/common/api/detection_engine/fleet_integrations @elastic/security-detection-rule-management -/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema @elastic/security-detection-rule-management @elastic/security-detection-engine -/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules @elastic/security-detection-rule-management -/x-pack/plugins/security_solution/common/api/detection_engine/rule_management @elastic/security-detection-rule-management -/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring @elastic/security-detection-rule-management -/x-pack/plugins/security_solution/common/detection_engine/rule_management @elastic/security-detection-rule-management - -/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management @elastic/security-detection-rule-management -/x-pack/plugins/security_solution/docs/rfcs/detection_response @elastic/security-detection-rule-management @elastic/security-detection-engine -/x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/prebuilt_rules @elastic/security-detection-rule-management -/x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/rule_management @elastic/security-detection-rule-management -/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management @elastic/security-detection-rule-management - -/x-pack/plugins/security_solution/public/common/components/health_truncate_text @elastic/security-detection-rule-management -/x-pack/plugins/security_solution/public/common/components/links_to_docs @elastic/security-detection-rule-management -/x-pack/plugins/security_solution/public/common/components/ml_popover @elastic/security-detection-rule-management -/x-pack/plugins/security_solution/public/common/components/popover_items @elastic/security-detection-rule-management -/x-pack/plugins/security_solution/public/detection_engine/fleet_integrations @elastic/security-detection-rule-management -/x-pack/plugins/security_solution/public/detection_engine/endpoint_exceptions @elastic/security-defend-workflows -/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui @elastic/security-detection-rule-management -/x-pack/plugins/security_solution/public/detection_engine/rule_management @elastic/security-detection-rule-management -/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui @elastic/security-detection-rule-management -/x-pack/plugins/security_solution/public/detection_engine/rule_monitoring @elastic/security-detection-rule-management -/x-pack/plugins/security_solution/public/detections/components/callouts @elastic/security-detection-rule-management -/x-pack/plugins/security_solution/public/detections/components/modals/ml_job_upgrade_modal @elastic/security-detection-rule-management -/x-pack/plugins/security_solution/public/detections/components/rules @elastic/security-detection-rule-management -/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview @elastic/security-detection-engine -/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules @elastic/security-detection-rule-management -/x-pack/plugins/security_solution/public/detections/mitre @elastic/security-detection-rule-management -/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules @elastic/security-detection-rule-management -/x-pack/plugins/security_solution/public/rules @elastic/security-detection-rule-management - -/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations @elastic/security-detection-rule-management -/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules @elastic/security-detection-rule-management -/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management @elastic/security-detection-rule-management -/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring @elastic/security-detection-rule-management -/x-pack/plugins/security_solution/server/lib/detection_engine/rule_schema @elastic/security-detection-rule-management @elastic/security-detection-engine - -/x-pack/plugins/security_solution/scripts/openapi @elastic/security-detection-rule-management - -## Security Solution sub teams - Detection Engine -/x-pack/plugins/security_solution/common/api/detection_engine/alert_tags @elastic/security-detection-engine -/x-pack/plugins/security_solution/common/api/detection_engine/index_management @elastic/security-detection-engine -/x-pack/plugins/security_solution/common/api/detection_engine/model/alerts @elastic/security-detection-engine -/x-pack/plugins/security_solution/common/api/detection_engine/rule_exceptions @elastic/security-detection-engine -/x-pack/plugins/security_solution/common/api/detection_engine/rule_preview @elastic/security-detection-engine -/x-pack/plugins/security_solution/common/api/detection_engine/signals @elastic/security-detection-engine -/x-pack/plugins/security_solution/common/api/detection_engine/signals_migration @elastic/security-detection-engine -/x-pack/plugins/security_solution/common/cti @elastic/security-detection-engine -/x-pack/plugins/security_solution/common/field_maps @elastic/security-detection-engine -/x-pack/test/functional/es_archives/entity/risks @elastic/security-detection-engine -/x-pack/test/functional/es_archives/entity/host_risk @elastic/security-detection-engine - -/x-pack/plugins/security_solution/public/sourcerer @elastic/security-threat-hunting-investigations -/x-pack/plugins/security_solution/public/detection_engine/rule_creation @elastic/security-detection-engine -/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui @elastic/security-detection-engine -/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions @elastic/security-detection-engine -/x-pack/plugins/security_solution/public/detection_engine/rule_gaps @elastic/security-detection-engine -/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists @elastic/security-detection-engine -/x-pack/plugins/security_solution/public/detections/pages/alerts @elastic/security-detection-engine -/x-pack/plugins/security_solution/public/exceptions @elastic/security-detection-engine - -/x-pack/plugins/security_solution/server/lib/detection_engine/migrations @elastic/security-detection-engine -/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy @elastic/security-detection-engine -/x-pack/plugins/security_solution/server/lib/detection_engine/rule_exceptions @elastic/security-detection-engine -/x-pack/plugins/security_solution/server/lib/detection_engine/rule_preview @elastic/security-detection-engine -/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types @elastic/security-detection-engine -/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index @elastic/security-detection-engine -/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals @elastic/security-detection-engine - -/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine @elastic/security-detection-engine - -/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine @elastic/security-detection-engine -/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/rule_gaps.ts @elastic/security-detection-engine -/x-pack/test/security_solution_api_integration/test_suites/lists_and_exception_lists @elastic/security-detection-engine -/x-pack/test/functional/es_archives/asset_criticality @elastic/security-detection-engine - -## Security Threat Intelligence - Under Security Platform -/x-pack/plugins/security_solution/public/common/components/threat_match @elastic/security-detection-engine - -## Security Solution sub teams - security-defend-workflows -/x-pack/plugins/security_solution/public/management/ @elastic/security-defend-workflows -/x-pack/plugins/security_solution/public/common/lib/endpoint/ @elastic/security-defend-workflows -/x-pack/plugins/security_solution/public/common/components/endpoint/ @elastic/security-defend-workflows -/x-pack/plugins/security_solution/public/common/hooks/endpoint/ @elastic/security-defend-workflows -/x-pack/plugins/security_solution/public/common/mock/endpoint @elastic/security-defend-workflows -/x-pack/plugins/security_solution/public/flyout/document_details/isolate_host/ @elastic/security-defend-workflows -/x-pack/plugins/security_solution/common/endpoint/ @elastic/security-defend-workflows -/x-pack/plugins/security_solution/common/api/endpoint/ @elastic/security-defend-workflows -/x-pack/plugins/security_solution/server/endpoint/ @elastic/security-defend-workflows -/x-pack/plugins/security_solution/server/lists_integration/endpoint/ @elastic/security-defend-workflows -/x-pack/plugins/security_solution/server/lib/license/ @elastic/security-defend-workflows -/x-pack/plugins/security_solution/server/fleet_integration/ @elastic/security-defend-workflows -/x-pack/plugins/security_solution/scripts/endpoint/ @elastic/security-defend-workflows -/x-pack/test/security_solution_endpoint/ @elastic/security-defend-workflows -/x-pack/test/security_solution_api_integration/test_suites/edr_workflows/ @elastic/security-defend-workflows -/x-pack/test_serverless/shared/lib/security/kibana_roles/ @elastic/security-defend-workflows -/x-pack/plugins/security_solution_serverless/public/upselling/sections/endpoint_management @elastic/security-defend-workflows -/x-pack/plugins/security_solution_serverless/public/upselling/pages/endpoint_management @elastic/security-defend-workflows -/x-pack/plugins/security_solution_serverless/server/endpoint @elastic/security-defend-workflows - -## Security Solution sub teams - security-telemetry (Data Engineering) -x-pack/plugins/security_solution/server/usage/ @elastic/security-data-analytics -x-pack/plugins/security_solution/server/lib/telemetry/ @elastic/security-data-analytics - -## Security Solution sub teams - adaptive-workload-protection -x-pack/plugins/security_solution/public/common/components/sessions_viewer @elastic/kibana-cloud-security-posture -x-pack/plugins/security_solution/public/kubernetes @elastic/kibana-cloud-security-posture - -## Security Solution sub teams - Entity Analytics -x-pack/plugins/security_solution/common/entity_analytics @elastic/security-entity-analytics -x-pack/plugins/security_solution/common/search_strategy/security_solution/risk_score @elastic/security-entity-analytics -x-pack/plugins/security_solution/public/entity_analytics @elastic/security-entity-analytics -x-pack/plugins/security_solution/server/lib/entity_analytics @elastic/security-entity-analytics -x-pack/plugins/security_solution/server/lib/risk_score @elastic/security-entity-analytics -x-pack/test/security_solution_api_integration/test_suites/entity_analytics @elastic/security-entity-analytics -x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics @elastic/security-entity-analytics -x-pack/plugins/security_solution/public/flyout/entity_details @elastic/security-entity-analytics -x-pack/plugins/security_solution/common/api/entity_analytics @elastic/security-entity-analytics - -## Security Solution sub teams - GenAI -x-pack/test/security_solution_api_integration/test_suites/genai @elastic/security-generative-ai - -# Security Defend Workflows - OSQuery Ownership -x-pack/plugins/osquery @elastic/security-defend-workflows -/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_response_actions @elastic/security-defend-workflows -/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions @elastic/security-defend-workflows -/x-pack/plugins/security_solution/server/lib/detection_engine/rule_response_actions @elastic/security-defend-workflows -/x-pack/plugins/security_solution/public/detections/components/osquery @elastic/security-defend-workflows - -# Cloud Defend -/x-pack/plugins/security_solution/public/cloud_defend @elastic/kibana-cloud-security-posture - -# Cloud Security Posture -x-pack/packages/kbn-cloud-security-posture @elastic/kibana-cloud-security-posture -/x-pack/test_serverless/functional/test_suites/security/config.cloud_security_posture.* @elastic/kibana-cloud-security-posture -/x-pack/plugins/security_solution/public/cloud_security_posture @elastic/kibana-cloud-security-posture -/x-pack/test/api_integration/apis/cloud_security_posture/ @elastic/kibana-cloud-security-posture -/x-pack/test/cloud_security_posture_functional/ @elastic/kibana-cloud-security-posture -/x-pack/test/cloud_security_posture_api/ @elastic/kibana-cloud-security-posture -/x-pack/test_serverless/functional/test_suites/security/ftr/cloud_security_posture/ @elastic/kibana-cloud-security-posture -/x-pack/test_serverless/functional/test_suites/security/config.cloud_security_posture.basic.ts @elastic/kibana-cloud-security-posture -/x-pack/test_serverless/functional/test_suites/security/config.cloud_security_posture.essentials.ts @elastic/kibana-cloud-security-posture -/x-pack/test_serverless/api_integration/test_suites/security/cloud_security_posture/ @elastic/kibana-cloud-security-posture -/x-pack/plugins/fleet/public/components/cloud_security_posture @elastic/fleet @elastic/kibana-cloud-security-posture -/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/components/cloud_security_posture @elastic/fleet @elastic/kibana-cloud-security-posture -/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.* @elastic/fleet @elastic/kibana-cloud-security-posture -/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/cloud_posture_third_party_support_callout.* @elastic/fleet @elastic/kibana-cloud-security-posture -/x-pack/plugins/security_solution/public/cloud_security_posture @elastic/kibana-cloud-security-posture -/x-pack/test/security_solution_cypress/cypress/e2e/cloud_security_posture/misconfiguration_contextual_flyout.cy.ts @elastic/kibana-cloud-security-posture -/x-pack/test/security_solution_cypress/cypress/e2e/cloud_security_posture/vulnerabilities_contextual_flyout.cy.ts @elastic/kibana-cloud-security-posture - -# Security Solution onboarding tour -/x-pack/plugins/security_solution/public/common/components/guided_onboarding @elastic/security-threat-hunting-explore - -# Security Service Integrations -x-pack/plugins/security_solution/common/security_integrations @elastic/security-service-integrations -x-pack/plugins/security_solution/public/security_integrations @elastic/security-service-integrations -x-pack/plugins/security_solution/server/security_integrations @elastic/security-service-integrations -x-pack/plugins/security_solution/server/lib/security_integrations @elastic/security-service-integrations - -# Kibana design -# scss overrides should be below this line for specificity -**/*.scss @elastic/kibana-design - -# Observability design -/x-pack/plugins/fleet/**/*.scss @elastic/observability-design -/x-pack/plugins/monitoring/**/*.scss @elastic/observability-design - -# Ent. Search design -/x-pack/plugins/enterprise_search/**/*.scss @elastic/search-design - -# Security design -/x-pack/plugins/endpoint/**/*.scss @elastic/security-design -/x-pack/plugins/security_solution/**/*.scss @elastic/security-design -/x-pack/plugins/security_solution_ess/**/*.scss @elastic/security-design -/x-pack/plugins/security_solution_serverless/**/*.scss @elastic/security-design - -# Logstash -#CC# /x-pack/plugins/logstash/ @elastic/logstash - -# EUI team -/src/plugins/kibana_react/public/page_template/ @elastic/eui-team @elastic/appex-sharedux - -# Landing page for guided onboarding in Home plugin -/src/plugins/home/public/application/components/guided_onboarding @elastic/appex-sharedux - -# Changes to translation files should not ping code reviewers -x-pack/plugins/translations/translations - -# Profiling api integration testing -x-pack/test/profiling_api_integration @elastic/obs-ux-infra_services-team - -# Observability shared profiling -x-pack/plugins/observability_solution/observability_shared/public/components/profiling @elastic/obs-ux-infra_services-team - -# Shared UX -/x-pack/test_serverless/functional/test_suites/common/spaces/spaces_selection.ts @elastic/appex-sharedux -/x-pack/test_serverless/functional/test_suites/common/spaces/index.ts @elastic/appex-sharedux -packages/react @elastic/appex-sharedux -test/functional/page_objects/solution_navigation.ts @elastic/appex-sharedux -/x-pack/test_serverless/functional/page_objects/svl_common_navigation.ts @elastic/appex-sharedux -/x-pack/test_serverless/functional/fixtures/kbn_archiver/reporting @elastic/appex-sharedux -/x-pack/test_serverless/functional/page_objects/svl_sec_landing_page.ts @elastic/appex-sharedux -/x-pack/test_serverless/functional/test_suites/security/ftr/navigation.ts @elastic/appex-sharedux - -# OpenAPI spec files -oas_docs/.spectral.yaml @elastic/platform-docs -oas_docs/kibana.info.serverless.yaml @elastic/platform-docs -oas_docs/kibana.info.yaml @elastic/platform-docs - -# Plugin manifests -/src/plugins/**/kibana.jsonc @elastic/kibana-core -/x-pack/plugins/**/kibana.jsonc @elastic/kibana-core - -# Temporary Encrypted Saved Objects (ESO) guarding -# This additional code-ownership is meant to be a temporary precaution to notify the Kibana platform security team -# when an encrypted saved object is changed. Very careful review is necessary to ensure any changes are compatible -# with serverless zero downtime upgrades (ZDT). This section should be removed only when proper guidance for -# maintaining ESOs has been documented and consuming teams have acclimated to ZDT changes. -x-pack/plugins/actions/server/saved_objects/index.ts @elastic/response-ops @elastic/kibana-security -x-pack/plugins/alerting/server/saved_objects/index.ts @elastic/response-ops @elastic/kibana-security -x-pack/plugins/fleet/server/saved_objects/index.ts @elastic/fleet @elastic/kibana-security -x-pack/plugins/observability_solution/synthetics/server/saved_objects/saved_objects.ts @elastic/obs-ux-management-team @elastic/kibana-security -x-pack/plugins/observability_solution/synthetics/server/saved_objects/synthetics_monitor.ts @elastic/obs-ux-management-team @elastic/kibana-security -x-pack/plugins/observability_solution/synthetics/server/saved_objects/synthetics_param.ts @elastic/obs-ux-management-team @elastic/kibana-security - -# Specialised GitHub workflows for the Observability robots -/.github/workflows/deploy-my-kibana.yml @elastic/observablt-robots @elastic/kibana-operations -/.github/workflows/oblt-github-commands @elastic/observablt-robots @elastic/kibana-operations -/.github/workflows/undeploy-my-kibana.yml @elastic/observablt-robots @elastic/kibana-operations - -#### -## These rules are always last so they take ultimate priority over everything else -#### diff --git a/config/serverless.es.yml b/config/serverless.es.yml index 326b5f2d403bd..481b46155dc64 100644 --- a/config/serverless.es.yml +++ b/config/serverless.es.yml @@ -10,7 +10,6 @@ xpack.observability.enabled: false xpack.securitySolution.enabled: false xpack.serverless.observability.enabled: false enterpriseSearch.enabled: false -xpack.fleet.enabled: false xpack.observabilityAIAssistant.enabled: false xpack.osquery.enabled: false @@ -88,4 +87,15 @@ xpack.searchInferenceEndpoints.ui.enabled: false xpack.search.notebooks.catalog.url: https://elastic-enterprise-search.s3.us-east-2.amazonaws.com/serverless/catalog.json # Semantic text UI -xpack.index_management.dev.enableSemanticText: false +xpack.index_management.dev.enableSemanticText: true + +# AI Assistant config +xpack.observabilityAIAssistant.enabled: true +xpack.searchAssistant.enabled: true +xpack.searchAssistant.ui.enabled: true +xpack.observabilityAIAssistant.scope: "search" +xpack.observabilityAIAssistant.enableKnowledgeBase: false +aiAssistantManagementSelection.preferredAIAssistantType: "observability" +xpack.observabilityAiAssistantManagement.logSourcesEnabled: false +xpack.observabilityAiAssistantManagement.spacesEnabled: false +xpack.observabilityAiAssistantManagement.visibilityEnabled: false diff --git a/config/serverless.oblt.yml b/config/serverless.oblt.yml index 1146a9280ac4e..91f1227ce0d9f 100644 --- a/config/serverless.oblt.yml +++ b/config/serverless.oblt.yml @@ -183,6 +183,7 @@ xpack.apm.featureFlags.storageExplorerAvailable: false ## Set the AI Assistant type aiAssistantManagementSelection.preferredAIAssistantType: "observability" +xpack.observabilityAIAssistant.scope: "observability" # Specify in telemetry the project type telemetry.labels.serverless: observability diff --git a/dev_docs/tutorials/saved_objects.mdx b/dev_docs/tutorials/saved_objects.mdx index f93d774e663f3..233795374f2e9 100644 --- a/dev_docs/tutorials/saved_objects.mdx +++ b/dev_docs/tutorials/saved_objects.mdx @@ -18,7 +18,7 @@ import { SavedObjectsType } from 'src/core/server'; export const dashboardVisualization: SavedObjectsType = { name: 'dashboard_visualization', [1] - hidden: true, + hidden: true, [3] switchToModelVersionAt: '8.10.0', // this is the default, feel free to omit it unless you intend to switch to using model versions before 8.10.0 namespaceType: 'multiple-isolated', [2] mappings: { @@ -46,6 +46,9 @@ these should follow our API URL path convention and always be written in snake c that objects of this type can only exist in a single space. See for more information. +[3] This field determines whether repositories have access to the type by default. Hidden types will not be automatically exposed via the Saved Objects Client APIs. +Hidden types must be listed in `SavedObjectsClientProviderOptions[includedHiddenTypes]` to be accessible by the client. + **src/plugins/my_plugin/server/saved_objects/index.ts** ```ts @@ -301,4 +304,4 @@ export const foo: SavedObjectsType = { [1] Needs to be `false` to use the `hiddenFromHttpApis` option -[2] Set this to `true` to build your own HTTP API and have complete control over the route handler. \ No newline at end of file +[2] Set this to `true` to build your own HTTP API and have complete control over the route handler. diff --git a/docs/CHANGELOG.asciidoc b/docs/CHANGELOG.asciidoc index 978461e197ef1..981834b68eda6 100644 --- a/docs/CHANGELOG.asciidoc +++ b/docs/CHANGELOG.asciidoc @@ -10,6 +10,8 @@ Review important information about the {kib} 8.x releases. +* <> +* <> * <> * <> * <> @@ -77,6 +79,386 @@ Review important information about the {kib} 8.x releases. include::upgrade-notes.asciidoc[] + +[[release-notes-8.16.0]] +== {kib} 8.16.0 + +For information about the {kib} 8.16.0 release, review the following information. + +[float] +[[deprecations-8.16.0]] +=== Deprecations + +The following functionality is deprecated in 8.16.0, and will be removed in 9.0.0. +Deprecated functionality does not have an immediate impact on your application, but we strongly recommend +you make the necessary updates after you upgrade to 8.16.0. + +[discrete] +.The Logs Stream is now hidden by default in favor of the Logs Explorer app. +[%collapsible] +==== +*Details* + +You can find the Logs Explorer app in the navigation menu under Logs > Explorer, or as a separate tab in Discover. For more information, refer to ({kibana-pull}194519[#194519]). + +*Impact* + +You can still show the Logs Stream app again by navigating to Stack Management > Advanced Settings and by enabling the `observability:enableLogsStream` setting. +==== + +[discrete] +.Deprecates the Observability AI Assistant specific advanced setting `observability:aiAssistantLogsIndexPattern`. +[%collapsible] +==== +*Details* + +The Observability AI Assistant specific advanced setting for Logs index patterns `observability:aiAssistantLogsIndexPattern` is deprecated and no longer used. The AI Assistant will now use the existing **Log sources** setting `observability:logSources` instead. For more information, refer to ({kibana-pull}192003[#192003]). + +//*Impact* + +//!!TODO!! +==== + + + +[float] +[[features-8.16.0]] +=== Features +{kib} 8.16.0 adds the following new and notable features. + +AGPL license:: +* Adds AGPL 3.0 license ({kibana-pull}192025[#192025]). +Alerting:: +* Adds TheHive connector ({kibana-pull}180138[#180138]). +* Adds flapping settings per rule ({kibana-pull}189341[#189341]). +* Efficiency improvements in the Kibana task manager and alerting frameworks ({kibana-issue}188194[#188194]) +Cases:: +* Support TheHive connector in cases ({kibana-pull}180931[#180931]). +Dashboards and visualizations:: +* Adds the ability to star your favorite dashboards and quickly find them ({kibana-pull}189285[#189285]). +* Adds a chart showing usage statistics to the dashboard details ({kibana-pull}187993[#187993]). +* Adds metric styling options in *Lens* ({kibana-pull}186929[#186929]). +* Adds support for coloring table cells by terms with color mappings assignments. This is supported for both Rows and Metric dimensions ({kibana-pull}189895[#189895]). +Data ingestion and Fleet:: +* Support content packages in UI ({kibana-pull}195831[#195831]). +* Advanced agent monitoring options UI for HTTP endpoint and diagnostics ({kibana-pull}193361[#193361]). +* Adds option to have Kafka dynamic topics in outputs ({kibana-pull}192720[#192720]). +* Adds support for GeoIP processor databases in Ingest Pipelines ({kibana-pull}190830[#190830]). +// !!TODO!! The above PR had a lengthy release note description: +// The Ingest Pipelines app now supports adding and managing databases for the GeoIP processor. Additionally, the pipeline creation flow now includes support for the IP Location processor. +* Adds agentless ux creation flow ({kibana-pull}189932[#189932]). +* Enable feature flag for reusable integration policies ({kibana-pull}187153[#187153]). +Discover:: +* When writing ES|QL queries, you now get recommendations to help you get started ({kibana-pull}194418[#194418]). +* Enhances the inline documentation experience in ES|QL mode ({kibana-pull}192156[#192156]). +* Adds the ability to break down the histogram by field for ES|QL queries in Discover ({kibana-pull}193820[#193820]). +* Adds a summary column to the Documents table when exploring log data in Discover ({kibana-pull}192567[#192567]). +* Adds row indicators to the Documents table when exploring log data in Discover ({kibana-pull}190676[#190676]). +* Moves the button to switch between ES|QL and classic modes to the toolbar ({kibana-pull}188898[#188898]). +* Adds density settings to allow further customization of the Documents table layout ({kibana-pull}188495[#188495]). +* Enables the time picker for indices without the @timestamp field when editing ES|QL queries ({kibana-pull}184361[#184361]). +Elastic Observability solution:: +* Show monitors from all permitted spaces !! ({kibana-pull}196109[#196109]). +* Adds experimental logs overview to the observability hosts and service overviews ({kibana-pull}195673[#195673]). +* Show alerts for entities ({kibana-pull}195250[#195250]). +* Create sub-feature role to manage APM settings write permissions ({kibana-pull}194419[#194419]). +* Adds related alerts tab to the alert details page ({kibana-pull}193263[#193263]). +* Adds labels field !! ({kibana-pull}193250[#193250]). +* Implement _ignored root cause identification flow ({kibana-pull}192370[#192370]). +* Enable page for synthetics ({kibana-pull}191846[#191846]). +* Settings add config to enable default rules ({kibana-pull}190800[#190800]). +* Added alerts page ({kibana-pull}190751[#190751]). +* Monitor list add bulk delete ({kibana-pull}190674[#190674]). +* Delete monitor API via id param !! ({kibana-pull}190210[#190210]). +* Enable metrics and traces in the Data Set Quality page ({kibana-pull}190043[#190043]). +* Adds alert grouping functionality to the observability alerts page ({kibana-pull}189958[#189958]). +* Adds a new SLO Burn Rate embeddable ({kibana-pull}189429[#189429]). +* The Slack Web API Alert Connector is now supported as a default connector for Synthetics and Uptime rules ({kibana-pull}188437[#188437]). +* Adds option to enable backfill transform ({kibana-pull}188379[#188379]). +* Save the ECS group by fields at the AAD root level ({kibana-pull}188241[#188241]). +* Adds last value aggregation ({kibana-pull}187082[#187082]). +* Improve synthetics alerting ({kibana-pull}186585[#186585]). +* Make overview grid embeddable ({kibana-pull}160597[#160597]). +Elastic Security solution:: +For the Elastic Security 8.16.0 release information, refer to {security-guide}/release-notes.html[_Elastic Security Solution Release Notes_]. +Kibana security:: +* Adds an API endpoint `POST security/roles` that can be used to bulk create or update roles ({kibana-pull}189173[#189173]). +* Automatic Import can now create integrations for logs in the CSV format ({kibana-pull}194386[#194386]). +* Adds an error handling framework to Automatic Import that provides error messages with more context to user ({kibana-pull}193577[#193577]). +* When running in FIPS mode, Kibana forbids usage of PKCS12 configuration options ({kibana-pull}192627[#192627]). +Machine Learning:: +* Adds new section for creating daylight saving time calendar events ({kibana-pull}193605[#193605]). +* Anomaly Detection: Adds a page to list supplied job configurations ({kibana-pull}191564[#191564]). +* Redesigns start/update model deployment dialog to support adaptive resources ({kibana-pull}190243[#190243]). +* File upload: Adds support for PDF files ({kibana-pull}186956[#186956]). +* Adds Pattern analysis embeddable for dashboards ({kibana-pull}186539[#186539]). +Management:: +* This release introduces a fresh, modern look for the console, now featuring the Monaco editor. We've added a file import and export functionality, and the console is fully responsive with stackable panels for a smoother experience. New buttons allow for quick clearing of editor values and output. Additionally, the history and config tabs were improved to enhance usability. ({kibana-pull}189748[#189748]). + +For more information about the features introduced in 8.16.0, refer to <>. + +[[enhancements-and-bug-fixes-v8.16.0]] +=== Enhancements and bug fixes + +For detailed information about the 8.16.0 release, review the enhancements and bug fixes. + + +[float] +[[enhancement-v8.16.0]] +=== Enhancements +Alerting:: +* Allow users to select template while adding a case action in the rule ({kibana-pull}190701[#190701]). +* New full-page rule form in the Stack Management app ({kibana-pull}194655[#194655]). +Dashboards and visualizations:: +* Adds compressed style for dashboard controls ({kibana-pull}190636[#190636]). +* Adds the ability to duplicate a managed dashboard from its `managed` badge ({kibana-pull}189404[#189404]). +* Adds the ability to expand the height of various sections in the Edit ES|QL visualization flyout ({kibana-pull}193453[#193453]). +* Improves the query authoring experience when editing an ES|QL visualization ({kibana-pull}186875[#186875]). +* Syncs the cursor for time series charts powered by ES|QL ({kibana-pull}192837[#192837]). +* Gauge and metric Lens visualizations are no longer experimental ({kibana-pull}192359[#192359]). +* Sets gauge default palette to "temperature" in *Lens* ({kibana-pull}191853[#191853]). +* Supports fuzzy search on field pickers and field lists in *Lens* ({kibana-pull}186894[#186894]). +Data ingestion and Fleet:: +* Update max supported package version ({kibana-pull}196551[#196551]). +* Adds additional columns to Agent Logs UI ({kibana-pull}192262[#192262]). +* Show `+build` versions for Elastic Agent upgrades ({kibana-pull}192171[#192171]). +* Added format parameter to `agent_policies` APIs ({kibana-pull}191811[#191811]). +* Adds toggles for `agent.monitoring.http.enabled` and `agent.monitoring.http.buffer.enabled` to agent policy advanced settings ({kibana-pull}190984[#190984]). +* Support integration policies without agent policy references (aka orphaned integration policies) ({kibana-pull}190649[#190649]). +* Changed the UX of the Edit Integration Policy page to update agent policies ({kibana-pull}190583[#190583]). +* Allow `traces` to be added to the `monitoring_enabled` array in Agent policies ({kibana-pull}189908[#189908]). +* Create task that periodically unenrolls inactive agents ({kibana-pull}189861[#189861]). +* Adds setup technology selector to add integration page ({kibana-pull}189612[#189612]). +* Support integration-level outputs ({kibana-pull}189125[#189125]). +Discover:: +* Renames the Documents tab to Results in ES|QL mode ({kibana-pull}197833[#197833]). +* Adds a cluster details tab for CCS data sources when inspecting requests in ES|QL mode ({kibana-pull}195373[#195373]). +* Adds the query time to the list of statistics when inspecting requests in ES|QL mode ({kibana-pull}194806[#194806]). +* Improves display of error messages in ES|QL mode ({kibana-pull}191320[#191320]). +* Adds a help menu to the ES|QL mode ({kibana-pull}190579[#190579]). +* Initializes the ES|QL editor with time named parameters when switching from the classic mode with a data view without @timestamp ({kibana-pull}189367[#189367]). +* Adds the ability to select multiple rows from the Documents table using "Shift + Select" ({kibana-pull}193619[#193619]). +* Adds the ability to filter on field names and values in the expanded document view ({kibana-pull}192299[#192299]). +* Adds filtering for selected fields ({kibana-pull}191930[#191930]). +* Adds a dedicated column to the document viewer flyout for pinning and unpinning rows ({kibana-pull}190344[#190344]). +* Improves absolute column width handling ({kibana-pull}190288[#190288]). +* Allows filtering by field type in the document viewer flyout ({kibana-pull}189981[#189981]). +* Improves the document viewer flyout to remember the last active tab ({kibana-pull}189806[#189806]). +* Adds ability to hide fields with null values from the document viewer ({kibana-pull}189601[#189601]). +* Adds the ability to copy selected rows as text ({kibana-pull}189512[#189512]). +* Adds a log level badge cell renderer to the Discover logs profile ({kibana-pull}188281[#188281]). +* Shows ECS field descriptions in Discover and adds markdown support for field descriptions ({kibana-pull}187160[#187160]). +* Adds support for the Log overview tab to the Discover log profile ({kibana-pull}186680[#186680]). +* Adds default app state extension and log integration data source profiles ({kibana-pull}186347[#186347]). +* Allows to select and deselect all rows in the grid at once ({kibana-pull}184241[#184241]). +* Limits the height of long field values by default ({kibana-pull}183736[#183736]). +ES|QL editor:: +* Changes the auto-focus to be on the ES|QL editor when loading the page ({kibana-pull}193800[#193800]). +* Updates the autocomplete behavior for `SORT` to be in line with other field-list-based experiences like `KEEP` in ES|QL queries ({kibana-pull}193595[#193595]). +* Adds `all (*)` to the list of suggestions for `COUNT` functions in ES|QL queries ({kibana-pull}192205[#192205]). +* Improves ES|QL autocomplete suggestions for `case()` expressions ({kibana-pull}192135[#192135]). +* Opens suggestions automatically for sources lists and `ENRICH` functions when writing ES|QL queries ({kibana-pull}191312[#191312]). +* Improves wrapping and readability for ES|QL queries ({kibana-pull}191269[#191269]). +* Improves suggestions based on previous function arguments and date suggestions for `bucket` functions in ES|QL queries ({kibana-pull}190828[#190828]). +* Show the `LIMIT` information in the ES|QL editor's footer ({kibana-pull}190498[#190498]). +* Opens suggestions automatically for field lists in ES|QL queries ({kibana-pull}190466[#190466]). +* Integrates a time picker for date fields into the ES|QL editor ({kibana-pull}187047[#187047]). +* Improves ES|QL support for Elasticsearch sub-types in AST for both validation and autocomplete ({kibana-pull}189689[#189689]). +* Adds ECS information to the ES|QL editor suggestions and prioritizes fields based on ECS information on the editor ({kibana-pull}187922[#187922]). +* Improves `BY` suggestions in ES|QL queries to include pipe and comma operators ({kibana-pull}189458[#189458]). +* Makes the suggestion menu open automatically in more places in ES|QL queries ({kibana-pull}189585[#189585]). +* Adds hints upon hover for function argument types and time system types ({kibana-pull}191881[#191881]). +Elastic Observability solution:: +* Enable Kubernetes Otel flow ({kibana-pull}196531[#196531]). +* Pass function responses when copying conversation ({kibana-pull}195635[#195635]). +* Turn 'fast filter' on by default and ensure tech preview badge shows when turned on ({kibana-pull}193710[#193710]). +* Custom Service Name Cell ({kibana-pull}192381[#192381]). +* Remove manage_transform and manage_ingest_pipeline privilege requirements ({kibana-pull}190572[#190572]). +* Create new formula for CPU Usage metric ({kibana-pull}189261[#189261]). +* Adds customizable header for quickstart flows ({kibana-pull}188340[#188340]). +* Change Kubernetes guide to link to observability onboarding ({kibana-pull}188322[#188322]). +* Adds KB user instructions ({kibana-pull}187607[#187607]). +* Refactor Synthetics Overview page for increased scalability ({kibana-pull}187092[#187092]). +* Improve synthetics alerting ({kibana-pull}186585[#186585]). +* Annotations Initial phase ({kibana-pull}184325[#184325]). +Elastic Search solution:: +* Adds Alibaba AI Search to Deletion, search and filtering of inference endpoints ({kibana-pull}190783[#190783]). +Elastic Security solution:: +For the Elastic Security 8.16.0 release information, refer to {security-guide}/release-notes.html[_Elastic Security Solution Release Notes_]. +Kibana security:: +* Enhances Open API spec generation to include Route Security Authorization if available ({kibana-pull}197001[#197001]). +* Automatic Import now analyzes larger number of samples to generate an integration ({kibana-pull}196233[#196233]). +* Extended `KibanaRouteOptions` to include security configuration at the route definition level ({kibana-pull}191973[#191973]). +* Adds several UX improvements to the management of Spaces in **Stack Management > Spaces**, including the ability to assign Roles to an existing Space. ({kibana-pull}191795[#191795]). +* Displays an "invalid file" error when selecting unsupported file types for the user profile image ({kibana-pull}190077[#190077]). +* Displays a warning to users whenever role mappings with empty `any` or `all` rules are created or updated ({kibana-pull}189340[#189340]). +* Adds support for CHIPS cookies ({kibana-pull}188519[#188519]). +* Adds support for Permissions Policy reporting ({kibana-pull}186892[#186892]). +Machine Learning:: +* File upload: enables check for model allocations ({kibana-pull}197395[#197395]). +* Data visualizer: Adds icons for semantic text, sparse vector, and dense vector ({kibana-pull}196069[#196069]). +* Updates vCPUs ranges for start model deployment ({kibana-pull}195617[#195617]). +* Adds ML tasks to the Kibana audit log ({kibana-pull}195120[#195120]). +* Anomaly Detection: adds ability to delete forecasts from job ({kibana-pull}194896[#194896]). +* Updates for Trained Models table layout and model states ({kibana-pull}194614[#194614]). +* Log rate analysis: ensures ability to sort on Log rate change ({kibana-pull}193501[#193501]). +* Single Metric Viewer: Enables cross-filtering for 'by', 'over', and 'partition' field values ({kibana-pull}193255[#193255]). +* Adds link to anomaly detection configurations from Integration > Assets tab ({kibana-pull}193105[#193105]). +* Anomaly Explorer: Displays markers for scheduled events in distribution-type anomaly charts ({kibana-pull}192377[#192377]). +* Serverless Security: Adds ES|QL visualizer menu item to the nav ({kibana-pull}192314[#192314]). +* Updates icons for Machine Learning embeddable dashboard panel types ({kibana-pull}191718[#191718]). +* AIOps: Uses no minimum time range by default for pattern analysis ({kibana-pull}191192[#191192]). +* Links to ML assets from Integration > Assets tab ({kibana-pull}189767[#189767]). +* Utilizes the `DataViewLazy` in ML plugin ({kibana-pull}189188[#189188]). +* AIOps: Chunks groups of field candidates into single queries for top items and histograms ({kibana-pull}189155[#189155]). +* AIOps: Updates fields filter popover to be able to filter fields from analysis (not just grouping) ({kibana-pull}188913[#188913]). +* Single Metric Viewer embeddable: adds forecasting ({kibana-pull}188791[#188791]). +* Adds new custom rule action to force time shift ({kibana-pull}188710[#188710]). +* AIOps: Chunks groups of field candidates into single queries ({kibana-pull}188137[#188137]). +* AIOps: Adds log rate analysis to alert details page contextual insight ({kibana-pull}187690[#187690]). +* Adds ability to toggle visibility for empty fields when choosing an aggregation or field in Anomaly detection, data frame analytics ({kibana-pull}186670[#186670]). +* Anomaly Detection: Adds popover links menu to anomaly explorer charts ({kibana-pull}186587[#186587]). +Management:: +* Adds an option to show or hide empty fields in dropdown lists in Transform ({kibana-pull}195485[#195485]). +* Adds a confirmation dialog when deleting a transform from a warning banner ({kibana-pull}192080[#192080]). +* Improves the autocomplete to suggest fields for the `dense_vector` type in Console ({kibana-pull}190769[#190769]). +* Adds the ability to view an ILM policy details in read-only mode ({kibana-pull}186955[#186955]). + +[float] +[[fixes-v8.16.0]] +=== Bug fixes +Alerting:: +* Show up to 1k maintenance windows in the UI ({kibana-pull}198504[#198504]) +* Skip scheduling actions for the alerts without scheduledActions ({kibana-pull}195948[#195948]). +* Fixes Stack Alerts feature API access control ({kibana-pull}193948[#193948]). +* Remove unintended internal find routes API with public access ({kibana-pull}193757[#193757]). +* Convert timestamp before passing to validation ({kibana-pull}192379[#192379]). +* Grouped over field is not populated correctly when editing a rule ({kibana-pull}192297[#192297]). +* Mark slack rate-limiting errors as user errors ({kibana-pull}192200[#192200]). +* Fixes maintenance window filtering with wildcards ({kibana-pull}194777[#194777]). +* Fixes search filters in rules, alerts, and maintenance windows ({kibana-pull}193623[#193623]). +Cases:: +* Use absolute time ranges when adding visualizations to a case ({kibana-pull}189168[#189168]). +* Fixes custom fields with long text that could not be edited in the UI ({kibana-pull}190490[#190490]). +Dashboards and visualizations:: +* Correctly show full screen mode when opening a dashboard or panel from a URL that contains the fullScreenMode parameter ({kibana-pull}196275[#196275]) and ({kibana-pull}190086[#190086]). +* Fixes an issue that could cause a the dashboard list to stay in loading state ({kibana-pull}195277[#195277]). +* Correctly use the same field icons as Discover ({kibana-pull}194095[#194095]). +* Fixes an issue where panels could disappear from a dashboard when canceling edit after saving the dashboard ({kibana-pull}193914[#193914]). +* Adds scroll margin to panels ({kibana-pull}193430[#193430]). +* Fixes an issue with the breadcrumb update icon not working when clicked ({kibana-pull}192240[#192240]). +* Fixes an issue where unsaved changes could remain after saving a dashboard ({kibana-pull}190165[#190165]). +* Fixes an issue causing the flyout to close when canceling the Save to library action ({kibana-pull}188995[#188995]). +* Fixes incomplete string escaping and encoding in *TSVB* ({kibana-pull}196248[#196248]). +* Fixes an issue where label truncation in heat map legends was not working properly in *Lens* ({kibana-pull}195928[#195928]). +* Fixes an issue where the color picker and axis side settings were incorrectly available in the breakdown dimension editor for XY charts in *Lens* ({kibana-pull}195845[#195845]). +* Fixes the tooltip position on faceted charts in *Vega* ({kibana-pull}194620[#194620]). +* Fixes the filter out legend action for ES|QL visualizations ({kibana-pull}194374[#194374]). +* Fixes element sizing issues in full screen mode in *Vega* ({kibana-pull}194330[#194330]). +* Fixes the default cell text alignment setting for non-numeric field types in *Lens* ({kibana-pull}193886[#193886]). +* Limits the height of the query bar input for long KQL queries ({kibana-pull}193737[#193737]). +* Makes the title correctly align left after removing an icon in **Lens** metric charts ({kibana-pull}191057[#191057]). +* Fixes a "No data" error caused by the "Collapse by" setting in **Lens** metric charts ({kibana-pull}190966[#190966]). +* Fixes an issue causing the color of a cell to disappear when clicking the "Expand cell" icon in *Lens* ({kibana-pull}190618[#190618]). +* Removes unnecessary index pattern references from Lens charts ({kibana-pull}190296[#190296]). +* Fixes several accessibility issues ({kibana-pull}188624[#188624]). +Data ingestion and Fleet:: +* Revert "Fix client-side validation for agent policy timeout fields" ({kibana-pull}194338[#194338]). +* Adds proxy arguments to install snippets ({kibana-pull}193922[#193922]). +* Rollover if dimension mappings changed in dynamic templates ({kibana-pull}192098[#192098]). +Discover:: +* Fixes an issue with search highlighting ({kibana-pull}197607[#197607]). +* Correctly pass embeddable filters to the Surrounding Documents page ({kibana-pull}197190[#197190]). +* Fixes trailing decimals dropped from client side validation messages ({kibana-pull}196570[#196570]). +* Fixes several validation issues and creates an expression type evaluator for ES|QL queries ({kibana-pull}195989[#195989]). +* Fixes duplicate autocomplete suggestions for `WHERE` clauses and suggestions with no space in between in ES|QL queries ({kibana-pull}195771[#195771]). +* Improves variable and field name handling in ES|QL queries ({kibana-pull}195149[#195149]). +* Fixes an issue where the Unified Field List popover could get cut off ({kibana-pull}195147[#195147]). +* Fixes the width for saved object type columns ({kibana-pull}194388[#194388]). +* Adds tooltips to Discover button icons ({kibana-pull}192963[#192963]). +* Excludes inactive integration data stream suggestions ({kibana-pull}192953[#192953]). +* Fixes new variables being suggested in incorrect places ({kibana-pull}192405[#192405]). +* Only log requests in the Inspector when they completed ({kibana-pull}191232[#191232]). +ES|QL editor:: +* Fixes an issue where the autocomplete suggestions could cause duplicate entries in ES|QL queries ({kibana-pull}190465[#190465]). +* Fixes several styling issues in the ES|QL editor ({kibana-pull}190170[#190170]). +Elastic Observability solution:: +* Change the slice outcome from bad to good whenever there is no data during the slice window ({kibana-pull}196942[#196942]). +* Make agent names generic with otel-native mode ({kibana-pull}195594[#195594]). +* Avoid showing unnecessary error toast ({kibana-pull}195331[#195331]). +* Use `fields` instead of `_source` on APM queries ({kibana-pull}195242[#195242]). +* Fixes ping heatmap payload ({kibana-pull}195107[#195107]). +* Fixes rule modal warnings in the developer console ({kibana-pull}194766[#194766]). +* Avoid AI assistant overlaying AI conversations ({kibana-pull}194722[#194722]). +* Improve loading state for metric items ({kibana-pull}192930[#192930]). +* Fixes issue where heatmap UI crashes on undefined histogram data ({kibana-pull}192508[#192508]). +* Calculate the latest metadata lookback based on the calculated history delay ({kibana-pull}191324[#191324]). +* Remove dedicated language setting ({kibana-pull}190983[#190983]). +* Change latest metric to use @timestamp ({kibana-pull}190417[#190417]). +* Prevent initial error when adding filters ({kibana-pull}190214[#190214]). +* Display error message when failing to enable machine learning anomaly detection in Inventory ({kibana-pull}189627[#189627]). +* Convert route validation to Zod ({kibana-pull}188691[#188691]). +* Fixes functions table height in asset details view profiling tab ({kibana-pull}188650[#188650]). +* Adds four decimal places float validation for transaction_sample_rate ({kibana-pull}188555[#188555]). +* Centralize data fetching and better control of when data can be refreshed ({kibana-pull}187736[#187736]). +* Fixes heatmap on monitor detail/history page for very large doc counts ({kibana-pull}184177[#184177]). +* Adds settings to serverless allowlist ({kibana-pull}190098[#190098]). +* Set missing group to false by default and show checkbox value in disable mode ({kibana-pull}188402[#188402]). +Elastic Search solution:: +* Fixes an issue with the {ref}/es-connectors-network-drive.html[Network Drive connector] where advanced configuration fields were not displayed for CSV file role mappings with `Drive Type: Linux` selected. +Elastic Security solution:: +For the Elastic Security 8.16.0 release information, refer to {security-guide}/release-notes.html[_Elastic Security Solution Release Notes_]. +Kibana platform:: +* Fixes an issue causing a wrong date to show in the header of a report when generated from relative date ({kibana-pull}197027[#197027]). +* Fixes an issue where the Created and Updated timestamps for Dashboards were ignoring the default timezone settings in Advanced settings. ({kibana-pull}196977[#196977]). +* Fixes an issue causing searches including a colon `:` character to show inaccurate results ({kibana-pull}190464[#190464]). +Kibana security:: +* Fixes an issue where an LLM was likely to generate invalid processors containing array access in Automatic Import ({kibana-pull}196207[#196207]). +Machine Learning:: +* File upload: fixes PDF character count limit ({kibana-pull}197333[#197333]). +* Data Drift: Updates brush positions on window resize fix ({kibana-pull}196830[#196830]). +* AIOps: Fixes issue where some queries cause filters to not be applied ({kibana-pull}196585[#196585]). +* Transforms: Limits the data grid result window ({kibana-pull}196510[#196510]). +* Fixes Anomaly Swim Lane Embeddable not updating properly on query change ({kibana-pull}195090[#195090]). +* Hides ES|QL based saved searches in ML & Transforms ({kibana-pull}195084[#195084]). +* Fixes query for pattern analysis and change point analysis ({kibana-pull}194742[#194742]). +* Anomaly explorer: Shows data gaps and connect anomalous points on Single Metric Charts ({kibana-pull}194119[#194119]). +* Fixes file upload with no ingest pipeline ({kibana-pull}193744[#193744]). +* Disables field statistics panel in Dashboard if ES|QL is disabled ({kibana-pull}193587[#193587]). +* Fixes display of assignees when attaching ML panels to a new case ({kibana-pull}192163[#192163]). +* Anomaly explorer: Fixes the order of the coordinates displayed on the map tooltip ({kibana-pull}192077[#192077]). +* Fixes links to the Single Metric Viewer from the Annotations and Forecasts tables ({kibana-pull}192000[#192000]). +* Trained models: fixes responsiveness of state column for smaller displays ({kibana-pull}191900[#191900]). +* File upload: increases timeout for upload request ({kibana-pull}191770[#191770]). +* Improves expired license check ({kibana-pull}191503[#191503]). +Management:: +* Fixes the pagination of the source documents data grid in Transforms ({kibana-pull}196119[#196119]). +* Fixes autocomplete suggestions after a comma in Console ({kibana-pull}189656[#189656]). + +[[release-notes-8.15.4]] +== {kib} 8.15.4 + +The 8.15.4 release includes the following bug fixes. + +[float] +[[fixes-v8.15.4]] +=== Bug fixes +Dashboards and visualizations:: +* Fixes incomplete string escaping and encoding in *TSVB* ({kibana-pull}196248[#196248]). +* Adds scroll margin to panels ({kibana-pull}193430[#193430]). +* Fixes an issue where label truncation in heat map legends was not working properly in *Lens* ({kibana-pull}195928[#195928]). +Discover:: +* Fixes the width for saved object Type column ({kibana-pull}194388[#194388]). +Elastic Observability solution:: +* Changes the slice outcome from bad to good whenever there is no data during the slice window ({kibana-pull}196942[#196942]). +Elastic Search solution:: +* Fixes a bug with the {ref}/es-connectors-network-drive.html[Network Drive connector] where advanced configuration fields were not displayed for CSV file role mappings with `Drive Type: Linux` selected ({kibana-pull}195567[#195567]). +Elastic Security solution:: +For the Elastic Security 8.15.4 release information, refer to {security-guide}/release-notes.html[_Elastic Security Solution Release Notes_]. +Kibana platform:: +* Fixes an issue causing the wrong date to show in the header of a report when generated from a relative date ({kibana-pull}197027[#197027]). +* Fixes an issue with the export options for PNG/PDF reports in a dashboard ({kibana-pull}192530[#192530]). +Machine Learning:: +* Fixes an issue preventing Anomaly swim lane panels from updating on query changes ({kibana-pull}195090[#195090]). +Management:: +* Fixes the pagination of the source documents data grid in Transforms ({kibana-pull}196119[#196119]). + [[release-notes-8.15.3]] == {kib} 8.15.3 @@ -102,7 +484,6 @@ Elastic Security solution:: For the Elastic Security 8.15.3 release information, refer to {security-guide}/release-notes.html[_Elastic Security Solution Release Notes_]. Kibana security:: * Automatic Import no longer asks the LLM to map fields to reserved ECS fields ({kibana-pull}195168[#195168]). -* Automatic Import no longer returns an "Invalid ECS field" message when the ECS mapping slightly differs from the expected format. For example `date_format` instead of `date_formats` ({kibana-pull}195167[#195167]). * Fixes an issue that was causing the Grok processor to return non-ECS compatible fields when processing structured or unstructured syslog samples in Automatic Import ({kibana-pull}194727[#194727]). * Fixes the integrationName when uploading a new version of an existing integration using a ZIP upload ({kibana-pull}194298[#194298]). * Fixes a bug that caused the Deploy step of Automatic Import to fail after a pipeline was edited and saved ({kibana-pull}194203[#194203]). @@ -3852,7 +4233,7 @@ In 8.1.0 and later, {kib} uses the field caps API, by default, to determine the `visualization:visualize:legacyPieChartsLibrary` has been removed from *Advanced Settings*. The setting allowed you to create aggregation-based pie chart visualizations using the legacy charts library. For more information, refer to {kibana-pull}146990[#146990]. *Impact* + -In 7.14.0 and later, the new aggregation-based pie chart visualization is available by default. For more information, check link:https://www.elastic.co/guide/en/kibana/current/add-aggregation-based-visualization-panels.html[Aggregation-based]. +In 7.14.0 and later, the new aggregation-based pie chart visualization is available by default. For more information, check <>. ==== [discrete] diff --git a/docs/api/synthetics/monitors/delete-monitor-api.asciidoc b/docs/api/synthetics/monitors/delete-monitor-api.asciidoc index 70861fcd60a36..74798b40830b7 100644 --- a/docs/api/synthetics/monitors/delete-monitor-api.asciidoc +++ b/docs/api/synthetics/monitors/delete-monitor-api.asciidoc @@ -17,9 +17,6 @@ Deletes one or more monitors from the Synthetics app. You must have `all` privileges for the *Synthetics* feature in the *{observability}* section of the <>. -You must have `all` privileges for the *Synthetics* feature in the *{observability}* section of the -<>. - [[delete-monitor-api-path-params]] === {api-path-parms-title} @@ -27,7 +24,6 @@ You must have `all` privileges for the *Synthetics* feature in the *{observabili `config_id`:: (Required, string) The ID of the monitor that you want to delete. - Here is an example of a DELETE request to delete a monitor by ID: [source,sh] @@ -37,7 +33,7 @@ DELETE /api/synthetics/monitors/monitor1-id ==== Bulk Delete Monitors -You can delete multiple monitors by sending a list of config ids to a DELETE request to the `/api/synthetics/monitors` endpoint. +You can delete multiple monitors by sending a list of config ids to a POST request to the `/api/synthetics/monitors/_bulk_delete` endpoint. [[monitors-delete-request-body]] @@ -49,11 +45,11 @@ The request body should contain an array of monitors IDs that you want to delete (Required, array of strings) An array of monitor IDs to delete. -Here is an example of a DELETE request to delete a list of monitors by ID: +Here is an example of a POST request to delete a list of monitors by ID: [source,sh] -------------------------------------------------- -DELETE /api/synthetics/monitors +POST /api/synthetics/monitors/_bulk_delete { "ids": [ "monitor1-id", diff --git a/docs/api/synthetics/params/delete-param.asciidoc b/docs/api/synthetics/params/delete-param.asciidoc index 4c7d7911ec180..031a47501a8a8 100644 --- a/docs/api/synthetics/params/delete-param.asciidoc +++ b/docs/api/synthetics/params/delete-param.asciidoc @@ -8,9 +8,9 @@ Deletes one or more parameters from the Synthetics app. === {api-request-title} -`DELETE :/api/synthetics/params` +`DELETE :/api/synthetics/params/` -`DELETE :/s//api/synthetics/params` +`DELETE :/s//api/synthetics/params/` === {api-prereq-title} @@ -20,26 +20,19 @@ You must have `all` privileges for the *Synthetics* feature in the *{observabili You must have `all` privileges for the *Synthetics* feature in the *{observability}* section of the <>. -[[parameters-delete-request-body]] -==== Request Body +[[parameters-delete-path-param]] +==== Path Parameters The request body should contain an array of parameter IDs that you want to delete. -`ids`:: -(Required, array of strings) An array of parameter IDs to delete. +`param_id`:: +(Required, string) An id of parameter to delete. - -Here is an example of a DELETE request to delete a list of parameters by ID: +Here is an example of a DELETE request to delete a parameter by its ID: [source,sh] -------------------------------------------------- -DELETE /api/synthetics/params -{ - "ids": [ - "param1-id", - "param2-id" - ] -} +DELETE /api/synthetics/params/param_id1 -------------------------------------------------- [[parameters-delete-response-example]] @@ -58,10 +51,21 @@ Here's an example response for deleting multiple parameters: { "id": "param1-id", "deleted": true - }, - { - "id": "param2-id", - "deleted": true } ] --------------------------------------------------- \ No newline at end of file +-------------------------------------------------- + +==== Bulk delete parameters +To delete multiple parameters, you can send a POST request to `/api/synthetics/params/_bulk_delete` with an array of parameter IDs to delete via body. + +Here is an example of a POST request to delete multiple parameters: + +[source,sh] +-------------------------------------------------- +POST /api/synthetics/params/_bulk_delete +{ + "ids": ["param1-id", "param2-id"] +} +-------------------------------------------------- + + diff --git a/docs/canvas/canvas-expression-lifecycle.asciidoc b/docs/canvas/canvas-expression-lifecycle.asciidoc deleted file mode 100644 index a20181c4b3808..0000000000000 --- a/docs/canvas/canvas-expression-lifecycle.asciidoc +++ /dev/null @@ -1,263 +0,0 @@ -[role="xpack"] -[[canvas-expression-lifecycle]] -== Canvas expression lifecycle - -Elements in Canvas are all created using an *expression language* that defines how to retrieve, manipulate, and ultimately visualize data. The goal is to allow you to do most of what you need without understanding the *expression language*, but learning how it works unlocks a lot of Canvas's power. - - -[[canvas-expressions-always-start-with-a-function]] -=== Expressions always start with a function - -Expressions simply execute <> in a specific order, which produce some output value. That output can then be inserted into another function, and another after that, until it produces the output you need. - -To use demo dataset available in Canvas to produce a table, run the following expression: - -[source,text] ----- -/* Simple demo table */ -filters -| demodata -| table -| render ----- - -This expression starts out with the <> function, which provides the value of any time filters or dropdown filters in the workpad. This is then inserted into <>, a function that returns exactly what you expect, demo data. Because the <> function receives the filter information from the <> function before it, it applies those filters to reduce the set of data it returns. We call the output from the previous function _context_. - -The filtered <> becomes the _context_ of the next function, <>, which creates a table visualization from this data set. The <> function isn’t strictly required, but by being explicit, you have the option of providing arguments to control things like the font used in the table. The output of the <> function becomes the _context_ of the <> function. Like the <>, the <> function isn’t required either, but it allows access to other arguments, such as styling the border of the element or injecting custom CSS. - -It is possible to add comments to the expression by starting them with a `//` sequence or by using `/*` and `*/` to enclose multi-line comments. - -[[canvas-function-arguments]] -=== Function arguments - -Let’s look at another expression, which uses the same <> function, but instead produces a pie chart. - -image::images/canvas-functions-can-take-arguments-pie-chart.png[Pie chart showing output of demodata function] -[source,text] ----- -filters -| demodata -| pointseries color="state" size="max(price)" -| pie -| render ----- - -To produce a filtered set of random data, the expression uses the <> and <> functions. This time, however, the output becomes the context for the <> function, which is a way to aggregate your data, similar to how Elasticsearch works, but more generalized. In this case, the data is split up using the `color` and `size` dimensions, using arguments on the <> function. Each unique value in the state column will have an associated size value, which in this case, will be the maximum value of the price column. - -If the expression stopped there, it would produce a `pointseries` data type as the output of this expression. But instead of looking at the raw values, the result is inserted into the <> function, which will produce an output that will render a pie visualization. And just like before, this is inserted into the <> function, which is useful for its arguments. - -The end result is a simple pie chart that uses the default color palette, but the <> function can take additional arguments that control how it gets rendered. For example, you can provide a `hole` argument to turn your pie chart into a donut chart by changing the expression to: - - -image::images/canvas-functions-can-take-arguments-donut-chart.png[Alternative output as donut chart] -[source,text] ----- -filters -| demodata -| pointseries color="state" size="max(price)" -| pie hole=50 -| render ----- - - -[[canvas-aliases-and-unnamed-arguments]] -=== Aliases and unnamed arguments - -Argument definitions have one canonical name, which is always provided in the underlying code. When argument definitions are used in an expression, they often include aliases that make them easier or faster to type. - -For example, the <> function has 2 arguments: - -* `expression` - Produces a calculated value. -* `name` - The name of column. - -The `expression` argument includes some aliases, namely `exp`, `fn`, and `function`. That means that you can use any of those four options to provide that argument’s value. - -So `mapColumn name=newColumn fn={string example}` is equal to `mapColumn name=newColumn expression={string example}`. - -There’s also a special type of alias which allows you to leave off the argument’s name entirely. The alias for this is an underscore, which indicates that the argument is an _unnamed_ argument and can be provided without explicitly naming it in the expression. The `name` argument here uses the _unnamed_ alias, which means that you can further simplify our example to `mapColumn newColumn fn={string example}`. - -NOTE: There can only be one _unnamed_ argument for each function. - - -[[canvas-change-your-expression-change-your-output]] -=== Change your expression, change your output -You can substitute one function for another to change the output. For example, you could change the visualization by swapping out the <> function for another renderer, a function that returns a `render` data type. - -Let’s change that last pie chart into a bubble chart by replacing the <> function with the <> function. This is possible because both functions can accept a `pointseries` data type as their _context_. Switching the functions will work, but it won’t produce a useful visualization on its own since you don’t have the x-axis and y-axis defined. You will also need to modify the <> function to change its output. In this case, you can change the `size` argument to `y`, so the maximum price values are plotted on the y-axis, and add an `x` argument using the `@timestamp` field in the data to plot those values over time. This leaves you with the following expression and produces a bubble chart showing the max price of each state over time: - -image::images/canvas-change-your-expression-chart.png[Bubble Chart, with price along x axis, and time along y axis] -[source,text] ----- -filters -| demodata -| pointseries color="state" y="max(price)" x="@timestamp" -| plot -| render ----- - -Similar to the <> function, the <> function takes arguments that control the design elements of the visualization. As one example, passing a `legend` argument with a value of `false` to the function will hide the legend on the chart. - -image::images/canvas-change-your-expression-chart-no-legend.png[Bubble Chart Without Legend] -[source,text,subs=+quotes] ----- -filters -| demodata -| pointseries color="state" y="max(price)" x="@timestamp" -| plot *legend=false* -| render ----- - - -[[canvas-fetch-and-manipulate-data]] -=== Fetch and manipulate data -So far, you have only seen expressions as a way to produce visualizations, but that’s not really what’s happening. Expressions only produce data, which is then used to create something, which in the case of Canvas, means rendering an element. An element can be a visualization, driven by data, but it can also be something much simpler, like a static image. Either way, an expression is used to produce an output that is used to render the desired result. For example, here’s an expression that shows an image: - -[source,text] ----- -image dataurl=https://placekitten.com/160/160 mode="cover" ----- - -But as mentioned, this doesn’t actually _render that image_, but instead it _produces some output that can be used to render that image_. That’s an important distinction, and you can see the actual output by adding in the render function and telling it to produce debug output. For example: - -[source,text] ----- -image dataurl=https://placekitten.com/160/160 mode="cover" -| render as=debug ----- - -The follow appears as JSON output: - -[source,JSON] ----- -{ - "type": "image", - "mode": "cover", - "dataurl": "https://placekitten.com/160/160" -} ----- - -NOTE: You may need to expand the element’s size to see the whole output. - -Canvas uses this output’s data type to map to a specific renderer and passes the entire output into it. It’s up to the image render function to produce an image on the workpad’s page. In this case, the expression produces some JSON output, but expressions can also produce other, simpler data, like a string or a number. Typically, useful results use JSON. - -Canvas uses the output to render an element, but other applications can use expressions to do pretty much anything. As stated previously, expressions simply execute functions, and the functions are all written in Javascript. That means if you can do something in Javascript, you can do it with an expression. - -This can include: - -* Sending emails -* Sending notifications -* Reading from a file -* Writing to a file -* Controlling devices with WebUSB or Web Bluetooth -* Consuming external APIs - -If your Javascript works in the environment where the code will run, such as in Node.js or in a browser, you can do it with an expression. - -[[canvas-expressions-compose-functions-with-subexpressions]] -=== Compose functions with sub-expressions - -You may have noticed another syntax in examples from other sections, namely expressions inside of curly brackets. These are called sub-expressions, and they can be used to provide a calculated value to another expression, instead of just a static one. - -A simple example of this is when you upload your own images to a Canvas workpad. That upload becomes an asset, and that asset can be retrieved using the `asset` function. Usually you’ll just do this from the UI, adding an image element to the page and uploading your image from the control in the sidebar, or picking an existing asset from there as well. In both cases, the system will consume that asset via the `asset` function, and you’ll end up with an expression similar to this: - -[source,text] ----- -image dataurl={asset 3cb3ec3a-84d7-48fa-8709-274ad5cc9e0b} ----- - -Sub-expressions are executed before the function that uses them is executed. In this case, `asset` will be run first, it will produce a value, the base64-encoded value of the image and that value will be used as the value for the `dataurl` argument in the <> function. After the asset function executes, you will get the following output: - -[source,text] ----- -image dataurl="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0" ----- - -Since all of the sub-expressions are now resolved into actual values, the <> function can be executed to produce its JSON output, just as it’s explained previously. In the case of images, the ability to nest sub-expressions is particularly useful to show one of several images conditionally. For example, you could swap between two images based on some calculated value by mixing in the <> function, like in this example expression: - -[source,text] ----- -demodata -| image dataurl={ - if condition={getCell price | gte 100} - then={asset "asset-3cb3ec3a-84d7-48fa-8709-274ad5cc9e0b"} - else={asset "asset-cbc11a1f-8f25-4163-94b4-2c3a060192e7"} -} ----- - -NOTE: The examples in this section can’t be copy and pasted directly, since the values used throughout will not exist in your workpad. - -Here, the expression to use for the value of the `condition` argument, `getCell price | gte 100`, runs first since it is nested deeper. - -The expression does the following: - -* Retrieves the value from the *price* column in the first row of the `demodata` data table -* Inputs the value to the `gte` function -* Compares the value to `100` -* Returns `true` if the value is 100 or greater, and `false` if the value is 100 or less - -That boolean value becomes the value for the `condition` argument. The output from the `then` expression is used as the output when `condition` is `true`. The output from the `else` expression is used when `condition` is false. In both cases, a base64-encoded image will be returned, and one of the two images will be displayed. - -You might be wondering how the <> function in the sub-expression accessed the data from the <> function, even though <> was not being directly inserted into <>. The answer is simple, but important to understand. When nested sub-expressions are executed, they automatically receive the same _context_, or output of the previous function that its parent function receives. In this specific expression, demodata’s data table is automatically provided to the nested expression’s `getCell` function, which allows that expression to pull out a value and compare it to another value. - -The passing of the _context_ is automatic, and it happens no matter how deeply you nest your sub-expressions. To demonstrate this, let’s modify the expression slightly to compare the value of the price against multiple conditions using the <> function. - -[source,text] ----- -demodata -| image dataurl={ - if condition={getCell price | all {gte 100} {neq 105}} - then={asset 3cb3ec3a-84d7-48fa-8709-274ad5cc9e0b} - else={asset cbc11a1f-8f25-4163-94b4-2c3a060192e7} -} ----- - -This time, `getCell price` is run, and the result is passed into the next function as the context. Then, each sub-expression of the <> function is run, with the context given to their parent, which in this case is the result of `getCell price`. If `all` of these sub-expressions evaluate to `true`, then the `if` condition argument will be true. - -Sub-expressions can seem a little foreign, especially if you aren’t a developer, but they’re worth getting familiar with, since they provide a ton of power and flexibility. Since you can nest any expression you want, you can also use this behavior to mix data from multiple indices, or even data from multiple sources. As an example, you could query an API for a value to use as part of the query provided to <>. - -This whole section is really just scratching the surface, but hopefully after reading it, you at least understand how to read expressions and make sense of what they are doing. With a little practice, you’ll get the hang of mixing _context_ and sub-expressions together to turn any input into your desired output. - -[[canvas-handling-context-and-argument-types]] -=== Handling context and argument types -If you look through the <>, you may notice that all of them define what a function accepts and what it returns. Additionally, every argument includes a type property that specifies the kind of data that can be used. These two types of values are actually the same, and can be used as a guide for how to deal with piping to other functions and using subexpressions for argument values. - -To explain how this works, consider the following expression from the previous section: - -[source,text] ----- -image dataurl={asset 3cb3ec3a-84d7-48fa-8709-274ad5cc9e0b} ----- - -If you <> for the `image` function, you’ll see that it accepts the `null` data type and returns an `image` data type. Accepting `null` effectively means that it does not use context at all, so if you insert anything to `image`, the value that was produced previously will be ignored. When the function executes, it will produce an `image` output, which is simply an object of type `image` that contains the information required to render an image. - -NOTE: The function does not render an image itself. - -As explained in the "<>" section, the output of an expression is just data. So the `image` type here is just a specific shape of data, not an actual image. - -Next, let’s take a look at the `asset` function. Like `image`, it accepts `null`, but it returns something different, a `string` in this case. Because `asset` will produce a string, its output can be used as the input for any function or argument that accepts a string. - -<> for the `dataurl` argument, its type is `string`, meaning it will accept any kind of string. There are some rules about the value of the string that the function itself enforces, but as far as the interpreter is concerned, that expression is valid because the argument accepts a string and the output of `asset` is a string. - -The interpreter also attempts to cast some input types into others, which allows you to use a string input even when the function or argument calls for a number. Keep in mind that it’s not able to convert any string value, but if the string is a number, it can easily be cast into a `number` type. Take the following expression for example: - -[source,text] ----- -string "0.4" -| revealImage image={asset asset-06511b39-ec44-408a-a5f3-abe2da44a426} ----- - -If you <> for the `revealImage` function, you’ll see that it accepts a `number` but the `string` function returns a `string` type. In this case, because the string value is a number, it can be converted into a `number` type and used without you having to do anything else. - -Most `primitive` types can be converted automatically, as you might expect. You just saw that a `string` can be cast into a `number`, but you can also pretty easily cast things into `boolean` too, and you can cast anything to `null`. - -There are other useful type casting options available. For example, something of type `datatable` can be cast to a type `pointseries` simply by only preserving specific columns from the data (namely x, y, size, color, and text). This allows you to treat your source data, which is generally of type `datatable`, like a `pointseries` type simply by convention. - -You can fetch data from Elasticsearch using `essql`, which allows you to aggregate the data, provide a custom name for the value, and insert that data directly to another function that only accepts `pointseries` even though `essql` will output a `datatable` type. This makes the following example expression valid: - -[source,text] ----- -essql "SELECT user AS x, sum(cost) AS y FROM index GROUP BY user" -| plot ----- - -In the docs you can see that `essql` returns a `datatable` type, but `plot` expects a `pointseries` context. This works because the `datatable` output will have the columns `x` and `y` as a result of using `AS` in the sql statement to name them. Because the data follows the convention of the `pointseries` data type, casting it into `pointseries` is possible, and it can be passed directly to `plot` as a result. diff --git a/docs/canvas/canvas-tutorial.asciidoc b/docs/canvas/canvas-tutorial.asciidoc index 5016dbec88aac..666d8885c2885 100644 --- a/docs/canvas/canvas-tutorial.asciidoc +++ b/docs/canvas/canvas-tutorial.asciidoc @@ -9,15 +9,11 @@ To familiarize yourself with *Canvas*, add the Sample eCommerce orders data, the To create a workpad of the eCommerce store data, add the data set, then create the workpad. -. On the home page, click *Try sample data*. +. <>. -. Click *Other sample data sets*. +. Go to **Canvas** using the navigation menu or the <>. -. On the *Sample eCommerce orders* card, click *Add data*. - -. Open the main menu, then click *Canvas*. - -. On the *Canvas workpads* page, click *Create workpad*. +. Select *Create workpad*. [float] === Customize your workpad with images diff --git a/docs/concepts/data-views.asciidoc b/docs/concepts/data-views.asciidoc index 2eba42aed3051..2a260e611a060 100644 --- a/docs/concepts/data-views.asciidoc +++ b/docs/concepts/data-views.asciidoc @@ -168,7 +168,7 @@ and field popularity data. Deleting a {data-source} does not remove any indices WARNING: Deleting a {data-source} breaks all visualizations, saved searches, and other saved objects that reference the data view. -. Open the main menu, and then click *Stack Management > Data Views*. +. Go to the **Data Views** management page using the navigation menu or the <>. . Find the {data-source} that you want to delete, and then click image:management/index-patterns/images/delete.png[Delete icon] in the *Actions* column. diff --git a/docs/concepts/esql.asciidoc b/docs/concepts/esql.asciidoc index 0390f9f6e2bc7..a3a091a4c6d0a 100644 --- a/docs/concepts/esql.asciidoc +++ b/docs/concepts/esql.asciidoc @@ -8,15 +8,12 @@ Based on the query, Lens suggestions in Discover create a visualization of the q {esql} comes with its own dedicated {esql} Compute Engine for greater efficiency. With one query you can search, aggregate, calculate and perform data transformations without leaving **Discover**. Write your query directly in **Discover** or use the **Dev Tools** with the {ref}/esql-rest.html[{esql} API]. -Here's how to use {esql} in the data view selector in **Discover**: +You can switch to the ES|QL mode of Discover from the application menu bar. -[role="screenshot"] -image:images/esql-data-view-menu.png[An image of the Discover UI where users can access the {esql} feature, width=30%, align="center"] - -{esql} also features in-app help, so you can get started faster and don't have to leave the application to check syntax. +{esql} also features in-app help and suggestions, so you can get started faster and don't have to leave the application to check syntax. [role="screenshot"] -image:images/esql-in-app-help.png[An image of the Discover UI where users can browse the in-app help] +image:images/esql-in-app-help.png[The ES|QL syntax reference and the autocomplete menu] You can also use ES|QL queries to create panels on your dashboards, create enrich policies, and create alerting rules. diff --git a/docs/concepts/images/esql-in-app-help.png b/docs/concepts/images/esql-in-app-help.png index 5f00248c10af2..00db2cf8e50c8 100644 Binary files a/docs/concepts/images/esql-in-app-help.png and b/docs/concepts/images/esql-in-app-help.png differ diff --git a/docs/dev-tools/grokdebugger/index.asciidoc b/docs/dev-tools/grokdebugger/index.asciidoc index 6a809c13fcb93..0932df0c7abfb 100644 --- a/docs/dev-tools/grokdebugger/index.asciidoc +++ b/docs/dev-tools/grokdebugger/index.asciidoc @@ -36,7 +36,8 @@ is automatically enabled in {kib}. NOTE: If you're using {stack-security-features}, you must have the `manage_pipeline` permission to use the Grok Debugger. -. Open the main menu, click *Dev Tools*, then click *Grok Debugger*. +. Find the *Grok Debugger* by navigating to the *Developer tools* page using the +navigation menu or the <>. . In *Sample Data*, enter a message that is representative of the data that you want to parse. For example: + diff --git a/docs/dev-tools/painlesslab/index.asciidoc b/docs/dev-tools/painlesslab/index.asciidoc index 387c0522dd1ca..84aa13b4590ca 100644 --- a/docs/dev-tools/painlesslab/index.asciidoc +++ b/docs/dev-tools/painlesslab/index.asciidoc @@ -12,7 +12,8 @@ process {ref}/docs-reindex.html[reindexed data], define complex <>, and work with data in other contexts. -To get started, open the main menu, click *Dev Tools*, and then click *Painless Lab*. +Find *Painless Lab* by navigating to the *Developer tools* page using the +navigation menu or the <>. [role="screenshot"] image::dev-tools/painlesslab/images/painless-lab.png[Painless Lab] diff --git a/docs/dev-tools/searchprofiler/index.asciidoc b/docs/dev-tools/searchprofiler/index.asciidoc index c323427318d54..7ce6e9fa48b39 100644 --- a/docs/dev-tools/searchprofiler/index.asciidoc +++ b/docs/dev-tools/searchprofiler/index.asciidoc @@ -14,9 +14,8 @@ poorly performing queries much faster. [[search-profiler-getting-started]] === Get started -*{searchprofiler}* is automatically enabled in {kib}. Open the main menu, -click *Dev Tools*, and then click *{searchprofiler}* -to get started. +. Find the *{searchprofiler}* by navigating to the *Developer tools* page using the +navigation menu or the <>. *{searchprofiler}* displays the names of the indices searched, the shards in each index, and how long it took for the query to complete. To try it out, replace the default `match_all` query diff --git a/docs/developer/getting-started/sample-data.asciidoc b/docs/developer/getting-started/sample-data.asciidoc index 2454c9d8a6146..a932db91c0377 100644 --- a/docs/developer/getting-started/sample-data.asciidoc +++ b/docs/developer/getting-started/sample-data.asciidoc @@ -8,7 +8,7 @@ There are a couple ways to easily get data ingested into {es}. The easiest is to install one or more of our available sample data packages. If you have no data, you should be prompted to install when running {kib} for the first time. You can also access and install the sample data packages -by going to the home page and clicking "add sample data". +by going to the **Integrations** page and selecting **Sample data**. [discrete] === makelogs script @@ -27,5 +27,5 @@ Make sure to execute `node scripts/makelogs` *after* {es} is up and running! [discrete] === CSV upload -You can also use the CSV uploader provided on the {kib} home page. +You can also use the CSV uploader provided on the **Upload file** page available in the list of **Integrations**. Navigate to **Add data** > **Upload file** to upload your data from a file. \ No newline at end of file diff --git a/docs/discover/document-explorer.asciidoc b/docs/discover/document-explorer.asciidoc index 071c9f9875028..921e0504f4596 100644 --- a/docs/discover/document-explorer.asciidoc +++ b/docs/discover/document-explorer.asciidoc @@ -1,8 +1,7 @@ [[document-explorer]] -== Explore your documents +== Customize the Discover view Fine tune your explorations by customizing *Discover* to bring out the the best view of your documents. -Adjust the chart height, modify the document table, and look inside a document. [role="screenshot"] image::images/hello-field.png[A view of the Discover app] @@ -10,34 +9,27 @@ image::images/hello-field.png[A view of the Discover app] [float] [[document-explorer-c]] -=== Hide or resize the chart +=== Hide or resize areas -Hide or resize the chart for a better fit. +* You can hide and show the chart and the fields list using the available collapse and expand button in the corresponding area. -* To turn off the display of the chart, click -image:images/chart-icon.png[icon button for opening Show/Hide chart menu, width=24px] -to open the *Chart options* menu, and then click *Hide chart*. - -* To change the chart height, drag the resize handle -image:images/resize-icon.png[two-line icon for increasing or decreasing the height of the chart, width=24px] +* Adjust the width and height of each area by dragging their border to the size you want. -The chart size is saved in your browser. - -* To reset the height, open the *Chart options* menu, and then select *Reset to default height*. +The size of each area is saved in your browser for the next time you open **Discover**. [float] [[document-explorer-customize]] === Modify the document table -Customize the appearance of the document table and its contents by resizing the columns and rows, -sorting and modifying the fields, and filtering the documents. +Customize the appearance of the document table and its contents to your liking. + +image:images/discover-customize-table.png[Options to customize the table in Discover] [float] [[document-explorer-columns]] ==== Reorder and resize the columns -* To move a single column, click its header. In the dropdown menu, -click *Move left* or *Move right*. +* To move a single column, open the column's contextual options, and select *Move left* or *Move right* in the available options. * To move multiple columns, click *Columns*. In the pop-up, drag the column names to their new order. @@ -46,17 +38,31 @@ In the pop-up, drag the column names to their new order. + Column widths are stored with a saved search. When you visualize saved searches on dashboards, the saved search appears the same as in **Discover**. +[float] +[[document-explorer-density]] +==== Customize the table density + +You can adjust the density of the table from the **Display options** located in the table toolbar. This can be particularly useful when scrolling through many results. [float] [[document-explorer-row-height]] ==== Adjust the row height To set the row height to one or more lines, or automatically -adjust the height to fit the contents, click the row height icon -image:images/row-height-icon.png[icon to open the Row height pop-up]. +adjust the height to fit the contents, open the **Display options** in the table toolbar, and adjust it as you need. + +You can define different settings for the header row and body rows. + +[float] +[[document-explorer-sample-size]] +==== Limit the sample size + +When the number of results returned by your search query (displayed at the top of the **Documents** or **Results** tab) is greater than the value of <>, the number of results displayed in the table is limited to the configured value by default. You can adjust the initial sample size for searches to any number between 10 and `discover:sampleSize` from the **Display options** located in the table toolbar. + +On the last page of the table, a message indicates that you've reached the end of the loaded search results. From that message, you can choose to load more results to continue exploring. + +image:images/discover-limit-sample-size.png[Limit sample size in Discover] -[role="screenshot"] -image::images/document-explorer-row-height.png[Row height settings for the document table, width="50%"] [float] [[document-explorer-sort-data]] @@ -70,7 +76,7 @@ column header, and then select the sort order. To sort by multiple fields: -. Click the *field sorted* option. +. Click the *Sort fields* option. + [role="screenshot"] image::images/document-explorer-sort-data.png[Pop-up in document table for sorting columns, width="50%"] @@ -106,62 +112,18 @@ Narrow your results to a subset of documents so you're comparing just the data o . Select the documents you want to compare. -. Click the *documents selected* option, and then select *Show selected documents only*. +. Click the *Selected* option, and then select *Show selected documents only*. + [role="screenshot"] -image::images/document-explorer-compare-data.png[Compare data in the document table, width="50%"] - -[float] -[[document-explorer-configure-table]] -==== Set the number of rows per page - -To change the numbers of rows you want to display on each page, use the *Rows per page* menu. The default is 100 rows per page. - -[role="screenshot"] -image::images/document-table-rows-per-page.png["Menu with options for setting the number of rows in the document table"] +image::images/document-explorer-compare-data.png[Compare data in the document table, width="40%"] +You can also compare individual field values using the <>. [float] -[[document-explorer-expand-documents]] - -=== Go inside a document - -Dive into an individual document to inspect its fields, set filters, and view -the documents that occurred before and after it. - -. Click the expand icon -image:images/expand-icon-2.png[double arrow icon to open a flyout with the document details]. -+ -You can view the document in two ways. The **Table** view displays the document fields row-by-row. -The **JSON** (JavaScript Object Notation) view allows you to look at how {es} returns the document. -+ -[role="screenshot"] -image::images/document-table-expanded.png[Expanded view of the document table] -+ -. In the *Table* view, scan through the fields and their values, or search for a field by name. - -. When you find a field of interest, -hover your mouse over the *Actions* column -to: -.. Filter the results to include or exclude specific fields or values. -.. Toggle the field in or out the document table. -.. Pin the field so it stays at the top. - -. To navigate to the next and previous documents, click the < and > arrows at the top of the view. +[[document-explorer-configure-table]] +==== Set the number of results per page -. To create a view of the document that you can bookmark and share, click **Single document**. -+ -[role="screenshot"] -image::images/discover-view-single-document.png[Discover single document view] -+ -The link is valid for the time the document is available in Elasticsearch. To create a customized view of the document, -you can create <>. +To change the numbers of results you want to display on each page, use the *Rows per page* menu. The default is 100 results per page. -. To view documents that occurred before or after the event you are looking at, click **Surrounding documents**. -+ -Documents are displayed using the same set of columns as the *Discover* view from which -the context was opened. The filters you applied are also carried over. Pinned -filters remain active, while other filters are copied in a disabled state. -+ [role="screenshot"] -image::images/discover-context.png[Image showing context view feature, with anchor documents highlighted in blue] +image::images/document-table-rows-per-page.png["Menu with options for setting the number of results in the document table"] diff --git a/docs/discover/field-statistics.asciidoc b/docs/discover/field-statistics.asciidoc index 8dccc0d4a5bbd..dc83d226ff364 100644 --- a/docs/discover/field-statistics.asciidoc +++ b/docs/discover/field-statistics.asciidoc @@ -12,7 +12,7 @@ for the data and its cardinality? This example explores the fields in the <>, or you can use your own data. -. Open the main menu, and click *Discover*. +. Go to *Discover*. . Expand the {data-source} dropdown, and select *Kibana Sample Data Logs*. diff --git a/docs/discover/get-started-discover.asciidoc b/docs/discover/get-started-discover.asciidoc new file mode 100644 index 0000000000000..ec44f977f4aac --- /dev/null +++ b/docs/discover/get-started-discover.asciidoc @@ -0,0 +1,356 @@ +[[discover-get-started]] +== Explore fields and data with Discover + +Learn how to use *Discover* to: + +- **Select** and **filter** your {es} data. +- **Explore** the fields and content of your data in depth. +- **Present** your findings in a visualization. + +*Prerequisites:* + +- If you don’t already have {kib}, https://www.elastic.co/cloud/elasticsearch-service/signup?baymax=docs-body&elektra=docs[start a free trial] on Elastic Cloud. +- You must have data in {es}. Examples on this page use the +<>, but you can use your own data. +- You should have an understanding of {ref}/documents-indices.html[{es} documents and indices] +and <>. + + +[float] +[[find-the-data-you-want-to-use]] +=== Load data into Discover + +Select the data you want to explore, and then specify the time range in which to view that data. + +. Find **Discover** in the navigation menu or by using the <>. + +. Select the data view that contains the data you want to explore. ++ +TIP: {kib} requires a <> to access your Elasticsearch data. A {data-source} can point to one or more indices, {ref}/data-streams.html[data streams], or {ref}/alias.html[index aliases]. When adding data to {es} using one of the many integrations available, sometimes data views are created automatically, but you can also create your own. ++ +If you're using sample data, data views are automatically created and are ready to use. ++ +[role="screenshot"] +image::images/discover-data-view.png[How to set the {data-source} in Discover, width="40%"] + +. If needed, adjust the <>, for example by setting it to the *Last 7 days*. ++ +The range selection is based on the default time field in your data view. +If you are using the sample data, this value was set when the data view was created. +If you are using your own data view, and it does not have a time field, the range selection is not available. + +**Discover** is populated with your data and you can view various areas with different information: + +* All fields detected are listed in a dedicated panel. +* A chart allows you to visualize your data. +* A table displays the results of your search. +By default, the table includes a column for the time field and a *Summary* column with an overview of each result. +You can modify the document table to display your fields of interest. + +You can later filter the data that shows in the chart and in the table by specifying a query and changing the time range. + +[float] +[[explore-fields-in-your-data]] +=== Explore the fields in your data + +**Discover** provides utilities designed to help you make sense of your data: + +. In the sidebar, check the available fields. It's very common to have hundreds of fields. Use the search at the top of that sidebar to look for specific terms in the field names. ++ +In this example, we've entered `ma` in the search field to find the `manufacturer` field. ++ +[role="screenshot"] +image:images/discover-sidebar-available-fields.png[Fields list that displays the top five search results, width=40%] ++ +TIP: You can combine multiple keywords or characters. For example, `geo dest` finds `geo.dest` and `geo.src.dest`. + +. Select a field to view its most frequent values. ++ +**Discover** shows the top 10 values and the number of records used to calculate those values. + +. Select the *Plus* icon to add fields to the results table. +You can also drag them from the list into the table. ++ +[role="screenshot"] +image::images/discover-add-icon.png[How to add a field as a column in the table, width="50%"] ++ +When you add fields to the table, the **Summary** column is replaced. ++ +[role="screenshot"] +image:images/document-table.png[Document table with fields for manufacturer, customer_first_name, and customer_last_name] + +. Arrange the view to your liking to display the fields and data you care most about using the various display options of **Discover**. For example, you can change the order and size of columns, expand the table to be in full screen or collapse the chart and the list of fields. Check <>. + +. **Save** your changes to be able to open the same view later on and explore your data further. + + +[float] +[[add-field-in-discover]] +==== Add a field to your {data-source} + +What happens if you forgot to define an important value as a separate field? Or, what if you +want to combine two fields and treat them as one? This is where {ref}/runtime.html[runtime fields] come into play. +You can add a runtime field to your {data-source} from inside of **Discover**, +and then use that field for analysis and visualizations, +the same way you do with other fields. + +. In the sidebar, select *Add a field*. + +. Select the **Type** of the new field. + +. **Name** the field. Name it in a way that corresponds to the way other fields of the data view are named. +You can set a custom label and description for the field to make it more recognizable in your data view. + +. Define the value that you want the field to show. By default, the field value is retrieved from the source data if it already contains a field with the same name. You can customize this with the following options: + +** **Set value**: Define a script that will determine the value to show for the field. For more information on adding fields and Painless scripting language examples, +refer to <>. +** **Set format**: Set your preferred format for displaying the value. Changing the format can affect the value and prevent highlighting in Discover. + +. In the advanced settings, you can adjust the field popularity to make it appear higher or lower in the fields list. By default, Discover orders popular fields from most selected to least selected. + +. **Save** your new field. + +You can now find it in the list of fields and add it to the table. + +In the following example, we're adding 2 fields: A simple "Hello world" field, and a second field that combines and transforms the `customer_first_name` and `customer_last_name` fields of the sample data into a single "customer" field: + +**Hello world field example**: + +* **Name**: `hello` +* **Type**: `Keyword` +* **Set value**: enabled +* **Script**: ++ +```ts +emit("Hello World!"); +``` + +**Customer field example**: + +* **Name**: `customer` +* **Type**: `Keyword` +* **Set value**: enabled +* **Script**: ++ +```ts +String str = doc['customer_first_name.keyword'].value; +char ch1 = str.charAt(0); +emit(doc['customer_last_name.keyword'].value + ", " + ch1); +``` + +[float] +==== Visualize aggregated fields +If a field can be {ref}/search-aggregations.html[aggregated], you can quickly +visualize it in detail by opening it in **Lens** from **Discover**. **Lens** is the default visualization editor in {kib}. + +. In the list of fields, find an aggregatable field. For example, with the sample data, you can look for `day_of_week`. ++ +[role="screenshot"] +image:images/discover-day-of-week.png[Top values for the day_of_week field, plus Visualize button, width=50%] + +. In the popup, click **Visualize**. ++ +{kib} creates a **Lens** visualization best suited for this field. + +. In **Lens**, from the *Available fields* list, drag and drop more fields to refine the visualization. In this example, we're adding the `manufacturer.keyword` field onto the workspace, which automatically adds a breakdown of the top values to the visualization. ++ +[role="screenshot"] +image:images/discover-from-visualize.png[Visualization that opens from Discover based on your data] + +. Save the visualization if you'd like to add it to a dashboard or keep it in the Visualize library for later use. + +For geo point fields (image:images/geoip-icon.png[Geo point field icon, width=20px]), +if you click **Visualize**, +your data appears in a map. + +[role="screenshot"] +image:images/discover-maps.png[Map containing documents] + + +[float] +[[compare-documents-in-discover]] +==== Compare documents + +You can use *Discover* to compare and diff the field values of multiple results or documents in the table. + +. Select the results you want to compare from the Documents or Results tab in Discover. + +. From the **Selected** menu in the table toolbar, choose **Compare selected**. The comparison view opens and shows the selected results next to each other. + +. Compare the values of each field. By default the first result selected shows as the reference for displaying differences in the other results. When the value remains the same for a given field, it's displayed in green. When the value differs, it's displayed in red. ++ +TIP: You can change the result used as reference by selecting **Pin for comparison** in the contextual menu of any other result. ++ +image:images/discover-compare-rows.png[Comparison view in Discover] + +. Optionally, customize the **Comparison settings** to your liking. You can for example choose to not highlight the differences, to show them more granularly at the line, word, or character level, or even to hide fields where the value matches for all results. + +. Exit the comparison view at any time using the **Exit comparison mode** button. + +[float] +[[copy-row-content]] +==== Copy results as text or JSON + +You can quickly copy the content currently displayed in the table for one or several results to your clipboard. + +. Select the results you want to copy. + +. Open the **Selected** menu in the table toolbar, and select **Copy selection as text** or **Copy documents as JSON**. + +The content is copied to your clipboard in the selected format. +Fields that are not currently added to the table are ignored. + +[float] +[[look-inside-a-document]] +==== Explore individual result or document details in depth + +[[document-explorer-expand-documents]] +Dive into an individual document to view its fields and the documents +that occurred before and after it. + +. In the document table, click the expand icon +image:images/expand-icon-2.png[double arrow icon to open a flyout with the document details] +to show document details. ++ +[role="screenshot"] +image:images/document-table-expanded.png[Table view with document expanded] + +. Scan through the fields and their values. You can filter the table in several ways: +** If you find a field of interest, +hover your mouse over the *Field* or *Value* columns for filters and additional options. +** Use the search above the table to filter for specific fields or values, or filter by field type using the options to the right of the search field. +** You can pin some fields by clicking the left column to keep them displayed even if you filter the table. ++ +TIP: You can restrict the fields listed in the detailed view to just the fields that you explicitly added to the **Discover** table, using the **Selected only** toggle. In ES|QL mode, you also have an option to hide fields with null values. + +. To navigate to a view of the document that you can bookmark and share, select ** View single document**. + +. To view documents that occurred before or after the event you are looking at, select +**View surrounding documents**. + + + + +[float] +[[search-in-discover]] +=== Search and filter data + +[float] +==== Default mode: Search and filter using KQL + +One of the unique capabilities of **Discover** is the ability to combine +free text search with filtering based on structured data. +To search all fields, enter a simple string in the query bar. + +[role="screenshot"] +image:images/discover-search-field.png[Search field in Discover] + +To search particular fields and +build more complex queries, use the <>. +As you type, KQL prompts you with the fields you can search and the operators +you can use to build a structured query. + +For example, search the ecommerce sample data for documents where the country matches US: + +. Enter `g`, and then select *geoip.country_iso_code*. +. Select *:* for equals, and *US* for the value, and then click the refresh button or press the Enter key. +. For a more complex search, try: ++ +```ts +geoip.country_iso_code : US and products.taxless_price >= 75 +``` + +[[filter-in-discover]] +With the query input, you can filter data using the KQL or Lucene languages. You can also use the **Add filter** function available next to the query input to build your filters one by one or define them as Query DSL. + +For example, exclude results from the ecommerce sample data view where day of week is not Wednesday: + +. Click image:images/add-icon.png[Add icon] next to the query bar. +. In the *Add filter* pop-up, set the field to *day_of_week*, the operator to *is not*, +and the value to *Wednesday*. ++ +[role="screenshot"] +image:images/discover-add-filter.png[Add filter dialog in Discover] + +. Click **Add filter**. +. Continue your exploration by adding more filters. +. To remove a filter, click the close icon (x) next to its name in the filter bar. + +[float] +==== Search and filter using ES|QL + +You can use **Discover** with the Elasticsearch Query Language, ES|QL. When using ES|QL, +you don't have to select a data view. It's your query that determines the data to explore and display in Discover. + +You can switch to the ES|QL mode of Discover from the application menu bar. + +Note that in ES|QL mode, the **Documents** tab is named **Results**. + +Learn more about how to use ES|QL queries in <>. + + + +[float] +[[save-discover-search]] +==== Save your search for later use + +Save your search so you can use it later, generate a CSV report, or use it to create visualizations, dashboards, and Canvas workpads. +Saving a search saves the query text, filters, +and current view of *Discover*, including the columns selected in +the document table, the sort order, and the {data-source}. + +. In the application menu bar, click **Save**. + +. Give your search a title and a description. + +. Optionally store <> and the time range with the search. + +. Click **Save**. + +[float] +[[share-your-findings]] +==== Share your search + +To share your search and **Discover** view with a larger audience, click *Share* in the application menu bar. +For detailed information about the sharing options, refer to <>. + + +[float] +[[alert-from-Discover]] +=== Generate alerts + +From *Discover*, you can create a rule to periodically +check when data goes above or below a certain threshold within a given time interval. + +. Ensure that your data view, +query, and filters fetch the data for which you want an alert. +. In the application menu bar, click *Alerts > Create search threshold rule*. ++ +The *Create rule* form is pre-filled with the latest query sent to {es}. +. <> and <>. + +. Click *Save*. + +For more about this and other rules provided in {alert-features}, go to <>. + + +[float] +=== What’s next? + +* <>. + +* <> to better meet your needs. + +[float] +=== Troubleshooting + +This section references common questions and issues encountered when using Discover. +Also check the following blog post: {blog-ref}troubleshooting-guide-common-issues-kibana-discover-load[Learn how to resolve common issues with Discover.] + +**Some fields show as empty while they should not be, why is that?** + +This can happen in several cases: + +* With runtime fields and regular keyword fields, when the string exceeds the value set for the {ref}/ignore-above.html[ignore_above] setting used when indexing the data into {es}. +* Due to the structure of nested fields, a leaf field added to the table as a column will not contain values in any of its cells. Instead, add the root field as a column to view a JSON representation of its values. Learn more in https://www.elastic.co/de/blog/discover-uses-fields-api-in-7-12[this blog post]. \ No newline at end of file diff --git a/docs/discover/images/discover-add-filter.png b/docs/discover/images/discover-add-filter.png index 3ce158fc4fb84..f72d4074b4b85 100644 Binary files a/docs/discover/images/discover-add-filter.png and b/docs/discover/images/discover-add-filter.png differ diff --git a/docs/discover/images/discover-compare-rows.png b/docs/discover/images/discover-compare-rows.png new file mode 100644 index 0000000000000..868a17fd7ca2d Binary files /dev/null and b/docs/discover/images/discover-compare-rows.png differ diff --git a/docs/discover/images/discover-customize-table.png b/docs/discover/images/discover-customize-table.png new file mode 100644 index 0000000000000..a0aba47f6cd15 Binary files /dev/null and b/docs/discover/images/discover-customize-table.png differ diff --git a/docs/discover/images/discover-data-view.png b/docs/discover/images/discover-data-view.png index 869fc9b928811..e6c3a9aa832d5 100644 Binary files a/docs/discover/images/discover-data-view.png and b/docs/discover/images/discover-data-view.png differ diff --git a/docs/discover/images/discover-limit-sample-size.png b/docs/discover/images/discover-limit-sample-size.png new file mode 100644 index 0000000000000..1e8628ebace55 Binary files /dev/null and b/docs/discover/images/discover-limit-sample-size.png differ diff --git a/docs/discover/images/document-explorer-compare-data.png b/docs/discover/images/document-explorer-compare-data.png index 36560dcabd13e..2a980f8977393 100644 Binary files a/docs/discover/images/document-explorer-compare-data.png and b/docs/discover/images/document-explorer-compare-data.png differ diff --git a/docs/discover/images/document-table-expanded.png b/docs/discover/images/document-table-expanded.png index a6fee908b668f..f73c7d08fe09f 100644 Binary files a/docs/discover/images/document-table-expanded.png and b/docs/discover/images/document-table-expanded.png differ diff --git a/docs/discover/images/document-table.png b/docs/discover/images/document-table.png index 8fbabe4703b24..ab9141cbb9b54 100644 Binary files a/docs/discover/images/document-table.png and b/docs/discover/images/document-table.png differ diff --git a/docs/discover/images/esql-custom-time-series.png b/docs/discover/images/esql-custom-time-series.png new file mode 100644 index 0000000000000..1be4e5f137fc1 Binary files /dev/null and b/docs/discover/images/esql-custom-time-series.png differ diff --git a/docs/discover/images/esql-full-query.png b/docs/discover/images/esql-full-query.png index e4f5faeef3cf7..6bcfba71c4cd6 100644 Binary files a/docs/discover/images/esql-full-query.png and b/docs/discover/images/esql-full-query.png differ diff --git a/docs/discover/images/esql-limit.png b/docs/discover/images/esql-limit.png index b03ecdcc091e6..37a59e0c6c797 100644 Binary files a/docs/discover/images/esql-limit.png and b/docs/discover/images/esql-limit.png differ diff --git a/docs/discover/images/esql-machine-os-ram.png b/docs/discover/images/esql-machine-os-ram.png index ad46d88b219ff..8e2e548a7b317 100644 Binary files a/docs/discover/images/esql-machine-os-ram.png and b/docs/discover/images/esql-machine-os-ram.png differ diff --git a/docs/discover/images/esql-no-time-series.png b/docs/discover/images/esql-no-time-series.png new file mode 100644 index 0000000000000..779269582e7ba Binary files /dev/null and b/docs/discover/images/esql-no-time-series.png differ diff --git a/docs/discover/images/hello-field.png b/docs/discover/images/hello-field.png index 261cb00acfa4c..8aee22bf2a847 100644 Binary files a/docs/discover/images/hello-field.png and b/docs/discover/images/hello-field.png differ diff --git a/docs/discover/log-pattern-analysis.asciidoc b/docs/discover/log-pattern-analysis.asciidoc index b4bd9fec29ec9..5131b68a073b4 100644 --- a/docs/discover/log-pattern-analysis.asciidoc +++ b/docs/discover/log-pattern-analysis.asciidoc @@ -7,7 +7,7 @@ Log pattern analysis works on every text field. This example uses the <>, or you can use your own data. -. Open the main menu, and click *Discover*. +. Go to *Discover*. . Expand the {data-source} dropdown, and select *Kibana Sample Data Logs*. diff --git a/docs/discover/save-search.asciidoc b/docs/discover/save-search.asciidoc index 10abef2e4a1bb..024fd97ab107b 100644 --- a/docs/discover/save-search.asciidoc +++ b/docs/discover/save-search.asciidoc @@ -43,7 +43,7 @@ used for the saved search is also automatically selected. [float] === Add search results to a dashboard -. Open the main menu, and then click *Dashboard*. +. Go to *Dashboards*. . Open or create the dashboard, then click *Edit*. . Click *Add from library*. . From the *Types* dropdown, select *Saved search*. diff --git a/docs/discover/search-sessions.asciidoc b/docs/discover/search-sessions.asciidoc index e7d7466b5ae0c..fe1e945e676ff 100644 --- a/docs/discover/search-sessions.asciidoc +++ b/docs/discover/search-sessions.asciidoc @@ -52,8 +52,8 @@ image::images/search-session-awhile.png[Search Session indicator displaying the Once you save a search session, you can start a new search, navigate to a different application, or close the browser. -. To view your saved searches, open the main menu, and then click -*Stack Management > Search Sessions*. +. To view your saved searches, go to the +*Search Sessions* management page using the navigation menu or the <>. For a saved or completed session, you can also open this view from the search sessions popup. + diff --git a/docs/discover/search.asciidoc b/docs/discover/search.asciidoc index 4f4f8f5b48d10..439c5c443cc02 100644 --- a/docs/discover/search.asciidoc +++ b/docs/discover/search.asciidoc @@ -113,8 +113,7 @@ To save the current search: . Click *Save* in the toolbar. . Enter a name for the search and click *Save*. -To import, export, and delete saved searches, open the main menu, -then click *Stack Management > Saved Objects*. +To import, export, and delete saved searches, go to the *Saved Objects* management page using the navigation menu or the <>. ==== Open a saved search To load a saved search into Discover: diff --git a/docs/discover/try-esql.asciidoc b/docs/discover/try-esql.asciidoc index 53862be75f010..149ce80dbb349 100644 --- a/docs/discover/try-esql.asciidoc +++ b/docs/discover/try-esql.asciidoc @@ -5,11 +5,17 @@ The Elasticsearch Query Language, {esql}, makes it easier to explore your data w In this tutorial we'll use the {kib} sample web logs in Discover and Lens to explore the data and create visualizations. +[TIP] +==== +For the complete {esql} documentation, including tutorials, examples and the full syntax reference, refer to the {ref}/esql.html[{es} documentation]. +For a more detailed overview of {esql} in {kib}, refer to {ref}/esql-kibana.html[Use {esql} in Kibana]. +==== + [float] [[prerequisite]] === Prerequisite -To be able to select **Language {esql}** from the Data views menu the `enableESQL` setting must be enabled from **Stack Management > Advanced Settings**. It is enabled by default. +To view the {esql} option in **Discover**, the `enableESQL` setting must be enabled from Kibana's **Advanced Settings**. It is enabled by default. [float] [[tutorial-try-esql]] @@ -17,24 +23,24 @@ To be able to select **Language {esql}** from the Data views menu the `enableESQ To load the sample data: -. On the home page, click **Try sample data**. -. Click **Other sample data sets**. -. On the Sample web logs card, click **Add data**. -. Open the main menu and select *Discover*. -. From the Data views menu, select *Language {esql}*. +. <>. +. Go to *Discover*. +. Select *Try {esql}* from the application menu bar. Let's say we want to find out what operating system users have and how much RAM is on their machine. . Set the time range to **Last 7 days**. -. Expand image:images/expand-icon-2.png[An image of the expand icon] the query bar. -. Put each processing command on a new line for better readability. . Copy the query below: + [source,esql] ---- -FROM kibana_sample_data_logs -| KEEP machine.os, machine.ram +FROM kibana_sample_data_logs <1> +| KEEP machine.os, machine.ram <2> ---- +<1> We're specifically looking for data from the sample web logs we just installed. +<2> We're only keeping the `machine.os` and `machine.ram` fields in the results table. ++ +TIP: Put each processing command on a new line for better readability. + . Click **▶Run**. + @@ -57,12 +63,14 @@ FROM kibana_sample_data_logs | LIMIT 10 ---- + -. Click **▶Run**. +. Click **▶Run** again. You can notice that the table is now limited to 10 results. The visualization also updated automatically based on the query, and broke down the data for you. ++ +NOTE: When you don't specify any specific fields to retain using `KEEP`, the visualization isn't broken down automatically. Instead, an additional option appears above the visualization and lets you select a field manually. + [role="screenshot"] image:images/esql-limit.png[An image of the extended query result] -Let's sort the data by machine ram and filter out the destination GB. +We will now take it a step further to sort the data by machine ram and filter out the `GB` destination. . Copy the query below: + @@ -75,18 +83,51 @@ FROM kibana_sample_data_logs | LIMIT 10 ---- + -. Click **▶Run**. +. Click **▶Run** again. The table and visualization no longer show results for which the `geo.dest` field value is "GB", and the results are now sorted in descending order in the table based on the `machine.ram` field. + [role="screenshot"] image:images/esql-full-query.png[An image of the full query result] + . Click **Save** to save the query and visualization to a dashboard. -To make changes to the visualization you can use the visualization drop-down. To make changes to the colors used or the axes, or click the pencil icon. This opens an in-line editor where you can change the colors and axes of the visualization. +[float] +==== Edit the ES|QL visualization + +You can make changes to the visualization by clicking the pencil icon. This opens additional settings that let you adjust the chart type, axes, breakdown, colors, and information displayed to your liking. If you're not sure which route to go, check one of the suggestions available in the visualization editor. + +If you'd like to keep the visualization and add it to a dashboard, you can save it using the floppy disk icon. + +[float] +==== ES|QL and time series data + +By default, ES|QL identifies time series data when an index contains a `@timestamp` field. This enables the time range selector and visualization options for your query. + +If your index doesn't have an explicit `@timestamp` field, but has a different time field, you can still enable the time range selector and visualization options by calling the `?_start` and `?_tend` parameters in your query. + +For example, the eCommerce sample data set doesn't have a `@timestamp` field, but has an `order_date` field. + +By default, when querying this data set, time series capabilities aren't active. No visualization is generated and the time picker is disabled. + +[source,esql] +---- +FROM kibana_sample_data_ecommerce +| KEEP customer_first_name, email, products._id.keyword +---- + +image::images/esql-no-time-series.png[ESQL query without time series capabilities enabled] + +While still querying the same data set, by adding the `?_start` and `?_tend` parameters based on the `order_date` field, **Discover** enables times series capabilities. + +[source,esql] +---- +FROM kibana_sample_data_ecommerce +| WHERE order_date >= ?_tstart and order_date <= ?_tend +---- + +image::images/esql-custom-time-series.png[ESQL query with a custom time field enabled] + + + + -[TIP] -==== -For the complete {esql} documentation, including tutorials, examples and the full syntax reference, refer to the {ref}/esql.html[{es} documentation]. -For a more detailed overview of {esql} in {kib}, refer to {ref}/esql-kibana.html[Use {esql} in Kibana]. -==== diff --git a/docs/fleet/fleet.asciidoc b/docs/fleet/fleet.asciidoc index dfee4c36171dc..52c2825557001 100644 --- a/docs/fleet/fleet.asciidoc +++ b/docs/fleet/fleet.asciidoc @@ -31,7 +31,7 @@ You can make a complete clone of a whole managed dashboard. If you clone a panel To clone a dashboard: -. Open the main menu (≡) and click *Dashboards*. +. Go to *Dashboards*. . Click on the name of the managed dashboard to view the dashboard. . Click *Clone* in the toolbar. . Click *Save and return* after editing the dashboard. diff --git a/docs/getting-started/quick-start-guide.asciidoc b/docs/getting-started/quick-start-guide.asciidoc index 1a25f2f1ec9f2..6be9dbfa2edb2 100644 --- a/docs/getting-started/quick-start-guide.asciidoc +++ b/docs/getting-started/quick-start-guide.asciidoc @@ -22,18 +22,19 @@ include::{docs-root}/shared/cloud/ess-getting-started.asciidoc[] [float] [[gs-get-data-into-kibana]] -== Add the sample data +== Add sample data Sample data sets come with sample visualizations, dashboards, and more to help you explore {kib} before you ingest or add your own data. -. On the home page, click *Try sample data*. +. Open the **Integrations** page from the navigation menu or using the <>. -. Click *Other sample data sets*. +. In the list of integrations, select **Sample Data**. -. On the *Sample eCommerce orders* card, click *Add data*. -+ -[role="screenshot"] -image::images/addData_sampleDataCards_8.6.0.png[Add data UI for the sample data sets] +. On the page that opens, select *Other sample data sets*. + +. Install the sample data sets that you want. + +Once installed, you can access the sample data in the various {kib} apps available to you. [float] [[explore-the-data]] @@ -41,7 +42,7 @@ image::images/addData_sampleDataCards_8.6.0.png[Add data UI for the sample data *Discover* displays the data in an interactive histogram that shows the distribution of data, or documents, over time, and a table that lists the fields for each document that matches the {data-source}. To view a subset of the documents, you can apply filters to the data, and customize the table to display only the fields you want to explore. -. Open the main menu, then click *Discover*. +. Go to *Discover*. . Change the <> to *Last 7 days*. + @@ -67,7 +68,7 @@ image::images/availableFields_discover_8.4.0.png[Discover table that displays on A dashboard is a collection of panels that you can use to visualize the data. Panels contain visualizations, interactive controls, text, and more. -. Open the main menu, then click *Dashboard*. +. Go to *Dashboards*. . Click *[eCommerce] Revenue Dashboard*. + diff --git a/docs/management/action-types.asciidoc b/docs/management/action-types.asciidoc index 361892e430afd..1357af980d278 100644 --- a/docs/management/action-types.asciidoc +++ b/docs/management/action-types.asciidoc @@ -28,9 +28,9 @@ a| <> | Send a request to {gemini}. -a| <> +a| <> -| Send a request to {inference}. +| Send a request to {infer}. a| <> diff --git a/docs/management/advanced-options.asciidoc b/docs/management/advanced-options.asciidoc index cda03f91dfc17..f6b8e6844ce04 100644 --- a/docs/management/advanced-options.asciidoc +++ b/docs/management/advanced-options.asciidoc @@ -20,7 +20,7 @@ indicator is displayed: [role="screenshot"] image::images/settings-read-only-badge.png[Example of Advanced Settings Management's read only access indicator in Kibana's header] -To add the privilege, open the main menu, then click *Stack Management > Roles*. +To add the privilege, go to the *Roles* management page using the navigation menu or the <>. For more information on granting access to {kib}, refer to <>. @@ -30,7 +30,7 @@ For more information on granting access to {kib}, refer to < Advanced Settings*. +. Go to the *Advanced settings* page using the navigation menu or the <>. . Click *Space Settings*. . Scroll or search for the setting. . Make your change, then click *Save changes*. @@ -330,9 +330,6 @@ the minimum and maximum values of a numeric field or a map of a geo field. [[discover:showMultiFields]]`discover:showMultiFields`:: Controls the display of multi-fields in the expanded document view. -[[discover:showLegacyFieldTopValues]]`discover:showLegacyFieldTopValues`:: -To calculate the top values for a field in the sidebar using 500 instead of 5,000 records per shard, turn on this option. - [[discover-sort-defaultorder]]`discover:sort:defaultOrder`:: The default sort direction for time-based data views. @@ -647,7 +644,7 @@ Disable this option if you prefer to use the new heatmap charts with improved pe Change the settings that apply only to {kib} spaces. -. Open the main menu, then click *Stack Management > Advanced Settings*. +. Go to the *Advanced settings* page using the navigation menu or the <>. . Click *Global Settings*. . Scroll or search for the setting. . Make your change, then click *Save changes*. diff --git a/docs/management/connectors/action-types/inference.asciidoc b/docs/management/connectors/action-types/inference.asciidoc index 8c7f2840f9c5c..ea8a0be675e18 100644 --- a/docs/management/connectors/action-types/inference.asciidoc +++ b/docs/management/connectors/action-types/inference.asciidoc @@ -1,7 +1,7 @@ [[inference-action-type]] == {infer-cap} connector and action ++++ -{inference} +{infer-cap} ++++ :frontmatter-description: Add a connector that can send requests to {inference}. :frontmatter-tags-products: [kibana] @@ -9,7 +9,8 @@ :frontmatter-tags-user-goals: [configure] -The {infer} connector uses the {es} client to send requests to an {infer} service. The connector uses the <> to send the request. +The {infer} connector uses the {es} client to send requests to an {infer} service. +The connector uses the <> to send the request. [float] [[define-inference-ui]] @@ -19,7 +20,7 @@ You can create connectors in *{stack-manage-app} > {connectors-ui}*. For example [role="screenshot"] image::management/connectors/images/inference-connector.png[{inference} connector] -// NOTE: This is an autogenerated screenshot. Do not edit it directly. + [float] [[inference-connector-configuration]] @@ -44,7 +45,8 @@ while creating or editing the connector in {kib}. For example: [role="screenshot"] image::management/connectors/images/inference-completion-params.png[{infer} params test] -// NOTE: This is an autogenerated screenshot. Do not edit it directly. + + [float] [[inference-connector-actions]] === {infer-cap} connector actions @@ -56,14 +58,17 @@ The {infer} actions have the following configuration properties. Properties depe ==== Completion The following example performs a completion task on the example question. + Input:: The text on which you want to perform the {infer} task. For example: + -[source,text] -- +[source,text] +------------------------------------------------------------ { input: 'What is Elastic?' } +------------------------------------------------------------ -- [float] @@ -71,18 +76,22 @@ The text on which you want to perform the {infer} task. For example: ==== Text embedding The following example performs a text embedding task. + Input:: The text on which you want to perform the {infer} task. For example: + -[source,text] -- +[source,text] +------------------------------------------------------------ { input: 'The sky above the port was the color of television tuned to a dead channel.', task_settings: { input_type: 'ingest' } } +------------------------------------------------------------ -- + Input type:: An optional string that overwrites the connector's default model. @@ -91,16 +100,20 @@ An optional string that overwrites the connector's default model. ==== Reranking The following example performs a reranking task on the example input. + Input:: The text on which you want to perform the {infer} task. Should be a string array. For example: + -[source,text] -- +[source,text] +------------------------------------------------------------ { input: ['luke', 'like', 'leia', 'chewy', 'r2d2', 'star', 'wars'], query: 'star wars main character' } +------------------------------------------------------------ -- + Query:: The search query text. @@ -109,14 +122,17 @@ The search query text. ==== Sparse embedding The following example performs a sparse embedding task on the example sentence. + Input:: The text on which you want to perform the {infer} task. For example: + -[source,text] -- +[source,text] +------------------------------------------------------------ { input: 'The sky above the port was the color of television tuned to a dead channel.' } +------------------------------------------------------------ -- [float] diff --git a/docs/management/connectors/action-types/jira.asciidoc b/docs/management/connectors/action-types/jira.asciidoc index 906a2945d82de..2111de7a77ce6 100644 --- a/docs/management/connectors/action-types/jira.asciidoc +++ b/docs/management/connectors/action-types/jira.asciidoc @@ -14,7 +14,7 @@ The Jira connector uses the https://developer.atlassian.com/cloud/jira/platform/ [[jira-compatibility]] === Compatibility -Jira on-premise deployments (Server and Data Center) are not supported. +Jira Cloud and Jira Data Center are supported. Jira on-premise deployments are not supported. [float] [[define-jira-ui]] @@ -37,7 +37,7 @@ Name:: The name of the connector. URL:: Jira instance URL. Project key:: Jira project key. Email:: The account email for HTTP Basic authentication. -API token:: Jira API authentication token for HTTP Basic authentication. +API token:: Jira API authentication token for HTTP Basic authentication. For Jira Data Center, this value should be the password associated with the email owner. [float] [[jira-action-configuration]] diff --git a/docs/management/connectors/action-types/servicenow-sir.asciidoc b/docs/management/connectors/action-types/servicenow-sir.asciidoc index 4c3b6d15bf59e..295a326b490b2 100644 --- a/docs/management/connectors/action-types/servicenow-sir.asciidoc +++ b/docs/management/connectors/action-types/servicenow-sir.asciidoc @@ -285,7 +285,8 @@ IMPORTANT: Deprecated connectors will continue to function with the rules they w To update a deprecated connector: -. Open the main menu and go to *{stack-manage-app} > {connectors-ui}*. +. Go to the *{connectors-ui}* page using the navigation menu or the +<>. . Select the deprecated connector to open the *Edit connector* flyout. . In the warning message, click *Update this connector*. . Complete the guided steps in the *Edit connector* flyout. diff --git a/docs/management/connectors/action-types/servicenow.asciidoc b/docs/management/connectors/action-types/servicenow.asciidoc index 83f8bd050d044..852db21e77544 100644 --- a/docs/management/connectors/action-types/servicenow.asciidoc +++ b/docs/management/connectors/action-types/servicenow.asciidoc @@ -338,7 +338,8 @@ IMPORTANT: Deprecated connectors will continue to function with the rules they w To update a deprecated connector: -. Open the main menu and go to *{stack-manage-app} > {connectors-ui}*. +. Go to the *{connectors-ui}* page using the navigation menu or the +<>. . Select the deprecated connector to open the *Edit connector* flyout. . In the warning message, click *Update this connector*. . Complete the guided steps in the *Edit connector* flyout. diff --git a/docs/management/connectors/pre-configured-connectors.asciidoc b/docs/management/connectors/pre-configured-connectors.asciidoc index 8f9536331bb1c..06a77a12beab3 100644 --- a/docs/management/connectors/pre-configured-connectors.asciidoc +++ b/docs/management/connectors/pre-configured-connectors.asciidoc @@ -66,7 +66,8 @@ Sensitive properties, such as passwords, can also be stored in the [[managing-preconfigured-connectors]] === View preconfigured connectors -When you open the main menu, click *{stack-manage-app} > {connectors-ui}*. +go to the *{connectors-ui}* page using the navigation menu or the +<>. Preconfigured connectors appear regardless of which space you are in. They are tagged as “preconfigured”, and you cannot delete them. diff --git a/docs/management/manage-data-views.asciidoc b/docs/management/manage-data-views.asciidoc index 936d764433fe9..4c6a0d77b7a9e 100644 --- a/docs/management/manage-data-views.asciidoc +++ b/docs/management/manage-data-views.asciidoc @@ -39,7 +39,7 @@ then define the field values by emitting a single value using the {ref}/modules-scripting-painless.html[Painless scripting language]. You can also add runtime fields in <> and <>. -. Open the main menu, then click *Stack Management > Data Views*. +. Go to the *Data Views* management page using the navigation menu or the <>. . Select the data view that you want to add the runtime field to, then click *Add field*. @@ -162,7 +162,7 @@ else { Edit the settings for runtime fields, or remove runtime fields from data views. -. Open the main menu, then click *Stack Management > Data Views*. +. Go to the *Data Views* management page using the navigation menu or the <>. . Select the data view that contains the runtime field you want to manage, then open the runtime field edit options or delete the runtime field. @@ -198,7 +198,7 @@ https://www.elastic.co/blog/using-painless-kibana-scripted-fields[Using Painless Create and add scripted fields to your data views. -. Open the main menu, then click *Stack Management > Data Views*. +. Go to the *Data Views* management page using the navigation menu or the <>. . Select the data view you want to add a scripted field to. @@ -214,7 +214,7 @@ For more information about scripted fields in {es}, refer to {ref}/modules-scrip [[update-scripted-field]] ==== Manage scripted fields -. Open the main menu, then click *Stack Management > Data Views*. +. Go to the *Data Views* management page using the navigation menu or the <>. . Select the data view that contains the scripted field you want to manage. @@ -230,7 +230,7 @@ exceptions when you view the dynamically generated data. {kib} uses the same field types as {es}, however, some {es} field types are unsupported in {kib}. To customize how {kib} displays data fields, use the formatting options. -. Open the main menu, then click *Stack Management > Data Views*. +. Go to the *Data Views* management page using the navigation menu or the <>. . Click the data view that contains the field you want to change. diff --git a/docs/management/managing-licenses.asciidoc b/docs/management/managing-licenses.asciidoc index 837a83f0aae38..14b359276356c 100644 --- a/docs/management/managing-licenses.asciidoc +++ b/docs/management/managing-licenses.asciidoc @@ -11,14 +11,16 @@ If you need more than 30 days to complete your evaluation, request an extended trial at {extendtrial}. To view the status of your license, start a trial, or install a new -license, open the main menu, then click *Stack Management > License Management*. +license, go to the *License Management* page using the navigation menu or the +<>. [discrete] === Required permissions The `manage` cluster privilege is required to access *License Management*. -To add the privilege, open the main menu, then click *Stack Management > Roles*. +To add the privilege, go to the *Roles* management page using the navigation menu or the +<>. [discrete] [[license-expiration]] diff --git a/docs/management/managing-saved-objects.asciidoc b/docs/management/managing-saved-objects.asciidoc index 231843081e7e1..1e2e5d194cd3e 100644 --- a/docs/management/managing-saved-objects.asciidoc +++ b/docs/management/managing-saved-objects.asciidoc @@ -4,7 +4,8 @@ Edit, import, export, and copy your saved objects. These objects include dashboards, visualizations, maps, {data-sources}, *Canvas* workpads, and other saved objects. -To get started, open the main menu, and then click *Stack Management > Saved Objects*. +You can find the *Saved Objects* page using the navigation menu or the +<>. [role="screenshot"] image::images/management-saved-objects.png[Saved Objects] @@ -14,7 +15,8 @@ image::images/management-saved-objects.png[Saved Objects] To access *Saved Objects*, you must have the required `Saved Objects Management` {kib} privilege. -To add the privilege, open the main menu, and then click *Stack Management > Roles*. +To add the privilege, go to the *Roles* management page using the navigation menu or the +<>. NOTE: Granting access to `Saved Objects Management` authorizes users to manage all saved objects in {kib}, including objects that are managed by diff --git a/docs/management/managing-tags.asciidoc b/docs/management/managing-tags.asciidoc index b9fbe85760786..20e5fa897c0ae 100644 --- a/docs/management/managing-tags.asciidoc +++ b/docs/management/managing-tags.asciidoc @@ -5,7 +5,8 @@ Use tags to categorize your saved objects, then filter for related objects based on shared tags. -To get started, open the main menu, and then click *Stack Management > Tags*. +To get started, go to the *Tags* management page using the navigation menu or the +<>. [role="screenshot"] image::images/tags/tag-management-section.png[Tags management] @@ -15,8 +16,8 @@ image::images/tags/tag-management-section.png[Tags management] To create tags, you must meet the minimum requirements. -* Access to *Tags* requires the `Tag Management` Kibana privilege. To add the privilege, open the main menu, -and then click *Stack Management > Roles*. +* Access to *Tags* requires the `Tag Management` Kibana privilege. To add the privilege, go to the *Roles* page using the navigation menu or the +<>. * The `read` privilege allows you to assign tags to the saved objects for which you have write permission. * The `write` privilege enables you to create, edit, and delete tags. diff --git a/docs/management/rollups/create_and_manage_rollups.asciidoc b/docs/management/rollups/create_and_manage_rollups.asciidoc index 2f9ede62c0b0f..c6e379c3d53aa 100644 --- a/docs/management/rollups/create_and_manage_rollups.asciidoc +++ b/docs/management/rollups/create_and_manage_rollups.asciidoc @@ -9,7 +9,8 @@ an index pattern, and then rolls it into a new index. Rollup indices are a good way to compactly store months or years of historical data for use in visualizations and reports. -To get started, open the main menu, then click *Stack Management > Rollup Jobs*. +You can go to the *Rollup Jobs* page using the navigation menu or the +<>. [role="screenshot"] image::images/management_rollup_list.png[List of currently active rollup jobs] @@ -23,7 +24,8 @@ detailed information. The `manage_rollup` cluster privilege is required to access *Rollup jobs*. -To add the privilege, open the main menu, then click *Stack Management > Roles*. +To add the privilege, go to the *Roles* management page using the navigation menu or the +<>. [float] [[create-and-manage-rollup-job]] @@ -142,7 +144,8 @@ rollup index, or you can remove or archive it using Your next step is to visualize your rolled up data in a vertical bar chart. Most visualizations support rolled up data, with the exception of Timelion and Vega visualizations. -. Open the main menu, then click *Stack Management > Data Views*. +. Go to the *Data Views* page using the navigation menu or the +<>. . Click *Create data view*, and select *Rollup data view* from the dropdown. @@ -153,7 +156,7 @@ The notation for a combination data view with both raw and rolled up data is `rollup_logstash,kibana_sample_data_logs`. In this data view, `rollup_logstash` matches the rollup index and `kibana_sample_data_logs` matches the raw data. -. Open the main menu, click *Dashboard*, then *Create dashboard*. +. Go to *Dashboards*, then select *Create dashboard*. . Set the <> to *Last 90 days*. diff --git a/docs/management/watcher-ui/index.asciidoc b/docs/management/watcher-ui/index.asciidoc index 2e941cb86ca0b..7f85376ad5698 100644 --- a/docs/management/watcher-ui/index.asciidoc +++ b/docs/management/watcher-ui/index.asciidoc @@ -8,8 +8,8 @@ Watches are helpful for analyzing mission-critical and business-critical streaming data. For example, you might watch application logs for performance outages or audit access logs for security threats. -To get started, open the main menu, -then click *Stack Management > Watcher*. +Go to the *Watcher* page using the navigation menu or the +<>. With this UI, you can: * <> @@ -39,7 +39,7 @@ and either of these Watcher roles: * `watcher_admin`. You can perform all Watcher actions, including create and edit watches. * `watcher_user`. You can view watches, but not create or edit them. -To manage roles, open the main menu, then click *Stack Management > Roles*, or use the {api-kibana}/group/endpoint-roles[role APIs]. +To manage roles, go to the *Roles* management page, or use the {api-kibana}/group/endpoint-roles[role APIs]. Watches are shared between all users with the same role. NOTE: If you are creating a threshold watch, you must also have the `view_index_metadata` index privilege. See diff --git a/docs/maps/asset-tracking-tutorial.asciidoc b/docs/maps/asset-tracking-tutorial.asciidoc index 32ab099575c92..b1ded453214f6 100644 --- a/docs/maps/asset-tracking-tutorial.asciidoc +++ b/docs/maps/asset-tracking-tutorial.asciidoc @@ -35,7 +35,8 @@ To get to the fun of visualizing and alerting on Portland public transport vehic [float] ==== Step 1: Set up an Elasticsearch index -. In Kibana, open the main menu, then click *Dev Tools*. +. In Kibana, go to *Developer tools* using the navigation menu or the +<>. . In *Console*, create the `tri_met_tracks` index lifecyle policy. This policy will keep the events in the hot data phase for 7 days. The data then moves to the warm phase. After 365 days in the warm phase, the data is deleted. + .ILM policy definition @@ -503,7 +504,7 @@ TIP: You may want to tweak this Data View to adjust the field names and number o [float] ==== Step 4: Explore the Portland TriMet data -. Open the main menu, and click *Discover*. +. Go to *Discover*. . Set the data view to *{ems-asset-index-name}*. . Open the <>, and set the time range to the last 15 minutes. . Expand a document and explore some of the fields that you will use later in this tutorial: `trimet.bearing`, `trimet.inCongestion`, `trimet.location`, and `trimet.vehicleID`. @@ -523,7 +524,7 @@ It's hard to get an overview of Portland vehicles by looking at individual event Create your map and set the theme for the default layer to dark mode. -. Open the main menu, and click *Maps*. +. Go to *Maps*. . Click *Create map*. . In the *Layers* list, click *Road map*, and then click *Edit layer settings*. . Open the *Tile service* dropdown, and select *Road map - dark*. diff --git a/docs/maps/geojson-upload.asciidoc b/docs/maps/geojson-upload.asciidoc index f4208663078af..8bd8a32e5d444 100644 --- a/docs/maps/geojson-upload.asciidoc +++ b/docs/maps/geojson-upload.asciidoc @@ -19,8 +19,8 @@ GeoJSON is the most commonly used and flexible option. Follow these instructions to upload a GeoJSON data file, or try the <>. -. Open the main menu, click *Maps*, and then click *Add layer*. -. Click *Uploaded GeoJSON*. +. Go to *Maps*, and select *Add layer*. +. Select *Uploaded GeoJSON*. + [role="screenshot"] image::maps/images/fu_gs_select_source_file_upload.png[] diff --git a/docs/maps/import-geospatial-data.asciidoc b/docs/maps/import-geospatial-data.asciidoc index e84ba3c3cbd27..47d05c5f1d00f 100644 --- a/docs/maps/import-geospatial-data.asciidoc +++ b/docs/maps/import-geospatial-data.asciidoc @@ -23,7 +23,7 @@ To upload GeoJSON files, shapefiles, and draw features in {kib} with *Maps*, you * The `create` and `create_index` index privileges for destination indices * To use the index in *Maps*, you must also have the `read` and `view_index_metadata` index privileges for destination indices -To upload delimited files (such as CSV, TSV, or JSON files) on the {kib} home page, you must also have: +To upload delimited files (such as CSV, TSV, or JSON files) from the **Upload file** integration, you must also have: * The `all` {kib} privilege for *Discover* * The `manage_pipeline` or `manage_ingest_pipelines` cluster privilege @@ -33,9 +33,9 @@ To upload delimited files (such as CSV, TSV, or JSON files) on the {kib} home pa [discrete] === Upload delimited files with latitude and longitude columns -On the {kib} home page, you can upload a file and import it into an {es} index with latitude and longitude columns combined into a `geo_point` field. +You can upload a file and import it into an {es} index with latitude and longitude columns combined into a `geo_point` field. -. Go to the {kib} home page and click *Upload a file*. +. Go to the *Integrations* page and select *Upload file*. . Select a file in one of the supported file formats. . Click *Import*. . Select the *Advanced* tab. diff --git a/docs/maps/maps-getting-started.asciidoc b/docs/maps/maps-getting-started.asciidoc index 39579d935275e..8dec40df5eb31 100644 --- a/docs/maps/maps-getting-started.asciidoc +++ b/docs/maps/maps-getting-started.asciidoc @@ -31,7 +31,7 @@ refer to <>. [[maps-create]] === Step 1. Create a map -. Open the main menu, and then click *Dashboard*. +. Go to *Dashboards*. . Click **Create dashboard**. . Set the time range to *Last 7 days*. . Click the **Create new Maps** icon image:maps/images/app_gis_icon.png[]. diff --git a/docs/maps/reverse-geocoding-tutorial.asciidoc b/docs/maps/reverse-geocoding-tutorial.asciidoc index 48151281fb07d..ec221dfc5fb95 100644 --- a/docs/maps/reverse-geocoding-tutorial.asciidoc +++ b/docs/maps/reverse-geocoding-tutorial.asciidoc @@ -26,12 +26,7 @@ GeoIP is a common way of transforming an IP address to a longitude and latitude. You’ll use the <> that comes with Kibana for this tutorial. Web logs sample data set has longitude and latitude. If your web log data does not contain longitude and latitude, use {ref}/geoip-processor.html[GeoIP processor] to transform an IP address into a {ref}/geo-point.html[geo_point] field. -To install web logs sample data set: - -. On the home page, click *Try sample data*. -. Expand *Other sample data sets*. -. On the *Sample web logs* card, click *Add data*. - +To install the web logs sample data set, refer to <>. [float] === Step 2: Index Combined Statistical Area (CSA) regions @@ -46,7 +41,7 @@ To get the CSA boundary data: . Go to the https://www.census.gov/geographies/mapping-files/time-series/geo/carto-boundary-file.html[Census Bureau’s website] and download the `cb_2018_us_csa_500k.zip` file. . Uncompress the zip file. -. In Kibana, open the main menu, and click *Maps*. +. In Kibana, go to *Maps*. . Click *Create map*. . Click *Add layer*. . Click *Upload file*. @@ -71,7 +66,8 @@ image::maps/images/reverse-geocoding-tutorial/csa_regions.png[Map showing metro === Step 3: Reverse geocoding To visualize CSA regions by web log traffic, the web log traffic must contain a CSA region identifier. You'll use {es} {ref}/enrich-processor.html[enrich processor] to add CSA region identifiers to the web logs sample data set. You can skip this step if your source data already contains region identifiers. -. Open the main menu, and then click *Dev Tools*. +. Go to *Developer tools* using the navigation menu or the +<>. . In *Console*, create a {ref}/geo-match-enrich-policy-type.html[geo_match enrichment policy]: + [source,js] @@ -142,7 +138,7 @@ PUT kibana_sample_data_logs/_settings } ---------------------------------- -. Open the main menu, and click *Discover*. +. Go to *Discover*. . Set the data view to *Kibana Sample Data Logs*. . Open the <>, and set the time range to the last 30 days. . Scan through the list of *Available fields* until you find the `csa.GEOID` field. You can also search for the field by name. @@ -158,7 +154,7 @@ image::maps/images/reverse-geocoding-tutorial/discover_enriched_web_log.png[View === Step 4: Visualize Combined Statistical Area (CSA) regions by web traffic Now that our web traffic contains CSA region identifiers, you'll visualize CSA regions by web traffic. -. Open the main menu, and click *Maps*. +. Go to *Maps*. . Click *Create map*. . Click *Add layer*. . Click *Choropleth*. diff --git a/docs/maps/search.asciidoc b/docs/maps/search.asciidoc index bfd293aa2352f..b094934bc6b4f 100644 --- a/docs/maps/search.asciidoc +++ b/docs/maps/search.asciidoc @@ -85,7 +85,7 @@ Create filters from your map to focus in on just the data you want. *Maps* provi To filter your dashboard by your map bounds as you pan and zoom your map: -. Open the main menu, and then click *Dashboard*. +. Go to *Dashboards*. . Select your dashboard from the list or click *Create dashboard*. . If your dashboard does not have a map, add a map panel. . Click the gear icon image:maps/images/gear_icon.png[gear icon] to open the map panel menu. diff --git a/docs/osquery/manage-integration.asciidoc b/docs/osquery/manage-integration.asciidoc index 69cf505e724a2..7d6131ce88bfa 100644 --- a/docs/osquery/manage-integration.asciidoc +++ b/docs/osquery/manage-integration.asciidoc @@ -53,7 +53,8 @@ Any changes you make to `packs` from this field are not reflected in the UI on t While this allows you to use advanced Osquery functionality like pack discovery queries, you do lose the ability to manage packs defined this way from the Osquery *Packs* page. ========================= -. From the {kib} main menu, click *Fleet*, then the *Agent policies* tab. +. Go to *Fleet* using the navigation menu or the +<>, then open the *Agent policies* tab. . Click the name of the agent policy where you want to adjust the Osquery configuration. The configuration changes you make only apply to the policy you select. @@ -136,7 +137,8 @@ and Osquerybeat in the agent directory. Refer to the {fleet-guide}/installation- To get more details in the logs, change the agent logging level to debug: -. Open the main menu, and then select **Fleet**. +. Go to **Fleet** using the navigation menu or the +<>. . Select the agent that you want to debug. diff --git a/docs/osquery/osquery.asciidoc b/docs/osquery/osquery.asciidoc index 4f0859ac21b19..ebfd58c973370 100644 --- a/docs/osquery/osquery.asciidoc +++ b/docs/osquery/osquery.asciidoc @@ -36,7 +36,8 @@ view live and scheduled query results, but you cannot run live queries or edit. To inspect hosts, run a query against one or more agents or policies, then view the results. -. Open the main menu, and then click *Osquery*. +. Go to *Osquery* using the navigation menu or the +<>. . In the *Live queries* view, click **New live query**. . Choose to run a single query or a query pack. . Select one or more agents or groups to query. Start typing in the search field, diff --git a/docs/setup/configuring-reporting.asciidoc b/docs/setup/configuring-reporting.asciidoc index 4213cf38b6398..61ef028d1504f 100644 --- a/docs/setup/configuring-reporting.asciidoc +++ b/docs/setup/configuring-reporting.asciidoc @@ -48,9 +48,10 @@ NOTE: If you use the default settings, you can still create a custom role that g . Create the reporting role. -.. Open the main menu, then click *Stack Management*. +.. Go to the *Roles* management page using the navigation menu or the +<>. -.. Click *Roles > Create role*. +.. Click *Create role*. . Specify the role settings. @@ -86,9 +87,10 @@ NOTE: If the *Reporting* options for application features are unavailable, and t . Assign the reporting role to a user. -.. Open the main menu, then click *Stack Management*. +.. Go to the *Users* management page using the navigation menu or the +<>. -.. Click *Users*, then click the user you want to assign the reporting role to. +.. Select the user you want to assign the reporting role to. .. From the *Roles* dropdown, select *custom_reporting_user*. diff --git a/docs/setup/connect-to-elasticsearch.asciidoc b/docs/setup/connect-to-elasticsearch.asciidoc index f5c8ce3e732f2..f6e6c71e25fbd 100644 --- a/docs/setup/connect-to-elasticsearch.asciidoc +++ b/docs/setup/connect-to-elasticsearch.asciidoc @@ -6,8 +6,7 @@ which are pre-packaged assets that are available for a wide array of popular services and platforms. With integrations, you can add monitoring for logs and metrics, protect systems from security threats, and more. -All integrations are available in a single view, and -{kib} guides you there from the *Welcome* screen, home page, and main menu. +All integrations are available in a single view on the **Integrations** page. [role="screenshot"] image::images/add-integration.png[Integrations page from which you can choose integrations to start collecting and analyzing data] diff --git a/docs/upgrade-notes.asciidoc b/docs/upgrade-notes.asciidoc index 43fb4d5ac66e5..84a27e1194a16 100644 --- a/docs/upgrade-notes.asciidoc +++ b/docs/upgrade-notes.asciidoc @@ -997,7 +997,7 @@ In 8.1.0 and later, {kib} uses the field caps API, by default, to determine the `visualization:visualize:legacyPieChartsLibrary` has been removed from *Advanced Settings*. The setting allowed you to create aggregation-based pie chart visualizations using the legacy charts library. For more information, refer to {kibana-pull}146990[#146990]. *Impact* + -In 7.14.0 and later, the new aggregation-based pie chart visualization is available by default. For more information, check link:https://www.elastic.co/guide/en/kibana/current/add-aggregation-based-visualization-panels.html[Aggregation-based]. +In 7.14.0 and later, the new aggregation-based pie chart visualization is available by default. For more information, check <>. ==== [discrete] @@ -1672,6 +1672,27 @@ When you create *Lens* visualization, the default for the *Legend width* is now [float] ==== Elastic Observability solution +[discrete] +[[deprecation-192003]] +.Deprecated the Observability AI Assistant specific advanced setting `observability:aiAssistantLogsIndexPattern`. (8.16) +[%collapsible] +==== +*Details* + +The Observability AI Assistant specific advanced setting for Logs index patterns `observability:aiAssistantLogsIndexPattern` is deprecated and no longer used. The AI Assistant will now use the existing **Log sources** setting `observability:logSources` instead. For more information, refer to ({kibana-pull}192003[#192003]). +==== + +[discrete] +[[deprecation-194519]] +.The Logs Stream was hidden by default in favor of the Logs Explorer app. (8.16) +[%collapsible] +==== +*Details* + +You can find the Logs Explorer app in the navigation menu under Logs > Explorer, or as a separate tab in Discover. For more information, refer to ({kibana-pull}194519[#194519]). + +*Impact* + +You can still show the Logs Stream app again by navigating to Stack Management > Advanced Settings and by enabling the `observability:enableLogsStream` setting. +==== + [discrete] [[deprecation-120689]] diff --git a/docs/user/canvas.asciidoc b/docs/user/canvas.asciidoc index e7b4fdaf20921..21803b90034ad 100644 --- a/docs/user/canvas.asciidoc +++ b/docs/user/canvas.asciidoc @@ -43,7 +43,8 @@ To create workpads, you must meet the minimum requirements. * Make sure you have sufficient privileges to create and save workpads. When the read-only indicator appears, you have insufficient privileges, and the options to create and save workpads are unavailable. For more information, refer to <>. -To open *Canvas*, open the main menu, then click *Canvas*. +You can open *Canvas* using the navigation menu or the +<>. [float] [[start-with-a-blank-workpad]] @@ -175,7 +176,7 @@ To share workpads with a larger audience, click *Share* in the toolbar. For deta [[export-single-workpad]] == Export workpads -Want to export multiple workpads? Go to the *Canvas* home page, select the workpads you want to export, then click *Export*. +Want to export multiple workpads? Go to the *Canvas* page, select the workpads you want to export, then click *Export*. -- @@ -185,8 +186,6 @@ include::{kibana-root}/docs/canvas/canvas-present-workpad.asciidoc[] include::{kibana-root}/docs/canvas/canvas-tutorial.asciidoc[] -include::{kibana-root}/docs/canvas/canvas-expression-lifecycle.asciidoc[] - include::{kibana-root}/docs/canvas/canvas-function-reference.asciidoc[] include::{kibana-root}/docs/canvas/canvas-tinymath-functions.asciidoc[] diff --git a/docs/user/dashboard/aggregation-based.asciidoc b/docs/user/dashboard/aggregation-based.asciidoc index 9098ea6265291..f27d60928e6fe 100644 --- a/docs/user/dashboard/aggregation-based.asciidoc +++ b/docs/user/dashboard/aggregation-based.asciidoc @@ -140,13 +140,9 @@ a bar chart that displays the top five log traffic sources for every three hours Add the sample web logs data that you'll use to create the bar chart, then create the dashboard. -. On the home page, click *Try sample data*. +. <>. -. Click *Other sample data sets*. - -. On the *Sample web logs* card, click *Add data*. - -. Open the main menu, then click *Dashboard*. +. Go to *Dashboards*. . On the *Dashboards* page, click *Create dashboard*. diff --git a/docs/user/dashboard/create-dashboards.asciidoc b/docs/user/dashboard/create-dashboards.asciidoc index b07b4e88a684a..8b0d5e5f524fd 100644 --- a/docs/user/dashboard/create-dashboards.asciidoc +++ b/docs/user/dashboard/create-dashboards.asciidoc @@ -83,6 +83,7 @@ image::https://images.contentstack.io/v3/assets/bltefdd0b53724fa2ce/bltf75cdb828 [[save-dashboards]] . **Save** the dashboard. You can then leave the **Edit** mode and *Switch to view mode*. +NOTE: Managed dashboards can't be edited directly, but you can <> them and edit these duplicates. [[reset-the-dashboard]] === Reset dashboard changes @@ -155,6 +156,24 @@ Copy panels from one dashboard to another dashboard. [role="screenshot"] image:https://images.contentstack.io/v3/assets/bltefdd0b53724fa2ce/blt64206db263cf5514/66f49286833cffb09bebd18d/copy-to-dashboard-8.15.0.gif[Copy a panel to another dashboard, width 30%] +[[duplicate-dashboards]] +== Duplicate dashboards + +. Open the dashboard you want to duplicate. + +. In *View* mode, click *Duplicate* in the toolbar. + +. In the *Duplicate dashboard* window, enter a title and optional description and tags. + +. Click *Save*. + +You will be redirected to the duplicated dashboard. + +To duplicate a managed dashboard, follow the instructions above or click the *Managed* badge in the toolbar. Then click *Duplicate* in the dialogue that appears. + +[role="screenshot"] +image::images/managed-dashboard-popover-8.16.0.png[Managed badge dialog with Duplicate button, width=40%] + == Import dashboards You can import dashboards from the **Saved Objects** page under **Stack Management**. Refer to <>. diff --git a/docs/user/dashboard/create-visualizations.asciidoc b/docs/user/dashboard/create-visualizations.asciidoc index 5115677a4f51a..815f46d5711eb 100644 --- a/docs/user/dashboard/create-visualizations.asciidoc +++ b/docs/user/dashboard/create-visualizations.asciidoc @@ -213,7 +213,7 @@ You can then **Save** and add it to an existing or a new dashboard using the sav . From your dashboard, select **Add panel**. . Choose **ES|QL** under **Visualizations**. An ES|QL editor appears and lets you configure your query and its associated visualization. The **Suggestions** panel can help you find alternative ways to configure the visualization. + -TIP: Check the link:esql-language.html[ES|QL reference] to get familiar with the syntax and optimize your query. +TIP: Check the link:{ref}/esql-language.html[ES|QL reference] to get familiar with the syntax and optimize your query. . When editing your query or its configuration, run the query to update the preview of the visualization. + image:https://images.contentstack.io/v3/assets/bltefdd0b53724fa2ce/blt69dcceb4f1e12bc1/66c752d6aff77d384dc44209/edit-esql-visualization.gif[Previewing an ESQL visualization] @@ -232,7 +232,7 @@ The Maps editor has extensive documentation. For your reading comfort, we have m . From your dashboard, select **Add panel**. . Choose **Field statistics** under **Visualizations**. An ES|QL editor appears and lets you configure your query with the fields and information that you want to show. + -TIP: Check the link:esql-language.html[ES|QL reference] to get familiar with the syntax and optimize your query. +TIP: Check the link:{ref}/esql-language.html[ES|QL reference] to get familiar with the syntax and optimize your query. . When editing your query or its configuration, run the query to update the preview of the visualization. + image:https://images.contentstack.io/v3/assets/bltefdd0b53724fa2ce/blte2b1371159f5b9ff/66fc6ca13804eb2532b20727/field-statistics-preview-8.16.0.gif[Editing a field statistics dashboard panel and running the query to update the preview] @@ -289,7 +289,8 @@ To personalize your dashboards, add your own logos and graphics with the *Image* [role="screenshot"] image::images/dashboard_addImageEditor_8.7.0.png[Add image editor] -To manage your uploaded image files, open the main menu, then click *Stack Management > Kibana > Files*. +To manage your uploaded image files, go to the *Files* management page using the navigation menu or the +<>. [WARNING] diff --git a/docs/user/dashboard/dashboard.asciidoc b/docs/user/dashboard/dashboard.asciidoc index 5ca198c9831af..2bc6738516f15 100644 --- a/docs/user/dashboard/dashboard.asciidoc +++ b/docs/user/dashboard/dashboard.asciidoc @@ -17,7 +17,7 @@ There are several <> in {kib} that let you create // add link to sharing section At any time, you can <> you've created with your team, in {kib} or outside. -Some dashboards are created and managed by the system, and are identified as `managed` in your list of of dashboards. This generally happens when you set up an integration to add data. You can't edit managed dashboards directly, but you can duplicate them and edit these duplicates. +Some dashboards are created and managed by the system, and are identified as `managed` in your list of dashboards. This generally happens when you set up an integration to add data. You can't edit managed dashboards directly, but you can <> them and edit these duplicates. -- diff --git a/docs/user/dashboard/drilldowns.asciidoc b/docs/user/dashboard/drilldowns.asciidoc index 6b3a6d80ecdda..cb568d97e69ee 100644 --- a/docs/user/dashboard/drilldowns.asciidoc +++ b/docs/user/dashboard/drilldowns.asciidoc @@ -1,6 +1,6 @@ [role="xpack"] [[drilldowns]] -=== Drilldowns +=== Add drilldowns Panels have built-in interactive capabilities that apply filters to the dashboard data. For example, when you drag a time range or click a pie slice, a filter for the time range or pie slice is applied. Drilldowns let you customize the interactive behavior while keeping the context of the interaction. diff --git a/docs/user/dashboard/images/managed-dashboard-popover-8.16.0.png b/docs/user/dashboard/images/managed-dashboard-popover-8.16.0.png new file mode 100644 index 0000000000000..b1cd0562a42c7 Binary files /dev/null and b/docs/user/dashboard/images/managed-dashboard-popover-8.16.0.png differ diff --git a/docs/user/dashboard/lens-advanced.asciidoc b/docs/user/dashboard/lens-advanced.asciidoc index 88dbe958b146a..5107172c40b31 100644 --- a/docs/user/dashboard/lens-advanced.asciidoc +++ b/docs/user/dashboard/lens-advanced.asciidoc @@ -16,15 +16,9 @@ Before you begin, you should be familiar with the <>. Add the sample eCommerce data, and create and set up the dashboard. -. On the home page, click *Try sample data*. +. <>. -. Expand *Other sample data sets*. - -. On the *Sample eCommerce orders* card, click *Add data*. - -Create the dashboard where you'll display the visualization panels. - -. Open the main menu, then click *Dashboards*. +. Go to *Dashboards*. . On the *Dashboards* page, click *Create dashboard*. diff --git a/docs/user/dashboard/timelion.asciidoc b/docs/user/dashboard/timelion.asciidoc index 27222e6a40e84..000cad3bdbc1d 100644 --- a/docs/user/dashboard/timelion.asciidoc +++ b/docs/user/dashboard/timelion.asciidoc @@ -88,7 +88,7 @@ Set up Metricbeat, then create the dashboard. . To set up Metricbeat, go to {metricbeat-ref}/metricbeat-installation-configuration.html[Metricbeat quick start: installation and configuration] -. From {kib}, open the main menu, then click *Dashboard*. +. Go to *Dashboards*. . On the *Dashboards* page, click *Create dashboard*. diff --git a/docs/user/dashboard/tutorial-create-a-dashboard-of-lens-panels.asciidoc b/docs/user/dashboard/tutorial-create-a-dashboard-of-lens-panels.asciidoc index db0717522d928..4d299ba951296 100644 --- a/docs/user/dashboard/tutorial-create-a-dashboard-of-lens-panels.asciidoc +++ b/docs/user/dashboard/tutorial-create-a-dashboard-of-lens-panels.asciidoc @@ -18,15 +18,9 @@ Before you begin, you should be familiar with the <>. Add the sample web logs data, and create and set up the dashboard. -. On the home page, click *Try sample data*. +. <>. -. Expand *Other sample data sets*. - -. On the *Sample web logs* card, click *Add data*. - -Create the dashboard where you'll display the visualization panels. - -. Open the main menu, then click *Dashboards*. +. Go to *Dashboards*. . Click *Create dashboard*. diff --git a/docs/user/dashboard/tutorials.asciidoc b/docs/user/dashboard/tutorials.asciidoc index 6c25fd221fe2a..e7752279ba476 100644 --- a/docs/user/dashboard/tutorials.asciidoc +++ b/docs/user/dashboard/tutorials.asciidoc @@ -2,7 +2,7 @@ Learn more about building dashboards and creating visualizations with the following tutorials. -These tutorials use sample data available in {kib} as a way to get started more easily, but you can apply and adapt these instructions to your own data as well. +These tutorials use <> available in {kib} as a way to get started more easily, but you can apply and adapt these instructions to your own data as well. include::tutorial-create-a-dashboard-of-lens-panels.asciidoc[] diff --git a/docs/user/dashboard/vega.asciidoc b/docs/user/dashboard/vega.asciidoc index cbb1f5dbf8cda..4ae9c994a54bb 100644 --- a/docs/user/dashboard/vega.asciidoc +++ b/docs/user/dashboard/vega.asciidoc @@ -40,13 +40,9 @@ As you edit the specs, work in small steps, and frequently save your work. Small Before starting, add the eCommerce sample data that you'll use in your spec, then create the dashboard. -. On the home page, click *Try sample data*. +. <>. -. Click *Other sample data sets*. - -. On the *Sample eCommerce orders* card, click *Add data*. - -. Open the main menu, then click *Dashboard*. +. Go to *Dashboards*. . On the *Dashboards* page, click *Create dashboard*. @@ -90,7 +86,8 @@ To check your work, open and use the <> on a separate . Open {kib} on a new tab. -. Open the main menu, then click *Dev Tools*. +. Go to the *Developer tools* page using the navigation menu or the +<>. . On the *Console* editor, enter the aggregation, then click *Click to send request*: diff --git a/docs/user/discover.asciidoc b/docs/user/discover.asciidoc index ddd06b06c9cd8..7cab19889f278 100644 --- a/docs/user/discover.asciidoc +++ b/docs/user/discover.asciidoc @@ -8,6 +8,7 @@ What pages on your website contain a specific word or phrase? What events were logged most recently? What processes take longer than 500 milliseconds to respond? +[[save-your-search]] With *Discover*, you can quickly search and filter your data, get information about the structure of the fields, and display your findings in a visualization. You can also customize and save your searches and place them on a dashboard. @@ -16,331 +17,10 @@ You can also customize and save your searches and place them on a dashboard. image::images/hello-field.png[A view of the Discover app] -[float] -=== Explore and query your data - -This tutorial shows you how to use *Discover* to search large amounts of -data and understand what’s going on at any given time. - -You’ll learn to: - -- **Select** data for your exploration, set a time range for that data, -search it with the {kib} Query Language, and filter the results. -- **Explore** the details of your data, view individual documents, and create tables -that summarize the contents of the data. -- **Present** your findings in a visualization. - -At the end of this tutorial, you’ll be ready to start exploring with your own -data in *Discover*. - -*Prerequisites:* - -- If you don’t already have {kib}, set it up with https://www.elastic.co/cloud/elasticsearch-service/signup?baymax=docs-body&elektra=docs[our free trial]. -- You must have data in {es}. This tutorial uses the -<>, but you can use your own data. -- You should have an understanding of {ref}/documents-indices.html[{es} documents and indices] -and <>. - - -[float] -[[find-the-data-you-want-to-use]] -=== Find your data - -Tell {kib} where to find the data you want to explore, and then specify the time range in which to view that data. - -. Open the main menu, and select **Discover**. - -. Select the data you want to work with. -+ -{kib} uses a <> to tell it where to find -your {es} data. -To view the ecommerce sample data, open the {data-source} menu, and then select **Kibana Sample Data Ecommerce**. -+ -[role="screenshot"] -image::images/discover-data-view.png[How to set the {data-source} in Discover, width="40%"] - -+ -To create a data view for your own data, -click *Create a data view*. -For details, refer to <> - -. Adjust the <> to view data for the *Last 7 days*. -+ -The range selection is based on the default time field in your data. -If you are using the sample data, this value was set when you added the data. -If you are using your own data, and it does not have a time field, the range selection is not available. - -. To view the count of documents for a given time in the specified range, -click and drag the mouse over the chart. - -[float] -[[explore-fields-in-your-data]] -=== Explore the fields in your data - -**Discover** includes a table -that shows all the documents that match your search. -By default, the document table includes a column for the time field and a column that lists all other fields in the document. -You’ll modify the document table to display your fields of interest. - -. In the sidebar, enter `ma` in the search field to find the `manufacturer` field. -+ -[role="screenshot"] -image:images/discover-sidebar-available-fields.png[Fields list that displays the top five search results, width=50%] -+ -NOTE: You can use wildcards in field searches. For example, `goe*dest` finds `geo.dest` and `geo.src.dest`. - -. In the *Available fields* list, click `manufacturer` to view its most popular values. -+ -**Discover** shows the top 10 values and the number of records used to calculate those values. - -. Click image:images/add-icon.png[Add icon] to toggle the field into the document table. -You can also drag the field from the *Available fields* list into the document table. -+ -[role="screenshot"] -image::images/discover-add-icon.png[How to add a field as a column in the table, width="50%"] - -. Find the `customer_first_name` and `customer_last_name` fields and add -them to the document table. Your table should look similar to this: -+ -[role="screenshot"] -image:images/document-table.png[Document table with fields for manufacturer, customer_first_name, and customer_last_name] - - -. Optionally try out these actions: -+ -* To rearrange the table columns, click a -column header, and then select *Move left* or *Move right*. -+ -* To copy the name or values in a column to the clipboard, click the column header and select the desired **Copy** option. -+ -* To view more of the document table, -click -image:images/chart-icon.png[icon button for opening Show/Hide chart menu, width=24px] -to open the *Chart options* menu, -and then select *Hide chart*. -+ -* For keyboard shortcuts on the document table, click -image:images/keyboard-shortcut-icon.png[icon button for opening list of keyboard shortcuts, width=24px]. -+ -* To set the row height to one or more lines, or automatically -adjust the height to fit the contents, click -image:images/row-height-icon.png[icon to open the Row height pop-up, width=24px]. -+ -* To toggle the table in and out of fullscreen mode, click the fullscreen icon -image:images/fullscreen-icon.png[icon to display the document table in fullscreen mode]. - - - - - - -[float] -[[add-field-in-discover]] -=== Add a field to your {data-source} - -What happens if you forgot to define an important value as a separate field? Or, what if you -want to combine two fields and treat them as one? This is where {ref}/runtime.html[runtime fields] come into play. -You can add a runtime field to your {data-source} from inside of **Discover**, -and then use that field for analysis and visualizations, -the same way you do with other fields. - -. In the sidebar, click *Add a field*. - -. In the *Create field* form, enter `hello` for the name. - -. Turn on *Set value*. - -. Define the script using the Painless scripting language. Runtime fields require an `emit()`. -+ -```ts -emit("Hello World!"); -``` - -. Click *Save*. - -. In the sidebar, search for the *hello* field, and then add it to the document table. -+ -[role="screenshot"] -image:images/hello-field.png[hello field in the document tables] - -. Create a second field named `customer` that combines customer last name and first initial. -+ -```ts -String str = doc['customer_first_name.keyword'].value; -char ch1 = str.charAt(0); -emit(doc['customer_last_name.keyword'].value + ", " + ch1); -``` -. Remove `customer_first_name` and `customer_last_name` from the document table, and then add `customer`. -+ -[role="screenshot"] -image:images/customer.png[Customer last name, first initial in the document table] -+ -For more information on adding fields and Painless scripting language examples, -refer to <>. - - -[float] -[[search-in-discover]] -=== Search your data - -One of the unique capabilities of **Discover** is the ability to combine -free text search with filtering based on structured data. -To search all fields, enter a simple string in the query bar. - -[role="screenshot"] -image:images/discover-search-field.png[Search field in Discover] - - -To search particular fields and -build more complex queries, use the <>. -As you type, KQL prompts you with the fields you can search and the operators -you can use to build a structured query. - -Search the ecommerce data for documents where the country matches US: - -. Enter `g`, and then select *geoip.country_iso_code*. -. Select *:* for equals some value and *US*, and then click the refresh button or press the Enter key. -. For a more complex search, try: -+ -```ts -geoip.country_iso_code : US and products.taxless_price >= 75 -``` - -[float] -[[filter-in-discover]] -=== Filter your data - -Whereas the query defines the set of documents you are interested in, -filters enable you to zero in on subsets of those documents. -You can filter results to include or exclude specific fields, filter for a value in a range, -and more. - -Exclude documents where day of week is not Wednesday: - -. Click image:images/add-icon.png[Add icon] next to the query bar. -. In the *Add filter* pop-up, set the field to *day_of_week*, the operator to *is not*, -and the value to *Wednesday*. -+ -[role="screenshot"] -image:images/discover-add-filter.png[Add filter dialog in Discover] - -. Click **Add filter**. -. Continue your exploration by adding more filters. -. To remove a filter, -click the close icon (x) next to its name in the filter bar. - -[float] -[[look-inside-a-document]] -=== Look inside a document - -Dive into an individual document to view its fields and the documents -that occurred before and after it. - -. In the document table, click the expand icon -image:images/expand-icon-2.png[double arrow icon to open a flyout with the document details] -to show document details. -+ -[role="screenshot"] -image:images/document-table-expanded.png[Table view with document expanded] - -. Scan through the fields and their values. If you find a field of interest, -hover your mouse over the *Actions* column for filters and other options. - -. To create a view of the document that you can bookmark and share, click **Single document**. - -. To view documents that occurred before or after the event you are looking at, click -**Surrounding documents**. - - - -[float] -[[save-your-search]] -=== Save your search for later use - -Save your search so you can use it later, generate a CSV report, or use it to create visualizations, dashboards, and Canvas workpads. -Saving a search saves the query text, filters, -and current view of *Discover*, including the columns selected in -the document table, the sort order, and the {data-source}. - -. In the toolbar, click **Save**. - -. Give your search a title. - -. Optionally store <> and the time range with the search. - -. Click **Save**. - -[float] -=== Visualize your findings -If a field can be {ref}/search-aggregations.html[aggregated], you can quickly -visualize it from **Discover**. - -. In the sidebar, find and then click `day_of_week`. -+ -[role="screenshot"] -image:images/discover-day-of-week.png[Top values for the day_of_week field, plus Visualize button, width=50%] - - -. In the popup, click **Visualize**. -+ -{kib} creates a visualization best suited for this field. - -. From the *Available fields* list, drag and drop `manufacturer.keyword` onto the workspace. -+ -[role="screenshot"] -image:images/discover-from-visualize.png[Visualization that opens from Discover based on your data] - -. Save your visualization for use on a dashboard. -+ -For geo point fields (image:images/geoip-icon.png[Geo point field icon, width=20px]), -if you click **Visualize**, -your data appears in a map. -+ -[role="screenshot"] -image:images/discover-maps.png[Map containing documents] - -[float] -[[share-your-findings]] -=== Share your findings - -To share your findings with a larger audience, click *Share* in the *Discover* toolbar. -For detailed information about the sharing options, refer to <>. - -[float] -[[alert-from-Discover]] -=== Generate alerts - -From *Discover*, you can create a rule to periodically -check when data goes above or below a certain threshold within a given time interval. - -. Ensure that your data view, -query, and filters fetch the data for which you want an alert. -. In the toolbar, click *Alerts > Create search threshold rule*. -+ -The *Create rule* form is pre-filled with the latest query sent to {es}. -. <> and <>. - -. Click *Save*. - -For more about this and other rules provided in {alert-features}, go to <>. - - -[float] -=== What’s next? - -* <>. - -* <>. - -* <> to better meet your needs. - -[float] -=== Troubleshooting - -* {blog-ref}troubleshooting-guide-common-issues-kibana-discover-load[Learn how to resolve common issues with Discover.] +-- +include::{kibana-root}/docs/discover/get-started-discover.asciidoc[] --- include::{kibana-root}/docs/discover/document-explorer.asciidoc[] include::{kibana-root}/docs/discover/search-for-relevance.asciidoc[] diff --git a/docs/user/graph/getting-started.asciidoc b/docs/user/graph/getting-started.asciidoc index 03274bec76714..40d23ba249fd0 100644 --- a/docs/user/graph/getting-started.asciidoc +++ b/docs/user/graph/getting-started.asciidoc @@ -9,7 +9,7 @@ You must index data into {es} before you can create a graph. [[exploring-connections]] === Graph a data connection -. Open the main menu, then click *Graph*. +. Go to *Graph*. + If this is your first graph, follow the prompts to create it. For subsequent graphs, click *New*. diff --git a/docs/user/graph/index.asciidoc b/docs/user/graph/index.asciidoc index 5e7b689b8d8f1..d6d30dfa80682 100644 --- a/docs/user/graph/index.asciidoc +++ b/docs/user/graph/index.asciidoc @@ -71,7 +71,7 @@ affecting the cluster. Use *Graph* to reveal the relationships in your data. -. Open the main menu, and then click *Graph*. +. Go to *Graph*. + If you're new to {kib}, and don't yet have any data, follow the link to add sample data. This example uses the {kib} sample web logs data set. diff --git a/docs/user/images/dashboard-star.png b/docs/user/images/dashboard-star.png new file mode 100644 index 0000000000000..25219d8866c0b Binary files /dev/null and b/docs/user/images/dashboard-star.png differ diff --git a/docs/user/images/dashboard-usage.png b/docs/user/images/dashboard-usage.png new file mode 100644 index 0000000000000..e18843511e21a Binary files /dev/null and b/docs/user/images/dashboard-usage.png differ diff --git a/docs/user/images/discover-log-level.png b/docs/user/images/discover-log-level.png new file mode 100644 index 0000000000000..a6de92c0ae020 Binary files /dev/null and b/docs/user/images/discover-log-level.png differ diff --git a/docs/user/images/esql-autocomplete-suggestions.png b/docs/user/images/esql-autocomplete-suggestions.png new file mode 100644 index 0000000000000..bd78201b0d121 Binary files /dev/null and b/docs/user/images/esql-autocomplete-suggestions.png differ diff --git a/docs/user/images/esql-suggestions.png b/docs/user/images/esql-suggestions.png new file mode 100644 index 0000000000000..234f0339003a1 Binary files /dev/null and b/docs/user/images/esql-suggestions.png differ diff --git a/docs/user/images/hello-field.png b/docs/user/images/hello-field.png new file mode 100644 index 0000000000000..8aee22bf2a847 Binary files /dev/null and b/docs/user/images/hello-field.png differ diff --git a/docs/user/images/ip-location-processor.png b/docs/user/images/ip-location-processor.png new file mode 100644 index 0000000000000..b1de4a540f52d Binary files /dev/null and b/docs/user/images/ip-location-processor.png differ diff --git a/docs/user/images/metric-customization.png b/docs/user/images/metric-customization.png new file mode 100644 index 0000000000000..238df1aee82ac Binary files /dev/null and b/docs/user/images/metric-customization.png differ diff --git a/docs/user/images/monaco-console.png b/docs/user/images/monaco-console.png new file mode 100644 index 0000000000000..3bdd4be4eb498 Binary files /dev/null and b/docs/user/images/monaco-console.png differ diff --git a/docs/user/images/solution-view-obs.png b/docs/user/images/solution-view-obs.png new file mode 100644 index 0000000000000..4ae5942dbae37 Binary files /dev/null and b/docs/user/images/solution-view-obs.png differ diff --git a/docs/user/images/space-settings.png b/docs/user/images/space-settings.png new file mode 100644 index 0000000000000..a3a38c1ca88c7 Binary files /dev/null and b/docs/user/images/space-settings.png differ diff --git a/docs/user/images/table-coloring.png b/docs/user/images/table-coloring.png new file mode 100644 index 0000000000000..6c96daf381164 Binary files /dev/null and b/docs/user/images/table-coloring.png differ diff --git a/docs/user/introduction.asciidoc b/docs/user/introduction.asciidoc index 48c9dfd91c9c6..cd04da190eac8 100644 --- a/docs/user/introduction.asciidoc +++ b/docs/user/introduction.asciidoc @@ -71,8 +71,7 @@ image::images/visualization-journey.png[User data analysis journey] | *1* | *Add data.* The best way to add data to the Elastic Stack is to use one of our many <>. -Alternatively, you can add a sample data set or upload a file. All three options are available -on the home page. +On the **Integrations** page, you can also find options to add sample data sets or to upload a file. | *2* | *Explore.* With <>, you can search your data for hidden diff --git a/docs/user/ml/index.asciidoc b/docs/user/ml/index.asciidoc index e84ca23dbc84d..91227055fa8a7 100644 --- a/docs/user/ml/index.asciidoc +++ b/docs/user/ml/index.asciidoc @@ -51,7 +51,8 @@ information, refer to {ml-docs}/ml-limitations.html[{ml-cap}]. preview::[] -You can find the data drift view in **{ml-app}** > *{data-viz}* in {kib}. +You can find the data drift view in **{ml-app}** > *{data-viz}* in {kib} or by using +the <>. The data drift view shows you the differences in each field for two different time ranges in a given {data-source}. The view helps you to visualize the changes in your data over time and enables you to understand its behavior @@ -167,7 +168,7 @@ It makes it easy to find and investigate causes of unusual spikes or drops by us Examine the histogram chart of the log rates for a given {data-source}, and find the reason behind a particular change possibly in millions of log events across multiple fields and values. You can find log rate analysis embedded in multiple applications. -In {kib}, you can find it under **{ml-app}** > **AIOps Labs** where you can select the {data-source} or saved search that you want to analyze. +In {kib}, you can find it under **{ml-app}** > **AIOps Labs** or by using the <>. Here, you can select the {data-source} or saved search that you want to analyze. [role="screenshot"] image::user/ml/images/ml-log-rate-analysis-before.png[Log event histogram chart] @@ -201,8 +202,8 @@ displays them together with a chart that shows the distribution of each category and an example document that matches the category. //end::log-pattern-analysis-intro[] -You can find log pattern analysis under **{ml-app}** > **AIOps Labs** where you -can select the {data-source} or saved search that you want to analyze, or in +You can find log pattern analysis under **{ml-app}** > **AIOps Labs** or by using the <>. +Here, you can select the {data-source} or saved search that you want to analyze, or in **Discover** as an available action for any text field. [role="screenshot"] @@ -226,8 +227,8 @@ Change point detection uses the to detect distribution changes, trend changes, and other statistically significant change points in a metric of your time series data. -You can find change point detection under **{ml-app}** > **AIOps Labs** where -you can select the {data-source} or saved search that you want to analyze. +You can find change point detection under **{ml-app}** > **AIOps Labs** or by using the <>. +Here, you can select the {data-source} or saved search that you want to analyze. [role="screenshot"] image::user/ml/images/ml-change-point-detection.png[Change point detection UI] diff --git a/docs/user/monitoring/monitoring-elastic-agent.asciidoc b/docs/user/monitoring/monitoring-elastic-agent.asciidoc index 33899e69ba269..2be91f08cdc0d 100644 --- a/docs/user/monitoring/monitoring-elastic-agent.asciidoc +++ b/docs/user/monitoring/monitoring-elastic-agent.asciidoc @@ -27,7 +27,7 @@ in the {ref}/monitoring-production.html[{es} monitoring documentation]. To collect {kib} monitoring data, add a {kib} integration to an {agent} and deploy it to the host where {kib} is running. -. Go to the {kib} home page and click **Add integrations**. +. Go to the **Integrations** page. + NOTE: If you're using a monitoring cluster, use the {kib} instance connected to the monitoring cluster. diff --git a/docs/user/monitoring/monitoring-kibana.asciidoc b/docs/user/monitoring/monitoring-kibana.asciidoc index 7f060d7aab738..65c5bdf868b9b 100644 --- a/docs/user/monitoring/monitoring-kibana.asciidoc +++ b/docs/user/monitoring/monitoring-kibana.asciidoc @@ -49,7 +49,8 @@ By default, if you are running {kib} locally, go to `http://localhost:5601/`. If {security-features} are enabled, log in. -- -... Open the main menu, then click *Stack Monitoring*. If data collection is +... Go to the *Stack Monitoring* page using the +<>. If data collection is disabled, you are prompted to turn it on. ** From the Console or command line, set `xpack.monitoring.collection.enabled` diff --git a/docs/user/monitoring/viewing-metrics.asciidoc b/docs/user/monitoring/viewing-metrics.asciidoc index 0aaf7ad6bd332..342a8da76cc35 100644 --- a/docs/user/monitoring/viewing-metrics.asciidoc +++ b/docs/user/monitoring/viewing-metrics.asciidoc @@ -86,7 +86,8 @@ By default, if you are running {kib} locally, go to `http://localhost:5601/`. If the Elastic {security-features} are enabled, log in. -- -. Open the main menu, then click *Stack Monitoring*. +. Go to the *Stack Monitoring* page using the +<>. + -- If data collection is disabled, you are prompted to turn on data collection. diff --git a/docs/user/reporting/automating-report-generation.asciidoc b/docs/user/reporting/automating-report-generation.asciidoc index f2102e7c0e2db..9587674b59e61 100644 --- a/docs/user/reporting/automating-report-generation.asciidoc +++ b/docs/user/reporting/automating-report-generation.asciidoc @@ -12,7 +12,7 @@ Create the POST URL that triggers a report to generate PDF and CSV reports. To create the POST URL for PDF reports: -. Open the main menu, then click *Dashboard*, *Visualize Library*, or *Canvas*. +. Go to *Dashboards*, *Visualize Library*, or *Canvas*. . Open the dashboard, visualization, or **Canvas** workpad you want to view as a report. @@ -24,7 +24,7 @@ To create the POST URL for PDF reports: To create the POST URL for CSV reports: -. Open the main menu, then click *Discover*. +. Go to *Discover*. . Open the saved search you want to share. diff --git a/docs/user/reporting/index.asciidoc b/docs/user/reporting/index.asciidoc index 5f09ed6907c1f..ed4fef61026f5 100644 --- a/docs/user/reporting/index.asciidoc +++ b/docs/user/reporting/index.asciidoc @@ -54,7 +54,7 @@ In the following dashboard, the shareable container is highlighted: [role="screenshot"] image::user/reporting/images/shareable-container.png["Shareable Container"] -. Open the main menu, then open the saved search, dashboard, visualization, or workpad you want to share. +. Open the saved search, dashboard, visualization, or workpad you want to share. . From the toolbar, click *Share*, then select the report option. @@ -94,7 +94,7 @@ include::reporting-pdf-limitations.asciidoc[] Create and share JSON files for workpads. -. Open the main menu, then click *Canvas*. +. Go to *Canvas*. . Open the workpad you want to share. @@ -118,7 +118,7 @@ change {kib} sizing, {ess-console}[edit the deployment]. beta[] Create and securely share static *Canvas* workpads on a website. To customize the behavior of the workpad on your website, you can choose to autoplay the pages or hide the workpad toolbar. -. Open the main menu, then click *Canvas*. +. Go to *Canvas*. . Open the workpad you want to share. @@ -140,7 +140,7 @@ Display your dashboards on an internal company website or personal web page with For information about granting access to embedded dashboards, refer to <>. -. Open the main menu, then open the dashboard you want to share. +. Open the dashboard you want to share. . Click *Share > Embed code*. diff --git a/docs/user/security/api-keys/index.asciidoc b/docs/user/security/api-keys/index.asciidoc index 2f9a0d337e3b9..bbc5f2834c2cb 100644 --- a/docs/user/security/api-keys/index.asciidoc +++ b/docs/user/security/api-keys/index.asciidoc @@ -13,7 +13,8 @@ You can use {kib} to manage your different API keys: * Cross-cluster API key: allows other clusters to connect to this cluster. * Managed API key: created and managed by Kibana to run background tasks. -To manage API keys, open the main menu, then click *Stack Management > Security > API Keys*. +To manage API keys, go to the *API Keys* management page using the navigation menu or the +<>. [role="screenshot"] image:images/api-keys.png["API Keys UI"] @@ -28,13 +29,15 @@ image:images/api-keys.png["API Keys UI"] * To create or update a *cross-cluster API key*, you must have the `manage_security` privilege and an Enterprise license. * To have a read-only view on the API keys, you must have access to the page and the `read_security` cluster privilege. -To manage roles, open the main menu, then click *Stack Management > Security > Roles*, or use the {api-kibana}/group/endpoint-roles[role APIs]. +To manage roles, go to the *Roles* management page using the navigation menu or the +<>, or use the {api-kibana}/group/endpoint-roles[role APIs]. [float] [[create-api-key]] === Create an API key -To create an API key, open the main menu, then click *Stack Management > Security > API Keys > Create API key*. +To create an API key, go to the *API Keys* management page using the navigation menu or the +<>, and select *Create API key*. [role="screenshot"] image:images/create-ccr-api-key.png["Create API Key UI"] @@ -48,7 +51,8 @@ Refer to the {ref}/security-api-create-cross-cluster-api-key.html[create cross-c [[udpate-api-key]] === Update an API key -To update an API key, open the main menu, click *Stack Management > Security > API Keys*, and then click on the name of the key. You cannot update the name or the type of API key. +To update an API key, go to the *API Keys* management page using the navigation menu or the +<>, and then click on the name of the key. You cannot update the name or the type of API key. Refer to the {ref}/security-api-update-api-key.html[update API key] documentation to learn more about updating user API keys. diff --git a/docs/user/security/index.asciidoc b/docs/user/security/index.asciidoc index 44d7c41391c35..3ea0245a21657 100644 --- a/docs/user/security/index.asciidoc +++ b/docs/user/security/index.asciidoc @@ -21,7 +21,8 @@ The `manage_security` cluster privilege is required to access all Security featu [float] === Users -To create and manage users, open the main menu, then click *Stack Management > Users*. +To create and manage users, go to the *Users* management page using the navigation menu or the +<>. You can also change their passwords and roles. For more information about authentication and built-in users, see {ref}/setting-up-authentication.html[Setting up user authentication]. @@ -29,7 +30,8 @@ authentication and built-in users, see [float] === Roles -To manage roles, open the main menu, then click *Stack Management > Roles*, or use +To manage roles, go to the *Roles* management page using the navigation menu or the +<>, or use the {api-kibana}/group/endpoint-roles[role APIs]. For more information on configuring roles for {kib}, see <>. For a more holistic overview of configuring roles for the entire stack, diff --git a/docs/user/security/role-mappings/index.asciidoc b/docs/user/security/role-mappings/index.asciidoc index df4ded4321c13..35de39e052236 100644 --- a/docs/user/security/role-mappings/index.asciidoc +++ b/docs/user/security/role-mappings/index.asciidoc @@ -8,7 +8,8 @@ describe which roles to assign to your users using a set of rules. Role mappings are required when authenticating via an external identity provider, such as Active Directory, Kerberos, PKI, OIDC, or SAML. Role mappings have no effect for users inside the `native` or `file` realms. -To manage your role mappings, open the main menu, then click *Stack Management > Role Mappings*. +You can find the *Role mappings* management page using the navigation menu or the +<>. With *Role mappings*, you can: @@ -27,7 +28,8 @@ The `manage_security` cluster privilege is required to manage Role Mappings. [float] === Create a role mapping -. Open the main menu, then click *Stack Management > Role Mappings*. +. Go to the *Role mappings* management page using the navigation menu or the +<>. . Click *Create role mapping*. . Give your role mapping a unique name, and choose which roles you wish to assign to your users. + diff --git a/docs/user/security/securing-kibana.asciidoc b/docs/user/security/securing-kibana.asciidoc index 98290bb093e41..0c05dd89ebecf 100644 --- a/docs/user/security/securing-kibana.asciidoc +++ b/docs/user/security/securing-kibana.asciidoc @@ -65,10 +65,12 @@ the `elastic` user or other built-in users, run the . [[kibana-roles]]Create roles and users to grant access to {kib}. + -- -To manage privileges in {kib}, open the main menu, then click *Stack Management > Roles*. The built-in `kibana_admin` role will grant +To manage privileges in {kib}, go to the *Roles* management page using the navigation menu or the +<>. The built-in `kibana_admin` role will grant access to {kib} with administrator privileges. Alternatively, you can create additional roles that grant limited access to {kib}. -If you're using the default native realm with Basic Authentication, open the main menu, then click *Stack Management > Users* to create +If you're using the default native realm with Basic Authentication, go to the *Users* management page using the navigation menu or the +<> to create users and assign roles, or use the {es} {ref}/security-api.html#security-user-apis[user management APIs]. For example, the following creates a user named `jacknich` and assigns it the `kibana_admin` role: diff --git a/docs/user/security/tutorials/how-to-secure-access-to-kibana.asciidoc b/docs/user/security/tutorials/how-to-secure-access-to-kibana.asciidoc index d62ccebb05657..3b4e4b02af677 100644 --- a/docs/user/security/tutorials/how-to-secure-access-to-kibana.asciidoc +++ b/docs/user/security/tutorials/how-to-secure-access-to-kibana.asciidoc @@ -52,8 +52,8 @@ Let’s work through an example together. Consider a marketing analyst who wants Create a Marketing space for your marketing analysts to use. -. Open the main menu, and select **Stack Management**. -. Under **{kib}**, select **Spaces**. +. Go to the *Spaces* management page using the navigation menu or the +<>. . Click **Create a space**. . Give this space a unique name. For example: `Marketing`. . Click **Create space**. @@ -75,8 +75,8 @@ In this example, a marketing analyst will need: To create the role: -. Open the main menu, and select **Stack Management**. -. Under **Security**, select **Roles**. +. Go to the *Roles* management page using the navigation menu or the +<>. . Click **Create role**. . Give this role a unique name. For example: `marketing_dashboards_role`. . For this example, you want to store all marketing data in the `acme-marketing-*` set of indices. To grant this access, locate the **Index privileges** section and enter: diff --git a/docs/user/whats-new.asciidoc b/docs/user/whats-new.asciidoc index 2a726ba3dc4f3..25568518ad2ec 100644 --- a/docs/user/whats-new.asciidoc +++ b/docs/user/whats-new.asciidoc @@ -1,175 +1,144 @@ [[whats-new]] -== What's new in 8.15 +== What's new in 8.16 -Here are the highlights of what's new and improved in 8.15. +Here are the highlights of what's new and improved in 8.16. For detailed information about this release, check the <>. -Previous versions: {kibana-ref-all}/8.14/whats-new.html[8.14] | {kibana-ref-all}/8.13/whats-new.html[8.13] | {kibana-ref-all}/8.12/whats-new.html[8.12] | {kibana-ref-all}/8.11/whats-new.html[8.11] | {kibana-ref-all}/8.10/whats-new.html[8.10] | {kibana-ref-all}/8.9/whats-new.html[8.9] | {kibana-ref-all}/8.8/whats-new.html[8.8] | {kibana-ref-all}/8.7/whats-new.html[8.7] | {kibana-ref-all}/8.6/whats-new.html[8.6] | {kibana-ref-all}/8.5/whats-new.html[8.5] | {kibana-ref-all}/8.4/whats-new.html[8.4] | {kibana-ref-all}/8.3/whats-new.html[8.3] | {kibana-ref-all}/8.2/whats-new.html[8.2] | {kibana-ref-all}/8.1/whats-new.html[8.1] | {kibana-ref-all}/8.0/whats-new.html[8.0] +Previous versions: {kibana-ref-all}/8.15/whats-new.html[8.15] | {kibana-ref-all}/8.14/whats-new.html[8.14] | {kibana-ref-all}/8.13/whats-new.html[8.13] | {kibana-ref-all}/8.12/whats-new.html[8.12] | {kibana-ref-all}/8.11/whats-new.html[8.11] | {kibana-ref-all}/8.10/whats-new.html[8.10] | {kibana-ref-all}/8.9/whats-new.html[8.9] | {kibana-ref-all}/8.8/whats-new.html[8.8] | {kibana-ref-all}/8.7/whats-new.html[8.7] | {kibana-ref-all}/8.6/whats-new.html[8.6] | {kibana-ref-all}/8.5/whats-new.html[8.5] | {kibana-ref-all}/8.4/whats-new.html[8.4] | {kibana-ref-all}/8.3/whats-new.html[8.3] | {kibana-ref-all}/8.2/whats-new.html[8.2] | {kibana-ref-all}/8.1/whats-new.html[8.1] | {kibana-ref-all}/8.0/whats-new.html[8.0] [discrete] -=== ES|QL +=== Solution-oriented navigation +On Elastic Cloud Hosted deployments running on version 8.16, you can now navigate Kibana using a lighter, solution-oriented left navigation menu, called **Solution view**. -[discrete] -==== Filter UX improvements in ES|QL +There are four selectable solution views: Search, Observability, Security, and Classic. Search, Observability, and Security are the new navigation menus. Each of those brings simplicity by focusing the left navigation menu on a relevant subset of features, scoped to its associated use cases, and offers a dedicated home page. Classic has the same navigation menu as 8.15 and before. -We're thrilled to unveil a complete overhaul of filtering in the ES|QL UX. Now, you can seamlessly filter data by browsing a time series chart, allowing for quick and intuitive time-based filtering. Interactive chart filtering lets you refine your data directly by clicking on any chart, while creating WHERE clause filters from the Discover table or sidebar has never been easier. These enhancements streamline data exploration and analysis, making your ES|QL experience more efficient and user-friendly than ever. +Each space has its own solution view setting which determines the navigation experience for all users of that space. -*Filter by clicking a chart:* +When creating a new deployment, you will now be asked to choose between one of the 3 new solution views for your default space. If you prefer to stick with the classic, multi-layered navigation, you can do so once the deployment is created by navigating to your space settings. -image::https://images.contentstack.io/v3/assets/bltefdd0b53724fa2ce/blt965a5190f246f7c8/669a7d41e5f7c84793b031cb/filter-by-clicking-chart.gif[Filter by clicking a chart] +Deployments upgrading from a previous version to 8.16 keep the classic navigation. Admins can enable one of the new solution views from the space settings. -*Filter by browsing a time series chart:* +image::images/solution-view-obs.png[Example of observability solution view] +_The Observability solution view and its Home page._ -image::https://images.contentstack.io/v3/assets/bltefdd0b53724fa2ce/blta20c9a93dded707c/669a7d40843f93a02fe51013/filter-by-brushing-time-series.gif[Filter by browsing a time series chart] +[discrete] +=== Discover and ES|QL -*Create WHERE clause filters from Discover table or sidebar:* +[discrete] +==== Contextual Data presentation -image::https://images.contentstack.io/v3/assets/bltefdd0b53724fa2ce/blt50ac35ab3af29ff8/669a7d4006a6fafe4c7cb39d/create-where-clause-filters-from-sidebar.gif[Create WHERE clause filters from Discover table or sidebar] +In this release, Discover introduces enhanced contextual data presentation. Previously, you needed to manually select relevant fields and set up your workspace before diving into data exploration. Now, Discover automatically tailors the user experience based on the data being explored, powered by a scalable contextual architecture. For example, when analyzing logs, you'll see a *log.level* field rendered directly in the table, a custom Logs overview in the document viewer, and log.level indicators on individual rows. +image::images/discover-log-level.png[Log level badge displaying in the Discover grid] [discrete] -==== Field statistics in ES|QL +==== Recommended ES|QL queries -Field statistics are now available in ES|QL. This feature is designed to provide comprehensive insights for each data field. With this enhancement, you can access detailed statistics such as distributions, averages, and other key metrics, helping you quickly understand your data. This makes data exploration and quality assessment more efficient, providing deeper insights and streamlining the analysis of field-level data in ES|QL. +Writing ES|QL queries just got easier. Many users face challenges when authoring queries, and even more so when unfamiliar with the syntax or data structure. This can lead to inefficiencies in data analysis and visualization. We want to reduce the time it takes to create queries and to lower the learning curve for both new and existing users by suggesting recommended queries within the ES|QL Help menu and from the auto-complete. -image::images/field-statistics-esql.png[Field statistics in ES|QL] +image::images/esql-suggestions.png[A list of suggestions to get started with an ES|QL query, width=30%] +_Recommended ES|QL queries from the ES|QL help menu_ -[discrete] -==== Integrations support in the ES|QL editor when using FROM command. +image::images/esql-autocomplete-suggestions.png[A list of suggestions in the autocomplete menu of an ES|QL query, width=50%] +_Recommended ES|QL queries from auto-complete suggestions_ -We're excited to announce enhanced support for integrations in the ES|QL editor with the *FROM* command. Previously, you could only access indices, but now you can also view a list of installed integrations directly within the editor. This improvement streamlines your workflow, making it easier to manage and utilize various integrations while working with your data. - -image::images/integrations-in-esql.png[Accessing an integration from ES|QL] [discrete] === Dashboards [discrete] -==== Field statistics in Dashboards - -It's now easier than ever to include your field statistics view from **Discover** into **Dashboards**. While running investigations, it is very common that you need to see some field information, such as unique values and their distribution, to make sense of the data. Select the fields that you want with your ES|QL query and get the document count, values, and distribution in your dashboard so you don't have to navigate back and forth to **Discover** to see this information. +==== Manage dashboards more easily and efficiently +As part of a series of improvements to help you find and manage your dashboards https://www.elastic.co/guide/en/kibana/8.15/whats-new.html#_view_dashboard_creator_and_last_editor[started in version 8.15], the new default way to sort your dashboards is by recently viewed, and we are adding an option to star your favorite dashboards, as well as some statistics to monitor the usage of your dashboards. -image::https://images.contentstack.io/v3/assets/bltefdd0b53724fa2ce/blt9bc52ff7851acc52/669a4f6a490fbc64fa22f279/field-statistics.gif[Showing field statistics panel in Dashboards] +You can find your favorite dashboards in the new **Starred** tab. -[discrete] -==== Statistics in legends +image::images/dashboard-star.png[Viewing starred dashboards] -Accelerate time to insights by summarizing the values of your charts using average, minimum, maximum, median, and variance, among many others. You can add these statistics for **Lens** and ES|QL visualizations. It is important to note that these statistics are computed using the data points from the chart considering the aggregation used and not the raw data. In the following example, the chart shows the median memory per host, so the Max = 15.3KB for the first series (artifacts.elastic.co) is the maximum value of the median memory per host. +By opening a dashboard's details using the “info” icon from the dashboard list view, you can now get a sense of the popularity of that dashboard with a histogram showing how many times the dashboard was viewed in the last 90 days. -image::images/statistics-in-legends.png[Statistics in legends] +image::images/dashboard-usage.png[Dashboard usage chart] -You can find the option to select statistics for your legends along with an explanation for each calculation when editing your visualization, as shown in the following image. +[discrete] +==== Log Pattern Analysis dashboard panels +Log Pattern Analysis panels are now available for you to add to your dashboards, making AIOps even more embedded in your workflows and where you need it. When filtering patterns, the dashboard’s data adjusts accordingly. You can also choose the filtering to transition you into Discover for further exploration. -image::images/statistics-in-legends2.png[Select statistics in legends] +image:https://images.contentstack.io/v3/assets/bltefdd0b53724fa2ce/blt8288e01386b5830c/67222fb0d2da223e27bc1e67/log_analysis_panel.gif[Log pattern analysis panel in dashboards] [discrete] -==== View dashboard creator and last editor +==== Color text values in tables +Previously, you could only decide to color numeric values in tables. We're adding the ability to also color your string values. You can decide whether you want to color the whole cell, or only the text. -You can now see who created and who last updated a dashboard. +image::images/table-coloring.png[Coloring table cells with string values] -You can find the creator information right from the dashboard list. -image::images/dashboard-creator.png[Dashboard creator column in dashboard list] +[discrete] +==== Formatting options for your metrics +We've received a lot of feedback asking for more flexibility to customize the appearance of your metrics. In this version, we are adding the ability to customize the title and value alignment, as well as the font size. Selecting the *Fit* option will adjust the font size and make the metric value occupy the entire panel. -Quickly find all dashboards created by the same user with a simple filter. +image::images/metric-customization.png[Customization options for a metric panel] -image::images/dashboard-creator-filter.png[Filtering dashboards by creator] -Note that the creator information will be visible only for dashboards created on or after version 8.14. -You can also see who last updated a dashboard by clicking the dashboard information icon from the dashboard list. The creator is also visible next to it. This information is immutable and cannot be changed. +//[discrete] +//=== Alerting, cases, and connectors -image::images/dashboard-last-editor.png[Dashboard details panel with the name of the last editor] [discrete] -=== Discover +=== Managing {kib} and data [discrete] -==== Push flyout for Discover document viewer +==== Edit space access from the space settings +As an admin, you can now assign roles to and edit role permissions on a given space directly from the settings of that space. -You can now seamlessly view document details and the main table simultaneously in **Discover** with the new _push_ flyout. You can adjust the width of the flyout to suit your needs and explore your data much more easily. - -image::https://images.contentstack.io/v3/assets/bltefdd0b53724fa2ce/bltb40a408acf4ab688/669a58ea9fecd85219d58ed2/discover-push-flyout.gif[Resizable push flyout in Discover] +Prior to 8.16, you could only do this from the role settings, which was counterintuitive. +image::space-settings.png[Editing space settings with new options] [discrete] -=== Alerting, cases, and connectors +==== New IP Location processor +Enhancing location information based on IP addresses just got easier with the new IP Location processor. In addition to the existing free GeoLite offerings from MaxMind, we have integrated with MaxMind’s premium GeoIP databases for users who have licensed MaxMind’s products. If you're an Enterprise Elastic customer, you now have an additional third-party product, IP Info, available for use as well. These additional data sources provide improved options for enriching data with location information associated with IP addresses to improve telemetry and insights. To utilize these features beyond the free MaxMind GeoIP database, you will need to have licensed premium MaxMind products and/or the IP Info database. -[discrete] -==== Case templates - -{kib} cases offer a new powerful capability to enhance the efficiency of your analyst teams with <>. -You can manage multiple templates, each of which can be used to auto-populate values in a case with pre-defined knowledge. -This streamlines the investigative process and significantly reduces time to resolution. +image::images/ip-location-processor.png[The IP Location processor] [discrete] -==== Case custom fields are GA +==== File uploader PDF support +The file uploader provides a quick way to upload data and start using Elastic. In 8.16, we are improving it to allow you to upload data from PDF files. -In 8.11, <> were added to cases and they are now moving from technical preview to general availability. -You can set custom field values in your templates to enhance consistency across cases. +image:https://images.contentstack.io/v3/assets/bltefdd0b53724fa2ce/blte8f0b295330b7e68/67222fb0ca492a5044b51bd8/file_uploader_pdf.gif[File uploader with PDF support] [discrete] -==== {sn} additional fields +=== Developer Tools Console redesign +We're excited to introduce a number of improvements to the overall user experience on one of our most popular features: **Console**. If you're new to Console, you will be welcomed by an onboarding tour that will help you get started quickly with your first requests. And if you're already a regular Console user, you will notice a variety of new features, including the ability to copy outputs to the clipboard, import and export request files, enjoy improved responsiveness, and other quality of life improvements. -You can now create enriched {sn} tickets based on detected alerts with a more comprehensive structure that matches the {sn} ticket scheme. -A new JSON field is now available as part of the {sn} action, which enables you to send any field from {kib} alerts to {sn} tickets. - -[discrete] -==== {webhook-cm} SSL auth support - -It's common for organizations to integrate with third parties using secured authentication. -Currently, most of the available case connectors use basic authentication (user and passwords or tokens), which might not be sufficient to meet organization security policies. -With this release, the <> now supports client certification, which enables you to leverage the connector for secured integration with third parties. - -The {webhook-cm} connector also moves from technical preview to general availability in this release. +image::images/monaco-console.png[Console's redesign featuring the Monaco editor] [discrete] === Machine Learning [discrete] -==== Improved UX for Log Pattern Analysis in Discover +==== The Inference API is now Generally Available -Analyze large volumes of logs efficiently, in very short times with Log Pattern Analysis in **Discover**. In 8.15, we redesigned the Log Pattern Analysis user flow in **Discover** to make it easier to use. Discover log patterns with one click for the message field (and other applicable text fields) and easily filter in and out logs to drastically reduce MTTR. - -image::https://images.contentstack.io/v3/assets/bltefdd0b53724fa2ce/blt7e63d7e764ab183e/669a807bd316c7015db35458/ml-log-pattern-analysis.gif[New log pattern analysis interface] +Starting in 8.16, the {ref}/inference-apis.html[Inference API] is now GA, offering production-level stability, robustness and performance. Elastic’s Inference API integrates the state-of-the-art in AI inference, including ELSER, your Elastic hosted models and {ref}/put-inference-api.html#put-inference-api-desc[an increasing array of external models and tasks] in a unified, lean syntax. Used with {ref}/semantic-text.html[semantic_text] or the vector fields supported by the Elastic vector database, you can perform AI search, reranking, and completion with simplicity. In 8.16, we're also adding streamed completions for improved flows and real time interactions and GenAI experiences. [discrete] -==== Log Rate Analysis contextual insights in serverless Observability +==== ELSER and trained models adaptive resources and chunking strategies -You can now see insights in natural language, for example for the root cause of a log rate change or threshold alert, in Log Rate Analysis. This feature is currently only available for Observability serverless projects. +From 8.16, ELSER and the other AI search and NLP models you use in Elastic automatically adapt resource consumption according to the inference load, providing the performance you need during peak times and reducing the cost during slow periods, all the way down to zero cost during idle times. -image::images/obs-log-rate-analysis-insigths.png[Log Rate Analysis contextual insights in serverless Observability] +We're also improving the UX through which you deploy your models. You can provision search-optimized and ingest-optimized model deployments with a one-click selection. An optimized configuration is created without the need to specify parameters such as threads and allocations. Combined with the flexibility of ML auto-scaling on Elastic Cloud and the incredible elasticity of Elastic Cloud Serverless, you are in full control of both performance and cost. -[discrete] -==== Inference API improvements +image::https://images.contentstack.io/v3/assets/bltefdd0b53724fa2ce/blt429790e1de1b4f93/67222fb048ec8c73255ef4eb/trained_models.gif[Trained models and ELSER] -The inference API provides a seamless, intuitive interface to perform inference and other tasks against proprietary, hosted, and integrated external services. In 8.15, we're extending it with the following capabilities: +In addition, from 8.16 you can choose between a word or sequence-based chunking strategy to use with your trained models, and you can also customize the maximum size and overlap parameters. A suitable chunking strategy can result in gains depending on the model you use, the length and nature of the texts and the length and complexity of the search queries. -* Support for Anthropic's chat completion API. -* Ability to host cross encoder models and perform the reranking task. - - -[discrete] -=== Managing {kib} users and objects [discrete] -==== Sharing improvements +==== Support for Daylight Saving Time changes in Anomaly Detection -You can now share a dashboard, search, or Lens object in one click. When sharing an object, the most common actions are directly presented to you, and a short link is automatically generated, making it simpler than ever to share your work. +In 8.16, we are introducing support for DST changes in Anomaly Detection. Set up a DST calendar by selecting the right timezone and apply it to your anomaly detection jobs individually or in groups. This feature eliminates any false positives that you may have experienced previously due to Daylight Saving Time changes, and works without the need for your intervention for many years ahead. -image::images/share-modal.png[New object share modal, width=50%] - -[discrete] -==== Quick API key creation - -Many API keys don’t require custom settings, so we made it simple to generate a standard key. From the **Endpoints & API keys** top menu in Search, you can create a key in seconds. - -image::images/create-simple-api-key.png[Shortcut to create an API key, width=60%] - -[discrete] -==== Filtering by User in Kibana Audit Logs +image::https://images.contentstack.io/v3/assets/bltefdd0b53724fa2ce/blt5fb82f18cde26710/67222fb086339971144a31e5/daylight_savings.gif[DST support in Anomaly Detection] -We are pleased to share that ignoring events by user in Kibana audit logs is now possible. This enhancement will give you more flexibility to reduce the overall number of events logged by the Kibana audit logs service and to control the volume of data being generated in audit logs. While we currently offer a number of ways to do this using the `xpack.security.audit.ignore_filters.[]` configuration setting, there wasn't an easy option to filter by user. With this addition, you can configure Kibana audit logs to ignore events based on values from the following fields: users, spaces, outcomes, categories, types and actions. \ No newline at end of file diff --git a/examples/discover_customization_examples/public/plugin.tsx b/examples/discover_customization_examples/public/plugin.tsx index 7c35287b843ba..6dc6e8f48da58 100644 --- a/examples/discover_customization_examples/public/plugin.tsx +++ b/examples/discover_customization_examples/public/plugin.tsx @@ -7,17 +7,9 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { - EuiButton, - EuiContextMenu, - EuiFlexItem, - EuiPopover, - EuiWrappingPopover, - IconType, -} from '@elastic/eui'; +import { EuiButton, EuiContextMenu, EuiFlexItem, EuiPopover, IconType } from '@elastic/eui'; import { CoreSetup, CoreStart, Plugin, SimpleSavedObject } from '@kbn/core/public'; import type { DeveloperExamplesSetup } from '@kbn/developer-examples-plugin/public'; -import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import type { CustomizationCallback, DiscoverSetup, @@ -102,112 +94,14 @@ export class DiscoverCustomizationExamplesPlugin implements Plugin { } start(core: CoreStart, plugins: DiscoverCustomizationExamplesStartPlugins) { - const { discover } = plugins; - - let isOptionsOpen = false; - const optionsContainer = document.createElement('div'); - const closeOptionsPopover = () => { - ReactDOM.unmountComponentAtNode(optionsContainer); - document.body.removeChild(optionsContainer); - isOptionsOpen = false; - }; - this.customizationCallback = ({ customizations, stateContainer }) => { customizations.set({ id: 'top_nav', defaultMenu: { newItem: { disabled: true }, openItem: { disabled: true }, - shareItem: { order: 200 }, alertsItem: { disabled: true }, inspectItem: { disabled: true }, - saveItem: { order: 400 }, - }, - getMenuItems: () => [ - { - data: { - id: 'options', - label: 'Options', - iconType: 'arrowDown', - iconSide: 'right', - testId: 'customOptionsButton', - run: (anchorElement: HTMLElement) => { - if (isOptionsOpen) { - closeOptionsPopover(); - return; - } - - isOptionsOpen = true; - document.body.appendChild(optionsContainer); - - const element = ( - - - alert('Create new clicked'), - }, - { - name: 'Make a copy', - icon: 'copy', - onClick: () => alert('Make a copy clicked'), - }, - { - name: 'Manage saved searches', - icon: 'gear', - onClick: () => alert('Manage saved searches clicked'), - }, - ], - }, - ]} - data-test-subj="customOptionsPopover" - /> - - - ); - - ReactDOM.render(element, optionsContainer); - }, - }, - order: 100, - }, - { - data: { - id: 'documentExplorer', - label: 'Document explorer', - iconType: 'discoverApp', - testId: 'documentExplorerButton', - run: () => { - discover.locator?.navigate({}); - }, - }, - order: 300, - }, - ], - getBadges: () => { - return [ - { - data: { - badgeText: 'Example badge', - color: 'warning', - }, - order: 10, - }, - ]; }, }); diff --git a/examples/discover_customization_examples/tsconfig.json b/examples/discover_customization_examples/tsconfig.json index 776153f943fac..30ff666575f1d 100644 --- a/examples/discover_customization_examples/tsconfig.json +++ b/examples/discover_customization_examples/tsconfig.json @@ -13,7 +13,6 @@ "@kbn/i18n-react", "@kbn/react-kibana-context-theme", "@kbn/data-plugin", - "@kbn/react-kibana-context-render", ], "exclude": ["target/**/*"] } diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/helpers.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/helpers.tsx index a117062f7efa9..19a0c54a722c6 100644 --- a/examples/esql_ast_inspector/public/components/esql_inspector/helpers.tsx +++ b/examples/esql_ast_inspector/public/components/esql_inspector/helpers.tsx @@ -82,6 +82,8 @@ export const highlight = (query: EsqlQuery): Annotation[] => { }); Walker.visitComments(query.ast, (comment) => { + if (!comment.location) return; + annotations.push([ comment.location.min, comment.location.max, diff --git a/examples/grid_example/public/app.tsx b/examples/grid_example/public/app.tsx index 332649720742a..0e73a76d790fd 100644 --- a/examples/grid_example/public/app.tsx +++ b/examples/grid_example/public/app.tsx @@ -7,53 +7,186 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React from 'react'; +import { cloneDeep } from 'lodash'; +import React, { useRef, useState } from 'react'; import ReactDOM from 'react-dom'; -import { GridLayout, type GridLayoutData } from '@kbn/grid-layout'; +import { v4 as uuidv4 } from 'uuid'; + +import { + EuiBadge, + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiPageTemplate, + EuiProvider, + EuiSpacer, +} from '@elastic/eui'; import { AppMountParameters } from '@kbn/core-application-browser'; -import { EuiPageTemplate, EuiProvider } from '@elastic/eui'; +import { CoreStart } from '@kbn/core-lifecycle-browser'; +import { GridLayout, GridLayoutData, isLayoutEqual, type GridLayoutApi } from '@kbn/grid-layout'; +import { i18n } from '@kbn/i18n'; + +import { getPanelId } from './get_panel_id'; +import { + clearSerializedGridLayout, + getSerializedGridLayout, + setSerializedGridLayout, +} from './serialized_grid_layout'; + +const DASHBOARD_MARGIN_SIZE = 8; +const DASHBOARD_GRID_HEIGHT = 20; +const DASHBOARD_GRID_COLUMN_COUNT = 48; +const DEFAULT_PANEL_HEIGHT = 15; +const DEFAULT_PANEL_WIDTH = DASHBOARD_GRID_COLUMN_COUNT / 2; + +export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => { + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + + const [layoutKey, setLayoutKey] = useState(uuidv4()); + const [gridLayoutApi, setGridLayoutApi] = useState(); + const savedLayout = useRef(getSerializedGridLayout()); + const currentLayout = useRef(savedLayout.current); -export const GridExample = () => { return ( - + + + { + clearSerializedGridLayout(); + window.location.reload(); + }} + > + {i18n.translate('examples.gridExample.resetExampleButton', { + defaultMessage: 'Reset example', + })} + + + + + + { + const panelId = await getPanelId({ + coreStart, + suggestion: `panel${(gridLayoutApi?.getPanelCount() ?? 0) + 1}`, + }); + if (panelId) + gridLayoutApi?.addPanel(panelId, { + width: DEFAULT_PANEL_WIDTH, + height: DEFAULT_PANEL_HEIGHT, + }); + }} + > + {i18n.translate('examples.gridExample.addPanelButton', { + defaultMessage: 'Add a panel', + })} + + + + + {hasUnsavedChanges && ( + + + {i18n.translate('examples.gridExample.unsavedChangesBadge', { + defaultMessage: 'Unsaved changes', + })} + + + )} + + { + currentLayout.current = cloneDeep(savedLayout.current); + setHasUnsavedChanges(false); + setLayoutKey(uuidv4()); // force remount of grid + }} + > + {i18n.translate('examples.gridExample.resetLayoutButton', { + defaultMessage: 'Reset', + })} + + + + { + if (gridLayoutApi) { + const layoutToSave = gridLayoutApi.serializeState(); + setSerializedGridLayout(layoutToSave); + savedLayout.current = layoutToSave; + setHasUnsavedChanges(false); + } + }} + > + {i18n.translate('examples.gridExample.saveLayoutButton', { + defaultMessage: 'Save', + })} + + + + + + { + currentLayout.current = cloneDeep(newLayout); + setHasUnsavedChanges(!isLayoutEqual(savedLayout.current, newLayout)); + }} + ref={setGridLayoutApi} renderPanelContents={(id) => { - return
{id}
; + return ( + <> +
{id}
+ { + gridLayoutApi?.removePanel(id); + }} + > + {i18n.translate('examples.gridExample.deletePanelButton', { + defaultMessage: 'Delete panel', + })} + + { + const newPanelId = await getPanelId({ + coreStart, + suggestion: `panel${(gridLayoutApi?.getPanelCount() ?? 0) + 1}`, + }); + if (newPanelId) gridLayoutApi?.replacePanel(id, newPanelId); + }} + > + {i18n.translate('examples.gridExample.replacePanelButton', { + defaultMessage: 'Replace panel', + })} + + + ); }} getCreationOptions={() => { - const initialLayout: GridLayoutData = [ - { - title: 'Large section', - isCollapsed: false, - panels: { - panel1: { column: 0, row: 0, width: 12, height: 6, id: 'panel1' }, - panel2: { column: 0, row: 6, width: 8, height: 4, id: 'panel2' }, - panel3: { column: 8, row: 6, width: 12, height: 4, id: 'panel3' }, - panel4: { column: 0, row: 10, width: 48, height: 4, id: 'panel4' }, - panel5: { column: 12, row: 0, width: 36, height: 6, id: 'panel5' }, - panel6: { column: 24, row: 6, width: 24, height: 4, id: 'panel6' }, - panel7: { column: 20, row: 6, width: 4, height: 2, id: 'panel7' }, - panel8: { column: 20, row: 8, width: 4, height: 2, id: 'panel8' }, - }, - }, - { - title: 'Small section', - isCollapsed: false, - panels: { panel9: { column: 0, row: 0, width: 12, height: 16, id: 'panel9' } }, - }, - { - title: 'Another small section', - isCollapsed: false, - panels: { panel10: { column: 24, row: 0, width: 12, height: 6, id: 'panel10' } }, - }, - ]; - return { - gridSettings: { gutterSize: 8, rowHeight: 26, columnCount: 48 }, - initialLayout, + gridSettings: { + gutterSize: DASHBOARD_MARGIN_SIZE, + rowHeight: DASHBOARD_GRID_HEIGHT, + columnCount: DASHBOARD_GRID_COLUMN_COUNT, + }, + initialLayout: cloneDeep(currentLayout.current), }; }} /> @@ -63,8 +196,11 @@ export const GridExample = () => { ); }; -export const renderGridExampleApp = (element: AppMountParameters['element']) => { - ReactDOM.render(, element); +export const renderGridExampleApp = ( + element: AppMountParameters['element'], + coreStart: CoreStart +) => { + ReactDOM.render(, element); return () => ReactDOM.unmountComponentAtNode(element); }; diff --git a/examples/grid_example/public/get_panel_id.tsx b/examples/grid_example/public/get_panel_id.tsx new file mode 100644 index 0000000000000..d83d0b232b53a --- /dev/null +++ b/examples/grid_example/public/get_panel_id.tsx @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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, { useState } from 'react'; + +import { + EuiButton, + EuiCallOut, + EuiFieldText, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSpacer, +} from '@elastic/eui'; +import { CoreStart } from '@kbn/core-lifecycle-browser'; +import { toMountPoint } from '@kbn/react-kibana-mount'; +import { i18n } from '@kbn/i18n'; + +const PanelIdModal = ({ + suggestion, + onClose, + onSubmit, +}: { + suggestion: string; + onClose: () => void; + onSubmit: (id: string) => void; +}) => { + const [panelId, setPanelId] = useState(suggestion); + + return ( + + + + {i18n.translate('examples.gridExample.getPanelIdModalTitle', { + defaultMessage: 'Panel ID', + })} + + + + + + + + { + setPanelId(e.target.value ?? ''); + }} + /> + + + { + onSubmit(panelId); + }} + > + {i18n.translate('examples.gridExample.getPanelIdSubmitButton', { + defaultMessage: 'Submit', + })} + + + + ); +}; + +export const getPanelId = async ({ + coreStart, + suggestion, +}: { + coreStart: CoreStart; + suggestion: string; +}): Promise => { + return new Promise((resolve) => { + const session = coreStart.overlays.openModal( + toMountPoint( + { + resolve(undefined); + session.close(); + }} + onSubmit={(newPanelId) => { + resolve(newPanelId); + session.close(); + }} + />, + { + theme: coreStart.theme, + i18n: coreStart.i18n, + } + ) + ); + }); +}; diff --git a/examples/grid_example/public/plugin.ts b/examples/grid_example/public/plugin.ts index 0f7d441a1be15..d57b06ac96017 100644 --- a/examples/grid_example/public/plugin.ts +++ b/examples/grid_example/public/plugin.ts @@ -26,8 +26,11 @@ export class GridExamplePlugin title: gridExampleTitle, visibleIn: [], async mount(params: AppMountParameters) { - const { renderGridExampleApp } = await import('./app'); - return renderGridExampleApp(params.element); + const [{ renderGridExampleApp }, [coreStart]] = await Promise.all([ + import('./app'), + core.getStartServices(), + ]); + return renderGridExampleApp(params.element, coreStart); }, }); developerExamples.register({ diff --git a/examples/grid_example/public/serialized_grid_layout.ts b/examples/grid_example/public/serialized_grid_layout.ts new file mode 100644 index 0000000000000..2bb20052398f8 --- /dev/null +++ b/examples/grid_example/public/serialized_grid_layout.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", 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 GridLayoutData } from '@kbn/grid-layout'; + +const STATE_SESSION_STORAGE_KEY = 'kibana.examples.gridExample.state'; + +export function clearSerializedGridLayout() { + sessionStorage.removeItem(STATE_SESSION_STORAGE_KEY); +} + +export function getSerializedGridLayout(): GridLayoutData { + const serializedStateJSON = sessionStorage.getItem(STATE_SESSION_STORAGE_KEY); + return serializedStateJSON ? JSON.parse(serializedStateJSON) : initialGridLayout; +} + +export function setSerializedGridLayout(layout: GridLayoutData) { + sessionStorage.setItem(STATE_SESSION_STORAGE_KEY, JSON.stringify(layout)); +} + +const initialGridLayout: GridLayoutData = [ + { + title: 'Large section', + isCollapsed: false, + panels: { + panel1: { column: 0, row: 0, width: 12, height: 6, id: 'panel1' }, + panel2: { column: 0, row: 6, width: 8, height: 4, id: 'panel2' }, + panel3: { column: 8, row: 6, width: 12, height: 4, id: 'panel3' }, + panel4: { column: 0, row: 10, width: 48, height: 4, id: 'panel4' }, + panel5: { column: 12, row: 0, width: 36, height: 6, id: 'panel5' }, + panel6: { column: 24, row: 6, width: 24, height: 4, id: 'panel6' }, + panel7: { column: 20, row: 6, width: 4, height: 2, id: 'panel7' }, + panel8: { column: 20, row: 8, width: 4, height: 2, id: 'panel8' }, + }, + }, + { + title: 'Small section', + isCollapsed: false, + panels: { panel9: { column: 0, row: 0, width: 12, height: 16, id: 'panel9' } }, + }, + { + title: 'Another small section', + isCollapsed: false, + panels: { panel10: { column: 24, row: 0, width: 12, height: 6, id: 'panel10' } }, + }, +]; diff --git a/examples/grid_example/tsconfig.json b/examples/grid_example/tsconfig.json index 23be45a74c2f7..ad692e9697b2d 100644 --- a/examples/grid_example/tsconfig.json +++ b/examples/grid_example/tsconfig.json @@ -10,5 +10,8 @@ "@kbn/core-application-browser", "@kbn/core", "@kbn/developer-examples-plugin", + "@kbn/core-lifecycle-browser", + "@kbn/react-kibana-mount", + "@kbn/i18n", ] } diff --git a/examples/guided_onboarding_example/public/application.tsx b/examples/guided_onboarding_example/public/application.tsx index 1227b8e7271df..b3d67e9de630a 100755 --- a/examples/guided_onboarding_example/public/application.tsx +++ b/examples/guided_onboarding_example/public/application.tsx @@ -10,20 +10,24 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { AppMountParameters, CoreStart } from '@kbn/core/public'; +import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import { AppPluginStartDependencies } from './types'; import { GuidedOnboardingExampleApp } from './components/app'; export const renderApp = ( - { notifications }: CoreStart, + coreStart: CoreStart, { guidedOnboarding }: AppPluginStartDependencies, { element, history }: AppMountParameters ) => { + const { notifications } = coreStart; ReactDOM.render( - , + + + , element ); diff --git a/examples/guided_onboarding_example/public/components/app.tsx b/examples/guided_onboarding_example/public/components/app.tsx index 20430534a54e3..650f683e82bbb 100755 --- a/examples/guided_onboarding_example/public/components/app.tsx +++ b/examples/guided_onboarding_example/public/components/app.tsx @@ -8,11 +8,10 @@ */ import React from 'react'; -import { FormattedMessage, I18nProvider } from '@kbn/i18n-react'; +import { FormattedMessage } from '@kbn/i18n-react'; import { Routes, Router, Route } from '@kbn/shared-ux-router'; import { EuiPageTemplate } from '@elastic/eui'; import { CoreStart, ScopedHistory } from '@kbn/core/public'; - import { GuidedOnboardingPluginStart } from '@kbn/guided-onboarding-plugin/public/types'; import { StepTwo } from './step_two'; import { StepOne } from './step_one'; @@ -30,62 +29,60 @@ export const GuidedOnboardingExampleApp = (props: GuidedOnboardingExampleAppDeps const { notifications, guidedOnboarding, history } = props; return ( - - - + + + } + /> + {guidedOnboarding?.guidedOnboardingApi?.isEnabled ? ( + + + + +
+ + + + + + + + + + + + + + + + + ) : ( + + + + } + body={ +

+ +

} /> - {guidedOnboarding?.guidedOnboardingApi?.isEnabled ? ( - - - - -
- - - - - - - - - - - - - - - - - ) : ( - - - - } - body={ -

- -

- } - /> - )} - - + )} + ); }; diff --git a/examples/guided_onboarding_example/tsconfig.json b/examples/guided_onboarding_example/tsconfig.json index 0707df0a33308..6dca87ec7eb23 100644 --- a/examples/guided_onboarding_example/tsconfig.json +++ b/examples/guided_onboarding_example/tsconfig.json @@ -17,6 +17,7 @@ "@kbn/i18n", "@kbn/guided-onboarding", "@kbn/shared-ux-router", + "@kbn/react-kibana-context-render", ], "exclude": [ "target/**/*", diff --git a/examples/response_stream/public/containers/app/pages/page_redux_stream/hooks.ts b/examples/response_stream/public/containers/app/pages/page_redux_stream/hooks.ts index f1c8c671611a8..735e70916593f 100644 --- a/examples/response_stream/public/containers/app/pages/page_redux_stream/hooks.ts +++ b/examples/response_stream/public/containers/app/pages/page_redux_stream/hooks.ts @@ -8,10 +8,9 @@ */ import type { TypedUseSelectorHook } from 'react-redux'; -import { useDispatch, useSelector, useStore } from 'react-redux'; -import type { AppDispatch, AppStore, RootState } from './store'; +import { useDispatch, useSelector } from 'react-redux'; +import type { AppDispatch, RootState } from './store'; // Use throughout your app instead of plain `useDispatch` and `useSelector` export const useAppDispatch: () => AppDispatch = useDispatch; export const useAppSelector: TypedUseSelectorHook = useSelector; -export const useAppStore: () => AppStore = useStore; diff --git a/examples/routing_example/common/index.ts b/examples/routing_example/common/index.ts index b83582b66ff08..5bec77ebe0c0f 100644 --- a/examples/routing_example/common/index.ts +++ b/examples/routing_example/common/index.ts @@ -17,6 +17,7 @@ export const POST_MESSAGE_ROUTE_PATH = '/api/post_message'; export const INTERNAL_GET_MESSAGE_BY_ID_ROUTE = '/internal/get_message'; export const DEPRECATED_ROUTES = { + DEPRECATED_ROUTE: '/api/routing_example/d/deprecated_route', REMOVED_ROUTE: '/api/routing_example/d/removed_route', MIGRATED_ROUTE: '/api/routing_example/d/migrated_route', VERSIONED_ROUTE: '/api/routing_example/d/versioned', diff --git a/examples/routing_example/server/routes/deprecated_routes/unversioned.ts b/examples/routing_example/server/routes/deprecated_routes/unversioned.ts index 4e1451a91fc38..aeb856d2eaf61 100644 --- a/examples/routing_example/server/routes/deprecated_routes/unversioned.ts +++ b/examples/routing_example/server/routes/deprecated_routes/unversioned.ts @@ -12,6 +12,28 @@ import { schema } from '@kbn/config-schema'; import { DEPRECATED_ROUTES } from '../../../common'; export const registerDeprecatedRoute = (router: IRouter) => { + router.get( + { + path: DEPRECATED_ROUTES.DEPRECATED_ROUTE, + validate: false, + options: { + access: 'public', + deprecated: { + documentationUrl: 'https://elastic.co/', + severity: 'warning', + message: + 'This deprecation message will be surfaced in UA. use `i18n.translate` to internationalize this message.', + reason: { type: 'deprecate' }, + }, + }, + }, + async (ctx, req, res) => { + return res.ok({ + body: { result: 'Called deprecated route. Check UA to see the deprecation.' }, + }); + } + ); + router.get( { path: DEPRECATED_ROUTES.REMOVED_ROUTE, @@ -27,7 +49,7 @@ export const registerDeprecatedRoute = (router: IRouter) => { }, async (ctx, req, res) => { return res.ok({ - body: { result: 'Called deprecated route. Check UA to see the deprecation.' }, + body: { result: 'Called to be removed route. Check UA to see the deprecation.' }, }); } ); @@ -55,7 +77,7 @@ export const registerDeprecatedRoute = (router: IRouter) => { }, async (ctx, req, res) => { return res.ok({ - body: { result: 'Called deprecated route. Check UA to see the deprecation.' }, + body: { result: 'Called to be migrated route. Check UA to see the deprecation.' }, }); } ); diff --git a/examples/routing_example/server/routes/deprecated_routes/versioned.ts b/examples/routing_example/server/routes/deprecated_routes/versioned.ts index 54d6f779f77c3..060bc64403dba 100644 --- a/examples/routing_example/server/routes/deprecated_routes/versioned.ts +++ b/examples/routing_example/server/routes/deprecated_routes/versioned.ts @@ -7,16 +7,9 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { RequestHandler } from '@kbn/core-http-server'; import type { IRouter } from '@kbn/core/server'; import { DEPRECATED_ROUTES } from '../../../common'; -const createDummyHandler = - (version: string): RequestHandler => - (ctx, req, res) => { - return res.ok({ body: { result: `API version ${version}.` } }); - }; - export const registerVersionedDeprecatedRoute = (router: IRouter) => { const versionedRoute = router.versioned.get({ path: DEPRECATED_ROUTES.VERSIONED_ROUTE, @@ -40,7 +33,11 @@ export const registerVersionedDeprecatedRoute = (router: IRouter) => { validate: false, version: '1', }, - createDummyHandler('1') + (ctx, req, res) => { + return res.ok({ + body: { result: 'Called deprecated version of the API. API version 1 -> 2' }, + }); + } ); versionedRoute.addVersion( @@ -48,6 +45,8 @@ export const registerVersionedDeprecatedRoute = (router: IRouter) => { version: '2', validate: false, }, - createDummyHandler('2') + (ctx, req, res) => { + return res.ok({ body: { result: 'Called API version 2' } }); + } ); }; diff --git a/examples/routing_example/tsconfig.json b/examples/routing_example/tsconfig.json index 86bfda9d3d529..b35e8dbd34f4a 100644 --- a/examples/routing_example/tsconfig.json +++ b/examples/routing_example/tsconfig.json @@ -20,6 +20,5 @@ "@kbn/core-http-browser", "@kbn/config-schema", "@kbn/react-kibana-context-render", - "@kbn/core-http-server", ] } diff --git a/oas_docs/README.md b/oas_docs/README.md index e37eefaed4851..3312bc60771e0 100644 --- a/oas_docs/README.md +++ b/oas_docs/README.md @@ -45,8 +45,7 @@ Besides the scripts in the `oas_docs/scripts` folder, there is an `oas_docs/make | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `api-docs` | Builds ESS Kibana OpenAPI bundle | | `api-docs-serverless` | Builds Serverless Kibana OpenAPI bundle | -| `api-docs-lint` | Lints built result bundles | -| `api-docs-lint-errs` | Lints built result bundles for errors | +| `api-docs-lint` | Lints built result bundles | | `api-docs-preview` | Generates (ESS + Serverless) Kibana OpenAPI bundles preview | | `api-docs-overlay` | Applies [overlays](https://docs.bump.sh/help/specification-support/overlays/) from `overlays` folder to the Kibana OpenAPI bundles and generate `*.new.yaml` files. Overlays help to fine tune the result bundles. | | `api-docs-overlay-preview` | Generates a preview for bundles produced by `api-docs-overlay` | diff --git a/oas_docs/bundle.json b/oas_docs/bundle.json index 7a43eed976fd8..95965f1f96804 100644 --- a/oas_docs/bundle.json +++ b/oas_docs/bundle.json @@ -347,7 +347,7 @@ "/api/actions": { "get": { "deprecated": true, - "operationId": "%2Fapi%2Factions#0", + "operationId": "get-actions", "parameters": [ { "description": "The version of the API to use", @@ -372,7 +372,7 @@ "/api/actions/action": { "post": { "deprecated": true, - "operationId": "%2Fapi%2Factions%2Faction#0", + "operationId": "post-actions-action", "parameters": [ { "description": "The version of the API to use", @@ -496,7 +496,7 @@ "delete": { "deprecated": true, "description": "WARNING: When you delete a connector, it cannot be recovered.", - "operationId": "%2Fapi%2Factions%2Faction%2F%7Bid%7D#0", + "operationId": "delete-actions-action-id", "parameters": [ { "description": "The version of the API to use", @@ -542,7 +542,7 @@ }, "get": { "deprecated": true, - "operationId": "%2Fapi%2Factions%2Faction%2F%7Bid%7D#1", + "operationId": "get-actions-action-id", "parameters": [ { "description": "The version of the API to use", @@ -628,7 +628,7 @@ }, "put": { "deprecated": true, - "operationId": "%2Fapi%2Factions%2Faction%2F%7Bid%7D#2", + "operationId": "put-actions-action-id", "parameters": [ { "description": "The version of the API to use", @@ -754,7 +754,7 @@ "/api/actions/action/{id}/_execute": { "post": { "deprecated": true, - "operationId": "%2Fapi%2Factions%2Faction%2F%7Bid%7D%2F_execute#0", + "operationId": "post-actions-action-id-execute", "parameters": [ { "description": "The version of the API to use", @@ -871,7 +871,7 @@ "/api/actions/connector/{id}": { "delete": { "description": "WARNING: When you delete a connector, it cannot be recovered.", - "operationId": "%2Fapi%2Factions%2Fconnector%2F%7Bid%7D#0", + "operationId": "delete-actions-connector-id", "parameters": [ { "description": "The version of the API to use", @@ -916,7 +916,7 @@ ] }, "get": { - "operationId": "%2Fapi%2Factions%2Fconnector%2F%7Bid%7D#1", + "operationId": "get-actions-connector-id", "parameters": [ { "description": "The version of the API to use", @@ -1001,7 +1001,7 @@ ] }, "post": { - "operationId": "%2Fapi%2Factions%2Fconnector%2F%7Bid%3F%7D#0", + "operationId": "post-actions-connector-id", "parameters": [ { "description": "The version of the API to use", @@ -1130,7 +1130,7 @@ ] }, "put": { - "operationId": "%2Fapi%2Factions%2Fconnector%2F%7Bid%7D#2", + "operationId": "put-actions-connector-id", "parameters": [ { "description": "The version of the API to use", @@ -1257,7 +1257,7 @@ "/api/actions/connector/{id}/_execute": { "post": { "description": "You can use this API to test an action that involves interaction with Kibana services or integrations with third-party systems.", - "operationId": "%2Fapi%2Factions%2Fconnector%2F%7Bid%7D%2F_execute#0", + "operationId": "post-actions-connector-id-execute", "parameters": [ { "description": "The version of the API to use", @@ -1374,7 +1374,7 @@ "/api/actions/connector_types": { "get": { "description": "You do not need any Kibana feature privileges to run this API.", - "operationId": "%2Fapi%2Factions%2Fconnector_types#0", + "operationId": "get-actions-connector-types", "parameters": [ { "description": "The version of the API to use", @@ -1407,7 +1407,7 @@ }, "/api/actions/connectors": { "get": { - "operationId": "%2Fapi%2Factions%2Fconnectors#0", + "operationId": "get-actions-connectors", "parameters": [ { "description": "The version of the API to use", @@ -1432,7 +1432,7 @@ "/api/actions/list_action_types": { "get": { "deprecated": true, - "operationId": "%2Fapi%2Factions%2Flist_action_types#0", + "operationId": "get-actions-list-action-types", "parameters": [ { "description": "The version of the API to use", @@ -1456,7 +1456,7 @@ }, "/api/alerting/rule/{id}": { "delete": { - "operationId": "%2Fapi%2Falerting%2Frule%2F%7Bid%7D#2", + "operationId": "delete-alerting-rule-id", "parameters": [ { "description": "The version of the API to use", @@ -1510,7 +1510,7 @@ ] }, "get": { - "operationId": "%2Fapi%2Falerting%2Frule%2F%7Bid%7D#0", + "operationId": "get-alerting-rule-id", "parameters": [ { "description": "The version of the API to use", @@ -2388,7 +2388,7 @@ ] }, "post": { - "operationId": "%2Fapi%2Falerting%2Frule%2F%7Bid%3F%7D#0", + "operationId": "post-alerting-rule-id", "parameters": [ { "description": "The version of the API to use", @@ -3568,7 +3568,7 @@ ] }, "put": { - "operationId": "%2Fapi%2Falerting%2Frule%2F%7Bid%7D#1", + "operationId": "put-alerting-rule-id", "parameters": [ { "description": "The version of the API to use", @@ -4736,7 +4736,7 @@ }, "/api/alerting/rule/{id}/_disable": { "post": { - "operationId": "%2Fapi%2Falerting%2Frule%2F%7Bid%7D%2F_disable#0", + "operationId": "post-alerting-rule-id-disable", "parameters": [ { "description": "The version of the API to use", @@ -4810,7 +4810,7 @@ }, "/api/alerting/rule/{id}/_enable": { "post": { - "operationId": "%2Fapi%2Falerting%2Frule%2F%7Bid%7D%2F_enable#0", + "operationId": "post-alerting-rule-id-enable", "parameters": [ { "description": "The version of the API to use", @@ -4866,7 +4866,7 @@ }, "/api/alerting/rule/{id}/_mute_all": { "post": { - "operationId": "%2Fapi%2Falerting%2Frule%2F%7Bid%7D%2F_mute_all#0", + "operationId": "post-alerting-rule-id-mute-all", "parameters": [ { "description": "The version of the API to use", @@ -4922,7 +4922,7 @@ }, "/api/alerting/rule/{id}/_unmute_all": { "post": { - "operationId": "%2Fapi%2Falerting%2Frule%2F%7Bid%7D%2F_unmute_all#0", + "operationId": "post-alerting-rule-id-unmute-all", "parameters": [ { "description": "The version of the API to use", @@ -4978,7 +4978,7 @@ }, "/api/alerting/rule/{id}/_update_api_key": { "post": { - "operationId": "%2Fapi%2Falerting%2Frule%2F%7Bid%7D%2F_update_api_key#0", + "operationId": "post-alerting-rule-id-update-api-key", "parameters": [ { "description": "The version of the API to use", @@ -5037,7 +5037,7 @@ }, "/api/alerting/rule/{rule_id}/alert/{alert_id}/_mute": { "post": { - "operationId": "%2Fapi%2Falerting%2Frule%2F%7Brule_id%7D%2Falert%2F%7Balert_id%7D%2F_mute#0", + "operationId": "post-alerting-rule-rule-id-alert-alert-id-mute", "parameters": [ { "description": "The version of the API to use", @@ -5102,7 +5102,7 @@ }, "/api/alerting/rule/{rule_id}/alert/{alert_id}/_unmute": { "post": { - "operationId": "%2Fapi%2Falerting%2Frule%2F%7Brule_id%7D%2Falert%2F%7Balert_id%7D%2F_unmute#0", + "operationId": "post-alerting-rule-rule-id-alert-alert-id-unmute", "parameters": [ { "description": "The version of the API to use", @@ -5167,7 +5167,7 @@ }, "/api/alerting/rules/_find": { "get": { - "operationId": "%2Fapi%2Falerting%2Frules%2F_find#0", + "operationId": "get-alerting-rules-find", "parameters": [ { "description": "The version of the API to use", @@ -6177,7 +6177,7 @@ }, "/api/security/role": { "get": { - "operationId": "%2Fapi%2Fsecurity%2Frole#0", + "operationId": "get-security-role", "parameters": [ { "description": "The version of the API to use", @@ -6214,7 +6214,7 @@ }, "/api/security/role/{name}": { "delete": { - "operationId": "%2Fapi%2Fsecurity%2Frole%2F%7Bname%7D#1", + "operationId": "delete-security-role-name", "parameters": [ { "description": "The version of the API to use", @@ -6259,7 +6259,7 @@ ] }, "get": { - "operationId": "%2Fapi%2Fsecurity%2Frole%2F%7Bname%7D#0", + "operationId": "get-security-role-name", "parameters": [ { "description": "The version of the API to use", @@ -6305,7 +6305,7 @@ }, "put": { "description": "Create a new Kibana role or update the attributes of an existing role. Kibana roles are stored in the Elasticsearch native realm.", - "operationId": "%2Fapi%2Fsecurity%2Frole%2F%7Bname%7D#2", + "operationId": "put-security-role-name", "parameters": [ { "description": "The version of the API to use", @@ -6624,7 +6624,7 @@ }, "/api/security/roles": { "post": { - "operationId": "%2Fapi%2Fsecurity%2Froles#0", + "operationId": "post-security-roles", "parameters": [ { "description": "The version of the API to use", @@ -6934,8 +6934,8 @@ }, "/api/spaces/_copy_saved_objects": { "post": { - "description": "It also allows you to automatically copy related objects, so when you copy a dashboard, this can automatically copy over the associated visualizations, data views, and saved searches, as required. You can request to overwrite any objects that already exist in the target space if they share an identifier or you can use the resolve copy saved objects conflicts API to do this on a per-object basis.", - "operationId": "%2Fapi%2Fspaces%2F_copy_saved_objects#0", + "description": "It also allows you to automatically copy related objects, so when you copy a dashboard, this can automatically copy over the associated visualizations, data views, and saved searches, as required. You can request to overwrite any objects that already exist in the target space if they share an identifier or you can use the resolve copy saved objects conflicts API to do this on a per-object basis.

[Required authorization] Route required privileges: ALL of [copySavedObjectsToSpaces].", + "operationId": "post-spaces-copy-saved-objects", "parameters": [ { "description": "The version of the API to use", @@ -7033,7 +7033,7 @@ }, "/api/spaces/_disable_legacy_url_aliases": { "post": { - "operationId": "%2Fapi%2Fspaces%2F_disable_legacy_url_aliases#0", + "operationId": "post-spaces-disable-legacy-url-aliases", "parameters": [ { "description": "The version of the API to use", @@ -7109,7 +7109,7 @@ "/api/spaces/_get_shareable_references": { "post": { "description": "Collect references and space contexts for saved objects.", - "operationId": "%2Fapi%2Fspaces%2F_get_shareable_references#0", + "operationId": "post-spaces-get-shareable-references", "parameters": [ { "description": "The version of the API to use", @@ -7177,8 +7177,8 @@ }, "/api/spaces/_resolve_copy_saved_objects_errors": { "post": { - "description": "Overwrite saved objects that are returned as errors from the copy saved objects to space API.", - "operationId": "%2Fapi%2Fspaces%2F_resolve_copy_saved_objects_errors#0", + "description": "Overwrite saved objects that are returned as errors from the copy saved objects to space API.

[Required authorization] Route required privileges: ALL of [copySavedObjectsToSpaces].", + "operationId": "post-spaces-resolve-copy-saved-objects-errors", "parameters": [ { "description": "The version of the API to use", @@ -7299,7 +7299,7 @@ "/api/spaces/_update_objects_spaces": { "post": { "description": "Update one or more saved objects to add or remove them from some spaces.", - "operationId": "%2Fapi%2Fspaces%2F_update_objects_spaces#0", + "operationId": "post-spaces-update-objects-spaces", "parameters": [ { "description": "The version of the API to use", @@ -7385,7 +7385,7 @@ }, "/api/spaces/space": { "get": { - "operationId": "%2Fapi%2Fspaces%2Fspace#0", + "operationId": "get-spaces-space", "parameters": [ { "description": "The version of the API to use", @@ -7465,7 +7465,7 @@ ] }, "post": { - "operationId": "%2Fapi%2Fspaces%2Fspace#1", + "operationId": "post-spaces-space", "parameters": [ { "description": "The version of the API to use", @@ -7566,7 +7566,7 @@ "/api/spaces/space/{id}": { "delete": { "description": "When you delete a space, all saved objects that belong to the space are automatically deleted, which is permanent and cannot be undone.", - "operationId": "%2Fapi%2Fspaces%2Fspace%2F%7Bid%7D#2", + "operationId": "delete-spaces-space-id", "parameters": [ { "description": "The version of the API to use", @@ -7614,7 +7614,7 @@ ] }, "get": { - "operationId": "%2Fapi%2Fspaces%2Fspace%2F%7Bid%7D#0", + "operationId": "get-spaces-space-id", "parameters": [ { "description": "The version of the API to use", @@ -7649,7 +7649,7 @@ ] }, "put": { - "operationId": "%2Fapi%2Fspaces%2Fspace%2F%7Bid%7D#1", + "operationId": "put-spaces-space-id", "parameters": [ { "description": "The version of the API to use", @@ -7758,7 +7758,7 @@ }, "/api/status": { "get": { - "operationId": "%2Fapi%2Fstatus#0", + "operationId": "get-status", "parameters": [ { "description": "The version of the API to use", diff --git a/oas_docs/kibana.info.serverless.yaml b/oas_docs/kibana.info.serverless.yaml deleted file mode 100644 index b2f451373e7a1..0000000000000 --- a/oas_docs/kibana.info.serverless.yaml +++ /dev/null @@ -1,51 +0,0 @@ -openapi: 3.0.3 -info: - title: Kibana Serverless APIs - description: | - **Technical preview** - This functionality is in technical preview and may be changed or removed in a future release. - Elastic will work to fix any issues, but features in technical preview are not subject to the support SLA of official GA features. - - The Kibana REST APIs for Elastic serverless enable you to manage resources - such as connectors, data views, and saved objects. The API calls are - stateless. Each request that you make happens in isolation from other calls - and must include all of the necessary information for Kibana to fulfill the - request. API requests return JSON output, which is a format that is - machine-readable and works well for automation. - - To interact with Kibana APIs, use the following operations: - - - GET: Fetches the information. - - POST: Adds new information. - - PUT: Updates the existing information. - - DELETE: Removes the information. - - You can prepend any Kibana API endpoint with `kbn:` and run the request in - **Dev Tools → Console**. For example: - - ``` - GET kbn:/api/data_views - ``` - - ## Documentation source and versions - - This documentation is derived from the `main` branch of the [kibana](https://github.com/elastic/kibana) repository. - It is provided under license [Attribution-NonCommercial-NoDerivatives 4.0 International](https://creativecommons.org/licenses/by-nc-nd/4.0/). - version: "1.0.2" - x-doc-license: - name: Attribution-NonCommercial-NoDerivatives 4.0 International - url: https://creativecommons.org/licenses/by-nc-nd/4.0/ - contact: - name: Kibana Team - x-feedbackLink: - label: Feedback - url: https://github.com/elastic/docs-content/issues/new?assignees=&labels=feedback%2Ccommunity&projects=&template=api-feedback.yaml&title=%5BFeedback%5D%3A+ -security: - - apiKeyAuth: [] -components: - securitySchemes: - apiKeyAuth: - type: apiKey - in: header - name: Authorization - description: You must create an API key and use the encoded value in the request header. To learn about creating keys, go to [API keys](https://www.elastic.co/docs/current/serverless/api-keys). diff --git a/oas_docs/.spectral.yaml b/oas_docs/linters/.spectral.yaml similarity index 100% rename from oas_docs/.spectral.yaml rename to oas_docs/linters/.spectral.yaml diff --git a/oas_docs/linters/redocly.yaml b/oas_docs/linters/redocly.yaml new file mode 100644 index 0000000000000..139a503ba856c --- /dev/null +++ b/oas_docs/linters/redocly.yaml @@ -0,0 +1,52 @@ +extends: + - recommended +rules: +# Built-in rules + # Descriptions + parameter-description: warn + tag-description: warn + operation-description: off + # Document info + info-contact: warn + info-license: warn + # Examples + no-invalid-media-type-examples: + severity: warn + allowAdditionalProperties: false + no-invalid-schema-examples: + severity: warn + allowAdditionalProperties: false + # Operations + operation-operationId: error + operation-operationId-unique: error + operation-operationId-url-safe: warn + operation-summary: warn + # Parameters + path-parameters-defined: warn + # Paths + no-ambiguous-paths: warn + no-identical-paths: error + path-excludes-patterns: + severity: error + patterns: + - ^\/internal + # Responses + operation-4xx-response: off + operation-2xx-response: off + # Schema + spec: off + spec-strict-refs: off + # Tags + operation-tag-defined: off + tags-alphabetical: off + operation-singular-tag: off +# Custom rules + rule/operation-summary-length: + subject: + type: Operation + property: summary + message: Operation summary must have a minimum of 5 and maximum of 45 characters + severity: warn + assertions: + maxLength: 45 + minLength: 5 \ No newline at end of file diff --git a/oas_docs/makefile b/oas_docs/makefile index 7b690e4c07593..c97b5046c62a9 100644 --- a/oas_docs/makefile +++ b/oas_docs/makefile @@ -21,50 +21,30 @@ api-docs: ## Generate ESS Kibana OpenAPI bundles with kbn-openapi-bundler api-docs-stateful: ## Generate only kibana.yaml @node scripts/merge_ess_oas.js -.PHONY: api-docs-serverless -api-docs-serverless: ## Generate only kibana.serverless.yaml - @node scripts/merge_serverless_oas.js - .PHONY: api-docs-lint -api-docs-lint: ## Run spectral API docs linter - @npx @stoplight/spectral-cli lint "output/*.yaml" --ruleset ".spectral.yaml" - -.PHONY: api-docs-lint-errs -api-docs-lint-errs: ## Run spectral API docs linter and return only errors - @npx @stoplight/spectral-cli lint "output/*.yaml" --ruleset ".spectral.yaml" -D +api-docs-lint: ## Run redocly API docs linter + @npx @redocly/cli lint "output/*.yaml" --config "linters/redocly.yaml" --format stylish --max-problems 500 .PHONY: api-docs-lint-stateful -api-docs-lint-stateful: ## Run spectral API docs linter on kibana.yaml - @npx @stoplight/spectral-cli lint "output/kibana.yaml" --ruleset ".spectral.yaml" - -.PHONY: api-docs-lint-serverless -api-docs-lint-serverless: ## Run spectral API docs linter on kibana.serverless.yaml - @npx @stoplight/spectral-cli lint "output/kibana.serverless.yaml" --ruleset ".spectral.yaml" +api-docs-lint-stateful: ## Run redocly API docs linter on kibana.yaml + @npx @redocly/cli lint "output/kibana.yaml" --config "linters/redocly.yaml" --format stylish --max-problems 500 .PHONY: api-docs-overlay -api-docs-overlay: ## Run spectral API docs linter on kibana.serverless.yaml - @npx bump overlay "output/kibana.serverless.yaml" "overlays/kibana.overlays.serverless.yaml" > "output/kibana.serverless.tmp1.yaml" - @npx bump overlay "output/kibana.serverless.tmp1.yaml" "overlays/alerting.overlays.yaml" > "output/kibana.serverless.tmp2.yaml" - @npx bump overlay "output/kibana.serverless.tmp2.yaml" "overlays/connectors.overlays.yaml" > "output/kibana.serverless.tmp3.yaml" - @npx bump overlay "output/kibana.serverless.tmp3.yaml" "overlays/kibana.overlays.shared.yaml" > "output/kibana.serverless.tmp4.yaml" +api-docs-overlay: ## Run spectral API docs linter @npx bump overlay "output/kibana.yaml" "overlays/kibana.overlays.yaml" > "output/kibana.tmp1.yaml" @npx bump overlay "output/kibana.tmp1.yaml" "overlays/alerting.overlays.yaml" > "output/kibana.tmp2.yaml" @npx bump overlay "output/kibana.tmp2.yaml" "overlays/connectors.overlays.yaml" > "output/kibana.tmp3.yaml" @npx bump overlay "output/kibana.tmp3.yaml" "overlays/kibana.overlays.shared.yaml" > "output/kibana.tmp4.yaml" - @npx @redocly/cli bundle output/kibana.serverless.tmp4.yaml --ext yaml -o output/kibana.serverless.new.yaml @npx @redocly/cli bundle output/kibana.tmp4.yaml --ext yaml -o output/kibana.new.yaml rm output/kibana.tmp*.yaml - rm output/kibana.serverless.tmp*.yaml .PHONY: api-docs-preview -api-docs-preview: ## Generate a preview for kibana.yaml and kibana.serverless.yaml +api-docs-preview: ## Generate a preview for kibana.yaml @npx bump preview "output/kibana.yaml" - @npx bump preview "output/kibana.serverless.yaml" .PHONY: api-docs-overlay-preview -api-docs-overlay-preview: ## Generate a preview for kibana.new.yaml and kibana.serverless.new.yaml +api-docs-overlay-preview: ## Generate a preview for kibana.new.yaml @npx bump preview "output/kibana.new.yaml" - @npx bump preview "output/kibana.serverless.new.yaml" help: ## Display help @awk 'BEGIN {FS = ":.*##"; printf "Usage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) diff --git a/oas_docs/output/kibana.serverless.staging.yaml b/oas_docs/output/kibana.serverless.staging.yaml deleted file mode 100644 index e69de29bb2d1d..0000000000000 diff --git a/oas_docs/output/kibana.staging.yaml b/oas_docs/output/kibana.staging.yaml deleted file mode 100644 index e69de29bb2d1d..0000000000000 diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index 3fe73a417aae1..38b99fdf1018b 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -78,7 +78,7 @@ paths: /api/actions: get: deprecated: true - operationId: '%2Fapi%2Factions#0' + operationId: get-actions parameters: - description: The version of the API to use in: header @@ -95,7 +95,7 @@ paths: /api/actions/action: post: deprecated: true - operationId: '%2Fapi%2Factions%2Faction#0' + operationId: post-actions-action parameters: - description: The version of the API to use in: header @@ -188,7 +188,7 @@ paths: delete: deprecated: true description: 'WARNING: When you delete a connector, it cannot be recovered.' - operationId: '%2Fapi%2Factions%2Faction%2F%7Bid%7D#0' + operationId: delete-actions-action-id parameters: - description: The version of the API to use in: header @@ -219,7 +219,7 @@ paths: - connectors get: deprecated: true - operationId: '%2Fapi%2Factions%2Faction%2F%7Bid%7D#1' + operationId: get-actions-action-id parameters: - description: The version of the API to use in: header @@ -285,7 +285,7 @@ paths: - connectors put: deprecated: true - operationId: '%2Fapi%2Factions%2Faction%2F%7Bid%7D#2' + operationId: put-actions-action-id parameters: - description: The version of the API to use in: header @@ -378,7 +378,7 @@ paths: '/api/actions/action/{id}/_execute': post: deprecated: true - operationId: '%2Fapi%2Factions%2Faction%2F%7Bid%7D%2F_execute#0' + operationId: post-actions-action-id-execute parameters: - description: The version of the API to use in: header @@ -464,7 +464,7 @@ paths: /api/actions/connector_types: get: description: You do not need any Kibana feature privileges to run this API. - operationId: '%2Fapi%2Factions%2Fconnector_types#0' + operationId: get-actions-connector-types parameters: - description: The version of the API to use in: header @@ -489,7 +489,7 @@ paths: '/api/actions/connector/{id}': delete: description: 'WARNING: When you delete a connector, it cannot be recovered.' - operationId: '%2Fapi%2Factions%2Fconnector%2F%7Bid%7D#0' + operationId: delete-actions-connector-id parameters: - description: The version of the API to use in: header @@ -519,7 +519,7 @@ paths: tags: - connectors get: - operationId: '%2Fapi%2Factions%2Fconnector%2F%7Bid%7D#1' + operationId: get-actions-connector-id parameters: - description: The version of the API to use in: header @@ -584,7 +584,7 @@ paths: tags: - connectors post: - operationId: '%2Fapi%2Factions%2Fconnector%2F%7Bid%3F%7D#0' + operationId: post-actions-connector-id parameters: - description: The version of the API to use in: header @@ -680,7 +680,7 @@ paths: tags: - connectors put: - operationId: '%2Fapi%2Factions%2Fconnector%2F%7Bid%7D#2' + operationId: put-actions-connector-id parameters: - description: The version of the API to use in: header @@ -776,7 +776,7 @@ paths: description: >- You can use this API to test an action that involves interaction with Kibana services or integrations with third-party systems. - operationId: '%2Fapi%2Factions%2Fconnector%2F%7Bid%7D%2F_execute#0' + operationId: post-actions-connector-id-execute parameters: - description: The version of the API to use in: header @@ -861,7 +861,7 @@ paths: - connectors /api/actions/connectors: get: - operationId: '%2Fapi%2Factions%2Fconnectors#0' + operationId: get-actions-connectors parameters: - description: The version of the API to use in: header @@ -878,7 +878,7 @@ paths: /api/actions/list_action_types: get: deprecated: true - operationId: '%2Fapi%2Factions%2Flist_action_types#0' + operationId: get-actions-list-action-types parameters: - description: The version of the API to use in: header @@ -1282,7 +1282,7 @@ paths: - alerting '/api/alerting/rule/{id}': delete: - operationId: '%2Fapi%2Falerting%2Frule%2F%7Bid%7D#2' + operationId: delete-alerting-rule-id parameters: - description: The version of the API to use in: header @@ -1318,7 +1318,7 @@ paths: tags: - alerting get: - operationId: '%2Fapi%2Falerting%2Frule%2F%7Bid%7D#0' + operationId: get-alerting-rule-id parameters: - description: The version of the API to use in: header @@ -2116,7 +2116,7 @@ paths: tags: - alerting post: - operationId: '%2Fapi%2Falerting%2Frule%2F%7Bid%3F%7D#0' + operationId: post-alerting-rule-id parameters: - description: The version of the API to use in: header @@ -3239,7 +3239,7 @@ paths: tags: - alerting put: - operationId: '%2Fapi%2Falerting%2Frule%2F%7Bid%7D#1' + operationId: put-alerting-rule-id parameters: - description: The version of the API to use in: header @@ -4336,7 +4336,7 @@ paths: - alerting '/api/alerting/rule/{id}/_disable': post: - operationId: '%2Fapi%2Falerting%2Frule%2F%7Bid%7D%2F_disable#0' + operationId: post-alerting-rule-id-disable parameters: - description: The version of the API to use in: header @@ -4385,7 +4385,7 @@ paths: - alerting '/api/alerting/rule/{id}/_enable': post: - operationId: '%2Fapi%2Falerting%2Frule%2F%7Bid%7D%2F_enable#0' + operationId: post-alerting-rule-id-enable parameters: - description: The version of the API to use in: header @@ -4422,7 +4422,7 @@ paths: - alerting '/api/alerting/rule/{id}/_mute_all': post: - operationId: '%2Fapi%2Falerting%2Frule%2F%7Bid%7D%2F_mute_all#0' + operationId: post-alerting-rule-id-mute-all parameters: - description: The version of the API to use in: header @@ -4459,7 +4459,7 @@ paths: - alerting '/api/alerting/rule/{id}/_unmute_all': post: - operationId: '%2Fapi%2Falerting%2Frule%2F%7Bid%7D%2F_unmute_all#0' + operationId: post-alerting-rule-id-unmute-all parameters: - description: The version of the API to use in: header @@ -4496,7 +4496,7 @@ paths: - alerting '/api/alerting/rule/{id}/_update_api_key': post: - operationId: '%2Fapi%2Falerting%2Frule%2F%7Bid%7D%2F_update_api_key#0' + operationId: post-alerting-rule-id-update-api-key parameters: - description: The version of the API to use in: header @@ -4535,8 +4535,7 @@ paths: - alerting '/api/alerting/rule/{rule_id}/alert/{alert_id}/_mute': post: - operationId: >- - %2Fapi%2Falerting%2Frule%2F%7Brule_id%7D%2Falert%2F%7Balert_id%7D%2F_mute#0 + operationId: post-alerting-rule-rule-id-alert-alert-id-mute parameters: - description: The version of the API to use in: header @@ -4579,8 +4578,7 @@ paths: - alerting '/api/alerting/rule/{rule_id}/alert/{alert_id}/_unmute': post: - operationId: >- - %2Fapi%2Falerting%2Frule%2F%7Brule_id%7D%2Falert%2F%7Balert_id%7D%2F_unmute#0 + operationId: post-alerting-rule-rule-id-alert-alert-id-unmute parameters: - description: The version of the API to use in: header @@ -4623,7 +4621,7 @@ paths: - alerting /api/alerting/rules/_find: get: - operationId: '%2Fapi%2Falerting%2Frules%2F_find#0' + operationId: get-alerting-rules-find parameters: - description: The version of the API to use in: header @@ -6318,49 +6316,110 @@ paths: post: description: Create a new agent key for APM. operationId: createAgentKey + parameters: + - $ref: '#/components/parameters/APM_UI_elastic_api_version' + - $ref: '#/components/parameters/APM_UI_kbn_xsrf' requestBody: content: application/json; Elastic-Api-Version=2023-10-31: schema: - type: object - properties: - name: - type: string - privileges: - items: - enum: - - 'event:write' - - 'config_agent:read' - type: string - type: array + $ref: '#/components/schemas/APM_UI_agent_keys_object' required: true responses: '200': content: application/json; Elastic-Api-Version=2023-10-31: schema: - type: object - properties: - api_key: - type: string - encoded: - type: string - expiration: - format: int64 - type: integer - id: - type: string - name: - type: string + $ref: '#/components/schemas/APM_UI_agent_keys_response' description: Agent key created successfully + '400': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_400_response' + description: Bad Request response + '401': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_401_response' + description: Unauthorized response + '403': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_403_response' + description: Forbidden response + '500': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_500_response' + description: Internal Server Error response summary: Create an APM agent key tags: - APM agent keys + /api/apm/fleet/apm_server_schema: + post: + operationId: saveApmServerSchema + parameters: + - $ref: '#/components/parameters/APM_UI_elastic_api_version' + - $ref: '#/components/parameters/APM_UI_kbn_xsrf' + requestBody: + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + type: object + properties: + schema: + additionalProperties: true + description: Schema object + example: + foo: bar + type: object + required: true + responses: + '200': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + additionalProperties: false + type: object + description: Successful response + '400': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_400_response' + description: Bad Request response + '401': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_401_response' + description: Unauthorized response + '403': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_403_response' + description: Forbidden response + '404': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_404_response' + description: Not found response + summary: Save APM server schema + tags: + - APM server schema '/api/apm/services/{serviceName}/annotation': post: description: Create a new annotation for a specific service. operationId: createAnnotation parameters: + - $ref: '#/components/parameters/APM_UI_elastic_api_version' + - $ref: '#/components/parameters/APM_UI_kbn_xsrf' - description: The name of the service in: path name: serviceName @@ -6371,63 +6430,39 @@ paths: content: application/json; Elastic-Api-Version=2023-10-31: schema: - type: object - properties: - '@timestamp': - type: string - message: - type: string - service: - type: object - properties: - environment: - type: string - version: - type: string - tags: - items: - type: string - type: array + $ref: '#/components/schemas/APM_UI_create_annotation_object' required: true responses: '200': content: application/json; Elastic-Api-Version=2023-10-31: schema: - type: object - properties: - _id: - type: string - _index: - type: string - _source: - type: object - properties: - '@timestamp': - type: string - annotation: - type: string - event: - type: object - properties: - created: - type: string - message: - type: string - service: - type: object - properties: - environment: - type: string - name: - type: string - version: - type: string - tags: - items: - type: string - type: array + $ref: '#/components/schemas/APM_UI_create_annotation_response' description: Annotation created successfully + '400': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_400_response' + description: Bad Request response + '401': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_401_response' + description: Unauthorized response + '403': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_403_response' + description: Forbidden response + '404': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_404_response' + description: Not found response summary: Create a service annotation tags: - APM annotations @@ -6436,6 +6471,7 @@ paths: description: Search for annotations related to a specific service. operationId: getAnnotation parameters: + - $ref: '#/components/parameters/APM_UI_elastic_api_version' - description: The name of the service in: path name: serviceName @@ -6465,27 +6501,484 @@ paths: content: application/json; Elastic-Api-Version=2023-10-31: schema: - type: object - properties: - annotations: - items: - type: object - properties: - '@timestamp': - type: number - id: - type: string - text: - type: string - type: - enum: - - version - type: string - type: array + $ref: '#/components/schemas/APM_UI_annotation_search_response' description: Successful response + '400': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_400_response' + description: Bad Request response + '401': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_401_response' + description: Unauthorized response + '500': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_500_response' + description: Internal Server Error response summary: Search for annotations tags: - APM annotations + /api/apm/settings/agent-configuration: + delete: + operationId: deleteAgentConfiguration + parameters: + - $ref: '#/components/parameters/APM_UI_elastic_api_version' + - $ref: '#/components/parameters/APM_UI_kbn_xsrf' + requestBody: + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_service_object' + required: true + responses: + '200': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: >- + #/components/schemas/APM_UI_delete_agent_configurations_response + description: Successful response + '400': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_400_response' + description: Bad Request response + '401': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_401_response' + description: Unauthorized response + '403': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_403_response' + description: Forbidden response + '404': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_404_response' + description: Not found response + summary: Delete agent configuration + tags: + - APM agent configuration + get: + operationId: getAgentConfigurations + parameters: + - $ref: '#/components/parameters/APM_UI_elastic_api_version' + responses: + '200': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_agent_configurations_response' + description: Successful response + '400': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_400_response' + description: Bad Request response + '401': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_401_response' + description: Unauthorized response + '404': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_404_response' + description: Not found response + summary: Get a list of agent configurations + tags: + - APM agent configuration + put: + operationId: createUpdateAgentConfiguration + parameters: + - $ref: '#/components/parameters/APM_UI_elastic_api_version' + - $ref: '#/components/parameters/APM_UI_kbn_xsrf' + - description: If the config exists ?overwrite=true is required + in: query + name: overwrite + schema: + type: boolean + requestBody: + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_agent_configuration_intake_object' + required: true + responses: + '200': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + additionalProperties: false + type: object + description: Successful response + '400': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_400_response' + description: Bad Request response + '401': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_401_response' + description: Unauthorized response + '403': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_403_response' + description: Forbidden response + '404': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_404_response' + description: Not found response + summary: Create or update agent configuration + tags: + - APM agent configuration + /api/apm/settings/agent-configuration/agent_name: + get: + description: Retrieve `agentName` for a service. + operationId: getAgentNameForService + parameters: + - $ref: '#/components/parameters/APM_UI_elastic_api_version' + - description: The name of the service + example: node + in: query + name: serviceName + required: true + schema: + type: string + responses: + '200': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_service_agent_name_response' + description: Successful response + '400': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_400_response' + description: Bad Request response + '401': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_401_response' + description: Unauthorized response + '404': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_404_response' + description: Not found response + summary: Get agent name for service + tags: + - APM agent configuration + /api/apm/settings/agent-configuration/environments: + get: + operationId: getEnvironmentsForService + parameters: + - $ref: '#/components/parameters/APM_UI_elastic_api_version' + - description: The name of the service + in: query + name: serviceName + schema: + type: string + responses: + '200': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_service_environments_response' + description: Successful response + '400': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_400_response' + description: Bad Request response + '401': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_401_response' + description: Unauthorized response + '404': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_404_response' + description: Not found response + summary: Get environments for service + tags: + - APM agent configuration + /api/apm/settings/agent-configuration/search: + post: + description: > + This endpoint allows to search for single agent configuration and update + 'applied_by_agent' field. + operationId: searchSingleConfiguration + parameters: + - $ref: '#/components/parameters/APM_UI_elastic_api_version' + - $ref: '#/components/parameters/APM_UI_kbn_xsrf' + requestBody: + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_search_agent_configuration_object' + required: true + responses: + '200': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: >- + #/components/schemas/APM_UI_search_agent_configuration_response + description: Successful response + '400': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_400_response' + description: Bad Request response + '401': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_401_response' + description: Unauthorized response + '404': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_404_response' + description: Not found response + summary: Lookup single agent configuration + tags: + - APM agent configuration + /api/apm/settings/agent-configuration/view: + get: + operationId: getSingleAgentConfiguration + parameters: + - $ref: '#/components/parameters/APM_UI_elastic_api_version' + - description: Service name + example: node + in: query + name: name + schema: + type: string + - description: Service environment + example: prod + in: query + name: environment + schema: + type: string + responses: + '200': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: >- + #/components/schemas/APM_UI_single_agent_configuration_response + description: Successful response + '400': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_400_response' + description: Bad Request response + '401': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_401_response' + description: Unauthorized response + '404': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_404_response' + description: Not found response + summary: Get single agent configuration + tags: + - APM agent configuration + /api/apm/sourcemaps: + get: + description: 'Returns an array of Fleet artifacts, including source map uploads.' + operationId: getSourceMaps + parameters: + - $ref: '#/components/parameters/APM_UI_elastic_api_version' + - description: Page number + in: query + name: page + schema: + type: number + - description: Number of records per page + in: query + name: perPage + schema: + type: number + responses: + '200': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_source_maps_response' + description: Successful response + '400': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_400_response' + description: Bad Request response + '401': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_401_response' + description: Unauthorized response + '500': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_500_response' + description: Internal Server Error response + '501': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_501_response' + description: Not Implemented response + summary: Get source maps + tags: + - APM sourcemaps + post: + description: Upload a source map for a specific service and version. + operationId: uploadSourceMap + parameters: + - $ref: '#/components/parameters/APM_UI_elastic_api_version' + - $ref: '#/components/parameters/APM_UI_kbn_xsrf' + requestBody: + content: + multipart/form-data; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_upload_source_map_object' + required: true + responses: + '200': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_upload_source_maps_response' + description: Successful response + '400': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_400_response' + description: Bad Request response + '401': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_401_response' + description: Unauthorized response + '403': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_403_response' + description: Forbidden response + '500': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_500_response' + description: Internal Server Error response + '501': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_501_response' + description: Not Implemented response + summary: Upload source map + tags: + - APM sourcemaps + '/api/apm/sourcemaps/{id}': + delete: + description: Delete a previously uploaded source map. + operationId: deleteSourceMap + parameters: + - $ref: '#/components/parameters/APM_UI_elastic_api_version' + - $ref: '#/components/parameters/APM_UI_kbn_xsrf' + - description: Source map identifier + in: path + name: id + required: true + schema: + type: string + responses: + '200': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + additionalProperties: false + type: object + description: Successful response + '400': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_400_response' + description: Bad Request response + '401': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_401_response' + description: Unauthorized response + '403': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_403_response' + description: Forbidden response + '500': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_500_response' + description: Internal Server Error response + '501': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: '#/components/schemas/APM_UI_501_response' + description: Not Implemented response + summary: Delete source map + tags: + - APM sourcemaps /api/asset_criticality: delete: description: Delete the asset criticality record for a specific asset if it exists. @@ -20191,7 +20684,7 @@ paths: - Prompts API /api/security/role: get: - operationId: '%2Fapi%2Fsecurity%2Frole#0' + operationId: get-security-role parameters: - description: The version of the API to use in: header @@ -20218,7 +20711,7 @@ paths: - roles '/api/security/role/{name}': delete: - operationId: '%2Fapi%2Fsecurity%2Frole%2F%7Bname%7D#1' + operationId: delete-security-role-name parameters: - description: The version of the API to use in: header @@ -20248,7 +20741,7 @@ paths: tags: - roles get: - operationId: '%2Fapi%2Fsecurity%2Frole%2F%7Bname%7D#0' + operationId: get-security-role-name parameters: - description: The version of the API to use in: header @@ -20284,7 +20777,7 @@ paths: description: >- Create a new Kibana role or update the attributes of an existing role. Kibana roles are stored in the Elasticsearch native realm. - operationId: '%2Fapi%2Fsecurity%2Frole%2F%7Bname%7D#2' + operationId: put-security-role-name parameters: - description: The version of the API to use in: header @@ -20567,7 +21060,7 @@ paths: - roles /api/security/roles: post: - operationId: '%2Fapi%2Fsecurity%2Froles#0' + operationId: post-security-roles parameters: - description: The version of the API to use in: header @@ -20857,8 +21350,10 @@ paths: visualizations, data views, and saved searches, as required. You can request to overwrite any objects that already exist in the target space if they share an identifier or you can use the resolve copy saved - objects conflicts API to do this on a per-object basis. - operationId: '%2Fapi%2Fspaces%2F_copy_saved_objects#0' + objects conflicts API to do this on a per-object + basis.

[Required authorization] Route required privileges: ALL + of [copySavedObjectsToSpaces]. + operationId: post-spaces-copy-saved-objects parameters: - description: The version of the API to use in: header @@ -20945,7 +21440,7 @@ paths: - spaces /api/spaces/_disable_legacy_url_aliases: post: - operationId: '%2Fapi%2Fspaces%2F_disable_legacy_url_aliases#0' + operationId: post-spaces-disable-legacy-url-aliases parameters: - description: The version of the API to use in: header @@ -20999,7 +21494,7 @@ paths: /api/spaces/_get_shareable_references: post: description: Collect references and space contexts for saved objects. - operationId: '%2Fapi%2Fspaces%2F_get_shareable_references#0' + operationId: post-spaces-get-shareable-references parameters: - description: The version of the API to use in: header @@ -21046,8 +21541,9 @@ paths: post: description: >- Overwrite saved objects that are returned as errors from the copy saved - objects to space API. - operationId: '%2Fapi%2Fspaces%2F_resolve_copy_saved_objects_errors#0' + objects to space API.

[Required authorization] Route required + privileges: ALL of [copySavedObjectsToSpaces]. + operationId: post-spaces-resolve-copy-saved-objects-errors parameters: - description: The version of the API to use in: header @@ -21142,7 +21638,7 @@ paths: /api/spaces/_update_objects_spaces: post: description: Update one or more saved objects to add or remove them from some spaces. - operationId: '%2Fapi%2Fspaces%2F_update_objects_spaces#0' + operationId: post-spaces-update-objects-spaces parameters: - description: The version of the API to use in: header @@ -21205,7 +21701,7 @@ paths: - spaces /api/spaces/space: get: - operationId: '%2Fapi%2Fspaces%2Fspace#0' + operationId: get-spaces-space parameters: - description: The version of the API to use in: header @@ -21261,7 +21757,7 @@ paths: tags: - spaces post: - operationId: '%2Fapi%2Fspaces%2Fspace#1' + operationId: post-spaces-space parameters: - description: The version of the API to use in: header @@ -21350,7 +21846,7 @@ paths: description: >- When you delete a space, all saved objects that belong to the space are automatically deleted, which is permanent and cannot be undone. - operationId: '%2Fapi%2Fspaces%2Fspace%2F%7Bid%7D#2' + operationId: delete-spaces-space-id parameters: - description: The version of the API to use in: header @@ -21382,7 +21878,7 @@ paths: tags: - spaces get: - operationId: '%2Fapi%2Fspaces%2Fspace%2F%7Bid%7D#0' + operationId: get-spaces-space-id parameters: - description: The version of the API to use in: header @@ -21405,7 +21901,7 @@ paths: tags: - spaces put: - operationId: '%2Fapi%2Fspaces%2Fspace%2F%7Bid%7D#1' + operationId: put-spaces-space-id parameters: - description: The version of the API to use in: header @@ -21499,7 +21995,7 @@ paths: - spaces /api/status: get: - operationId: '%2Fapi%2Fstatus#0' + operationId: get-status parameters: - description: The version of the API to use in: header @@ -25481,6 +25977,24 @@ components: required: true schema: type: string + APM_UI_elastic_api_version: + description: The version of the API to use + in: header + name: elastic-api-version + required: true + schema: + default: '2023-10-31' + enum: + - '2023-10-31' + type: string + APM_UI_kbn_xsrf: + description: A required header to protect against CSRF attacks + in: header + name: kbn-xsrf + required: true + schema: + example: 'true' + type: string Cases_alert_id: description: An identifier for the alert. in: path @@ -26059,6 +26573,471 @@ components: description: Specifies the data type for the field. example: scaled_float type: string + APM_UI_400_response: + type: object + properties: + error: + description: Error type + example: Not Found + type: string + message: + description: Error message + example: Not Found + type: string + statusCode: + description: Error status code + example: 400 + type: number + APM_UI_401_response: + type: object + properties: + error: + description: Error type + example: Unauthorized + type: string + message: + description: Error message + type: string + statusCode: + description: Error status code + example: 401 + type: number + APM_UI_403_response: + type: object + properties: + error: + description: Error type + example: Forbidden + type: string + message: + description: Error message + type: string + statusCode: + description: Error status code + example: 403 + type: number + APM_UI_404_response: + type: object + properties: + error: + description: Error type + example: Not Found + type: string + message: + description: Error message + example: Not Found + type: string + statusCode: + description: Error status code + example: 404 + type: number + APM_UI_500_response: + type: object + properties: + error: + description: Error type + example: Internal Server Error + type: string + message: + description: Error message + type: string + statusCode: + description: Error status code + example: 500 + type: number + APM_UI_501_response: + type: object + properties: + error: + description: Error type + example: Not Implemented + type: string + message: + description: Error message + example: Not Implemented + type: string + statusCode: + description: Error status code + example: 501 + type: number + APM_UI_agent_configuration_intake_object: + type: object + properties: + agent_name: + description: Agent name + type: string + service: + $ref: '#/components/schemas/APM_UI_service_object' + settings: + $ref: '#/components/schemas/APM_UI_settings_object' + required: + - service + - settings + APM_UI_agent_configuration_object: + description: Agent configuration + type: object + properties: + '@timestamp': + description: Timestamp + example: 1730194190636 + type: number + agent_name: + description: Agent name + type: string + applied_by_agent: + description: Applied by agent + example: true + type: boolean + etag: + description: Etag + example: 0bc3b5ebf18fba8163fe4c96f491e3767a358f85 + type: string + service: + $ref: '#/components/schemas/APM_UI_service_object' + settings: + $ref: '#/components/schemas/APM_UI_settings_object' + required: + - service + - settings + - '@timestamp' + - etag + APM_UI_agent_configurations_response: + type: object + properties: + configurations: + description: Agent configuration + items: + $ref: '#/components/schemas/APM_UI_agent_configuration_object' + type: array + APM_UI_agent_keys_object: + type: object + properties: + name: + description: Agent name + type: string + privileges: + description: Privileges configuration + items: + enum: + - 'event:write' + - 'config_agent:read' + type: string + type: array + required: + - name + - privileges + APM_UI_agent_keys_response: + type: object + properties: + agentKey: + description: Agent key + type: object + properties: + api_key: + type: string + encoded: + type: string + expiration: + format: int64 + type: integer + id: + type: string + name: + type: string + required: + - id + - name + - api_key + - encoded + APM_UI_annotation_search_response: + type: object + properties: + annotations: + description: Annotations + items: + type: object + properties: + '@timestamp': + type: number + id: + type: string + text: + type: string + type: + enum: + - version + type: string + type: array + APM_UI_base_source_map_object: + type: object + properties: + compressionAlgorithm: + description: Compression Algorithm + type: string + created: + description: Created date + type: string + decodedSha256: + description: Decoded SHA-256 + type: string + decodedSize: + description: Decoded size + type: number + encodedSha256: + description: Encoded SHA-256 + type: string + encodedSize: + description: Encoded size + type: number + encryptionAlgorithm: + description: Encryption Algorithm + type: string + id: + description: Identifier + type: string + identifier: + description: Identifier + type: string + packageName: + description: Package name + type: string + relative_url: + description: Relative URL + type: string + type: + description: Type + type: string + APM_UI_create_annotation_object: + type: object + properties: + '@timestamp': + description: Timestamp + type: string + message: + description: Message + type: string + service: + description: Service + type: object + properties: + environment: + type: string + version: + type: string + required: + - version + tags: + description: Tags + items: + type: string + type: array + required: + - '@timestamp' + - service + APM_UI_create_annotation_response: + type: object + properties: + _id: + description: Identifier + type: string + _index: + description: Index + type: string + _source: + description: Response + type: object + properties: + '@timestamp': + type: string + annotation: + type: object + properties: + title: + type: string + type: + type: string + event: + type: object + properties: + created: + type: string + message: + type: string + service: + type: object + properties: + environment: + type: string + name: + type: string + version: + type: string + tags: + items: + type: string + type: array + APM_UI_delete_agent_configurations_response: + type: object + properties: + result: + description: Result + type: string + APM_UI_search_agent_configuration_object: + type: object + properties: + etag: + description: If etags match then `applied_by_agent` field will be set to `true` + example: 0bc3b5ebf18fba8163fe4c96f491e3767a358f85 + type: string + mark_as_applied_by_agent: + description: > + `markAsAppliedByAgent=true` means "force setting it to true + regardless of etag". + + This is needed for Jaeger agent that doesn't have etags + type: boolean + service: + $ref: '#/components/schemas/APM_UI_service_object' + required: + - service + APM_UI_search_agent_configuration_response: + type: object + properties: + _id: + description: Identifier + type: string + _index: + description: Index + type: string + _score: + description: Score + type: number + _source: + $ref: '#/components/schemas/APM_UI_agent_configuration_object' + APM_UI_service_agent_name_response: + type: object + properties: + agentName: + description: Agent name + example: nodejs + type: string + APM_UI_service_environment_object: + type: object + properties: + alreadyConfigured: + description: Already configured + type: boolean + name: + description: Service environment name + example: ALL_OPTION_VALUE + type: string + APM_UI_service_environments_response: + type: object + properties: + environments: + description: Service environment list + items: + $ref: '#/components/schemas/APM_UI_service_environment_object' + type: array + APM_UI_service_object: + description: Service + type: object + properties: + environment: + description: Environment + example: prod + type: string + name: + description: Name + example: node + type: string + APM_UI_settings_object: + additionalProperties: + type: string + description: Agent configuration settings + type: object + APM_UI_single_agent_configuration_response: + allOf: + - type: object + properties: + id: + type: string + required: + - id + - $ref: '#/components/schemas/APM_UI_agent_configuration_object' + APM_UI_source_maps_response: + type: object + properties: + artifacts: + description: Artifacts + items: + allOf: + - type: object + properties: + body: + type: object + properties: + bundleFilepath: + type: string + serviceName: + type: string + serviceVersion: + type: string + sourceMap: + type: object + properties: + file: + type: string + mappings: + type: string + sourceRoot: + type: string + sources: + items: + type: string + type: array + sourcesContent: + items: + type: string + type: array + version: + type: number + - $ref: '#/components/schemas/APM_UI_base_source_map_object' + type: array + APM_UI_upload_source_map_object: + type: object + properties: + bundle_filepath: + description: >- + The absolute path of the final bundle as used in the web + application. + type: string + service_name: + description: The name of the service that the service map should apply to. + type: string + service_version: + description: The version of the service that the service map should apply to. + type: string + sourcemap: + description: > + The source map. String or file upload. It must follow the + + [source map revision 3 + proposal](https://docs.google.com/document/d/1U1RGAehQwRypUTovF1KRlpiOFze0b-_2gc6fAH0KY0k). + format: binary + type: string + required: + - service_name + - service_version + - bundle_filepath + - sourcemap + APM_UI_upload_source_maps_response: + allOf: + - type: object + properties: + body: + type: string + - $ref: '#/components/schemas/APM_UI_base_source_map_object' Cases_4xx_response: properties: error: @@ -39170,6 +40149,8 @@ components: Security_Entity_Analytics_API_EngineDescriptor: type: object properties: + error: + type: object fieldHistoryLength: type: integer filter: @@ -42555,6 +43536,9 @@ security: - basicAuth: [] tags: - name: alerting + - description: | + Adjust APM agent configuration without need to redeploy your application. + name: APM agent configuration - description: > Configure APM agent keys to authorize requests from APM agents to the APM Server. @@ -42564,6 +43548,10 @@ tags: Annotations enable you to easily see how events are impacting the performance of your applications. name: APM annotations + - description: Create APM fleet server schema. + name: APM server schema + - description: Configure APM source maps. + name: APM sourcemaps - description: Case APIs enable you to open and track issues. name: cases - name: connectors diff --git a/oas_docs/overlays/kibana.overlays.serverless.yaml b/oas_docs/overlays/kibana.overlays.serverless.yaml deleted file mode 100644 index f1f6dbb8f51d2..0000000000000 --- a/oas_docs/overlays/kibana.overlays.serverless.yaml +++ /dev/null @@ -1,71 +0,0 @@ -# overlays.yaml -overlay: 1.0.0 -info: - title: Overlays for the Kibana API document - version: 0.0.1 -actions: - # Clean up server definitions - - target: '$.servers.*' - description: Remove all servers so we can add our own. - remove: true - - target: '$.servers' - description: Add server into the now empty server array. - update: - - url: https://{kibana_url} - variables: - kibana_url: - default: localhost:5601 - # Mark all operations as beta - - target: "$.paths[*]['get','put','post','delete','options','head','patch','trace']" - description: Add x-beta - update: - x-beta: true - # Add some tag descriptions and displayNames - - target: '$.tags[?(@.name=="alerting")]' - description: Change tag description and displayName - update: - description: > - Alerting enables you to define rules, which detect complex conditions within your data. - When a condition is met, the rule tracks it as an alert and runs the actions that are defined in the rule. - Actions typically involve the use of connectors to interact with Kibana services or third party integrations. - externalDocs: - description: Alerting documentation - url: https://www.elastic.co/docs/8.x/serverless/rules - x-displayName: "Alerting" - - target: '$.tags[?(@.name=="connectors")]' - description: Change tag description and displayName - update: - description: > - Connectors provide a central place to store connection information for services and integrations with Elastic or third party systems. - Alerting rules can use connectors to run actions when rule conditions are met. - externalDocs: - description: Connector documentation - url: https://www.elastic.co/docs/8.x/serverless/action-connectors - x-displayName: "Connectors" - - target: '$.tags[?(@.name=="data views")]' - description: Change displayName - update: - x-displayName: "Data views" - - target: '$.tags[?(@.name=="ml")]' - description: Change displayName - update: - x-displayName: "Machine learning" - - target: '$.tags[?(@.name=="slo")]' - description: Change displayName - update: - x-displayName: "Service level objectives" - - target: '$.tags[?(@.name=="spaces")]' - description: Change displayName - update: - x-displayName: "Spaces" - description: Manage your Kibana spaces. - - target: '$.tags[?(@.name=="system")]' - description: Change displayName and description - update: - x-displayName: "System" - description: > - Get information about the system status, resource usage, and installed plugins. - # Remove extra tags from operations - - target: "$.paths[*][*].tags[1:]" - description: Remove all but first tag from operations - remove: true \ No newline at end of file diff --git a/oas_docs/scripts/merge_ess_oas.js b/oas_docs/scripts/merge_ess_oas.js index da71cb41595e6..ee6aa23570938 100644 --- a/oas_docs/scripts/merge_ess_oas.js +++ b/oas_docs/scripts/merge_ess_oas.js @@ -23,7 +23,7 @@ const { REPO_ROOT } = require('@kbn/repo-info'); `${REPO_ROOT}/x-pack/plugins/fleet/common/openapi/bundled.yaml`, // Observability Solution - `${REPO_ROOT}/x-pack/plugins/observability_solution/apm/docs/openapi/apm.yaml`, + `${REPO_ROOT}/x-pack/plugins/observability_solution/apm/docs/openapi/apm/bundled.yaml`, `${REPO_ROOT}/x-pack/plugins/observability_solution/slo/docs/openapi/slo/bundled.yaml`, // Security solution diff --git a/oas_docs/scripts/merge_ess_oas_staging.js b/oas_docs/scripts/merge_ess_oas_staging.js deleted file mode 100644 index 03fc7d0786a3d..0000000000000 --- a/oas_docs/scripts/merge_ess_oas_staging.js +++ /dev/null @@ -1,42 +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", 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('../../src/setup_node_env'); -const { merge } = require('@kbn/openapi-bundler'); -const { REPO_ROOT } = require('@kbn/repo-info'); - -(async () => { - await merge({ - sourceGlobs: [ - `${REPO_ROOT}/oas_docs/bundle.json`, - `${REPO_ROOT}/x-pack/plugins/alerting/docs/openapi/bundled.yaml`, - `${REPO_ROOT}/x-pack/plugins/cases/docs/openapi/bundled.yaml`, - `${REPO_ROOT}/src/plugins/data_views/docs/openapi/bundled.yaml`, - `${REPO_ROOT}/x-pack/plugins/ml/common/openapi/ml_apis.yaml`, - `${REPO_ROOT}/packages/core/saved-objects/docs/openapi/bundled.yaml`, - `${REPO_ROOT}/x-pack/plugins/fleet/common/openapi/bundled.yaml`, - - // Observability Solution - `${REPO_ROOT}/x-pack/plugins/observability_solution/apm/docs/openapi/apm.yaml`, - `${REPO_ROOT}/x-pack/plugins/observability_solution/slo/docs/openapi/slo/bundled.yaml`, - - // Security solution - `${REPO_ROOT}/x-pack/plugins/security_solution/docs/openapi/ess/*.schema.yaml`, - `${REPO_ROOT}/packages/kbn-securitysolution-lists-common/docs/openapi/ess/*.schema.yaml`, - `${REPO_ROOT}/packages/kbn-securitysolution-exceptions-common/docs/openapi/ess/*.schema.yaml`, - `${REPO_ROOT}/packages/kbn-securitysolution-endpoint-exceptions-common/docs/openapi/ess/*.schema.yaml`, - `${REPO_ROOT}/x-pack/packages/kbn-elastic-assistant-common/docs/openapi/ess/*.schema.yaml`, - `${REPO_ROOT}/x-pack/plugins/osquery/docs/openapi/ess/*.schema.yaml`, - ], - outputFilePath: `${REPO_ROOT}/oas_docs/output/kibana.staging.yaml`, - options: { - prototypeDocument: `${REPO_ROOT}/oas_docs/kibana.info.yaml`, - }, - }); -})(); diff --git a/oas_docs/scripts/merge_serverless_oas.js b/oas_docs/scripts/merge_serverless_oas.js deleted file mode 100644 index d9d91dfb032b4..0000000000000 --- a/oas_docs/scripts/merge_serverless_oas.js +++ /dev/null @@ -1,40 +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", 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('../../src/setup_node_env'); -const { merge } = require('@kbn/openapi-bundler'); -const { REPO_ROOT } = require('@kbn/repo-info'); - -(async () => { - await merge({ - sourceGlobs: [ - `${REPO_ROOT}/oas_docs/bundle.serverless.json`, - `${REPO_ROOT}/src/plugins/data_views/docs/openapi/bundled.yaml`, - `${REPO_ROOT}/x-pack/plugins/ml/common/openapi/ml_apis_serverless.yaml`, - `${REPO_ROOT}/packages/core/saved-objects/docs/openapi/bundled_serverless.yaml`, - `${REPO_ROOT}/x-pack/plugins/fleet/common/openapi/bundled.yaml`, - - // Observability Solution - `${REPO_ROOT}/x-pack/plugins/observability_solution/apm/docs/openapi/apm.yaml`, - `${REPO_ROOT}/x-pack/plugins/observability_solution/slo/docs/openapi/slo/bundled.yaml`, - - // Security solution - `${REPO_ROOT}/x-pack/plugins/security_solution/docs/openapi/serverless/*.schema.yaml`, - `${REPO_ROOT}/packages/kbn-securitysolution-lists-common/docs/openapi/serverless/*.schema.yaml`, - `${REPO_ROOT}/packages/kbn-securitysolution-exceptions-common/docs/openapi/serverless/*.schema.yaml`, - `${REPO_ROOT}/packages/kbn-securitysolution-endpoint-exceptions-common/docs/openapi/serverless/*.schema.yaml`, - `${REPO_ROOT}/x-pack/packages/kbn-elastic-assistant-common/docs/openapi/serverless/*.schema.yaml`, - `${REPO_ROOT}/x-pack/plugins/osquery/docs/openapi/serverless/*.schema.yaml`, - ], - outputFilePath: `${REPO_ROOT}/oas_docs/output/kibana.serverless.yaml`, - options: { - prototypeDocument: `${REPO_ROOT}/oas_docs/kibana.info.serverless.yaml`, - }, - }); -})(); diff --git a/oas_docs/scripts/merge_serverless_oas_staging.js b/oas_docs/scripts/merge_serverless_oas_staging.js deleted file mode 100644 index 72b5c744df79b..0000000000000 --- a/oas_docs/scripts/merge_serverless_oas_staging.js +++ /dev/null @@ -1,40 +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", 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('../../src/setup_node_env'); -const { merge } = require('@kbn/openapi-bundler'); -const { REPO_ROOT } = require('@kbn/repo-info'); - -(async () => { - await merge({ - sourceGlobs: [ - `${REPO_ROOT}/oas_docs/bundle.serverless.json`, - `${REPO_ROOT}/src/plugins/data_views/docs/openapi/bundled.yaml`, - `${REPO_ROOT}/x-pack/plugins/ml/common/openapi/ml_apis_serverless.yaml`, - `${REPO_ROOT}/packages/core/saved-objects/docs/openapi/bundled_serverless.yaml`, - `${REPO_ROOT}/x-pack/plugins/fleet/common/openapi/bundled.yaml`, - - // Observability Solution - `${REPO_ROOT}/x-pack/plugins/observability_solution/apm/docs/openapi/apm.yaml`, - `${REPO_ROOT}/x-pack/plugins/observability_solution/slo/docs/openapi/slo/bundled.yaml`, - - // Security solution - `${REPO_ROOT}/x-pack/plugins/security_solution/docs/openapi/serverless/*.schema.yaml`, - `${REPO_ROOT}/packages/kbn-securitysolution-lists-common/docs/openapi/serverless/*.schema.yaml`, - `${REPO_ROOT}/packages/kbn-securitysolution-exceptions-common/docs/openapi/serverless/*.schema.yaml`, - `${REPO_ROOT}/packages/kbn-securitysolution-endpoint-exceptions-common/docs/openapi/serverless/*.schema.yaml`, - `${REPO_ROOT}/x-pack/packages/kbn-elastic-assistant-common/docs/openapi/serverless/*.schema.yaml`, - `${REPO_ROOT}/x-pack/plugins/osquery/docs/openapi/serverless/*.schema.yaml`, - ], - outputFilePath: `${REPO_ROOT}/oas_docs/output/kibana.serverless.staging.yaml`, - options: { - prototypeDocument: `${REPO_ROOT}/oas_docs/kibana.info.serverless.yaml`, - }, - }); -})(); diff --git a/package.json b/package.json index d3938c64969fb..c4ae24e4be0d5 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "resolutions": { "**/@bazel/typescript/protobufjs": "6.11.4", "**/@hello-pangea/dnd": "16.6.0", - "**/@langchain/core": "^0.2.18", + "**/@langchain/core": "^0.3.16", "**/@langchain/google-common": "^0.1.1", "**/@types/node": "20.10.5", "**/@typescript-eslint/utils": "5.62.0", @@ -90,7 +90,7 @@ "**/globule/minimatch": "^3.1.2", "**/hoist-non-react-statics": "^3.3.2", "**/isomorphic-fetch/node-fetch": "^2.6.7", - "**/langchain": "^0.2.11", + "**/langchain": "^0.3.5", "**/remark-parse/trim": "1.0.1", "**/sharp": "0.32.6", "**/typescript": "5.1.6", @@ -118,7 +118,7 @@ "@elastic/ecs": "^8.11.1", "@elastic/elasticsearch": "^8.15.0", "@elastic/ems-client": "8.5.3", - "@elastic/eui": "97.2.0", + "@elastic/eui": "97.3.0", "@elastic/filesaver": "1.1.2", "@elastic/node-crypto": "^1.2.3", "@elastic/numeral": "^2.5.1", @@ -571,6 +571,7 @@ "@kbn/index-management-plugin": "link:x-pack/plugins/index_management", "@kbn/index-management-shared-types": "link:x-pack/packages/index-management/index_management_shared_types", "@kbn/index-patterns-test-plugin": "link:test/plugin_functional/plugins/index_patterns", + "@kbn/inference-common": "link:x-pack/packages/ai-infra/inference-common", "@kbn/inference-plugin": "link:x-pack/plugins/inference", "@kbn/inference_integration_flyout": "link:x-pack/packages/ml/inference_integration_flyout", "@kbn/infra-forge": "link:x-pack/packages/kbn-infra-forge", @@ -808,7 +809,6 @@ "@kbn/security-plugin-types-public": "link:x-pack/packages/security/plugin_types_public", "@kbn/security-plugin-types-server": "link:x-pack/packages/security/plugin_types_server", "@kbn/security-role-management-model": "link:x-pack/packages/security/role_management_model", - "@kbn/security-solution-common": "link:x-pack/packages/security-solution/common", "@kbn/security-solution-distribution-bar": "link:x-pack/packages/security-solution/distribution_bar", "@kbn/security-solution-ess": "link:x-pack/plugins/security_solution_ess", "@kbn/security-solution-features": "link:x-pack/packages/security-solution/features", @@ -1008,15 +1008,15 @@ "@kbn/xstate-utils": "link:packages/kbn-xstate-utils", "@kbn/zod": "link:packages/kbn-zod", "@kbn/zod-helpers": "link:packages/kbn-zod-helpers", - "@langchain/community": "0.2.18", - "@langchain/core": "^0.2.18", + "@langchain/community": "0.3.11", + "@langchain/core": "^0.3.16", "@langchain/google-common": "^0.1.1", - "@langchain/google-genai": "^0.1.0", + "@langchain/google-genai": "^0.1.2", "@langchain/google-vertexai": "^0.1.0", - "@langchain/langgraph": "0.0.34", - "@langchain/openai": "^0.1.3", + "@langchain/langgraph": "0.2.19", + "@langchain/openai": "^0.3.11", "@langtrase/trace-attributes": "^3.0.8", - "@launchdarkly/node-server-sdk": "^9.6.0", + "@launchdarkly/node-server-sdk": "^9.7.0", "@launchdarkly/openfeature-node-server": "^1.0.0", "@loaders.gl/core": "^3.4.7", "@loaders.gl/json": "^3.4.7", @@ -1026,10 +1026,10 @@ "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mapbox/mapbox-gl-supported": "2.0.1", "@mapbox/vector-tile": "1.3.1", - "@openfeature/core": "^1.4.0", + "@openfeature/core": "^1.5.0", "@openfeature/launchdarkly-client-provider": "^0.3.0", - "@openfeature/server-sdk": "^1.15.1", - "@openfeature/web-sdk": "^1.2.4", + "@openfeature/server-sdk": "^1.16.1", + "@openfeature/web-sdk": "^1.3.1", "@opentelemetry/api": "^1.1.0", "@opentelemetry/api-metrics": "^0.31.0", "@opentelemetry/exporter-metrics-otlp-grpc": "^0.34.0", @@ -1157,9 +1157,9 @@ "jsonwebtoken": "^9.0.2", "jsts": "^1.6.2", "kea": "^2.6.0", - "langchain": "^0.2.11", - "langsmith": "^0.1.55", - "launchdarkly-js-client-sdk": "^3.4.0", + "langchain": "^0.3.5", + "langsmith": "^0.2.3", + "launchdarkly-js-client-sdk": "^3.5.0", "load-json-file": "^6.2.0", "lodash": "^4.17.21", "lru-cache": "^4.1.5", @@ -1187,7 +1187,7 @@ "nunjucks": "^3.2.4", "object-hash": "^1.3.1", "object-path-immutable": "^3.1.1", - "openai": "^4.24.1", + "openai": "^4.68.0", "openpgp": "5.10.1", "opn": "^5.5.0", "ora": "^4.0.4", @@ -1197,13 +1197,13 @@ "p-settle": "4.1.1", "papaparse": "^5.2.0", "pbf": "3.2.1", - "pdfmake": "^0.2.7", + "pdfmake": "^0.2.15", "peggy": "^1.2.0", "polished": "^3.7.2", "pretty-ms": "6.0.0", "prop-types": "^15.8.1", "proxy-from-env": "1.0.0", - "puppeteer": "23.3.1", + "puppeteer": "23.7.0", "query-string": "^6.13.2", "rbush": "^3.0.1", "re-resizable": "^6.9.9", @@ -1518,9 +1518,9 @@ "@storybook/react": "^6.5.16", "@storybook/testing-react": "^1.3.0", "@storybook/theming": "^6.5.16", - "@testing-library/dom": "^8.19.0", + "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.5.0", - "@testing-library/react": "^12.1.5", + "@testing-library/react": "^16.0.1", "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^14.5.2", "@types/adm-zip": "^0.5.0", @@ -1677,7 +1677,7 @@ "buildkite-test-collector": "^1.7.0", "callsites": "^3.1.0", "chance": "1.0.18", - "chromedriver": "^129.0.0", + "chromedriver": "^130.0.1", "clean-webpack-plugin": "^3.0.0", "cli-progress": "^3.12.0", "cli-table3": "^0.6.1", @@ -1715,6 +1715,7 @@ "eslint-plugin-react": "^7.32.2", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-perf": "^3.3.1", + "eslint-plugin-testing-library": "^6.4.0", "eslint-traverse": "^1.0.0", "exit-hook": "^2.2.0", "expect": "^29.7.0", @@ -1725,7 +1726,7 @@ "file-loader": "^4.2.0", "find-cypress-specs": "^1.41.4", "form-data": "^4.0.0", - "geckodriver": "^4.4.4", + "geckodriver": "^4.5.1", "gulp-brotli": "^3.0.0", "gulp-postcss": "^9.0.1", "gulp-terser": "^2.1.0", diff --git a/packages/content-management/table_list_view_table/src/table_list_view.test.tsx b/packages/content-management/table_list_view_table/src/table_list_view.test.tsx index 38e05299184e4..38229399f2ec8 100644 --- a/packages/content-management/table_list_view_table/src/table_list_view.test.tsx +++ b/packages/content-management/table_list_view_table/src/table_list_view.test.tsx @@ -242,8 +242,8 @@ describe('TableListView', () => { const updatedAtValues: Moment[] = []; const updatedHits = hits.map(({ id, attributes, references }, i) => { - const updatedAt = new Date(new Date().setDate(new Date().getDate() - (7 + i))); - updatedAtValues.push(moment(updatedAt)); + const updatedAt = moment().subtract(7 + i, 'days'); + updatedAtValues.push(updatedAt); return { id, diff --git a/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx b/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx index 5d86209ec8800..434639b07efdf 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx +++ b/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx @@ -36,6 +36,7 @@ import type { ChromeSetProjectBreadcrumbsParams, NavigationTreeDefinition, AppDeepLinkId, + SolutionId, } from '@kbn/core-chrome-browser'; import type { CustomBrandingStart } from '@kbn/core-custom-branding-browser'; import type { @@ -343,7 +344,10 @@ export class ChromeService { LinkId extends AppDeepLinkId = AppDeepLinkId, Id extends string = string, ChildrenId extends string = Id - >(id: string, navigationTree$: Observable>) { + >( + id: SolutionId, + navigationTree$: Observable> + ) { validateChromeStyle(); projectNavigation.initNavigation(id, navigationTree$); } diff --git a/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.test.ts b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.test.ts index d1be94aad246a..124b44e80e30f 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.test.ts +++ b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.test.ts @@ -110,7 +110,7 @@ describe('initNavigation()', () => { beforeAll(() => { projectNavigation.initNavigation( - 'foo', + 'es', of({ body: [ { @@ -185,7 +185,7 @@ describe('initNavigation()', () => { const { projectNavigation: projNavigation, getNavigationTree: getNavTree } = setupInitNavigation(); projNavigation.initNavigation( - 'foo', + 'es', of({ body: [ { @@ -210,7 +210,7 @@ describe('initNavigation()', () => { const { projectNavigation: projNavigation } = setupInitNavigation(); projNavigation.initNavigation( - 'foo', + 'es', of({ body: [ { @@ -399,7 +399,7 @@ describe('initNavigation()', () => { // 2. initNavigation() is called projectNavigation.initNavigation( - 'foo', + 'es', of({ body: [ { @@ -427,7 +427,7 @@ describe('initNavigation()', () => { }); projectNavigation.initNavigation( - 'foo', + 'es', // @ts-expect-error - We pass a non valid cloudLink that is not TS valid of({ body: [ @@ -533,7 +533,7 @@ describe('breadcrumbs', () => { const obs = subj.asObservable(); if (initiateNavigation) { - projectNavigation.initNavigation('foo', obs); + projectNavigation.initNavigation('es', obs); } return { @@ -740,7 +740,7 @@ describe('breadcrumbs', () => { { text: 'custom1', href: '/custom1' }, { text: 'custom2', href: '/custom1/custom2' }, ]); - projectNavigation.initNavigation('foo', of(mockNavigation)); // init navigation + projectNavigation.initNavigation('es', of(mockNavigation)); // init navigation const breadcrumbs = await firstValueFrom(projectNavigation.getProjectBreadcrumbs$()); expect(breadcrumbs).toHaveLength(4); @@ -779,7 +779,7 @@ describe('getActiveNodes$()', () => { expect(activeNodes).toEqual([]); projectNavigation.initNavigation( - 'foo', + 'es', of({ body: [ { @@ -835,7 +835,7 @@ describe('getActiveNodes$()', () => { expect(activeNodes).toEqual([]); projectNavigation.initNavigation( - 'foo', + 'es', of({ body: [ { @@ -889,7 +889,7 @@ describe('getActiveNodes$()', () => { describe('solution navigations', () => { const solution1: SolutionNavigationDefinition = { - id: 'solution1', + id: 'es', title: 'Solution 1', icon: 'logoSolution1', homePage: 'discover', @@ -897,7 +897,7 @@ describe('solution navigations', () => { }; const solution2: SolutionNavigationDefinition = { - id: 'solution2', + id: 'oblt', title: 'Solution 2', icon: 'logoSolution2', homePage: 'app2', @@ -906,7 +906,7 @@ describe('solution navigations', () => { }; const solution3: SolutionNavigationDefinition = { - id: 'solution3', + id: 'security', title: 'Solution 3', icon: 'logoSolution3', homePage: 'discover', @@ -943,30 +943,30 @@ describe('solution navigations', () => { } { - projectNavigation.updateSolutionNavigations({ 1: solution1, 2: solution2 }); + projectNavigation.updateSolutionNavigations({ es: solution1, oblt: solution2 }); const solutionNavs = await lastValueFrom( projectNavigation.getSolutionsNavDefinitions$().pipe(take(1)) ); - expect(solutionNavs).toEqual({ 1: solution1, 2: solution2 }); + expect(solutionNavs).toEqual({ es: solution1, oblt: solution2 }); } { // Test partial update - projectNavigation.updateSolutionNavigations({ 3: solution3 }, false); + projectNavigation.updateSolutionNavigations({ security: solution3 }, false); const solutionNavs = await lastValueFrom( projectNavigation.getSolutionsNavDefinitions$().pipe(take(1)) ); - expect(solutionNavs).toEqual({ 1: solution1, 2: solution2, 3: solution3 }); + expect(solutionNavs).toEqual({ es: solution1, oblt: solution2, security: solution3 }); } { // Test full replacement - projectNavigation.updateSolutionNavigations({ 4: solution3 }, true); + projectNavigation.updateSolutionNavigations({ security: solution3 }, true); const solutionNavs = await lastValueFrom( projectNavigation.getSolutionsNavDefinitions$().pipe(take(1)) ); - expect(solutionNavs).toEqual({ 4: solution3 }); + expect(solutionNavs).toEqual({ security: solution3 }); } }); @@ -980,8 +980,8 @@ describe('solution navigations', () => { expect(activeSolution).toBeNull(); } - projectNavigation.changeActiveSolutionNavigation('2'); // Set **before** the navs are registered - projectNavigation.updateSolutionNavigations({ 1: solution1, 2: solution2 }); + projectNavigation.changeActiveSolutionNavigation('oblt'); // Set **before** the navs are registered + projectNavigation.updateSolutionNavigations({ es: solution1, oblt: solution2 }); { const activeSolution = await lastValueFrom( @@ -994,7 +994,7 @@ describe('solution navigations', () => { expect(activeSolution).toEqual(rest); } - projectNavigation.changeActiveSolutionNavigation('1'); // Set **after** the navs are registered + projectNavigation.changeActiveSolutionNavigation('es'); // Set **after** the navs are registered { const activeSolution = await lastValueFrom( @@ -1027,7 +1027,7 @@ describe('solution navigations', () => { { const fooSolution: SolutionNavigationDefinition = { - id: 'fooSolution', + id: 'es', title: 'Foo solution', icon: 'logoSolution', homePage: 'discover', @@ -1053,8 +1053,8 @@ describe('solution navigations', () => { }), }; - projectNavigation.changeActiveSolutionNavigation('foo'); - projectNavigation.updateSolutionNavigations({ foo: fooSolution }); + projectNavigation.changeActiveSolutionNavigation('es'); + projectNavigation.updateSolutionNavigations({ es: fooSolution }); projectNavigation.setPanelSelectedNode('link2'); // Set the selected node using its id diff --git a/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.ts b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.ts index 85c3fd1905adb..7960d9f710c90 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.ts +++ b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.ts @@ -17,6 +17,7 @@ import type { NavigationTreeDefinition, SolutionNavigationDefinitions, CloudLinks, + SolutionId, } from '@kbn/core-chrome-browser'; import type { InternalHttpStart } from '@kbn/core-http-browser-internal'; import { @@ -86,9 +87,9 @@ export class ProjectNavigationService { private readonly solutionNavDefinitions$ = new BehaviorSubject({}); // As the active definition **id** and the definitions are set independently, one before the other without // any guarantee of order, we need to store the next active definition id in a separate BehaviorSubject - private readonly nextSolutionNavDefinitionId$ = new BehaviorSubject(null); + private readonly nextSolutionNavDefinitionId$ = new BehaviorSubject(null); // The active solution navigation definition id that has been initiated and is currently active - private readonly activeSolutionNavDefinitionId$ = new BehaviorSubject(null); + private readonly activeSolutionNavDefinitionId$ = new BehaviorSubject(null); private readonly location$ = new BehaviorSubject(createLocation('/')); private deepLinksMap$: Observable> = of({}); private cloudLinks$ = new BehaviorSubject({}); @@ -138,7 +139,7 @@ export class ProjectNavigationService { return this.projectName$.asObservable(); }, initNavigation: ( - id: string, + id: SolutionId, navTreeDefinition$: Observable> ) => { this.initNavigation(id, navTreeDefinition$); @@ -202,7 +203,7 @@ export class ProjectNavigationService { * @param id Id for the navigation tree definition * @param navTreeDefinition$ The navigation tree definition */ - private initNavigation(id: string, navTreeDefinition$: Observable) { + private initNavigation(id: SolutionId, navTreeDefinition$: Observable) { if (this.activeSolutionNavDefinitionId$.getValue() === id) return; if (this.navigationChangeSubscription) { @@ -220,7 +221,7 @@ export class ProjectNavigationService { .pipe( takeUntil(this.stop$), map(([def, deepLinksMap, cloudLinks]) => { - return parseNavigationTree(def, { + return parseNavigationTree(id, def, { deepLinks: deepLinksMap, cloudLinks, }); @@ -382,7 +383,7 @@ export class ProjectNavigationService { this.projectHome$.next(homeHref); } - private changeActiveSolutionNavigation(id: string | null) { + private changeActiveSolutionNavigation(id: SolutionId | null) { if (this.nextSolutionNavDefinitionId$.getValue() === id) return; this.nextSolutionNavDefinitionId$.next(id); } @@ -400,7 +401,7 @@ export class ProjectNavigationService { if (!definitions[id]) return null; // We strip out the sideNavComponent from the definition as it should only be used internally - const { sideNavComponent, ...definition } = definitions[id]; + const { sideNavComponent, ...definition } = definitions[id]!; return definition; }) ); diff --git a/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/utils.ts b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/utils.ts index 9a45290c95389..bdf3929c464dc 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/utils.ts +++ b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/utils.ts @@ -22,6 +22,7 @@ import type { CloudLinkId, CloudLinks, ItemDefinition, + SolutionId, } from '@kbn/core-chrome-browser/src'; import type { Location } from 'history'; import type { MouseEventHandler } from 'react'; @@ -364,6 +365,7 @@ const isRecentlyAccessedDefinition = ( }; export const parseNavigationTree = ( + id: SolutionId, navigationTreeDef: NavigationTreeDefinition, { deepLinks, cloudLinks }: { deepLinks: Record; cloudLinks: CloudLinks } ): { @@ -376,7 +378,7 @@ export const parseNavigationTree = ( const navigationTree: ChromeProjectNavigationNode[] = []; // Contains UI layout information (body, footer) and render "special" blocks like recently accessed. - const navigationTreeUI: NavigationTreeDefinitionUI = { body: [] }; + const navigationTreeUI: NavigationTreeDefinitionUI = { id, body: [] }; const initNodeAndChildren = ( node: GroupDefinition | ItemDefinition | NodeDefinition, diff --git a/packages/core/chrome/core-chrome-browser-internal/src/types.ts b/packages/core/chrome/core-chrome-browser-internal/src/types.ts index 0e6bec4d2678c..36a247e22f847 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/types.ts +++ b/packages/core/chrome/core-chrome-browser-internal/src/types.ts @@ -18,6 +18,7 @@ import type { NavigationTreeDefinitionUI, CloudURLs, SolutionNavigationDefinitions, + SolutionId, } from '@kbn/core-chrome-browser'; import type { Observable } from 'rxjs'; @@ -66,7 +67,7 @@ export interface InternalChromeStart extends ChromeStart { Id extends string = string, ChildrenId extends string = Id >( - id: string, + id: SolutionId, navigationTree$: Observable> ): void; @@ -117,6 +118,6 @@ export interface InternalChromeStart extends ChromeStart { * @param id The id of the active solution navigation. If `null` is provided, the solution navigation * will be replaced with the legacy Kibana navigation. */ - changeActiveSolutionNavigation(id: string | null): void; + changeActiveSolutionNavigation(id: SolutionId | null): void; }; } diff --git a/packages/core/chrome/core-chrome-browser/index.ts b/packages/core/chrome/core-chrome-browser/index.ts index afb2050d12e80..7b8658791340f 100644 --- a/packages/core/chrome/core-chrome-browser/index.ts +++ b/packages/core/chrome/core-chrome-browser/index.ts @@ -60,4 +60,5 @@ export type { SolutionNavigationDefinitions, EuiSideNavItemTypeEnhanced, RenderAs, + SolutionId, } from './src'; diff --git a/packages/core/chrome/core-chrome-browser/src/index.ts b/packages/core/chrome/core-chrome-browser/src/index.ts index efc2fb5636d84..efc709ff512da 100644 --- a/packages/core/chrome/core-chrome-browser/src/index.ts +++ b/packages/core/chrome/core-chrome-browser/src/index.ts @@ -38,6 +38,7 @@ export type { PanelSelectedNode, AppDeepLinkId, AppId, + SolutionId, CloudLinkId, CloudLink, CloudLinks, diff --git a/packages/core/chrome/core-chrome-browser/src/project_navigation.ts b/packages/core/chrome/core-chrome-browser/src/project_navigation.ts index 3e6afeb8f6117..f4a5af26c4176 100644 --- a/packages/core/chrome/core-chrome-browser/src/project_navigation.ts +++ b/packages/core/chrome/core-chrome-browser/src/project_navigation.ts @@ -42,6 +42,8 @@ import type { AppId as SharedApp, DeepLinkId as SharedLink } from '@kbn/deeplink import type { ChromeNavLink } from './nav_links'; import type { ChromeRecentlyAccessedHistoryItem } from './recently_accessed'; +export type SolutionId = 'es' | 'oblt' | 'security'; + /** @public */ export type AppId = | DevToolsApp @@ -414,6 +416,7 @@ export interface NavigationTreeDefinition< * with their corresponding "deepLink"...) */ export interface NavigationTreeDefinitionUI { + id: SolutionId; body: Array; footer?: Array; } @@ -429,7 +432,7 @@ export interface NavigationTreeDefinitionUI { export interface SolutionNavigationDefinition { /** Unique id for the solution navigation. */ - id: string; + id: SolutionId; /** Title for the solution navigation. */ title: string; /** The navigation tree definition */ @@ -442,9 +445,9 @@ export interface SolutionNavigationDefinition { `); }); + it('returns deprecated type deprecated route', async () => { + const getDeprecations = createGetApiDeprecations({ coreUsageData, http }); + const deprecatedRoute = createDeprecatedRouteDetails({ + routePath: '/api/test_deprecated/', + routeDeprecationOptions: { reason: { type: 'deprecate' }, message: 'additional message' }, + }); + http.getRegisteredDeprecatedApis.mockReturnValue([deprecatedRoute]); + usageClientMock.getDeprecatedApiUsageStats.mockResolvedValue([ + createApiUsageStat(buildApiDeprecationId(deprecatedRoute)), + ]); + + const deprecations = await getDeprecations(); + expect(deprecations).toMatchInlineSnapshot(` + Array [ + Object { + "apiId": "123|get|/api/test_deprecated", + "correctiveActions": Object { + "manualSteps": Array [ + "Identify the origin of these API calls.", + "For now, the API will still work, but will be moved or removed in a future version. Check the Learn more link for more information. If you are no longer using the API, you can mark this issue as resolved. It will no longer appear in the Upgrade Assistant unless another call using this API is detected.", + ], + "mark_as_resolved_api": Object { + "apiTotalCalls": 13, + "routeMethod": "get", + "routePath": "/api/test_deprecated/", + "routeVersion": "123", + "timestamp": 2024-10-17T12:06:41.224Z, + "totalMarkedAsResolved": 1, + }, + }, + "deprecationType": "api", + "documentationUrl": "https://fake-url", + "domainId": "core.routes-deprecations", + "level": "critical", + "message": Array [ + "The API \\"GET /api/test_deprecated/\\" has been called 13 times. The last call was on Sunday, September 1, 2024 6:06 AM -04:00.", + "This issue has been marked as resolved on Thursday, October 17, 2024 8:06 AM -04:00 but the API has been called 12 times since.", + "additional message", + ], + "title": "The \\"GET /api/test_deprecated/\\" route is deprecated", + }, + ] + `); + }); + it('does not return resolved deprecated route', async () => { const getDeprecations = createGetApiDeprecations({ coreUsageData, http }); const deprecatedRoute = createDeprecatedRouteDetails({ routePath: '/api/test_resolved/' }); diff --git a/packages/core/deprecations/core-deprecations-server-internal/src/deprecations/i18n_texts.ts b/packages/core/deprecations/core-deprecations-server-internal/src/deprecations/i18n_texts.ts index cb1dacc97bd91..e52dd1f3d8fd1 100644 --- a/packages/core/deprecations/core-deprecations-server-internal/src/deprecations/i18n_texts.ts +++ b/packages/core/deprecations/core-deprecations-server-internal/src/deprecations/i18n_texts.ts @@ -35,7 +35,7 @@ export const getApiDeprecationMessage = ( details: RouterDeprecatedRouteDetails, apiUsageStats: CoreDeprecatedApiUsageStats ): string[] => { - const { routePath, routeMethod } = details; + const { routePath, routeMethod, routeDeprecationOptions } = details; const { apiLastCalledAt, apiTotalCalls, markedAsResolvedLastCalledAt, totalMarkedAsResolved } = apiUsageStats; @@ -71,6 +71,11 @@ export const getApiDeprecationMessage = ( ); } + if (routeDeprecationOptions.message) { + // Surfaces additional deprecation messages passed into the route in UA + messages.push(routeDeprecationOptions.message); + } + return messages; }; @@ -106,6 +111,15 @@ export const getApiDeprecationsManualSteps = (details: RouterDeprecatedRouteDeta ); break; } + case 'deprecate': { + manualSteps.push( + i18n.translate('core.deprecations.deprecations.manualSteps.removeTypeExplainationStep', { + defaultMessage: + 'For now, the API will still work, but will be moved or removed in a future version. Check the Learn more link for more information. If you are no longer using the API, you can mark this issue as resolved. It will no longer appear in the Upgrade Assistant unless another call using this API is detected.', + }) + ); + break; + } case 'migrate': { const { newApiPath, newApiMethod } = routeDeprecationOptions.reason; const newRouteWithMethod = `${newApiMethod.toUpperCase()} ${newApiPath}`; @@ -121,12 +135,14 @@ export const getApiDeprecationsManualSteps = (details: RouterDeprecatedRouteDeta } } - manualSteps.push( - i18n.translate('core.deprecations.deprecations.manualSteps.markAsResolvedStep', { - defaultMessage: - 'Check that you are no longer using the old API in any requests, and mark this issue as resolved. It will no longer appear in the Upgrade Assistant unless another call using this API is detected.', - }) - ); + if (deprecationType !== 'deprecate') { + manualSteps.push( + i18n.translate('core.deprecations.deprecations.manualSteps.markAsResolvedStep', { + defaultMessage: + 'Check that you are no longer using the old API in any requests, and mark this issue as resolved. It will no longer appear in the Upgrade Assistant unless another call using this API is detected.', + }) + ); + } return manualSteps; }; diff --git a/packages/core/http/core-http-server/src/router/route.ts b/packages/core/http/core-http-server/src/router/route.ts index 17fecd1c48b17..eec9a01f60562 100644 --- a/packages/core/http/core-http-server/src/router/route.ts +++ b/packages/core/http/core-http-server/src/router/route.ts @@ -120,36 +120,82 @@ export type Privilege = string; * created from HTTP API introspection (like OAS). */ export interface RouteDeprecationInfo { + /** + * link to the documentation for more details on the deprecation. + */ documentationUrl: string; + /** + * The description message to be displayed for the deprecation. + * Check the README for writing deprecations in `src/core/server/deprecations/README.mdx` + */ + message?: string; + /** + * levels: + * - warning: will not break deployment upon upgrade. + * - critical: needs to be addressed before upgrade. + */ severity: 'warning' | 'critical'; - reason: VersionBumpDeprecationType | RemovalApiDeprecationType | MigrationApiDeprecationType; + /** + * API deprecation reason: + * - bump: New version of the API is available. + * - remove: API was fully removed with no replacement. + * - migrate: API has been migrated to a different path. + * - deprecated: the deprecated API is deprecated, it might be removed or migrated, or got a version bump in the future. + * It is a catch-all deprecation for APIs but the API will work in the next upgrades. + */ + reason: + | VersionBumpDeprecationType + | RemovalApiDeprecationType + | MigrationApiDeprecationType + | DeprecateApiDeprecationType; } -/** - * bump deprecation reason denotes a new version of the API is available - */ interface VersionBumpDeprecationType { + /** + * bump deprecation reason denotes a new version of the API is available + */ type: 'bump'; + /** + * new version of the API to be used instead. + */ newApiVersion: string; } -/** - * remove deprecation reason denotes the API was fully removed with no replacement - */ interface RemovalApiDeprecationType { + /** + * remove deprecation reason denotes the API was fully removed with no replacement + */ type: 'remove'; } -/** - * migrate deprecation reason denotes the API has been migrated to a different API path - * Please make sure that if you are only incrementing the version of the API to use 'bump' instead - */ interface MigrationApiDeprecationType { + /** + * migrate deprecation reason denotes the API has been migrated to a different API path + * Please make sure that if you are only incrementing the version of the API to use 'bump' instead + */ type: 'migrate'; + /** + * new API path to be used instead + */ newApiPath: string; + /** + * new API method (GET POST PUT DELETE) to be used with the new API. + */ newApiMethod: string; } +interface DeprecateApiDeprecationType { + /** + * deprecate deprecation reason denotes the API is deprecated but it doesnt have a replacement + * or a clear version that it will be removed in. This is useful to alert users that the API is deprecated + * to allow them as much time as possible to work around this fact before the deprecation + * turns into a `remove` or `migrate` or `bump` type. + * + * Recommended to pair this with `severity: 'warning'` to avoid blocking the upgrades for this type. + */ + type: 'deprecate'; +} + /** * A set of privileges that can be used to define complex authorization requirements. * diff --git a/packages/core/i18n/core-i18n-browser-internal/src/__snapshots__/i18n_service.test.tsx.snap b/packages/core/i18n/core-i18n-browser-internal/src/__snapshots__/i18n_service.test.tsx.snap index bd50f4ffe0e44..9fd84ce731847 100644 --- a/packages/core/i18n/core-i18n-browser-internal/src/__snapshots__/i18n_service.test.tsx.snap +++ b/packages/core/i18n/core-i18n-browser-internal/src/__snapshots__/i18n_service.test.tsx.snap @@ -129,15 +129,14 @@ exports[`#start() returns \`Context\` component 1`] = ` "euiDatePopoverContent.startDateLabel": "Start date", "euiDisplaySelector.buttonText": "Display options", "euiDisplaySelector.densityLabel": "Density", - "euiDisplaySelector.labelAuto": "Auto fit", + "euiDisplaySelector.labelAuto": "Auto", "euiDisplaySelector.labelCompact": "Compact", - "euiDisplaySelector.labelCustom": "Custom", "euiDisplaySelector.labelExpanded": "Expanded", + "euiDisplaySelector.labelMax": "Max", "euiDisplaySelector.labelNormal": "Normal", - "euiDisplaySelector.labelSingle": "Single", - "euiDisplaySelector.lineCountLabel": "Lines per row", + "euiDisplaySelector.labelStatic": "Static", "euiDisplaySelector.resetButtonText": "Reset to default", - "euiDisplaySelector.rowHeightLabel": "Row height", + "euiDisplaySelector.rowHeightLabel": "Lines per row", "euiDualRange.sliderScreenReaderInstructions": "You are in a custom range slider. Use the Up and Down arrow keys to change the minimum value. Press Tab to interact with the maximum value.", "euiErrorBoundary.error": "Error", "euiExternalLinkIcon.externalTarget.screenReaderOnlyText": "(external)", diff --git a/packages/core/i18n/core-i18n-browser-internal/src/i18n_eui_mapping.tsx b/packages/core/i18n/core-i18n-browser-internal/src/i18n_eui_mapping.tsx index ad1ba505dc6f2..732c43a0593c7 100644 --- a/packages/core/i18n/core-i18n-browser-internal/src/i18n_eui_mapping.tsx +++ b/packages/core/i18n/core-i18n-browser-internal/src/i18n_eui_mapping.tsx @@ -724,19 +724,16 @@ export const getEuiContextMapping = (): EuiTokensObject => { 'euiDisplaySelector.labelExpanded': i18n.translate('core.euiDisplaySelector.labelExpanded', { defaultMessage: 'Expanded', }), - 'euiDisplaySelector.labelSingle': i18n.translate('core.euiDisplaySelector.labelSingle', { - defaultMessage: 'Single', - }), 'euiDisplaySelector.labelAuto': i18n.translate('core.euiDisplaySelector.labelAuto', { - defaultMessage: 'Auto fit', + defaultMessage: 'Auto', }), - 'euiDisplaySelector.labelCustom': i18n.translate('core.euiDisplaySelector.labelCustom', { - defaultMessage: 'Custom', + 'euiDisplaySelector.labelStatic': i18n.translate('core.euiDisplaySelector.labelStatic', { + defaultMessage: 'Static', }), - 'euiDisplaySelector.rowHeightLabel': i18n.translate('core.euiDisplaySelector.rowHeightLabel', { - defaultMessage: 'Row height', + 'euiDisplaySelector.labelMax': i18n.translate('core.euiDisplaySelector.labelMax', { + defaultMessage: 'Max', }), - 'euiDisplaySelector.lineCountLabel': i18n.translate('core.euiDisplaySelector.lineCountLabel', { + 'euiDisplaySelector.rowHeightLabel': i18n.translate('core.euiDisplaySelector.rowHeightLabel', { defaultMessage: 'Lines per row', }), 'euiFieldPassword.showPassword': i18n.translate('core.euiFieldPassword.showPassword', { diff --git a/packages/core/rendering/core-rendering-server-internal/src/rendering_service.tsx b/packages/core/rendering/core-rendering-server-internal/src/rendering_service.tsx index 44841ec0fbe3f..ace0399f242af 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/rendering_service.tsx +++ b/packages/core/rendering/core-rendering-server-internal/src/rendering_service.tsx @@ -20,10 +20,10 @@ import type { IUiSettingsClient } from '@kbn/core-ui-settings-server'; import type { UiPlugins } from '@kbn/core-plugins-base-server-internal'; import type { CustomBranding } from '@kbn/core-custom-branding-common'; import { - type UserProvidedValues, type DarkModeValue, parseDarkModeValue, type UiSettingsParams, + type UserProvidedValues, } from '@kbn/core-ui-settings-common'; import { Template } from './views'; import { @@ -148,23 +148,29 @@ export class RenderingService { const basePath = http.basePath.get(request); const { serverBasePath, publicBaseUrl } = http.basePath; - let settingsUserValues: Record = {}; - let globalSettingsUserValues: Record = {}; - - if (!isAnonymousPage) { - const userValues = await Promise.all([ - uiSettings.client?.getUserProvided(), - uiSettings.globalClient?.getUserProvided(), - ]); - - settingsUserValues = userValues[0]; - globalSettingsUserValues = userValues[1]; - } - - const defaultSettings = await withAsyncDefaultValues( - request, - uiSettings.client?.getRegistered() - ); + // Grouping all async HTTP requests to run them concurrently for performance reasons. + const [ + defaultSettings, + settingsUserValues = {}, + globalSettingsUserValues = {}, + userSettingDarkMode, + ] = await Promise.all([ + // All sites + withAsyncDefaultValues(request, uiSettings.client?.getRegistered()), + // Only non-anonymous pages + ...(!isAnonymousPage + ? ([ + uiSettings.client?.getUserProvided(), + uiSettings.globalClient?.getUserProvided(), + // dark mode + userSettings?.getUserSettingDarkMode(request), + ] as [ + Promise>, + Promise>, + Promise | undefined + ]) + : []), + ]); const settings = { defaults: defaultSettings, @@ -196,10 +202,6 @@ export class RenderingService { } // dark mode - const userSettingDarkMode = isAnonymousPage - ? undefined - : await userSettings?.getUserSettingDarkMode(request); - const isThemeOverridden = settings.user['theme:darkMode']?.isOverridden ?? false; let darkMode: DarkModeValue; diff --git a/packages/core/root/core-root-browser-internal/src/kbn_bootstrap.ts b/packages/core/root/core-root-browser-internal/src/kbn_bootstrap.ts index 80020b79427f5..a06abd107fd06 100644 --- a/packages/core/root/core-root-browser-internal/src/kbn_bootstrap.ts +++ b/packages/core/root/core-root-browser-internal/src/kbn_bootstrap.ts @@ -39,6 +39,76 @@ export async function __kbnBootstrap__() { }), ]); + const isDomStorageDisabled = () => { + try { + const key = 'kbn_bootstrap_domStorageEnabled'; + sessionStorage.setItem(key, 'true'); + sessionStorage.removeItem(key); + localStorage.setItem(key, 'true'); + localStorage.removeItem(key); + return false; + } catch (e) { + return true; + } + }; + + if (isDomStorageDisabled()) { + const defaultErrorTitle = `Couldn't load the page`; + const defaultErrorText = `Update your browser's settings to allow storage of cookies and site data, and reload the page.`; + const defaultErrorReload = 'Reload'; + + const errorTitle = i18nError + ? defaultErrorTitle + : i18n.translate('core.ui.welcomeErrorCouldNotLoadPage', { + defaultMessage: defaultErrorTitle, + }); + + const errorText = i18nError + ? defaultErrorText + : i18n.translate('core.ui.welcomeErrorDomStorageDisabled', { + defaultMessage: defaultErrorText, + }); + + const errorReload = i18nError + ? defaultErrorReload + : i18n.translate('core.ui.welcomeErrorReloadButton', { + defaultMessage: defaultErrorReload, + }); + + const err = document.createElement('div'); + err.style.textAlign = 'center'; + err.style.padding = '120px 20px'; + err.style.fontFamily = 'Inter, BlinkMacSystemFont, Helvetica, Arial, sans-serif'; + + const errorTitleEl = document.createElement('h1'); + errorTitleEl.innerText = errorTitle; + errorTitleEl.style.margin = '20px'; + errorTitleEl.style.color = '#1a1c21'; + + const errorTextEl = document.createElement('p'); + errorTextEl.innerText = errorText; + errorTextEl.style.margin = '20px'; + errorTextEl.style.color = '#343741'; + + const errorReloadEl = document.createElement('button'); + errorReloadEl.innerText = errorReload; + errorReloadEl.onclick = function () { + location.reload(); + }; + errorReloadEl.setAttribute( + 'style', + 'cursor: pointer; padding-inline: 12px; block-size: 40px; font-size: 1rem; line-height: 1.4286rem; border-radius: 6px; min-inline-size: 112px; color: rgb(255, 255, 255); background-color: rgb(0, 119, 204); outline-color: rgb(0, 0, 0); border:none' + ); + + err.appendChild(errorTitleEl); + err.appendChild(errorTextEl); + err.appendChild(errorReloadEl); + + document.body.innerHTML = ''; + document.body.appendChild(err); + return; + } + const coreSystem = new CoreSystem({ injectedMetadata, rootDomElement: document.body, diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/find.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/find.test.ts index 4c28f2003a986..12b14bb6f1a32 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/find.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/find.test.ts @@ -344,6 +344,33 @@ describe('find', () => { ), }); }); + + it('does not perform migrations when a partial document is requested by specifying no fields should be retrieved', async () => { + const noNamespaceSearchResults = generateIndexPatternSearchResults(); + client.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(noNamespaceSearchResults) + ); + migrator.migrateDocument.mockImplementationOnce((doc) => ({ ...doc, migrated: true })); + await expect( + repository.find({ + type, + fields: [], // don't return any fields + }) + ).resolves.not.toHaveProperty('saved_objects.0.migrated'); + expect(migrator.migrateDocument).not.toHaveBeenCalled(); + }); + + it('does not perform migrations when a partial document is requested by specifying some fields', async () => { + const noNamespaceSearchResults = generateIndexPatternSearchResults(); + client.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(noNamespaceSearchResults) + ); + migrator.migrateDocument.mockImplementationOnce((doc) => ({ ...doc, migrated: true })); + await expect(repository.find({ type, fields: ['title'] })).resolves.not.toHaveProperty( + 'saved_objects.0.migrated' + ); + expect(migrator.migrateDocument).not.toHaveBeenCalled(); + }); }); describe('search dsl', () => { diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/find.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/find.ts index 1222425ac2150..40501a0d80a37 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/find.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/find.ts @@ -16,6 +16,7 @@ import { CheckAuthorizationResult, SavedObjectsRawDocSource, GetFindRedactTypeMapParams, + SavedObjectUnsanitizedDoc, } from '@kbn/core-saved-objects-server'; import { DEFAULT_NAMESPACE_STRING, @@ -57,6 +58,7 @@ export const performFind = async ( common: commonHelper, serializer: serializerHelper, migration: migrationHelper, + encryption: encryptionHelper, } = helpers; const { securityExtension, spacesExtension } = extensions; let namespaces!: string[]; @@ -266,13 +268,27 @@ export const performFind = async ( const migratedDocuments: Array> = []; try { for (const savedObject of savedObjects) { + let migrated: SavedObjectUnsanitizedDoc | undefined; const { sort, score, ...rawObject } = savedObject; - const migrated = disableExtensions - ? migrationHelper.migrateStorageDocument(rawObject) - : await migrationHelper.migrateAndDecryptStorageDocument({ - document: rawObject, - typeMap: redactTypeMap, - }); + + if (fields !== undefined) { + // If the fields argument is set, don't migrate. + // This document may only contains a subset of it's fields meaning the migration + // (transform and forwardCompatibilitySchema) is not guaranteed to succeed. We + // still try to decrypt/redact the fields that are present in the document. + migrated = await encryptionHelper.optionallyDecryptAndRedactSingleResult( + savedObject, + redactTypeMap + ); + } else if (disableExtensions) { + migrated = migrationHelper.migrateStorageDocument(rawObject); + } else { + migrated = await migrationHelper.migrateAndDecryptStorageDocument({ + document: rawObject, + typeMap: redactTypeMap, + }); + } + migratedDocuments.push({ ...migrated, sort, score } as SavedObjectsFindResult); } } catch (error) { diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/helpers/common.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/helpers/common.ts index 27bd0918b0f9b..870f6833b4edc 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/helpers/common.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/helpers/common.ts @@ -105,10 +105,14 @@ export class CommonHelper { if (!id) { return SavedObjectsUtils.generateId(); } - // only allow a specified ID if we're overwriting an existing ESO with a Version - // this helps us ensure that the document really was previously created using ESO - // and not being used to get around the specified ID limitation - const canSpecifyID = (overwrite && version) || SavedObjectsUtils.isRandomId(id); + + const shouldEnforceRandomId = this.encryptionExtension?.shouldEnforceRandomId(type); + + // Allow specified ID if: + // 1. we're overwriting an existing ESO with a Version (this helps us ensure that the document really was previously created using ESO) + // 2. enforceRandomId is explicitly set to false + const canSpecifyID = + !shouldEnforceRandomId || (overwrite && version) || SavedObjectsUtils.isRandomId(id); if (!canSpecifyID) { throw SavedObjectsErrorHelpers.createBadRequestError( 'Predefined IDs are not allowed for saved objects with encrypted attributes unless the ID is a UUID.' diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.encryption_extension.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.encryption_extension.test.ts index f5c8c8518a58a..cf66621565577 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.encryption_extension.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.encryption_extension.test.ts @@ -261,6 +261,7 @@ describe('SavedObjectsRepository Encryption Extension', () => { it(`fails if non-UUID ID is specified for encrypted type`, async () => { mockEncryptionExt.isEncryptableType.mockReturnValue(true); + mockEncryptionExt.shouldEnforceRandomId.mockReturnValue(true); mockEncryptionExt.decryptOrStripResponseAttributes.mockResolvedValue({ ...encryptedSO, ...decryptedStrippedAttributes, @@ -291,6 +292,25 @@ describe('SavedObjectsRepository Encryption Extension', () => { ).resolves.not.toThrowError(); }); + it('allows to opt-out of random ID enforcement', async () => { + mockEncryptionExt.isEncryptableType.mockReturnValue(true); + mockEncryptionExt.shouldEnforceRandomId.mockReturnValue(false); + mockEncryptionExt.decryptOrStripResponseAttributes.mockResolvedValue({ + ...encryptedSO, + ...decryptedStrippedAttributes, + }); + + const result = await repository.create(encryptedSO.type, encryptedSO.attributes, { + id: encryptedSO.id, + version: mockVersion, + }); + + expect(client.create).toHaveBeenCalled(); + expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(encryptedSO.type); + expect(mockEncryptionExt.shouldEnforceRandomId).toHaveBeenCalledWith(encryptedSO.type); + expect(result.id).toBe(encryptedSO.id); + }); + describe('namespace', () => { const doTest = async (optNamespace: string, expectNamespaceInDescriptor: boolean) => { const options = { overwrite: true, namespace: optNamespace }; @@ -483,6 +503,7 @@ describe('SavedObjectsRepository Encryption Extension', () => { it(`fails if non-UUID ID is specified for encrypted type`, async () => { mockEncryptionExt.isEncryptableType.mockReturnValue(true); + mockEncryptionExt.shouldEnforceRandomId.mockReturnValue(true); const result = await bulkCreateSuccess(client, repository, [ encryptedSO, // Predefined IDs are not allowed for saved objects with encrypted attributes unless the ID is a UUID ]); @@ -529,6 +550,25 @@ describe('SavedObjectsRepository Encryption Extension', () => { expect(result.saved_objects.length).toBe(1); expect(result.saved_objects[0].error).toBeUndefined(); }); + + it('allows to opt-out of random ID enforcement', async () => { + mockEncryptionExt.isEncryptableType.mockReturnValue(true); + mockEncryptionExt.shouldEnforceRandomId.mockReturnValue(false); + mockEncryptionExt.decryptOrStripResponseAttributes.mockResolvedValue({ + ...encryptedSO, + ...decryptedStrippedAttributes, + }); + + const result = await bulkCreateSuccess(client, repository, [ + { ...encryptedSO, version: mockVersion }, + ]); + + expect(client.bulk).toHaveBeenCalled(); + expect(result.saved_objects).not.toBeUndefined(); + expect(result.saved_objects.length).toBe(1); + expect(result.saved_objects[0].error).toBeUndefined(); + expect(result.saved_objects[0].id).toBe(encryptedSO.id); + }); }); describe('#bulkUpdate', () => { diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/saved_objects_extensions.mock.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/saved_objects_extensions.mock.ts index 2061bb63240b2..9dc7c0f0133c5 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/saved_objects_extensions.mock.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/saved_objects_extensions.mock.ts @@ -17,6 +17,7 @@ const createEncryptionExtension = (): jest.Mocked => ({ diff --git a/packages/core/saved-objects/core-saved-objects-api-server-mocks/src/saved_objects_extensions.mock.ts b/packages/core/saved-objects/core-saved-objects-api-server-mocks/src/saved_objects_extensions.mock.ts index 2a2d121b568be..776ecfe3a7385 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-mocks/src/saved_objects_extensions.mock.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-mocks/src/saved_objects_extensions.mock.ts @@ -18,6 +18,7 @@ const createEncryptionExtension = (): jest.Mocked => ({ diff --git a/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/import_saved_objects.test.ts b/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/import_saved_objects.test.ts index d8c2b0b25874f..06c8e351bc445 100644 --- a/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/import_saved_objects.test.ts +++ b/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/import_saved_objects.test.ts @@ -19,6 +19,7 @@ import { } from './import_saved_objects.test.mock'; import { Readable } from 'stream'; +import { loggerMock, type MockedLogger } from '@kbn/logging-mocks'; import { v4 as uuidv4 } from 'uuid'; import type { SavedObjectsImportFailure, @@ -40,8 +41,10 @@ import { import type { ImportStateMap } from './lib'; describe('#importSavedObjectsFromStream', () => { + let logger: MockedLogger; beforeEach(() => { jest.clearAllMocks(); + logger = loggerMock.create(); // mock empty output of each of these mocked modules so the import doesn't throw an error mockCollectSavedObjects.mockResolvedValue({ errors: [], @@ -72,7 +75,6 @@ describe('#importSavedObjectsFromStream', () => { let savedObjectsClient: jest.Mocked; let typeRegistry: jest.Mocked; const namespace = 'some-namespace'; - const setupOptions = ({ createNewCopies = false, getTypeImpl = (type: string) => @@ -102,6 +104,7 @@ describe('#importSavedObjectsFromStream', () => { createNewCopies, importHooks, managed, + log: logger, }; }; const createObject = ({ diff --git a/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/import_saved_objects.ts b/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/import_saved_objects.ts index 4182daa610b37..7d6bf9668286a 100644 --- a/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/import_saved_objects.ts +++ b/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/import_saved_objects.ts @@ -17,6 +17,7 @@ import type { ISavedObjectTypeRegistry, SavedObjectsImportHook, } from '@kbn/core-saved-objects-server'; +import type { Logger } from '@kbn/logging'; import { checkReferenceOrigins, validateReferences, @@ -59,6 +60,7 @@ export interface ImportSavedObjectsOptions { * If provided, Kibana will apply the given option to the `managed` property. */ managed?: boolean; + log: Logger; } /** @@ -79,7 +81,11 @@ export async function importSavedObjectsFromStream({ refresh, compatibilityMode, managed, + log, }: ImportSavedObjectsOptions): Promise { + log.debug( + `Importing with overwrite ${overwrite ? 'enabled' : 'disabled'} and size limit ${objectLimit}` + ); let errorAccumulator: SavedObjectsImportFailure[] = []; const supportedTypes = typeRegistry.getImportableAndExportableTypes().map((type) => type.name); @@ -90,6 +96,11 @@ export async function importSavedObjectsFromStream({ supportedTypes, managed, }); + log.debug( + `Importing types: ${[ + ...new Set(collectSavedObjectsResult.collectedObjects.map((obj) => obj.type)), + ].join(', ')}` + ); errorAccumulator = [...errorAccumulator, ...collectSavedObjectsResult.errors]; // Map of all IDs for objects that we are attempting to import, and any references that are not included in the read stream; // each value is empty by default @@ -197,7 +208,17 @@ export async function importSavedObjectsFromStream({ objects: createSavedObjectsResult.createdObjects, importHooks, }); - + if (errorAccumulator.length > 0) { + log.error( + `Failed to import saved objects. ${errorAccumulator.length} errors: ${JSON.stringify( + errorAccumulator + )}` + ); + } else { + log.info( + `Successfully imported ${createSavedObjectsResult.createdObjects.length} saved objects.` + ); + } return { successCount: createSavedObjectsResult.createdObjects.length, success: errorAccumulator.length === 0, diff --git a/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/saved_objects_importer.ts b/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/saved_objects_importer.ts index f990eb13c435b..e8c13180bbdaf 100644 --- a/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/saved_objects_importer.ts +++ b/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/saved_objects_importer.ts @@ -62,7 +62,6 @@ export class SavedObjectsImporter implements ISavedObjectsImporter { compatibilityMode, managed, }: SavedObjectsImportOptions): Promise { - this.#log.debug('Starting the import process'); return importSavedObjectsFromStream({ readStream, createNewCopies, @@ -75,6 +74,7 @@ export class SavedObjectsImporter implements ISavedObjectsImporter { typeRegistry: this.#typeRegistry, importHooks: this.#importHooks, managed, + log: this.#log, }); } diff --git a/packages/core/saved-objects/core-saved-objects-server/src/extensions/encryption.ts b/packages/core/saved-objects/core-saved-objects-server/src/extensions/encryption.ts index 3fdb29203fe13..4560ab5672666 100644 --- a/packages/core/saved-objects/core-saved-objects-server/src/extensions/encryption.ts +++ b/packages/core/saved-objects/core-saved-objects-server/src/extensions/encryption.ts @@ -39,6 +39,14 @@ export interface ISavedObjectsEncryptionExtension { */ isEncryptableType: (type: string) => boolean; + /** + * Returns false if ESO type explicitly opts out of highly random UID + * + * @param type the string name of the object type + * @returns boolean, true by default unless explicitly set to false + */ + shouldEnforceRandomId: (type: string) => boolean; + /** * Given a saved object, will return a decrypted saved object or will strip * attributes from the returned object if decryption fails. diff --git a/packages/core/saved-objects/core-saved-objects-server/src/saved_objects_type.ts b/packages/core/saved-objects/core-saved-objects-server/src/saved_objects_type.ts index 1912863b52703..a29875a733d68 100644 --- a/packages/core/saved-objects/core-saved-objects-server/src/saved_objects_type.ts +++ b/packages/core/saved-objects/core-saved-objects-server/src/saved_objects_type.ts @@ -37,7 +37,12 @@ export interface SavedObjectsType { * The hidden types will not be automatically exposed via the HTTP API. * Therefore, that should prevent unexpected behavior in the client code, as all the interactions will be done via the plugin API. * + * Hidden types must be listed to be accessible by the client. + * + * (await context.core).savedObjects.getClient({ includeHiddenTypes: [MY_PLUGIN_HIDDEN_SAVED_OBJECT_TYPE] }) + * * See {@link SavedObjectsServiceStart.createInternalRepository | createInternalRepository}. + * */ hidden: boolean; /** diff --git a/packages/deeplinks/search/constants.ts b/packages/deeplinks/search/constants.ts index a2a17b20efba8..9848bb0c3d42e 100644 --- a/packages/deeplinks/search/constants.ts +++ b/packages/deeplinks/search/constants.ts @@ -21,3 +21,7 @@ export const SERVERLESS_ES_SEARCH_INFERENCE_ENDPOINTS_ID = 'searchInferenceEndpo export const SEARCH_HOMEPAGE = 'searchHomepage'; export const SEARCH_INDICES_START = 'elasticsearchStart'; export const SEARCH_INDICES = 'elasticsearchIndices'; +export const SEARCH_ELASTICSEARCH = 'enterpriseSearchElasticsearch'; +export const SEARCH_VECTOR_SEARCH = 'enterpriseSearchVectorSearch'; +export const SEARCH_SEMANTIC_SEARCH = 'enterpriseSearchSemanticSearch'; +export const SEARCH_AI_SEARCH = 'enterpriseSearchAISearch'; diff --git a/packages/deeplinks/search/deep_links.ts b/packages/deeplinks/search/deep_links.ts index 98703f18ac3fb..22dfb91bdff33 100644 --- a/packages/deeplinks/search/deep_links.ts +++ b/packages/deeplinks/search/deep_links.ts @@ -22,6 +22,10 @@ import { SEARCH_HOMEPAGE, SEARCH_INDICES_START, SEARCH_INDICES, + SEARCH_ELASTICSEARCH, + SEARCH_VECTOR_SEARCH, + SEARCH_SEMANTIC_SEARCH, + SEARCH_AI_SEARCH, } from './constants'; export type EnterpriseSearchApp = typeof ENTERPRISE_SEARCH_APP_ID; @@ -38,6 +42,10 @@ export type SearchInferenceEndpointsId = typeof SERVERLESS_ES_SEARCH_INFERENCE_E export type SearchHomepage = typeof SEARCH_HOMEPAGE; export type SearchStart = typeof SEARCH_INDICES_START; export type SearchIndices = typeof SEARCH_INDICES; +export type SearchElasticsearch = typeof SEARCH_ELASTICSEARCH; +export type SearchVectorSearch = typeof SEARCH_VECTOR_SEARCH; +export type SearchSemanticSearch = typeof SEARCH_SEMANTIC_SEARCH; +export type SearchAISearch = typeof SEARCH_AI_SEARCH; export type ContentLinkId = 'searchIndices' | 'connectors' | 'webCrawlers'; @@ -65,4 +73,8 @@ export type DeepLinkId = | `${EnterpriseSearchAppsearchApp}:${AppsearchLinkId}` | `${EnterpriseSearchRelevanceApp}:${RelevanceLinkId}` | SearchStart - | SearchIndices; + | SearchIndices + | SearchElasticsearch + | SearchVectorSearch + | SearchSemanticSearch + | SearchAISearch; diff --git a/packages/deeplinks/search/index.ts b/packages/deeplinks/search/index.ts index 250dfeed299e6..7c78d64081133 100644 --- a/packages/deeplinks/search/index.ts +++ b/packages/deeplinks/search/index.ts @@ -17,6 +17,10 @@ export { ENTERPRISE_SEARCH_WORKPLACESEARCH_APP_ID, SERVERLESS_ES_APP_ID, SERVERLESS_ES_CONNECTORS_ID, + SEARCH_ELASTICSEARCH, + SEARCH_VECTOR_SEARCH, + SEARCH_SEMANTIC_SEARCH, + SEARCH_AI_SEARCH, } from './constants'; export type { diff --git a/packages/kbn-alerts-as-data-utils/src/schemas/generated/observability_uptime_schema.ts b/packages/kbn-alerts-as-data-utils/src/schemas/generated/observability_uptime_schema.ts index 8770b70402d08..bf37ffc1ddb9c 100644 --- a/packages/kbn-alerts-as-data-utils/src/schemas/generated/observability_uptime_schema.ts +++ b/packages/kbn-alerts-as-data-utils/src/schemas/generated/observability_uptime_schema.ts @@ -77,6 +77,7 @@ const ObservabilityUptimeAlertOptional = rt.partial({ 'anomaly.start': schemaDate, configId: schemaString, 'error.message': schemaString, + 'error.stack_trace': schemaString, 'host.name': schemaString, 'kibana.alert.context': schemaUnknown, 'kibana.alert.evaluation.threshold': schemaStringOrNumber, diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/utils/check_action_type_enabled.test.ts b/packages/kbn-alerts-ui-shared/src/rule_form/utils/check_action_type_enabled.test.ts index 987d95ef3d070..7794f83825c76 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/utils/check_action_type_enabled.test.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/check_action_type_enabled.test.ts @@ -99,6 +99,26 @@ describe('checkActionTypeEnabled', () => { } `); }); + test('checkActionTypeEnabled returns true when actionType is disabled by config', async () => { + const actionType: ActionType = { + id: '1', + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting'], + name: 'my action', + enabled: false, + enabledInConfig: false, + enabledInLicense: true, + isSystemActionType: false, + }; + + const isPreconfiguredConnector = true; + + expect(checkActionTypeEnabled(actionType, isPreconfiguredConnector)).toMatchInlineSnapshot(` + Object { + "isEnabled": true, + } + `); + }); }); describe('checkActionFormActionTypeEnabled', () => { diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/utils/check_action_type_enabled.ts b/packages/kbn-alerts-ui-shared/src/rule_form/utils/check_action_type_enabled.ts index 891012f0eeb23..79c26b7052e86 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/utils/check_action_type_enabled.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/check_action_type_enabled.ts @@ -22,13 +22,14 @@ export interface IsDisabledResult { } export const checkActionTypeEnabled = ( - actionType?: ActionType + actionType?: ActionType, + isPreconfiguredConnector: boolean = false ): IsEnabledResult | IsDisabledResult => { if (actionType?.enabledInLicense === false) { return getLicenseCheckResult(actionType); } - if (actionType?.enabledInConfig === false) { + if (actionType?.enabledInConfig === false && isPreconfiguredConnector === false) { return configurationCheckResult; } diff --git a/packages/kbn-alerts-ui-shared/src/rule_type_modal/components/rule_type_list.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_type_modal/components/rule_type_list.test.tsx index 175d90924586c..7003b06875659 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_type_modal/components/rule_type_list.test.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_type_modal/components/rule_type_list.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { RuleTypeList } from './rule_type_list'; import { RuleTypeWithDescription } from '../types'; @@ -93,5 +94,8 @@ describe('RuleTypeList', () => { expect(secondRuleInList).not.toBeDisabled(); const thirdRuleInList = within(ruleListEl[2]).getByRole('button', { name: 'Rule Type 2' }); expect(thirdRuleInList).toBeDisabled(); + + await userEvent.hover(ruleListEl[2]); + expect(await screen.findByText('This rule requires a platinum license.')).toBeInTheDocument(); }); }); diff --git a/packages/kbn-alerts-ui-shared/src/rule_type_modal/components/rule_type_list.tsx b/packages/kbn-alerts-ui-shared/src/rule_type_modal/components/rule_type_list.tsx index 4e21e428f3c5c..91710dbdfade5 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_type_modal/components/rule_type_list.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_type_modal/components/rule_type_list.tsx @@ -20,6 +20,7 @@ import { EuiEmptyPrompt, EuiButton, useEuiTheme, + EuiToolTip, } from '@elastic/eui'; import { omit } from 'lodash'; import { PRODUCER_DISPLAY_NAMES } from '../../common/i18n'; @@ -86,6 +87,28 @@ export const RuleTypeList: React.FC = ({ const onClickAll = useCallback(() => onFilterByProducer(null), [onFilterByProducer]); + const ruleCard = (rule: RuleTypeWithDescription) => ( + onSelectRuleType(rule.id)} + description={rule.description} + style={{ marginRight: '8px', flexGrow: 0 }} + data-test-subj={`${rule.id}-SelectOption`} + isDisabled={!rule.enabledInLicense} + > + + {producerToDisplayName(rule.producer)} + + + ); + return ( = ({ )} {ruleTypesList.map((rule) => ( - onSelectRuleType(rule.id)} - description={rule.description} - style={{ marginRight: '8px', flexGrow: 0 }} - data-test-subj={`${rule.id}-SelectOption`} - isDisabled={rule.enabledInLicense === false} - > - - {producerToDisplayName(rule.producer)} - - + <>{ruleCard(rule)} + + )} ))} diff --git a/packages/kbn-apm-synthtrace-client/src/lib/entities/index.ts b/packages/kbn-apm-synthtrace-client/src/lib/entities/index.ts index 4cb2a7aa25cc3..8665ef0120e25 100644 --- a/packages/kbn-apm-synthtrace-client/src/lib/entities/index.ts +++ b/packages/kbn-apm-synthtrace-client/src/lib/entities/index.ts @@ -11,8 +11,19 @@ import { Fields } from '../entity'; import { serviceEntity } from './service_entity'; import { hostEntity } from './host_entity'; import { containerEntity } from './container_entity'; +import { k8sClusterJobEntity } from './kubernetes/cluster_entity'; +import { k8sCronJobEntity } from './kubernetes/cron_job_entity'; +import { k8sDaemonSetEntity } from './kubernetes/daemon_set_entity'; +import { k8sDeploymentEntity } from './kubernetes/deployment_entity'; +import { k8sJobSetEntity } from './kubernetes/job_set_entity'; +import { k8sNodeEntity } from './kubernetes/node_entity'; +import { k8sPodEntity } from './kubernetes/pod_entity'; +import { k8sReplicaSetEntity } from './kubernetes/replica_set'; +import { k8sStatefulSetEntity } from './kubernetes/stateful_set'; +import { k8sContainerEntity } from './kubernetes/container_entity'; export type EntityDataStreamType = 'metrics' | 'logs' | 'traces'; +export type Schema = 'ecs' | 'semconv'; export type EntityFields = Fields & Partial<{ @@ -32,4 +43,20 @@ export type EntityFields = Fields & [key: string]: any; }>; -export const entities = { serviceEntity, hostEntity, containerEntity }; +export const entities = { + serviceEntity, + hostEntity, + containerEntity, + k8s: { + k8sClusterJobEntity, + k8sCronJobEntity, + k8sDaemonSetEntity, + k8sDeploymentEntity, + k8sJobSetEntity, + k8sNodeEntity, + k8sPodEntity, + k8sReplicaSetEntity, + k8sStatefulSetEntity, + k8sContainerEntity, + }, +}; diff --git a/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/cluster_entity.ts b/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/cluster_entity.ts new file mode 100644 index 0000000000000..9fa4c81d86ffb --- /dev/null +++ b/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/cluster_entity.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 '..'; +import { K8sEntity } from '.'; + +export function k8sClusterJobEntity({ + schema, + name, + entityId, + ...others +}: { + schema: Schema; + name: string; + entityId: string; + [key: string]: any; +}) { + if (schema === 'ecs') { + return new K8sEntity(schema, { + 'entity.type': 'cluster', + 'orchestrator.cluster.name': name, + 'entity.id': entityId, + ...others, + }); + } + + return new K8sEntity(schema, { + 'entity.type': 'cluster', + 'k8s.cluster.uid': name, + 'entity.id': entityId, + ...others, + }); +} diff --git a/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/container_entity.ts b/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/container_entity.ts new file mode 100644 index 0000000000000..b05d412b0dd5c --- /dev/null +++ b/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/container_entity.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 '..'; +import { K8sEntity } from '.'; + +export function k8sContainerEntity({ + schema, + id, + entityId, + ...others +}: { + schema: Schema; + id: string; + entityId: string; + [key: string]: any; +}) { + if (schema === 'ecs') { + return new K8sEntity(schema, { + 'entity.type': 'container', + 'kubernetes.container.id': id, + 'entity.id': entityId, + ...others, + }); + } + + return new K8sEntity(schema, { + 'entity.type': 'container', + 'container.id': id, + 'entity.id': entityId, + ...others, + }); +} diff --git a/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/cron_job_entity.ts b/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/cron_job_entity.ts new file mode 100644 index 0000000000000..8590378e699fb --- /dev/null +++ b/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/cron_job_entity.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 '..'; +import { K8sEntity } from '.'; + +export function k8sCronJobEntity({ + schema, + name, + uid, + clusterName, + entityId, + ...others +}: { + schema: Schema; + name: string; + uid?: string; + clusterName?: string; + entityId: string; + [key: string]: any; +}) { + if (schema === 'ecs') { + return new K8sEntity(schema, { + 'entity.type': 'cron_job', + 'kubernetes.cronjob.name': name, + 'kubernetes.cronjob.uid': uid, + 'kubernetes.namespace': clusterName, + 'entity.id': entityId, + ...others, + }); + } + + return new K8sEntity(schema, { + 'entity.type': 'cron_job', + 'k8s.cronjob.name': name, + 'k8s.cronjob.uid': uid, + 'k8s.cluster.name': clusterName, + 'entity.id': entityId, + ...others, + }); +} diff --git a/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/daemon_set_entity.ts b/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/daemon_set_entity.ts new file mode 100644 index 0000000000000..7e20b1c6d506f --- /dev/null +++ b/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/daemon_set_entity.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 '..'; +import { K8sEntity } from '.'; + +export function k8sDaemonSetEntity({ + schema, + name, + uid, + clusterName, + entityId, + ...others +}: { + schema: Schema; + name: string; + uid?: string; + clusterName?: string; + entityId: string; + [key: string]: any; +}) { + if (schema === 'ecs') { + return new K8sEntity(schema, { + 'entity.type': 'daemon_set', + 'kubernetes.daemonset.name': name, + 'kubernetes.daemonset.uid': uid, + 'kubernetes.namespace': clusterName, + 'entity.id': entityId, + ...others, + }); + } + + return new K8sEntity(schema, { + 'entity.type': 'daemon_set', + 'k8s.daemonset.name': name, + 'k8s.daemonset.uid': uid, + 'k8s.cluster.name': clusterName, + 'entity.id': entityId, + ...others, + }); +} diff --git a/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/deployment_entity.ts b/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/deployment_entity.ts new file mode 100644 index 0000000000000..7eabdd0827325 --- /dev/null +++ b/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/deployment_entity.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 '..'; +import { K8sEntity } from '.'; + +export function k8sDeploymentEntity({ + schema, + name, + uid, + clusterName, + entityId, + ...others +}: { + schema: Schema; + name: string; + uid?: string; + clusterName?: string; + entityId: string; + [key: string]: any; +}) { + if (schema === 'ecs') { + return new K8sEntity(schema, { + 'entity.type': 'deployment', + 'kubernetes.deployment.name': name, + 'kubernetes.deployment.uid': uid, + 'kubernetes.namespace': clusterName, + 'entity.id': entityId, + ...others, + }); + } + + return new K8sEntity(schema, { + 'entity.type': 'deployment', + 'k8s.deployment.name': name, + 'k8s.deployment.uid': uid, + 'k8s.cluster.name': clusterName, + 'entity.id': entityId, + ...others, + }); +} diff --git a/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/index.ts b/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/index.ts new file mode 100644 index 0000000000000..db95dcf4155bc --- /dev/null +++ b/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/index.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { EntityFields, Schema } from '..'; +import { Serializable } from '../../serializable'; + +const identityFieldsMap: Record> = { + ecs: { + pod: ['kubernetes.pod.name'], + cluster: ['orchestrator.cluster.name'], + cron_job: ['kubernetes.cronjob.name'], + daemon_set: ['kubernetes.daemonset.name'], + deployment: ['kubernetes.deployment.name'], + job: ['kubernetes.job.name'], + node: ['kubernetes.node.name'], + replica_set: ['kubernetes.replicaset.name'], + stateful_set: ['kubernetes.statefulset.name'], + container: ['kubernetes.container.id'], + }, + semconv: { + pod: ['k8s.pod.name'], + cluster: ['k8s.cluster.uid'], + cron_job: ['k8s.cronjob.name'], + daemon_set: ['k8s.daemonset.name'], + deployment: ['k8s.deployment.name'], + job: ['k8s.job.name'], + node: ['k8s.node.uid'], + replica_set: ['k8s.replicaset.name'], + stateful_set: ['k8s.statefulset.name'], + container: ['container.id'], + }, +}; + +export class K8sEntity extends Serializable { + constructor(schema: Schema, fields: EntityFields) { + const entityType = fields['entity.type']; + if (entityType === undefined) { + throw new Error(`Entity type not defined: ${entityType}`); + } + + const entityTypeWithSchema = `kubernetes_${entityType}_${schema}`; + const identityFields = identityFieldsMap[schema][entityType]; + if (identityFields === undefined || identityFields.length === 0) { + throw new Error( + `Identity fields not defined for schema: ${schema} and entity type: ${entityType}` + ); + } + + super({ + ...fields, + 'entity.type': entityTypeWithSchema, + 'entity.definition_id': `builtin_${entityTypeWithSchema}`, + 'entity.identity_fields': identityFields, + 'entity.display_name': getDisplayName({ identityFields, fields }), + }); + } +} + +function getDisplayName({ + identityFields, + fields, +}: { + identityFields: string[]; + fields: EntityFields; +}) { + return identityFields + .map((field) => fields[field]) + .filter((_) => _) + .join(':'); +} diff --git a/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/job_set_entity.ts b/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/job_set_entity.ts new file mode 100644 index 0000000000000..e0383563c7266 --- /dev/null +++ b/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/job_set_entity.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 '..'; +import { K8sEntity } from '.'; + +export function k8sJobSetEntity({ + schema, + name, + uid, + clusterName, + entityId, + ...others +}: { + schema: Schema; + name: string; + uid?: string; + clusterName?: string; + entityId: string; + [key: string]: any; +}) { + if (schema === 'ecs') { + return new K8sEntity(schema, { + 'entity.type': 'job', + 'kubernetes.job.name': name, + 'kubernetes.job.uid': uid, + 'kubernetes.namespace': clusterName, + 'entity.id': entityId, + ...others, + }); + } + + return new K8sEntity(schema, { + 'entity.type': 'job', + 'k8s.job.name': name, + 'k8s.job.uid': uid, + 'k8s.cluster.name': clusterName, + 'entity.id': entityId, + ...others, + }); +} diff --git a/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/node_entity.ts b/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/node_entity.ts new file mode 100644 index 0000000000000..283df5250d41d --- /dev/null +++ b/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/node_entity.ts @@ -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", 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 '..'; +import { K8sEntity } from '.'; + +export function k8sNodeEntity({ + schema, + name, + uid, + clusterName, + entityId, + ...others +}: { + schema: Schema; + name: string; + uid?: string; + clusterName?: string; + entityId: string; + [key: string]: any; +}) { + if (schema === 'ecs') { + return new K8sEntity(schema, { + 'entity.type': 'node', + 'kubernetes.node.name': name, + 'kubernetes.node.uid': uid, + 'kubernetes.namespace': clusterName, + 'entity.id': entityId, + ...others, + }); + } + + return new K8sEntity(schema, { + 'entity.type': 'node', + 'k8s.node.uid': uid, + 'k8s.cluster.name': clusterName, + 'entity.id': entityId, + ...others, + }); +} diff --git a/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/pod_entity.ts b/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/pod_entity.ts new file mode 100644 index 0000000000000..1b71c4e39a4fc --- /dev/null +++ b/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/pod_entity.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 '..'; +import { K8sEntity } from '.'; + +export function k8sPodEntity({ + schema, + name, + uid, + clusterName, + entityId, + ...others +}: { + schema: Schema; + name: string; + uid?: string; + clusterName?: string; + entityId: string; + [key: string]: any; +}) { + if (schema === 'ecs') { + return new K8sEntity(schema, { + 'entity.type': 'pod', + 'kubernetes.pod.name': name, + 'kubernetes.pod.uid': uid, + 'kubernetes.namespace': clusterName, + 'entity.id': entityId, + ...others, + }); + } + + return new K8sEntity(schema, { + 'entity.type': 'pod', + 'k8s.pod.name': name, + 'k8s.pod.uid': uid, + 'k8s.cluster.name': clusterName, + 'entity.id': entityId, + ...others, + }); +} diff --git a/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/replica_set.ts b/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/replica_set.ts new file mode 100644 index 0000000000000..fcf20c0530c30 --- /dev/null +++ b/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/replica_set.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 '..'; +import { K8sEntity } from '.'; + +export function k8sReplicaSetEntity({ + schema, + name, + uid, + clusterName, + entityId, + ...others +}: { + schema: Schema; + name: string; + uid?: string; + clusterName?: string; + entityId: string; + [key: string]: any; +}) { + if (schema === 'ecs') { + return new K8sEntity(schema, { + 'entity.type': 'replica_set', + 'kubernetes.replicaset.name': name, + 'kubernetes.replicaset.uid': uid, + 'kubernetes.namespace': clusterName, + 'entity.id': entityId, + ...others, + }); + } + + return new K8sEntity(schema, { + 'entity.type': 'replica_set', + 'k8s.replicaset.name': name, + 'k8s.replicaset.uid': uid, + 'k8s.cluster.name': clusterName, + 'entity.id': entityId, + ...others, + }); +} diff --git a/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/stateful_set.ts b/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/stateful_set.ts new file mode 100644 index 0000000000000..58c603704ebc0 --- /dev/null +++ b/packages/kbn-apm-synthtrace-client/src/lib/entities/kubernetes/stateful_set.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 '..'; +import { K8sEntity } from '.'; + +export function k8sStatefulSetEntity({ + schema, + name, + uid, + clusterName, + entityId, + ...others +}: { + schema: Schema; + name: string; + uid?: string; + clusterName?: string; + entityId: string; + [key: string]: any; +}) { + if (schema === 'ecs') { + return new K8sEntity(schema, { + 'entity.type': 'stateful_set', + 'kubernetes.statefulset.name': name, + 'kubernetes.statefulset.uid': uid, + 'kubernetes.namespace': clusterName, + 'entity.id': entityId, + ...others, + }); + } + + return new K8sEntity(schema, { + 'entity.type': 'stateful_set', + 'k8s.statefulset.name': name, + 'k8s.statefulset.uid': uid, + 'k8s.cluster.name': clusterName, + 'entity.id': entityId, + ...others, + }); +} diff --git a/packages/kbn-apm-synthtrace-client/src/types/agent_names.ts b/packages/kbn-apm-synthtrace-client/src/types/agent_names.ts index d181a437fb73d..5953331bf5aa8 100644 --- a/packages/kbn-apm-synthtrace-client/src/types/agent_names.ts +++ b/packages/kbn-apm-synthtrace-client/src/types/agent_names.ts @@ -24,6 +24,8 @@ type OpenTelemetryAgentName = | 'otlp' | 'opentelemetry/cpp' | 'opentelemetry/dotnet' + | 'opentelemetry/dotnet/opentelemetry-dotnet-instrumentation' + | 'opentelemetry/dotnet/elastic' | 'opentelemetry/erlang' | 'opentelemetry/go' | 'opentelemetry/java' diff --git a/packages/kbn-apm-synthtrace/src/lib/apm/client/apm_synthtrace_kibana_client.ts b/packages/kbn-apm-synthtrace/src/lib/apm/client/apm_synthtrace_kibana_client.ts index 9533ded24dec5..58bfbb6f6d58c 100644 --- a/packages/kbn-apm-synthtrace/src/lib/apm/client/apm_synthtrace_kibana_client.ts +++ b/packages/kbn-apm-synthtrace/src/lib/apm/client/apm_synthtrace_kibana_client.ts @@ -16,10 +16,12 @@ import { getFetchAgent } from '../../../cli/utils/ssl'; export class ApmSynthtraceKibanaClient { private readonly logger: Logger; private target: string; + private headers: Record; - constructor(options: { logger: Logger; target: string }) { + constructor(options: { logger: Logger; target: string; headers?: Record }) { this.logger = options.logger; this.target = options.target; + this.headers = { ...kibanaHeaders(), ...(options.headers ?? {}) }; } getFleetApmPackagePath(packageVersion?: string): string { @@ -63,7 +65,7 @@ export class ApmSynthtraceKibanaClient { async () => { const res = await fetch(url, { method: 'POST', - headers: kibanaHeaders(), + headers: this.headers, body: '{"force":true}', agent: getFetchAgent(url), }); @@ -111,7 +113,7 @@ export class ApmSynthtraceKibanaClient { async () => { const res = await fetch(url, { method: 'DELETE', - headers: kibanaHeaders(), + headers: this.headers, body: '{"force":true}', agent: getFetchAgent(url), }); diff --git a/packages/kbn-apm-synthtrace/src/lib/entities/entities_synthtrace_es_client.ts b/packages/kbn-apm-synthtrace/src/lib/entities/entities_synthtrace_es_client.ts index 5373950fa6497..4c5d17111fca6 100644 --- a/packages/kbn-apm-synthtrace/src/lib/entities/entities_synthtrace_es_client.ts +++ b/packages/kbn-apm-synthtrace/src/lib/entities/entities_synthtrace_es_client.ts @@ -82,7 +82,8 @@ function getRoutingTransform() { const entityIndexName = `${entityType}s`; document._action = { index: { - _index: `.entities.v1.latest.builtin_${entityIndexName}_from_ecs_data`, + _index: + `.entities.v1.latest.builtin_${entityIndexName}_from_ecs_data`.toLocaleLowerCase(), _id: document['entity.id'], }, }; diff --git a/packages/kbn-apm-synthtrace/src/scenarios/helpers/unstructured_logs.ts b/packages/kbn-apm-synthtrace/src/scenarios/helpers/unstructured_logs.ts index 490bd449e2b60..9f2e2f76dfee6 100644 --- a/packages/kbn-apm-synthtrace/src/scenarios/helpers/unstructured_logs.ts +++ b/packages/kbn-apm-synthtrace/src/scenarios/helpers/unstructured_logs.ts @@ -33,7 +33,7 @@ export const unstructuredLogMessageGenerators = { ])} successfully ${f.number.int({ max: 100000 })} times`, ], taskStatusSuccess: (f: Faker) => [ - `${f.hacker.noun()}: ${f.word.words()} ${f.helpers.arrayElement([ + `${f.hacker.noun()}: ${f.word.words()} ${f.string.uuid()} ${f.helpers.arrayElement([ 'triggered', 'executed', 'processed', @@ -46,7 +46,7 @@ export const unstructuredLogMessageGenerators = { 'execution', 'processing', 'handling', - ])} of ${f.word.words()} failed at ${f.date.recent().toISOString()}`, + ])} of ${f.string.uuid()} failed at ${f.date.recent().toISOString()}`, ], error: (f: Faker) => [ `${f.helpers.arrayElement([ @@ -58,7 +58,7 @@ export const unstructuredLogMessageGenerators = { 'Issue', ])}: ${f.hacker.phrase()}`, `Stopping ${f.number.int(42)} background tasks...`, - 'Shutting down process...', + `Shutting down process ${f.string.hexadecimal({ length: 16, prefix: '' })}...`, ], restart: (f: Faker) => { const service = f.database.engine(); @@ -72,13 +72,27 @@ export const unstructuredLogMessageGenerators = { ])}`, ]; }, - userAuthentication: (f: Faker) => [ - `User ${f.internet.userName()} ${f.helpers.arrayElement([ - 'logged in', - 'logged out', - 'failed to login', - ])}`, - ], + userAuthentication: (f: Faker) => + f.helpers.arrayElements( + [ + `User ${f.internet.userName()} (id ${f.string.uuid()}) ${f.helpers.arrayElement([ + 'logged in', + 'logged out', + ])} at ${f.date.recent().toISOString()} from ${f.internet.ip()}:${f.internet.port()}`, + `Created new user ${f.internet.userName()} (id ${f.string.uuid()})`, + `Disabled user ${f.internet.userName()} (id ${f.string.uuid()}) due to level ${f.number.int( + { max: 10 } + )} ${f.helpers.arrayElement([ + 'suspicious activity', + 'security concerns', + 'policy violation', + ])}`, + `Login ${f.internet.userName()} (id ${f.string.uuid()}) incorrect ${f.number.int({ + max: 100, + })} times from ${f.internet.ipv6()}.`, + ], + { min: 1, max: 3 } + ), networkEvent: (f: Faker) => [ `Network ${f.helpers.arrayElement([ 'connection', diff --git a/packages/kbn-apm-synthtrace/src/scenarios/k8s_entities.ts b/packages/kbn-apm-synthtrace/src/scenarios/k8s_entities.ts new file mode 100644 index 0000000000000..7d94cc3180a7e --- /dev/null +++ b/packages/kbn-apm-synthtrace/src/scenarios/k8s_entities.ts @@ -0,0 +1,155 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { EntityFields, entities, generateShortId } from '@kbn/apm-synthtrace-client'; +import { Schema } from '@kbn/apm-synthtrace-client/src/lib/entities'; +import { Scenario } from '../cli/scenario'; +import { withClient } from '../lib/utils/with_client'; + +const CLUSTER_NAME = 'cluster_foo'; + +const CLUSTER_ENTITY_ID = generateShortId(); +const POD_ENTITY_ID = generateShortId(); +const POD_UID = generateShortId(); +const REPLICA_SET_ENTITY_ID = generateShortId(); +const REPLICA_SET_UID = generateShortId(); +const DEPLOYMENT_ENTITY_ID = generateShortId(); +const DEPLOYMENT_UID = generateShortId(); +const STATEFUL_SET_ENTITY_ID = generateShortId(); +const STATEFUL_SET_UID = generateShortId(); +const DAEMON_SET_ENTITY_ID = generateShortId(); +const DAEMON_SET_UID = generateShortId(); +const JOB_SET_ENTITY_ID = generateShortId(); +const JOB_SET_UID = generateShortId(); +const CRON_JOB_ENTITY_ID = generateShortId(); +const CRON_JOB_UID = generateShortId(); +const NODE_ENTITY_ID = generateShortId(); +const NODE_UID = generateShortId(); + +const scenario: Scenario> = async (runOptions) => { + const { logger } = runOptions; + + return { + bootstrap: async ({ entitiesKibanaClient }) => { + await entitiesKibanaClient.installEntityIndexPatterns(); + }, + generate: ({ range, clients: { entitiesEsClient } }) => { + const getK8sEntitiesEvents = (schema: Schema) => + range + .interval('1m') + .rate(1) + .generator((timestamp) => { + return [ + entities.k8s + .k8sClusterJobEntity({ + schema, + name: CLUSTER_NAME, + entityId: CLUSTER_ENTITY_ID, + }) + .timestamp(timestamp), + entities.k8s + .k8sPodEntity({ + schema, + clusterName: CLUSTER_NAME, + name: 'pod_foo', + uid: POD_UID, + entityId: POD_ENTITY_ID, + }) + .timestamp(timestamp), + entities.k8s + .k8sReplicaSetEntity({ + clusterName: CLUSTER_NAME, + name: 'replica_set_foo', + schema, + uid: REPLICA_SET_UID, + entityId: REPLICA_SET_ENTITY_ID, + }) + .timestamp(timestamp), + entities.k8s + .k8sDeploymentEntity({ + clusterName: CLUSTER_NAME, + name: 'deployment_foo', + schema, + uid: DEPLOYMENT_UID, + entityId: DEPLOYMENT_ENTITY_ID, + }) + .timestamp(timestamp), + entities.k8s + .k8sStatefulSetEntity({ + clusterName: CLUSTER_NAME, + name: 'stateful_set_foo', + schema, + uid: STATEFUL_SET_UID, + entityId: STATEFUL_SET_ENTITY_ID, + }) + .timestamp(timestamp), + entities.k8s + .k8sDaemonSetEntity({ + clusterName: CLUSTER_NAME, + name: 'daemon_set_foo', + schema, + uid: DAEMON_SET_UID, + entityId: DAEMON_SET_ENTITY_ID, + }) + .timestamp(timestamp), + entities.k8s + .k8sJobSetEntity({ + clusterName: CLUSTER_NAME, + name: 'job_set_foo', + schema, + uid: JOB_SET_UID, + entityId: JOB_SET_ENTITY_ID, + }) + .timestamp(timestamp), + entities.k8s + .k8sCronJobEntity({ + clusterName: CLUSTER_NAME, + name: 'cron_job_foo', + schema, + uid: CRON_JOB_UID, + entityId: CRON_JOB_ENTITY_ID, + }) + .timestamp(timestamp), + entities.k8s + .k8sNodeEntity({ + clusterName: CLUSTER_NAME, + name: 'node_job_foo', + schema, + uid: NODE_UID, + entityId: NODE_ENTITY_ID, + }) + .timestamp(timestamp), + entities.k8s + .k8sContainerEntity({ + id: '123', + schema, + entityId: NODE_ENTITY_ID, + }) + .timestamp(timestamp), + ]; + }); + + const ecsEntities = getK8sEntitiesEvents('ecs'); + const otelEntities = getK8sEntitiesEvents('semconv'); + + return [ + withClient( + entitiesEsClient, + logger.perf('generating_entities_ecs_events', () => ecsEntities) + ), + withClient( + entitiesEsClient, + logger.perf('generating_entities_otel_events', () => otelEntities) + ), + ]; + }, + }; +}; + +export default scenario; diff --git a/packages/kbn-apm-synthtrace/src/scenarios/many_entities.ts b/packages/kbn-apm-synthtrace/src/scenarios/many_entities.ts new file mode 100644 index 0000000000000..8b0d2afa5a971 --- /dev/null +++ b/packages/kbn-apm-synthtrace/src/scenarios/many_entities.ts @@ -0,0 +1,185 @@ +/* + * Copyright 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 { EntityFields, entities, generateShortId } from '@kbn/apm-synthtrace-client'; +import { Schema } from '@kbn/apm-synthtrace-client/src/lib/entities'; +import { Scenario } from '../cli/scenario'; +import { withClient } from '../lib/utils/with_client'; + +const CLUSTER_NAME = 'cluster_foo'; + +const CLUSTER_ENTITY_ID = generateShortId(); +const POD_ENTITY_ID = generateShortId(); +const POD_UID = generateShortId(); +const REPLICA_SET_ENTITY_ID = generateShortId(); +const REPLICA_SET_UID = generateShortId(); +const DEPLOYMENT_ENTITY_ID = generateShortId(); +const DEPLOYMENT_UID = generateShortId(); +const STATEFUL_SET_ENTITY_ID = generateShortId(); +const STATEFUL_SET_UID = generateShortId(); +const DAEMON_SET_ENTITY_ID = generateShortId(); +const DAEMON_SET_UID = generateShortId(); +const JOB_SET_ENTITY_ID = generateShortId(); +const JOB_SET_UID = generateShortId(); +const CRON_JOB_ENTITY_ID = generateShortId(); +const CRON_JOB_UID = generateShortId(); +const NODE_ENTITY_ID = generateShortId(); +const NODE_UID = generateShortId(); +const SYNTH_JAVA_TRACE_ENTITY_ID = generateShortId(); +const SYNTH_HOST_FOO_LOGS_ENTITY_ID = generateShortId(); +const SYNTH_CONTAINER_FOO_LOGS_ENTITY_ID = generateShortId(); + +const scenario: Scenario> = async (runOptions) => { + const { logger } = runOptions; + + return { + bootstrap: async ({ entitiesKibanaClient }) => { + await entitiesKibanaClient.installEntityIndexPatterns(); + }, + generate: ({ range, clients: { entitiesEsClient } }) => { + const rangeInterval = range.interval('1m').rate(1); + const getK8sEntitiesEvents = (schema: Schema) => + rangeInterval.generator((timestamp) => { + return [ + entities.k8s + .k8sClusterJobEntity({ + schema, + name: CLUSTER_NAME, + entityId: CLUSTER_ENTITY_ID, + }) + .timestamp(timestamp), + entities.k8s + .k8sPodEntity({ + schema, + clusterName: CLUSTER_NAME, + name: 'pod_foo', + uid: POD_UID, + entityId: POD_ENTITY_ID, + }) + .timestamp(timestamp), + entities.k8s + .k8sReplicaSetEntity({ + clusterName: CLUSTER_NAME, + name: 'replica_set_foo', + schema, + uid: REPLICA_SET_UID, + entityId: REPLICA_SET_ENTITY_ID, + }) + .timestamp(timestamp), + entities.k8s + .k8sDeploymentEntity({ + clusterName: CLUSTER_NAME, + name: 'deployment_foo', + schema, + uid: DEPLOYMENT_UID, + entityId: DEPLOYMENT_ENTITY_ID, + }) + .timestamp(timestamp), + entities.k8s + .k8sStatefulSetEntity({ + clusterName: CLUSTER_NAME, + name: 'stateful_set_foo', + schema, + uid: STATEFUL_SET_UID, + entityId: STATEFUL_SET_ENTITY_ID, + }) + .timestamp(timestamp), + entities.k8s + .k8sDaemonSetEntity({ + clusterName: CLUSTER_NAME, + name: 'daemon_set_foo', + schema, + uid: DAEMON_SET_UID, + entityId: DAEMON_SET_ENTITY_ID, + }) + .timestamp(timestamp), + entities.k8s + .k8sJobSetEntity({ + clusterName: CLUSTER_NAME, + name: 'job_set_foo', + schema, + uid: JOB_SET_UID, + entityId: JOB_SET_ENTITY_ID, + }) + .timestamp(timestamp), + entities.k8s + .k8sCronJobEntity({ + clusterName: CLUSTER_NAME, + name: 'cron_job_foo', + schema, + uid: CRON_JOB_UID, + entityId: CRON_JOB_ENTITY_ID, + }) + .timestamp(timestamp), + entities.k8s + .k8sNodeEntity({ + clusterName: CLUSTER_NAME, + name: 'node_job_foo', + schema, + uid: NODE_UID, + entityId: NODE_ENTITY_ID, + }) + .timestamp(timestamp), + entities.k8s + .k8sContainerEntity({ + id: '123', + schema, + entityId: NODE_ENTITY_ID, + }) + .timestamp(timestamp), + ]; + }); + + const ecsEntities = getK8sEntitiesEvents('ecs'); + const otelEntities = getK8sEntitiesEvents('semconv'); + const synthJavaTraces = entities.serviceEntity({ + serviceName: 'synth_java', + agentName: ['java'], + dataStreamType: ['traces'], + environment: 'production', + entityId: SYNTH_JAVA_TRACE_ENTITY_ID, + }); + const synthHostFooLogs = entities.hostEntity({ + hostName: 'synth_host_foo', + agentName: ['macbook'], + dataStreamType: ['logs'], + entityId: SYNTH_HOST_FOO_LOGS_ENTITY_ID, + }); + const synthContainerFooLogs = entities.containerEntity({ + containerId: 'synth_container_foo', + agentName: ['macbook'], + dataStreamType: ['logs'], + entityId: SYNTH_CONTAINER_FOO_LOGS_ENTITY_ID, + }); + + const otherEvents = rangeInterval.generator((timestamp) => [ + synthJavaTraces.timestamp(timestamp), + synthHostFooLogs.timestamp(timestamp), + synthContainerFooLogs.timestamp(timestamp), + ]); + + return [ + withClient( + entitiesEsClient, + logger.perf('generating_entities_k8s_ecs_events', () => ecsEntities) + ), + withClient( + entitiesEsClient, + logger.perf('generating_entities_k8s_otel_events', () => otelEntities) + ), + withClient( + entitiesEsClient, + logger.perf('generating_entities_other_events', () => otherEvents) + ), + ]; + }, + }; +}; + +export default scenario; diff --git a/packages/kbn-cell-actions/src/actions/copy_to_clipboard/copy_to_clipboard.ts b/packages/kbn-cell-actions/src/actions/copy_to_clipboard/copy_to_clipboard.ts index 850a534278fbe..90e93923fa360 100644 --- a/packages/kbn-cell-actions/src/actions/copy_to_clipboard/copy_to_clipboard.ts +++ b/packages/kbn-cell-actions/src/actions/copy_to_clipboard/copy_to_clipboard.ts @@ -33,7 +33,7 @@ const COPY_TO_CLIPBOARD_SUCCESS = i18n.translate( } ); -const escapeValue = (value: string) => value.replace(/"/g, '\\"'); +const escapeValue = (value: string) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); export const createCopyToClipboardActionFactory = createCellActionFactory( ({ notifications }: { notifications: NotificationsStart }) => ({ diff --git a/packages/kbn-check-mappings-update-cli/current_fields.json b/packages/kbn-check-mappings-update-cli/current_fields.json index 5dabd353a8a9a..5ca2fc677b167 100644 --- a/packages/kbn-check-mappings-update-cli/current_fields.json +++ b/packages/kbn-check-mappings-update-cli/current_fields.json @@ -1072,6 +1072,7 @@ "urls" ], "synthetics-param": [], + "synthetics-private-location": [], "synthetics-privates-locations": [], "tag": [ "color", diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index 414faf88b304f..1e9ff6ac20c79 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -3549,6 +3549,10 @@ "dynamic": false, "properties": {} }, + "synthetics-private-location": { + "dynamic": false, + "properties": {} + }, "synthetics-privates-locations": { "dynamic": false, "properties": {} diff --git a/packages/kbn-data-stream-adapter/src/field_maps/types.ts b/packages/kbn-data-stream-adapter/src/field_maps/types.ts index 62f4c7c600036..1cdafc7c61809 100644 --- a/packages/kbn-data-stream-adapter/src/field_maps/types.ts +++ b/packages/kbn-data-stream-adapter/src/field_maps/types.ts @@ -54,6 +54,8 @@ export type FieldMap = Record< scaling_factor?: number; dynamic?: boolean | 'strict'; properties?: Record; + inference_id?: string; + copy_to?: string; } >; diff --git a/packages/kbn-discover-utils/index.ts b/packages/kbn-discover-utils/index.ts index 7234944783037..4345c0f8fc6c4 100644 --- a/packages/kbn-discover-utils/index.ts +++ b/packages/kbn-discover-utils/index.ts @@ -56,6 +56,7 @@ export { getVisibleColumns, canPrependTimeFieldColumn, DiscoverFlyouts, + AppMenuRegistry, dismissAllFlyoutsExceptFor, dismissFlyouts, LogLevelBadge, diff --git a/packages/kbn-discover-utils/src/components/app_menu/__snapshots__/app_menu_registry.test.ts.snap b/packages/kbn-discover-utils/src/components/app_menu/__snapshots__/app_menu_registry.test.ts.snap new file mode 100644 index 0000000000000..88ee3c6f55a76 --- /dev/null +++ b/packages/kbn-discover-utils/src/components/app_menu/__snapshots__/app_menu_registry.test.ts.snap @@ -0,0 +1,184 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AppMenuRegistry should allow to override actions under submenu 1`] = ` +Array [ + Object { + "controlProps": Object { + "label": "Action 2", + "onClick": [MockFunction], + }, + "id": "action-2", + "order": 200, + "type": "secondary", + }, + Object { + "actions": Array [ + Object { + "controlProps": Object { + "label": "Action 3.2", + "onClick": [MockFunction], + }, + "id": "action-3-2", + "order": 200, + "type": "secondary", + }, + Object { + "controlProps": Object { + "label": "Action Custom", + "onClick": [MockFunction], + }, + "id": "action-3-1", + "type": "custom", + }, + ], + "id": "action-3", + "label": "Action 3", + "order": 300, + "type": "secondary", + }, + Object { + "controlProps": Object { + "iconType": "bell", + "label": "Action 1", + "onClick": [MockFunction], + }, + "id": "action-1", + "order": 100, + "type": "primary", + }, +] +`; + +exports[`AppMenuRegistry should allow to register custom actions 1`] = ` +Array [ + Object { + "controlProps": Object { + "label": "Action Custom", + "onClick": [MockFunction], + }, + "id": "action-custom", + "type": "custom", + }, + Object { + "actions": Array [ + Object { + "controlProps": Object { + "label": "Action Custom Submenu 1", + "onClick": [MockFunction], + }, + "id": "action-custom-submenu-1", + "type": "custom", + }, + ], + "id": "action-custom-submenu", + "label": "Action Custom Submenu", + "type": "custom", + }, + Object { + "controlProps": Object { + "label": "Action 2", + "onClick": [MockFunction], + }, + "id": "action-2", + "order": 200, + "type": "secondary", + }, + Object { + "actions": Array [ + Object { + "controlProps": Object { + "iconType": "heart", + "label": "Action 3.1", + "onClick": [MockFunction], + }, + "id": "action-3-1", + "order": 100, + "type": "secondary", + }, + Object { + "controlProps": Object { + "label": "Action 3.2", + "onClick": [MockFunction], + }, + "id": "action-3-2", + "order": 200, + "type": "secondary", + }, + ], + "id": "action-3", + "label": "Action 3", + "order": 300, + "type": "secondary", + }, + Object { + "controlProps": Object { + "iconType": "bell", + "label": "Action 1", + "onClick": [MockFunction], + }, + "id": "action-1", + "order": 100, + "type": "primary", + }, +] +`; + +exports[`AppMenuRegistry should allow to register custom actions under submenu 1`] = ` +Array [ + Object { + "controlProps": Object { + "label": "Action 2", + "onClick": [MockFunction], + }, + "id": "action-2", + "order": 200, + "type": "secondary", + }, + Object { + "actions": Array [ + Object { + "controlProps": Object { + "iconType": "heart", + "label": "Action 3.1", + "onClick": [MockFunction], + }, + "id": "action-3-1", + "order": 100, + "type": "secondary", + }, + Object { + "controlProps": Object { + "label": "Action Custom", + "onClick": [MockFunction], + }, + "id": "action-custom", + "order": 101, + "type": "custom", + }, + Object { + "controlProps": Object { + "label": "Action 3.2", + "onClick": [MockFunction], + }, + "id": "action-3-2", + "order": 200, + "type": "secondary", + }, + ], + "id": "action-3", + "label": "Action 3", + "order": 300, + "type": "secondary", + }, + Object { + "controlProps": Object { + "iconType": "bell", + "label": "Action 1", + "onClick": [MockFunction], + }, + "id": "action-1", + "order": 100, + "type": "primary", + }, +] +`; diff --git a/packages/kbn-discover-utils/src/components/app_menu/app_menu_registry.test.ts b/packages/kbn-discover-utils/src/components/app_menu/app_menu_registry.test.ts new file mode 100644 index 0000000000000..46b565c490b0e --- /dev/null +++ b/packages/kbn-discover-utils/src/components/app_menu/app_menu_registry.test.ts @@ -0,0 +1,188 @@ +/* + * Copyright 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 { AppMenuRegistry } from './app_menu_registry'; +import { + AppMenuActionSubmenuSecondary, + AppMenuActionType, + AppMenuSubmenuActionCustom, +} from './types'; + +describe('AppMenuRegistry', () => { + it('should initialize correctly', () => { + const appMenuRegistry = initializeAppMenuRegistry(); + expect(appMenuRegistry.isActionRegistered('action-1')).toBe(true); + expect(appMenuRegistry.isActionRegistered('action-2')).toBe(true); + expect(appMenuRegistry.isActionRegistered('action-3')).toBe(true); + expect(appMenuRegistry.isActionRegistered('action-3-1')).toBe(true); + expect(appMenuRegistry.isActionRegistered('action-3-2')).toBe(true); + expect(appMenuRegistry.isActionRegistered('action-n')).toBe(false); + expect(appMenuRegistry.getSortedItems()).toHaveLength(3); + }); + + it('should allow to register custom actions', () => { + const appMenuRegistry = initializeAppMenuRegistry(); + expect(appMenuRegistry.isActionRegistered('action-custom')).toBe(false); + + appMenuRegistry.registerCustomAction({ + id: 'action-custom', + type: AppMenuActionType.custom, + controlProps: { + label: 'Action Custom', + onClick: jest.fn(), + }, + }); + + appMenuRegistry.registerCustomAction({ + id: 'action-custom-submenu', + type: AppMenuActionType.custom, + label: 'Action Custom Submenu', + actions: [ + { + id: 'action-custom-submenu-1', + type: AppMenuActionType.custom, + controlProps: { + label: 'Action Custom Submenu 1', + onClick: jest.fn(), + }, + }, + ], + }); + + expect(appMenuRegistry.isActionRegistered('action-custom')).toBe(true); + expect(appMenuRegistry.isActionRegistered('action-custom-submenu')).toBe(true); + expect(appMenuRegistry.getSortedItems()).toHaveLength(5); + + appMenuRegistry.registerCustomAction({ + id: 'action-custom-extra', + type: AppMenuActionType.custom, + controlProps: { + label: 'Action Custom Extra', + onClick: jest.fn(), + }, + }); + + // should limit the number of custom items + const items = appMenuRegistry.getSortedItems(); + expect(items).toHaveLength(5); + expect(items).toMatchSnapshot(); + }); + + it('should allow to register custom actions under submenu', () => { + const appMenuRegistry = initializeAppMenuRegistry(); + expect(appMenuRegistry.isActionRegistered('action-custom')).toBe(false); + + let items = appMenuRegistry.getSortedItems(); + let submenuItem = items.find((item) => item.id === 'action-3') as AppMenuActionSubmenuSecondary; + expect(items).toHaveLength(3); + expect(submenuItem.actions).toHaveLength(2); + + appMenuRegistry.registerCustomActionUnderSubmenu('action-3', { + id: 'action-custom', + type: AppMenuActionType.custom, + order: 101, + controlProps: { + label: 'Action Custom', + onClick: jest.fn(), + }, + }); + + expect(appMenuRegistry.isActionRegistered('action-custom')).toBe(true); + + items = appMenuRegistry.getSortedItems(); + expect(items).toHaveLength(3); + + // calling it again should not add a duplicate + items = appMenuRegistry.getSortedItems(); + expect(items).toHaveLength(3); + + submenuItem = items.find((item) => item.id === 'action-3') as AppMenuActionSubmenuSecondary; + expect(submenuItem.actions).toHaveLength(3); + expect(items).toMatchSnapshot(); + }); + + it('should allow to override actions under submenu', () => { + const appMenuRegistry = initializeAppMenuRegistry(); + + let items = appMenuRegistry.getSortedItems(); + expect(items).toHaveLength(3); + + let submenuItem = items.find((item) => item.id === 'action-3') as AppMenuActionSubmenuSecondary; + const existingSecondaryActionId = submenuItem.actions[0].id; + expect(submenuItem.actions).toHaveLength(2); + + expect(appMenuRegistry.isActionRegistered(existingSecondaryActionId)).toBe(true); + + const customAction: AppMenuSubmenuActionCustom = { + id: existingSecondaryActionId, // using the same id to override the action with a custom one + type: AppMenuActionType.custom, + controlProps: { + label: 'Action Custom', + onClick: jest.fn(), + }, + }; + appMenuRegistry.registerCustomActionUnderSubmenu('action-3', customAction); + + expect(appMenuRegistry.isActionRegistered(existingSecondaryActionId)).toBe(true); + + items = appMenuRegistry.getSortedItems(); + submenuItem = items.find((item) => item.id === 'action-3') as AppMenuActionSubmenuSecondary; + expect(submenuItem.actions).toHaveLength(2); + expect(submenuItem.actions.find((item) => item.id === existingSecondaryActionId)).toBe( + customAction + ); + expect(items).toMatchSnapshot(); + }); +}); + +function initializeAppMenuRegistry() { + return new AppMenuRegistry([ + { + id: 'action-1', + type: AppMenuActionType.primary, + controlProps: { + label: 'Action 1', + iconType: 'bell', + onClick: jest.fn(), + }, + }, + { + id: 'action-2', + type: AppMenuActionType.secondary, + controlProps: { + label: 'Action 2', + onClick: jest.fn(), + }, + }, + { + id: 'action-3', + type: AppMenuActionType.secondary, + label: 'Action 3', + actions: [ + { + id: 'action-3-1', + type: AppMenuActionType.secondary, + controlProps: { + label: 'Action 3.1', + iconType: 'heart', + onClick: jest.fn(), + }, + }, + { + id: 'action-3-2', + type: AppMenuActionType.secondary, + controlProps: { + label: 'Action 3.2', + onClick: jest.fn(), + }, + }, + ], + }, + ]); +} diff --git a/packages/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts b/packages/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts new file mode 100644 index 0000000000000..65145c7de6751 --- /dev/null +++ b/packages/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts @@ -0,0 +1,214 @@ +/* + * Copyright 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 { + AppMenuActionBase, + AppMenuActionSubmenuBase, + AppMenuActionSubmenuCustom, + AppMenuSubmenuHorizontalRule, + AppMenuActionSubmenuSecondary, + AppMenuActionType, + AppMenuItem, + AppMenuItemCustom, + AppMenuItemPrimary, + AppMenuItemSecondary, + AppMenuSubmenuActionCustom, +} from './types'; + +export class AppMenuRegistry { + static CUSTOM_ITEMS_LIMIT = 2; + + private appMenuItems: AppMenuItem[]; + /** + * As custom actions can be registered under a submenu from both root and data source profiles, we need to keep track of them separately. + * Otherwise, it would be less predictable. For example, we would override/reset the actions from the data source profile with the ones from the root profile. + * @private + */ + private customSubmenuItemsBySubmenuId: Map< + string, + Array + >; + + constructor(primaryAndSecondaryActions: Array) { + this.appMenuItems = assignOrderToActions(primaryAndSecondaryActions); + this.customSubmenuItemsBySubmenuId = new Map(); + } + + public isActionRegistered(appMenuItemId: string) { + return ( + this.appMenuItems.some((item) => { + if (item.id === appMenuItemId) { + return true; + } + if (isAppMenuActionSubmenu(item)) { + return item.actions.some((submenuItem) => submenuItem.id === appMenuItemId); + } + return false; + }) || + [...this.customSubmenuItemsBySubmenuId.values()].some((submenuItems) => + submenuItems.some((item) => item.id === appMenuItemId) + ) + ); + } + + /** + * Register a custom action to the app menu. It can be a simple action or a submenu with more actions and horizontal rules. + * Note: Only 2 top level custom actions are allowed to be rendered in the app menu. The rest will be ignored. + * A custom action can also open a flyout or a modal. For that, return your custom react node from action's `onClick` event and call `onFinishAction` when you're done. + * @param appMenuItem + */ + public registerCustomAction(appMenuItem: AppMenuItemCustom) { + this.appMenuItems = [ + ...this.appMenuItems.filter( + // prevent duplicates + (item) => !(item.id === appMenuItem.id && item.type === AppMenuActionType.custom) + ), + appMenuItem, + ]; + } + + /** + * Register a custom action under a submenu. It can be an action or a horizontal rule. + * Any number of submenu actions can be registered and rendered. + * You can also extend an existing submenu with more actions. For example, AppMenuActionType.alerts. + * `order` property is optional and can be used to control the order of actions in the submenu. + * @param submenuId + * @param appMenuItem + */ + public registerCustomActionUnderSubmenu( + submenuId: string, + appMenuItem: AppMenuSubmenuActionCustom | AppMenuSubmenuHorizontalRule + ) { + this.customSubmenuItemsBySubmenuId.set(submenuId, [ + ...(this.customSubmenuItemsBySubmenuId.get(submenuId) ?? []).filter( + // prevent duplicates and allow overrides + (item) => item.id !== appMenuItem.id + ), + appMenuItem, + ]); + } + + private getSortedItemsForType(type: AppMenuActionType) { + let actions = this.appMenuItems.filter((item) => item.type === type); + + if (type === AppMenuActionType.custom && actions.length > AppMenuRegistry.CUSTOM_ITEMS_LIMIT) { + // apply the limitation on how many custom items can be shown + actions = actions.slice(0, AppMenuRegistry.CUSTOM_ITEMS_LIMIT); + } + + // enrich submenus with custom actions + if (type === AppMenuActionType.secondary || type === AppMenuActionType.custom) { + [...this.customSubmenuItemsBySubmenuId.entries()].forEach(([submenuId, customActions]) => { + actions = actions.map((item) => { + if (item.id === submenuId && isAppMenuActionSubmenu(item)) { + return extendSubmenuWithCustomActions(item, customActions); + } + return item; + }); + }); + } + + return sortAppMenuItemsByOrder(actions); + } + + /** + * Get the resulting app menu items sorted by type and order. + */ + public getSortedItems() { + const primaryItems = this.getSortedItemsForType(AppMenuActionType.primary); + const secondaryItems = this.getSortedItemsForType(AppMenuActionType.secondary); + const customItems = this.getSortedItemsForType(AppMenuActionType.custom); + + return [...customItems, ...secondaryItems, ...primaryItems]; + } +} + +function isAppMenuActionSubmenu( + appMenuItem: AppMenuItem +): appMenuItem is AppMenuActionSubmenuSecondary | AppMenuActionSubmenuCustom { + return 'actions' in appMenuItem && Array.isArray(appMenuItem.actions); +} + +const FALLBACK_ORDER = Number.MAX_SAFE_INTEGER; + +function sortByOrder(a: T, b: T): number { + return (a.order ?? FALLBACK_ORDER) - (b.order ?? FALLBACK_ORDER); +} + +function getAppMenuSubmenuWithSortedItemsByOrder< + T extends AppMenuActionSubmenuBase = AppMenuActionSubmenuSecondary | AppMenuActionSubmenuCustom +>(appMenuItem: T): T { + return { + ...appMenuItem, + actions: [...appMenuItem.actions].sort(sortByOrder), + }; +} + +function sortAppMenuItemsByOrder(appMenuItems: AppMenuItem[]): AppMenuItem[] { + const sortedAppMenuItems = [...appMenuItems].sort(sortByOrder); + return sortedAppMenuItems.map((appMenuItem) => { + if (isAppMenuActionSubmenu(appMenuItem)) { + return getAppMenuSubmenuWithSortedItemsByOrder(appMenuItem); + } + return appMenuItem; + }); +} + +function getAppMenuSubmenuWithAssignedOrder< + T extends AppMenuActionSubmenuBase = AppMenuActionSubmenuSecondary | AppMenuActionSubmenuCustom +>(appMenuItem: T, order: number): T { + let orderInSubmenu = 0; + const actionsWithOrder = appMenuItem.actions.map((action) => { + orderInSubmenu = orderInSubmenu + 100; + return { + ...action, + order: action.order ?? orderInSubmenu, + }; + }); + return { + ...appMenuItem, + order: appMenuItem.order ?? order, + actions: actionsWithOrder, + }; +} + +function extendSubmenuWithCustomActions< + T extends AppMenuActionSubmenuBase = AppMenuActionSubmenuSecondary | AppMenuActionSubmenuCustom +>( + appMenuItem: T, + customActions: Array +): T { + const customActionsIds = new Set(customActions.map((action) => action.id)); + return { + ...appMenuItem, + actions: [ + ...appMenuItem.actions.filter((item) => !customActionsIds.has(item.id)), // allow to override secondary actions with custom ones + ...customActions, + ], + }; +} + +/** + * All primary and secondary actions by default get order 100, 200, 300,... assigned to them. + * Same for actions under a submenu. + * @param appMenuItems + */ +function assignOrderToActions(appMenuItems: AppMenuItem[]): AppMenuItem[] { + let order = 0; + return appMenuItems.map((appMenuItem) => { + order = order + 100; + if (isAppMenuActionSubmenu(appMenuItem)) { + return getAppMenuSubmenuWithAssignedOrder(appMenuItem, order); + } + return { + ...appMenuItem, + order: appMenuItem.order ?? order, + }; + }); +} diff --git a/packages/kbn-discover-utils/src/components/app_menu/types.ts b/packages/kbn-discover-utils/src/components/app_menu/types.ts new file mode 100644 index 0000000000000..d5cd1bde16be7 --- /dev/null +++ b/packages/kbn-discover-utils/src/components/app_menu/types.ts @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 type { TopNavMenuData } from '@kbn/navigation-plugin/public'; +import type { EuiIconType } from '@elastic/eui/src/components/icon/icon'; + +export interface AppMenuControlOnClickParams { + anchorElement: HTMLElement; + onFinishAction: () => void; +} + +export type AppMenuControlProps = Pick< + TopNavMenuData, + 'testId' | 'isLoading' | 'label' | 'description' | 'disableButton' | 'href' | 'tooltip' +> & { + onClick: + | ((params: AppMenuControlOnClickParams) => Promise) + | ((params: AppMenuControlOnClickParams) => React.ReactNode | void) + | undefined; +}; + +export type AppMenuControlWithIconProps = AppMenuControlProps & { + iconType: EuiIconType; +}; + +interface ControlWithOptionalIcon { + iconType?: EuiIconType; +} + +export enum AppMenuActionId { + new = 'new', + open = 'open', + share = 'share', + alerts = 'alerts', + inspect = 'inspect', + createRule = 'createRule', + manageRulesAndConnectors = 'manageRulesAndConnectors', +} + +export enum AppMenuActionType { + primary = 'primary', + secondary = 'secondary', + custom = 'custom', + submenuHorizontalRule = 'submenuHorizontalRule', +} + +export interface AppMenuActionBase { + readonly id: AppMenuActionId | string; + readonly order?: number | undefined; +} + +/** + * A secondary menu action + */ +export interface AppMenuActionSecondary extends AppMenuActionBase { + readonly type: AppMenuActionType.secondary; + readonly controlProps: AppMenuControlProps; +} + +/** + * A secondary submenu action + */ +export interface AppMenuSubmenuActionSecondary + extends Omit { + readonly controlProps: AppMenuControlProps & ControlWithOptionalIcon; +} + +/** + * A custom menu action + */ +export interface AppMenuActionCustom extends AppMenuActionBase { + readonly type: AppMenuActionType.custom; + readonly controlProps: AppMenuControlProps; +} + +/** + * A custom submenu action + */ +export interface AppMenuSubmenuActionCustom extends Omit { + readonly controlProps: AppMenuControlProps & ControlWithOptionalIcon; +} + +/** + * A primary menu action (with icon only) + */ +export interface AppMenuActionPrimary extends AppMenuActionBase { + readonly type: AppMenuActionType.primary; + readonly controlProps: AppMenuControlWithIconProps; +} + +/** + * A horizontal rule between menu items + */ +export interface AppMenuSubmenuHorizontalRule extends AppMenuActionBase { + readonly type: AppMenuActionType.submenuHorizontalRule; + readonly testId?: TopNavMenuData['testId']; +} + +/** + * A menu action which opens a submenu with more actions + */ +export interface AppMenuActionSubmenuBase + extends AppMenuActionBase { + readonly type: T extends AppMenuActionSecondary + ? AppMenuActionType.secondary + : AppMenuActionType.custom; + readonly label: TopNavMenuData['label']; + readonly description?: TopNavMenuData['description']; + readonly testId?: TopNavMenuData['testId']; + readonly actions: T extends AppMenuActionSecondary + ? Array< + AppMenuSubmenuActionSecondary | AppMenuSubmenuActionCustom | AppMenuSubmenuHorizontalRule + > + : Array; +} + +/** + * A menu action which opens a submenu with more secondary actions + */ +export type AppMenuActionSubmenuSecondary = AppMenuActionSubmenuBase; +/** + * A menu action which opens a submenu with more custom actions + */ +export type AppMenuActionSubmenuCustom = AppMenuActionSubmenuBase; + +/** + * A primary menu item can only have an icon + */ +export type AppMenuItemPrimary = AppMenuActionPrimary; +/** + * A secondary menu item can have only a label or a submenu + */ +export type AppMenuItemSecondary = AppMenuActionSecondary | AppMenuActionSubmenuSecondary; +/** + * A custom menu item can have only a label or a submenu + */ +export type AppMenuItemCustom = AppMenuActionCustom | AppMenuActionSubmenuCustom; +/** + * A menu item can be primary, secondary or custom + */ +export type AppMenuItem = AppMenuItemPrimary | AppMenuItemSecondary | AppMenuItemCustom; diff --git a/packages/kbn-discover-utils/src/index.ts b/packages/kbn-discover-utils/src/index.ts index 8fe9a9418c9fe..243dd05774448 100644 --- a/packages/kbn-discover-utils/src/index.ts +++ b/packages/kbn-discover-utils/src/index.ts @@ -14,3 +14,4 @@ export * from './utils'; export * from './data_types'; export * from './components/custom_control_columns'; +export { AppMenuRegistry } from './components/app_menu/app_menu_registry'; diff --git a/packages/kbn-discover-utils/src/types.ts b/packages/kbn-discover-utils/src/types.ts index 63297edfe7643..2c298da999490 100644 --- a/packages/kbn-discover-utils/src/types.ts +++ b/packages/kbn-discover-utils/src/types.ts @@ -17,6 +17,8 @@ export type { RowControlProps, RowControlRowProps, } from './components/custom_control_columns/types'; +export type * from './components/app_menu/types'; +export { AppMenuActionId, AppMenuActionType } from './components/app_menu/types'; type DiscoverSearchHit = SearchHit>; diff --git a/packages/kbn-discover-utils/tsconfig.json b/packages/kbn-discover-utils/tsconfig.json index 865603e379eca..c26624d139dec 100644 --- a/packages/kbn-discover-utils/tsconfig.json +++ b/packages/kbn-discover-utils/tsconfig.json @@ -27,7 +27,8 @@ "@kbn/core-ui-settings-browser", "@kbn/expressions-plugin", "@kbn/logs-data-access-plugin", - "@kbn/ui-theme", - "@kbn/i18n-react" + "@kbn/i18n-react", + "@kbn/navigation-plugin", + "@kbn/ui-theme" ] } diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index 1aa917eb1eae8..09df44219e484 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -201,7 +201,7 @@ export const getDocLinks = ({ kibanaBranch, buildFlavor }: GetDocLinkOptions): D deployTrainedModels: `${MACHINE_LEARNING_DOCS}ml-nlp-deploy-models.html`, documentLevelSecurity: `${ELASTICSEARCH_DOCS}document-level-security.html`, e5Model: `${MACHINE_LEARNING_DOCS}ml-nlp-e5.html`, - elser: `${ELASTICSEARCH_DOCS}semantic-search-elser.html`, + elser: `${ELASTICSEARCH_DOCS}semantic-search-semantic-text.html`, engines: `${ENTERPRISE_SEARCH_DOCS}engines.html`, indexApi: `${ELASTICSEARCH_DOCS}docs-index_.html`, inferenceApiCreate: `${ELASTICSEARCH_DOCS}put-inference-api.html`, @@ -287,9 +287,6 @@ export const getDocLinks = ({ kibanaBranch, buildFlavor }: GetDocLinkOptions): D base: `${ELASTIC_WEBSITE_URL}guide/en/logstash/${DOC_LINK_VERSION}`, inputElasticAgent: `${ELASTIC_WEBSITE_URL}guide/en/logstash/${DOC_LINK_VERSION}/plugins-inputs-elastic_agent.html`, }, - functionbeat: { - base: `${ELASTIC_WEBSITE_URL}guide/en/beats/functionbeat/${DOC_LINK_VERSION}`, - }, winlogbeat: { base: `${ELASTIC_WEBSITE_URL}guide/en/beats/winlogbeat/${DOC_LINK_VERSION}`, }, diff --git a/packages/kbn-doc-links/src/types.ts b/packages/kbn-doc-links/src/types.ts index 67d4106d5be9d..7fc395ff8a90b 100644 --- a/packages/kbn-doc-links/src/types.ts +++ b/packages/kbn-doc-links/src/types.ts @@ -243,9 +243,6 @@ export interface DocLinks { readonly base: string; readonly inputElasticAgent: string; }; - readonly functionbeat: { - readonly base: string; - }; readonly winlogbeat: { readonly base: string; }; diff --git a/packages/kbn-es-types/src/search.ts b/packages/kbn-es-types/src/search.ts index 4c780fb2a2986..87f9dd15517c9 100644 --- a/packages/kbn-es-types/src/search.ts +++ b/packages/kbn-es-types/src/search.ts @@ -682,6 +682,7 @@ export interface ESQLSearchResponse { all_columns?: ESQLColumn[]; values: ESQLRow[]; took?: number; + _clusters?: estypes.ClusterStatistics; } export interface ESQLSearchParams { diff --git a/packages/kbn-esql-ast/src/antlr/esql_lexer.g4 b/packages/kbn-esql-ast/src/antlr/esql_lexer.g4 index 0ffee7c0b0d4f..ad17de2984ad7 100644 --- a/packages/kbn-esql-ast/src/antlr/esql_lexer.g4 +++ b/packages/kbn-esql-ast/src/antlr/esql_lexer.g4 @@ -107,6 +107,8 @@ WS : [ \r\n\t]+ -> channel(HIDDEN) ; +COLON : ':'; + // // Expression - used by most command // @@ -209,8 +211,8 @@ MINUS : '-'; ASTERISK : '*'; SLASH : '/'; PERCENT : '%'; +EXPRESSION_COLON : {this.isDevVersion()}? COLON -> type(COLON); -MATCH : 'match'; NESTED_WHERE : WHERE -> type(WHERE); NAMED_OR_POSITIONAL_PARAM @@ -479,7 +481,7 @@ SHOW_WS mode SETTING_MODE; SETTING_CLOSING_BRACKET : CLOSING_BRACKET -> type(CLOSING_BRACKET), popMode; -COLON : ':'; +SETTING_COLON : COLON -> type(COLON); SETTING : (ASPERAND | DIGIT| DOT | LETTER | UNDERSCORE)+ diff --git a/packages/kbn-esql-ast/src/antlr/esql_lexer.interp b/packages/kbn-esql-ast/src/antlr/esql_lexer.interp index 2566da379af73..8f9c5956dddd5 100644 --- a/packages/kbn-esql-ast/src/antlr/esql_lexer.interp +++ b/packages/kbn-esql-ast/src/antlr/esql_lexer.interp @@ -23,6 +23,7 @@ null null null null +':' '|' null null @@ -62,7 +63,6 @@ null '*' '/' '%' -'match' null null ']' @@ -103,7 +103,6 @@ null null null null -':' null null null @@ -146,6 +145,7 @@ UNKNOWN_CMD LINE_COMMENT MULTILINE_COMMENT WS +COLON PIPE QUOTED_STRING INTEGER_LITERAL @@ -185,7 +185,6 @@ MINUS ASTERISK SLASH PERCENT -MATCH NAMED_OR_POSITIONAL_PARAM OPENING_BRACKET CLOSING_BRACKET @@ -226,7 +225,6 @@ INFO SHOW_LINE_COMMENT SHOW_MULTILINE_COMMENT SHOW_WS -COLON SETTING SETTING_LINE_COMMENT SETTTING_MULTILINE_COMMENT @@ -268,6 +266,7 @@ UNKNOWN_CMD LINE_COMMENT MULTILINE_COMMENT WS +COLON PIPE DIGIT LETTER @@ -317,7 +316,7 @@ MINUS ASTERISK SLASH PERCENT -MATCH +EXPRESSION_COLON NESTED_WHERE NAMED_OR_POSITIONAL_PARAM OPENING_BRACKET @@ -406,7 +405,7 @@ SHOW_LINE_COMMENT SHOW_MULTILINE_COMMENT SHOW_WS SETTING_CLOSING_BRACKET -COLON +SETTING_COLON SETTING SETTING_LINE_COMMENT SETTTING_MULTILINE_COMMENT @@ -466,4 +465,4 @@ METRICS_MODE CLOSING_METRICS_MODE atn: -[4, 0, 120, 1479, 6, -1, 6, -1, 6, -1, 6, -1, 6, -1, 6, -1, 6, -1, 6, -1, 6, -1, 6, -1, 6, -1, 6, -1, 6, -1, 6, -1, 6, -1, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 2, 8, 7, 8, 2, 9, 7, 9, 2, 10, 7, 10, 2, 11, 7, 11, 2, 12, 7, 12, 2, 13, 7, 13, 2, 14, 7, 14, 2, 15, 7, 15, 2, 16, 7, 16, 2, 17, 7, 17, 2, 18, 7, 18, 2, 19, 7, 19, 2, 20, 7, 20, 2, 21, 7, 21, 2, 22, 7, 22, 2, 23, 7, 23, 2, 24, 7, 24, 2, 25, 7, 25, 2, 26, 7, 26, 2, 27, 7, 27, 2, 28, 7, 28, 2, 29, 7, 29, 2, 30, 7, 30, 2, 31, 7, 31, 2, 32, 7, 32, 2, 33, 7, 33, 2, 34, 7, 34, 2, 35, 7, 35, 2, 36, 7, 36, 2, 37, 7, 37, 2, 38, 7, 38, 2, 39, 7, 39, 2, 40, 7, 40, 2, 41, 7, 41, 2, 42, 7, 42, 2, 43, 7, 43, 2, 44, 7, 44, 2, 45, 7, 45, 2, 46, 7, 46, 2, 47, 7, 47, 2, 48, 7, 48, 2, 49, 7, 49, 2, 50, 7, 50, 2, 51, 7, 51, 2, 52, 7, 52, 2, 53, 7, 53, 2, 54, 7, 54, 2, 55, 7, 55, 2, 56, 7, 56, 2, 57, 7, 57, 2, 58, 7, 58, 2, 59, 7, 59, 2, 60, 7, 60, 2, 61, 7, 61, 2, 62, 7, 62, 2, 63, 7, 63, 2, 64, 7, 64, 2, 65, 7, 65, 2, 66, 7, 66, 2, 67, 7, 67, 2, 68, 7, 68, 2, 69, 7, 69, 2, 70, 7, 70, 2, 71, 7, 71, 2, 72, 7, 72, 2, 73, 7, 73, 2, 74, 7, 74, 2, 75, 7, 75, 2, 76, 7, 76, 2, 77, 7, 77, 2, 78, 7, 78, 2, 79, 7, 79, 2, 80, 7, 80, 2, 81, 7, 81, 2, 82, 7, 82, 2, 83, 7, 83, 2, 84, 7, 84, 2, 85, 7, 85, 2, 86, 7, 86, 2, 87, 7, 87, 2, 88, 7, 88, 2, 89, 7, 89, 2, 90, 7, 90, 2, 91, 7, 91, 2, 92, 7, 92, 2, 93, 7, 93, 2, 94, 7, 94, 2, 95, 7, 95, 2, 96, 7, 96, 2, 97, 7, 97, 2, 98, 7, 98, 2, 99, 7, 99, 2, 100, 7, 100, 2, 101, 7, 101, 2, 102, 7, 102, 2, 103, 7, 103, 2, 104, 7, 104, 2, 105, 7, 105, 2, 106, 7, 106, 2, 107, 7, 107, 2, 108, 7, 108, 2, 109, 7, 109, 2, 110, 7, 110, 2, 111, 7, 111, 2, 112, 7, 112, 2, 113, 7, 113, 2, 114, 7, 114, 2, 115, 7, 115, 2, 116, 7, 116, 2, 117, 7, 117, 2, 118, 7, 118, 2, 119, 7, 119, 2, 120, 7, 120, 2, 121, 7, 121, 2, 122, 7, 122, 2, 123, 7, 123, 2, 124, 7, 124, 2, 125, 7, 125, 2, 126, 7, 126, 2, 127, 7, 127, 2, 128, 7, 128, 2, 129, 7, 129, 2, 130, 7, 130, 2, 131, 7, 131, 2, 132, 7, 132, 2, 133, 7, 133, 2, 134, 7, 134, 2, 135, 7, 135, 2, 136, 7, 136, 2, 137, 7, 137, 2, 138, 7, 138, 2, 139, 7, 139, 2, 140, 7, 140, 2, 141, 7, 141, 2, 142, 7, 142, 2, 143, 7, 143, 2, 144, 7, 144, 2, 145, 7, 145, 2, 146, 7, 146, 2, 147, 7, 147, 2, 148, 7, 148, 2, 149, 7, 149, 2, 150, 7, 150, 2, 151, 7, 151, 2, 152, 7, 152, 2, 153, 7, 153, 2, 154, 7, 154, 2, 155, 7, 155, 2, 156, 7, 156, 2, 157, 7, 157, 2, 158, 7, 158, 2, 159, 7, 159, 2, 160, 7, 160, 2, 161, 7, 161, 2, 162, 7, 162, 2, 163, 7, 163, 2, 164, 7, 164, 2, 165, 7, 165, 2, 166, 7, 166, 2, 167, 7, 167, 2, 168, 7, 168, 2, 169, 7, 169, 2, 170, 7, 170, 2, 171, 7, 171, 2, 172, 7, 172, 2, 173, 7, 173, 2, 174, 7, 174, 2, 175, 7, 175, 2, 176, 7, 176, 2, 177, 7, 177, 2, 178, 7, 178, 2, 179, 7, 179, 2, 180, 7, 180, 2, 181, 7, 181, 2, 182, 7, 182, 2, 183, 7, 183, 2, 184, 7, 184, 2, 185, 7, 185, 2, 186, 7, 186, 2, 187, 7, 187, 2, 188, 7, 188, 2, 189, 7, 189, 2, 190, 7, 190, 2, 191, 7, 191, 2, 192, 7, 192, 2, 193, 7, 193, 2, 194, 7, 194, 2, 195, 7, 195, 2, 196, 7, 196, 2, 197, 7, 197, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 7, 1, 7, 1, 7, 1, 7, 1, 7, 1, 7, 1, 7, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 10, 1, 10, 1, 10, 1, 10, 1, 10, 1, 10, 1, 10, 1, 10, 1, 10, 1, 11, 1, 11, 1, 11, 1, 11, 1, 11, 1, 11, 1, 12, 1, 12, 1, 12, 1, 12, 1, 12, 1, 12, 1, 12, 1, 13, 1, 13, 1, 13, 1, 13, 1, 13, 1, 13, 1, 13, 1, 14, 1, 14, 1, 14, 1, 14, 1, 14, 1, 14, 1, 14, 1, 14, 1, 15, 1, 15, 1, 15, 1, 15, 1, 15, 1, 15, 1, 15, 1, 15, 1, 16, 1, 16, 1, 16, 1, 16, 1, 16, 1, 16, 1, 16, 1, 16, 1, 16, 1, 16, 1, 16, 1, 16, 1, 16, 1, 16, 1, 16, 1, 17, 1, 17, 1, 17, 1, 17, 1, 17, 1, 17, 1, 17, 1, 17, 1, 17, 1, 17, 1, 18, 1, 18, 1, 18, 1, 18, 1, 18, 1, 18, 1, 18, 1, 18, 1, 18, 1, 18, 1, 18, 1, 19, 4, 19, 578, 8, 19, 11, 19, 12, 19, 579, 1, 19, 1, 19, 1, 20, 1, 20, 1, 20, 1, 20, 5, 20, 588, 8, 20, 10, 20, 12, 20, 591, 9, 20, 1, 20, 3, 20, 594, 8, 20, 1, 20, 3, 20, 597, 8, 20, 1, 20, 1, 20, 1, 21, 1, 21, 1, 21, 1, 21, 1, 21, 5, 21, 606, 8, 21, 10, 21, 12, 21, 609, 9, 21, 1, 21, 1, 21, 1, 21, 1, 21, 1, 21, 1, 22, 4, 22, 617, 8, 22, 11, 22, 12, 22, 618, 1, 22, 1, 22, 1, 23, 1, 23, 1, 23, 1, 23, 1, 24, 1, 24, 1, 25, 1, 25, 1, 26, 1, 26, 1, 26, 1, 27, 1, 27, 1, 28, 1, 28, 3, 28, 638, 8, 28, 1, 28, 4, 28, 641, 8, 28, 11, 28, 12, 28, 642, 1, 29, 1, 29, 1, 30, 1, 30, 1, 31, 1, 31, 1, 31, 3, 31, 652, 8, 31, 1, 32, 1, 32, 1, 33, 1, 33, 1, 33, 3, 33, 659, 8, 33, 1, 34, 1, 34, 1, 34, 5, 34, 664, 8, 34, 10, 34, 12, 34, 667, 9, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 5, 34, 675, 8, 34, 10, 34, 12, 34, 678, 9, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 3, 34, 685, 8, 34, 1, 34, 3, 34, 688, 8, 34, 3, 34, 690, 8, 34, 1, 35, 4, 35, 693, 8, 35, 11, 35, 12, 35, 694, 1, 36, 4, 36, 698, 8, 36, 11, 36, 12, 36, 699, 1, 36, 1, 36, 5, 36, 704, 8, 36, 10, 36, 12, 36, 707, 9, 36, 1, 36, 1, 36, 4, 36, 711, 8, 36, 11, 36, 12, 36, 712, 1, 36, 4, 36, 716, 8, 36, 11, 36, 12, 36, 717, 1, 36, 1, 36, 5, 36, 722, 8, 36, 10, 36, 12, 36, 725, 9, 36, 3, 36, 727, 8, 36, 1, 36, 1, 36, 1, 36, 1, 36, 4, 36, 733, 8, 36, 11, 36, 12, 36, 734, 1, 36, 1, 36, 3, 36, 739, 8, 36, 1, 37, 1, 37, 1, 37, 1, 38, 1, 38, 1, 38, 1, 38, 1, 39, 1, 39, 1, 39, 1, 39, 1, 40, 1, 40, 1, 41, 1, 41, 1, 41, 1, 42, 1, 42, 1, 43, 1, 43, 1, 43, 1, 43, 1, 43, 1, 44, 1, 44, 1, 45, 1, 45, 1, 45, 1, 45, 1, 45, 1, 45, 1, 46, 1, 46, 1, 46, 1, 46, 1, 46, 1, 46, 1, 47, 1, 47, 1, 47, 1, 48, 1, 48, 1, 48, 1, 49, 1, 49, 1, 49, 1, 49, 1, 49, 1, 50, 1, 50, 1, 50, 1, 50, 1, 50, 1, 51, 1, 51, 1, 52, 1, 52, 1, 52, 1, 52, 1, 53, 1, 53, 1, 53, 1, 53, 1, 53, 1, 54, 1, 54, 1, 54, 1, 54, 1, 54, 1, 54, 1, 55, 1, 55, 1, 55, 1, 56, 1, 56, 1, 57, 1, 57, 1, 57, 1, 57, 1, 57, 1, 57, 1, 58, 1, 58, 1, 59, 1, 59, 1, 59, 1, 59, 1, 59, 1, 60, 1, 60, 1, 60, 1, 61, 1, 61, 1, 61, 1, 62, 1, 62, 1, 62, 1, 63, 1, 63, 1, 64, 1, 64, 1, 64, 1, 65, 1, 65, 1, 66, 1, 66, 1, 66, 1, 67, 1, 67, 1, 68, 1, 68, 1, 69, 1, 69, 1, 70, 1, 70, 1, 71, 1, 71, 1, 72, 1, 72, 1, 72, 1, 72, 1, 72, 1, 72, 1, 73, 1, 73, 1, 73, 1, 73, 1, 74, 1, 74, 1, 74, 3, 74, 871, 8, 74, 1, 74, 5, 74, 874, 8, 74, 10, 74, 12, 74, 877, 9, 74, 1, 74, 1, 74, 4, 74, 881, 8, 74, 11, 74, 12, 74, 882, 3, 74, 885, 8, 74, 1, 75, 1, 75, 1, 75, 1, 75, 1, 75, 1, 76, 1, 76, 1, 76, 1, 76, 1, 76, 1, 77, 1, 77, 5, 77, 899, 8, 77, 10, 77, 12, 77, 902, 9, 77, 1, 77, 1, 77, 3, 77, 906, 8, 77, 1, 77, 4, 77, 909, 8, 77, 11, 77, 12, 77, 910, 3, 77, 913, 8, 77, 1, 78, 1, 78, 4, 78, 917, 8, 78, 11, 78, 12, 78, 918, 1, 78, 1, 78, 1, 79, 1, 79, 1, 80, 1, 80, 1, 80, 1, 80, 1, 81, 1, 81, 1, 81, 1, 81, 1, 82, 1, 82, 1, 82, 1, 82, 1, 83, 1, 83, 1, 83, 1, 83, 1, 83, 1, 84, 1, 84, 1, 84, 1, 84, 1, 84, 1, 85, 1, 85, 1, 85, 1, 85, 1, 86, 1, 86, 1, 86, 1, 86, 1, 87, 1, 87, 1, 87, 1, 87, 1, 88, 1, 88, 1, 88, 1, 88, 1, 88, 1, 89, 1, 89, 1, 89, 1, 89, 1, 90, 1, 90, 1, 90, 1, 90, 1, 91, 1, 91, 1, 91, 1, 91, 1, 92, 1, 92, 1, 92, 1, 92, 1, 93, 1, 93, 1, 93, 1, 93, 1, 94, 1, 94, 1, 94, 1, 94, 1, 94, 1, 94, 1, 94, 1, 94, 1, 94, 1, 95, 1, 95, 1, 95, 3, 95, 996, 8, 95, 1, 96, 4, 96, 999, 8, 96, 11, 96, 12, 96, 1000, 1, 97, 1, 97, 1, 97, 1, 97, 1, 98, 1, 98, 1, 98, 1, 98, 1, 99, 1, 99, 1, 99, 1, 99, 1, 100, 1, 100, 1, 100, 1, 100, 1, 101, 1, 101, 1, 101, 1, 101, 1, 102, 1, 102, 1, 102, 1, 102, 1, 102, 1, 103, 1, 103, 1, 103, 1, 103, 1, 104, 1, 104, 1, 104, 1, 104, 1, 105, 1, 105, 1, 105, 1, 105, 1, 105, 1, 106, 1, 106, 1, 106, 1, 106, 1, 106, 1, 107, 1, 107, 1, 107, 1, 107, 3, 107, 1050, 8, 107, 1, 108, 1, 108, 3, 108, 1054, 8, 108, 1, 108, 5, 108, 1057, 8, 108, 10, 108, 12, 108, 1060, 9, 108, 1, 108, 1, 108, 3, 108, 1064, 8, 108, 1, 108, 4, 108, 1067, 8, 108, 11, 108, 12, 108, 1068, 3, 108, 1071, 8, 108, 1, 109, 1, 109, 4, 109, 1075, 8, 109, 11, 109, 12, 109, 1076, 1, 110, 1, 110, 1, 110, 1, 110, 1, 111, 1, 111, 1, 111, 1, 111, 1, 112, 1, 112, 1, 112, 1, 112, 1, 113, 1, 113, 1, 113, 1, 113, 1, 113, 1, 114, 1, 114, 1, 114, 1, 114, 1, 115, 1, 115, 1, 115, 1, 115, 1, 116, 1, 116, 1, 116, 1, 116, 1, 117, 1, 117, 1, 117, 1, 117, 1, 117, 1, 118, 1, 118, 1, 118, 1, 118, 1, 118, 1, 119, 1, 119, 1, 119, 1, 120, 1, 120, 1, 120, 1, 120, 1, 121, 1, 121, 1, 121, 1, 121, 1, 122, 1, 122, 1, 122, 1, 122, 1, 123, 1, 123, 1, 123, 1, 123, 1, 124, 1, 124, 1, 124, 1, 124, 1, 124, 1, 125, 1, 125, 1, 125, 1, 125, 1, 125, 1, 126, 1, 126, 1, 126, 1, 126, 1, 126, 1, 127, 1, 127, 1, 127, 1, 127, 1, 127, 1, 127, 1, 127, 1, 128, 1, 128, 1, 129, 4, 129, 1162, 8, 129, 11, 129, 12, 129, 1163, 1, 129, 1, 129, 3, 129, 1168, 8, 129, 1, 129, 4, 129, 1171, 8, 129, 11, 129, 12, 129, 1172, 1, 130, 1, 130, 1, 130, 1, 130, 1, 131, 1, 131, 1, 131, 1, 131, 1, 132, 1, 132, 1, 132, 1, 132, 1, 133, 1, 133, 1, 133, 1, 133, 1, 134, 1, 134, 1, 134, 1, 134, 1, 134, 1, 134, 1, 135, 1, 135, 1, 135, 1, 135, 1, 136, 1, 136, 1, 136, 1, 136, 1, 137, 1, 137, 1, 137, 1, 137, 1, 138, 1, 138, 1, 138, 1, 138, 1, 139, 1, 139, 1, 139, 1, 139, 1, 140, 1, 140, 1, 140, 1, 140, 1, 141, 1, 141, 1, 141, 1, 141, 1, 141, 1, 142, 1, 142, 1, 142, 1, 142, 1, 142, 1, 143, 1, 143, 1, 143, 1, 143, 1, 144, 1, 144, 1, 144, 1, 144, 1, 145, 1, 145, 1, 145, 1, 145, 1, 146, 1, 146, 1, 146, 1, 146, 1, 146, 1, 147, 1, 147, 1, 147, 1, 147, 1, 148, 1, 148, 1, 148, 1, 148, 1, 148, 1, 149, 1, 149, 1, 149, 1, 149, 1, 149, 1, 150, 1, 150, 1, 150, 1, 150, 1, 151, 1, 151, 1, 151, 1, 151, 1, 152, 1, 152, 1, 152, 1, 152, 1, 153, 1, 153, 1, 153, 1, 153, 1, 154, 1, 154, 1, 154, 1, 154, 1, 155, 1, 155, 1, 155, 1, 155, 1, 155, 1, 156, 1, 156, 1, 156, 1, 156, 1, 156, 1, 157, 1, 157, 1, 157, 1, 157, 1, 158, 1, 158, 1, 158, 1, 158, 1, 159, 1, 159, 1, 159, 1, 159, 1, 160, 1, 160, 1, 160, 1, 160, 1, 160, 1, 161, 1, 161, 1, 162, 1, 162, 1, 162, 1, 162, 1, 162, 4, 162, 1316, 8, 162, 11, 162, 12, 162, 1317, 1, 163, 1, 163, 1, 163, 1, 163, 1, 164, 1, 164, 1, 164, 1, 164, 1, 165, 1, 165, 1, 165, 1, 165, 1, 166, 1, 166, 1, 166, 1, 166, 1, 166, 1, 167, 1, 167, 1, 167, 1, 167, 1, 168, 1, 168, 1, 168, 1, 168, 1, 169, 1, 169, 1, 169, 1, 169, 1, 170, 1, 170, 1, 170, 1, 170, 1, 170, 1, 171, 1, 171, 1, 171, 1, 171, 1, 172, 1, 172, 1, 172, 1, 172, 1, 173, 1, 173, 1, 173, 1, 173, 1, 174, 1, 174, 1, 174, 1, 174, 1, 175, 1, 175, 1, 175, 1, 175, 1, 176, 1, 176, 1, 176, 1, 176, 1, 176, 1, 176, 1, 177, 1, 177, 1, 177, 1, 177, 1, 178, 1, 178, 1, 178, 1, 178, 1, 179, 1, 179, 1, 179, 1, 179, 1, 180, 1, 180, 1, 180, 1, 180, 1, 181, 1, 181, 1, 181, 1, 181, 1, 182, 1, 182, 1, 182, 1, 182, 1, 183, 1, 183, 1, 183, 1, 183, 1, 183, 1, 184, 1, 184, 1, 184, 1, 184, 1, 184, 1, 184, 1, 185, 1, 185, 1, 185, 1, 185, 1, 185, 1, 185, 1, 186, 1, 186, 1, 186, 1, 186, 1, 187, 1, 187, 1, 187, 1, 187, 1, 188, 1, 188, 1, 188, 1, 188, 1, 189, 1, 189, 1, 189, 1, 189, 1, 189, 1, 189, 1, 190, 1, 190, 1, 190, 1, 190, 1, 190, 1, 190, 1, 191, 1, 191, 1, 191, 1, 191, 1, 192, 1, 192, 1, 192, 1, 192, 1, 193, 1, 193, 1, 193, 1, 193, 1, 194, 1, 194, 1, 194, 1, 194, 1, 194, 1, 194, 1, 195, 1, 195, 1, 195, 1, 195, 1, 195, 1, 195, 1, 196, 1, 196, 1, 196, 1, 196, 1, 196, 1, 196, 1, 197, 1, 197, 1, 197, 1, 197, 1, 197, 2, 607, 676, 0, 198, 15, 1, 17, 2, 19, 3, 21, 4, 23, 5, 25, 6, 27, 7, 29, 8, 31, 9, 33, 10, 35, 11, 37, 12, 39, 13, 41, 14, 43, 15, 45, 16, 47, 17, 49, 18, 51, 19, 53, 20, 55, 21, 57, 22, 59, 23, 61, 24, 63, 0, 65, 0, 67, 0, 69, 0, 71, 0, 73, 0, 75, 0, 77, 0, 79, 0, 81, 0, 83, 25, 85, 26, 87, 27, 89, 28, 91, 29, 93, 30, 95, 31, 97, 32, 99, 33, 101, 34, 103, 35, 105, 36, 107, 37, 109, 38, 111, 39, 113, 40, 115, 41, 117, 42, 119, 43, 121, 44, 123, 45, 125, 46, 127, 47, 129, 48, 131, 49, 133, 50, 135, 51, 137, 52, 139, 53, 141, 54, 143, 55, 145, 56, 147, 57, 149, 58, 151, 59, 153, 60, 155, 61, 157, 62, 159, 63, 161, 0, 163, 64, 165, 65, 167, 66, 169, 67, 171, 0, 173, 68, 175, 69, 177, 70, 179, 71, 181, 0, 183, 0, 185, 72, 187, 73, 189, 74, 191, 0, 193, 0, 195, 0, 197, 0, 199, 0, 201, 0, 203, 75, 205, 0, 207, 76, 209, 0, 211, 0, 213, 77, 215, 78, 217, 79, 219, 0, 221, 0, 223, 0, 225, 0, 227, 0, 229, 0, 231, 0, 233, 80, 235, 81, 237, 82, 239, 83, 241, 0, 243, 0, 245, 0, 247, 0, 249, 0, 251, 0, 253, 84, 255, 0, 257, 85, 259, 86, 261, 87, 263, 0, 265, 0, 267, 88, 269, 89, 271, 0, 273, 90, 275, 0, 277, 91, 279, 92, 281, 93, 283, 0, 285, 0, 287, 0, 289, 0, 291, 0, 293, 0, 295, 0, 297, 0, 299, 0, 301, 94, 303, 95, 305, 96, 307, 0, 309, 0, 311, 0, 313, 0, 315, 0, 317, 0, 319, 97, 321, 98, 323, 99, 325, 0, 327, 100, 329, 101, 331, 102, 333, 103, 335, 0, 337, 104, 339, 105, 341, 106, 343, 107, 345, 108, 347, 0, 349, 0, 351, 0, 353, 0, 355, 0, 357, 0, 359, 0, 361, 109, 363, 110, 365, 111, 367, 0, 369, 0, 371, 0, 373, 0, 375, 112, 377, 113, 379, 114, 381, 0, 383, 0, 385, 0, 387, 115, 389, 116, 391, 117, 393, 0, 395, 0, 397, 118, 399, 119, 401, 120, 403, 0, 405, 0, 407, 0, 409, 0, 15, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 35, 2, 0, 68, 68, 100, 100, 2, 0, 73, 73, 105, 105, 2, 0, 83, 83, 115, 115, 2, 0, 69, 69, 101, 101, 2, 0, 67, 67, 99, 99, 2, 0, 84, 84, 116, 116, 2, 0, 82, 82, 114, 114, 2, 0, 79, 79, 111, 111, 2, 0, 80, 80, 112, 112, 2, 0, 78, 78, 110, 110, 2, 0, 72, 72, 104, 104, 2, 0, 86, 86, 118, 118, 2, 0, 65, 65, 97, 97, 2, 0, 76, 76, 108, 108, 2, 0, 88, 88, 120, 120, 2, 0, 70, 70, 102, 102, 2, 0, 77, 77, 109, 109, 2, 0, 71, 71, 103, 103, 2, 0, 75, 75, 107, 107, 2, 0, 87, 87, 119, 119, 2, 0, 85, 85, 117, 117, 6, 0, 9, 10, 13, 13, 32, 32, 47, 47, 91, 91, 93, 93, 2, 0, 10, 10, 13, 13, 3, 0, 9, 10, 13, 13, 32, 32, 1, 0, 48, 57, 2, 0, 65, 90, 97, 122, 8, 0, 34, 34, 78, 78, 82, 82, 84, 84, 92, 92, 110, 110, 114, 114, 116, 116, 4, 0, 10, 10, 13, 13, 34, 34, 92, 92, 2, 0, 43, 43, 45, 45, 1, 0, 96, 96, 2, 0, 66, 66, 98, 98, 2, 0, 89, 89, 121, 121, 11, 0, 9, 10, 13, 13, 32, 32, 34, 34, 44, 44, 47, 47, 58, 58, 61, 61, 91, 91, 93, 93, 124, 124, 2, 0, 42, 42, 47, 47, 11, 0, 9, 10, 13, 13, 32, 32, 34, 35, 44, 44, 47, 47, 58, 58, 60, 60, 62, 63, 92, 92, 124, 124, 1507, 0, 15, 1, 0, 0, 0, 0, 17, 1, 0, 0, 0, 0, 19, 1, 0, 0, 0, 0, 21, 1, 0, 0, 0, 0, 23, 1, 0, 0, 0, 0, 25, 1, 0, 0, 0, 0, 27, 1, 0, 0, 0, 0, 29, 1, 0, 0, 0, 0, 31, 1, 0, 0, 0, 0, 33, 1, 0, 0, 0, 0, 35, 1, 0, 0, 0, 0, 37, 1, 0, 0, 0, 0, 39, 1, 0, 0, 0, 0, 41, 1, 0, 0, 0, 0, 43, 1, 0, 0, 0, 0, 45, 1, 0, 0, 0, 0, 47, 1, 0, 0, 0, 0, 49, 1, 0, 0, 0, 0, 51, 1, 0, 0, 0, 0, 53, 1, 0, 0, 0, 0, 55, 1, 0, 0, 0, 0, 57, 1, 0, 0, 0, 0, 59, 1, 0, 0, 0, 1, 61, 1, 0, 0, 0, 1, 83, 1, 0, 0, 0, 1, 85, 1, 0, 0, 0, 1, 87, 1, 0, 0, 0, 1, 89, 1, 0, 0, 0, 1, 91, 1, 0, 0, 0, 1, 93, 1, 0, 0, 0, 1, 95, 1, 0, 0, 0, 1, 97, 1, 0, 0, 0, 1, 99, 1, 0, 0, 0, 1, 101, 1, 0, 0, 0, 1, 103, 1, 0, 0, 0, 1, 105, 1, 0, 0, 0, 1, 107, 1, 0, 0, 0, 1, 109, 1, 0, 0, 0, 1, 111, 1, 0, 0, 0, 1, 113, 1, 0, 0, 0, 1, 115, 1, 0, 0, 0, 1, 117, 1, 0, 0, 0, 1, 119, 1, 0, 0, 0, 1, 121, 1, 0, 0, 0, 1, 123, 1, 0, 0, 0, 1, 125, 1, 0, 0, 0, 1, 127, 1, 0, 0, 0, 1, 129, 1, 0, 0, 0, 1, 131, 1, 0, 0, 0, 1, 133, 1, 0, 0, 0, 1, 135, 1, 0, 0, 0, 1, 137, 1, 0, 0, 0, 1, 139, 1, 0, 0, 0, 1, 141, 1, 0, 0, 0, 1, 143, 1, 0, 0, 0, 1, 145, 1, 0, 0, 0, 1, 147, 1, 0, 0, 0, 1, 149, 1, 0, 0, 0, 1, 151, 1, 0, 0, 0, 1, 153, 1, 0, 0, 0, 1, 155, 1, 0, 0, 0, 1, 157, 1, 0, 0, 0, 1, 159, 1, 0, 0, 0, 1, 161, 1, 0, 0, 0, 1, 163, 1, 0, 0, 0, 1, 165, 1, 0, 0, 0, 1, 167, 1, 0, 0, 0, 1, 169, 1, 0, 0, 0, 1, 173, 1, 0, 0, 0, 1, 175, 1, 0, 0, 0, 1, 177, 1, 0, 0, 0, 1, 179, 1, 0, 0, 0, 2, 181, 1, 0, 0, 0, 2, 183, 1, 0, 0, 0, 2, 185, 1, 0, 0, 0, 2, 187, 1, 0, 0, 0, 2, 189, 1, 0, 0, 0, 3, 191, 1, 0, 0, 0, 3, 193, 1, 0, 0, 0, 3, 195, 1, 0, 0, 0, 3, 197, 1, 0, 0, 0, 3, 199, 1, 0, 0, 0, 3, 201, 1, 0, 0, 0, 3, 203, 1, 0, 0, 0, 3, 207, 1, 0, 0, 0, 3, 209, 1, 0, 0, 0, 3, 211, 1, 0, 0, 0, 3, 213, 1, 0, 0, 0, 3, 215, 1, 0, 0, 0, 3, 217, 1, 0, 0, 0, 4, 219, 1, 0, 0, 0, 4, 221, 1, 0, 0, 0, 4, 223, 1, 0, 0, 0, 4, 225, 1, 0, 0, 0, 4, 227, 1, 0, 0, 0, 4, 233, 1, 0, 0, 0, 4, 235, 1, 0, 0, 0, 4, 237, 1, 0, 0, 0, 4, 239, 1, 0, 0, 0, 5, 241, 1, 0, 0, 0, 5, 243, 1, 0, 0, 0, 5, 245, 1, 0, 0, 0, 5, 247, 1, 0, 0, 0, 5, 249, 1, 0, 0, 0, 5, 251, 1, 0, 0, 0, 5, 253, 1, 0, 0, 0, 5, 255, 1, 0, 0, 0, 5, 257, 1, 0, 0, 0, 5, 259, 1, 0, 0, 0, 5, 261, 1, 0, 0, 0, 6, 263, 1, 0, 0, 0, 6, 265, 1, 0, 0, 0, 6, 267, 1, 0, 0, 0, 6, 269, 1, 0, 0, 0, 6, 273, 1, 0, 0, 0, 6, 275, 1, 0, 0, 0, 6, 277, 1, 0, 0, 0, 6, 279, 1, 0, 0, 0, 6, 281, 1, 0, 0, 0, 7, 283, 1, 0, 0, 0, 7, 285, 1, 0, 0, 0, 7, 287, 1, 0, 0, 0, 7, 289, 1, 0, 0, 0, 7, 291, 1, 0, 0, 0, 7, 293, 1, 0, 0, 0, 7, 295, 1, 0, 0, 0, 7, 297, 1, 0, 0, 0, 7, 299, 1, 0, 0, 0, 7, 301, 1, 0, 0, 0, 7, 303, 1, 0, 0, 0, 7, 305, 1, 0, 0, 0, 8, 307, 1, 0, 0, 0, 8, 309, 1, 0, 0, 0, 8, 311, 1, 0, 0, 0, 8, 313, 1, 0, 0, 0, 8, 315, 1, 0, 0, 0, 8, 317, 1, 0, 0, 0, 8, 319, 1, 0, 0, 0, 8, 321, 1, 0, 0, 0, 8, 323, 1, 0, 0, 0, 9, 325, 1, 0, 0, 0, 9, 327, 1, 0, 0, 0, 9, 329, 1, 0, 0, 0, 9, 331, 1, 0, 0, 0, 9, 333, 1, 0, 0, 0, 10, 335, 1, 0, 0, 0, 10, 337, 1, 0, 0, 0, 10, 339, 1, 0, 0, 0, 10, 341, 1, 0, 0, 0, 10, 343, 1, 0, 0, 0, 10, 345, 1, 0, 0, 0, 11, 347, 1, 0, 0, 0, 11, 349, 1, 0, 0, 0, 11, 351, 1, 0, 0, 0, 11, 353, 1, 0, 0, 0, 11, 355, 1, 0, 0, 0, 11, 357, 1, 0, 0, 0, 11, 359, 1, 0, 0, 0, 11, 361, 1, 0, 0, 0, 11, 363, 1, 0, 0, 0, 11, 365, 1, 0, 0, 0, 12, 367, 1, 0, 0, 0, 12, 369, 1, 0, 0, 0, 12, 371, 1, 0, 0, 0, 12, 373, 1, 0, 0, 0, 12, 375, 1, 0, 0, 0, 12, 377, 1, 0, 0, 0, 12, 379, 1, 0, 0, 0, 13, 381, 1, 0, 0, 0, 13, 383, 1, 0, 0, 0, 13, 385, 1, 0, 0, 0, 13, 387, 1, 0, 0, 0, 13, 389, 1, 0, 0, 0, 13, 391, 1, 0, 0, 0, 14, 393, 1, 0, 0, 0, 14, 395, 1, 0, 0, 0, 14, 397, 1, 0, 0, 0, 14, 399, 1, 0, 0, 0, 14, 401, 1, 0, 0, 0, 14, 403, 1, 0, 0, 0, 14, 405, 1, 0, 0, 0, 14, 407, 1, 0, 0, 0, 14, 409, 1, 0, 0, 0, 15, 411, 1, 0, 0, 0, 17, 421, 1, 0, 0, 0, 19, 428, 1, 0, 0, 0, 21, 437, 1, 0, 0, 0, 23, 444, 1, 0, 0, 0, 25, 454, 1, 0, 0, 0, 27, 461, 1, 0, 0, 0, 29, 468, 1, 0, 0, 0, 31, 475, 1, 0, 0, 0, 33, 483, 1, 0, 0, 0, 35, 495, 1, 0, 0, 0, 37, 504, 1, 0, 0, 0, 39, 510, 1, 0, 0, 0, 41, 517, 1, 0, 0, 0, 43, 524, 1, 0, 0, 0, 45, 532, 1, 0, 0, 0, 47, 540, 1, 0, 0, 0, 49, 555, 1, 0, 0, 0, 51, 565, 1, 0, 0, 0, 53, 577, 1, 0, 0, 0, 55, 583, 1, 0, 0, 0, 57, 600, 1, 0, 0, 0, 59, 616, 1, 0, 0, 0, 61, 622, 1, 0, 0, 0, 63, 626, 1, 0, 0, 0, 65, 628, 1, 0, 0, 0, 67, 630, 1, 0, 0, 0, 69, 633, 1, 0, 0, 0, 71, 635, 1, 0, 0, 0, 73, 644, 1, 0, 0, 0, 75, 646, 1, 0, 0, 0, 77, 651, 1, 0, 0, 0, 79, 653, 1, 0, 0, 0, 81, 658, 1, 0, 0, 0, 83, 689, 1, 0, 0, 0, 85, 692, 1, 0, 0, 0, 87, 738, 1, 0, 0, 0, 89, 740, 1, 0, 0, 0, 91, 743, 1, 0, 0, 0, 93, 747, 1, 0, 0, 0, 95, 751, 1, 0, 0, 0, 97, 753, 1, 0, 0, 0, 99, 756, 1, 0, 0, 0, 101, 758, 1, 0, 0, 0, 103, 763, 1, 0, 0, 0, 105, 765, 1, 0, 0, 0, 107, 771, 1, 0, 0, 0, 109, 777, 1, 0, 0, 0, 111, 780, 1, 0, 0, 0, 113, 783, 1, 0, 0, 0, 115, 788, 1, 0, 0, 0, 117, 793, 1, 0, 0, 0, 119, 795, 1, 0, 0, 0, 121, 799, 1, 0, 0, 0, 123, 804, 1, 0, 0, 0, 125, 810, 1, 0, 0, 0, 127, 813, 1, 0, 0, 0, 129, 815, 1, 0, 0, 0, 131, 821, 1, 0, 0, 0, 133, 823, 1, 0, 0, 0, 135, 828, 1, 0, 0, 0, 137, 831, 1, 0, 0, 0, 139, 834, 1, 0, 0, 0, 141, 837, 1, 0, 0, 0, 143, 839, 1, 0, 0, 0, 145, 842, 1, 0, 0, 0, 147, 844, 1, 0, 0, 0, 149, 847, 1, 0, 0, 0, 151, 849, 1, 0, 0, 0, 153, 851, 1, 0, 0, 0, 155, 853, 1, 0, 0, 0, 157, 855, 1, 0, 0, 0, 159, 857, 1, 0, 0, 0, 161, 863, 1, 0, 0, 0, 163, 884, 1, 0, 0, 0, 165, 886, 1, 0, 0, 0, 167, 891, 1, 0, 0, 0, 169, 912, 1, 0, 0, 0, 171, 914, 1, 0, 0, 0, 173, 922, 1, 0, 0, 0, 175, 924, 1, 0, 0, 0, 177, 928, 1, 0, 0, 0, 179, 932, 1, 0, 0, 0, 181, 936, 1, 0, 0, 0, 183, 941, 1, 0, 0, 0, 185, 946, 1, 0, 0, 0, 187, 950, 1, 0, 0, 0, 189, 954, 1, 0, 0, 0, 191, 958, 1, 0, 0, 0, 193, 963, 1, 0, 0, 0, 195, 967, 1, 0, 0, 0, 197, 971, 1, 0, 0, 0, 199, 975, 1, 0, 0, 0, 201, 979, 1, 0, 0, 0, 203, 983, 1, 0, 0, 0, 205, 995, 1, 0, 0, 0, 207, 998, 1, 0, 0, 0, 209, 1002, 1, 0, 0, 0, 211, 1006, 1, 0, 0, 0, 213, 1010, 1, 0, 0, 0, 215, 1014, 1, 0, 0, 0, 217, 1018, 1, 0, 0, 0, 219, 1022, 1, 0, 0, 0, 221, 1027, 1, 0, 0, 0, 223, 1031, 1, 0, 0, 0, 225, 1035, 1, 0, 0, 0, 227, 1040, 1, 0, 0, 0, 229, 1049, 1, 0, 0, 0, 231, 1070, 1, 0, 0, 0, 233, 1074, 1, 0, 0, 0, 235, 1078, 1, 0, 0, 0, 237, 1082, 1, 0, 0, 0, 239, 1086, 1, 0, 0, 0, 241, 1090, 1, 0, 0, 0, 243, 1095, 1, 0, 0, 0, 245, 1099, 1, 0, 0, 0, 247, 1103, 1, 0, 0, 0, 249, 1107, 1, 0, 0, 0, 251, 1112, 1, 0, 0, 0, 253, 1117, 1, 0, 0, 0, 255, 1120, 1, 0, 0, 0, 257, 1124, 1, 0, 0, 0, 259, 1128, 1, 0, 0, 0, 261, 1132, 1, 0, 0, 0, 263, 1136, 1, 0, 0, 0, 265, 1141, 1, 0, 0, 0, 267, 1146, 1, 0, 0, 0, 269, 1151, 1, 0, 0, 0, 271, 1158, 1, 0, 0, 0, 273, 1167, 1, 0, 0, 0, 275, 1174, 1, 0, 0, 0, 277, 1178, 1, 0, 0, 0, 279, 1182, 1, 0, 0, 0, 281, 1186, 1, 0, 0, 0, 283, 1190, 1, 0, 0, 0, 285, 1196, 1, 0, 0, 0, 287, 1200, 1, 0, 0, 0, 289, 1204, 1, 0, 0, 0, 291, 1208, 1, 0, 0, 0, 293, 1212, 1, 0, 0, 0, 295, 1216, 1, 0, 0, 0, 297, 1220, 1, 0, 0, 0, 299, 1225, 1, 0, 0, 0, 301, 1230, 1, 0, 0, 0, 303, 1234, 1, 0, 0, 0, 305, 1238, 1, 0, 0, 0, 307, 1242, 1, 0, 0, 0, 309, 1247, 1, 0, 0, 0, 311, 1251, 1, 0, 0, 0, 313, 1256, 1, 0, 0, 0, 315, 1261, 1, 0, 0, 0, 317, 1265, 1, 0, 0, 0, 319, 1269, 1, 0, 0, 0, 321, 1273, 1, 0, 0, 0, 323, 1277, 1, 0, 0, 0, 325, 1281, 1, 0, 0, 0, 327, 1286, 1, 0, 0, 0, 329, 1291, 1, 0, 0, 0, 331, 1295, 1, 0, 0, 0, 333, 1299, 1, 0, 0, 0, 335, 1303, 1, 0, 0, 0, 337, 1308, 1, 0, 0, 0, 339, 1315, 1, 0, 0, 0, 341, 1319, 1, 0, 0, 0, 343, 1323, 1, 0, 0, 0, 345, 1327, 1, 0, 0, 0, 347, 1331, 1, 0, 0, 0, 349, 1336, 1, 0, 0, 0, 351, 1340, 1, 0, 0, 0, 353, 1344, 1, 0, 0, 0, 355, 1348, 1, 0, 0, 0, 357, 1353, 1, 0, 0, 0, 359, 1357, 1, 0, 0, 0, 361, 1361, 1, 0, 0, 0, 363, 1365, 1, 0, 0, 0, 365, 1369, 1, 0, 0, 0, 367, 1373, 1, 0, 0, 0, 369, 1379, 1, 0, 0, 0, 371, 1383, 1, 0, 0, 0, 373, 1387, 1, 0, 0, 0, 375, 1391, 1, 0, 0, 0, 377, 1395, 1, 0, 0, 0, 379, 1399, 1, 0, 0, 0, 381, 1403, 1, 0, 0, 0, 383, 1408, 1, 0, 0, 0, 385, 1414, 1, 0, 0, 0, 387, 1420, 1, 0, 0, 0, 389, 1424, 1, 0, 0, 0, 391, 1428, 1, 0, 0, 0, 393, 1432, 1, 0, 0, 0, 395, 1438, 1, 0, 0, 0, 397, 1444, 1, 0, 0, 0, 399, 1448, 1, 0, 0, 0, 401, 1452, 1, 0, 0, 0, 403, 1456, 1, 0, 0, 0, 405, 1462, 1, 0, 0, 0, 407, 1468, 1, 0, 0, 0, 409, 1474, 1, 0, 0, 0, 411, 412, 7, 0, 0, 0, 412, 413, 7, 1, 0, 0, 413, 414, 7, 2, 0, 0, 414, 415, 7, 2, 0, 0, 415, 416, 7, 3, 0, 0, 416, 417, 7, 4, 0, 0, 417, 418, 7, 5, 0, 0, 418, 419, 1, 0, 0, 0, 419, 420, 6, 0, 0, 0, 420, 16, 1, 0, 0, 0, 421, 422, 7, 0, 0, 0, 422, 423, 7, 6, 0, 0, 423, 424, 7, 7, 0, 0, 424, 425, 7, 8, 0, 0, 425, 426, 1, 0, 0, 0, 426, 427, 6, 1, 1, 0, 427, 18, 1, 0, 0, 0, 428, 429, 7, 3, 0, 0, 429, 430, 7, 9, 0, 0, 430, 431, 7, 6, 0, 0, 431, 432, 7, 1, 0, 0, 432, 433, 7, 4, 0, 0, 433, 434, 7, 10, 0, 0, 434, 435, 1, 0, 0, 0, 435, 436, 6, 2, 2, 0, 436, 20, 1, 0, 0, 0, 437, 438, 7, 3, 0, 0, 438, 439, 7, 11, 0, 0, 439, 440, 7, 12, 0, 0, 440, 441, 7, 13, 0, 0, 441, 442, 1, 0, 0, 0, 442, 443, 6, 3, 0, 0, 443, 22, 1, 0, 0, 0, 444, 445, 7, 3, 0, 0, 445, 446, 7, 14, 0, 0, 446, 447, 7, 8, 0, 0, 447, 448, 7, 13, 0, 0, 448, 449, 7, 12, 0, 0, 449, 450, 7, 1, 0, 0, 450, 451, 7, 9, 0, 0, 451, 452, 1, 0, 0, 0, 452, 453, 6, 4, 3, 0, 453, 24, 1, 0, 0, 0, 454, 455, 7, 15, 0, 0, 455, 456, 7, 6, 0, 0, 456, 457, 7, 7, 0, 0, 457, 458, 7, 16, 0, 0, 458, 459, 1, 0, 0, 0, 459, 460, 6, 5, 4, 0, 460, 26, 1, 0, 0, 0, 461, 462, 7, 17, 0, 0, 462, 463, 7, 6, 0, 0, 463, 464, 7, 7, 0, 0, 464, 465, 7, 18, 0, 0, 465, 466, 1, 0, 0, 0, 466, 467, 6, 6, 0, 0, 467, 28, 1, 0, 0, 0, 468, 469, 7, 18, 0, 0, 469, 470, 7, 3, 0, 0, 470, 471, 7, 3, 0, 0, 471, 472, 7, 8, 0, 0, 472, 473, 1, 0, 0, 0, 473, 474, 6, 7, 1, 0, 474, 30, 1, 0, 0, 0, 475, 476, 7, 13, 0, 0, 476, 477, 7, 1, 0, 0, 477, 478, 7, 16, 0, 0, 478, 479, 7, 1, 0, 0, 479, 480, 7, 5, 0, 0, 480, 481, 1, 0, 0, 0, 481, 482, 6, 8, 0, 0, 482, 32, 1, 0, 0, 0, 483, 484, 7, 16, 0, 0, 484, 485, 7, 11, 0, 0, 485, 486, 5, 95, 0, 0, 486, 487, 7, 3, 0, 0, 487, 488, 7, 14, 0, 0, 488, 489, 7, 8, 0, 0, 489, 490, 7, 12, 0, 0, 490, 491, 7, 9, 0, 0, 491, 492, 7, 0, 0, 0, 492, 493, 1, 0, 0, 0, 493, 494, 6, 9, 5, 0, 494, 34, 1, 0, 0, 0, 495, 496, 7, 6, 0, 0, 496, 497, 7, 3, 0, 0, 497, 498, 7, 9, 0, 0, 498, 499, 7, 12, 0, 0, 499, 500, 7, 16, 0, 0, 500, 501, 7, 3, 0, 0, 501, 502, 1, 0, 0, 0, 502, 503, 6, 10, 6, 0, 503, 36, 1, 0, 0, 0, 504, 505, 7, 6, 0, 0, 505, 506, 7, 7, 0, 0, 506, 507, 7, 19, 0, 0, 507, 508, 1, 0, 0, 0, 508, 509, 6, 11, 0, 0, 509, 38, 1, 0, 0, 0, 510, 511, 7, 2, 0, 0, 511, 512, 7, 10, 0, 0, 512, 513, 7, 7, 0, 0, 513, 514, 7, 19, 0, 0, 514, 515, 1, 0, 0, 0, 515, 516, 6, 12, 7, 0, 516, 40, 1, 0, 0, 0, 517, 518, 7, 2, 0, 0, 518, 519, 7, 7, 0, 0, 519, 520, 7, 6, 0, 0, 520, 521, 7, 5, 0, 0, 521, 522, 1, 0, 0, 0, 522, 523, 6, 13, 0, 0, 523, 42, 1, 0, 0, 0, 524, 525, 7, 2, 0, 0, 525, 526, 7, 5, 0, 0, 526, 527, 7, 12, 0, 0, 527, 528, 7, 5, 0, 0, 528, 529, 7, 2, 0, 0, 529, 530, 1, 0, 0, 0, 530, 531, 6, 14, 0, 0, 531, 44, 1, 0, 0, 0, 532, 533, 7, 19, 0, 0, 533, 534, 7, 10, 0, 0, 534, 535, 7, 3, 0, 0, 535, 536, 7, 6, 0, 0, 536, 537, 7, 3, 0, 0, 537, 538, 1, 0, 0, 0, 538, 539, 6, 15, 0, 0, 539, 46, 1, 0, 0, 0, 540, 541, 4, 16, 0, 0, 541, 542, 7, 1, 0, 0, 542, 543, 7, 9, 0, 0, 543, 544, 7, 13, 0, 0, 544, 545, 7, 1, 0, 0, 545, 546, 7, 9, 0, 0, 546, 547, 7, 3, 0, 0, 547, 548, 7, 2, 0, 0, 548, 549, 7, 5, 0, 0, 549, 550, 7, 12, 0, 0, 550, 551, 7, 5, 0, 0, 551, 552, 7, 2, 0, 0, 552, 553, 1, 0, 0, 0, 553, 554, 6, 16, 0, 0, 554, 48, 1, 0, 0, 0, 555, 556, 4, 17, 1, 0, 556, 557, 7, 13, 0, 0, 557, 558, 7, 7, 0, 0, 558, 559, 7, 7, 0, 0, 559, 560, 7, 18, 0, 0, 560, 561, 7, 20, 0, 0, 561, 562, 7, 8, 0, 0, 562, 563, 1, 0, 0, 0, 563, 564, 6, 17, 8, 0, 564, 50, 1, 0, 0, 0, 565, 566, 4, 18, 2, 0, 566, 567, 7, 16, 0, 0, 567, 568, 7, 3, 0, 0, 568, 569, 7, 5, 0, 0, 569, 570, 7, 6, 0, 0, 570, 571, 7, 1, 0, 0, 571, 572, 7, 4, 0, 0, 572, 573, 7, 2, 0, 0, 573, 574, 1, 0, 0, 0, 574, 575, 6, 18, 9, 0, 575, 52, 1, 0, 0, 0, 576, 578, 8, 21, 0, 0, 577, 576, 1, 0, 0, 0, 578, 579, 1, 0, 0, 0, 579, 577, 1, 0, 0, 0, 579, 580, 1, 0, 0, 0, 580, 581, 1, 0, 0, 0, 581, 582, 6, 19, 0, 0, 582, 54, 1, 0, 0, 0, 583, 584, 5, 47, 0, 0, 584, 585, 5, 47, 0, 0, 585, 589, 1, 0, 0, 0, 586, 588, 8, 22, 0, 0, 587, 586, 1, 0, 0, 0, 588, 591, 1, 0, 0, 0, 589, 587, 1, 0, 0, 0, 589, 590, 1, 0, 0, 0, 590, 593, 1, 0, 0, 0, 591, 589, 1, 0, 0, 0, 592, 594, 5, 13, 0, 0, 593, 592, 1, 0, 0, 0, 593, 594, 1, 0, 0, 0, 594, 596, 1, 0, 0, 0, 595, 597, 5, 10, 0, 0, 596, 595, 1, 0, 0, 0, 596, 597, 1, 0, 0, 0, 597, 598, 1, 0, 0, 0, 598, 599, 6, 20, 10, 0, 599, 56, 1, 0, 0, 0, 600, 601, 5, 47, 0, 0, 601, 602, 5, 42, 0, 0, 602, 607, 1, 0, 0, 0, 603, 606, 3, 57, 21, 0, 604, 606, 9, 0, 0, 0, 605, 603, 1, 0, 0, 0, 605, 604, 1, 0, 0, 0, 606, 609, 1, 0, 0, 0, 607, 608, 1, 0, 0, 0, 607, 605, 1, 0, 0, 0, 608, 610, 1, 0, 0, 0, 609, 607, 1, 0, 0, 0, 610, 611, 5, 42, 0, 0, 611, 612, 5, 47, 0, 0, 612, 613, 1, 0, 0, 0, 613, 614, 6, 21, 10, 0, 614, 58, 1, 0, 0, 0, 615, 617, 7, 23, 0, 0, 616, 615, 1, 0, 0, 0, 617, 618, 1, 0, 0, 0, 618, 616, 1, 0, 0, 0, 618, 619, 1, 0, 0, 0, 619, 620, 1, 0, 0, 0, 620, 621, 6, 22, 10, 0, 621, 60, 1, 0, 0, 0, 622, 623, 5, 124, 0, 0, 623, 624, 1, 0, 0, 0, 624, 625, 6, 23, 11, 0, 625, 62, 1, 0, 0, 0, 626, 627, 7, 24, 0, 0, 627, 64, 1, 0, 0, 0, 628, 629, 7, 25, 0, 0, 629, 66, 1, 0, 0, 0, 630, 631, 5, 92, 0, 0, 631, 632, 7, 26, 0, 0, 632, 68, 1, 0, 0, 0, 633, 634, 8, 27, 0, 0, 634, 70, 1, 0, 0, 0, 635, 637, 7, 3, 0, 0, 636, 638, 7, 28, 0, 0, 637, 636, 1, 0, 0, 0, 637, 638, 1, 0, 0, 0, 638, 640, 1, 0, 0, 0, 639, 641, 3, 63, 24, 0, 640, 639, 1, 0, 0, 0, 641, 642, 1, 0, 0, 0, 642, 640, 1, 0, 0, 0, 642, 643, 1, 0, 0, 0, 643, 72, 1, 0, 0, 0, 644, 645, 5, 64, 0, 0, 645, 74, 1, 0, 0, 0, 646, 647, 5, 96, 0, 0, 647, 76, 1, 0, 0, 0, 648, 652, 8, 29, 0, 0, 649, 650, 5, 96, 0, 0, 650, 652, 5, 96, 0, 0, 651, 648, 1, 0, 0, 0, 651, 649, 1, 0, 0, 0, 652, 78, 1, 0, 0, 0, 653, 654, 5, 95, 0, 0, 654, 80, 1, 0, 0, 0, 655, 659, 3, 65, 25, 0, 656, 659, 3, 63, 24, 0, 657, 659, 3, 79, 32, 0, 658, 655, 1, 0, 0, 0, 658, 656, 1, 0, 0, 0, 658, 657, 1, 0, 0, 0, 659, 82, 1, 0, 0, 0, 660, 665, 5, 34, 0, 0, 661, 664, 3, 67, 26, 0, 662, 664, 3, 69, 27, 0, 663, 661, 1, 0, 0, 0, 663, 662, 1, 0, 0, 0, 664, 667, 1, 0, 0, 0, 665, 663, 1, 0, 0, 0, 665, 666, 1, 0, 0, 0, 666, 668, 1, 0, 0, 0, 667, 665, 1, 0, 0, 0, 668, 690, 5, 34, 0, 0, 669, 670, 5, 34, 0, 0, 670, 671, 5, 34, 0, 0, 671, 672, 5, 34, 0, 0, 672, 676, 1, 0, 0, 0, 673, 675, 8, 22, 0, 0, 674, 673, 1, 0, 0, 0, 675, 678, 1, 0, 0, 0, 676, 677, 1, 0, 0, 0, 676, 674, 1, 0, 0, 0, 677, 679, 1, 0, 0, 0, 678, 676, 1, 0, 0, 0, 679, 680, 5, 34, 0, 0, 680, 681, 5, 34, 0, 0, 681, 682, 5, 34, 0, 0, 682, 684, 1, 0, 0, 0, 683, 685, 5, 34, 0, 0, 684, 683, 1, 0, 0, 0, 684, 685, 1, 0, 0, 0, 685, 687, 1, 0, 0, 0, 686, 688, 5, 34, 0, 0, 687, 686, 1, 0, 0, 0, 687, 688, 1, 0, 0, 0, 688, 690, 1, 0, 0, 0, 689, 660, 1, 0, 0, 0, 689, 669, 1, 0, 0, 0, 690, 84, 1, 0, 0, 0, 691, 693, 3, 63, 24, 0, 692, 691, 1, 0, 0, 0, 693, 694, 1, 0, 0, 0, 694, 692, 1, 0, 0, 0, 694, 695, 1, 0, 0, 0, 695, 86, 1, 0, 0, 0, 696, 698, 3, 63, 24, 0, 697, 696, 1, 0, 0, 0, 698, 699, 1, 0, 0, 0, 699, 697, 1, 0, 0, 0, 699, 700, 1, 0, 0, 0, 700, 701, 1, 0, 0, 0, 701, 705, 3, 103, 44, 0, 702, 704, 3, 63, 24, 0, 703, 702, 1, 0, 0, 0, 704, 707, 1, 0, 0, 0, 705, 703, 1, 0, 0, 0, 705, 706, 1, 0, 0, 0, 706, 739, 1, 0, 0, 0, 707, 705, 1, 0, 0, 0, 708, 710, 3, 103, 44, 0, 709, 711, 3, 63, 24, 0, 710, 709, 1, 0, 0, 0, 711, 712, 1, 0, 0, 0, 712, 710, 1, 0, 0, 0, 712, 713, 1, 0, 0, 0, 713, 739, 1, 0, 0, 0, 714, 716, 3, 63, 24, 0, 715, 714, 1, 0, 0, 0, 716, 717, 1, 0, 0, 0, 717, 715, 1, 0, 0, 0, 717, 718, 1, 0, 0, 0, 718, 726, 1, 0, 0, 0, 719, 723, 3, 103, 44, 0, 720, 722, 3, 63, 24, 0, 721, 720, 1, 0, 0, 0, 722, 725, 1, 0, 0, 0, 723, 721, 1, 0, 0, 0, 723, 724, 1, 0, 0, 0, 724, 727, 1, 0, 0, 0, 725, 723, 1, 0, 0, 0, 726, 719, 1, 0, 0, 0, 726, 727, 1, 0, 0, 0, 727, 728, 1, 0, 0, 0, 728, 729, 3, 71, 28, 0, 729, 739, 1, 0, 0, 0, 730, 732, 3, 103, 44, 0, 731, 733, 3, 63, 24, 0, 732, 731, 1, 0, 0, 0, 733, 734, 1, 0, 0, 0, 734, 732, 1, 0, 0, 0, 734, 735, 1, 0, 0, 0, 735, 736, 1, 0, 0, 0, 736, 737, 3, 71, 28, 0, 737, 739, 1, 0, 0, 0, 738, 697, 1, 0, 0, 0, 738, 708, 1, 0, 0, 0, 738, 715, 1, 0, 0, 0, 738, 730, 1, 0, 0, 0, 739, 88, 1, 0, 0, 0, 740, 741, 7, 30, 0, 0, 741, 742, 7, 31, 0, 0, 742, 90, 1, 0, 0, 0, 743, 744, 7, 12, 0, 0, 744, 745, 7, 9, 0, 0, 745, 746, 7, 0, 0, 0, 746, 92, 1, 0, 0, 0, 747, 748, 7, 12, 0, 0, 748, 749, 7, 2, 0, 0, 749, 750, 7, 4, 0, 0, 750, 94, 1, 0, 0, 0, 751, 752, 5, 61, 0, 0, 752, 96, 1, 0, 0, 0, 753, 754, 5, 58, 0, 0, 754, 755, 5, 58, 0, 0, 755, 98, 1, 0, 0, 0, 756, 757, 5, 44, 0, 0, 757, 100, 1, 0, 0, 0, 758, 759, 7, 0, 0, 0, 759, 760, 7, 3, 0, 0, 760, 761, 7, 2, 0, 0, 761, 762, 7, 4, 0, 0, 762, 102, 1, 0, 0, 0, 763, 764, 5, 46, 0, 0, 764, 104, 1, 0, 0, 0, 765, 766, 7, 15, 0, 0, 766, 767, 7, 12, 0, 0, 767, 768, 7, 13, 0, 0, 768, 769, 7, 2, 0, 0, 769, 770, 7, 3, 0, 0, 770, 106, 1, 0, 0, 0, 771, 772, 7, 15, 0, 0, 772, 773, 7, 1, 0, 0, 773, 774, 7, 6, 0, 0, 774, 775, 7, 2, 0, 0, 775, 776, 7, 5, 0, 0, 776, 108, 1, 0, 0, 0, 777, 778, 7, 1, 0, 0, 778, 779, 7, 9, 0, 0, 779, 110, 1, 0, 0, 0, 780, 781, 7, 1, 0, 0, 781, 782, 7, 2, 0, 0, 782, 112, 1, 0, 0, 0, 783, 784, 7, 13, 0, 0, 784, 785, 7, 12, 0, 0, 785, 786, 7, 2, 0, 0, 786, 787, 7, 5, 0, 0, 787, 114, 1, 0, 0, 0, 788, 789, 7, 13, 0, 0, 789, 790, 7, 1, 0, 0, 790, 791, 7, 18, 0, 0, 791, 792, 7, 3, 0, 0, 792, 116, 1, 0, 0, 0, 793, 794, 5, 40, 0, 0, 794, 118, 1, 0, 0, 0, 795, 796, 7, 9, 0, 0, 796, 797, 7, 7, 0, 0, 797, 798, 7, 5, 0, 0, 798, 120, 1, 0, 0, 0, 799, 800, 7, 9, 0, 0, 800, 801, 7, 20, 0, 0, 801, 802, 7, 13, 0, 0, 802, 803, 7, 13, 0, 0, 803, 122, 1, 0, 0, 0, 804, 805, 7, 9, 0, 0, 805, 806, 7, 20, 0, 0, 806, 807, 7, 13, 0, 0, 807, 808, 7, 13, 0, 0, 808, 809, 7, 2, 0, 0, 809, 124, 1, 0, 0, 0, 810, 811, 7, 7, 0, 0, 811, 812, 7, 6, 0, 0, 812, 126, 1, 0, 0, 0, 813, 814, 5, 63, 0, 0, 814, 128, 1, 0, 0, 0, 815, 816, 7, 6, 0, 0, 816, 817, 7, 13, 0, 0, 817, 818, 7, 1, 0, 0, 818, 819, 7, 18, 0, 0, 819, 820, 7, 3, 0, 0, 820, 130, 1, 0, 0, 0, 821, 822, 5, 41, 0, 0, 822, 132, 1, 0, 0, 0, 823, 824, 7, 5, 0, 0, 824, 825, 7, 6, 0, 0, 825, 826, 7, 20, 0, 0, 826, 827, 7, 3, 0, 0, 827, 134, 1, 0, 0, 0, 828, 829, 5, 61, 0, 0, 829, 830, 5, 61, 0, 0, 830, 136, 1, 0, 0, 0, 831, 832, 5, 61, 0, 0, 832, 833, 5, 126, 0, 0, 833, 138, 1, 0, 0, 0, 834, 835, 5, 33, 0, 0, 835, 836, 5, 61, 0, 0, 836, 140, 1, 0, 0, 0, 837, 838, 5, 60, 0, 0, 838, 142, 1, 0, 0, 0, 839, 840, 5, 60, 0, 0, 840, 841, 5, 61, 0, 0, 841, 144, 1, 0, 0, 0, 842, 843, 5, 62, 0, 0, 843, 146, 1, 0, 0, 0, 844, 845, 5, 62, 0, 0, 845, 846, 5, 61, 0, 0, 846, 148, 1, 0, 0, 0, 847, 848, 5, 43, 0, 0, 848, 150, 1, 0, 0, 0, 849, 850, 5, 45, 0, 0, 850, 152, 1, 0, 0, 0, 851, 852, 5, 42, 0, 0, 852, 154, 1, 0, 0, 0, 853, 854, 5, 47, 0, 0, 854, 156, 1, 0, 0, 0, 855, 856, 5, 37, 0, 0, 856, 158, 1, 0, 0, 0, 857, 858, 7, 16, 0, 0, 858, 859, 7, 12, 0, 0, 859, 860, 7, 5, 0, 0, 860, 861, 7, 4, 0, 0, 861, 862, 7, 10, 0, 0, 862, 160, 1, 0, 0, 0, 863, 864, 3, 45, 15, 0, 864, 865, 1, 0, 0, 0, 865, 866, 6, 73, 12, 0, 866, 162, 1, 0, 0, 0, 867, 870, 3, 127, 56, 0, 868, 871, 3, 65, 25, 0, 869, 871, 3, 79, 32, 0, 870, 868, 1, 0, 0, 0, 870, 869, 1, 0, 0, 0, 871, 875, 1, 0, 0, 0, 872, 874, 3, 81, 33, 0, 873, 872, 1, 0, 0, 0, 874, 877, 1, 0, 0, 0, 875, 873, 1, 0, 0, 0, 875, 876, 1, 0, 0, 0, 876, 885, 1, 0, 0, 0, 877, 875, 1, 0, 0, 0, 878, 880, 3, 127, 56, 0, 879, 881, 3, 63, 24, 0, 880, 879, 1, 0, 0, 0, 881, 882, 1, 0, 0, 0, 882, 880, 1, 0, 0, 0, 882, 883, 1, 0, 0, 0, 883, 885, 1, 0, 0, 0, 884, 867, 1, 0, 0, 0, 884, 878, 1, 0, 0, 0, 885, 164, 1, 0, 0, 0, 886, 887, 5, 91, 0, 0, 887, 888, 1, 0, 0, 0, 888, 889, 6, 75, 0, 0, 889, 890, 6, 75, 0, 0, 890, 166, 1, 0, 0, 0, 891, 892, 5, 93, 0, 0, 892, 893, 1, 0, 0, 0, 893, 894, 6, 76, 11, 0, 894, 895, 6, 76, 11, 0, 895, 168, 1, 0, 0, 0, 896, 900, 3, 65, 25, 0, 897, 899, 3, 81, 33, 0, 898, 897, 1, 0, 0, 0, 899, 902, 1, 0, 0, 0, 900, 898, 1, 0, 0, 0, 900, 901, 1, 0, 0, 0, 901, 913, 1, 0, 0, 0, 902, 900, 1, 0, 0, 0, 903, 906, 3, 79, 32, 0, 904, 906, 3, 73, 29, 0, 905, 903, 1, 0, 0, 0, 905, 904, 1, 0, 0, 0, 906, 908, 1, 0, 0, 0, 907, 909, 3, 81, 33, 0, 908, 907, 1, 0, 0, 0, 909, 910, 1, 0, 0, 0, 910, 908, 1, 0, 0, 0, 910, 911, 1, 0, 0, 0, 911, 913, 1, 0, 0, 0, 912, 896, 1, 0, 0, 0, 912, 905, 1, 0, 0, 0, 913, 170, 1, 0, 0, 0, 914, 916, 3, 75, 30, 0, 915, 917, 3, 77, 31, 0, 916, 915, 1, 0, 0, 0, 917, 918, 1, 0, 0, 0, 918, 916, 1, 0, 0, 0, 918, 919, 1, 0, 0, 0, 919, 920, 1, 0, 0, 0, 920, 921, 3, 75, 30, 0, 921, 172, 1, 0, 0, 0, 922, 923, 3, 171, 78, 0, 923, 174, 1, 0, 0, 0, 924, 925, 3, 55, 20, 0, 925, 926, 1, 0, 0, 0, 926, 927, 6, 80, 10, 0, 927, 176, 1, 0, 0, 0, 928, 929, 3, 57, 21, 0, 929, 930, 1, 0, 0, 0, 930, 931, 6, 81, 10, 0, 931, 178, 1, 0, 0, 0, 932, 933, 3, 59, 22, 0, 933, 934, 1, 0, 0, 0, 934, 935, 6, 82, 10, 0, 935, 180, 1, 0, 0, 0, 936, 937, 3, 165, 75, 0, 937, 938, 1, 0, 0, 0, 938, 939, 6, 83, 13, 0, 939, 940, 6, 83, 14, 0, 940, 182, 1, 0, 0, 0, 941, 942, 3, 61, 23, 0, 942, 943, 1, 0, 0, 0, 943, 944, 6, 84, 15, 0, 944, 945, 6, 84, 11, 0, 945, 184, 1, 0, 0, 0, 946, 947, 3, 59, 22, 0, 947, 948, 1, 0, 0, 0, 948, 949, 6, 85, 10, 0, 949, 186, 1, 0, 0, 0, 950, 951, 3, 55, 20, 0, 951, 952, 1, 0, 0, 0, 952, 953, 6, 86, 10, 0, 953, 188, 1, 0, 0, 0, 954, 955, 3, 57, 21, 0, 955, 956, 1, 0, 0, 0, 956, 957, 6, 87, 10, 0, 957, 190, 1, 0, 0, 0, 958, 959, 3, 61, 23, 0, 959, 960, 1, 0, 0, 0, 960, 961, 6, 88, 15, 0, 961, 962, 6, 88, 11, 0, 962, 192, 1, 0, 0, 0, 963, 964, 3, 165, 75, 0, 964, 965, 1, 0, 0, 0, 965, 966, 6, 89, 13, 0, 966, 194, 1, 0, 0, 0, 967, 968, 3, 167, 76, 0, 968, 969, 1, 0, 0, 0, 969, 970, 6, 90, 16, 0, 970, 196, 1, 0, 0, 0, 971, 972, 3, 337, 161, 0, 972, 973, 1, 0, 0, 0, 973, 974, 6, 91, 17, 0, 974, 198, 1, 0, 0, 0, 975, 976, 3, 99, 42, 0, 976, 977, 1, 0, 0, 0, 977, 978, 6, 92, 18, 0, 978, 200, 1, 0, 0, 0, 979, 980, 3, 95, 40, 0, 980, 981, 1, 0, 0, 0, 981, 982, 6, 93, 19, 0, 982, 202, 1, 0, 0, 0, 983, 984, 7, 16, 0, 0, 984, 985, 7, 3, 0, 0, 985, 986, 7, 5, 0, 0, 986, 987, 7, 12, 0, 0, 987, 988, 7, 0, 0, 0, 988, 989, 7, 12, 0, 0, 989, 990, 7, 5, 0, 0, 990, 991, 7, 12, 0, 0, 991, 204, 1, 0, 0, 0, 992, 996, 8, 32, 0, 0, 993, 994, 5, 47, 0, 0, 994, 996, 8, 33, 0, 0, 995, 992, 1, 0, 0, 0, 995, 993, 1, 0, 0, 0, 996, 206, 1, 0, 0, 0, 997, 999, 3, 205, 95, 0, 998, 997, 1, 0, 0, 0, 999, 1000, 1, 0, 0, 0, 1000, 998, 1, 0, 0, 0, 1000, 1001, 1, 0, 0, 0, 1001, 208, 1, 0, 0, 0, 1002, 1003, 3, 207, 96, 0, 1003, 1004, 1, 0, 0, 0, 1004, 1005, 6, 97, 20, 0, 1005, 210, 1, 0, 0, 0, 1006, 1007, 3, 83, 34, 0, 1007, 1008, 1, 0, 0, 0, 1008, 1009, 6, 98, 21, 0, 1009, 212, 1, 0, 0, 0, 1010, 1011, 3, 55, 20, 0, 1011, 1012, 1, 0, 0, 0, 1012, 1013, 6, 99, 10, 0, 1013, 214, 1, 0, 0, 0, 1014, 1015, 3, 57, 21, 0, 1015, 1016, 1, 0, 0, 0, 1016, 1017, 6, 100, 10, 0, 1017, 216, 1, 0, 0, 0, 1018, 1019, 3, 59, 22, 0, 1019, 1020, 1, 0, 0, 0, 1020, 1021, 6, 101, 10, 0, 1021, 218, 1, 0, 0, 0, 1022, 1023, 3, 61, 23, 0, 1023, 1024, 1, 0, 0, 0, 1024, 1025, 6, 102, 15, 0, 1025, 1026, 6, 102, 11, 0, 1026, 220, 1, 0, 0, 0, 1027, 1028, 3, 103, 44, 0, 1028, 1029, 1, 0, 0, 0, 1029, 1030, 6, 103, 22, 0, 1030, 222, 1, 0, 0, 0, 1031, 1032, 3, 99, 42, 0, 1032, 1033, 1, 0, 0, 0, 1033, 1034, 6, 104, 18, 0, 1034, 224, 1, 0, 0, 0, 1035, 1036, 4, 105, 3, 0, 1036, 1037, 3, 127, 56, 0, 1037, 1038, 1, 0, 0, 0, 1038, 1039, 6, 105, 23, 0, 1039, 226, 1, 0, 0, 0, 1040, 1041, 4, 106, 4, 0, 1041, 1042, 3, 163, 74, 0, 1042, 1043, 1, 0, 0, 0, 1043, 1044, 6, 106, 24, 0, 1044, 228, 1, 0, 0, 0, 1045, 1050, 3, 65, 25, 0, 1046, 1050, 3, 63, 24, 0, 1047, 1050, 3, 79, 32, 0, 1048, 1050, 3, 153, 69, 0, 1049, 1045, 1, 0, 0, 0, 1049, 1046, 1, 0, 0, 0, 1049, 1047, 1, 0, 0, 0, 1049, 1048, 1, 0, 0, 0, 1050, 230, 1, 0, 0, 0, 1051, 1054, 3, 65, 25, 0, 1052, 1054, 3, 153, 69, 0, 1053, 1051, 1, 0, 0, 0, 1053, 1052, 1, 0, 0, 0, 1054, 1058, 1, 0, 0, 0, 1055, 1057, 3, 229, 107, 0, 1056, 1055, 1, 0, 0, 0, 1057, 1060, 1, 0, 0, 0, 1058, 1056, 1, 0, 0, 0, 1058, 1059, 1, 0, 0, 0, 1059, 1071, 1, 0, 0, 0, 1060, 1058, 1, 0, 0, 0, 1061, 1064, 3, 79, 32, 0, 1062, 1064, 3, 73, 29, 0, 1063, 1061, 1, 0, 0, 0, 1063, 1062, 1, 0, 0, 0, 1064, 1066, 1, 0, 0, 0, 1065, 1067, 3, 229, 107, 0, 1066, 1065, 1, 0, 0, 0, 1067, 1068, 1, 0, 0, 0, 1068, 1066, 1, 0, 0, 0, 1068, 1069, 1, 0, 0, 0, 1069, 1071, 1, 0, 0, 0, 1070, 1053, 1, 0, 0, 0, 1070, 1063, 1, 0, 0, 0, 1071, 232, 1, 0, 0, 0, 1072, 1075, 3, 231, 108, 0, 1073, 1075, 3, 171, 78, 0, 1074, 1072, 1, 0, 0, 0, 1074, 1073, 1, 0, 0, 0, 1075, 1076, 1, 0, 0, 0, 1076, 1074, 1, 0, 0, 0, 1076, 1077, 1, 0, 0, 0, 1077, 234, 1, 0, 0, 0, 1078, 1079, 3, 55, 20, 0, 1079, 1080, 1, 0, 0, 0, 1080, 1081, 6, 110, 10, 0, 1081, 236, 1, 0, 0, 0, 1082, 1083, 3, 57, 21, 0, 1083, 1084, 1, 0, 0, 0, 1084, 1085, 6, 111, 10, 0, 1085, 238, 1, 0, 0, 0, 1086, 1087, 3, 59, 22, 0, 1087, 1088, 1, 0, 0, 0, 1088, 1089, 6, 112, 10, 0, 1089, 240, 1, 0, 0, 0, 1090, 1091, 3, 61, 23, 0, 1091, 1092, 1, 0, 0, 0, 1092, 1093, 6, 113, 15, 0, 1093, 1094, 6, 113, 11, 0, 1094, 242, 1, 0, 0, 0, 1095, 1096, 3, 95, 40, 0, 1096, 1097, 1, 0, 0, 0, 1097, 1098, 6, 114, 19, 0, 1098, 244, 1, 0, 0, 0, 1099, 1100, 3, 99, 42, 0, 1100, 1101, 1, 0, 0, 0, 1101, 1102, 6, 115, 18, 0, 1102, 246, 1, 0, 0, 0, 1103, 1104, 3, 103, 44, 0, 1104, 1105, 1, 0, 0, 0, 1105, 1106, 6, 116, 22, 0, 1106, 248, 1, 0, 0, 0, 1107, 1108, 4, 117, 5, 0, 1108, 1109, 3, 127, 56, 0, 1109, 1110, 1, 0, 0, 0, 1110, 1111, 6, 117, 23, 0, 1111, 250, 1, 0, 0, 0, 1112, 1113, 4, 118, 6, 0, 1113, 1114, 3, 163, 74, 0, 1114, 1115, 1, 0, 0, 0, 1115, 1116, 6, 118, 24, 0, 1116, 252, 1, 0, 0, 0, 1117, 1118, 7, 12, 0, 0, 1118, 1119, 7, 2, 0, 0, 1119, 254, 1, 0, 0, 0, 1120, 1121, 3, 233, 109, 0, 1121, 1122, 1, 0, 0, 0, 1122, 1123, 6, 120, 25, 0, 1123, 256, 1, 0, 0, 0, 1124, 1125, 3, 55, 20, 0, 1125, 1126, 1, 0, 0, 0, 1126, 1127, 6, 121, 10, 0, 1127, 258, 1, 0, 0, 0, 1128, 1129, 3, 57, 21, 0, 1129, 1130, 1, 0, 0, 0, 1130, 1131, 6, 122, 10, 0, 1131, 260, 1, 0, 0, 0, 1132, 1133, 3, 59, 22, 0, 1133, 1134, 1, 0, 0, 0, 1134, 1135, 6, 123, 10, 0, 1135, 262, 1, 0, 0, 0, 1136, 1137, 3, 61, 23, 0, 1137, 1138, 1, 0, 0, 0, 1138, 1139, 6, 124, 15, 0, 1139, 1140, 6, 124, 11, 0, 1140, 264, 1, 0, 0, 0, 1141, 1142, 3, 165, 75, 0, 1142, 1143, 1, 0, 0, 0, 1143, 1144, 6, 125, 13, 0, 1144, 1145, 6, 125, 26, 0, 1145, 266, 1, 0, 0, 0, 1146, 1147, 7, 7, 0, 0, 1147, 1148, 7, 9, 0, 0, 1148, 1149, 1, 0, 0, 0, 1149, 1150, 6, 126, 27, 0, 1150, 268, 1, 0, 0, 0, 1151, 1152, 7, 19, 0, 0, 1152, 1153, 7, 1, 0, 0, 1153, 1154, 7, 5, 0, 0, 1154, 1155, 7, 10, 0, 0, 1155, 1156, 1, 0, 0, 0, 1156, 1157, 6, 127, 27, 0, 1157, 270, 1, 0, 0, 0, 1158, 1159, 8, 34, 0, 0, 1159, 272, 1, 0, 0, 0, 1160, 1162, 3, 271, 128, 0, 1161, 1160, 1, 0, 0, 0, 1162, 1163, 1, 0, 0, 0, 1163, 1161, 1, 0, 0, 0, 1163, 1164, 1, 0, 0, 0, 1164, 1165, 1, 0, 0, 0, 1165, 1166, 3, 337, 161, 0, 1166, 1168, 1, 0, 0, 0, 1167, 1161, 1, 0, 0, 0, 1167, 1168, 1, 0, 0, 0, 1168, 1170, 1, 0, 0, 0, 1169, 1171, 3, 271, 128, 0, 1170, 1169, 1, 0, 0, 0, 1171, 1172, 1, 0, 0, 0, 1172, 1170, 1, 0, 0, 0, 1172, 1173, 1, 0, 0, 0, 1173, 274, 1, 0, 0, 0, 1174, 1175, 3, 273, 129, 0, 1175, 1176, 1, 0, 0, 0, 1176, 1177, 6, 130, 28, 0, 1177, 276, 1, 0, 0, 0, 1178, 1179, 3, 55, 20, 0, 1179, 1180, 1, 0, 0, 0, 1180, 1181, 6, 131, 10, 0, 1181, 278, 1, 0, 0, 0, 1182, 1183, 3, 57, 21, 0, 1183, 1184, 1, 0, 0, 0, 1184, 1185, 6, 132, 10, 0, 1185, 280, 1, 0, 0, 0, 1186, 1187, 3, 59, 22, 0, 1187, 1188, 1, 0, 0, 0, 1188, 1189, 6, 133, 10, 0, 1189, 282, 1, 0, 0, 0, 1190, 1191, 3, 61, 23, 0, 1191, 1192, 1, 0, 0, 0, 1192, 1193, 6, 134, 15, 0, 1193, 1194, 6, 134, 11, 0, 1194, 1195, 6, 134, 11, 0, 1195, 284, 1, 0, 0, 0, 1196, 1197, 3, 95, 40, 0, 1197, 1198, 1, 0, 0, 0, 1198, 1199, 6, 135, 19, 0, 1199, 286, 1, 0, 0, 0, 1200, 1201, 3, 99, 42, 0, 1201, 1202, 1, 0, 0, 0, 1202, 1203, 6, 136, 18, 0, 1203, 288, 1, 0, 0, 0, 1204, 1205, 3, 103, 44, 0, 1205, 1206, 1, 0, 0, 0, 1206, 1207, 6, 137, 22, 0, 1207, 290, 1, 0, 0, 0, 1208, 1209, 3, 269, 127, 0, 1209, 1210, 1, 0, 0, 0, 1210, 1211, 6, 138, 29, 0, 1211, 292, 1, 0, 0, 0, 1212, 1213, 3, 233, 109, 0, 1213, 1214, 1, 0, 0, 0, 1214, 1215, 6, 139, 25, 0, 1215, 294, 1, 0, 0, 0, 1216, 1217, 3, 173, 79, 0, 1217, 1218, 1, 0, 0, 0, 1218, 1219, 6, 140, 30, 0, 1219, 296, 1, 0, 0, 0, 1220, 1221, 4, 141, 7, 0, 1221, 1222, 3, 127, 56, 0, 1222, 1223, 1, 0, 0, 0, 1223, 1224, 6, 141, 23, 0, 1224, 298, 1, 0, 0, 0, 1225, 1226, 4, 142, 8, 0, 1226, 1227, 3, 163, 74, 0, 1227, 1228, 1, 0, 0, 0, 1228, 1229, 6, 142, 24, 0, 1229, 300, 1, 0, 0, 0, 1230, 1231, 3, 55, 20, 0, 1231, 1232, 1, 0, 0, 0, 1232, 1233, 6, 143, 10, 0, 1233, 302, 1, 0, 0, 0, 1234, 1235, 3, 57, 21, 0, 1235, 1236, 1, 0, 0, 0, 1236, 1237, 6, 144, 10, 0, 1237, 304, 1, 0, 0, 0, 1238, 1239, 3, 59, 22, 0, 1239, 1240, 1, 0, 0, 0, 1240, 1241, 6, 145, 10, 0, 1241, 306, 1, 0, 0, 0, 1242, 1243, 3, 61, 23, 0, 1243, 1244, 1, 0, 0, 0, 1244, 1245, 6, 146, 15, 0, 1245, 1246, 6, 146, 11, 0, 1246, 308, 1, 0, 0, 0, 1247, 1248, 3, 103, 44, 0, 1248, 1249, 1, 0, 0, 0, 1249, 1250, 6, 147, 22, 0, 1250, 310, 1, 0, 0, 0, 1251, 1252, 4, 148, 9, 0, 1252, 1253, 3, 127, 56, 0, 1253, 1254, 1, 0, 0, 0, 1254, 1255, 6, 148, 23, 0, 1255, 312, 1, 0, 0, 0, 1256, 1257, 4, 149, 10, 0, 1257, 1258, 3, 163, 74, 0, 1258, 1259, 1, 0, 0, 0, 1259, 1260, 6, 149, 24, 0, 1260, 314, 1, 0, 0, 0, 1261, 1262, 3, 173, 79, 0, 1262, 1263, 1, 0, 0, 0, 1263, 1264, 6, 150, 30, 0, 1264, 316, 1, 0, 0, 0, 1265, 1266, 3, 169, 77, 0, 1266, 1267, 1, 0, 0, 0, 1267, 1268, 6, 151, 31, 0, 1268, 318, 1, 0, 0, 0, 1269, 1270, 3, 55, 20, 0, 1270, 1271, 1, 0, 0, 0, 1271, 1272, 6, 152, 10, 0, 1272, 320, 1, 0, 0, 0, 1273, 1274, 3, 57, 21, 0, 1274, 1275, 1, 0, 0, 0, 1275, 1276, 6, 153, 10, 0, 1276, 322, 1, 0, 0, 0, 1277, 1278, 3, 59, 22, 0, 1278, 1279, 1, 0, 0, 0, 1279, 1280, 6, 154, 10, 0, 1280, 324, 1, 0, 0, 0, 1281, 1282, 3, 61, 23, 0, 1282, 1283, 1, 0, 0, 0, 1283, 1284, 6, 155, 15, 0, 1284, 1285, 6, 155, 11, 0, 1285, 326, 1, 0, 0, 0, 1286, 1287, 7, 1, 0, 0, 1287, 1288, 7, 9, 0, 0, 1288, 1289, 7, 15, 0, 0, 1289, 1290, 7, 7, 0, 0, 1290, 328, 1, 0, 0, 0, 1291, 1292, 3, 55, 20, 0, 1292, 1293, 1, 0, 0, 0, 1293, 1294, 6, 157, 10, 0, 1294, 330, 1, 0, 0, 0, 1295, 1296, 3, 57, 21, 0, 1296, 1297, 1, 0, 0, 0, 1297, 1298, 6, 158, 10, 0, 1298, 332, 1, 0, 0, 0, 1299, 1300, 3, 59, 22, 0, 1300, 1301, 1, 0, 0, 0, 1301, 1302, 6, 159, 10, 0, 1302, 334, 1, 0, 0, 0, 1303, 1304, 3, 167, 76, 0, 1304, 1305, 1, 0, 0, 0, 1305, 1306, 6, 160, 16, 0, 1306, 1307, 6, 160, 11, 0, 1307, 336, 1, 0, 0, 0, 1308, 1309, 5, 58, 0, 0, 1309, 338, 1, 0, 0, 0, 1310, 1316, 3, 73, 29, 0, 1311, 1316, 3, 63, 24, 0, 1312, 1316, 3, 103, 44, 0, 1313, 1316, 3, 65, 25, 0, 1314, 1316, 3, 79, 32, 0, 1315, 1310, 1, 0, 0, 0, 1315, 1311, 1, 0, 0, 0, 1315, 1312, 1, 0, 0, 0, 1315, 1313, 1, 0, 0, 0, 1315, 1314, 1, 0, 0, 0, 1316, 1317, 1, 0, 0, 0, 1317, 1315, 1, 0, 0, 0, 1317, 1318, 1, 0, 0, 0, 1318, 340, 1, 0, 0, 0, 1319, 1320, 3, 55, 20, 0, 1320, 1321, 1, 0, 0, 0, 1321, 1322, 6, 163, 10, 0, 1322, 342, 1, 0, 0, 0, 1323, 1324, 3, 57, 21, 0, 1324, 1325, 1, 0, 0, 0, 1325, 1326, 6, 164, 10, 0, 1326, 344, 1, 0, 0, 0, 1327, 1328, 3, 59, 22, 0, 1328, 1329, 1, 0, 0, 0, 1329, 1330, 6, 165, 10, 0, 1330, 346, 1, 0, 0, 0, 1331, 1332, 3, 61, 23, 0, 1332, 1333, 1, 0, 0, 0, 1333, 1334, 6, 166, 15, 0, 1334, 1335, 6, 166, 11, 0, 1335, 348, 1, 0, 0, 0, 1336, 1337, 3, 337, 161, 0, 1337, 1338, 1, 0, 0, 0, 1338, 1339, 6, 167, 17, 0, 1339, 350, 1, 0, 0, 0, 1340, 1341, 3, 99, 42, 0, 1341, 1342, 1, 0, 0, 0, 1342, 1343, 6, 168, 18, 0, 1343, 352, 1, 0, 0, 0, 1344, 1345, 3, 103, 44, 0, 1345, 1346, 1, 0, 0, 0, 1346, 1347, 6, 169, 22, 0, 1347, 354, 1, 0, 0, 0, 1348, 1349, 3, 267, 126, 0, 1349, 1350, 1, 0, 0, 0, 1350, 1351, 6, 170, 32, 0, 1351, 1352, 6, 170, 33, 0, 1352, 356, 1, 0, 0, 0, 1353, 1354, 3, 207, 96, 0, 1354, 1355, 1, 0, 0, 0, 1355, 1356, 6, 171, 20, 0, 1356, 358, 1, 0, 0, 0, 1357, 1358, 3, 83, 34, 0, 1358, 1359, 1, 0, 0, 0, 1359, 1360, 6, 172, 21, 0, 1360, 360, 1, 0, 0, 0, 1361, 1362, 3, 55, 20, 0, 1362, 1363, 1, 0, 0, 0, 1363, 1364, 6, 173, 10, 0, 1364, 362, 1, 0, 0, 0, 1365, 1366, 3, 57, 21, 0, 1366, 1367, 1, 0, 0, 0, 1367, 1368, 6, 174, 10, 0, 1368, 364, 1, 0, 0, 0, 1369, 1370, 3, 59, 22, 0, 1370, 1371, 1, 0, 0, 0, 1371, 1372, 6, 175, 10, 0, 1372, 366, 1, 0, 0, 0, 1373, 1374, 3, 61, 23, 0, 1374, 1375, 1, 0, 0, 0, 1375, 1376, 6, 176, 15, 0, 1376, 1377, 6, 176, 11, 0, 1377, 1378, 6, 176, 11, 0, 1378, 368, 1, 0, 0, 0, 1379, 1380, 3, 99, 42, 0, 1380, 1381, 1, 0, 0, 0, 1381, 1382, 6, 177, 18, 0, 1382, 370, 1, 0, 0, 0, 1383, 1384, 3, 103, 44, 0, 1384, 1385, 1, 0, 0, 0, 1385, 1386, 6, 178, 22, 0, 1386, 372, 1, 0, 0, 0, 1387, 1388, 3, 233, 109, 0, 1388, 1389, 1, 0, 0, 0, 1389, 1390, 6, 179, 25, 0, 1390, 374, 1, 0, 0, 0, 1391, 1392, 3, 55, 20, 0, 1392, 1393, 1, 0, 0, 0, 1393, 1394, 6, 180, 10, 0, 1394, 376, 1, 0, 0, 0, 1395, 1396, 3, 57, 21, 0, 1396, 1397, 1, 0, 0, 0, 1397, 1398, 6, 181, 10, 0, 1398, 378, 1, 0, 0, 0, 1399, 1400, 3, 59, 22, 0, 1400, 1401, 1, 0, 0, 0, 1401, 1402, 6, 182, 10, 0, 1402, 380, 1, 0, 0, 0, 1403, 1404, 3, 61, 23, 0, 1404, 1405, 1, 0, 0, 0, 1405, 1406, 6, 183, 15, 0, 1406, 1407, 6, 183, 11, 0, 1407, 382, 1, 0, 0, 0, 1408, 1409, 3, 207, 96, 0, 1409, 1410, 1, 0, 0, 0, 1410, 1411, 6, 184, 20, 0, 1411, 1412, 6, 184, 11, 0, 1412, 1413, 6, 184, 34, 0, 1413, 384, 1, 0, 0, 0, 1414, 1415, 3, 83, 34, 0, 1415, 1416, 1, 0, 0, 0, 1416, 1417, 6, 185, 21, 0, 1417, 1418, 6, 185, 11, 0, 1418, 1419, 6, 185, 34, 0, 1419, 386, 1, 0, 0, 0, 1420, 1421, 3, 55, 20, 0, 1421, 1422, 1, 0, 0, 0, 1422, 1423, 6, 186, 10, 0, 1423, 388, 1, 0, 0, 0, 1424, 1425, 3, 57, 21, 0, 1425, 1426, 1, 0, 0, 0, 1426, 1427, 6, 187, 10, 0, 1427, 390, 1, 0, 0, 0, 1428, 1429, 3, 59, 22, 0, 1429, 1430, 1, 0, 0, 0, 1430, 1431, 6, 188, 10, 0, 1431, 392, 1, 0, 0, 0, 1432, 1433, 3, 337, 161, 0, 1433, 1434, 1, 0, 0, 0, 1434, 1435, 6, 189, 17, 0, 1435, 1436, 6, 189, 11, 0, 1436, 1437, 6, 189, 9, 0, 1437, 394, 1, 0, 0, 0, 1438, 1439, 3, 99, 42, 0, 1439, 1440, 1, 0, 0, 0, 1440, 1441, 6, 190, 18, 0, 1441, 1442, 6, 190, 11, 0, 1442, 1443, 6, 190, 9, 0, 1443, 396, 1, 0, 0, 0, 1444, 1445, 3, 55, 20, 0, 1445, 1446, 1, 0, 0, 0, 1446, 1447, 6, 191, 10, 0, 1447, 398, 1, 0, 0, 0, 1448, 1449, 3, 57, 21, 0, 1449, 1450, 1, 0, 0, 0, 1450, 1451, 6, 192, 10, 0, 1451, 400, 1, 0, 0, 0, 1452, 1453, 3, 59, 22, 0, 1453, 1454, 1, 0, 0, 0, 1454, 1455, 6, 193, 10, 0, 1455, 402, 1, 0, 0, 0, 1456, 1457, 3, 173, 79, 0, 1457, 1458, 1, 0, 0, 0, 1458, 1459, 6, 194, 11, 0, 1459, 1460, 6, 194, 0, 0, 1460, 1461, 6, 194, 30, 0, 1461, 404, 1, 0, 0, 0, 1462, 1463, 3, 169, 77, 0, 1463, 1464, 1, 0, 0, 0, 1464, 1465, 6, 195, 11, 0, 1465, 1466, 6, 195, 0, 0, 1466, 1467, 6, 195, 31, 0, 1467, 406, 1, 0, 0, 0, 1468, 1469, 3, 89, 37, 0, 1469, 1470, 1, 0, 0, 0, 1470, 1471, 6, 196, 11, 0, 1471, 1472, 6, 196, 0, 0, 1472, 1473, 6, 196, 35, 0, 1473, 408, 1, 0, 0, 0, 1474, 1475, 3, 61, 23, 0, 1475, 1476, 1, 0, 0, 0, 1476, 1477, 6, 197, 15, 0, 1477, 1478, 6, 197, 11, 0, 1478, 410, 1, 0, 0, 0, 65, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 579, 589, 593, 596, 605, 607, 618, 637, 642, 651, 658, 663, 665, 676, 684, 687, 689, 694, 699, 705, 712, 717, 723, 726, 734, 738, 870, 875, 882, 884, 900, 905, 910, 912, 918, 995, 1000, 1049, 1053, 1058, 1063, 1068, 1070, 1074, 1076, 1163, 1167, 1172, 1315, 1317, 36, 5, 1, 0, 5, 4, 0, 5, 6, 0, 5, 2, 0, 5, 3, 0, 5, 8, 0, 5, 5, 0, 5, 9, 0, 5, 11, 0, 5, 13, 0, 0, 1, 0, 4, 0, 0, 7, 16, 0, 7, 65, 0, 5, 0, 0, 7, 24, 0, 7, 66, 0, 7, 104, 0, 7, 33, 0, 7, 31, 0, 7, 76, 0, 7, 25, 0, 7, 35, 0, 7, 47, 0, 7, 64, 0, 7, 80, 0, 5, 10, 0, 5, 7, 0, 7, 90, 0, 7, 89, 0, 7, 68, 0, 7, 67, 0, 7, 88, 0, 5, 12, 0, 5, 14, 0, 7, 28, 0] \ No newline at end of file +[4, 0, 119, 1484, 6, -1, 6, -1, 6, -1, 6, -1, 6, -1, 6, -1, 6, -1, 6, -1, 6, -1, 6, -1, 6, -1, 6, -1, 6, -1, 6, -1, 6, -1, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 2, 8, 7, 8, 2, 9, 7, 9, 2, 10, 7, 10, 2, 11, 7, 11, 2, 12, 7, 12, 2, 13, 7, 13, 2, 14, 7, 14, 2, 15, 7, 15, 2, 16, 7, 16, 2, 17, 7, 17, 2, 18, 7, 18, 2, 19, 7, 19, 2, 20, 7, 20, 2, 21, 7, 21, 2, 22, 7, 22, 2, 23, 7, 23, 2, 24, 7, 24, 2, 25, 7, 25, 2, 26, 7, 26, 2, 27, 7, 27, 2, 28, 7, 28, 2, 29, 7, 29, 2, 30, 7, 30, 2, 31, 7, 31, 2, 32, 7, 32, 2, 33, 7, 33, 2, 34, 7, 34, 2, 35, 7, 35, 2, 36, 7, 36, 2, 37, 7, 37, 2, 38, 7, 38, 2, 39, 7, 39, 2, 40, 7, 40, 2, 41, 7, 41, 2, 42, 7, 42, 2, 43, 7, 43, 2, 44, 7, 44, 2, 45, 7, 45, 2, 46, 7, 46, 2, 47, 7, 47, 2, 48, 7, 48, 2, 49, 7, 49, 2, 50, 7, 50, 2, 51, 7, 51, 2, 52, 7, 52, 2, 53, 7, 53, 2, 54, 7, 54, 2, 55, 7, 55, 2, 56, 7, 56, 2, 57, 7, 57, 2, 58, 7, 58, 2, 59, 7, 59, 2, 60, 7, 60, 2, 61, 7, 61, 2, 62, 7, 62, 2, 63, 7, 63, 2, 64, 7, 64, 2, 65, 7, 65, 2, 66, 7, 66, 2, 67, 7, 67, 2, 68, 7, 68, 2, 69, 7, 69, 2, 70, 7, 70, 2, 71, 7, 71, 2, 72, 7, 72, 2, 73, 7, 73, 2, 74, 7, 74, 2, 75, 7, 75, 2, 76, 7, 76, 2, 77, 7, 77, 2, 78, 7, 78, 2, 79, 7, 79, 2, 80, 7, 80, 2, 81, 7, 81, 2, 82, 7, 82, 2, 83, 7, 83, 2, 84, 7, 84, 2, 85, 7, 85, 2, 86, 7, 86, 2, 87, 7, 87, 2, 88, 7, 88, 2, 89, 7, 89, 2, 90, 7, 90, 2, 91, 7, 91, 2, 92, 7, 92, 2, 93, 7, 93, 2, 94, 7, 94, 2, 95, 7, 95, 2, 96, 7, 96, 2, 97, 7, 97, 2, 98, 7, 98, 2, 99, 7, 99, 2, 100, 7, 100, 2, 101, 7, 101, 2, 102, 7, 102, 2, 103, 7, 103, 2, 104, 7, 104, 2, 105, 7, 105, 2, 106, 7, 106, 2, 107, 7, 107, 2, 108, 7, 108, 2, 109, 7, 109, 2, 110, 7, 110, 2, 111, 7, 111, 2, 112, 7, 112, 2, 113, 7, 113, 2, 114, 7, 114, 2, 115, 7, 115, 2, 116, 7, 116, 2, 117, 7, 117, 2, 118, 7, 118, 2, 119, 7, 119, 2, 120, 7, 120, 2, 121, 7, 121, 2, 122, 7, 122, 2, 123, 7, 123, 2, 124, 7, 124, 2, 125, 7, 125, 2, 126, 7, 126, 2, 127, 7, 127, 2, 128, 7, 128, 2, 129, 7, 129, 2, 130, 7, 130, 2, 131, 7, 131, 2, 132, 7, 132, 2, 133, 7, 133, 2, 134, 7, 134, 2, 135, 7, 135, 2, 136, 7, 136, 2, 137, 7, 137, 2, 138, 7, 138, 2, 139, 7, 139, 2, 140, 7, 140, 2, 141, 7, 141, 2, 142, 7, 142, 2, 143, 7, 143, 2, 144, 7, 144, 2, 145, 7, 145, 2, 146, 7, 146, 2, 147, 7, 147, 2, 148, 7, 148, 2, 149, 7, 149, 2, 150, 7, 150, 2, 151, 7, 151, 2, 152, 7, 152, 2, 153, 7, 153, 2, 154, 7, 154, 2, 155, 7, 155, 2, 156, 7, 156, 2, 157, 7, 157, 2, 158, 7, 158, 2, 159, 7, 159, 2, 160, 7, 160, 2, 161, 7, 161, 2, 162, 7, 162, 2, 163, 7, 163, 2, 164, 7, 164, 2, 165, 7, 165, 2, 166, 7, 166, 2, 167, 7, 167, 2, 168, 7, 168, 2, 169, 7, 169, 2, 170, 7, 170, 2, 171, 7, 171, 2, 172, 7, 172, 2, 173, 7, 173, 2, 174, 7, 174, 2, 175, 7, 175, 2, 176, 7, 176, 2, 177, 7, 177, 2, 178, 7, 178, 2, 179, 7, 179, 2, 180, 7, 180, 2, 181, 7, 181, 2, 182, 7, 182, 2, 183, 7, 183, 2, 184, 7, 184, 2, 185, 7, 185, 2, 186, 7, 186, 2, 187, 7, 187, 2, 188, 7, 188, 2, 189, 7, 189, 2, 190, 7, 190, 2, 191, 7, 191, 2, 192, 7, 192, 2, 193, 7, 193, 2, 194, 7, 194, 2, 195, 7, 195, 2, 196, 7, 196, 2, 197, 7, 197, 2, 198, 7, 198, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 7, 1, 7, 1, 7, 1, 7, 1, 7, 1, 7, 1, 7, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 10, 1, 10, 1, 10, 1, 10, 1, 10, 1, 10, 1, 10, 1, 10, 1, 10, 1, 11, 1, 11, 1, 11, 1, 11, 1, 11, 1, 11, 1, 12, 1, 12, 1, 12, 1, 12, 1, 12, 1, 12, 1, 12, 1, 13, 1, 13, 1, 13, 1, 13, 1, 13, 1, 13, 1, 13, 1, 14, 1, 14, 1, 14, 1, 14, 1, 14, 1, 14, 1, 14, 1, 14, 1, 15, 1, 15, 1, 15, 1, 15, 1, 15, 1, 15, 1, 15, 1, 15, 1, 16, 1, 16, 1, 16, 1, 16, 1, 16, 1, 16, 1, 16, 1, 16, 1, 16, 1, 16, 1, 16, 1, 16, 1, 16, 1, 16, 1, 16, 1, 17, 1, 17, 1, 17, 1, 17, 1, 17, 1, 17, 1, 17, 1, 17, 1, 17, 1, 17, 1, 18, 1, 18, 1, 18, 1, 18, 1, 18, 1, 18, 1, 18, 1, 18, 1, 18, 1, 18, 1, 18, 1, 19, 4, 19, 580, 8, 19, 11, 19, 12, 19, 581, 1, 19, 1, 19, 1, 20, 1, 20, 1, 20, 1, 20, 5, 20, 590, 8, 20, 10, 20, 12, 20, 593, 9, 20, 1, 20, 3, 20, 596, 8, 20, 1, 20, 3, 20, 599, 8, 20, 1, 20, 1, 20, 1, 21, 1, 21, 1, 21, 1, 21, 1, 21, 5, 21, 608, 8, 21, 10, 21, 12, 21, 611, 9, 21, 1, 21, 1, 21, 1, 21, 1, 21, 1, 21, 1, 22, 4, 22, 619, 8, 22, 11, 22, 12, 22, 620, 1, 22, 1, 22, 1, 23, 1, 23, 1, 24, 1, 24, 1, 24, 1, 24, 1, 25, 1, 25, 1, 26, 1, 26, 1, 27, 1, 27, 1, 27, 1, 28, 1, 28, 1, 29, 1, 29, 3, 29, 642, 8, 29, 1, 29, 4, 29, 645, 8, 29, 11, 29, 12, 29, 646, 1, 30, 1, 30, 1, 31, 1, 31, 1, 32, 1, 32, 1, 32, 3, 32, 656, 8, 32, 1, 33, 1, 33, 1, 34, 1, 34, 1, 34, 3, 34, 663, 8, 34, 1, 35, 1, 35, 1, 35, 5, 35, 668, 8, 35, 10, 35, 12, 35, 671, 9, 35, 1, 35, 1, 35, 1, 35, 1, 35, 1, 35, 1, 35, 5, 35, 679, 8, 35, 10, 35, 12, 35, 682, 9, 35, 1, 35, 1, 35, 1, 35, 1, 35, 1, 35, 3, 35, 689, 8, 35, 1, 35, 3, 35, 692, 8, 35, 3, 35, 694, 8, 35, 1, 36, 4, 36, 697, 8, 36, 11, 36, 12, 36, 698, 1, 37, 4, 37, 702, 8, 37, 11, 37, 12, 37, 703, 1, 37, 1, 37, 5, 37, 708, 8, 37, 10, 37, 12, 37, 711, 9, 37, 1, 37, 1, 37, 4, 37, 715, 8, 37, 11, 37, 12, 37, 716, 1, 37, 4, 37, 720, 8, 37, 11, 37, 12, 37, 721, 1, 37, 1, 37, 5, 37, 726, 8, 37, 10, 37, 12, 37, 729, 9, 37, 3, 37, 731, 8, 37, 1, 37, 1, 37, 1, 37, 1, 37, 4, 37, 737, 8, 37, 11, 37, 12, 37, 738, 1, 37, 1, 37, 3, 37, 743, 8, 37, 1, 38, 1, 38, 1, 38, 1, 39, 1, 39, 1, 39, 1, 39, 1, 40, 1, 40, 1, 40, 1, 40, 1, 41, 1, 41, 1, 42, 1, 42, 1, 42, 1, 43, 1, 43, 1, 44, 1, 44, 1, 44, 1, 44, 1, 44, 1, 45, 1, 45, 1, 46, 1, 46, 1, 46, 1, 46, 1, 46, 1, 46, 1, 47, 1, 47, 1, 47, 1, 47, 1, 47, 1, 47, 1, 48, 1, 48, 1, 48, 1, 49, 1, 49, 1, 49, 1, 50, 1, 50, 1, 50, 1, 50, 1, 50, 1, 51, 1, 51, 1, 51, 1, 51, 1, 51, 1, 52, 1, 52, 1, 53, 1, 53, 1, 53, 1, 53, 1, 54, 1, 54, 1, 54, 1, 54, 1, 54, 1, 55, 1, 55, 1, 55, 1, 55, 1, 55, 1, 55, 1, 56, 1, 56, 1, 56, 1, 57, 1, 57, 1, 58, 1, 58, 1, 58, 1, 58, 1, 58, 1, 58, 1, 59, 1, 59, 1, 60, 1, 60, 1, 60, 1, 60, 1, 60, 1, 61, 1, 61, 1, 61, 1, 62, 1, 62, 1, 62, 1, 63, 1, 63, 1, 63, 1, 64, 1, 64, 1, 65, 1, 65, 1, 65, 1, 66, 1, 66, 1, 67, 1, 67, 1, 67, 1, 68, 1, 68, 1, 69, 1, 69, 1, 70, 1, 70, 1, 71, 1, 71, 1, 72, 1, 72, 1, 73, 1, 73, 1, 73, 1, 73, 1, 73, 1, 74, 1, 74, 1, 74, 1, 74, 1, 75, 1, 75, 1, 75, 3, 75, 874, 8, 75, 1, 75, 5, 75, 877, 8, 75, 10, 75, 12, 75, 880, 9, 75, 1, 75, 1, 75, 4, 75, 884, 8, 75, 11, 75, 12, 75, 885, 3, 75, 888, 8, 75, 1, 76, 1, 76, 1, 76, 1, 76, 1, 76, 1, 77, 1, 77, 1, 77, 1, 77, 1, 77, 1, 78, 1, 78, 5, 78, 902, 8, 78, 10, 78, 12, 78, 905, 9, 78, 1, 78, 1, 78, 3, 78, 909, 8, 78, 1, 78, 4, 78, 912, 8, 78, 11, 78, 12, 78, 913, 3, 78, 916, 8, 78, 1, 79, 1, 79, 4, 79, 920, 8, 79, 11, 79, 12, 79, 921, 1, 79, 1, 79, 1, 80, 1, 80, 1, 81, 1, 81, 1, 81, 1, 81, 1, 82, 1, 82, 1, 82, 1, 82, 1, 83, 1, 83, 1, 83, 1, 83, 1, 84, 1, 84, 1, 84, 1, 84, 1, 84, 1, 85, 1, 85, 1, 85, 1, 85, 1, 85, 1, 86, 1, 86, 1, 86, 1, 86, 1, 87, 1, 87, 1, 87, 1, 87, 1, 88, 1, 88, 1, 88, 1, 88, 1, 89, 1, 89, 1, 89, 1, 89, 1, 89, 1, 90, 1, 90, 1, 90, 1, 90, 1, 91, 1, 91, 1, 91, 1, 91, 1, 92, 1, 92, 1, 92, 1, 92, 1, 93, 1, 93, 1, 93, 1, 93, 1, 94, 1, 94, 1, 94, 1, 94, 1, 95, 1, 95, 1, 95, 1, 95, 1, 95, 1, 95, 1, 95, 1, 95, 1, 95, 1, 96, 1, 96, 1, 96, 3, 96, 999, 8, 96, 1, 97, 4, 97, 1002, 8, 97, 11, 97, 12, 97, 1003, 1, 98, 1, 98, 1, 98, 1, 98, 1, 99, 1, 99, 1, 99, 1, 99, 1, 100, 1, 100, 1, 100, 1, 100, 1, 101, 1, 101, 1, 101, 1, 101, 1, 102, 1, 102, 1, 102, 1, 102, 1, 103, 1, 103, 1, 103, 1, 103, 1, 103, 1, 104, 1, 104, 1, 104, 1, 104, 1, 105, 1, 105, 1, 105, 1, 105, 1, 106, 1, 106, 1, 106, 1, 106, 1, 106, 1, 107, 1, 107, 1, 107, 1, 107, 1, 107, 1, 108, 1, 108, 1, 108, 1, 108, 3, 108, 1053, 8, 108, 1, 109, 1, 109, 3, 109, 1057, 8, 109, 1, 109, 5, 109, 1060, 8, 109, 10, 109, 12, 109, 1063, 9, 109, 1, 109, 1, 109, 3, 109, 1067, 8, 109, 1, 109, 4, 109, 1070, 8, 109, 11, 109, 12, 109, 1071, 3, 109, 1074, 8, 109, 1, 110, 1, 110, 4, 110, 1078, 8, 110, 11, 110, 12, 110, 1079, 1, 111, 1, 111, 1, 111, 1, 111, 1, 112, 1, 112, 1, 112, 1, 112, 1, 113, 1, 113, 1, 113, 1, 113, 1, 114, 1, 114, 1, 114, 1, 114, 1, 114, 1, 115, 1, 115, 1, 115, 1, 115, 1, 116, 1, 116, 1, 116, 1, 116, 1, 117, 1, 117, 1, 117, 1, 117, 1, 118, 1, 118, 1, 118, 1, 118, 1, 118, 1, 119, 1, 119, 1, 119, 1, 119, 1, 119, 1, 120, 1, 120, 1, 120, 1, 121, 1, 121, 1, 121, 1, 121, 1, 122, 1, 122, 1, 122, 1, 122, 1, 123, 1, 123, 1, 123, 1, 123, 1, 124, 1, 124, 1, 124, 1, 124, 1, 125, 1, 125, 1, 125, 1, 125, 1, 125, 1, 126, 1, 126, 1, 126, 1, 126, 1, 126, 1, 127, 1, 127, 1, 127, 1, 127, 1, 127, 1, 128, 1, 128, 1, 128, 1, 128, 1, 128, 1, 128, 1, 128, 1, 129, 1, 129, 1, 130, 4, 130, 1165, 8, 130, 11, 130, 12, 130, 1166, 1, 130, 1, 130, 3, 130, 1171, 8, 130, 1, 130, 4, 130, 1174, 8, 130, 11, 130, 12, 130, 1175, 1, 131, 1, 131, 1, 131, 1, 131, 1, 132, 1, 132, 1, 132, 1, 132, 1, 133, 1, 133, 1, 133, 1, 133, 1, 134, 1, 134, 1, 134, 1, 134, 1, 135, 1, 135, 1, 135, 1, 135, 1, 135, 1, 135, 1, 136, 1, 136, 1, 136, 1, 136, 1, 137, 1, 137, 1, 137, 1, 137, 1, 138, 1, 138, 1, 138, 1, 138, 1, 139, 1, 139, 1, 139, 1, 139, 1, 140, 1, 140, 1, 140, 1, 140, 1, 141, 1, 141, 1, 141, 1, 141, 1, 142, 1, 142, 1, 142, 1, 142, 1, 142, 1, 143, 1, 143, 1, 143, 1, 143, 1, 143, 1, 144, 1, 144, 1, 144, 1, 144, 1, 145, 1, 145, 1, 145, 1, 145, 1, 146, 1, 146, 1, 146, 1, 146, 1, 147, 1, 147, 1, 147, 1, 147, 1, 147, 1, 148, 1, 148, 1, 148, 1, 148, 1, 149, 1, 149, 1, 149, 1, 149, 1, 149, 1, 150, 1, 150, 1, 150, 1, 150, 1, 150, 1, 151, 1, 151, 1, 151, 1, 151, 1, 152, 1, 152, 1, 152, 1, 152, 1, 153, 1, 153, 1, 153, 1, 153, 1, 154, 1, 154, 1, 154, 1, 154, 1, 155, 1, 155, 1, 155, 1, 155, 1, 156, 1, 156, 1, 156, 1, 156, 1, 156, 1, 157, 1, 157, 1, 157, 1, 157, 1, 157, 1, 158, 1, 158, 1, 158, 1, 158, 1, 159, 1, 159, 1, 159, 1, 159, 1, 160, 1, 160, 1, 160, 1, 160, 1, 161, 1, 161, 1, 161, 1, 161, 1, 161, 1, 162, 1, 162, 1, 162, 1, 162, 1, 163, 1, 163, 1, 163, 1, 163, 1, 163, 4, 163, 1321, 8, 163, 11, 163, 12, 163, 1322, 1, 164, 1, 164, 1, 164, 1, 164, 1, 165, 1, 165, 1, 165, 1, 165, 1, 166, 1, 166, 1, 166, 1, 166, 1, 167, 1, 167, 1, 167, 1, 167, 1, 167, 1, 168, 1, 168, 1, 168, 1, 168, 1, 169, 1, 169, 1, 169, 1, 169, 1, 170, 1, 170, 1, 170, 1, 170, 1, 171, 1, 171, 1, 171, 1, 171, 1, 171, 1, 172, 1, 172, 1, 172, 1, 172, 1, 173, 1, 173, 1, 173, 1, 173, 1, 174, 1, 174, 1, 174, 1, 174, 1, 175, 1, 175, 1, 175, 1, 175, 1, 176, 1, 176, 1, 176, 1, 176, 1, 177, 1, 177, 1, 177, 1, 177, 1, 177, 1, 177, 1, 178, 1, 178, 1, 178, 1, 178, 1, 179, 1, 179, 1, 179, 1, 179, 1, 180, 1, 180, 1, 180, 1, 180, 1, 181, 1, 181, 1, 181, 1, 181, 1, 182, 1, 182, 1, 182, 1, 182, 1, 183, 1, 183, 1, 183, 1, 183, 1, 184, 1, 184, 1, 184, 1, 184, 1, 184, 1, 185, 1, 185, 1, 185, 1, 185, 1, 185, 1, 185, 1, 186, 1, 186, 1, 186, 1, 186, 1, 186, 1, 186, 1, 187, 1, 187, 1, 187, 1, 187, 1, 188, 1, 188, 1, 188, 1, 188, 1, 189, 1, 189, 1, 189, 1, 189, 1, 190, 1, 190, 1, 190, 1, 190, 1, 190, 1, 190, 1, 191, 1, 191, 1, 191, 1, 191, 1, 191, 1, 191, 1, 192, 1, 192, 1, 192, 1, 192, 1, 193, 1, 193, 1, 193, 1, 193, 1, 194, 1, 194, 1, 194, 1, 194, 1, 195, 1, 195, 1, 195, 1, 195, 1, 195, 1, 195, 1, 196, 1, 196, 1, 196, 1, 196, 1, 196, 1, 196, 1, 197, 1, 197, 1, 197, 1, 197, 1, 197, 1, 197, 1, 198, 1, 198, 1, 198, 1, 198, 1, 198, 2, 609, 680, 0, 199, 15, 1, 17, 2, 19, 3, 21, 4, 23, 5, 25, 6, 27, 7, 29, 8, 31, 9, 33, 10, 35, 11, 37, 12, 39, 13, 41, 14, 43, 15, 45, 16, 47, 17, 49, 18, 51, 19, 53, 20, 55, 21, 57, 22, 59, 23, 61, 24, 63, 25, 65, 0, 67, 0, 69, 0, 71, 0, 73, 0, 75, 0, 77, 0, 79, 0, 81, 0, 83, 0, 85, 26, 87, 27, 89, 28, 91, 29, 93, 30, 95, 31, 97, 32, 99, 33, 101, 34, 103, 35, 105, 36, 107, 37, 109, 38, 111, 39, 113, 40, 115, 41, 117, 42, 119, 43, 121, 44, 123, 45, 125, 46, 127, 47, 129, 48, 131, 49, 133, 50, 135, 51, 137, 52, 139, 53, 141, 54, 143, 55, 145, 56, 147, 57, 149, 58, 151, 59, 153, 60, 155, 61, 157, 62, 159, 63, 161, 0, 163, 0, 165, 64, 167, 65, 169, 66, 171, 67, 173, 0, 175, 68, 177, 69, 179, 70, 181, 71, 183, 0, 185, 0, 187, 72, 189, 73, 191, 74, 193, 0, 195, 0, 197, 0, 199, 0, 201, 0, 203, 0, 205, 75, 207, 0, 209, 76, 211, 0, 213, 0, 215, 77, 217, 78, 219, 79, 221, 0, 223, 0, 225, 0, 227, 0, 229, 0, 231, 0, 233, 0, 235, 80, 237, 81, 239, 82, 241, 83, 243, 0, 245, 0, 247, 0, 249, 0, 251, 0, 253, 0, 255, 84, 257, 0, 259, 85, 261, 86, 263, 87, 265, 0, 267, 0, 269, 88, 271, 89, 273, 0, 275, 90, 277, 0, 279, 91, 281, 92, 283, 93, 285, 0, 287, 0, 289, 0, 291, 0, 293, 0, 295, 0, 297, 0, 299, 0, 301, 0, 303, 94, 305, 95, 307, 96, 309, 0, 311, 0, 313, 0, 315, 0, 317, 0, 319, 0, 321, 97, 323, 98, 325, 99, 327, 0, 329, 100, 331, 101, 333, 102, 335, 103, 337, 0, 339, 0, 341, 104, 343, 105, 345, 106, 347, 107, 349, 0, 351, 0, 353, 0, 355, 0, 357, 0, 359, 0, 361, 0, 363, 108, 365, 109, 367, 110, 369, 0, 371, 0, 373, 0, 375, 0, 377, 111, 379, 112, 381, 113, 383, 0, 385, 0, 387, 0, 389, 114, 391, 115, 393, 116, 395, 0, 397, 0, 399, 117, 401, 118, 403, 119, 405, 0, 407, 0, 409, 0, 411, 0, 15, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 35, 2, 0, 68, 68, 100, 100, 2, 0, 73, 73, 105, 105, 2, 0, 83, 83, 115, 115, 2, 0, 69, 69, 101, 101, 2, 0, 67, 67, 99, 99, 2, 0, 84, 84, 116, 116, 2, 0, 82, 82, 114, 114, 2, 0, 79, 79, 111, 111, 2, 0, 80, 80, 112, 112, 2, 0, 78, 78, 110, 110, 2, 0, 72, 72, 104, 104, 2, 0, 86, 86, 118, 118, 2, 0, 65, 65, 97, 97, 2, 0, 76, 76, 108, 108, 2, 0, 88, 88, 120, 120, 2, 0, 70, 70, 102, 102, 2, 0, 77, 77, 109, 109, 2, 0, 71, 71, 103, 103, 2, 0, 75, 75, 107, 107, 2, 0, 87, 87, 119, 119, 2, 0, 85, 85, 117, 117, 6, 0, 9, 10, 13, 13, 32, 32, 47, 47, 91, 91, 93, 93, 2, 0, 10, 10, 13, 13, 3, 0, 9, 10, 13, 13, 32, 32, 1, 0, 48, 57, 2, 0, 65, 90, 97, 122, 8, 0, 34, 34, 78, 78, 82, 82, 84, 84, 92, 92, 110, 110, 114, 114, 116, 116, 4, 0, 10, 10, 13, 13, 34, 34, 92, 92, 2, 0, 43, 43, 45, 45, 1, 0, 96, 96, 2, 0, 66, 66, 98, 98, 2, 0, 89, 89, 121, 121, 11, 0, 9, 10, 13, 13, 32, 32, 34, 34, 44, 44, 47, 47, 58, 58, 61, 61, 91, 91, 93, 93, 124, 124, 2, 0, 42, 42, 47, 47, 11, 0, 9, 10, 13, 13, 32, 32, 34, 35, 44, 44, 47, 47, 58, 58, 60, 60, 62, 63, 92, 92, 124, 124, 1512, 0, 15, 1, 0, 0, 0, 0, 17, 1, 0, 0, 0, 0, 19, 1, 0, 0, 0, 0, 21, 1, 0, 0, 0, 0, 23, 1, 0, 0, 0, 0, 25, 1, 0, 0, 0, 0, 27, 1, 0, 0, 0, 0, 29, 1, 0, 0, 0, 0, 31, 1, 0, 0, 0, 0, 33, 1, 0, 0, 0, 0, 35, 1, 0, 0, 0, 0, 37, 1, 0, 0, 0, 0, 39, 1, 0, 0, 0, 0, 41, 1, 0, 0, 0, 0, 43, 1, 0, 0, 0, 0, 45, 1, 0, 0, 0, 0, 47, 1, 0, 0, 0, 0, 49, 1, 0, 0, 0, 0, 51, 1, 0, 0, 0, 0, 53, 1, 0, 0, 0, 0, 55, 1, 0, 0, 0, 0, 57, 1, 0, 0, 0, 0, 59, 1, 0, 0, 0, 0, 61, 1, 0, 0, 0, 1, 63, 1, 0, 0, 0, 1, 85, 1, 0, 0, 0, 1, 87, 1, 0, 0, 0, 1, 89, 1, 0, 0, 0, 1, 91, 1, 0, 0, 0, 1, 93, 1, 0, 0, 0, 1, 95, 1, 0, 0, 0, 1, 97, 1, 0, 0, 0, 1, 99, 1, 0, 0, 0, 1, 101, 1, 0, 0, 0, 1, 103, 1, 0, 0, 0, 1, 105, 1, 0, 0, 0, 1, 107, 1, 0, 0, 0, 1, 109, 1, 0, 0, 0, 1, 111, 1, 0, 0, 0, 1, 113, 1, 0, 0, 0, 1, 115, 1, 0, 0, 0, 1, 117, 1, 0, 0, 0, 1, 119, 1, 0, 0, 0, 1, 121, 1, 0, 0, 0, 1, 123, 1, 0, 0, 0, 1, 125, 1, 0, 0, 0, 1, 127, 1, 0, 0, 0, 1, 129, 1, 0, 0, 0, 1, 131, 1, 0, 0, 0, 1, 133, 1, 0, 0, 0, 1, 135, 1, 0, 0, 0, 1, 137, 1, 0, 0, 0, 1, 139, 1, 0, 0, 0, 1, 141, 1, 0, 0, 0, 1, 143, 1, 0, 0, 0, 1, 145, 1, 0, 0, 0, 1, 147, 1, 0, 0, 0, 1, 149, 1, 0, 0, 0, 1, 151, 1, 0, 0, 0, 1, 153, 1, 0, 0, 0, 1, 155, 1, 0, 0, 0, 1, 157, 1, 0, 0, 0, 1, 159, 1, 0, 0, 0, 1, 161, 1, 0, 0, 0, 1, 163, 1, 0, 0, 0, 1, 165, 1, 0, 0, 0, 1, 167, 1, 0, 0, 0, 1, 169, 1, 0, 0, 0, 1, 171, 1, 0, 0, 0, 1, 175, 1, 0, 0, 0, 1, 177, 1, 0, 0, 0, 1, 179, 1, 0, 0, 0, 1, 181, 1, 0, 0, 0, 2, 183, 1, 0, 0, 0, 2, 185, 1, 0, 0, 0, 2, 187, 1, 0, 0, 0, 2, 189, 1, 0, 0, 0, 2, 191, 1, 0, 0, 0, 3, 193, 1, 0, 0, 0, 3, 195, 1, 0, 0, 0, 3, 197, 1, 0, 0, 0, 3, 199, 1, 0, 0, 0, 3, 201, 1, 0, 0, 0, 3, 203, 1, 0, 0, 0, 3, 205, 1, 0, 0, 0, 3, 209, 1, 0, 0, 0, 3, 211, 1, 0, 0, 0, 3, 213, 1, 0, 0, 0, 3, 215, 1, 0, 0, 0, 3, 217, 1, 0, 0, 0, 3, 219, 1, 0, 0, 0, 4, 221, 1, 0, 0, 0, 4, 223, 1, 0, 0, 0, 4, 225, 1, 0, 0, 0, 4, 227, 1, 0, 0, 0, 4, 229, 1, 0, 0, 0, 4, 235, 1, 0, 0, 0, 4, 237, 1, 0, 0, 0, 4, 239, 1, 0, 0, 0, 4, 241, 1, 0, 0, 0, 5, 243, 1, 0, 0, 0, 5, 245, 1, 0, 0, 0, 5, 247, 1, 0, 0, 0, 5, 249, 1, 0, 0, 0, 5, 251, 1, 0, 0, 0, 5, 253, 1, 0, 0, 0, 5, 255, 1, 0, 0, 0, 5, 257, 1, 0, 0, 0, 5, 259, 1, 0, 0, 0, 5, 261, 1, 0, 0, 0, 5, 263, 1, 0, 0, 0, 6, 265, 1, 0, 0, 0, 6, 267, 1, 0, 0, 0, 6, 269, 1, 0, 0, 0, 6, 271, 1, 0, 0, 0, 6, 275, 1, 0, 0, 0, 6, 277, 1, 0, 0, 0, 6, 279, 1, 0, 0, 0, 6, 281, 1, 0, 0, 0, 6, 283, 1, 0, 0, 0, 7, 285, 1, 0, 0, 0, 7, 287, 1, 0, 0, 0, 7, 289, 1, 0, 0, 0, 7, 291, 1, 0, 0, 0, 7, 293, 1, 0, 0, 0, 7, 295, 1, 0, 0, 0, 7, 297, 1, 0, 0, 0, 7, 299, 1, 0, 0, 0, 7, 301, 1, 0, 0, 0, 7, 303, 1, 0, 0, 0, 7, 305, 1, 0, 0, 0, 7, 307, 1, 0, 0, 0, 8, 309, 1, 0, 0, 0, 8, 311, 1, 0, 0, 0, 8, 313, 1, 0, 0, 0, 8, 315, 1, 0, 0, 0, 8, 317, 1, 0, 0, 0, 8, 319, 1, 0, 0, 0, 8, 321, 1, 0, 0, 0, 8, 323, 1, 0, 0, 0, 8, 325, 1, 0, 0, 0, 9, 327, 1, 0, 0, 0, 9, 329, 1, 0, 0, 0, 9, 331, 1, 0, 0, 0, 9, 333, 1, 0, 0, 0, 9, 335, 1, 0, 0, 0, 10, 337, 1, 0, 0, 0, 10, 339, 1, 0, 0, 0, 10, 341, 1, 0, 0, 0, 10, 343, 1, 0, 0, 0, 10, 345, 1, 0, 0, 0, 10, 347, 1, 0, 0, 0, 11, 349, 1, 0, 0, 0, 11, 351, 1, 0, 0, 0, 11, 353, 1, 0, 0, 0, 11, 355, 1, 0, 0, 0, 11, 357, 1, 0, 0, 0, 11, 359, 1, 0, 0, 0, 11, 361, 1, 0, 0, 0, 11, 363, 1, 0, 0, 0, 11, 365, 1, 0, 0, 0, 11, 367, 1, 0, 0, 0, 12, 369, 1, 0, 0, 0, 12, 371, 1, 0, 0, 0, 12, 373, 1, 0, 0, 0, 12, 375, 1, 0, 0, 0, 12, 377, 1, 0, 0, 0, 12, 379, 1, 0, 0, 0, 12, 381, 1, 0, 0, 0, 13, 383, 1, 0, 0, 0, 13, 385, 1, 0, 0, 0, 13, 387, 1, 0, 0, 0, 13, 389, 1, 0, 0, 0, 13, 391, 1, 0, 0, 0, 13, 393, 1, 0, 0, 0, 14, 395, 1, 0, 0, 0, 14, 397, 1, 0, 0, 0, 14, 399, 1, 0, 0, 0, 14, 401, 1, 0, 0, 0, 14, 403, 1, 0, 0, 0, 14, 405, 1, 0, 0, 0, 14, 407, 1, 0, 0, 0, 14, 409, 1, 0, 0, 0, 14, 411, 1, 0, 0, 0, 15, 413, 1, 0, 0, 0, 17, 423, 1, 0, 0, 0, 19, 430, 1, 0, 0, 0, 21, 439, 1, 0, 0, 0, 23, 446, 1, 0, 0, 0, 25, 456, 1, 0, 0, 0, 27, 463, 1, 0, 0, 0, 29, 470, 1, 0, 0, 0, 31, 477, 1, 0, 0, 0, 33, 485, 1, 0, 0, 0, 35, 497, 1, 0, 0, 0, 37, 506, 1, 0, 0, 0, 39, 512, 1, 0, 0, 0, 41, 519, 1, 0, 0, 0, 43, 526, 1, 0, 0, 0, 45, 534, 1, 0, 0, 0, 47, 542, 1, 0, 0, 0, 49, 557, 1, 0, 0, 0, 51, 567, 1, 0, 0, 0, 53, 579, 1, 0, 0, 0, 55, 585, 1, 0, 0, 0, 57, 602, 1, 0, 0, 0, 59, 618, 1, 0, 0, 0, 61, 624, 1, 0, 0, 0, 63, 626, 1, 0, 0, 0, 65, 630, 1, 0, 0, 0, 67, 632, 1, 0, 0, 0, 69, 634, 1, 0, 0, 0, 71, 637, 1, 0, 0, 0, 73, 639, 1, 0, 0, 0, 75, 648, 1, 0, 0, 0, 77, 650, 1, 0, 0, 0, 79, 655, 1, 0, 0, 0, 81, 657, 1, 0, 0, 0, 83, 662, 1, 0, 0, 0, 85, 693, 1, 0, 0, 0, 87, 696, 1, 0, 0, 0, 89, 742, 1, 0, 0, 0, 91, 744, 1, 0, 0, 0, 93, 747, 1, 0, 0, 0, 95, 751, 1, 0, 0, 0, 97, 755, 1, 0, 0, 0, 99, 757, 1, 0, 0, 0, 101, 760, 1, 0, 0, 0, 103, 762, 1, 0, 0, 0, 105, 767, 1, 0, 0, 0, 107, 769, 1, 0, 0, 0, 109, 775, 1, 0, 0, 0, 111, 781, 1, 0, 0, 0, 113, 784, 1, 0, 0, 0, 115, 787, 1, 0, 0, 0, 117, 792, 1, 0, 0, 0, 119, 797, 1, 0, 0, 0, 121, 799, 1, 0, 0, 0, 123, 803, 1, 0, 0, 0, 125, 808, 1, 0, 0, 0, 127, 814, 1, 0, 0, 0, 129, 817, 1, 0, 0, 0, 131, 819, 1, 0, 0, 0, 133, 825, 1, 0, 0, 0, 135, 827, 1, 0, 0, 0, 137, 832, 1, 0, 0, 0, 139, 835, 1, 0, 0, 0, 141, 838, 1, 0, 0, 0, 143, 841, 1, 0, 0, 0, 145, 843, 1, 0, 0, 0, 147, 846, 1, 0, 0, 0, 149, 848, 1, 0, 0, 0, 151, 851, 1, 0, 0, 0, 153, 853, 1, 0, 0, 0, 155, 855, 1, 0, 0, 0, 157, 857, 1, 0, 0, 0, 159, 859, 1, 0, 0, 0, 161, 861, 1, 0, 0, 0, 163, 866, 1, 0, 0, 0, 165, 887, 1, 0, 0, 0, 167, 889, 1, 0, 0, 0, 169, 894, 1, 0, 0, 0, 171, 915, 1, 0, 0, 0, 173, 917, 1, 0, 0, 0, 175, 925, 1, 0, 0, 0, 177, 927, 1, 0, 0, 0, 179, 931, 1, 0, 0, 0, 181, 935, 1, 0, 0, 0, 183, 939, 1, 0, 0, 0, 185, 944, 1, 0, 0, 0, 187, 949, 1, 0, 0, 0, 189, 953, 1, 0, 0, 0, 191, 957, 1, 0, 0, 0, 193, 961, 1, 0, 0, 0, 195, 966, 1, 0, 0, 0, 197, 970, 1, 0, 0, 0, 199, 974, 1, 0, 0, 0, 201, 978, 1, 0, 0, 0, 203, 982, 1, 0, 0, 0, 205, 986, 1, 0, 0, 0, 207, 998, 1, 0, 0, 0, 209, 1001, 1, 0, 0, 0, 211, 1005, 1, 0, 0, 0, 213, 1009, 1, 0, 0, 0, 215, 1013, 1, 0, 0, 0, 217, 1017, 1, 0, 0, 0, 219, 1021, 1, 0, 0, 0, 221, 1025, 1, 0, 0, 0, 223, 1030, 1, 0, 0, 0, 225, 1034, 1, 0, 0, 0, 227, 1038, 1, 0, 0, 0, 229, 1043, 1, 0, 0, 0, 231, 1052, 1, 0, 0, 0, 233, 1073, 1, 0, 0, 0, 235, 1077, 1, 0, 0, 0, 237, 1081, 1, 0, 0, 0, 239, 1085, 1, 0, 0, 0, 241, 1089, 1, 0, 0, 0, 243, 1093, 1, 0, 0, 0, 245, 1098, 1, 0, 0, 0, 247, 1102, 1, 0, 0, 0, 249, 1106, 1, 0, 0, 0, 251, 1110, 1, 0, 0, 0, 253, 1115, 1, 0, 0, 0, 255, 1120, 1, 0, 0, 0, 257, 1123, 1, 0, 0, 0, 259, 1127, 1, 0, 0, 0, 261, 1131, 1, 0, 0, 0, 263, 1135, 1, 0, 0, 0, 265, 1139, 1, 0, 0, 0, 267, 1144, 1, 0, 0, 0, 269, 1149, 1, 0, 0, 0, 271, 1154, 1, 0, 0, 0, 273, 1161, 1, 0, 0, 0, 275, 1170, 1, 0, 0, 0, 277, 1177, 1, 0, 0, 0, 279, 1181, 1, 0, 0, 0, 281, 1185, 1, 0, 0, 0, 283, 1189, 1, 0, 0, 0, 285, 1193, 1, 0, 0, 0, 287, 1199, 1, 0, 0, 0, 289, 1203, 1, 0, 0, 0, 291, 1207, 1, 0, 0, 0, 293, 1211, 1, 0, 0, 0, 295, 1215, 1, 0, 0, 0, 297, 1219, 1, 0, 0, 0, 299, 1223, 1, 0, 0, 0, 301, 1228, 1, 0, 0, 0, 303, 1233, 1, 0, 0, 0, 305, 1237, 1, 0, 0, 0, 307, 1241, 1, 0, 0, 0, 309, 1245, 1, 0, 0, 0, 311, 1250, 1, 0, 0, 0, 313, 1254, 1, 0, 0, 0, 315, 1259, 1, 0, 0, 0, 317, 1264, 1, 0, 0, 0, 319, 1268, 1, 0, 0, 0, 321, 1272, 1, 0, 0, 0, 323, 1276, 1, 0, 0, 0, 325, 1280, 1, 0, 0, 0, 327, 1284, 1, 0, 0, 0, 329, 1289, 1, 0, 0, 0, 331, 1294, 1, 0, 0, 0, 333, 1298, 1, 0, 0, 0, 335, 1302, 1, 0, 0, 0, 337, 1306, 1, 0, 0, 0, 339, 1311, 1, 0, 0, 0, 341, 1320, 1, 0, 0, 0, 343, 1324, 1, 0, 0, 0, 345, 1328, 1, 0, 0, 0, 347, 1332, 1, 0, 0, 0, 349, 1336, 1, 0, 0, 0, 351, 1341, 1, 0, 0, 0, 353, 1345, 1, 0, 0, 0, 355, 1349, 1, 0, 0, 0, 357, 1353, 1, 0, 0, 0, 359, 1358, 1, 0, 0, 0, 361, 1362, 1, 0, 0, 0, 363, 1366, 1, 0, 0, 0, 365, 1370, 1, 0, 0, 0, 367, 1374, 1, 0, 0, 0, 369, 1378, 1, 0, 0, 0, 371, 1384, 1, 0, 0, 0, 373, 1388, 1, 0, 0, 0, 375, 1392, 1, 0, 0, 0, 377, 1396, 1, 0, 0, 0, 379, 1400, 1, 0, 0, 0, 381, 1404, 1, 0, 0, 0, 383, 1408, 1, 0, 0, 0, 385, 1413, 1, 0, 0, 0, 387, 1419, 1, 0, 0, 0, 389, 1425, 1, 0, 0, 0, 391, 1429, 1, 0, 0, 0, 393, 1433, 1, 0, 0, 0, 395, 1437, 1, 0, 0, 0, 397, 1443, 1, 0, 0, 0, 399, 1449, 1, 0, 0, 0, 401, 1453, 1, 0, 0, 0, 403, 1457, 1, 0, 0, 0, 405, 1461, 1, 0, 0, 0, 407, 1467, 1, 0, 0, 0, 409, 1473, 1, 0, 0, 0, 411, 1479, 1, 0, 0, 0, 413, 414, 7, 0, 0, 0, 414, 415, 7, 1, 0, 0, 415, 416, 7, 2, 0, 0, 416, 417, 7, 2, 0, 0, 417, 418, 7, 3, 0, 0, 418, 419, 7, 4, 0, 0, 419, 420, 7, 5, 0, 0, 420, 421, 1, 0, 0, 0, 421, 422, 6, 0, 0, 0, 422, 16, 1, 0, 0, 0, 423, 424, 7, 0, 0, 0, 424, 425, 7, 6, 0, 0, 425, 426, 7, 7, 0, 0, 426, 427, 7, 8, 0, 0, 427, 428, 1, 0, 0, 0, 428, 429, 6, 1, 1, 0, 429, 18, 1, 0, 0, 0, 430, 431, 7, 3, 0, 0, 431, 432, 7, 9, 0, 0, 432, 433, 7, 6, 0, 0, 433, 434, 7, 1, 0, 0, 434, 435, 7, 4, 0, 0, 435, 436, 7, 10, 0, 0, 436, 437, 1, 0, 0, 0, 437, 438, 6, 2, 2, 0, 438, 20, 1, 0, 0, 0, 439, 440, 7, 3, 0, 0, 440, 441, 7, 11, 0, 0, 441, 442, 7, 12, 0, 0, 442, 443, 7, 13, 0, 0, 443, 444, 1, 0, 0, 0, 444, 445, 6, 3, 0, 0, 445, 22, 1, 0, 0, 0, 446, 447, 7, 3, 0, 0, 447, 448, 7, 14, 0, 0, 448, 449, 7, 8, 0, 0, 449, 450, 7, 13, 0, 0, 450, 451, 7, 12, 0, 0, 451, 452, 7, 1, 0, 0, 452, 453, 7, 9, 0, 0, 453, 454, 1, 0, 0, 0, 454, 455, 6, 4, 3, 0, 455, 24, 1, 0, 0, 0, 456, 457, 7, 15, 0, 0, 457, 458, 7, 6, 0, 0, 458, 459, 7, 7, 0, 0, 459, 460, 7, 16, 0, 0, 460, 461, 1, 0, 0, 0, 461, 462, 6, 5, 4, 0, 462, 26, 1, 0, 0, 0, 463, 464, 7, 17, 0, 0, 464, 465, 7, 6, 0, 0, 465, 466, 7, 7, 0, 0, 466, 467, 7, 18, 0, 0, 467, 468, 1, 0, 0, 0, 468, 469, 6, 6, 0, 0, 469, 28, 1, 0, 0, 0, 470, 471, 7, 18, 0, 0, 471, 472, 7, 3, 0, 0, 472, 473, 7, 3, 0, 0, 473, 474, 7, 8, 0, 0, 474, 475, 1, 0, 0, 0, 475, 476, 6, 7, 1, 0, 476, 30, 1, 0, 0, 0, 477, 478, 7, 13, 0, 0, 478, 479, 7, 1, 0, 0, 479, 480, 7, 16, 0, 0, 480, 481, 7, 1, 0, 0, 481, 482, 7, 5, 0, 0, 482, 483, 1, 0, 0, 0, 483, 484, 6, 8, 0, 0, 484, 32, 1, 0, 0, 0, 485, 486, 7, 16, 0, 0, 486, 487, 7, 11, 0, 0, 487, 488, 5, 95, 0, 0, 488, 489, 7, 3, 0, 0, 489, 490, 7, 14, 0, 0, 490, 491, 7, 8, 0, 0, 491, 492, 7, 12, 0, 0, 492, 493, 7, 9, 0, 0, 493, 494, 7, 0, 0, 0, 494, 495, 1, 0, 0, 0, 495, 496, 6, 9, 5, 0, 496, 34, 1, 0, 0, 0, 497, 498, 7, 6, 0, 0, 498, 499, 7, 3, 0, 0, 499, 500, 7, 9, 0, 0, 500, 501, 7, 12, 0, 0, 501, 502, 7, 16, 0, 0, 502, 503, 7, 3, 0, 0, 503, 504, 1, 0, 0, 0, 504, 505, 6, 10, 6, 0, 505, 36, 1, 0, 0, 0, 506, 507, 7, 6, 0, 0, 507, 508, 7, 7, 0, 0, 508, 509, 7, 19, 0, 0, 509, 510, 1, 0, 0, 0, 510, 511, 6, 11, 0, 0, 511, 38, 1, 0, 0, 0, 512, 513, 7, 2, 0, 0, 513, 514, 7, 10, 0, 0, 514, 515, 7, 7, 0, 0, 515, 516, 7, 19, 0, 0, 516, 517, 1, 0, 0, 0, 517, 518, 6, 12, 7, 0, 518, 40, 1, 0, 0, 0, 519, 520, 7, 2, 0, 0, 520, 521, 7, 7, 0, 0, 521, 522, 7, 6, 0, 0, 522, 523, 7, 5, 0, 0, 523, 524, 1, 0, 0, 0, 524, 525, 6, 13, 0, 0, 525, 42, 1, 0, 0, 0, 526, 527, 7, 2, 0, 0, 527, 528, 7, 5, 0, 0, 528, 529, 7, 12, 0, 0, 529, 530, 7, 5, 0, 0, 530, 531, 7, 2, 0, 0, 531, 532, 1, 0, 0, 0, 532, 533, 6, 14, 0, 0, 533, 44, 1, 0, 0, 0, 534, 535, 7, 19, 0, 0, 535, 536, 7, 10, 0, 0, 536, 537, 7, 3, 0, 0, 537, 538, 7, 6, 0, 0, 538, 539, 7, 3, 0, 0, 539, 540, 1, 0, 0, 0, 540, 541, 6, 15, 0, 0, 541, 46, 1, 0, 0, 0, 542, 543, 4, 16, 0, 0, 543, 544, 7, 1, 0, 0, 544, 545, 7, 9, 0, 0, 545, 546, 7, 13, 0, 0, 546, 547, 7, 1, 0, 0, 547, 548, 7, 9, 0, 0, 548, 549, 7, 3, 0, 0, 549, 550, 7, 2, 0, 0, 550, 551, 7, 5, 0, 0, 551, 552, 7, 12, 0, 0, 552, 553, 7, 5, 0, 0, 553, 554, 7, 2, 0, 0, 554, 555, 1, 0, 0, 0, 555, 556, 6, 16, 0, 0, 556, 48, 1, 0, 0, 0, 557, 558, 4, 17, 1, 0, 558, 559, 7, 13, 0, 0, 559, 560, 7, 7, 0, 0, 560, 561, 7, 7, 0, 0, 561, 562, 7, 18, 0, 0, 562, 563, 7, 20, 0, 0, 563, 564, 7, 8, 0, 0, 564, 565, 1, 0, 0, 0, 565, 566, 6, 17, 8, 0, 566, 50, 1, 0, 0, 0, 567, 568, 4, 18, 2, 0, 568, 569, 7, 16, 0, 0, 569, 570, 7, 3, 0, 0, 570, 571, 7, 5, 0, 0, 571, 572, 7, 6, 0, 0, 572, 573, 7, 1, 0, 0, 573, 574, 7, 4, 0, 0, 574, 575, 7, 2, 0, 0, 575, 576, 1, 0, 0, 0, 576, 577, 6, 18, 9, 0, 577, 52, 1, 0, 0, 0, 578, 580, 8, 21, 0, 0, 579, 578, 1, 0, 0, 0, 580, 581, 1, 0, 0, 0, 581, 579, 1, 0, 0, 0, 581, 582, 1, 0, 0, 0, 582, 583, 1, 0, 0, 0, 583, 584, 6, 19, 0, 0, 584, 54, 1, 0, 0, 0, 585, 586, 5, 47, 0, 0, 586, 587, 5, 47, 0, 0, 587, 591, 1, 0, 0, 0, 588, 590, 8, 22, 0, 0, 589, 588, 1, 0, 0, 0, 590, 593, 1, 0, 0, 0, 591, 589, 1, 0, 0, 0, 591, 592, 1, 0, 0, 0, 592, 595, 1, 0, 0, 0, 593, 591, 1, 0, 0, 0, 594, 596, 5, 13, 0, 0, 595, 594, 1, 0, 0, 0, 595, 596, 1, 0, 0, 0, 596, 598, 1, 0, 0, 0, 597, 599, 5, 10, 0, 0, 598, 597, 1, 0, 0, 0, 598, 599, 1, 0, 0, 0, 599, 600, 1, 0, 0, 0, 600, 601, 6, 20, 10, 0, 601, 56, 1, 0, 0, 0, 602, 603, 5, 47, 0, 0, 603, 604, 5, 42, 0, 0, 604, 609, 1, 0, 0, 0, 605, 608, 3, 57, 21, 0, 606, 608, 9, 0, 0, 0, 607, 605, 1, 0, 0, 0, 607, 606, 1, 0, 0, 0, 608, 611, 1, 0, 0, 0, 609, 610, 1, 0, 0, 0, 609, 607, 1, 0, 0, 0, 610, 612, 1, 0, 0, 0, 611, 609, 1, 0, 0, 0, 612, 613, 5, 42, 0, 0, 613, 614, 5, 47, 0, 0, 614, 615, 1, 0, 0, 0, 615, 616, 6, 21, 10, 0, 616, 58, 1, 0, 0, 0, 617, 619, 7, 23, 0, 0, 618, 617, 1, 0, 0, 0, 619, 620, 1, 0, 0, 0, 620, 618, 1, 0, 0, 0, 620, 621, 1, 0, 0, 0, 621, 622, 1, 0, 0, 0, 622, 623, 6, 22, 10, 0, 623, 60, 1, 0, 0, 0, 624, 625, 5, 58, 0, 0, 625, 62, 1, 0, 0, 0, 626, 627, 5, 124, 0, 0, 627, 628, 1, 0, 0, 0, 628, 629, 6, 24, 11, 0, 629, 64, 1, 0, 0, 0, 630, 631, 7, 24, 0, 0, 631, 66, 1, 0, 0, 0, 632, 633, 7, 25, 0, 0, 633, 68, 1, 0, 0, 0, 634, 635, 5, 92, 0, 0, 635, 636, 7, 26, 0, 0, 636, 70, 1, 0, 0, 0, 637, 638, 8, 27, 0, 0, 638, 72, 1, 0, 0, 0, 639, 641, 7, 3, 0, 0, 640, 642, 7, 28, 0, 0, 641, 640, 1, 0, 0, 0, 641, 642, 1, 0, 0, 0, 642, 644, 1, 0, 0, 0, 643, 645, 3, 65, 25, 0, 644, 643, 1, 0, 0, 0, 645, 646, 1, 0, 0, 0, 646, 644, 1, 0, 0, 0, 646, 647, 1, 0, 0, 0, 647, 74, 1, 0, 0, 0, 648, 649, 5, 64, 0, 0, 649, 76, 1, 0, 0, 0, 650, 651, 5, 96, 0, 0, 651, 78, 1, 0, 0, 0, 652, 656, 8, 29, 0, 0, 653, 654, 5, 96, 0, 0, 654, 656, 5, 96, 0, 0, 655, 652, 1, 0, 0, 0, 655, 653, 1, 0, 0, 0, 656, 80, 1, 0, 0, 0, 657, 658, 5, 95, 0, 0, 658, 82, 1, 0, 0, 0, 659, 663, 3, 67, 26, 0, 660, 663, 3, 65, 25, 0, 661, 663, 3, 81, 33, 0, 662, 659, 1, 0, 0, 0, 662, 660, 1, 0, 0, 0, 662, 661, 1, 0, 0, 0, 663, 84, 1, 0, 0, 0, 664, 669, 5, 34, 0, 0, 665, 668, 3, 69, 27, 0, 666, 668, 3, 71, 28, 0, 667, 665, 1, 0, 0, 0, 667, 666, 1, 0, 0, 0, 668, 671, 1, 0, 0, 0, 669, 667, 1, 0, 0, 0, 669, 670, 1, 0, 0, 0, 670, 672, 1, 0, 0, 0, 671, 669, 1, 0, 0, 0, 672, 694, 5, 34, 0, 0, 673, 674, 5, 34, 0, 0, 674, 675, 5, 34, 0, 0, 675, 676, 5, 34, 0, 0, 676, 680, 1, 0, 0, 0, 677, 679, 8, 22, 0, 0, 678, 677, 1, 0, 0, 0, 679, 682, 1, 0, 0, 0, 680, 681, 1, 0, 0, 0, 680, 678, 1, 0, 0, 0, 681, 683, 1, 0, 0, 0, 682, 680, 1, 0, 0, 0, 683, 684, 5, 34, 0, 0, 684, 685, 5, 34, 0, 0, 685, 686, 5, 34, 0, 0, 686, 688, 1, 0, 0, 0, 687, 689, 5, 34, 0, 0, 688, 687, 1, 0, 0, 0, 688, 689, 1, 0, 0, 0, 689, 691, 1, 0, 0, 0, 690, 692, 5, 34, 0, 0, 691, 690, 1, 0, 0, 0, 691, 692, 1, 0, 0, 0, 692, 694, 1, 0, 0, 0, 693, 664, 1, 0, 0, 0, 693, 673, 1, 0, 0, 0, 694, 86, 1, 0, 0, 0, 695, 697, 3, 65, 25, 0, 696, 695, 1, 0, 0, 0, 697, 698, 1, 0, 0, 0, 698, 696, 1, 0, 0, 0, 698, 699, 1, 0, 0, 0, 699, 88, 1, 0, 0, 0, 700, 702, 3, 65, 25, 0, 701, 700, 1, 0, 0, 0, 702, 703, 1, 0, 0, 0, 703, 701, 1, 0, 0, 0, 703, 704, 1, 0, 0, 0, 704, 705, 1, 0, 0, 0, 705, 709, 3, 105, 45, 0, 706, 708, 3, 65, 25, 0, 707, 706, 1, 0, 0, 0, 708, 711, 1, 0, 0, 0, 709, 707, 1, 0, 0, 0, 709, 710, 1, 0, 0, 0, 710, 743, 1, 0, 0, 0, 711, 709, 1, 0, 0, 0, 712, 714, 3, 105, 45, 0, 713, 715, 3, 65, 25, 0, 714, 713, 1, 0, 0, 0, 715, 716, 1, 0, 0, 0, 716, 714, 1, 0, 0, 0, 716, 717, 1, 0, 0, 0, 717, 743, 1, 0, 0, 0, 718, 720, 3, 65, 25, 0, 719, 718, 1, 0, 0, 0, 720, 721, 1, 0, 0, 0, 721, 719, 1, 0, 0, 0, 721, 722, 1, 0, 0, 0, 722, 730, 1, 0, 0, 0, 723, 727, 3, 105, 45, 0, 724, 726, 3, 65, 25, 0, 725, 724, 1, 0, 0, 0, 726, 729, 1, 0, 0, 0, 727, 725, 1, 0, 0, 0, 727, 728, 1, 0, 0, 0, 728, 731, 1, 0, 0, 0, 729, 727, 1, 0, 0, 0, 730, 723, 1, 0, 0, 0, 730, 731, 1, 0, 0, 0, 731, 732, 1, 0, 0, 0, 732, 733, 3, 73, 29, 0, 733, 743, 1, 0, 0, 0, 734, 736, 3, 105, 45, 0, 735, 737, 3, 65, 25, 0, 736, 735, 1, 0, 0, 0, 737, 738, 1, 0, 0, 0, 738, 736, 1, 0, 0, 0, 738, 739, 1, 0, 0, 0, 739, 740, 1, 0, 0, 0, 740, 741, 3, 73, 29, 0, 741, 743, 1, 0, 0, 0, 742, 701, 1, 0, 0, 0, 742, 712, 1, 0, 0, 0, 742, 719, 1, 0, 0, 0, 742, 734, 1, 0, 0, 0, 743, 90, 1, 0, 0, 0, 744, 745, 7, 30, 0, 0, 745, 746, 7, 31, 0, 0, 746, 92, 1, 0, 0, 0, 747, 748, 7, 12, 0, 0, 748, 749, 7, 9, 0, 0, 749, 750, 7, 0, 0, 0, 750, 94, 1, 0, 0, 0, 751, 752, 7, 12, 0, 0, 752, 753, 7, 2, 0, 0, 753, 754, 7, 4, 0, 0, 754, 96, 1, 0, 0, 0, 755, 756, 5, 61, 0, 0, 756, 98, 1, 0, 0, 0, 757, 758, 5, 58, 0, 0, 758, 759, 5, 58, 0, 0, 759, 100, 1, 0, 0, 0, 760, 761, 5, 44, 0, 0, 761, 102, 1, 0, 0, 0, 762, 763, 7, 0, 0, 0, 763, 764, 7, 3, 0, 0, 764, 765, 7, 2, 0, 0, 765, 766, 7, 4, 0, 0, 766, 104, 1, 0, 0, 0, 767, 768, 5, 46, 0, 0, 768, 106, 1, 0, 0, 0, 769, 770, 7, 15, 0, 0, 770, 771, 7, 12, 0, 0, 771, 772, 7, 13, 0, 0, 772, 773, 7, 2, 0, 0, 773, 774, 7, 3, 0, 0, 774, 108, 1, 0, 0, 0, 775, 776, 7, 15, 0, 0, 776, 777, 7, 1, 0, 0, 777, 778, 7, 6, 0, 0, 778, 779, 7, 2, 0, 0, 779, 780, 7, 5, 0, 0, 780, 110, 1, 0, 0, 0, 781, 782, 7, 1, 0, 0, 782, 783, 7, 9, 0, 0, 783, 112, 1, 0, 0, 0, 784, 785, 7, 1, 0, 0, 785, 786, 7, 2, 0, 0, 786, 114, 1, 0, 0, 0, 787, 788, 7, 13, 0, 0, 788, 789, 7, 12, 0, 0, 789, 790, 7, 2, 0, 0, 790, 791, 7, 5, 0, 0, 791, 116, 1, 0, 0, 0, 792, 793, 7, 13, 0, 0, 793, 794, 7, 1, 0, 0, 794, 795, 7, 18, 0, 0, 795, 796, 7, 3, 0, 0, 796, 118, 1, 0, 0, 0, 797, 798, 5, 40, 0, 0, 798, 120, 1, 0, 0, 0, 799, 800, 7, 9, 0, 0, 800, 801, 7, 7, 0, 0, 801, 802, 7, 5, 0, 0, 802, 122, 1, 0, 0, 0, 803, 804, 7, 9, 0, 0, 804, 805, 7, 20, 0, 0, 805, 806, 7, 13, 0, 0, 806, 807, 7, 13, 0, 0, 807, 124, 1, 0, 0, 0, 808, 809, 7, 9, 0, 0, 809, 810, 7, 20, 0, 0, 810, 811, 7, 13, 0, 0, 811, 812, 7, 13, 0, 0, 812, 813, 7, 2, 0, 0, 813, 126, 1, 0, 0, 0, 814, 815, 7, 7, 0, 0, 815, 816, 7, 6, 0, 0, 816, 128, 1, 0, 0, 0, 817, 818, 5, 63, 0, 0, 818, 130, 1, 0, 0, 0, 819, 820, 7, 6, 0, 0, 820, 821, 7, 13, 0, 0, 821, 822, 7, 1, 0, 0, 822, 823, 7, 18, 0, 0, 823, 824, 7, 3, 0, 0, 824, 132, 1, 0, 0, 0, 825, 826, 5, 41, 0, 0, 826, 134, 1, 0, 0, 0, 827, 828, 7, 5, 0, 0, 828, 829, 7, 6, 0, 0, 829, 830, 7, 20, 0, 0, 830, 831, 7, 3, 0, 0, 831, 136, 1, 0, 0, 0, 832, 833, 5, 61, 0, 0, 833, 834, 5, 61, 0, 0, 834, 138, 1, 0, 0, 0, 835, 836, 5, 61, 0, 0, 836, 837, 5, 126, 0, 0, 837, 140, 1, 0, 0, 0, 838, 839, 5, 33, 0, 0, 839, 840, 5, 61, 0, 0, 840, 142, 1, 0, 0, 0, 841, 842, 5, 60, 0, 0, 842, 144, 1, 0, 0, 0, 843, 844, 5, 60, 0, 0, 844, 845, 5, 61, 0, 0, 845, 146, 1, 0, 0, 0, 846, 847, 5, 62, 0, 0, 847, 148, 1, 0, 0, 0, 848, 849, 5, 62, 0, 0, 849, 850, 5, 61, 0, 0, 850, 150, 1, 0, 0, 0, 851, 852, 5, 43, 0, 0, 852, 152, 1, 0, 0, 0, 853, 854, 5, 45, 0, 0, 854, 154, 1, 0, 0, 0, 855, 856, 5, 42, 0, 0, 856, 156, 1, 0, 0, 0, 857, 858, 5, 47, 0, 0, 858, 158, 1, 0, 0, 0, 859, 860, 5, 37, 0, 0, 860, 160, 1, 0, 0, 0, 861, 862, 4, 73, 3, 0, 862, 863, 3, 61, 23, 0, 863, 864, 1, 0, 0, 0, 864, 865, 6, 73, 12, 0, 865, 162, 1, 0, 0, 0, 866, 867, 3, 45, 15, 0, 867, 868, 1, 0, 0, 0, 868, 869, 6, 74, 13, 0, 869, 164, 1, 0, 0, 0, 870, 873, 3, 129, 57, 0, 871, 874, 3, 67, 26, 0, 872, 874, 3, 81, 33, 0, 873, 871, 1, 0, 0, 0, 873, 872, 1, 0, 0, 0, 874, 878, 1, 0, 0, 0, 875, 877, 3, 83, 34, 0, 876, 875, 1, 0, 0, 0, 877, 880, 1, 0, 0, 0, 878, 876, 1, 0, 0, 0, 878, 879, 1, 0, 0, 0, 879, 888, 1, 0, 0, 0, 880, 878, 1, 0, 0, 0, 881, 883, 3, 129, 57, 0, 882, 884, 3, 65, 25, 0, 883, 882, 1, 0, 0, 0, 884, 885, 1, 0, 0, 0, 885, 883, 1, 0, 0, 0, 885, 886, 1, 0, 0, 0, 886, 888, 1, 0, 0, 0, 887, 870, 1, 0, 0, 0, 887, 881, 1, 0, 0, 0, 888, 166, 1, 0, 0, 0, 889, 890, 5, 91, 0, 0, 890, 891, 1, 0, 0, 0, 891, 892, 6, 76, 0, 0, 892, 893, 6, 76, 0, 0, 893, 168, 1, 0, 0, 0, 894, 895, 5, 93, 0, 0, 895, 896, 1, 0, 0, 0, 896, 897, 6, 77, 11, 0, 897, 898, 6, 77, 11, 0, 898, 170, 1, 0, 0, 0, 899, 903, 3, 67, 26, 0, 900, 902, 3, 83, 34, 0, 901, 900, 1, 0, 0, 0, 902, 905, 1, 0, 0, 0, 903, 901, 1, 0, 0, 0, 903, 904, 1, 0, 0, 0, 904, 916, 1, 0, 0, 0, 905, 903, 1, 0, 0, 0, 906, 909, 3, 81, 33, 0, 907, 909, 3, 75, 30, 0, 908, 906, 1, 0, 0, 0, 908, 907, 1, 0, 0, 0, 909, 911, 1, 0, 0, 0, 910, 912, 3, 83, 34, 0, 911, 910, 1, 0, 0, 0, 912, 913, 1, 0, 0, 0, 913, 911, 1, 0, 0, 0, 913, 914, 1, 0, 0, 0, 914, 916, 1, 0, 0, 0, 915, 899, 1, 0, 0, 0, 915, 908, 1, 0, 0, 0, 916, 172, 1, 0, 0, 0, 917, 919, 3, 77, 31, 0, 918, 920, 3, 79, 32, 0, 919, 918, 1, 0, 0, 0, 920, 921, 1, 0, 0, 0, 921, 919, 1, 0, 0, 0, 921, 922, 1, 0, 0, 0, 922, 923, 1, 0, 0, 0, 923, 924, 3, 77, 31, 0, 924, 174, 1, 0, 0, 0, 925, 926, 3, 173, 79, 0, 926, 176, 1, 0, 0, 0, 927, 928, 3, 55, 20, 0, 928, 929, 1, 0, 0, 0, 929, 930, 6, 81, 10, 0, 930, 178, 1, 0, 0, 0, 931, 932, 3, 57, 21, 0, 932, 933, 1, 0, 0, 0, 933, 934, 6, 82, 10, 0, 934, 180, 1, 0, 0, 0, 935, 936, 3, 59, 22, 0, 936, 937, 1, 0, 0, 0, 937, 938, 6, 83, 10, 0, 938, 182, 1, 0, 0, 0, 939, 940, 3, 167, 76, 0, 940, 941, 1, 0, 0, 0, 941, 942, 6, 84, 14, 0, 942, 943, 6, 84, 15, 0, 943, 184, 1, 0, 0, 0, 944, 945, 3, 63, 24, 0, 945, 946, 1, 0, 0, 0, 946, 947, 6, 85, 16, 0, 947, 948, 6, 85, 11, 0, 948, 186, 1, 0, 0, 0, 949, 950, 3, 59, 22, 0, 950, 951, 1, 0, 0, 0, 951, 952, 6, 86, 10, 0, 952, 188, 1, 0, 0, 0, 953, 954, 3, 55, 20, 0, 954, 955, 1, 0, 0, 0, 955, 956, 6, 87, 10, 0, 956, 190, 1, 0, 0, 0, 957, 958, 3, 57, 21, 0, 958, 959, 1, 0, 0, 0, 959, 960, 6, 88, 10, 0, 960, 192, 1, 0, 0, 0, 961, 962, 3, 63, 24, 0, 962, 963, 1, 0, 0, 0, 963, 964, 6, 89, 16, 0, 964, 965, 6, 89, 11, 0, 965, 194, 1, 0, 0, 0, 966, 967, 3, 167, 76, 0, 967, 968, 1, 0, 0, 0, 968, 969, 6, 90, 14, 0, 969, 196, 1, 0, 0, 0, 970, 971, 3, 169, 77, 0, 971, 972, 1, 0, 0, 0, 972, 973, 6, 91, 17, 0, 973, 198, 1, 0, 0, 0, 974, 975, 3, 61, 23, 0, 975, 976, 1, 0, 0, 0, 976, 977, 6, 92, 12, 0, 977, 200, 1, 0, 0, 0, 978, 979, 3, 101, 43, 0, 979, 980, 1, 0, 0, 0, 980, 981, 6, 93, 18, 0, 981, 202, 1, 0, 0, 0, 982, 983, 3, 97, 41, 0, 983, 984, 1, 0, 0, 0, 984, 985, 6, 94, 19, 0, 985, 204, 1, 0, 0, 0, 986, 987, 7, 16, 0, 0, 987, 988, 7, 3, 0, 0, 988, 989, 7, 5, 0, 0, 989, 990, 7, 12, 0, 0, 990, 991, 7, 0, 0, 0, 991, 992, 7, 12, 0, 0, 992, 993, 7, 5, 0, 0, 993, 994, 7, 12, 0, 0, 994, 206, 1, 0, 0, 0, 995, 999, 8, 32, 0, 0, 996, 997, 5, 47, 0, 0, 997, 999, 8, 33, 0, 0, 998, 995, 1, 0, 0, 0, 998, 996, 1, 0, 0, 0, 999, 208, 1, 0, 0, 0, 1000, 1002, 3, 207, 96, 0, 1001, 1000, 1, 0, 0, 0, 1002, 1003, 1, 0, 0, 0, 1003, 1001, 1, 0, 0, 0, 1003, 1004, 1, 0, 0, 0, 1004, 210, 1, 0, 0, 0, 1005, 1006, 3, 209, 97, 0, 1006, 1007, 1, 0, 0, 0, 1007, 1008, 6, 98, 20, 0, 1008, 212, 1, 0, 0, 0, 1009, 1010, 3, 85, 35, 0, 1010, 1011, 1, 0, 0, 0, 1011, 1012, 6, 99, 21, 0, 1012, 214, 1, 0, 0, 0, 1013, 1014, 3, 55, 20, 0, 1014, 1015, 1, 0, 0, 0, 1015, 1016, 6, 100, 10, 0, 1016, 216, 1, 0, 0, 0, 1017, 1018, 3, 57, 21, 0, 1018, 1019, 1, 0, 0, 0, 1019, 1020, 6, 101, 10, 0, 1020, 218, 1, 0, 0, 0, 1021, 1022, 3, 59, 22, 0, 1022, 1023, 1, 0, 0, 0, 1023, 1024, 6, 102, 10, 0, 1024, 220, 1, 0, 0, 0, 1025, 1026, 3, 63, 24, 0, 1026, 1027, 1, 0, 0, 0, 1027, 1028, 6, 103, 16, 0, 1028, 1029, 6, 103, 11, 0, 1029, 222, 1, 0, 0, 0, 1030, 1031, 3, 105, 45, 0, 1031, 1032, 1, 0, 0, 0, 1032, 1033, 6, 104, 22, 0, 1033, 224, 1, 0, 0, 0, 1034, 1035, 3, 101, 43, 0, 1035, 1036, 1, 0, 0, 0, 1036, 1037, 6, 105, 18, 0, 1037, 226, 1, 0, 0, 0, 1038, 1039, 4, 106, 4, 0, 1039, 1040, 3, 129, 57, 0, 1040, 1041, 1, 0, 0, 0, 1041, 1042, 6, 106, 23, 0, 1042, 228, 1, 0, 0, 0, 1043, 1044, 4, 107, 5, 0, 1044, 1045, 3, 165, 75, 0, 1045, 1046, 1, 0, 0, 0, 1046, 1047, 6, 107, 24, 0, 1047, 230, 1, 0, 0, 0, 1048, 1053, 3, 67, 26, 0, 1049, 1053, 3, 65, 25, 0, 1050, 1053, 3, 81, 33, 0, 1051, 1053, 3, 155, 70, 0, 1052, 1048, 1, 0, 0, 0, 1052, 1049, 1, 0, 0, 0, 1052, 1050, 1, 0, 0, 0, 1052, 1051, 1, 0, 0, 0, 1053, 232, 1, 0, 0, 0, 1054, 1057, 3, 67, 26, 0, 1055, 1057, 3, 155, 70, 0, 1056, 1054, 1, 0, 0, 0, 1056, 1055, 1, 0, 0, 0, 1057, 1061, 1, 0, 0, 0, 1058, 1060, 3, 231, 108, 0, 1059, 1058, 1, 0, 0, 0, 1060, 1063, 1, 0, 0, 0, 1061, 1059, 1, 0, 0, 0, 1061, 1062, 1, 0, 0, 0, 1062, 1074, 1, 0, 0, 0, 1063, 1061, 1, 0, 0, 0, 1064, 1067, 3, 81, 33, 0, 1065, 1067, 3, 75, 30, 0, 1066, 1064, 1, 0, 0, 0, 1066, 1065, 1, 0, 0, 0, 1067, 1069, 1, 0, 0, 0, 1068, 1070, 3, 231, 108, 0, 1069, 1068, 1, 0, 0, 0, 1070, 1071, 1, 0, 0, 0, 1071, 1069, 1, 0, 0, 0, 1071, 1072, 1, 0, 0, 0, 1072, 1074, 1, 0, 0, 0, 1073, 1056, 1, 0, 0, 0, 1073, 1066, 1, 0, 0, 0, 1074, 234, 1, 0, 0, 0, 1075, 1078, 3, 233, 109, 0, 1076, 1078, 3, 173, 79, 0, 1077, 1075, 1, 0, 0, 0, 1077, 1076, 1, 0, 0, 0, 1078, 1079, 1, 0, 0, 0, 1079, 1077, 1, 0, 0, 0, 1079, 1080, 1, 0, 0, 0, 1080, 236, 1, 0, 0, 0, 1081, 1082, 3, 55, 20, 0, 1082, 1083, 1, 0, 0, 0, 1083, 1084, 6, 111, 10, 0, 1084, 238, 1, 0, 0, 0, 1085, 1086, 3, 57, 21, 0, 1086, 1087, 1, 0, 0, 0, 1087, 1088, 6, 112, 10, 0, 1088, 240, 1, 0, 0, 0, 1089, 1090, 3, 59, 22, 0, 1090, 1091, 1, 0, 0, 0, 1091, 1092, 6, 113, 10, 0, 1092, 242, 1, 0, 0, 0, 1093, 1094, 3, 63, 24, 0, 1094, 1095, 1, 0, 0, 0, 1095, 1096, 6, 114, 16, 0, 1096, 1097, 6, 114, 11, 0, 1097, 244, 1, 0, 0, 0, 1098, 1099, 3, 97, 41, 0, 1099, 1100, 1, 0, 0, 0, 1100, 1101, 6, 115, 19, 0, 1101, 246, 1, 0, 0, 0, 1102, 1103, 3, 101, 43, 0, 1103, 1104, 1, 0, 0, 0, 1104, 1105, 6, 116, 18, 0, 1105, 248, 1, 0, 0, 0, 1106, 1107, 3, 105, 45, 0, 1107, 1108, 1, 0, 0, 0, 1108, 1109, 6, 117, 22, 0, 1109, 250, 1, 0, 0, 0, 1110, 1111, 4, 118, 6, 0, 1111, 1112, 3, 129, 57, 0, 1112, 1113, 1, 0, 0, 0, 1113, 1114, 6, 118, 23, 0, 1114, 252, 1, 0, 0, 0, 1115, 1116, 4, 119, 7, 0, 1116, 1117, 3, 165, 75, 0, 1117, 1118, 1, 0, 0, 0, 1118, 1119, 6, 119, 24, 0, 1119, 254, 1, 0, 0, 0, 1120, 1121, 7, 12, 0, 0, 1121, 1122, 7, 2, 0, 0, 1122, 256, 1, 0, 0, 0, 1123, 1124, 3, 235, 110, 0, 1124, 1125, 1, 0, 0, 0, 1125, 1126, 6, 121, 25, 0, 1126, 258, 1, 0, 0, 0, 1127, 1128, 3, 55, 20, 0, 1128, 1129, 1, 0, 0, 0, 1129, 1130, 6, 122, 10, 0, 1130, 260, 1, 0, 0, 0, 1131, 1132, 3, 57, 21, 0, 1132, 1133, 1, 0, 0, 0, 1133, 1134, 6, 123, 10, 0, 1134, 262, 1, 0, 0, 0, 1135, 1136, 3, 59, 22, 0, 1136, 1137, 1, 0, 0, 0, 1137, 1138, 6, 124, 10, 0, 1138, 264, 1, 0, 0, 0, 1139, 1140, 3, 63, 24, 0, 1140, 1141, 1, 0, 0, 0, 1141, 1142, 6, 125, 16, 0, 1142, 1143, 6, 125, 11, 0, 1143, 266, 1, 0, 0, 0, 1144, 1145, 3, 167, 76, 0, 1145, 1146, 1, 0, 0, 0, 1146, 1147, 6, 126, 14, 0, 1147, 1148, 6, 126, 26, 0, 1148, 268, 1, 0, 0, 0, 1149, 1150, 7, 7, 0, 0, 1150, 1151, 7, 9, 0, 0, 1151, 1152, 1, 0, 0, 0, 1152, 1153, 6, 127, 27, 0, 1153, 270, 1, 0, 0, 0, 1154, 1155, 7, 19, 0, 0, 1155, 1156, 7, 1, 0, 0, 1156, 1157, 7, 5, 0, 0, 1157, 1158, 7, 10, 0, 0, 1158, 1159, 1, 0, 0, 0, 1159, 1160, 6, 128, 27, 0, 1160, 272, 1, 0, 0, 0, 1161, 1162, 8, 34, 0, 0, 1162, 274, 1, 0, 0, 0, 1163, 1165, 3, 273, 129, 0, 1164, 1163, 1, 0, 0, 0, 1165, 1166, 1, 0, 0, 0, 1166, 1164, 1, 0, 0, 0, 1166, 1167, 1, 0, 0, 0, 1167, 1168, 1, 0, 0, 0, 1168, 1169, 3, 61, 23, 0, 1169, 1171, 1, 0, 0, 0, 1170, 1164, 1, 0, 0, 0, 1170, 1171, 1, 0, 0, 0, 1171, 1173, 1, 0, 0, 0, 1172, 1174, 3, 273, 129, 0, 1173, 1172, 1, 0, 0, 0, 1174, 1175, 1, 0, 0, 0, 1175, 1173, 1, 0, 0, 0, 1175, 1176, 1, 0, 0, 0, 1176, 276, 1, 0, 0, 0, 1177, 1178, 3, 275, 130, 0, 1178, 1179, 1, 0, 0, 0, 1179, 1180, 6, 131, 28, 0, 1180, 278, 1, 0, 0, 0, 1181, 1182, 3, 55, 20, 0, 1182, 1183, 1, 0, 0, 0, 1183, 1184, 6, 132, 10, 0, 1184, 280, 1, 0, 0, 0, 1185, 1186, 3, 57, 21, 0, 1186, 1187, 1, 0, 0, 0, 1187, 1188, 6, 133, 10, 0, 1188, 282, 1, 0, 0, 0, 1189, 1190, 3, 59, 22, 0, 1190, 1191, 1, 0, 0, 0, 1191, 1192, 6, 134, 10, 0, 1192, 284, 1, 0, 0, 0, 1193, 1194, 3, 63, 24, 0, 1194, 1195, 1, 0, 0, 0, 1195, 1196, 6, 135, 16, 0, 1196, 1197, 6, 135, 11, 0, 1197, 1198, 6, 135, 11, 0, 1198, 286, 1, 0, 0, 0, 1199, 1200, 3, 97, 41, 0, 1200, 1201, 1, 0, 0, 0, 1201, 1202, 6, 136, 19, 0, 1202, 288, 1, 0, 0, 0, 1203, 1204, 3, 101, 43, 0, 1204, 1205, 1, 0, 0, 0, 1205, 1206, 6, 137, 18, 0, 1206, 290, 1, 0, 0, 0, 1207, 1208, 3, 105, 45, 0, 1208, 1209, 1, 0, 0, 0, 1209, 1210, 6, 138, 22, 0, 1210, 292, 1, 0, 0, 0, 1211, 1212, 3, 271, 128, 0, 1212, 1213, 1, 0, 0, 0, 1213, 1214, 6, 139, 29, 0, 1214, 294, 1, 0, 0, 0, 1215, 1216, 3, 235, 110, 0, 1216, 1217, 1, 0, 0, 0, 1217, 1218, 6, 140, 25, 0, 1218, 296, 1, 0, 0, 0, 1219, 1220, 3, 175, 80, 0, 1220, 1221, 1, 0, 0, 0, 1221, 1222, 6, 141, 30, 0, 1222, 298, 1, 0, 0, 0, 1223, 1224, 4, 142, 8, 0, 1224, 1225, 3, 129, 57, 0, 1225, 1226, 1, 0, 0, 0, 1226, 1227, 6, 142, 23, 0, 1227, 300, 1, 0, 0, 0, 1228, 1229, 4, 143, 9, 0, 1229, 1230, 3, 165, 75, 0, 1230, 1231, 1, 0, 0, 0, 1231, 1232, 6, 143, 24, 0, 1232, 302, 1, 0, 0, 0, 1233, 1234, 3, 55, 20, 0, 1234, 1235, 1, 0, 0, 0, 1235, 1236, 6, 144, 10, 0, 1236, 304, 1, 0, 0, 0, 1237, 1238, 3, 57, 21, 0, 1238, 1239, 1, 0, 0, 0, 1239, 1240, 6, 145, 10, 0, 1240, 306, 1, 0, 0, 0, 1241, 1242, 3, 59, 22, 0, 1242, 1243, 1, 0, 0, 0, 1243, 1244, 6, 146, 10, 0, 1244, 308, 1, 0, 0, 0, 1245, 1246, 3, 63, 24, 0, 1246, 1247, 1, 0, 0, 0, 1247, 1248, 6, 147, 16, 0, 1248, 1249, 6, 147, 11, 0, 1249, 310, 1, 0, 0, 0, 1250, 1251, 3, 105, 45, 0, 1251, 1252, 1, 0, 0, 0, 1252, 1253, 6, 148, 22, 0, 1253, 312, 1, 0, 0, 0, 1254, 1255, 4, 149, 10, 0, 1255, 1256, 3, 129, 57, 0, 1256, 1257, 1, 0, 0, 0, 1257, 1258, 6, 149, 23, 0, 1258, 314, 1, 0, 0, 0, 1259, 1260, 4, 150, 11, 0, 1260, 1261, 3, 165, 75, 0, 1261, 1262, 1, 0, 0, 0, 1262, 1263, 6, 150, 24, 0, 1263, 316, 1, 0, 0, 0, 1264, 1265, 3, 175, 80, 0, 1265, 1266, 1, 0, 0, 0, 1266, 1267, 6, 151, 30, 0, 1267, 318, 1, 0, 0, 0, 1268, 1269, 3, 171, 78, 0, 1269, 1270, 1, 0, 0, 0, 1270, 1271, 6, 152, 31, 0, 1271, 320, 1, 0, 0, 0, 1272, 1273, 3, 55, 20, 0, 1273, 1274, 1, 0, 0, 0, 1274, 1275, 6, 153, 10, 0, 1275, 322, 1, 0, 0, 0, 1276, 1277, 3, 57, 21, 0, 1277, 1278, 1, 0, 0, 0, 1278, 1279, 6, 154, 10, 0, 1279, 324, 1, 0, 0, 0, 1280, 1281, 3, 59, 22, 0, 1281, 1282, 1, 0, 0, 0, 1282, 1283, 6, 155, 10, 0, 1283, 326, 1, 0, 0, 0, 1284, 1285, 3, 63, 24, 0, 1285, 1286, 1, 0, 0, 0, 1286, 1287, 6, 156, 16, 0, 1287, 1288, 6, 156, 11, 0, 1288, 328, 1, 0, 0, 0, 1289, 1290, 7, 1, 0, 0, 1290, 1291, 7, 9, 0, 0, 1291, 1292, 7, 15, 0, 0, 1292, 1293, 7, 7, 0, 0, 1293, 330, 1, 0, 0, 0, 1294, 1295, 3, 55, 20, 0, 1295, 1296, 1, 0, 0, 0, 1296, 1297, 6, 158, 10, 0, 1297, 332, 1, 0, 0, 0, 1298, 1299, 3, 57, 21, 0, 1299, 1300, 1, 0, 0, 0, 1300, 1301, 6, 159, 10, 0, 1301, 334, 1, 0, 0, 0, 1302, 1303, 3, 59, 22, 0, 1303, 1304, 1, 0, 0, 0, 1304, 1305, 6, 160, 10, 0, 1305, 336, 1, 0, 0, 0, 1306, 1307, 3, 169, 77, 0, 1307, 1308, 1, 0, 0, 0, 1308, 1309, 6, 161, 17, 0, 1309, 1310, 6, 161, 11, 0, 1310, 338, 1, 0, 0, 0, 1311, 1312, 3, 61, 23, 0, 1312, 1313, 1, 0, 0, 0, 1313, 1314, 6, 162, 12, 0, 1314, 340, 1, 0, 0, 0, 1315, 1321, 3, 75, 30, 0, 1316, 1321, 3, 65, 25, 0, 1317, 1321, 3, 105, 45, 0, 1318, 1321, 3, 67, 26, 0, 1319, 1321, 3, 81, 33, 0, 1320, 1315, 1, 0, 0, 0, 1320, 1316, 1, 0, 0, 0, 1320, 1317, 1, 0, 0, 0, 1320, 1318, 1, 0, 0, 0, 1320, 1319, 1, 0, 0, 0, 1321, 1322, 1, 0, 0, 0, 1322, 1320, 1, 0, 0, 0, 1322, 1323, 1, 0, 0, 0, 1323, 342, 1, 0, 0, 0, 1324, 1325, 3, 55, 20, 0, 1325, 1326, 1, 0, 0, 0, 1326, 1327, 6, 164, 10, 0, 1327, 344, 1, 0, 0, 0, 1328, 1329, 3, 57, 21, 0, 1329, 1330, 1, 0, 0, 0, 1330, 1331, 6, 165, 10, 0, 1331, 346, 1, 0, 0, 0, 1332, 1333, 3, 59, 22, 0, 1333, 1334, 1, 0, 0, 0, 1334, 1335, 6, 166, 10, 0, 1335, 348, 1, 0, 0, 0, 1336, 1337, 3, 63, 24, 0, 1337, 1338, 1, 0, 0, 0, 1338, 1339, 6, 167, 16, 0, 1339, 1340, 6, 167, 11, 0, 1340, 350, 1, 0, 0, 0, 1341, 1342, 3, 61, 23, 0, 1342, 1343, 1, 0, 0, 0, 1343, 1344, 6, 168, 12, 0, 1344, 352, 1, 0, 0, 0, 1345, 1346, 3, 101, 43, 0, 1346, 1347, 1, 0, 0, 0, 1347, 1348, 6, 169, 18, 0, 1348, 354, 1, 0, 0, 0, 1349, 1350, 3, 105, 45, 0, 1350, 1351, 1, 0, 0, 0, 1351, 1352, 6, 170, 22, 0, 1352, 356, 1, 0, 0, 0, 1353, 1354, 3, 269, 127, 0, 1354, 1355, 1, 0, 0, 0, 1355, 1356, 6, 171, 32, 0, 1356, 1357, 6, 171, 33, 0, 1357, 358, 1, 0, 0, 0, 1358, 1359, 3, 209, 97, 0, 1359, 1360, 1, 0, 0, 0, 1360, 1361, 6, 172, 20, 0, 1361, 360, 1, 0, 0, 0, 1362, 1363, 3, 85, 35, 0, 1363, 1364, 1, 0, 0, 0, 1364, 1365, 6, 173, 21, 0, 1365, 362, 1, 0, 0, 0, 1366, 1367, 3, 55, 20, 0, 1367, 1368, 1, 0, 0, 0, 1368, 1369, 6, 174, 10, 0, 1369, 364, 1, 0, 0, 0, 1370, 1371, 3, 57, 21, 0, 1371, 1372, 1, 0, 0, 0, 1372, 1373, 6, 175, 10, 0, 1373, 366, 1, 0, 0, 0, 1374, 1375, 3, 59, 22, 0, 1375, 1376, 1, 0, 0, 0, 1376, 1377, 6, 176, 10, 0, 1377, 368, 1, 0, 0, 0, 1378, 1379, 3, 63, 24, 0, 1379, 1380, 1, 0, 0, 0, 1380, 1381, 6, 177, 16, 0, 1381, 1382, 6, 177, 11, 0, 1382, 1383, 6, 177, 11, 0, 1383, 370, 1, 0, 0, 0, 1384, 1385, 3, 101, 43, 0, 1385, 1386, 1, 0, 0, 0, 1386, 1387, 6, 178, 18, 0, 1387, 372, 1, 0, 0, 0, 1388, 1389, 3, 105, 45, 0, 1389, 1390, 1, 0, 0, 0, 1390, 1391, 6, 179, 22, 0, 1391, 374, 1, 0, 0, 0, 1392, 1393, 3, 235, 110, 0, 1393, 1394, 1, 0, 0, 0, 1394, 1395, 6, 180, 25, 0, 1395, 376, 1, 0, 0, 0, 1396, 1397, 3, 55, 20, 0, 1397, 1398, 1, 0, 0, 0, 1398, 1399, 6, 181, 10, 0, 1399, 378, 1, 0, 0, 0, 1400, 1401, 3, 57, 21, 0, 1401, 1402, 1, 0, 0, 0, 1402, 1403, 6, 182, 10, 0, 1403, 380, 1, 0, 0, 0, 1404, 1405, 3, 59, 22, 0, 1405, 1406, 1, 0, 0, 0, 1406, 1407, 6, 183, 10, 0, 1407, 382, 1, 0, 0, 0, 1408, 1409, 3, 63, 24, 0, 1409, 1410, 1, 0, 0, 0, 1410, 1411, 6, 184, 16, 0, 1411, 1412, 6, 184, 11, 0, 1412, 384, 1, 0, 0, 0, 1413, 1414, 3, 209, 97, 0, 1414, 1415, 1, 0, 0, 0, 1415, 1416, 6, 185, 20, 0, 1416, 1417, 6, 185, 11, 0, 1417, 1418, 6, 185, 34, 0, 1418, 386, 1, 0, 0, 0, 1419, 1420, 3, 85, 35, 0, 1420, 1421, 1, 0, 0, 0, 1421, 1422, 6, 186, 21, 0, 1422, 1423, 6, 186, 11, 0, 1423, 1424, 6, 186, 34, 0, 1424, 388, 1, 0, 0, 0, 1425, 1426, 3, 55, 20, 0, 1426, 1427, 1, 0, 0, 0, 1427, 1428, 6, 187, 10, 0, 1428, 390, 1, 0, 0, 0, 1429, 1430, 3, 57, 21, 0, 1430, 1431, 1, 0, 0, 0, 1431, 1432, 6, 188, 10, 0, 1432, 392, 1, 0, 0, 0, 1433, 1434, 3, 59, 22, 0, 1434, 1435, 1, 0, 0, 0, 1435, 1436, 6, 189, 10, 0, 1436, 394, 1, 0, 0, 0, 1437, 1438, 3, 61, 23, 0, 1438, 1439, 1, 0, 0, 0, 1439, 1440, 6, 190, 12, 0, 1440, 1441, 6, 190, 11, 0, 1441, 1442, 6, 190, 9, 0, 1442, 396, 1, 0, 0, 0, 1443, 1444, 3, 101, 43, 0, 1444, 1445, 1, 0, 0, 0, 1445, 1446, 6, 191, 18, 0, 1446, 1447, 6, 191, 11, 0, 1447, 1448, 6, 191, 9, 0, 1448, 398, 1, 0, 0, 0, 1449, 1450, 3, 55, 20, 0, 1450, 1451, 1, 0, 0, 0, 1451, 1452, 6, 192, 10, 0, 1452, 400, 1, 0, 0, 0, 1453, 1454, 3, 57, 21, 0, 1454, 1455, 1, 0, 0, 0, 1455, 1456, 6, 193, 10, 0, 1456, 402, 1, 0, 0, 0, 1457, 1458, 3, 59, 22, 0, 1458, 1459, 1, 0, 0, 0, 1459, 1460, 6, 194, 10, 0, 1460, 404, 1, 0, 0, 0, 1461, 1462, 3, 175, 80, 0, 1462, 1463, 1, 0, 0, 0, 1463, 1464, 6, 195, 11, 0, 1464, 1465, 6, 195, 0, 0, 1465, 1466, 6, 195, 30, 0, 1466, 406, 1, 0, 0, 0, 1467, 1468, 3, 171, 78, 0, 1468, 1469, 1, 0, 0, 0, 1469, 1470, 6, 196, 11, 0, 1470, 1471, 6, 196, 0, 0, 1471, 1472, 6, 196, 31, 0, 1472, 408, 1, 0, 0, 0, 1473, 1474, 3, 91, 38, 0, 1474, 1475, 1, 0, 0, 0, 1475, 1476, 6, 197, 11, 0, 1476, 1477, 6, 197, 0, 0, 1477, 1478, 6, 197, 35, 0, 1478, 410, 1, 0, 0, 0, 1479, 1480, 3, 63, 24, 0, 1480, 1481, 1, 0, 0, 0, 1481, 1482, 6, 198, 16, 0, 1482, 1483, 6, 198, 11, 0, 1483, 412, 1, 0, 0, 0, 65, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 581, 591, 595, 598, 607, 609, 620, 641, 646, 655, 662, 667, 669, 680, 688, 691, 693, 698, 703, 709, 716, 721, 727, 730, 738, 742, 873, 878, 885, 887, 903, 908, 913, 915, 921, 998, 1003, 1052, 1056, 1061, 1066, 1071, 1073, 1077, 1079, 1166, 1170, 1175, 1320, 1322, 36, 5, 1, 0, 5, 4, 0, 5, 6, 0, 5, 2, 0, 5, 3, 0, 5, 8, 0, 5, 5, 0, 5, 9, 0, 5, 11, 0, 5, 13, 0, 0, 1, 0, 4, 0, 0, 7, 24, 0, 7, 16, 0, 7, 65, 0, 5, 0, 0, 7, 25, 0, 7, 66, 0, 7, 34, 0, 7, 32, 0, 7, 76, 0, 7, 26, 0, 7, 36, 0, 7, 48, 0, 7, 64, 0, 7, 80, 0, 5, 10, 0, 5, 7, 0, 7, 90, 0, 7, 89, 0, 7, 68, 0, 7, 67, 0, 7, 88, 0, 5, 12, 0, 5, 14, 0, 7, 29, 0] \ No newline at end of file diff --git a/packages/kbn-esql-ast/src/antlr/esql_lexer.tokens b/packages/kbn-esql-ast/src/antlr/esql_lexer.tokens index 4d1f426289149..3dd1a2c754038 100644 --- a/packages/kbn-esql-ast/src/antlr/esql_lexer.tokens +++ b/packages/kbn-esql-ast/src/antlr/esql_lexer.tokens @@ -21,46 +21,46 @@ UNKNOWN_CMD=20 LINE_COMMENT=21 MULTILINE_COMMENT=22 WS=23 -PIPE=24 -QUOTED_STRING=25 -INTEGER_LITERAL=26 -DECIMAL_LITERAL=27 -BY=28 -AND=29 -ASC=30 -ASSIGN=31 -CAST_OP=32 -COMMA=33 -DESC=34 -DOT=35 -FALSE=36 -FIRST=37 -IN=38 -IS=39 -LAST=40 -LIKE=41 -LP=42 -NOT=43 -NULL=44 -NULLS=45 -OR=46 -PARAM=47 -RLIKE=48 -RP=49 -TRUE=50 -EQ=51 -CIEQ=52 -NEQ=53 -LT=54 -LTE=55 -GT=56 -GTE=57 -PLUS=58 -MINUS=59 -ASTERISK=60 -SLASH=61 -PERCENT=62 -MATCH=63 +COLON=24 +PIPE=25 +QUOTED_STRING=26 +INTEGER_LITERAL=27 +DECIMAL_LITERAL=28 +BY=29 +AND=30 +ASC=31 +ASSIGN=32 +CAST_OP=33 +COMMA=34 +DESC=35 +DOT=36 +FALSE=37 +FIRST=38 +IN=39 +IS=40 +LAST=41 +LIKE=42 +LP=43 +NOT=44 +NULL=45 +NULLS=46 +OR=47 +PARAM=48 +RLIKE=49 +RP=50 +TRUE=51 +EQ=52 +CIEQ=53 +NEQ=54 +LT=55 +LTE=56 +GT=57 +GTE=58 +PLUS=59 +MINUS=60 +ASTERISK=61 +SLASH=62 +PERCENT=63 NAMED_OR_POSITIONAL_PARAM=64 OPENING_BRACKET=65 CLOSING_BRACKET=66 @@ -101,23 +101,22 @@ INFO=100 SHOW_LINE_COMMENT=101 SHOW_MULTILINE_COMMENT=102 SHOW_WS=103 -COLON=104 -SETTING=105 -SETTING_LINE_COMMENT=106 -SETTTING_MULTILINE_COMMENT=107 -SETTING_WS=108 -LOOKUP_LINE_COMMENT=109 -LOOKUP_MULTILINE_COMMENT=110 -LOOKUP_WS=111 -LOOKUP_FIELD_LINE_COMMENT=112 -LOOKUP_FIELD_MULTILINE_COMMENT=113 -LOOKUP_FIELD_WS=114 -METRICS_LINE_COMMENT=115 -METRICS_MULTILINE_COMMENT=116 -METRICS_WS=117 -CLOSING_METRICS_LINE_COMMENT=118 -CLOSING_METRICS_MULTILINE_COMMENT=119 -CLOSING_METRICS_WS=120 +SETTING=104 +SETTING_LINE_COMMENT=105 +SETTTING_MULTILINE_COMMENT=106 +SETTING_WS=107 +LOOKUP_LINE_COMMENT=108 +LOOKUP_MULTILINE_COMMENT=109 +LOOKUP_WS=110 +LOOKUP_FIELD_LINE_COMMENT=111 +LOOKUP_FIELD_MULTILINE_COMMENT=112 +LOOKUP_FIELD_WS=113 +METRICS_LINE_COMMENT=114 +METRICS_MULTILINE_COMMENT=115 +METRICS_WS=116 +CLOSING_METRICS_LINE_COMMENT=117 +CLOSING_METRICS_MULTILINE_COMMENT=118 +CLOSING_METRICS_WS=119 'dissect'=1 'drop'=2 'enrich'=3 @@ -134,47 +133,46 @@ CLOSING_METRICS_WS=120 'sort'=14 'stats'=15 'where'=16 -'|'=24 -'by'=28 -'and'=29 -'asc'=30 -'='=31 -'::'=32 -','=33 -'desc'=34 -'.'=35 -'false'=36 -'first'=37 -'in'=38 -'is'=39 -'last'=40 -'like'=41 -'('=42 -'not'=43 -'null'=44 -'nulls'=45 -'or'=46 -'?'=47 -'rlike'=48 -')'=49 -'true'=50 -'=='=51 -'=~'=52 -'!='=53 -'<'=54 -'<='=55 -'>'=56 -'>='=57 -'+'=58 -'-'=59 -'*'=60 -'/'=61 -'%'=62 -'match'=63 +':'=24 +'|'=25 +'by'=29 +'and'=30 +'asc'=31 +'='=32 +'::'=33 +','=34 +'desc'=35 +'.'=36 +'false'=37 +'first'=38 +'in'=39 +'is'=40 +'last'=41 +'like'=42 +'('=43 +'not'=44 +'null'=45 +'nulls'=46 +'or'=47 +'?'=48 +'rlike'=49 +')'=50 +'true'=51 +'=='=52 +'=~'=53 +'!='=54 +'<'=55 +'<='=56 +'>'=57 +'>='=58 +'+'=59 +'-'=60 +'*'=61 +'/'=62 +'%'=63 ']'=66 'metadata'=75 'as'=84 'on'=88 'with'=89 'info'=100 -':'=104 diff --git a/packages/kbn-esql-ast/src/antlr/esql_lexer.ts b/packages/kbn-esql-ast/src/antlr/esql_lexer.ts index 589148bf08c7c..54546fef85904 100644 --- a/packages/kbn-esql-ast/src/antlr/esql_lexer.ts +++ b/packages/kbn-esql-ast/src/antlr/esql_lexer.ts @@ -46,46 +46,46 @@ export default class esql_lexer extends lexer_config { public static readonly LINE_COMMENT = 21; public static readonly MULTILINE_COMMENT = 22; public static readonly WS = 23; - public static readonly PIPE = 24; - public static readonly QUOTED_STRING = 25; - public static readonly INTEGER_LITERAL = 26; - public static readonly DECIMAL_LITERAL = 27; - public static readonly BY = 28; - public static readonly AND = 29; - public static readonly ASC = 30; - public static readonly ASSIGN = 31; - public static readonly CAST_OP = 32; - public static readonly COMMA = 33; - public static readonly DESC = 34; - public static readonly DOT = 35; - public static readonly FALSE = 36; - public static readonly FIRST = 37; - public static readonly IN = 38; - public static readonly IS = 39; - public static readonly LAST = 40; - public static readonly LIKE = 41; - public static readonly LP = 42; - public static readonly NOT = 43; - public static readonly NULL = 44; - public static readonly NULLS = 45; - public static readonly OR = 46; - public static readonly PARAM = 47; - public static readonly RLIKE = 48; - public static readonly RP = 49; - public static readonly TRUE = 50; - public static readonly EQ = 51; - public static readonly CIEQ = 52; - public static readonly NEQ = 53; - public static readonly LT = 54; - public static readonly LTE = 55; - public static readonly GT = 56; - public static readonly GTE = 57; - public static readonly PLUS = 58; - public static readonly MINUS = 59; - public static readonly ASTERISK = 60; - public static readonly SLASH = 61; - public static readonly PERCENT = 62; - public static readonly MATCH = 63; + public static readonly COLON = 24; + public static readonly PIPE = 25; + public static readonly QUOTED_STRING = 26; + public static readonly INTEGER_LITERAL = 27; + public static readonly DECIMAL_LITERAL = 28; + public static readonly BY = 29; + public static readonly AND = 30; + public static readonly ASC = 31; + public static readonly ASSIGN = 32; + public static readonly CAST_OP = 33; + public static readonly COMMA = 34; + public static readonly DESC = 35; + public static readonly DOT = 36; + public static readonly FALSE = 37; + public static readonly FIRST = 38; + public static readonly IN = 39; + public static readonly IS = 40; + public static readonly LAST = 41; + public static readonly LIKE = 42; + public static readonly LP = 43; + public static readonly NOT = 44; + public static readonly NULL = 45; + public static readonly NULLS = 46; + public static readonly OR = 47; + public static readonly PARAM = 48; + public static readonly RLIKE = 49; + public static readonly RP = 50; + public static readonly TRUE = 51; + public static readonly EQ = 52; + public static readonly CIEQ = 53; + public static readonly NEQ = 54; + public static readonly LT = 55; + public static readonly LTE = 56; + public static readonly GT = 57; + public static readonly GTE = 58; + public static readonly PLUS = 59; + public static readonly MINUS = 60; + public static readonly ASTERISK = 61; + public static readonly SLASH = 62; + public static readonly PERCENT = 63; public static readonly NAMED_OR_POSITIONAL_PARAM = 64; public static readonly OPENING_BRACKET = 65; public static readonly CLOSING_BRACKET = 66; @@ -126,23 +126,22 @@ export default class esql_lexer extends lexer_config { public static readonly SHOW_LINE_COMMENT = 101; public static readonly SHOW_MULTILINE_COMMENT = 102; public static readonly SHOW_WS = 103; - public static readonly COLON = 104; - public static readonly SETTING = 105; - public static readonly SETTING_LINE_COMMENT = 106; - public static readonly SETTTING_MULTILINE_COMMENT = 107; - public static readonly SETTING_WS = 108; - public static readonly LOOKUP_LINE_COMMENT = 109; - public static readonly LOOKUP_MULTILINE_COMMENT = 110; - public static readonly LOOKUP_WS = 111; - public static readonly LOOKUP_FIELD_LINE_COMMENT = 112; - public static readonly LOOKUP_FIELD_MULTILINE_COMMENT = 113; - public static readonly LOOKUP_FIELD_WS = 114; - public static readonly METRICS_LINE_COMMENT = 115; - public static readonly METRICS_MULTILINE_COMMENT = 116; - public static readonly METRICS_WS = 117; - public static readonly CLOSING_METRICS_LINE_COMMENT = 118; - public static readonly CLOSING_METRICS_MULTILINE_COMMENT = 119; - public static readonly CLOSING_METRICS_WS = 120; + public static readonly SETTING = 104; + public static readonly SETTING_LINE_COMMENT = 105; + public static readonly SETTTING_MULTILINE_COMMENT = 106; + public static readonly SETTING_WS = 107; + public static readonly LOOKUP_LINE_COMMENT = 108; + public static readonly LOOKUP_MULTILINE_COMMENT = 109; + public static readonly LOOKUP_WS = 110; + public static readonly LOOKUP_FIELD_LINE_COMMENT = 111; + public static readonly LOOKUP_FIELD_MULTILINE_COMMENT = 112; + public static readonly LOOKUP_FIELD_WS = 113; + public static readonly METRICS_LINE_COMMENT = 114; + public static readonly METRICS_MULTILINE_COMMENT = 115; + public static readonly METRICS_WS = 116; + public static readonly CLOSING_METRICS_LINE_COMMENT = 117; + public static readonly CLOSING_METRICS_MULTILINE_COMMENT = 118; + public static readonly CLOSING_METRICS_WS = 119; public static readonly EOF = Token.EOF; public static readonly EXPRESSION_MODE = 1; public static readonly EXPLAIN_MODE = 2; @@ -173,26 +172,26 @@ export default class esql_lexer extends lexer_config { null, null, null, null, null, null, - "'|'", null, + "':'", "'|'", null, null, - "'by'", "'and'", - "'asc'", "'='", - "'::'", "','", - "'desc'", "'.'", - "'false'", "'first'", - "'in'", "'is'", - "'last'", "'like'", - "'('", "'not'", - "'null'", "'nulls'", - "'or'", "'?'", - "'rlike'", "')'", - "'true'", "'=='", - "'=~'", "'!='", - "'<'", "'<='", - "'>'", "'>='", - "'+'", "'-'", - "'*'", "'/'", - "'%'", "'match'", + null, "'by'", + "'and'", "'asc'", + "'='", "'::'", + "','", "'desc'", + "'.'", "'false'", + "'first'", "'in'", + "'is'", "'last'", + "'like'", "'('", + "'not'", "'null'", + "'nulls'", "'or'", + "'?'", "'rlike'", + "')'", "'true'", + "'=='", "'=~'", + "'!='", "'<'", + "'<='", "'>'", + "'>='", "'+'", + "'-'", "'*'", + "'/'", "'%'", null, null, "']'", null, null, null, @@ -211,9 +210,7 @@ export default class esql_lexer extends lexer_config { null, null, null, null, null, null, - "'info'", null, - null, null, - "':'" ]; + "'info'" ]; public static readonly symbolicNames: (string | null)[] = [ null, "DISSECT", "DROP", "ENRICH", "EVAL", "EXPLAIN", @@ -229,8 +226,8 @@ export default class esql_lexer extends lexer_config { "UNKNOWN_CMD", "LINE_COMMENT", "MULTILINE_COMMENT", - "WS", "PIPE", - "QUOTED_STRING", + "WS", "COLON", + "PIPE", "QUOTED_STRING", "INTEGER_LITERAL", "DECIMAL_LITERAL", "BY", "AND", @@ -251,7 +248,7 @@ export default class esql_lexer extends lexer_config { "GTE", "PLUS", "MINUS", "ASTERISK", "SLASH", "PERCENT", - "MATCH", "NAMED_OR_POSITIONAL_PARAM", + "NAMED_OR_POSITIONAL_PARAM", "OPENING_BRACKET", "CLOSING_BRACKET", "UNQUOTED_IDENTIFIER", @@ -288,7 +285,7 @@ export default class esql_lexer extends lexer_config { "INFO", "SHOW_LINE_COMMENT", "SHOW_MULTILINE_COMMENT", "SHOW_WS", - "COLON", "SETTING", + "SETTING", "SETTING_LINE_COMMENT", "SETTTING_MULTILINE_COMMENT", "SETTING_WS", @@ -317,13 +314,13 @@ export default class esql_lexer extends lexer_config { "DISSECT", "DROP", "ENRICH", "EVAL", "EXPLAIN", "FROM", "GROK", "KEEP", "LIMIT", "MV_EXPAND", "RENAME", "ROW", "SHOW", "SORT", "STATS", "WHERE", "DEV_INLINESTATS", "DEV_LOOKUP", "DEV_METRICS", "UNKNOWN_CMD", "LINE_COMMENT", - "MULTILINE_COMMENT", "WS", "PIPE", "DIGIT", "LETTER", "ESCAPE_SEQUENCE", + "MULTILINE_COMMENT", "WS", "COLON", "PIPE", "DIGIT", "LETTER", "ESCAPE_SEQUENCE", "UNESCAPED_CHARS", "EXPONENT", "ASPERAND", "BACKQUOTE", "BACKQUOTE_BLOCK", "UNDERSCORE", "UNQUOTED_ID_BODY", "QUOTED_STRING", "INTEGER_LITERAL", "DECIMAL_LITERAL", "BY", "AND", "ASC", "ASSIGN", "CAST_OP", "COMMA", "DESC", "DOT", "FALSE", "FIRST", "IN", "IS", "LAST", "LIKE", "LP", "NOT", "NULL", "NULLS", "OR", "PARAM", "RLIKE", "RP", "TRUE", "EQ", "CIEQ", "NEQ", "LT", - "LTE", "GT", "GTE", "PLUS", "MINUS", "ASTERISK", "SLASH", "PERCENT", "MATCH", + "LTE", "GT", "GTE", "PLUS", "MINUS", "ASTERISK", "SLASH", "PERCENT", "EXPRESSION_COLON", "NESTED_WHERE", "NAMED_OR_POSITIONAL_PARAM", "OPENING_BRACKET", "CLOSING_BRACKET", "UNQUOTED_IDENTIFIER", "QUOTED_ID", "QUOTED_IDENTIFIER", "EXPR_LINE_COMMENT", "EXPR_MULTILINE_COMMENT", "EXPR_WS", "EXPLAIN_OPENING_BRACKET", "EXPLAIN_PIPE", @@ -346,7 +343,7 @@ export default class esql_lexer extends lexer_config { "MVEXPAND_DOT", "MVEXPAND_PARAM", "MVEXPAND_NAMED_OR_POSITIONAL_PARAM", "MVEXPAND_QUOTED_IDENTIFIER", "MVEXPAND_UNQUOTED_IDENTIFIER", "MVEXPAND_LINE_COMMENT", "MVEXPAND_MULTILINE_COMMENT", "MVEXPAND_WS", "SHOW_PIPE", "INFO", "SHOW_LINE_COMMENT", - "SHOW_MULTILINE_COMMENT", "SHOW_WS", "SETTING_CLOSING_BRACKET", "COLON", + "SHOW_MULTILINE_COMMENT", "SHOW_WS", "SETTING_CLOSING_BRACKET", "SETTING_COLON", "SETTING", "SETTING_LINE_COMMENT", "SETTTING_MULTILINE_COMMENT", "SETTING_WS", "LOOKUP_PIPE", "LOOKUP_COLON", "LOOKUP_COMMA", "LOOKUP_DOT", "LOOKUP_ON", "LOOKUP_UNQUOTED_SOURCE", "LOOKUP_QUOTED_SOURCE", "LOOKUP_LINE_COMMENT", @@ -386,21 +383,23 @@ export default class esql_lexer extends lexer_config { return this.DEV_LOOKUP_sempred(localctx, predIndex); case 18: return this.DEV_METRICS_sempred(localctx, predIndex); - case 105: - return this.PROJECT_PARAM_sempred(localctx, predIndex); + case 73: + return this.EXPRESSION_COLON_sempred(localctx, predIndex); case 106: + return this.PROJECT_PARAM_sempred(localctx, predIndex); + case 107: return this.PROJECT_NAMED_OR_POSITIONAL_PARAM_sempred(localctx, predIndex); - case 117: - return this.RENAME_PARAM_sempred(localctx, predIndex); case 118: + return this.RENAME_PARAM_sempred(localctx, predIndex); + case 119: return this.RENAME_NAMED_OR_POSITIONAL_PARAM_sempred(localctx, predIndex); - case 141: - return this.ENRICH_FIELD_PARAM_sempred(localctx, predIndex); case 142: + return this.ENRICH_FIELD_PARAM_sempred(localctx, predIndex); + case 143: return this.ENRICH_FIELD_NAMED_OR_POSITIONAL_PARAM_sempred(localctx, predIndex); - case 148: - return this.MVEXPAND_PARAM_sempred(localctx, predIndex); case 149: + return this.MVEXPAND_PARAM_sempred(localctx, predIndex); + case 150: return this.MVEXPAND_NAMED_OR_POSITIONAL_PARAM_sempred(localctx, predIndex); } return true; @@ -426,64 +425,71 @@ export default class esql_lexer extends lexer_config { } return true; } - private PROJECT_PARAM_sempred(localctx: RuleContext, predIndex: number): boolean { + private EXPRESSION_COLON_sempred(localctx: RuleContext, predIndex: number): boolean { switch (predIndex) { case 3: return this.isDevVersion(); } return true; } - private PROJECT_NAMED_OR_POSITIONAL_PARAM_sempred(localctx: RuleContext, predIndex: number): boolean { + private PROJECT_PARAM_sempred(localctx: RuleContext, predIndex: number): boolean { switch (predIndex) { case 4: return this.isDevVersion(); } return true; } - private RENAME_PARAM_sempred(localctx: RuleContext, predIndex: number): boolean { + private PROJECT_NAMED_OR_POSITIONAL_PARAM_sempred(localctx: RuleContext, predIndex: number): boolean { switch (predIndex) { case 5: return this.isDevVersion(); } return true; } - private RENAME_NAMED_OR_POSITIONAL_PARAM_sempred(localctx: RuleContext, predIndex: number): boolean { + private RENAME_PARAM_sempred(localctx: RuleContext, predIndex: number): boolean { switch (predIndex) { case 6: return this.isDevVersion(); } return true; } - private ENRICH_FIELD_PARAM_sempred(localctx: RuleContext, predIndex: number): boolean { + private RENAME_NAMED_OR_POSITIONAL_PARAM_sempred(localctx: RuleContext, predIndex: number): boolean { switch (predIndex) { case 7: return this.isDevVersion(); } return true; } - private ENRICH_FIELD_NAMED_OR_POSITIONAL_PARAM_sempred(localctx: RuleContext, predIndex: number): boolean { + private ENRICH_FIELD_PARAM_sempred(localctx: RuleContext, predIndex: number): boolean { switch (predIndex) { case 8: return this.isDevVersion(); } return true; } - private MVEXPAND_PARAM_sempred(localctx: RuleContext, predIndex: number): boolean { + private ENRICH_FIELD_NAMED_OR_POSITIONAL_PARAM_sempred(localctx: RuleContext, predIndex: number): boolean { switch (predIndex) { case 9: return this.isDevVersion(); } return true; } - private MVEXPAND_NAMED_OR_POSITIONAL_PARAM_sempred(localctx: RuleContext, predIndex: number): boolean { + private MVEXPAND_PARAM_sempred(localctx: RuleContext, predIndex: number): boolean { switch (predIndex) { case 10: return this.isDevVersion(); } return true; } + private MVEXPAND_NAMED_OR_POSITIONAL_PARAM_sempred(localctx: RuleContext, predIndex: number): boolean { + switch (predIndex) { + case 11: + return this.isDevVersion(); + } + return true; + } - public static readonly _serializedATN: number[] = [4,0,120,1479,6,-1,6, + public static readonly _serializedATN: number[] = [4,0,119,1484,6,-1,6, -1,6,-1,6,-1,6,-1,6,-1,6,-1,6,-1,6,-1,6,-1,6,-1,6,-1,6,-1,6,-1,6,-1,2,0, 7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5,2,6,7,6,2,7,7,7,2,8,7,8,2,9, 7,9,2,10,7,10,2,11,7,11,2,12,7,12,2,13,7,13,2,14,7,14,2,15,7,15,2,16,7, @@ -514,483 +520,485 @@ export default class esql_lexer extends lexer_config { 2,175,7,175,2,176,7,176,2,177,7,177,2,178,7,178,2,179,7,179,2,180,7,180, 2,181,7,181,2,182,7,182,2,183,7,183,2,184,7,184,2,185,7,185,2,186,7,186, 2,187,7,187,2,188,7,188,2,189,7,189,2,190,7,190,2,191,7,191,2,192,7,192, - 2,193,7,193,2,194,7,194,2,195,7,195,2,196,7,196,2,197,7,197,1,0,1,0,1,0, - 1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,1,2,1,2,1,2, - 1,2,1,2,1,2,1,2,1,2,1,3,1,3,1,3,1,3,1,3,1,3,1,3,1,4,1,4,1,4,1,4,1,4,1,4, - 1,4,1,4,1,4,1,4,1,5,1,5,1,5,1,5,1,5,1,5,1,5,1,6,1,6,1,6,1,6,1,6,1,6,1,6, - 1,7,1,7,1,7,1,7,1,7,1,7,1,7,1,8,1,8,1,8,1,8,1,8,1,8,1,8,1,8,1,9,1,9,1,9, - 1,9,1,9,1,9,1,9,1,9,1,9,1,9,1,9,1,9,1,10,1,10,1,10,1,10,1,10,1,10,1,10, - 1,10,1,10,1,11,1,11,1,11,1,11,1,11,1,11,1,12,1,12,1,12,1,12,1,12,1,12,1, - 12,1,13,1,13,1,13,1,13,1,13,1,13,1,13,1,14,1,14,1,14,1,14,1,14,1,14,1,14, - 1,14,1,15,1,15,1,15,1,15,1,15,1,15,1,15,1,15,1,16,1,16,1,16,1,16,1,16,1, - 16,1,16,1,16,1,16,1,16,1,16,1,16,1,16,1,16,1,16,1,17,1,17,1,17,1,17,1,17, - 1,17,1,17,1,17,1,17,1,17,1,18,1,18,1,18,1,18,1,18,1,18,1,18,1,18,1,18,1, - 18,1,18,1,19,4,19,578,8,19,11,19,12,19,579,1,19,1,19,1,20,1,20,1,20,1,20, - 5,20,588,8,20,10,20,12,20,591,9,20,1,20,3,20,594,8,20,1,20,3,20,597,8,20, - 1,20,1,20,1,21,1,21,1,21,1,21,1,21,5,21,606,8,21,10,21,12,21,609,9,21,1, - 21,1,21,1,21,1,21,1,21,1,22,4,22,617,8,22,11,22,12,22,618,1,22,1,22,1,23, - 1,23,1,23,1,23,1,24,1,24,1,25,1,25,1,26,1,26,1,26,1,27,1,27,1,28,1,28,3, - 28,638,8,28,1,28,4,28,641,8,28,11,28,12,28,642,1,29,1,29,1,30,1,30,1,31, - 1,31,1,31,3,31,652,8,31,1,32,1,32,1,33,1,33,1,33,3,33,659,8,33,1,34,1,34, - 1,34,5,34,664,8,34,10,34,12,34,667,9,34,1,34,1,34,1,34,1,34,1,34,1,34,5, - 34,675,8,34,10,34,12,34,678,9,34,1,34,1,34,1,34,1,34,1,34,3,34,685,8,34, - 1,34,3,34,688,8,34,3,34,690,8,34,1,35,4,35,693,8,35,11,35,12,35,694,1,36, - 4,36,698,8,36,11,36,12,36,699,1,36,1,36,5,36,704,8,36,10,36,12,36,707,9, - 36,1,36,1,36,4,36,711,8,36,11,36,12,36,712,1,36,4,36,716,8,36,11,36,12, - 36,717,1,36,1,36,5,36,722,8,36,10,36,12,36,725,9,36,3,36,727,8,36,1,36, - 1,36,1,36,1,36,4,36,733,8,36,11,36,12,36,734,1,36,1,36,3,36,739,8,36,1, - 37,1,37,1,37,1,38,1,38,1,38,1,38,1,39,1,39,1,39,1,39,1,40,1,40,1,41,1,41, - 1,41,1,42,1,42,1,43,1,43,1,43,1,43,1,43,1,44,1,44,1,45,1,45,1,45,1,45,1, - 45,1,45,1,46,1,46,1,46,1,46,1,46,1,46,1,47,1,47,1,47,1,48,1,48,1,48,1,49, - 1,49,1,49,1,49,1,49,1,50,1,50,1,50,1,50,1,50,1,51,1,51,1,52,1,52,1,52,1, - 52,1,53,1,53,1,53,1,53,1,53,1,54,1,54,1,54,1,54,1,54,1,54,1,55,1,55,1,55, - 1,56,1,56,1,57,1,57,1,57,1,57,1,57,1,57,1,58,1,58,1,59,1,59,1,59,1,59,1, - 59,1,60,1,60,1,60,1,61,1,61,1,61,1,62,1,62,1,62,1,63,1,63,1,64,1,64,1,64, - 1,65,1,65,1,66,1,66,1,66,1,67,1,67,1,68,1,68,1,69,1,69,1,70,1,70,1,71,1, - 71,1,72,1,72,1,72,1,72,1,72,1,72,1,73,1,73,1,73,1,73,1,74,1,74,1,74,3,74, - 871,8,74,1,74,5,74,874,8,74,10,74,12,74,877,9,74,1,74,1,74,4,74,881,8,74, - 11,74,12,74,882,3,74,885,8,74,1,75,1,75,1,75,1,75,1,75,1,76,1,76,1,76,1, - 76,1,76,1,77,1,77,5,77,899,8,77,10,77,12,77,902,9,77,1,77,1,77,3,77,906, - 8,77,1,77,4,77,909,8,77,11,77,12,77,910,3,77,913,8,77,1,78,1,78,4,78,917, - 8,78,11,78,12,78,918,1,78,1,78,1,79,1,79,1,80,1,80,1,80,1,80,1,81,1,81, - 1,81,1,81,1,82,1,82,1,82,1,82,1,83,1,83,1,83,1,83,1,83,1,84,1,84,1,84,1, - 84,1,84,1,85,1,85,1,85,1,85,1,86,1,86,1,86,1,86,1,87,1,87,1,87,1,87,1,88, - 1,88,1,88,1,88,1,88,1,89,1,89,1,89,1,89,1,90,1,90,1,90,1,90,1,91,1,91,1, - 91,1,91,1,92,1,92,1,92,1,92,1,93,1,93,1,93,1,93,1,94,1,94,1,94,1,94,1,94, - 1,94,1,94,1,94,1,94,1,95,1,95,1,95,3,95,996,8,95,1,96,4,96,999,8,96,11, - 96,12,96,1000,1,97,1,97,1,97,1,97,1,98,1,98,1,98,1,98,1,99,1,99,1,99,1, - 99,1,100,1,100,1,100,1,100,1,101,1,101,1,101,1,101,1,102,1,102,1,102,1, - 102,1,102,1,103,1,103,1,103,1,103,1,104,1,104,1,104,1,104,1,105,1,105,1, - 105,1,105,1,105,1,106,1,106,1,106,1,106,1,106,1,107,1,107,1,107,1,107,3, - 107,1050,8,107,1,108,1,108,3,108,1054,8,108,1,108,5,108,1057,8,108,10,108, - 12,108,1060,9,108,1,108,1,108,3,108,1064,8,108,1,108,4,108,1067,8,108,11, - 108,12,108,1068,3,108,1071,8,108,1,109,1,109,4,109,1075,8,109,11,109,12, - 109,1076,1,110,1,110,1,110,1,110,1,111,1,111,1,111,1,111,1,112,1,112,1, - 112,1,112,1,113,1,113,1,113,1,113,1,113,1,114,1,114,1,114,1,114,1,115,1, - 115,1,115,1,115,1,116,1,116,1,116,1,116,1,117,1,117,1,117,1,117,1,117,1, - 118,1,118,1,118,1,118,1,118,1,119,1,119,1,119,1,120,1,120,1,120,1,120,1, - 121,1,121,1,121,1,121,1,122,1,122,1,122,1,122,1,123,1,123,1,123,1,123,1, - 124,1,124,1,124,1,124,1,124,1,125,1,125,1,125,1,125,1,125,1,126,1,126,1, - 126,1,126,1,126,1,127,1,127,1,127,1,127,1,127,1,127,1,127,1,128,1,128,1, - 129,4,129,1162,8,129,11,129,12,129,1163,1,129,1,129,3,129,1168,8,129,1, - 129,4,129,1171,8,129,11,129,12,129,1172,1,130,1,130,1,130,1,130,1,131,1, - 131,1,131,1,131,1,132,1,132,1,132,1,132,1,133,1,133,1,133,1,133,1,134,1, - 134,1,134,1,134,1,134,1,134,1,135,1,135,1,135,1,135,1,136,1,136,1,136,1, - 136,1,137,1,137,1,137,1,137,1,138,1,138,1,138,1,138,1,139,1,139,1,139,1, - 139,1,140,1,140,1,140,1,140,1,141,1,141,1,141,1,141,1,141,1,142,1,142,1, - 142,1,142,1,142,1,143,1,143,1,143,1,143,1,144,1,144,1,144,1,144,1,145,1, - 145,1,145,1,145,1,146,1,146,1,146,1,146,1,146,1,147,1,147,1,147,1,147,1, - 148,1,148,1,148,1,148,1,148,1,149,1,149,1,149,1,149,1,149,1,150,1,150,1, - 150,1,150,1,151,1,151,1,151,1,151,1,152,1,152,1,152,1,152,1,153,1,153,1, - 153,1,153,1,154,1,154,1,154,1,154,1,155,1,155,1,155,1,155,1,155,1,156,1, - 156,1,156,1,156,1,156,1,157,1,157,1,157,1,157,1,158,1,158,1,158,1,158,1, - 159,1,159,1,159,1,159,1,160,1,160,1,160,1,160,1,160,1,161,1,161,1,162,1, - 162,1,162,1,162,1,162,4,162,1316,8,162,11,162,12,162,1317,1,163,1,163,1, - 163,1,163,1,164,1,164,1,164,1,164,1,165,1,165,1,165,1,165,1,166,1,166,1, - 166,1,166,1,166,1,167,1,167,1,167,1,167,1,168,1,168,1,168,1,168,1,169,1, - 169,1,169,1,169,1,170,1,170,1,170,1,170,1,170,1,171,1,171,1,171,1,171,1, - 172,1,172,1,172,1,172,1,173,1,173,1,173,1,173,1,174,1,174,1,174,1,174,1, - 175,1,175,1,175,1,175,1,176,1,176,1,176,1,176,1,176,1,176,1,177,1,177,1, - 177,1,177,1,178,1,178,1,178,1,178,1,179,1,179,1,179,1,179,1,180,1,180,1, - 180,1,180,1,181,1,181,1,181,1,181,1,182,1,182,1,182,1,182,1,183,1,183,1, - 183,1,183,1,183,1,184,1,184,1,184,1,184,1,184,1,184,1,185,1,185,1,185,1, - 185,1,185,1,185,1,186,1,186,1,186,1,186,1,187,1,187,1,187,1,187,1,188,1, - 188,1,188,1,188,1,189,1,189,1,189,1,189,1,189,1,189,1,190,1,190,1,190,1, - 190,1,190,1,190,1,191,1,191,1,191,1,191,1,192,1,192,1,192,1,192,1,193,1, - 193,1,193,1,193,1,194,1,194,1,194,1,194,1,194,1,194,1,195,1,195,1,195,1, - 195,1,195,1,195,1,196,1,196,1,196,1,196,1,196,1,196,1,197,1,197,1,197,1, - 197,1,197,2,607,676,0,198,15,1,17,2,19,3,21,4,23,5,25,6,27,7,29,8,31,9, - 33,10,35,11,37,12,39,13,41,14,43,15,45,16,47,17,49,18,51,19,53,20,55,21, - 57,22,59,23,61,24,63,0,65,0,67,0,69,0,71,0,73,0,75,0,77,0,79,0,81,0,83, - 25,85,26,87,27,89,28,91,29,93,30,95,31,97,32,99,33,101,34,103,35,105,36, - 107,37,109,38,111,39,113,40,115,41,117,42,119,43,121,44,123,45,125,46,127, - 47,129,48,131,49,133,50,135,51,137,52,139,53,141,54,143,55,145,56,147,57, - 149,58,151,59,153,60,155,61,157,62,159,63,161,0,163,64,165,65,167,66,169, - 67,171,0,173,68,175,69,177,70,179,71,181,0,183,0,185,72,187,73,189,74,191, - 0,193,0,195,0,197,0,199,0,201,0,203,75,205,0,207,76,209,0,211,0,213,77, - 215,78,217,79,219,0,221,0,223,0,225,0,227,0,229,0,231,0,233,80,235,81,237, - 82,239,83,241,0,243,0,245,0,247,0,249,0,251,0,253,84,255,0,257,85,259,86, - 261,87,263,0,265,0,267,88,269,89,271,0,273,90,275,0,277,91,279,92,281,93, - 283,0,285,0,287,0,289,0,291,0,293,0,295,0,297,0,299,0,301,94,303,95,305, - 96,307,0,309,0,311,0,313,0,315,0,317,0,319,97,321,98,323,99,325,0,327,100, - 329,101,331,102,333,103,335,0,337,104,339,105,341,106,343,107,345,108,347, - 0,349,0,351,0,353,0,355,0,357,0,359,0,361,109,363,110,365,111,367,0,369, - 0,371,0,373,0,375,112,377,113,379,114,381,0,383,0,385,0,387,115,389,116, - 391,117,393,0,395,0,397,118,399,119,401,120,403,0,405,0,407,0,409,0,15, - 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,35,2,0,68,68,100,100,2,0,73,73,105,105, - 2,0,83,83,115,115,2,0,69,69,101,101,2,0,67,67,99,99,2,0,84,84,116,116,2, - 0,82,82,114,114,2,0,79,79,111,111,2,0,80,80,112,112,2,0,78,78,110,110,2, - 0,72,72,104,104,2,0,86,86,118,118,2,0,65,65,97,97,2,0,76,76,108,108,2,0, - 88,88,120,120,2,0,70,70,102,102,2,0,77,77,109,109,2,0,71,71,103,103,2,0, - 75,75,107,107,2,0,87,87,119,119,2,0,85,85,117,117,6,0,9,10,13,13,32,32, - 47,47,91,91,93,93,2,0,10,10,13,13,3,0,9,10,13,13,32,32,1,0,48,57,2,0,65, - 90,97,122,8,0,34,34,78,78,82,82,84,84,92,92,110,110,114,114,116,116,4,0, - 10,10,13,13,34,34,92,92,2,0,43,43,45,45,1,0,96,96,2,0,66,66,98,98,2,0,89, - 89,121,121,11,0,9,10,13,13,32,32,34,34,44,44,47,47,58,58,61,61,91,91,93, - 93,124,124,2,0,42,42,47,47,11,0,9,10,13,13,32,32,34,35,44,44,47,47,58,58, - 60,60,62,63,92,92,124,124,1507,0,15,1,0,0,0,0,17,1,0,0,0,0,19,1,0,0,0,0, - 21,1,0,0,0,0,23,1,0,0,0,0,25,1,0,0,0,0,27,1,0,0,0,0,29,1,0,0,0,0,31,1,0, - 0,0,0,33,1,0,0,0,0,35,1,0,0,0,0,37,1,0,0,0,0,39,1,0,0,0,0,41,1,0,0,0,0, - 43,1,0,0,0,0,45,1,0,0,0,0,47,1,0,0,0,0,49,1,0,0,0,0,51,1,0,0,0,0,53,1,0, - 0,0,0,55,1,0,0,0,0,57,1,0,0,0,0,59,1,0,0,0,1,61,1,0,0,0,1,83,1,0,0,0,1, - 85,1,0,0,0,1,87,1,0,0,0,1,89,1,0,0,0,1,91,1,0,0,0,1,93,1,0,0,0,1,95,1,0, - 0,0,1,97,1,0,0,0,1,99,1,0,0,0,1,101,1,0,0,0,1,103,1,0,0,0,1,105,1,0,0,0, - 1,107,1,0,0,0,1,109,1,0,0,0,1,111,1,0,0,0,1,113,1,0,0,0,1,115,1,0,0,0,1, - 117,1,0,0,0,1,119,1,0,0,0,1,121,1,0,0,0,1,123,1,0,0,0,1,125,1,0,0,0,1,127, - 1,0,0,0,1,129,1,0,0,0,1,131,1,0,0,0,1,133,1,0,0,0,1,135,1,0,0,0,1,137,1, - 0,0,0,1,139,1,0,0,0,1,141,1,0,0,0,1,143,1,0,0,0,1,145,1,0,0,0,1,147,1,0, - 0,0,1,149,1,0,0,0,1,151,1,0,0,0,1,153,1,0,0,0,1,155,1,0,0,0,1,157,1,0,0, - 0,1,159,1,0,0,0,1,161,1,0,0,0,1,163,1,0,0,0,1,165,1,0,0,0,1,167,1,0,0,0, - 1,169,1,0,0,0,1,173,1,0,0,0,1,175,1,0,0,0,1,177,1,0,0,0,1,179,1,0,0,0,2, - 181,1,0,0,0,2,183,1,0,0,0,2,185,1,0,0,0,2,187,1,0,0,0,2,189,1,0,0,0,3,191, - 1,0,0,0,3,193,1,0,0,0,3,195,1,0,0,0,3,197,1,0,0,0,3,199,1,0,0,0,3,201,1, - 0,0,0,3,203,1,0,0,0,3,207,1,0,0,0,3,209,1,0,0,0,3,211,1,0,0,0,3,213,1,0, - 0,0,3,215,1,0,0,0,3,217,1,0,0,0,4,219,1,0,0,0,4,221,1,0,0,0,4,223,1,0,0, - 0,4,225,1,0,0,0,4,227,1,0,0,0,4,233,1,0,0,0,4,235,1,0,0,0,4,237,1,0,0,0, - 4,239,1,0,0,0,5,241,1,0,0,0,5,243,1,0,0,0,5,245,1,0,0,0,5,247,1,0,0,0,5, - 249,1,0,0,0,5,251,1,0,0,0,5,253,1,0,0,0,5,255,1,0,0,0,5,257,1,0,0,0,5,259, - 1,0,0,0,5,261,1,0,0,0,6,263,1,0,0,0,6,265,1,0,0,0,6,267,1,0,0,0,6,269,1, - 0,0,0,6,273,1,0,0,0,6,275,1,0,0,0,6,277,1,0,0,0,6,279,1,0,0,0,6,281,1,0, - 0,0,7,283,1,0,0,0,7,285,1,0,0,0,7,287,1,0,0,0,7,289,1,0,0,0,7,291,1,0,0, - 0,7,293,1,0,0,0,7,295,1,0,0,0,7,297,1,0,0,0,7,299,1,0,0,0,7,301,1,0,0,0, - 7,303,1,0,0,0,7,305,1,0,0,0,8,307,1,0,0,0,8,309,1,0,0,0,8,311,1,0,0,0,8, - 313,1,0,0,0,8,315,1,0,0,0,8,317,1,0,0,0,8,319,1,0,0,0,8,321,1,0,0,0,8,323, - 1,0,0,0,9,325,1,0,0,0,9,327,1,0,0,0,9,329,1,0,0,0,9,331,1,0,0,0,9,333,1, - 0,0,0,10,335,1,0,0,0,10,337,1,0,0,0,10,339,1,0,0,0,10,341,1,0,0,0,10,343, - 1,0,0,0,10,345,1,0,0,0,11,347,1,0,0,0,11,349,1,0,0,0,11,351,1,0,0,0,11, - 353,1,0,0,0,11,355,1,0,0,0,11,357,1,0,0,0,11,359,1,0,0,0,11,361,1,0,0,0, - 11,363,1,0,0,0,11,365,1,0,0,0,12,367,1,0,0,0,12,369,1,0,0,0,12,371,1,0, - 0,0,12,373,1,0,0,0,12,375,1,0,0,0,12,377,1,0,0,0,12,379,1,0,0,0,13,381, - 1,0,0,0,13,383,1,0,0,0,13,385,1,0,0,0,13,387,1,0,0,0,13,389,1,0,0,0,13, - 391,1,0,0,0,14,393,1,0,0,0,14,395,1,0,0,0,14,397,1,0,0,0,14,399,1,0,0,0, - 14,401,1,0,0,0,14,403,1,0,0,0,14,405,1,0,0,0,14,407,1,0,0,0,14,409,1,0, - 0,0,15,411,1,0,0,0,17,421,1,0,0,0,19,428,1,0,0,0,21,437,1,0,0,0,23,444, - 1,0,0,0,25,454,1,0,0,0,27,461,1,0,0,0,29,468,1,0,0,0,31,475,1,0,0,0,33, - 483,1,0,0,0,35,495,1,0,0,0,37,504,1,0,0,0,39,510,1,0,0,0,41,517,1,0,0,0, - 43,524,1,0,0,0,45,532,1,0,0,0,47,540,1,0,0,0,49,555,1,0,0,0,51,565,1,0, - 0,0,53,577,1,0,0,0,55,583,1,0,0,0,57,600,1,0,0,0,59,616,1,0,0,0,61,622, - 1,0,0,0,63,626,1,0,0,0,65,628,1,0,0,0,67,630,1,0,0,0,69,633,1,0,0,0,71, - 635,1,0,0,0,73,644,1,0,0,0,75,646,1,0,0,0,77,651,1,0,0,0,79,653,1,0,0,0, - 81,658,1,0,0,0,83,689,1,0,0,0,85,692,1,0,0,0,87,738,1,0,0,0,89,740,1,0, - 0,0,91,743,1,0,0,0,93,747,1,0,0,0,95,751,1,0,0,0,97,753,1,0,0,0,99,756, - 1,0,0,0,101,758,1,0,0,0,103,763,1,0,0,0,105,765,1,0,0,0,107,771,1,0,0,0, - 109,777,1,0,0,0,111,780,1,0,0,0,113,783,1,0,0,0,115,788,1,0,0,0,117,793, - 1,0,0,0,119,795,1,0,0,0,121,799,1,0,0,0,123,804,1,0,0,0,125,810,1,0,0,0, - 127,813,1,0,0,0,129,815,1,0,0,0,131,821,1,0,0,0,133,823,1,0,0,0,135,828, - 1,0,0,0,137,831,1,0,0,0,139,834,1,0,0,0,141,837,1,0,0,0,143,839,1,0,0,0, - 145,842,1,0,0,0,147,844,1,0,0,0,149,847,1,0,0,0,151,849,1,0,0,0,153,851, - 1,0,0,0,155,853,1,0,0,0,157,855,1,0,0,0,159,857,1,0,0,0,161,863,1,0,0,0, - 163,884,1,0,0,0,165,886,1,0,0,0,167,891,1,0,0,0,169,912,1,0,0,0,171,914, - 1,0,0,0,173,922,1,0,0,0,175,924,1,0,0,0,177,928,1,0,0,0,179,932,1,0,0,0, - 181,936,1,0,0,0,183,941,1,0,0,0,185,946,1,0,0,0,187,950,1,0,0,0,189,954, - 1,0,0,0,191,958,1,0,0,0,193,963,1,0,0,0,195,967,1,0,0,0,197,971,1,0,0,0, - 199,975,1,0,0,0,201,979,1,0,0,0,203,983,1,0,0,0,205,995,1,0,0,0,207,998, - 1,0,0,0,209,1002,1,0,0,0,211,1006,1,0,0,0,213,1010,1,0,0,0,215,1014,1,0, - 0,0,217,1018,1,0,0,0,219,1022,1,0,0,0,221,1027,1,0,0,0,223,1031,1,0,0,0, - 225,1035,1,0,0,0,227,1040,1,0,0,0,229,1049,1,0,0,0,231,1070,1,0,0,0,233, - 1074,1,0,0,0,235,1078,1,0,0,0,237,1082,1,0,0,0,239,1086,1,0,0,0,241,1090, - 1,0,0,0,243,1095,1,0,0,0,245,1099,1,0,0,0,247,1103,1,0,0,0,249,1107,1,0, - 0,0,251,1112,1,0,0,0,253,1117,1,0,0,0,255,1120,1,0,0,0,257,1124,1,0,0,0, - 259,1128,1,0,0,0,261,1132,1,0,0,0,263,1136,1,0,0,0,265,1141,1,0,0,0,267, - 1146,1,0,0,0,269,1151,1,0,0,0,271,1158,1,0,0,0,273,1167,1,0,0,0,275,1174, - 1,0,0,0,277,1178,1,0,0,0,279,1182,1,0,0,0,281,1186,1,0,0,0,283,1190,1,0, - 0,0,285,1196,1,0,0,0,287,1200,1,0,0,0,289,1204,1,0,0,0,291,1208,1,0,0,0, - 293,1212,1,0,0,0,295,1216,1,0,0,0,297,1220,1,0,0,0,299,1225,1,0,0,0,301, - 1230,1,0,0,0,303,1234,1,0,0,0,305,1238,1,0,0,0,307,1242,1,0,0,0,309,1247, - 1,0,0,0,311,1251,1,0,0,0,313,1256,1,0,0,0,315,1261,1,0,0,0,317,1265,1,0, - 0,0,319,1269,1,0,0,0,321,1273,1,0,0,0,323,1277,1,0,0,0,325,1281,1,0,0,0, - 327,1286,1,0,0,0,329,1291,1,0,0,0,331,1295,1,0,0,0,333,1299,1,0,0,0,335, - 1303,1,0,0,0,337,1308,1,0,0,0,339,1315,1,0,0,0,341,1319,1,0,0,0,343,1323, - 1,0,0,0,345,1327,1,0,0,0,347,1331,1,0,0,0,349,1336,1,0,0,0,351,1340,1,0, - 0,0,353,1344,1,0,0,0,355,1348,1,0,0,0,357,1353,1,0,0,0,359,1357,1,0,0,0, - 361,1361,1,0,0,0,363,1365,1,0,0,0,365,1369,1,0,0,0,367,1373,1,0,0,0,369, - 1379,1,0,0,0,371,1383,1,0,0,0,373,1387,1,0,0,0,375,1391,1,0,0,0,377,1395, - 1,0,0,0,379,1399,1,0,0,0,381,1403,1,0,0,0,383,1408,1,0,0,0,385,1414,1,0, - 0,0,387,1420,1,0,0,0,389,1424,1,0,0,0,391,1428,1,0,0,0,393,1432,1,0,0,0, - 395,1438,1,0,0,0,397,1444,1,0,0,0,399,1448,1,0,0,0,401,1452,1,0,0,0,403, - 1456,1,0,0,0,405,1462,1,0,0,0,407,1468,1,0,0,0,409,1474,1,0,0,0,411,412, - 7,0,0,0,412,413,7,1,0,0,413,414,7,2,0,0,414,415,7,2,0,0,415,416,7,3,0,0, - 416,417,7,4,0,0,417,418,7,5,0,0,418,419,1,0,0,0,419,420,6,0,0,0,420,16, - 1,0,0,0,421,422,7,0,0,0,422,423,7,6,0,0,423,424,7,7,0,0,424,425,7,8,0,0, - 425,426,1,0,0,0,426,427,6,1,1,0,427,18,1,0,0,0,428,429,7,3,0,0,429,430, - 7,9,0,0,430,431,7,6,0,0,431,432,7,1,0,0,432,433,7,4,0,0,433,434,7,10,0, - 0,434,435,1,0,0,0,435,436,6,2,2,0,436,20,1,0,0,0,437,438,7,3,0,0,438,439, - 7,11,0,0,439,440,7,12,0,0,440,441,7,13,0,0,441,442,1,0,0,0,442,443,6,3, - 0,0,443,22,1,0,0,0,444,445,7,3,0,0,445,446,7,14,0,0,446,447,7,8,0,0,447, - 448,7,13,0,0,448,449,7,12,0,0,449,450,7,1,0,0,450,451,7,9,0,0,451,452,1, - 0,0,0,452,453,6,4,3,0,453,24,1,0,0,0,454,455,7,15,0,0,455,456,7,6,0,0,456, - 457,7,7,0,0,457,458,7,16,0,0,458,459,1,0,0,0,459,460,6,5,4,0,460,26,1,0, - 0,0,461,462,7,17,0,0,462,463,7,6,0,0,463,464,7,7,0,0,464,465,7,18,0,0,465, - 466,1,0,0,0,466,467,6,6,0,0,467,28,1,0,0,0,468,469,7,18,0,0,469,470,7,3, - 0,0,470,471,7,3,0,0,471,472,7,8,0,0,472,473,1,0,0,0,473,474,6,7,1,0,474, - 30,1,0,0,0,475,476,7,13,0,0,476,477,7,1,0,0,477,478,7,16,0,0,478,479,7, - 1,0,0,479,480,7,5,0,0,480,481,1,0,0,0,481,482,6,8,0,0,482,32,1,0,0,0,483, - 484,7,16,0,0,484,485,7,11,0,0,485,486,5,95,0,0,486,487,7,3,0,0,487,488, - 7,14,0,0,488,489,7,8,0,0,489,490,7,12,0,0,490,491,7,9,0,0,491,492,7,0,0, - 0,492,493,1,0,0,0,493,494,6,9,5,0,494,34,1,0,0,0,495,496,7,6,0,0,496,497, - 7,3,0,0,497,498,7,9,0,0,498,499,7,12,0,0,499,500,7,16,0,0,500,501,7,3,0, - 0,501,502,1,0,0,0,502,503,6,10,6,0,503,36,1,0,0,0,504,505,7,6,0,0,505,506, - 7,7,0,0,506,507,7,19,0,0,507,508,1,0,0,0,508,509,6,11,0,0,509,38,1,0,0, - 0,510,511,7,2,0,0,511,512,7,10,0,0,512,513,7,7,0,0,513,514,7,19,0,0,514, - 515,1,0,0,0,515,516,6,12,7,0,516,40,1,0,0,0,517,518,7,2,0,0,518,519,7,7, - 0,0,519,520,7,6,0,0,520,521,7,5,0,0,521,522,1,0,0,0,522,523,6,13,0,0,523, - 42,1,0,0,0,524,525,7,2,0,0,525,526,7,5,0,0,526,527,7,12,0,0,527,528,7,5, - 0,0,528,529,7,2,0,0,529,530,1,0,0,0,530,531,6,14,0,0,531,44,1,0,0,0,532, - 533,7,19,0,0,533,534,7,10,0,0,534,535,7,3,0,0,535,536,7,6,0,0,536,537,7, - 3,0,0,537,538,1,0,0,0,538,539,6,15,0,0,539,46,1,0,0,0,540,541,4,16,0,0, - 541,542,7,1,0,0,542,543,7,9,0,0,543,544,7,13,0,0,544,545,7,1,0,0,545,546, - 7,9,0,0,546,547,7,3,0,0,547,548,7,2,0,0,548,549,7,5,0,0,549,550,7,12,0, - 0,550,551,7,5,0,0,551,552,7,2,0,0,552,553,1,0,0,0,553,554,6,16,0,0,554, - 48,1,0,0,0,555,556,4,17,1,0,556,557,7,13,0,0,557,558,7,7,0,0,558,559,7, - 7,0,0,559,560,7,18,0,0,560,561,7,20,0,0,561,562,7,8,0,0,562,563,1,0,0,0, - 563,564,6,17,8,0,564,50,1,0,0,0,565,566,4,18,2,0,566,567,7,16,0,0,567,568, - 7,3,0,0,568,569,7,5,0,0,569,570,7,6,0,0,570,571,7,1,0,0,571,572,7,4,0,0, - 572,573,7,2,0,0,573,574,1,0,0,0,574,575,6,18,9,0,575,52,1,0,0,0,576,578, - 8,21,0,0,577,576,1,0,0,0,578,579,1,0,0,0,579,577,1,0,0,0,579,580,1,0,0, - 0,580,581,1,0,0,0,581,582,6,19,0,0,582,54,1,0,0,0,583,584,5,47,0,0,584, - 585,5,47,0,0,585,589,1,0,0,0,586,588,8,22,0,0,587,586,1,0,0,0,588,591,1, - 0,0,0,589,587,1,0,0,0,589,590,1,0,0,0,590,593,1,0,0,0,591,589,1,0,0,0,592, - 594,5,13,0,0,593,592,1,0,0,0,593,594,1,0,0,0,594,596,1,0,0,0,595,597,5, - 10,0,0,596,595,1,0,0,0,596,597,1,0,0,0,597,598,1,0,0,0,598,599,6,20,10, - 0,599,56,1,0,0,0,600,601,5,47,0,0,601,602,5,42,0,0,602,607,1,0,0,0,603, - 606,3,57,21,0,604,606,9,0,0,0,605,603,1,0,0,0,605,604,1,0,0,0,606,609,1, - 0,0,0,607,608,1,0,0,0,607,605,1,0,0,0,608,610,1,0,0,0,609,607,1,0,0,0,610, - 611,5,42,0,0,611,612,5,47,0,0,612,613,1,0,0,0,613,614,6,21,10,0,614,58, - 1,0,0,0,615,617,7,23,0,0,616,615,1,0,0,0,617,618,1,0,0,0,618,616,1,0,0, - 0,618,619,1,0,0,0,619,620,1,0,0,0,620,621,6,22,10,0,621,60,1,0,0,0,622, - 623,5,124,0,0,623,624,1,0,0,0,624,625,6,23,11,0,625,62,1,0,0,0,626,627, - 7,24,0,0,627,64,1,0,0,0,628,629,7,25,0,0,629,66,1,0,0,0,630,631,5,92,0, - 0,631,632,7,26,0,0,632,68,1,0,0,0,633,634,8,27,0,0,634,70,1,0,0,0,635,637, - 7,3,0,0,636,638,7,28,0,0,637,636,1,0,0,0,637,638,1,0,0,0,638,640,1,0,0, - 0,639,641,3,63,24,0,640,639,1,0,0,0,641,642,1,0,0,0,642,640,1,0,0,0,642, - 643,1,0,0,0,643,72,1,0,0,0,644,645,5,64,0,0,645,74,1,0,0,0,646,647,5,96, - 0,0,647,76,1,0,0,0,648,652,8,29,0,0,649,650,5,96,0,0,650,652,5,96,0,0,651, - 648,1,0,0,0,651,649,1,0,0,0,652,78,1,0,0,0,653,654,5,95,0,0,654,80,1,0, - 0,0,655,659,3,65,25,0,656,659,3,63,24,0,657,659,3,79,32,0,658,655,1,0,0, - 0,658,656,1,0,0,0,658,657,1,0,0,0,659,82,1,0,0,0,660,665,5,34,0,0,661,664, - 3,67,26,0,662,664,3,69,27,0,663,661,1,0,0,0,663,662,1,0,0,0,664,667,1,0, - 0,0,665,663,1,0,0,0,665,666,1,0,0,0,666,668,1,0,0,0,667,665,1,0,0,0,668, - 690,5,34,0,0,669,670,5,34,0,0,670,671,5,34,0,0,671,672,5,34,0,0,672,676, - 1,0,0,0,673,675,8,22,0,0,674,673,1,0,0,0,675,678,1,0,0,0,676,677,1,0,0, - 0,676,674,1,0,0,0,677,679,1,0,0,0,678,676,1,0,0,0,679,680,5,34,0,0,680, - 681,5,34,0,0,681,682,5,34,0,0,682,684,1,0,0,0,683,685,5,34,0,0,684,683, - 1,0,0,0,684,685,1,0,0,0,685,687,1,0,0,0,686,688,5,34,0,0,687,686,1,0,0, - 0,687,688,1,0,0,0,688,690,1,0,0,0,689,660,1,0,0,0,689,669,1,0,0,0,690,84, - 1,0,0,0,691,693,3,63,24,0,692,691,1,0,0,0,693,694,1,0,0,0,694,692,1,0,0, - 0,694,695,1,0,0,0,695,86,1,0,0,0,696,698,3,63,24,0,697,696,1,0,0,0,698, - 699,1,0,0,0,699,697,1,0,0,0,699,700,1,0,0,0,700,701,1,0,0,0,701,705,3,103, - 44,0,702,704,3,63,24,0,703,702,1,0,0,0,704,707,1,0,0,0,705,703,1,0,0,0, - 705,706,1,0,0,0,706,739,1,0,0,0,707,705,1,0,0,0,708,710,3,103,44,0,709, - 711,3,63,24,0,710,709,1,0,0,0,711,712,1,0,0,0,712,710,1,0,0,0,712,713,1, - 0,0,0,713,739,1,0,0,0,714,716,3,63,24,0,715,714,1,0,0,0,716,717,1,0,0,0, - 717,715,1,0,0,0,717,718,1,0,0,0,718,726,1,0,0,0,719,723,3,103,44,0,720, - 722,3,63,24,0,721,720,1,0,0,0,722,725,1,0,0,0,723,721,1,0,0,0,723,724,1, - 0,0,0,724,727,1,0,0,0,725,723,1,0,0,0,726,719,1,0,0,0,726,727,1,0,0,0,727, - 728,1,0,0,0,728,729,3,71,28,0,729,739,1,0,0,0,730,732,3,103,44,0,731,733, - 3,63,24,0,732,731,1,0,0,0,733,734,1,0,0,0,734,732,1,0,0,0,734,735,1,0,0, - 0,735,736,1,0,0,0,736,737,3,71,28,0,737,739,1,0,0,0,738,697,1,0,0,0,738, - 708,1,0,0,0,738,715,1,0,0,0,738,730,1,0,0,0,739,88,1,0,0,0,740,741,7,30, - 0,0,741,742,7,31,0,0,742,90,1,0,0,0,743,744,7,12,0,0,744,745,7,9,0,0,745, - 746,7,0,0,0,746,92,1,0,0,0,747,748,7,12,0,0,748,749,7,2,0,0,749,750,7,4, - 0,0,750,94,1,0,0,0,751,752,5,61,0,0,752,96,1,0,0,0,753,754,5,58,0,0,754, - 755,5,58,0,0,755,98,1,0,0,0,756,757,5,44,0,0,757,100,1,0,0,0,758,759,7, - 0,0,0,759,760,7,3,0,0,760,761,7,2,0,0,761,762,7,4,0,0,762,102,1,0,0,0,763, - 764,5,46,0,0,764,104,1,0,0,0,765,766,7,15,0,0,766,767,7,12,0,0,767,768, - 7,13,0,0,768,769,7,2,0,0,769,770,7,3,0,0,770,106,1,0,0,0,771,772,7,15,0, - 0,772,773,7,1,0,0,773,774,7,6,0,0,774,775,7,2,0,0,775,776,7,5,0,0,776,108, - 1,0,0,0,777,778,7,1,0,0,778,779,7,9,0,0,779,110,1,0,0,0,780,781,7,1,0,0, - 781,782,7,2,0,0,782,112,1,0,0,0,783,784,7,13,0,0,784,785,7,12,0,0,785,786, - 7,2,0,0,786,787,7,5,0,0,787,114,1,0,0,0,788,789,7,13,0,0,789,790,7,1,0, - 0,790,791,7,18,0,0,791,792,7,3,0,0,792,116,1,0,0,0,793,794,5,40,0,0,794, - 118,1,0,0,0,795,796,7,9,0,0,796,797,7,7,0,0,797,798,7,5,0,0,798,120,1,0, - 0,0,799,800,7,9,0,0,800,801,7,20,0,0,801,802,7,13,0,0,802,803,7,13,0,0, - 803,122,1,0,0,0,804,805,7,9,0,0,805,806,7,20,0,0,806,807,7,13,0,0,807,808, - 7,13,0,0,808,809,7,2,0,0,809,124,1,0,0,0,810,811,7,7,0,0,811,812,7,6,0, - 0,812,126,1,0,0,0,813,814,5,63,0,0,814,128,1,0,0,0,815,816,7,6,0,0,816, - 817,7,13,0,0,817,818,7,1,0,0,818,819,7,18,0,0,819,820,7,3,0,0,820,130,1, - 0,0,0,821,822,5,41,0,0,822,132,1,0,0,0,823,824,7,5,0,0,824,825,7,6,0,0, - 825,826,7,20,0,0,826,827,7,3,0,0,827,134,1,0,0,0,828,829,5,61,0,0,829,830, - 5,61,0,0,830,136,1,0,0,0,831,832,5,61,0,0,832,833,5,126,0,0,833,138,1,0, - 0,0,834,835,5,33,0,0,835,836,5,61,0,0,836,140,1,0,0,0,837,838,5,60,0,0, - 838,142,1,0,0,0,839,840,5,60,0,0,840,841,5,61,0,0,841,144,1,0,0,0,842,843, - 5,62,0,0,843,146,1,0,0,0,844,845,5,62,0,0,845,846,5,61,0,0,846,148,1,0, - 0,0,847,848,5,43,0,0,848,150,1,0,0,0,849,850,5,45,0,0,850,152,1,0,0,0,851, - 852,5,42,0,0,852,154,1,0,0,0,853,854,5,47,0,0,854,156,1,0,0,0,855,856,5, - 37,0,0,856,158,1,0,0,0,857,858,7,16,0,0,858,859,7,12,0,0,859,860,7,5,0, - 0,860,861,7,4,0,0,861,862,7,10,0,0,862,160,1,0,0,0,863,864,3,45,15,0,864, - 865,1,0,0,0,865,866,6,73,12,0,866,162,1,0,0,0,867,870,3,127,56,0,868,871, - 3,65,25,0,869,871,3,79,32,0,870,868,1,0,0,0,870,869,1,0,0,0,871,875,1,0, - 0,0,872,874,3,81,33,0,873,872,1,0,0,0,874,877,1,0,0,0,875,873,1,0,0,0,875, - 876,1,0,0,0,876,885,1,0,0,0,877,875,1,0,0,0,878,880,3,127,56,0,879,881, - 3,63,24,0,880,879,1,0,0,0,881,882,1,0,0,0,882,880,1,0,0,0,882,883,1,0,0, - 0,883,885,1,0,0,0,884,867,1,0,0,0,884,878,1,0,0,0,885,164,1,0,0,0,886,887, - 5,91,0,0,887,888,1,0,0,0,888,889,6,75,0,0,889,890,6,75,0,0,890,166,1,0, - 0,0,891,892,5,93,0,0,892,893,1,0,0,0,893,894,6,76,11,0,894,895,6,76,11, - 0,895,168,1,0,0,0,896,900,3,65,25,0,897,899,3,81,33,0,898,897,1,0,0,0,899, - 902,1,0,0,0,900,898,1,0,0,0,900,901,1,0,0,0,901,913,1,0,0,0,902,900,1,0, - 0,0,903,906,3,79,32,0,904,906,3,73,29,0,905,903,1,0,0,0,905,904,1,0,0,0, - 906,908,1,0,0,0,907,909,3,81,33,0,908,907,1,0,0,0,909,910,1,0,0,0,910,908, - 1,0,0,0,910,911,1,0,0,0,911,913,1,0,0,0,912,896,1,0,0,0,912,905,1,0,0,0, - 913,170,1,0,0,0,914,916,3,75,30,0,915,917,3,77,31,0,916,915,1,0,0,0,917, - 918,1,0,0,0,918,916,1,0,0,0,918,919,1,0,0,0,919,920,1,0,0,0,920,921,3,75, - 30,0,921,172,1,0,0,0,922,923,3,171,78,0,923,174,1,0,0,0,924,925,3,55,20, - 0,925,926,1,0,0,0,926,927,6,80,10,0,927,176,1,0,0,0,928,929,3,57,21,0,929, - 930,1,0,0,0,930,931,6,81,10,0,931,178,1,0,0,0,932,933,3,59,22,0,933,934, - 1,0,0,0,934,935,6,82,10,0,935,180,1,0,0,0,936,937,3,165,75,0,937,938,1, - 0,0,0,938,939,6,83,13,0,939,940,6,83,14,0,940,182,1,0,0,0,941,942,3,61, - 23,0,942,943,1,0,0,0,943,944,6,84,15,0,944,945,6,84,11,0,945,184,1,0,0, - 0,946,947,3,59,22,0,947,948,1,0,0,0,948,949,6,85,10,0,949,186,1,0,0,0,950, - 951,3,55,20,0,951,952,1,0,0,0,952,953,6,86,10,0,953,188,1,0,0,0,954,955, - 3,57,21,0,955,956,1,0,0,0,956,957,6,87,10,0,957,190,1,0,0,0,958,959,3,61, - 23,0,959,960,1,0,0,0,960,961,6,88,15,0,961,962,6,88,11,0,962,192,1,0,0, - 0,963,964,3,165,75,0,964,965,1,0,0,0,965,966,6,89,13,0,966,194,1,0,0,0, - 967,968,3,167,76,0,968,969,1,0,0,0,969,970,6,90,16,0,970,196,1,0,0,0,971, - 972,3,337,161,0,972,973,1,0,0,0,973,974,6,91,17,0,974,198,1,0,0,0,975,976, - 3,99,42,0,976,977,1,0,0,0,977,978,6,92,18,0,978,200,1,0,0,0,979,980,3,95, - 40,0,980,981,1,0,0,0,981,982,6,93,19,0,982,202,1,0,0,0,983,984,7,16,0,0, - 984,985,7,3,0,0,985,986,7,5,0,0,986,987,7,12,0,0,987,988,7,0,0,0,988,989, - 7,12,0,0,989,990,7,5,0,0,990,991,7,12,0,0,991,204,1,0,0,0,992,996,8,32, - 0,0,993,994,5,47,0,0,994,996,8,33,0,0,995,992,1,0,0,0,995,993,1,0,0,0,996, - 206,1,0,0,0,997,999,3,205,95,0,998,997,1,0,0,0,999,1000,1,0,0,0,1000,998, - 1,0,0,0,1000,1001,1,0,0,0,1001,208,1,0,0,0,1002,1003,3,207,96,0,1003,1004, - 1,0,0,0,1004,1005,6,97,20,0,1005,210,1,0,0,0,1006,1007,3,83,34,0,1007,1008, - 1,0,0,0,1008,1009,6,98,21,0,1009,212,1,0,0,0,1010,1011,3,55,20,0,1011,1012, - 1,0,0,0,1012,1013,6,99,10,0,1013,214,1,0,0,0,1014,1015,3,57,21,0,1015,1016, - 1,0,0,0,1016,1017,6,100,10,0,1017,216,1,0,0,0,1018,1019,3,59,22,0,1019, - 1020,1,0,0,0,1020,1021,6,101,10,0,1021,218,1,0,0,0,1022,1023,3,61,23,0, - 1023,1024,1,0,0,0,1024,1025,6,102,15,0,1025,1026,6,102,11,0,1026,220,1, - 0,0,0,1027,1028,3,103,44,0,1028,1029,1,0,0,0,1029,1030,6,103,22,0,1030, - 222,1,0,0,0,1031,1032,3,99,42,0,1032,1033,1,0,0,0,1033,1034,6,104,18,0, - 1034,224,1,0,0,0,1035,1036,4,105,3,0,1036,1037,3,127,56,0,1037,1038,1,0, - 0,0,1038,1039,6,105,23,0,1039,226,1,0,0,0,1040,1041,4,106,4,0,1041,1042, - 3,163,74,0,1042,1043,1,0,0,0,1043,1044,6,106,24,0,1044,228,1,0,0,0,1045, - 1050,3,65,25,0,1046,1050,3,63,24,0,1047,1050,3,79,32,0,1048,1050,3,153, - 69,0,1049,1045,1,0,0,0,1049,1046,1,0,0,0,1049,1047,1,0,0,0,1049,1048,1, - 0,0,0,1050,230,1,0,0,0,1051,1054,3,65,25,0,1052,1054,3,153,69,0,1053,1051, - 1,0,0,0,1053,1052,1,0,0,0,1054,1058,1,0,0,0,1055,1057,3,229,107,0,1056, - 1055,1,0,0,0,1057,1060,1,0,0,0,1058,1056,1,0,0,0,1058,1059,1,0,0,0,1059, - 1071,1,0,0,0,1060,1058,1,0,0,0,1061,1064,3,79,32,0,1062,1064,3,73,29,0, - 1063,1061,1,0,0,0,1063,1062,1,0,0,0,1064,1066,1,0,0,0,1065,1067,3,229,107, - 0,1066,1065,1,0,0,0,1067,1068,1,0,0,0,1068,1066,1,0,0,0,1068,1069,1,0,0, - 0,1069,1071,1,0,0,0,1070,1053,1,0,0,0,1070,1063,1,0,0,0,1071,232,1,0,0, - 0,1072,1075,3,231,108,0,1073,1075,3,171,78,0,1074,1072,1,0,0,0,1074,1073, - 1,0,0,0,1075,1076,1,0,0,0,1076,1074,1,0,0,0,1076,1077,1,0,0,0,1077,234, - 1,0,0,0,1078,1079,3,55,20,0,1079,1080,1,0,0,0,1080,1081,6,110,10,0,1081, - 236,1,0,0,0,1082,1083,3,57,21,0,1083,1084,1,0,0,0,1084,1085,6,111,10,0, - 1085,238,1,0,0,0,1086,1087,3,59,22,0,1087,1088,1,0,0,0,1088,1089,6,112, - 10,0,1089,240,1,0,0,0,1090,1091,3,61,23,0,1091,1092,1,0,0,0,1092,1093,6, - 113,15,0,1093,1094,6,113,11,0,1094,242,1,0,0,0,1095,1096,3,95,40,0,1096, - 1097,1,0,0,0,1097,1098,6,114,19,0,1098,244,1,0,0,0,1099,1100,3,99,42,0, - 1100,1101,1,0,0,0,1101,1102,6,115,18,0,1102,246,1,0,0,0,1103,1104,3,103, - 44,0,1104,1105,1,0,0,0,1105,1106,6,116,22,0,1106,248,1,0,0,0,1107,1108, - 4,117,5,0,1108,1109,3,127,56,0,1109,1110,1,0,0,0,1110,1111,6,117,23,0,1111, - 250,1,0,0,0,1112,1113,4,118,6,0,1113,1114,3,163,74,0,1114,1115,1,0,0,0, - 1115,1116,6,118,24,0,1116,252,1,0,0,0,1117,1118,7,12,0,0,1118,1119,7,2, - 0,0,1119,254,1,0,0,0,1120,1121,3,233,109,0,1121,1122,1,0,0,0,1122,1123, - 6,120,25,0,1123,256,1,0,0,0,1124,1125,3,55,20,0,1125,1126,1,0,0,0,1126, - 1127,6,121,10,0,1127,258,1,0,0,0,1128,1129,3,57,21,0,1129,1130,1,0,0,0, - 1130,1131,6,122,10,0,1131,260,1,0,0,0,1132,1133,3,59,22,0,1133,1134,1,0, - 0,0,1134,1135,6,123,10,0,1135,262,1,0,0,0,1136,1137,3,61,23,0,1137,1138, - 1,0,0,0,1138,1139,6,124,15,0,1139,1140,6,124,11,0,1140,264,1,0,0,0,1141, - 1142,3,165,75,0,1142,1143,1,0,0,0,1143,1144,6,125,13,0,1144,1145,6,125, - 26,0,1145,266,1,0,0,0,1146,1147,7,7,0,0,1147,1148,7,9,0,0,1148,1149,1,0, - 0,0,1149,1150,6,126,27,0,1150,268,1,0,0,0,1151,1152,7,19,0,0,1152,1153, - 7,1,0,0,1153,1154,7,5,0,0,1154,1155,7,10,0,0,1155,1156,1,0,0,0,1156,1157, - 6,127,27,0,1157,270,1,0,0,0,1158,1159,8,34,0,0,1159,272,1,0,0,0,1160,1162, - 3,271,128,0,1161,1160,1,0,0,0,1162,1163,1,0,0,0,1163,1161,1,0,0,0,1163, - 1164,1,0,0,0,1164,1165,1,0,0,0,1165,1166,3,337,161,0,1166,1168,1,0,0,0, - 1167,1161,1,0,0,0,1167,1168,1,0,0,0,1168,1170,1,0,0,0,1169,1171,3,271,128, - 0,1170,1169,1,0,0,0,1171,1172,1,0,0,0,1172,1170,1,0,0,0,1172,1173,1,0,0, - 0,1173,274,1,0,0,0,1174,1175,3,273,129,0,1175,1176,1,0,0,0,1176,1177,6, - 130,28,0,1177,276,1,0,0,0,1178,1179,3,55,20,0,1179,1180,1,0,0,0,1180,1181, - 6,131,10,0,1181,278,1,0,0,0,1182,1183,3,57,21,0,1183,1184,1,0,0,0,1184, - 1185,6,132,10,0,1185,280,1,0,0,0,1186,1187,3,59,22,0,1187,1188,1,0,0,0, - 1188,1189,6,133,10,0,1189,282,1,0,0,0,1190,1191,3,61,23,0,1191,1192,1,0, - 0,0,1192,1193,6,134,15,0,1193,1194,6,134,11,0,1194,1195,6,134,11,0,1195, - 284,1,0,0,0,1196,1197,3,95,40,0,1197,1198,1,0,0,0,1198,1199,6,135,19,0, - 1199,286,1,0,0,0,1200,1201,3,99,42,0,1201,1202,1,0,0,0,1202,1203,6,136, - 18,0,1203,288,1,0,0,0,1204,1205,3,103,44,0,1205,1206,1,0,0,0,1206,1207, - 6,137,22,0,1207,290,1,0,0,0,1208,1209,3,269,127,0,1209,1210,1,0,0,0,1210, - 1211,6,138,29,0,1211,292,1,0,0,0,1212,1213,3,233,109,0,1213,1214,1,0,0, - 0,1214,1215,6,139,25,0,1215,294,1,0,0,0,1216,1217,3,173,79,0,1217,1218, - 1,0,0,0,1218,1219,6,140,30,0,1219,296,1,0,0,0,1220,1221,4,141,7,0,1221, - 1222,3,127,56,0,1222,1223,1,0,0,0,1223,1224,6,141,23,0,1224,298,1,0,0,0, - 1225,1226,4,142,8,0,1226,1227,3,163,74,0,1227,1228,1,0,0,0,1228,1229,6, - 142,24,0,1229,300,1,0,0,0,1230,1231,3,55,20,0,1231,1232,1,0,0,0,1232,1233, - 6,143,10,0,1233,302,1,0,0,0,1234,1235,3,57,21,0,1235,1236,1,0,0,0,1236, - 1237,6,144,10,0,1237,304,1,0,0,0,1238,1239,3,59,22,0,1239,1240,1,0,0,0, - 1240,1241,6,145,10,0,1241,306,1,0,0,0,1242,1243,3,61,23,0,1243,1244,1,0, - 0,0,1244,1245,6,146,15,0,1245,1246,6,146,11,0,1246,308,1,0,0,0,1247,1248, - 3,103,44,0,1248,1249,1,0,0,0,1249,1250,6,147,22,0,1250,310,1,0,0,0,1251, - 1252,4,148,9,0,1252,1253,3,127,56,0,1253,1254,1,0,0,0,1254,1255,6,148,23, - 0,1255,312,1,0,0,0,1256,1257,4,149,10,0,1257,1258,3,163,74,0,1258,1259, - 1,0,0,0,1259,1260,6,149,24,0,1260,314,1,0,0,0,1261,1262,3,173,79,0,1262, - 1263,1,0,0,0,1263,1264,6,150,30,0,1264,316,1,0,0,0,1265,1266,3,169,77,0, - 1266,1267,1,0,0,0,1267,1268,6,151,31,0,1268,318,1,0,0,0,1269,1270,3,55, - 20,0,1270,1271,1,0,0,0,1271,1272,6,152,10,0,1272,320,1,0,0,0,1273,1274, - 3,57,21,0,1274,1275,1,0,0,0,1275,1276,6,153,10,0,1276,322,1,0,0,0,1277, - 1278,3,59,22,0,1278,1279,1,0,0,0,1279,1280,6,154,10,0,1280,324,1,0,0,0, - 1281,1282,3,61,23,0,1282,1283,1,0,0,0,1283,1284,6,155,15,0,1284,1285,6, - 155,11,0,1285,326,1,0,0,0,1286,1287,7,1,0,0,1287,1288,7,9,0,0,1288,1289, - 7,15,0,0,1289,1290,7,7,0,0,1290,328,1,0,0,0,1291,1292,3,55,20,0,1292,1293, - 1,0,0,0,1293,1294,6,157,10,0,1294,330,1,0,0,0,1295,1296,3,57,21,0,1296, - 1297,1,0,0,0,1297,1298,6,158,10,0,1298,332,1,0,0,0,1299,1300,3,59,22,0, - 1300,1301,1,0,0,0,1301,1302,6,159,10,0,1302,334,1,0,0,0,1303,1304,3,167, - 76,0,1304,1305,1,0,0,0,1305,1306,6,160,16,0,1306,1307,6,160,11,0,1307,336, - 1,0,0,0,1308,1309,5,58,0,0,1309,338,1,0,0,0,1310,1316,3,73,29,0,1311,1316, - 3,63,24,0,1312,1316,3,103,44,0,1313,1316,3,65,25,0,1314,1316,3,79,32,0, - 1315,1310,1,0,0,0,1315,1311,1,0,0,0,1315,1312,1,0,0,0,1315,1313,1,0,0,0, - 1315,1314,1,0,0,0,1316,1317,1,0,0,0,1317,1315,1,0,0,0,1317,1318,1,0,0,0, - 1318,340,1,0,0,0,1319,1320,3,55,20,0,1320,1321,1,0,0,0,1321,1322,6,163, - 10,0,1322,342,1,0,0,0,1323,1324,3,57,21,0,1324,1325,1,0,0,0,1325,1326,6, - 164,10,0,1326,344,1,0,0,0,1327,1328,3,59,22,0,1328,1329,1,0,0,0,1329,1330, - 6,165,10,0,1330,346,1,0,0,0,1331,1332,3,61,23,0,1332,1333,1,0,0,0,1333, - 1334,6,166,15,0,1334,1335,6,166,11,0,1335,348,1,0,0,0,1336,1337,3,337,161, - 0,1337,1338,1,0,0,0,1338,1339,6,167,17,0,1339,350,1,0,0,0,1340,1341,3,99, - 42,0,1341,1342,1,0,0,0,1342,1343,6,168,18,0,1343,352,1,0,0,0,1344,1345, - 3,103,44,0,1345,1346,1,0,0,0,1346,1347,6,169,22,0,1347,354,1,0,0,0,1348, - 1349,3,267,126,0,1349,1350,1,0,0,0,1350,1351,6,170,32,0,1351,1352,6,170, - 33,0,1352,356,1,0,0,0,1353,1354,3,207,96,0,1354,1355,1,0,0,0,1355,1356, - 6,171,20,0,1356,358,1,0,0,0,1357,1358,3,83,34,0,1358,1359,1,0,0,0,1359, - 1360,6,172,21,0,1360,360,1,0,0,0,1361,1362,3,55,20,0,1362,1363,1,0,0,0, - 1363,1364,6,173,10,0,1364,362,1,0,0,0,1365,1366,3,57,21,0,1366,1367,1,0, - 0,0,1367,1368,6,174,10,0,1368,364,1,0,0,0,1369,1370,3,59,22,0,1370,1371, - 1,0,0,0,1371,1372,6,175,10,0,1372,366,1,0,0,0,1373,1374,3,61,23,0,1374, - 1375,1,0,0,0,1375,1376,6,176,15,0,1376,1377,6,176,11,0,1377,1378,6,176, - 11,0,1378,368,1,0,0,0,1379,1380,3,99,42,0,1380,1381,1,0,0,0,1381,1382,6, - 177,18,0,1382,370,1,0,0,0,1383,1384,3,103,44,0,1384,1385,1,0,0,0,1385,1386, - 6,178,22,0,1386,372,1,0,0,0,1387,1388,3,233,109,0,1388,1389,1,0,0,0,1389, - 1390,6,179,25,0,1390,374,1,0,0,0,1391,1392,3,55,20,0,1392,1393,1,0,0,0, - 1393,1394,6,180,10,0,1394,376,1,0,0,0,1395,1396,3,57,21,0,1396,1397,1,0, - 0,0,1397,1398,6,181,10,0,1398,378,1,0,0,0,1399,1400,3,59,22,0,1400,1401, - 1,0,0,0,1401,1402,6,182,10,0,1402,380,1,0,0,0,1403,1404,3,61,23,0,1404, - 1405,1,0,0,0,1405,1406,6,183,15,0,1406,1407,6,183,11,0,1407,382,1,0,0,0, - 1408,1409,3,207,96,0,1409,1410,1,0,0,0,1410,1411,6,184,20,0,1411,1412,6, - 184,11,0,1412,1413,6,184,34,0,1413,384,1,0,0,0,1414,1415,3,83,34,0,1415, - 1416,1,0,0,0,1416,1417,6,185,21,0,1417,1418,6,185,11,0,1418,1419,6,185, - 34,0,1419,386,1,0,0,0,1420,1421,3,55,20,0,1421,1422,1,0,0,0,1422,1423,6, - 186,10,0,1423,388,1,0,0,0,1424,1425,3,57,21,0,1425,1426,1,0,0,0,1426,1427, - 6,187,10,0,1427,390,1,0,0,0,1428,1429,3,59,22,0,1429,1430,1,0,0,0,1430, - 1431,6,188,10,0,1431,392,1,0,0,0,1432,1433,3,337,161,0,1433,1434,1,0,0, - 0,1434,1435,6,189,17,0,1435,1436,6,189,11,0,1436,1437,6,189,9,0,1437,394, - 1,0,0,0,1438,1439,3,99,42,0,1439,1440,1,0,0,0,1440,1441,6,190,18,0,1441, - 1442,6,190,11,0,1442,1443,6,190,9,0,1443,396,1,0,0,0,1444,1445,3,55,20, - 0,1445,1446,1,0,0,0,1446,1447,6,191,10,0,1447,398,1,0,0,0,1448,1449,3,57, - 21,0,1449,1450,1,0,0,0,1450,1451,6,192,10,0,1451,400,1,0,0,0,1452,1453, - 3,59,22,0,1453,1454,1,0,0,0,1454,1455,6,193,10,0,1455,402,1,0,0,0,1456, - 1457,3,173,79,0,1457,1458,1,0,0,0,1458,1459,6,194,11,0,1459,1460,6,194, - 0,0,1460,1461,6,194,30,0,1461,404,1,0,0,0,1462,1463,3,169,77,0,1463,1464, - 1,0,0,0,1464,1465,6,195,11,0,1465,1466,6,195,0,0,1466,1467,6,195,31,0,1467, - 406,1,0,0,0,1468,1469,3,89,37,0,1469,1470,1,0,0,0,1470,1471,6,196,11,0, - 1471,1472,6,196,0,0,1472,1473,6,196,35,0,1473,408,1,0,0,0,1474,1475,3,61, - 23,0,1475,1476,1,0,0,0,1476,1477,6,197,15,0,1477,1478,6,197,11,0,1478,410, - 1,0,0,0,65,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,579,589,593,596,605,607,618, - 637,642,651,658,663,665,676,684,687,689,694,699,705,712,717,723,726,734, - 738,870,875,882,884,900,905,910,912,918,995,1000,1049,1053,1058,1063,1068, - 1070,1074,1076,1163,1167,1172,1315,1317,36,5,1,0,5,4,0,5,6,0,5,2,0,5,3, - 0,5,8,0,5,5,0,5,9,0,5,11,0,5,13,0,0,1,0,4,0,0,7,16,0,7,65,0,5,0,0,7,24, - 0,7,66,0,7,104,0,7,33,0,7,31,0,7,76,0,7,25,0,7,35,0,7,47,0,7,64,0,7,80, - 0,5,10,0,5,7,0,7,90,0,7,89,0,7,68,0,7,67,0,7,88,0,5,12,0,5,14,0,7,28,0]; + 2,193,7,193,2,194,7,194,2,195,7,195,2,196,7,196,2,197,7,197,2,198,7,198, + 1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2, + 1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,3,1,3,1,3,1,3,1,3,1,3,1,3,1,4,1,4,1,4, + 1,4,1,4,1,4,1,4,1,4,1,4,1,4,1,5,1,5,1,5,1,5,1,5,1,5,1,5,1,6,1,6,1,6,1,6, + 1,6,1,6,1,6,1,7,1,7,1,7,1,7,1,7,1,7,1,7,1,8,1,8,1,8,1,8,1,8,1,8,1,8,1,8, + 1,9,1,9,1,9,1,9,1,9,1,9,1,9,1,9,1,9,1,9,1,9,1,9,1,10,1,10,1,10,1,10,1,10, + 1,10,1,10,1,10,1,10,1,11,1,11,1,11,1,11,1,11,1,11,1,12,1,12,1,12,1,12,1, + 12,1,12,1,12,1,13,1,13,1,13,1,13,1,13,1,13,1,13,1,14,1,14,1,14,1,14,1,14, + 1,14,1,14,1,14,1,15,1,15,1,15,1,15,1,15,1,15,1,15,1,15,1,16,1,16,1,16,1, + 16,1,16,1,16,1,16,1,16,1,16,1,16,1,16,1,16,1,16,1,16,1,16,1,17,1,17,1,17, + 1,17,1,17,1,17,1,17,1,17,1,17,1,17,1,18,1,18,1,18,1,18,1,18,1,18,1,18,1, + 18,1,18,1,18,1,18,1,19,4,19,580,8,19,11,19,12,19,581,1,19,1,19,1,20,1,20, + 1,20,1,20,5,20,590,8,20,10,20,12,20,593,9,20,1,20,3,20,596,8,20,1,20,3, + 20,599,8,20,1,20,1,20,1,21,1,21,1,21,1,21,1,21,5,21,608,8,21,10,21,12,21, + 611,9,21,1,21,1,21,1,21,1,21,1,21,1,22,4,22,619,8,22,11,22,12,22,620,1, + 22,1,22,1,23,1,23,1,24,1,24,1,24,1,24,1,25,1,25,1,26,1,26,1,27,1,27,1,27, + 1,28,1,28,1,29,1,29,3,29,642,8,29,1,29,4,29,645,8,29,11,29,12,29,646,1, + 30,1,30,1,31,1,31,1,32,1,32,1,32,3,32,656,8,32,1,33,1,33,1,34,1,34,1,34, + 3,34,663,8,34,1,35,1,35,1,35,5,35,668,8,35,10,35,12,35,671,9,35,1,35,1, + 35,1,35,1,35,1,35,1,35,5,35,679,8,35,10,35,12,35,682,9,35,1,35,1,35,1,35, + 1,35,1,35,3,35,689,8,35,1,35,3,35,692,8,35,3,35,694,8,35,1,36,4,36,697, + 8,36,11,36,12,36,698,1,37,4,37,702,8,37,11,37,12,37,703,1,37,1,37,5,37, + 708,8,37,10,37,12,37,711,9,37,1,37,1,37,4,37,715,8,37,11,37,12,37,716,1, + 37,4,37,720,8,37,11,37,12,37,721,1,37,1,37,5,37,726,8,37,10,37,12,37,729, + 9,37,3,37,731,8,37,1,37,1,37,1,37,1,37,4,37,737,8,37,11,37,12,37,738,1, + 37,1,37,3,37,743,8,37,1,38,1,38,1,38,1,39,1,39,1,39,1,39,1,40,1,40,1,40, + 1,40,1,41,1,41,1,42,1,42,1,42,1,43,1,43,1,44,1,44,1,44,1,44,1,44,1,45,1, + 45,1,46,1,46,1,46,1,46,1,46,1,46,1,47,1,47,1,47,1,47,1,47,1,47,1,48,1,48, + 1,48,1,49,1,49,1,49,1,50,1,50,1,50,1,50,1,50,1,51,1,51,1,51,1,51,1,51,1, + 52,1,52,1,53,1,53,1,53,1,53,1,54,1,54,1,54,1,54,1,54,1,55,1,55,1,55,1,55, + 1,55,1,55,1,56,1,56,1,56,1,57,1,57,1,58,1,58,1,58,1,58,1,58,1,58,1,59,1, + 59,1,60,1,60,1,60,1,60,1,60,1,61,1,61,1,61,1,62,1,62,1,62,1,63,1,63,1,63, + 1,64,1,64,1,65,1,65,1,65,1,66,1,66,1,67,1,67,1,67,1,68,1,68,1,69,1,69,1, + 70,1,70,1,71,1,71,1,72,1,72,1,73,1,73,1,73,1,73,1,73,1,74,1,74,1,74,1,74, + 1,75,1,75,1,75,3,75,874,8,75,1,75,5,75,877,8,75,10,75,12,75,880,9,75,1, + 75,1,75,4,75,884,8,75,11,75,12,75,885,3,75,888,8,75,1,76,1,76,1,76,1,76, + 1,76,1,77,1,77,1,77,1,77,1,77,1,78,1,78,5,78,902,8,78,10,78,12,78,905,9, + 78,1,78,1,78,3,78,909,8,78,1,78,4,78,912,8,78,11,78,12,78,913,3,78,916, + 8,78,1,79,1,79,4,79,920,8,79,11,79,12,79,921,1,79,1,79,1,80,1,80,1,81,1, + 81,1,81,1,81,1,82,1,82,1,82,1,82,1,83,1,83,1,83,1,83,1,84,1,84,1,84,1,84, + 1,84,1,85,1,85,1,85,1,85,1,85,1,86,1,86,1,86,1,86,1,87,1,87,1,87,1,87,1, + 88,1,88,1,88,1,88,1,89,1,89,1,89,1,89,1,89,1,90,1,90,1,90,1,90,1,91,1,91, + 1,91,1,91,1,92,1,92,1,92,1,92,1,93,1,93,1,93,1,93,1,94,1,94,1,94,1,94,1, + 95,1,95,1,95,1,95,1,95,1,95,1,95,1,95,1,95,1,96,1,96,1,96,3,96,999,8,96, + 1,97,4,97,1002,8,97,11,97,12,97,1003,1,98,1,98,1,98,1,98,1,99,1,99,1,99, + 1,99,1,100,1,100,1,100,1,100,1,101,1,101,1,101,1,101,1,102,1,102,1,102, + 1,102,1,103,1,103,1,103,1,103,1,103,1,104,1,104,1,104,1,104,1,105,1,105, + 1,105,1,105,1,106,1,106,1,106,1,106,1,106,1,107,1,107,1,107,1,107,1,107, + 1,108,1,108,1,108,1,108,3,108,1053,8,108,1,109,1,109,3,109,1057,8,109,1, + 109,5,109,1060,8,109,10,109,12,109,1063,9,109,1,109,1,109,3,109,1067,8, + 109,1,109,4,109,1070,8,109,11,109,12,109,1071,3,109,1074,8,109,1,110,1, + 110,4,110,1078,8,110,11,110,12,110,1079,1,111,1,111,1,111,1,111,1,112,1, + 112,1,112,1,112,1,113,1,113,1,113,1,113,1,114,1,114,1,114,1,114,1,114,1, + 115,1,115,1,115,1,115,1,116,1,116,1,116,1,116,1,117,1,117,1,117,1,117,1, + 118,1,118,1,118,1,118,1,118,1,119,1,119,1,119,1,119,1,119,1,120,1,120,1, + 120,1,121,1,121,1,121,1,121,1,122,1,122,1,122,1,122,1,123,1,123,1,123,1, + 123,1,124,1,124,1,124,1,124,1,125,1,125,1,125,1,125,1,125,1,126,1,126,1, + 126,1,126,1,126,1,127,1,127,1,127,1,127,1,127,1,128,1,128,1,128,1,128,1, + 128,1,128,1,128,1,129,1,129,1,130,4,130,1165,8,130,11,130,12,130,1166,1, + 130,1,130,3,130,1171,8,130,1,130,4,130,1174,8,130,11,130,12,130,1175,1, + 131,1,131,1,131,1,131,1,132,1,132,1,132,1,132,1,133,1,133,1,133,1,133,1, + 134,1,134,1,134,1,134,1,135,1,135,1,135,1,135,1,135,1,135,1,136,1,136,1, + 136,1,136,1,137,1,137,1,137,1,137,1,138,1,138,1,138,1,138,1,139,1,139,1, + 139,1,139,1,140,1,140,1,140,1,140,1,141,1,141,1,141,1,141,1,142,1,142,1, + 142,1,142,1,142,1,143,1,143,1,143,1,143,1,143,1,144,1,144,1,144,1,144,1, + 145,1,145,1,145,1,145,1,146,1,146,1,146,1,146,1,147,1,147,1,147,1,147,1, + 147,1,148,1,148,1,148,1,148,1,149,1,149,1,149,1,149,1,149,1,150,1,150,1, + 150,1,150,1,150,1,151,1,151,1,151,1,151,1,152,1,152,1,152,1,152,1,153,1, + 153,1,153,1,153,1,154,1,154,1,154,1,154,1,155,1,155,1,155,1,155,1,156,1, + 156,1,156,1,156,1,156,1,157,1,157,1,157,1,157,1,157,1,158,1,158,1,158,1, + 158,1,159,1,159,1,159,1,159,1,160,1,160,1,160,1,160,1,161,1,161,1,161,1, + 161,1,161,1,162,1,162,1,162,1,162,1,163,1,163,1,163,1,163,1,163,4,163,1321, + 8,163,11,163,12,163,1322,1,164,1,164,1,164,1,164,1,165,1,165,1,165,1,165, + 1,166,1,166,1,166,1,166,1,167,1,167,1,167,1,167,1,167,1,168,1,168,1,168, + 1,168,1,169,1,169,1,169,1,169,1,170,1,170,1,170,1,170,1,171,1,171,1,171, + 1,171,1,171,1,172,1,172,1,172,1,172,1,173,1,173,1,173,1,173,1,174,1,174, + 1,174,1,174,1,175,1,175,1,175,1,175,1,176,1,176,1,176,1,176,1,177,1,177, + 1,177,1,177,1,177,1,177,1,178,1,178,1,178,1,178,1,179,1,179,1,179,1,179, + 1,180,1,180,1,180,1,180,1,181,1,181,1,181,1,181,1,182,1,182,1,182,1,182, + 1,183,1,183,1,183,1,183,1,184,1,184,1,184,1,184,1,184,1,185,1,185,1,185, + 1,185,1,185,1,185,1,186,1,186,1,186,1,186,1,186,1,186,1,187,1,187,1,187, + 1,187,1,188,1,188,1,188,1,188,1,189,1,189,1,189,1,189,1,190,1,190,1,190, + 1,190,1,190,1,190,1,191,1,191,1,191,1,191,1,191,1,191,1,192,1,192,1,192, + 1,192,1,193,1,193,1,193,1,193,1,194,1,194,1,194,1,194,1,195,1,195,1,195, + 1,195,1,195,1,195,1,196,1,196,1,196,1,196,1,196,1,196,1,197,1,197,1,197, + 1,197,1,197,1,197,1,198,1,198,1,198,1,198,1,198,2,609,680,0,199,15,1,17, + 2,19,3,21,4,23,5,25,6,27,7,29,8,31,9,33,10,35,11,37,12,39,13,41,14,43,15, + 45,16,47,17,49,18,51,19,53,20,55,21,57,22,59,23,61,24,63,25,65,0,67,0,69, + 0,71,0,73,0,75,0,77,0,79,0,81,0,83,0,85,26,87,27,89,28,91,29,93,30,95,31, + 97,32,99,33,101,34,103,35,105,36,107,37,109,38,111,39,113,40,115,41,117, + 42,119,43,121,44,123,45,125,46,127,47,129,48,131,49,133,50,135,51,137,52, + 139,53,141,54,143,55,145,56,147,57,149,58,151,59,153,60,155,61,157,62,159, + 63,161,0,163,0,165,64,167,65,169,66,171,67,173,0,175,68,177,69,179,70,181, + 71,183,0,185,0,187,72,189,73,191,74,193,0,195,0,197,0,199,0,201,0,203,0, + 205,75,207,0,209,76,211,0,213,0,215,77,217,78,219,79,221,0,223,0,225,0, + 227,0,229,0,231,0,233,0,235,80,237,81,239,82,241,83,243,0,245,0,247,0,249, + 0,251,0,253,0,255,84,257,0,259,85,261,86,263,87,265,0,267,0,269,88,271, + 89,273,0,275,90,277,0,279,91,281,92,283,93,285,0,287,0,289,0,291,0,293, + 0,295,0,297,0,299,0,301,0,303,94,305,95,307,96,309,0,311,0,313,0,315,0, + 317,0,319,0,321,97,323,98,325,99,327,0,329,100,331,101,333,102,335,103, + 337,0,339,0,341,104,343,105,345,106,347,107,349,0,351,0,353,0,355,0,357, + 0,359,0,361,0,363,108,365,109,367,110,369,0,371,0,373,0,375,0,377,111,379, + 112,381,113,383,0,385,0,387,0,389,114,391,115,393,116,395,0,397,0,399,117, + 401,118,403,119,405,0,407,0,409,0,411,0,15,0,1,2,3,4,5,6,7,8,9,10,11,12, + 13,14,35,2,0,68,68,100,100,2,0,73,73,105,105,2,0,83,83,115,115,2,0,69,69, + 101,101,2,0,67,67,99,99,2,0,84,84,116,116,2,0,82,82,114,114,2,0,79,79,111, + 111,2,0,80,80,112,112,2,0,78,78,110,110,2,0,72,72,104,104,2,0,86,86,118, + 118,2,0,65,65,97,97,2,0,76,76,108,108,2,0,88,88,120,120,2,0,70,70,102,102, + 2,0,77,77,109,109,2,0,71,71,103,103,2,0,75,75,107,107,2,0,87,87,119,119, + 2,0,85,85,117,117,6,0,9,10,13,13,32,32,47,47,91,91,93,93,2,0,10,10,13,13, + 3,0,9,10,13,13,32,32,1,0,48,57,2,0,65,90,97,122,8,0,34,34,78,78,82,82,84, + 84,92,92,110,110,114,114,116,116,4,0,10,10,13,13,34,34,92,92,2,0,43,43, + 45,45,1,0,96,96,2,0,66,66,98,98,2,0,89,89,121,121,11,0,9,10,13,13,32,32, + 34,34,44,44,47,47,58,58,61,61,91,91,93,93,124,124,2,0,42,42,47,47,11,0, + 9,10,13,13,32,32,34,35,44,44,47,47,58,58,60,60,62,63,92,92,124,124,1512, + 0,15,1,0,0,0,0,17,1,0,0,0,0,19,1,0,0,0,0,21,1,0,0,0,0,23,1,0,0,0,0,25,1, + 0,0,0,0,27,1,0,0,0,0,29,1,0,0,0,0,31,1,0,0,0,0,33,1,0,0,0,0,35,1,0,0,0, + 0,37,1,0,0,0,0,39,1,0,0,0,0,41,1,0,0,0,0,43,1,0,0,0,0,45,1,0,0,0,0,47,1, + 0,0,0,0,49,1,0,0,0,0,51,1,0,0,0,0,53,1,0,0,0,0,55,1,0,0,0,0,57,1,0,0,0, + 0,59,1,0,0,0,0,61,1,0,0,0,1,63,1,0,0,0,1,85,1,0,0,0,1,87,1,0,0,0,1,89,1, + 0,0,0,1,91,1,0,0,0,1,93,1,0,0,0,1,95,1,0,0,0,1,97,1,0,0,0,1,99,1,0,0,0, + 1,101,1,0,0,0,1,103,1,0,0,0,1,105,1,0,0,0,1,107,1,0,0,0,1,109,1,0,0,0,1, + 111,1,0,0,0,1,113,1,0,0,0,1,115,1,0,0,0,1,117,1,0,0,0,1,119,1,0,0,0,1,121, + 1,0,0,0,1,123,1,0,0,0,1,125,1,0,0,0,1,127,1,0,0,0,1,129,1,0,0,0,1,131,1, + 0,0,0,1,133,1,0,0,0,1,135,1,0,0,0,1,137,1,0,0,0,1,139,1,0,0,0,1,141,1,0, + 0,0,1,143,1,0,0,0,1,145,1,0,0,0,1,147,1,0,0,0,1,149,1,0,0,0,1,151,1,0,0, + 0,1,153,1,0,0,0,1,155,1,0,0,0,1,157,1,0,0,0,1,159,1,0,0,0,1,161,1,0,0,0, + 1,163,1,0,0,0,1,165,1,0,0,0,1,167,1,0,0,0,1,169,1,0,0,0,1,171,1,0,0,0,1, + 175,1,0,0,0,1,177,1,0,0,0,1,179,1,0,0,0,1,181,1,0,0,0,2,183,1,0,0,0,2,185, + 1,0,0,0,2,187,1,0,0,0,2,189,1,0,0,0,2,191,1,0,0,0,3,193,1,0,0,0,3,195,1, + 0,0,0,3,197,1,0,0,0,3,199,1,0,0,0,3,201,1,0,0,0,3,203,1,0,0,0,3,205,1,0, + 0,0,3,209,1,0,0,0,3,211,1,0,0,0,3,213,1,0,0,0,3,215,1,0,0,0,3,217,1,0,0, + 0,3,219,1,0,0,0,4,221,1,0,0,0,4,223,1,0,0,0,4,225,1,0,0,0,4,227,1,0,0,0, + 4,229,1,0,0,0,4,235,1,0,0,0,4,237,1,0,0,0,4,239,1,0,0,0,4,241,1,0,0,0,5, + 243,1,0,0,0,5,245,1,0,0,0,5,247,1,0,0,0,5,249,1,0,0,0,5,251,1,0,0,0,5,253, + 1,0,0,0,5,255,1,0,0,0,5,257,1,0,0,0,5,259,1,0,0,0,5,261,1,0,0,0,5,263,1, + 0,0,0,6,265,1,0,0,0,6,267,1,0,0,0,6,269,1,0,0,0,6,271,1,0,0,0,6,275,1,0, + 0,0,6,277,1,0,0,0,6,279,1,0,0,0,6,281,1,0,0,0,6,283,1,0,0,0,7,285,1,0,0, + 0,7,287,1,0,0,0,7,289,1,0,0,0,7,291,1,0,0,0,7,293,1,0,0,0,7,295,1,0,0,0, + 7,297,1,0,0,0,7,299,1,0,0,0,7,301,1,0,0,0,7,303,1,0,0,0,7,305,1,0,0,0,7, + 307,1,0,0,0,8,309,1,0,0,0,8,311,1,0,0,0,8,313,1,0,0,0,8,315,1,0,0,0,8,317, + 1,0,0,0,8,319,1,0,0,0,8,321,1,0,0,0,8,323,1,0,0,0,8,325,1,0,0,0,9,327,1, + 0,0,0,9,329,1,0,0,0,9,331,1,0,0,0,9,333,1,0,0,0,9,335,1,0,0,0,10,337,1, + 0,0,0,10,339,1,0,0,0,10,341,1,0,0,0,10,343,1,0,0,0,10,345,1,0,0,0,10,347, + 1,0,0,0,11,349,1,0,0,0,11,351,1,0,0,0,11,353,1,0,0,0,11,355,1,0,0,0,11, + 357,1,0,0,0,11,359,1,0,0,0,11,361,1,0,0,0,11,363,1,0,0,0,11,365,1,0,0,0, + 11,367,1,0,0,0,12,369,1,0,0,0,12,371,1,0,0,0,12,373,1,0,0,0,12,375,1,0, + 0,0,12,377,1,0,0,0,12,379,1,0,0,0,12,381,1,0,0,0,13,383,1,0,0,0,13,385, + 1,0,0,0,13,387,1,0,0,0,13,389,1,0,0,0,13,391,1,0,0,0,13,393,1,0,0,0,14, + 395,1,0,0,0,14,397,1,0,0,0,14,399,1,0,0,0,14,401,1,0,0,0,14,403,1,0,0,0, + 14,405,1,0,0,0,14,407,1,0,0,0,14,409,1,0,0,0,14,411,1,0,0,0,15,413,1,0, + 0,0,17,423,1,0,0,0,19,430,1,0,0,0,21,439,1,0,0,0,23,446,1,0,0,0,25,456, + 1,0,0,0,27,463,1,0,0,0,29,470,1,0,0,0,31,477,1,0,0,0,33,485,1,0,0,0,35, + 497,1,0,0,0,37,506,1,0,0,0,39,512,1,0,0,0,41,519,1,0,0,0,43,526,1,0,0,0, + 45,534,1,0,0,0,47,542,1,0,0,0,49,557,1,0,0,0,51,567,1,0,0,0,53,579,1,0, + 0,0,55,585,1,0,0,0,57,602,1,0,0,0,59,618,1,0,0,0,61,624,1,0,0,0,63,626, + 1,0,0,0,65,630,1,0,0,0,67,632,1,0,0,0,69,634,1,0,0,0,71,637,1,0,0,0,73, + 639,1,0,0,0,75,648,1,0,0,0,77,650,1,0,0,0,79,655,1,0,0,0,81,657,1,0,0,0, + 83,662,1,0,0,0,85,693,1,0,0,0,87,696,1,0,0,0,89,742,1,0,0,0,91,744,1,0, + 0,0,93,747,1,0,0,0,95,751,1,0,0,0,97,755,1,0,0,0,99,757,1,0,0,0,101,760, + 1,0,0,0,103,762,1,0,0,0,105,767,1,0,0,0,107,769,1,0,0,0,109,775,1,0,0,0, + 111,781,1,0,0,0,113,784,1,0,0,0,115,787,1,0,0,0,117,792,1,0,0,0,119,797, + 1,0,0,0,121,799,1,0,0,0,123,803,1,0,0,0,125,808,1,0,0,0,127,814,1,0,0,0, + 129,817,1,0,0,0,131,819,1,0,0,0,133,825,1,0,0,0,135,827,1,0,0,0,137,832, + 1,0,0,0,139,835,1,0,0,0,141,838,1,0,0,0,143,841,1,0,0,0,145,843,1,0,0,0, + 147,846,1,0,0,0,149,848,1,0,0,0,151,851,1,0,0,0,153,853,1,0,0,0,155,855, + 1,0,0,0,157,857,1,0,0,0,159,859,1,0,0,0,161,861,1,0,0,0,163,866,1,0,0,0, + 165,887,1,0,0,0,167,889,1,0,0,0,169,894,1,0,0,0,171,915,1,0,0,0,173,917, + 1,0,0,0,175,925,1,0,0,0,177,927,1,0,0,0,179,931,1,0,0,0,181,935,1,0,0,0, + 183,939,1,0,0,0,185,944,1,0,0,0,187,949,1,0,0,0,189,953,1,0,0,0,191,957, + 1,0,0,0,193,961,1,0,0,0,195,966,1,0,0,0,197,970,1,0,0,0,199,974,1,0,0,0, + 201,978,1,0,0,0,203,982,1,0,0,0,205,986,1,0,0,0,207,998,1,0,0,0,209,1001, + 1,0,0,0,211,1005,1,0,0,0,213,1009,1,0,0,0,215,1013,1,0,0,0,217,1017,1,0, + 0,0,219,1021,1,0,0,0,221,1025,1,0,0,0,223,1030,1,0,0,0,225,1034,1,0,0,0, + 227,1038,1,0,0,0,229,1043,1,0,0,0,231,1052,1,0,0,0,233,1073,1,0,0,0,235, + 1077,1,0,0,0,237,1081,1,0,0,0,239,1085,1,0,0,0,241,1089,1,0,0,0,243,1093, + 1,0,0,0,245,1098,1,0,0,0,247,1102,1,0,0,0,249,1106,1,0,0,0,251,1110,1,0, + 0,0,253,1115,1,0,0,0,255,1120,1,0,0,0,257,1123,1,0,0,0,259,1127,1,0,0,0, + 261,1131,1,0,0,0,263,1135,1,0,0,0,265,1139,1,0,0,0,267,1144,1,0,0,0,269, + 1149,1,0,0,0,271,1154,1,0,0,0,273,1161,1,0,0,0,275,1170,1,0,0,0,277,1177, + 1,0,0,0,279,1181,1,0,0,0,281,1185,1,0,0,0,283,1189,1,0,0,0,285,1193,1,0, + 0,0,287,1199,1,0,0,0,289,1203,1,0,0,0,291,1207,1,0,0,0,293,1211,1,0,0,0, + 295,1215,1,0,0,0,297,1219,1,0,0,0,299,1223,1,0,0,0,301,1228,1,0,0,0,303, + 1233,1,0,0,0,305,1237,1,0,0,0,307,1241,1,0,0,0,309,1245,1,0,0,0,311,1250, + 1,0,0,0,313,1254,1,0,0,0,315,1259,1,0,0,0,317,1264,1,0,0,0,319,1268,1,0, + 0,0,321,1272,1,0,0,0,323,1276,1,0,0,0,325,1280,1,0,0,0,327,1284,1,0,0,0, + 329,1289,1,0,0,0,331,1294,1,0,0,0,333,1298,1,0,0,0,335,1302,1,0,0,0,337, + 1306,1,0,0,0,339,1311,1,0,0,0,341,1320,1,0,0,0,343,1324,1,0,0,0,345,1328, + 1,0,0,0,347,1332,1,0,0,0,349,1336,1,0,0,0,351,1341,1,0,0,0,353,1345,1,0, + 0,0,355,1349,1,0,0,0,357,1353,1,0,0,0,359,1358,1,0,0,0,361,1362,1,0,0,0, + 363,1366,1,0,0,0,365,1370,1,0,0,0,367,1374,1,0,0,0,369,1378,1,0,0,0,371, + 1384,1,0,0,0,373,1388,1,0,0,0,375,1392,1,0,0,0,377,1396,1,0,0,0,379,1400, + 1,0,0,0,381,1404,1,0,0,0,383,1408,1,0,0,0,385,1413,1,0,0,0,387,1419,1,0, + 0,0,389,1425,1,0,0,0,391,1429,1,0,0,0,393,1433,1,0,0,0,395,1437,1,0,0,0, + 397,1443,1,0,0,0,399,1449,1,0,0,0,401,1453,1,0,0,0,403,1457,1,0,0,0,405, + 1461,1,0,0,0,407,1467,1,0,0,0,409,1473,1,0,0,0,411,1479,1,0,0,0,413,414, + 7,0,0,0,414,415,7,1,0,0,415,416,7,2,0,0,416,417,7,2,0,0,417,418,7,3,0,0, + 418,419,7,4,0,0,419,420,7,5,0,0,420,421,1,0,0,0,421,422,6,0,0,0,422,16, + 1,0,0,0,423,424,7,0,0,0,424,425,7,6,0,0,425,426,7,7,0,0,426,427,7,8,0,0, + 427,428,1,0,0,0,428,429,6,1,1,0,429,18,1,0,0,0,430,431,7,3,0,0,431,432, + 7,9,0,0,432,433,7,6,0,0,433,434,7,1,0,0,434,435,7,4,0,0,435,436,7,10,0, + 0,436,437,1,0,0,0,437,438,6,2,2,0,438,20,1,0,0,0,439,440,7,3,0,0,440,441, + 7,11,0,0,441,442,7,12,0,0,442,443,7,13,0,0,443,444,1,0,0,0,444,445,6,3, + 0,0,445,22,1,0,0,0,446,447,7,3,0,0,447,448,7,14,0,0,448,449,7,8,0,0,449, + 450,7,13,0,0,450,451,7,12,0,0,451,452,7,1,0,0,452,453,7,9,0,0,453,454,1, + 0,0,0,454,455,6,4,3,0,455,24,1,0,0,0,456,457,7,15,0,0,457,458,7,6,0,0,458, + 459,7,7,0,0,459,460,7,16,0,0,460,461,1,0,0,0,461,462,6,5,4,0,462,26,1,0, + 0,0,463,464,7,17,0,0,464,465,7,6,0,0,465,466,7,7,0,0,466,467,7,18,0,0,467, + 468,1,0,0,0,468,469,6,6,0,0,469,28,1,0,0,0,470,471,7,18,0,0,471,472,7,3, + 0,0,472,473,7,3,0,0,473,474,7,8,0,0,474,475,1,0,0,0,475,476,6,7,1,0,476, + 30,1,0,0,0,477,478,7,13,0,0,478,479,7,1,0,0,479,480,7,16,0,0,480,481,7, + 1,0,0,481,482,7,5,0,0,482,483,1,0,0,0,483,484,6,8,0,0,484,32,1,0,0,0,485, + 486,7,16,0,0,486,487,7,11,0,0,487,488,5,95,0,0,488,489,7,3,0,0,489,490, + 7,14,0,0,490,491,7,8,0,0,491,492,7,12,0,0,492,493,7,9,0,0,493,494,7,0,0, + 0,494,495,1,0,0,0,495,496,6,9,5,0,496,34,1,0,0,0,497,498,7,6,0,0,498,499, + 7,3,0,0,499,500,7,9,0,0,500,501,7,12,0,0,501,502,7,16,0,0,502,503,7,3,0, + 0,503,504,1,0,0,0,504,505,6,10,6,0,505,36,1,0,0,0,506,507,7,6,0,0,507,508, + 7,7,0,0,508,509,7,19,0,0,509,510,1,0,0,0,510,511,6,11,0,0,511,38,1,0,0, + 0,512,513,7,2,0,0,513,514,7,10,0,0,514,515,7,7,0,0,515,516,7,19,0,0,516, + 517,1,0,0,0,517,518,6,12,7,0,518,40,1,0,0,0,519,520,7,2,0,0,520,521,7,7, + 0,0,521,522,7,6,0,0,522,523,7,5,0,0,523,524,1,0,0,0,524,525,6,13,0,0,525, + 42,1,0,0,0,526,527,7,2,0,0,527,528,7,5,0,0,528,529,7,12,0,0,529,530,7,5, + 0,0,530,531,7,2,0,0,531,532,1,0,0,0,532,533,6,14,0,0,533,44,1,0,0,0,534, + 535,7,19,0,0,535,536,7,10,0,0,536,537,7,3,0,0,537,538,7,6,0,0,538,539,7, + 3,0,0,539,540,1,0,0,0,540,541,6,15,0,0,541,46,1,0,0,0,542,543,4,16,0,0, + 543,544,7,1,0,0,544,545,7,9,0,0,545,546,7,13,0,0,546,547,7,1,0,0,547,548, + 7,9,0,0,548,549,7,3,0,0,549,550,7,2,0,0,550,551,7,5,0,0,551,552,7,12,0, + 0,552,553,7,5,0,0,553,554,7,2,0,0,554,555,1,0,0,0,555,556,6,16,0,0,556, + 48,1,0,0,0,557,558,4,17,1,0,558,559,7,13,0,0,559,560,7,7,0,0,560,561,7, + 7,0,0,561,562,7,18,0,0,562,563,7,20,0,0,563,564,7,8,0,0,564,565,1,0,0,0, + 565,566,6,17,8,0,566,50,1,0,0,0,567,568,4,18,2,0,568,569,7,16,0,0,569,570, + 7,3,0,0,570,571,7,5,0,0,571,572,7,6,0,0,572,573,7,1,0,0,573,574,7,4,0,0, + 574,575,7,2,0,0,575,576,1,0,0,0,576,577,6,18,9,0,577,52,1,0,0,0,578,580, + 8,21,0,0,579,578,1,0,0,0,580,581,1,0,0,0,581,579,1,0,0,0,581,582,1,0,0, + 0,582,583,1,0,0,0,583,584,6,19,0,0,584,54,1,0,0,0,585,586,5,47,0,0,586, + 587,5,47,0,0,587,591,1,0,0,0,588,590,8,22,0,0,589,588,1,0,0,0,590,593,1, + 0,0,0,591,589,1,0,0,0,591,592,1,0,0,0,592,595,1,0,0,0,593,591,1,0,0,0,594, + 596,5,13,0,0,595,594,1,0,0,0,595,596,1,0,0,0,596,598,1,0,0,0,597,599,5, + 10,0,0,598,597,1,0,0,0,598,599,1,0,0,0,599,600,1,0,0,0,600,601,6,20,10, + 0,601,56,1,0,0,0,602,603,5,47,0,0,603,604,5,42,0,0,604,609,1,0,0,0,605, + 608,3,57,21,0,606,608,9,0,0,0,607,605,1,0,0,0,607,606,1,0,0,0,608,611,1, + 0,0,0,609,610,1,0,0,0,609,607,1,0,0,0,610,612,1,0,0,0,611,609,1,0,0,0,612, + 613,5,42,0,0,613,614,5,47,0,0,614,615,1,0,0,0,615,616,6,21,10,0,616,58, + 1,0,0,0,617,619,7,23,0,0,618,617,1,0,0,0,619,620,1,0,0,0,620,618,1,0,0, + 0,620,621,1,0,0,0,621,622,1,0,0,0,622,623,6,22,10,0,623,60,1,0,0,0,624, + 625,5,58,0,0,625,62,1,0,0,0,626,627,5,124,0,0,627,628,1,0,0,0,628,629,6, + 24,11,0,629,64,1,0,0,0,630,631,7,24,0,0,631,66,1,0,0,0,632,633,7,25,0,0, + 633,68,1,0,0,0,634,635,5,92,0,0,635,636,7,26,0,0,636,70,1,0,0,0,637,638, + 8,27,0,0,638,72,1,0,0,0,639,641,7,3,0,0,640,642,7,28,0,0,641,640,1,0,0, + 0,641,642,1,0,0,0,642,644,1,0,0,0,643,645,3,65,25,0,644,643,1,0,0,0,645, + 646,1,0,0,0,646,644,1,0,0,0,646,647,1,0,0,0,647,74,1,0,0,0,648,649,5,64, + 0,0,649,76,1,0,0,0,650,651,5,96,0,0,651,78,1,0,0,0,652,656,8,29,0,0,653, + 654,5,96,0,0,654,656,5,96,0,0,655,652,1,0,0,0,655,653,1,0,0,0,656,80,1, + 0,0,0,657,658,5,95,0,0,658,82,1,0,0,0,659,663,3,67,26,0,660,663,3,65,25, + 0,661,663,3,81,33,0,662,659,1,0,0,0,662,660,1,0,0,0,662,661,1,0,0,0,663, + 84,1,0,0,0,664,669,5,34,0,0,665,668,3,69,27,0,666,668,3,71,28,0,667,665, + 1,0,0,0,667,666,1,0,0,0,668,671,1,0,0,0,669,667,1,0,0,0,669,670,1,0,0,0, + 670,672,1,0,0,0,671,669,1,0,0,0,672,694,5,34,0,0,673,674,5,34,0,0,674,675, + 5,34,0,0,675,676,5,34,0,0,676,680,1,0,0,0,677,679,8,22,0,0,678,677,1,0, + 0,0,679,682,1,0,0,0,680,681,1,0,0,0,680,678,1,0,0,0,681,683,1,0,0,0,682, + 680,1,0,0,0,683,684,5,34,0,0,684,685,5,34,0,0,685,686,5,34,0,0,686,688, + 1,0,0,0,687,689,5,34,0,0,688,687,1,0,0,0,688,689,1,0,0,0,689,691,1,0,0, + 0,690,692,5,34,0,0,691,690,1,0,0,0,691,692,1,0,0,0,692,694,1,0,0,0,693, + 664,1,0,0,0,693,673,1,0,0,0,694,86,1,0,0,0,695,697,3,65,25,0,696,695,1, + 0,0,0,697,698,1,0,0,0,698,696,1,0,0,0,698,699,1,0,0,0,699,88,1,0,0,0,700, + 702,3,65,25,0,701,700,1,0,0,0,702,703,1,0,0,0,703,701,1,0,0,0,703,704,1, + 0,0,0,704,705,1,0,0,0,705,709,3,105,45,0,706,708,3,65,25,0,707,706,1,0, + 0,0,708,711,1,0,0,0,709,707,1,0,0,0,709,710,1,0,0,0,710,743,1,0,0,0,711, + 709,1,0,0,0,712,714,3,105,45,0,713,715,3,65,25,0,714,713,1,0,0,0,715,716, + 1,0,0,0,716,714,1,0,0,0,716,717,1,0,0,0,717,743,1,0,0,0,718,720,3,65,25, + 0,719,718,1,0,0,0,720,721,1,0,0,0,721,719,1,0,0,0,721,722,1,0,0,0,722,730, + 1,0,0,0,723,727,3,105,45,0,724,726,3,65,25,0,725,724,1,0,0,0,726,729,1, + 0,0,0,727,725,1,0,0,0,727,728,1,0,0,0,728,731,1,0,0,0,729,727,1,0,0,0,730, + 723,1,0,0,0,730,731,1,0,0,0,731,732,1,0,0,0,732,733,3,73,29,0,733,743,1, + 0,0,0,734,736,3,105,45,0,735,737,3,65,25,0,736,735,1,0,0,0,737,738,1,0, + 0,0,738,736,1,0,0,0,738,739,1,0,0,0,739,740,1,0,0,0,740,741,3,73,29,0,741, + 743,1,0,0,0,742,701,1,0,0,0,742,712,1,0,0,0,742,719,1,0,0,0,742,734,1,0, + 0,0,743,90,1,0,0,0,744,745,7,30,0,0,745,746,7,31,0,0,746,92,1,0,0,0,747, + 748,7,12,0,0,748,749,7,9,0,0,749,750,7,0,0,0,750,94,1,0,0,0,751,752,7,12, + 0,0,752,753,7,2,0,0,753,754,7,4,0,0,754,96,1,0,0,0,755,756,5,61,0,0,756, + 98,1,0,0,0,757,758,5,58,0,0,758,759,5,58,0,0,759,100,1,0,0,0,760,761,5, + 44,0,0,761,102,1,0,0,0,762,763,7,0,0,0,763,764,7,3,0,0,764,765,7,2,0,0, + 765,766,7,4,0,0,766,104,1,0,0,0,767,768,5,46,0,0,768,106,1,0,0,0,769,770, + 7,15,0,0,770,771,7,12,0,0,771,772,7,13,0,0,772,773,7,2,0,0,773,774,7,3, + 0,0,774,108,1,0,0,0,775,776,7,15,0,0,776,777,7,1,0,0,777,778,7,6,0,0,778, + 779,7,2,0,0,779,780,7,5,0,0,780,110,1,0,0,0,781,782,7,1,0,0,782,783,7,9, + 0,0,783,112,1,0,0,0,784,785,7,1,0,0,785,786,7,2,0,0,786,114,1,0,0,0,787, + 788,7,13,0,0,788,789,7,12,0,0,789,790,7,2,0,0,790,791,7,5,0,0,791,116,1, + 0,0,0,792,793,7,13,0,0,793,794,7,1,0,0,794,795,7,18,0,0,795,796,7,3,0,0, + 796,118,1,0,0,0,797,798,5,40,0,0,798,120,1,0,0,0,799,800,7,9,0,0,800,801, + 7,7,0,0,801,802,7,5,0,0,802,122,1,0,0,0,803,804,7,9,0,0,804,805,7,20,0, + 0,805,806,7,13,0,0,806,807,7,13,0,0,807,124,1,0,0,0,808,809,7,9,0,0,809, + 810,7,20,0,0,810,811,7,13,0,0,811,812,7,13,0,0,812,813,7,2,0,0,813,126, + 1,0,0,0,814,815,7,7,0,0,815,816,7,6,0,0,816,128,1,0,0,0,817,818,5,63,0, + 0,818,130,1,0,0,0,819,820,7,6,0,0,820,821,7,13,0,0,821,822,7,1,0,0,822, + 823,7,18,0,0,823,824,7,3,0,0,824,132,1,0,0,0,825,826,5,41,0,0,826,134,1, + 0,0,0,827,828,7,5,0,0,828,829,7,6,0,0,829,830,7,20,0,0,830,831,7,3,0,0, + 831,136,1,0,0,0,832,833,5,61,0,0,833,834,5,61,0,0,834,138,1,0,0,0,835,836, + 5,61,0,0,836,837,5,126,0,0,837,140,1,0,0,0,838,839,5,33,0,0,839,840,5,61, + 0,0,840,142,1,0,0,0,841,842,5,60,0,0,842,144,1,0,0,0,843,844,5,60,0,0,844, + 845,5,61,0,0,845,146,1,0,0,0,846,847,5,62,0,0,847,148,1,0,0,0,848,849,5, + 62,0,0,849,850,5,61,0,0,850,150,1,0,0,0,851,852,5,43,0,0,852,152,1,0,0, + 0,853,854,5,45,0,0,854,154,1,0,0,0,855,856,5,42,0,0,856,156,1,0,0,0,857, + 858,5,47,0,0,858,158,1,0,0,0,859,860,5,37,0,0,860,160,1,0,0,0,861,862,4, + 73,3,0,862,863,3,61,23,0,863,864,1,0,0,0,864,865,6,73,12,0,865,162,1,0, + 0,0,866,867,3,45,15,0,867,868,1,0,0,0,868,869,6,74,13,0,869,164,1,0,0,0, + 870,873,3,129,57,0,871,874,3,67,26,0,872,874,3,81,33,0,873,871,1,0,0,0, + 873,872,1,0,0,0,874,878,1,0,0,0,875,877,3,83,34,0,876,875,1,0,0,0,877,880, + 1,0,0,0,878,876,1,0,0,0,878,879,1,0,0,0,879,888,1,0,0,0,880,878,1,0,0,0, + 881,883,3,129,57,0,882,884,3,65,25,0,883,882,1,0,0,0,884,885,1,0,0,0,885, + 883,1,0,0,0,885,886,1,0,0,0,886,888,1,0,0,0,887,870,1,0,0,0,887,881,1,0, + 0,0,888,166,1,0,0,0,889,890,5,91,0,0,890,891,1,0,0,0,891,892,6,76,0,0,892, + 893,6,76,0,0,893,168,1,0,0,0,894,895,5,93,0,0,895,896,1,0,0,0,896,897,6, + 77,11,0,897,898,6,77,11,0,898,170,1,0,0,0,899,903,3,67,26,0,900,902,3,83, + 34,0,901,900,1,0,0,0,902,905,1,0,0,0,903,901,1,0,0,0,903,904,1,0,0,0,904, + 916,1,0,0,0,905,903,1,0,0,0,906,909,3,81,33,0,907,909,3,75,30,0,908,906, + 1,0,0,0,908,907,1,0,0,0,909,911,1,0,0,0,910,912,3,83,34,0,911,910,1,0,0, + 0,912,913,1,0,0,0,913,911,1,0,0,0,913,914,1,0,0,0,914,916,1,0,0,0,915,899, + 1,0,0,0,915,908,1,0,0,0,916,172,1,0,0,0,917,919,3,77,31,0,918,920,3,79, + 32,0,919,918,1,0,0,0,920,921,1,0,0,0,921,919,1,0,0,0,921,922,1,0,0,0,922, + 923,1,0,0,0,923,924,3,77,31,0,924,174,1,0,0,0,925,926,3,173,79,0,926,176, + 1,0,0,0,927,928,3,55,20,0,928,929,1,0,0,0,929,930,6,81,10,0,930,178,1,0, + 0,0,931,932,3,57,21,0,932,933,1,0,0,0,933,934,6,82,10,0,934,180,1,0,0,0, + 935,936,3,59,22,0,936,937,1,0,0,0,937,938,6,83,10,0,938,182,1,0,0,0,939, + 940,3,167,76,0,940,941,1,0,0,0,941,942,6,84,14,0,942,943,6,84,15,0,943, + 184,1,0,0,0,944,945,3,63,24,0,945,946,1,0,0,0,946,947,6,85,16,0,947,948, + 6,85,11,0,948,186,1,0,0,0,949,950,3,59,22,0,950,951,1,0,0,0,951,952,6,86, + 10,0,952,188,1,0,0,0,953,954,3,55,20,0,954,955,1,0,0,0,955,956,6,87,10, + 0,956,190,1,0,0,0,957,958,3,57,21,0,958,959,1,0,0,0,959,960,6,88,10,0,960, + 192,1,0,0,0,961,962,3,63,24,0,962,963,1,0,0,0,963,964,6,89,16,0,964,965, + 6,89,11,0,965,194,1,0,0,0,966,967,3,167,76,0,967,968,1,0,0,0,968,969,6, + 90,14,0,969,196,1,0,0,0,970,971,3,169,77,0,971,972,1,0,0,0,972,973,6,91, + 17,0,973,198,1,0,0,0,974,975,3,61,23,0,975,976,1,0,0,0,976,977,6,92,12, + 0,977,200,1,0,0,0,978,979,3,101,43,0,979,980,1,0,0,0,980,981,6,93,18,0, + 981,202,1,0,0,0,982,983,3,97,41,0,983,984,1,0,0,0,984,985,6,94,19,0,985, + 204,1,0,0,0,986,987,7,16,0,0,987,988,7,3,0,0,988,989,7,5,0,0,989,990,7, + 12,0,0,990,991,7,0,0,0,991,992,7,12,0,0,992,993,7,5,0,0,993,994,7,12,0, + 0,994,206,1,0,0,0,995,999,8,32,0,0,996,997,5,47,0,0,997,999,8,33,0,0,998, + 995,1,0,0,0,998,996,1,0,0,0,999,208,1,0,0,0,1000,1002,3,207,96,0,1001,1000, + 1,0,0,0,1002,1003,1,0,0,0,1003,1001,1,0,0,0,1003,1004,1,0,0,0,1004,210, + 1,0,0,0,1005,1006,3,209,97,0,1006,1007,1,0,0,0,1007,1008,6,98,20,0,1008, + 212,1,0,0,0,1009,1010,3,85,35,0,1010,1011,1,0,0,0,1011,1012,6,99,21,0,1012, + 214,1,0,0,0,1013,1014,3,55,20,0,1014,1015,1,0,0,0,1015,1016,6,100,10,0, + 1016,216,1,0,0,0,1017,1018,3,57,21,0,1018,1019,1,0,0,0,1019,1020,6,101, + 10,0,1020,218,1,0,0,0,1021,1022,3,59,22,0,1022,1023,1,0,0,0,1023,1024,6, + 102,10,0,1024,220,1,0,0,0,1025,1026,3,63,24,0,1026,1027,1,0,0,0,1027,1028, + 6,103,16,0,1028,1029,6,103,11,0,1029,222,1,0,0,0,1030,1031,3,105,45,0,1031, + 1032,1,0,0,0,1032,1033,6,104,22,0,1033,224,1,0,0,0,1034,1035,3,101,43,0, + 1035,1036,1,0,0,0,1036,1037,6,105,18,0,1037,226,1,0,0,0,1038,1039,4,106, + 4,0,1039,1040,3,129,57,0,1040,1041,1,0,0,0,1041,1042,6,106,23,0,1042,228, + 1,0,0,0,1043,1044,4,107,5,0,1044,1045,3,165,75,0,1045,1046,1,0,0,0,1046, + 1047,6,107,24,0,1047,230,1,0,0,0,1048,1053,3,67,26,0,1049,1053,3,65,25, + 0,1050,1053,3,81,33,0,1051,1053,3,155,70,0,1052,1048,1,0,0,0,1052,1049, + 1,0,0,0,1052,1050,1,0,0,0,1052,1051,1,0,0,0,1053,232,1,0,0,0,1054,1057, + 3,67,26,0,1055,1057,3,155,70,0,1056,1054,1,0,0,0,1056,1055,1,0,0,0,1057, + 1061,1,0,0,0,1058,1060,3,231,108,0,1059,1058,1,0,0,0,1060,1063,1,0,0,0, + 1061,1059,1,0,0,0,1061,1062,1,0,0,0,1062,1074,1,0,0,0,1063,1061,1,0,0,0, + 1064,1067,3,81,33,0,1065,1067,3,75,30,0,1066,1064,1,0,0,0,1066,1065,1,0, + 0,0,1067,1069,1,0,0,0,1068,1070,3,231,108,0,1069,1068,1,0,0,0,1070,1071, + 1,0,0,0,1071,1069,1,0,0,0,1071,1072,1,0,0,0,1072,1074,1,0,0,0,1073,1056, + 1,0,0,0,1073,1066,1,0,0,0,1074,234,1,0,0,0,1075,1078,3,233,109,0,1076,1078, + 3,173,79,0,1077,1075,1,0,0,0,1077,1076,1,0,0,0,1078,1079,1,0,0,0,1079,1077, + 1,0,0,0,1079,1080,1,0,0,0,1080,236,1,0,0,0,1081,1082,3,55,20,0,1082,1083, + 1,0,0,0,1083,1084,6,111,10,0,1084,238,1,0,0,0,1085,1086,3,57,21,0,1086, + 1087,1,0,0,0,1087,1088,6,112,10,0,1088,240,1,0,0,0,1089,1090,3,59,22,0, + 1090,1091,1,0,0,0,1091,1092,6,113,10,0,1092,242,1,0,0,0,1093,1094,3,63, + 24,0,1094,1095,1,0,0,0,1095,1096,6,114,16,0,1096,1097,6,114,11,0,1097,244, + 1,0,0,0,1098,1099,3,97,41,0,1099,1100,1,0,0,0,1100,1101,6,115,19,0,1101, + 246,1,0,0,0,1102,1103,3,101,43,0,1103,1104,1,0,0,0,1104,1105,6,116,18,0, + 1105,248,1,0,0,0,1106,1107,3,105,45,0,1107,1108,1,0,0,0,1108,1109,6,117, + 22,0,1109,250,1,0,0,0,1110,1111,4,118,6,0,1111,1112,3,129,57,0,1112,1113, + 1,0,0,0,1113,1114,6,118,23,0,1114,252,1,0,0,0,1115,1116,4,119,7,0,1116, + 1117,3,165,75,0,1117,1118,1,0,0,0,1118,1119,6,119,24,0,1119,254,1,0,0,0, + 1120,1121,7,12,0,0,1121,1122,7,2,0,0,1122,256,1,0,0,0,1123,1124,3,235,110, + 0,1124,1125,1,0,0,0,1125,1126,6,121,25,0,1126,258,1,0,0,0,1127,1128,3,55, + 20,0,1128,1129,1,0,0,0,1129,1130,6,122,10,0,1130,260,1,0,0,0,1131,1132, + 3,57,21,0,1132,1133,1,0,0,0,1133,1134,6,123,10,0,1134,262,1,0,0,0,1135, + 1136,3,59,22,0,1136,1137,1,0,0,0,1137,1138,6,124,10,0,1138,264,1,0,0,0, + 1139,1140,3,63,24,0,1140,1141,1,0,0,0,1141,1142,6,125,16,0,1142,1143,6, + 125,11,0,1143,266,1,0,0,0,1144,1145,3,167,76,0,1145,1146,1,0,0,0,1146,1147, + 6,126,14,0,1147,1148,6,126,26,0,1148,268,1,0,0,0,1149,1150,7,7,0,0,1150, + 1151,7,9,0,0,1151,1152,1,0,0,0,1152,1153,6,127,27,0,1153,270,1,0,0,0,1154, + 1155,7,19,0,0,1155,1156,7,1,0,0,1156,1157,7,5,0,0,1157,1158,7,10,0,0,1158, + 1159,1,0,0,0,1159,1160,6,128,27,0,1160,272,1,0,0,0,1161,1162,8,34,0,0,1162, + 274,1,0,0,0,1163,1165,3,273,129,0,1164,1163,1,0,0,0,1165,1166,1,0,0,0,1166, + 1164,1,0,0,0,1166,1167,1,0,0,0,1167,1168,1,0,0,0,1168,1169,3,61,23,0,1169, + 1171,1,0,0,0,1170,1164,1,0,0,0,1170,1171,1,0,0,0,1171,1173,1,0,0,0,1172, + 1174,3,273,129,0,1173,1172,1,0,0,0,1174,1175,1,0,0,0,1175,1173,1,0,0,0, + 1175,1176,1,0,0,0,1176,276,1,0,0,0,1177,1178,3,275,130,0,1178,1179,1,0, + 0,0,1179,1180,6,131,28,0,1180,278,1,0,0,0,1181,1182,3,55,20,0,1182,1183, + 1,0,0,0,1183,1184,6,132,10,0,1184,280,1,0,0,0,1185,1186,3,57,21,0,1186, + 1187,1,0,0,0,1187,1188,6,133,10,0,1188,282,1,0,0,0,1189,1190,3,59,22,0, + 1190,1191,1,0,0,0,1191,1192,6,134,10,0,1192,284,1,0,0,0,1193,1194,3,63, + 24,0,1194,1195,1,0,0,0,1195,1196,6,135,16,0,1196,1197,6,135,11,0,1197,1198, + 6,135,11,0,1198,286,1,0,0,0,1199,1200,3,97,41,0,1200,1201,1,0,0,0,1201, + 1202,6,136,19,0,1202,288,1,0,0,0,1203,1204,3,101,43,0,1204,1205,1,0,0,0, + 1205,1206,6,137,18,0,1206,290,1,0,0,0,1207,1208,3,105,45,0,1208,1209,1, + 0,0,0,1209,1210,6,138,22,0,1210,292,1,0,0,0,1211,1212,3,271,128,0,1212, + 1213,1,0,0,0,1213,1214,6,139,29,0,1214,294,1,0,0,0,1215,1216,3,235,110, + 0,1216,1217,1,0,0,0,1217,1218,6,140,25,0,1218,296,1,0,0,0,1219,1220,3,175, + 80,0,1220,1221,1,0,0,0,1221,1222,6,141,30,0,1222,298,1,0,0,0,1223,1224, + 4,142,8,0,1224,1225,3,129,57,0,1225,1226,1,0,0,0,1226,1227,6,142,23,0,1227, + 300,1,0,0,0,1228,1229,4,143,9,0,1229,1230,3,165,75,0,1230,1231,1,0,0,0, + 1231,1232,6,143,24,0,1232,302,1,0,0,0,1233,1234,3,55,20,0,1234,1235,1,0, + 0,0,1235,1236,6,144,10,0,1236,304,1,0,0,0,1237,1238,3,57,21,0,1238,1239, + 1,0,0,0,1239,1240,6,145,10,0,1240,306,1,0,0,0,1241,1242,3,59,22,0,1242, + 1243,1,0,0,0,1243,1244,6,146,10,0,1244,308,1,0,0,0,1245,1246,3,63,24,0, + 1246,1247,1,0,0,0,1247,1248,6,147,16,0,1248,1249,6,147,11,0,1249,310,1, + 0,0,0,1250,1251,3,105,45,0,1251,1252,1,0,0,0,1252,1253,6,148,22,0,1253, + 312,1,0,0,0,1254,1255,4,149,10,0,1255,1256,3,129,57,0,1256,1257,1,0,0,0, + 1257,1258,6,149,23,0,1258,314,1,0,0,0,1259,1260,4,150,11,0,1260,1261,3, + 165,75,0,1261,1262,1,0,0,0,1262,1263,6,150,24,0,1263,316,1,0,0,0,1264,1265, + 3,175,80,0,1265,1266,1,0,0,0,1266,1267,6,151,30,0,1267,318,1,0,0,0,1268, + 1269,3,171,78,0,1269,1270,1,0,0,0,1270,1271,6,152,31,0,1271,320,1,0,0,0, + 1272,1273,3,55,20,0,1273,1274,1,0,0,0,1274,1275,6,153,10,0,1275,322,1,0, + 0,0,1276,1277,3,57,21,0,1277,1278,1,0,0,0,1278,1279,6,154,10,0,1279,324, + 1,0,0,0,1280,1281,3,59,22,0,1281,1282,1,0,0,0,1282,1283,6,155,10,0,1283, + 326,1,0,0,0,1284,1285,3,63,24,0,1285,1286,1,0,0,0,1286,1287,6,156,16,0, + 1287,1288,6,156,11,0,1288,328,1,0,0,0,1289,1290,7,1,0,0,1290,1291,7,9,0, + 0,1291,1292,7,15,0,0,1292,1293,7,7,0,0,1293,330,1,0,0,0,1294,1295,3,55, + 20,0,1295,1296,1,0,0,0,1296,1297,6,158,10,0,1297,332,1,0,0,0,1298,1299, + 3,57,21,0,1299,1300,1,0,0,0,1300,1301,6,159,10,0,1301,334,1,0,0,0,1302, + 1303,3,59,22,0,1303,1304,1,0,0,0,1304,1305,6,160,10,0,1305,336,1,0,0,0, + 1306,1307,3,169,77,0,1307,1308,1,0,0,0,1308,1309,6,161,17,0,1309,1310,6, + 161,11,0,1310,338,1,0,0,0,1311,1312,3,61,23,0,1312,1313,1,0,0,0,1313,1314, + 6,162,12,0,1314,340,1,0,0,0,1315,1321,3,75,30,0,1316,1321,3,65,25,0,1317, + 1321,3,105,45,0,1318,1321,3,67,26,0,1319,1321,3,81,33,0,1320,1315,1,0,0, + 0,1320,1316,1,0,0,0,1320,1317,1,0,0,0,1320,1318,1,0,0,0,1320,1319,1,0,0, + 0,1321,1322,1,0,0,0,1322,1320,1,0,0,0,1322,1323,1,0,0,0,1323,342,1,0,0, + 0,1324,1325,3,55,20,0,1325,1326,1,0,0,0,1326,1327,6,164,10,0,1327,344,1, + 0,0,0,1328,1329,3,57,21,0,1329,1330,1,0,0,0,1330,1331,6,165,10,0,1331,346, + 1,0,0,0,1332,1333,3,59,22,0,1333,1334,1,0,0,0,1334,1335,6,166,10,0,1335, + 348,1,0,0,0,1336,1337,3,63,24,0,1337,1338,1,0,0,0,1338,1339,6,167,16,0, + 1339,1340,6,167,11,0,1340,350,1,0,0,0,1341,1342,3,61,23,0,1342,1343,1,0, + 0,0,1343,1344,6,168,12,0,1344,352,1,0,0,0,1345,1346,3,101,43,0,1346,1347, + 1,0,0,0,1347,1348,6,169,18,0,1348,354,1,0,0,0,1349,1350,3,105,45,0,1350, + 1351,1,0,0,0,1351,1352,6,170,22,0,1352,356,1,0,0,0,1353,1354,3,269,127, + 0,1354,1355,1,0,0,0,1355,1356,6,171,32,0,1356,1357,6,171,33,0,1357,358, + 1,0,0,0,1358,1359,3,209,97,0,1359,1360,1,0,0,0,1360,1361,6,172,20,0,1361, + 360,1,0,0,0,1362,1363,3,85,35,0,1363,1364,1,0,0,0,1364,1365,6,173,21,0, + 1365,362,1,0,0,0,1366,1367,3,55,20,0,1367,1368,1,0,0,0,1368,1369,6,174, + 10,0,1369,364,1,0,0,0,1370,1371,3,57,21,0,1371,1372,1,0,0,0,1372,1373,6, + 175,10,0,1373,366,1,0,0,0,1374,1375,3,59,22,0,1375,1376,1,0,0,0,1376,1377, + 6,176,10,0,1377,368,1,0,0,0,1378,1379,3,63,24,0,1379,1380,1,0,0,0,1380, + 1381,6,177,16,0,1381,1382,6,177,11,0,1382,1383,6,177,11,0,1383,370,1,0, + 0,0,1384,1385,3,101,43,0,1385,1386,1,0,0,0,1386,1387,6,178,18,0,1387,372, + 1,0,0,0,1388,1389,3,105,45,0,1389,1390,1,0,0,0,1390,1391,6,179,22,0,1391, + 374,1,0,0,0,1392,1393,3,235,110,0,1393,1394,1,0,0,0,1394,1395,6,180,25, + 0,1395,376,1,0,0,0,1396,1397,3,55,20,0,1397,1398,1,0,0,0,1398,1399,6,181, + 10,0,1399,378,1,0,0,0,1400,1401,3,57,21,0,1401,1402,1,0,0,0,1402,1403,6, + 182,10,0,1403,380,1,0,0,0,1404,1405,3,59,22,0,1405,1406,1,0,0,0,1406,1407, + 6,183,10,0,1407,382,1,0,0,0,1408,1409,3,63,24,0,1409,1410,1,0,0,0,1410, + 1411,6,184,16,0,1411,1412,6,184,11,0,1412,384,1,0,0,0,1413,1414,3,209,97, + 0,1414,1415,1,0,0,0,1415,1416,6,185,20,0,1416,1417,6,185,11,0,1417,1418, + 6,185,34,0,1418,386,1,0,0,0,1419,1420,3,85,35,0,1420,1421,1,0,0,0,1421, + 1422,6,186,21,0,1422,1423,6,186,11,0,1423,1424,6,186,34,0,1424,388,1,0, + 0,0,1425,1426,3,55,20,0,1426,1427,1,0,0,0,1427,1428,6,187,10,0,1428,390, + 1,0,0,0,1429,1430,3,57,21,0,1430,1431,1,0,0,0,1431,1432,6,188,10,0,1432, + 392,1,0,0,0,1433,1434,3,59,22,0,1434,1435,1,0,0,0,1435,1436,6,189,10,0, + 1436,394,1,0,0,0,1437,1438,3,61,23,0,1438,1439,1,0,0,0,1439,1440,6,190, + 12,0,1440,1441,6,190,11,0,1441,1442,6,190,9,0,1442,396,1,0,0,0,1443,1444, + 3,101,43,0,1444,1445,1,0,0,0,1445,1446,6,191,18,0,1446,1447,6,191,11,0, + 1447,1448,6,191,9,0,1448,398,1,0,0,0,1449,1450,3,55,20,0,1450,1451,1,0, + 0,0,1451,1452,6,192,10,0,1452,400,1,0,0,0,1453,1454,3,57,21,0,1454,1455, + 1,0,0,0,1455,1456,6,193,10,0,1456,402,1,0,0,0,1457,1458,3,59,22,0,1458, + 1459,1,0,0,0,1459,1460,6,194,10,0,1460,404,1,0,0,0,1461,1462,3,175,80,0, + 1462,1463,1,0,0,0,1463,1464,6,195,11,0,1464,1465,6,195,0,0,1465,1466,6, + 195,30,0,1466,406,1,0,0,0,1467,1468,3,171,78,0,1468,1469,1,0,0,0,1469,1470, + 6,196,11,0,1470,1471,6,196,0,0,1471,1472,6,196,31,0,1472,408,1,0,0,0,1473, + 1474,3,91,38,0,1474,1475,1,0,0,0,1475,1476,6,197,11,0,1476,1477,6,197,0, + 0,1477,1478,6,197,35,0,1478,410,1,0,0,0,1479,1480,3,63,24,0,1480,1481,1, + 0,0,0,1481,1482,6,198,16,0,1482,1483,6,198,11,0,1483,412,1,0,0,0,65,0,1, + 2,3,4,5,6,7,8,9,10,11,12,13,14,581,591,595,598,607,609,620,641,646,655, + 662,667,669,680,688,691,693,698,703,709,716,721,727,730,738,742,873,878, + 885,887,903,908,913,915,921,998,1003,1052,1056,1061,1066,1071,1073,1077, + 1079,1166,1170,1175,1320,1322,36,5,1,0,5,4,0,5,6,0,5,2,0,5,3,0,5,8,0,5, + 5,0,5,9,0,5,11,0,5,13,0,0,1,0,4,0,0,7,24,0,7,16,0,7,65,0,5,0,0,7,25,0,7, + 66,0,7,34,0,7,32,0,7,76,0,7,26,0,7,36,0,7,48,0,7,64,0,7,80,0,5,10,0,5,7, + 0,7,90,0,7,89,0,7,68,0,7,67,0,7,88,0,5,12,0,5,14,0,7,29,0]; private static __ATN: ATN; public static get _ATN(): ATN { diff --git a/packages/kbn-esql-ast/src/antlr/esql_parser.g4 b/packages/kbn-esql-ast/src/antlr/esql_parser.g4 index 261b4f712b5b3..6a76e32d28f36 100644 --- a/packages/kbn-esql-ast/src/antlr/esql_parser.g4 +++ b/packages/kbn-esql-ast/src/antlr/esql_parser.g4 @@ -79,7 +79,7 @@ regexBooleanExpression ; matchBooleanExpression - : valueExpression MATCH queryString=string + : fieldExp=qualifiedName COLON queryString=constant ; valueExpression @@ -107,9 +107,7 @@ functionExpression ; functionName - // Additional function identifiers that are already a reserved word in the language - : MATCH - | identifierOrParameter + : identifierOrParameter ; dataType diff --git a/packages/kbn-esql-ast/src/antlr/esql_parser.interp b/packages/kbn-esql-ast/src/antlr/esql_parser.interp index b52d842e79fb2..a2b339f378f12 100644 --- a/packages/kbn-esql-ast/src/antlr/esql_parser.interp +++ b/packages/kbn-esql-ast/src/antlr/esql_parser.interp @@ -23,6 +23,7 @@ null null null null +':' '|' null null @@ -62,7 +63,6 @@ null '*' '/' '%' -'match' null null ']' @@ -103,7 +103,6 @@ null null null null -':' null null null @@ -146,6 +145,7 @@ UNKNOWN_CMD LINE_COMMENT MULTILINE_COMMENT WS +COLON PIPE QUOTED_STRING INTEGER_LITERAL @@ -185,7 +185,6 @@ MINUS ASTERISK SLASH PERCENT -MATCH NAMED_OR_POSITIONAL_PARAM OPENING_BRACKET CLOSING_BRACKET @@ -226,7 +225,6 @@ INFO SHOW_LINE_COMMENT SHOW_MULTILINE_COMMENT SHOW_WS -COLON SETTING SETTING_LINE_COMMENT SETTTING_MULTILINE_COMMENT @@ -310,4 +308,4 @@ inlinestatsCommand atn: -[4, 1, 120, 605, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 2, 8, 7, 8, 2, 9, 7, 9, 2, 10, 7, 10, 2, 11, 7, 11, 2, 12, 7, 12, 2, 13, 7, 13, 2, 14, 7, 14, 2, 15, 7, 15, 2, 16, 7, 16, 2, 17, 7, 17, 2, 18, 7, 18, 2, 19, 7, 19, 2, 20, 7, 20, 2, 21, 7, 21, 2, 22, 7, 22, 2, 23, 7, 23, 2, 24, 7, 24, 2, 25, 7, 25, 2, 26, 7, 26, 2, 27, 7, 27, 2, 28, 7, 28, 2, 29, 7, 29, 2, 30, 7, 30, 2, 31, 7, 31, 2, 32, 7, 32, 2, 33, 7, 33, 2, 34, 7, 34, 2, 35, 7, 35, 2, 36, 7, 36, 2, 37, 7, 37, 2, 38, 7, 38, 2, 39, 7, 39, 2, 40, 7, 40, 2, 41, 7, 41, 2, 42, 7, 42, 2, 43, 7, 43, 2, 44, 7, 44, 2, 45, 7, 45, 2, 46, 7, 46, 2, 47, 7, 47, 2, 48, 7, 48, 2, 49, 7, 49, 2, 50, 7, 50, 2, 51, 7, 51, 2, 52, 7, 52, 2, 53, 7, 53, 2, 54, 7, 54, 2, 55, 7, 55, 2, 56, 7, 56, 2, 57, 7, 57, 2, 58, 7, 58, 2, 59, 7, 59, 2, 60, 7, 60, 2, 61, 7, 61, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 5, 1, 134, 8, 1, 10, 1, 12, 1, 137, 9, 1, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 3, 2, 145, 8, 2, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 3, 3, 163, 8, 3, 1, 4, 1, 4, 1, 4, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 3, 5, 175, 8, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 5, 5, 182, 8, 5, 10, 5, 12, 5, 185, 9, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 3, 5, 192, 8, 5, 1, 5, 1, 5, 1, 5, 1, 5, 3, 5, 198, 8, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 5, 5, 206, 8, 5, 10, 5, 12, 5, 209, 9, 5, 1, 6, 1, 6, 3, 6, 213, 8, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 3, 6, 220, 8, 6, 1, 6, 1, 6, 1, 6, 3, 6, 225, 8, 6, 1, 7, 1, 7, 1, 7, 1, 7, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 3, 8, 236, 8, 8, 1, 9, 1, 9, 1, 9, 1, 9, 3, 9, 242, 8, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 5, 9, 250, 8, 9, 10, 9, 12, 9, 253, 9, 9, 1, 10, 1, 10, 1, 10, 1, 10, 1, 10, 1, 10, 1, 10, 1, 10, 3, 10, 263, 8, 10, 1, 10, 1, 10, 1, 10, 5, 10, 268, 8, 10, 10, 10, 12, 10, 271, 9, 10, 1, 11, 1, 11, 1, 11, 1, 11, 1, 11, 1, 11, 5, 11, 279, 8, 11, 10, 11, 12, 11, 282, 9, 11, 3, 11, 284, 8, 11, 1, 11, 1, 11, 1, 12, 1, 12, 3, 12, 290, 8, 12, 1, 13, 1, 13, 1, 14, 1, 14, 1, 14, 1, 15, 1, 15, 1, 15, 5, 15, 300, 8, 15, 10, 15, 12, 15, 303, 9, 15, 1, 16, 1, 16, 1, 16, 3, 16, 308, 8, 16, 1, 16, 1, 16, 1, 17, 1, 17, 1, 17, 1, 17, 5, 17, 316, 8, 17, 10, 17, 12, 17, 319, 9, 17, 1, 17, 3, 17, 322, 8, 17, 1, 18, 1, 18, 1, 18, 3, 18, 327, 8, 18, 1, 18, 1, 18, 1, 19, 1, 19, 1, 20, 1, 20, 1, 21, 1, 21, 3, 21, 337, 8, 21, 1, 22, 1, 22, 1, 22, 1, 22, 5, 22, 343, 8, 22, 10, 22, 12, 22, 346, 9, 22, 1, 23, 1, 23, 1, 23, 1, 23, 1, 24, 1, 24, 1, 24, 1, 24, 5, 24, 356, 8, 24, 10, 24, 12, 24, 359, 9, 24, 1, 24, 3, 24, 362, 8, 24, 1, 24, 1, 24, 3, 24, 366, 8, 24, 1, 25, 1, 25, 1, 25, 1, 26, 1, 26, 3, 26, 373, 8, 26, 1, 26, 1, 26, 3, 26, 377, 8, 26, 1, 27, 1, 27, 1, 27, 5, 27, 382, 8, 27, 10, 27, 12, 27, 385, 9, 27, 1, 28, 1, 28, 1, 28, 3, 28, 390, 8, 28, 1, 29, 1, 29, 1, 29, 5, 29, 395, 8, 29, 10, 29, 12, 29, 398, 9, 29, 1, 30, 1, 30, 1, 30, 5, 30, 403, 8, 30, 10, 30, 12, 30, 406, 9, 30, 1, 31, 1, 31, 1, 31, 5, 31, 411, 8, 31, 10, 31, 12, 31, 414, 9, 31, 1, 32, 1, 32, 1, 33, 1, 33, 1, 33, 3, 33, 421, 8, 33, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 5, 34, 436, 8, 34, 10, 34, 12, 34, 439, 9, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 5, 34, 447, 8, 34, 10, 34, 12, 34, 450, 9, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 5, 34, 458, 8, 34, 10, 34, 12, 34, 461, 9, 34, 1, 34, 1, 34, 3, 34, 465, 8, 34, 1, 35, 1, 35, 3, 35, 469, 8, 35, 1, 36, 1, 36, 1, 36, 3, 36, 474, 8, 36, 1, 37, 1, 37, 1, 37, 1, 38, 1, 38, 1, 38, 1, 38, 5, 38, 483, 8, 38, 10, 38, 12, 38, 486, 9, 38, 1, 39, 1, 39, 3, 39, 490, 8, 39, 1, 39, 1, 39, 3, 39, 494, 8, 39, 1, 40, 1, 40, 1, 40, 1, 41, 1, 41, 1, 41, 1, 42, 1, 42, 1, 42, 1, 42, 5, 42, 506, 8, 42, 10, 42, 12, 42, 509, 9, 42, 1, 43, 1, 43, 1, 43, 1, 43, 1, 44, 1, 44, 1, 44, 1, 44, 3, 44, 519, 8, 44, 1, 45, 1, 45, 1, 45, 1, 45, 1, 46, 1, 46, 1, 46, 1, 47, 1, 47, 1, 47, 5, 47, 531, 8, 47, 10, 47, 12, 47, 534, 9, 47, 1, 48, 1, 48, 1, 48, 1, 48, 1, 49, 1, 49, 1, 50, 1, 50, 3, 50, 544, 8, 50, 1, 51, 3, 51, 547, 8, 51, 1, 51, 1, 51, 1, 52, 3, 52, 552, 8, 52, 1, 52, 1, 52, 1, 53, 1, 53, 1, 54, 1, 54, 1, 55, 1, 55, 1, 55, 1, 56, 1, 56, 1, 56, 1, 56, 1, 57, 1, 57, 1, 57, 1, 58, 1, 58, 1, 58, 1, 58, 3, 58, 574, 8, 58, 1, 58, 1, 58, 1, 58, 1, 58, 5, 58, 580, 8, 58, 10, 58, 12, 58, 583, 9, 58, 3, 58, 585, 8, 58, 1, 59, 1, 59, 1, 59, 3, 59, 590, 8, 59, 1, 59, 1, 59, 1, 60, 1, 60, 1, 60, 1, 60, 1, 60, 1, 61, 1, 61, 1, 61, 1, 61, 3, 61, 603, 8, 61, 1, 61, 0, 4, 2, 10, 18, 20, 62, 0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 34, 36, 38, 40, 42, 44, 46, 48, 50, 52, 54, 56, 58, 60, 62, 64, 66, 68, 70, 72, 74, 76, 78, 80, 82, 84, 86, 88, 90, 92, 94, 96, 98, 100, 102, 104, 106, 108, 110, 112, 114, 116, 118, 120, 122, 0, 8, 1, 0, 58, 59, 1, 0, 60, 62, 2, 0, 25, 25, 76, 76, 1, 0, 67, 68, 2, 0, 30, 30, 34, 34, 2, 0, 37, 37, 40, 40, 2, 0, 36, 36, 50, 50, 2, 0, 51, 51, 53, 57, 631, 0, 124, 1, 0, 0, 0, 2, 127, 1, 0, 0, 0, 4, 144, 1, 0, 0, 0, 6, 162, 1, 0, 0, 0, 8, 164, 1, 0, 0, 0, 10, 197, 1, 0, 0, 0, 12, 224, 1, 0, 0, 0, 14, 226, 1, 0, 0, 0, 16, 235, 1, 0, 0, 0, 18, 241, 1, 0, 0, 0, 20, 262, 1, 0, 0, 0, 22, 272, 1, 0, 0, 0, 24, 289, 1, 0, 0, 0, 26, 291, 1, 0, 0, 0, 28, 293, 1, 0, 0, 0, 30, 296, 1, 0, 0, 0, 32, 307, 1, 0, 0, 0, 34, 311, 1, 0, 0, 0, 36, 326, 1, 0, 0, 0, 38, 330, 1, 0, 0, 0, 40, 332, 1, 0, 0, 0, 42, 336, 1, 0, 0, 0, 44, 338, 1, 0, 0, 0, 46, 347, 1, 0, 0, 0, 48, 351, 1, 0, 0, 0, 50, 367, 1, 0, 0, 0, 52, 370, 1, 0, 0, 0, 54, 378, 1, 0, 0, 0, 56, 386, 1, 0, 0, 0, 58, 391, 1, 0, 0, 0, 60, 399, 1, 0, 0, 0, 62, 407, 1, 0, 0, 0, 64, 415, 1, 0, 0, 0, 66, 420, 1, 0, 0, 0, 68, 464, 1, 0, 0, 0, 70, 468, 1, 0, 0, 0, 72, 473, 1, 0, 0, 0, 74, 475, 1, 0, 0, 0, 76, 478, 1, 0, 0, 0, 78, 487, 1, 0, 0, 0, 80, 495, 1, 0, 0, 0, 82, 498, 1, 0, 0, 0, 84, 501, 1, 0, 0, 0, 86, 510, 1, 0, 0, 0, 88, 514, 1, 0, 0, 0, 90, 520, 1, 0, 0, 0, 92, 524, 1, 0, 0, 0, 94, 527, 1, 0, 0, 0, 96, 535, 1, 0, 0, 0, 98, 539, 1, 0, 0, 0, 100, 543, 1, 0, 0, 0, 102, 546, 1, 0, 0, 0, 104, 551, 1, 0, 0, 0, 106, 555, 1, 0, 0, 0, 108, 557, 1, 0, 0, 0, 110, 559, 1, 0, 0, 0, 112, 562, 1, 0, 0, 0, 114, 566, 1, 0, 0, 0, 116, 569, 1, 0, 0, 0, 118, 589, 1, 0, 0, 0, 120, 593, 1, 0, 0, 0, 122, 598, 1, 0, 0, 0, 124, 125, 3, 2, 1, 0, 125, 126, 5, 0, 0, 1, 126, 1, 1, 0, 0, 0, 127, 128, 6, 1, -1, 0, 128, 129, 3, 4, 2, 0, 129, 135, 1, 0, 0, 0, 130, 131, 10, 1, 0, 0, 131, 132, 5, 24, 0, 0, 132, 134, 3, 6, 3, 0, 133, 130, 1, 0, 0, 0, 134, 137, 1, 0, 0, 0, 135, 133, 1, 0, 0, 0, 135, 136, 1, 0, 0, 0, 136, 3, 1, 0, 0, 0, 137, 135, 1, 0, 0, 0, 138, 145, 3, 110, 55, 0, 139, 145, 3, 34, 17, 0, 140, 145, 3, 28, 14, 0, 141, 145, 3, 114, 57, 0, 142, 143, 4, 2, 1, 0, 143, 145, 3, 48, 24, 0, 144, 138, 1, 0, 0, 0, 144, 139, 1, 0, 0, 0, 144, 140, 1, 0, 0, 0, 144, 141, 1, 0, 0, 0, 144, 142, 1, 0, 0, 0, 145, 5, 1, 0, 0, 0, 146, 163, 3, 50, 25, 0, 147, 163, 3, 8, 4, 0, 148, 163, 3, 80, 40, 0, 149, 163, 3, 74, 37, 0, 150, 163, 3, 52, 26, 0, 151, 163, 3, 76, 38, 0, 152, 163, 3, 82, 41, 0, 153, 163, 3, 84, 42, 0, 154, 163, 3, 88, 44, 0, 155, 163, 3, 90, 45, 0, 156, 163, 3, 116, 58, 0, 157, 163, 3, 92, 46, 0, 158, 159, 4, 3, 2, 0, 159, 163, 3, 122, 61, 0, 160, 161, 4, 3, 3, 0, 161, 163, 3, 120, 60, 0, 162, 146, 1, 0, 0, 0, 162, 147, 1, 0, 0, 0, 162, 148, 1, 0, 0, 0, 162, 149, 1, 0, 0, 0, 162, 150, 1, 0, 0, 0, 162, 151, 1, 0, 0, 0, 162, 152, 1, 0, 0, 0, 162, 153, 1, 0, 0, 0, 162, 154, 1, 0, 0, 0, 162, 155, 1, 0, 0, 0, 162, 156, 1, 0, 0, 0, 162, 157, 1, 0, 0, 0, 162, 158, 1, 0, 0, 0, 162, 160, 1, 0, 0, 0, 163, 7, 1, 0, 0, 0, 164, 165, 5, 16, 0, 0, 165, 166, 3, 10, 5, 0, 166, 9, 1, 0, 0, 0, 167, 168, 6, 5, -1, 0, 168, 169, 5, 43, 0, 0, 169, 198, 3, 10, 5, 8, 170, 198, 3, 16, 8, 0, 171, 198, 3, 12, 6, 0, 172, 174, 3, 16, 8, 0, 173, 175, 5, 43, 0, 0, 174, 173, 1, 0, 0, 0, 174, 175, 1, 0, 0, 0, 175, 176, 1, 0, 0, 0, 176, 177, 5, 38, 0, 0, 177, 178, 5, 42, 0, 0, 178, 183, 3, 16, 8, 0, 179, 180, 5, 33, 0, 0, 180, 182, 3, 16, 8, 0, 181, 179, 1, 0, 0, 0, 182, 185, 1, 0, 0, 0, 183, 181, 1, 0, 0, 0, 183, 184, 1, 0, 0, 0, 184, 186, 1, 0, 0, 0, 185, 183, 1, 0, 0, 0, 186, 187, 5, 49, 0, 0, 187, 198, 1, 0, 0, 0, 188, 189, 3, 16, 8, 0, 189, 191, 5, 39, 0, 0, 190, 192, 5, 43, 0, 0, 191, 190, 1, 0, 0, 0, 191, 192, 1, 0, 0, 0, 192, 193, 1, 0, 0, 0, 193, 194, 5, 44, 0, 0, 194, 198, 1, 0, 0, 0, 195, 196, 4, 5, 4, 0, 196, 198, 3, 14, 7, 0, 197, 167, 1, 0, 0, 0, 197, 170, 1, 0, 0, 0, 197, 171, 1, 0, 0, 0, 197, 172, 1, 0, 0, 0, 197, 188, 1, 0, 0, 0, 197, 195, 1, 0, 0, 0, 198, 207, 1, 0, 0, 0, 199, 200, 10, 5, 0, 0, 200, 201, 5, 29, 0, 0, 201, 206, 3, 10, 5, 6, 202, 203, 10, 4, 0, 0, 203, 204, 5, 46, 0, 0, 204, 206, 3, 10, 5, 5, 205, 199, 1, 0, 0, 0, 205, 202, 1, 0, 0, 0, 206, 209, 1, 0, 0, 0, 207, 205, 1, 0, 0, 0, 207, 208, 1, 0, 0, 0, 208, 11, 1, 0, 0, 0, 209, 207, 1, 0, 0, 0, 210, 212, 3, 16, 8, 0, 211, 213, 5, 43, 0, 0, 212, 211, 1, 0, 0, 0, 212, 213, 1, 0, 0, 0, 213, 214, 1, 0, 0, 0, 214, 215, 5, 41, 0, 0, 215, 216, 3, 106, 53, 0, 216, 225, 1, 0, 0, 0, 217, 219, 3, 16, 8, 0, 218, 220, 5, 43, 0, 0, 219, 218, 1, 0, 0, 0, 219, 220, 1, 0, 0, 0, 220, 221, 1, 0, 0, 0, 221, 222, 5, 48, 0, 0, 222, 223, 3, 106, 53, 0, 223, 225, 1, 0, 0, 0, 224, 210, 1, 0, 0, 0, 224, 217, 1, 0, 0, 0, 225, 13, 1, 0, 0, 0, 226, 227, 3, 16, 8, 0, 227, 228, 5, 63, 0, 0, 228, 229, 3, 106, 53, 0, 229, 15, 1, 0, 0, 0, 230, 236, 3, 18, 9, 0, 231, 232, 3, 18, 9, 0, 232, 233, 3, 108, 54, 0, 233, 234, 3, 18, 9, 0, 234, 236, 1, 0, 0, 0, 235, 230, 1, 0, 0, 0, 235, 231, 1, 0, 0, 0, 236, 17, 1, 0, 0, 0, 237, 238, 6, 9, -1, 0, 238, 242, 3, 20, 10, 0, 239, 240, 7, 0, 0, 0, 240, 242, 3, 18, 9, 3, 241, 237, 1, 0, 0, 0, 241, 239, 1, 0, 0, 0, 242, 251, 1, 0, 0, 0, 243, 244, 10, 2, 0, 0, 244, 245, 7, 1, 0, 0, 245, 250, 3, 18, 9, 3, 246, 247, 10, 1, 0, 0, 247, 248, 7, 0, 0, 0, 248, 250, 3, 18, 9, 2, 249, 243, 1, 0, 0, 0, 249, 246, 1, 0, 0, 0, 250, 253, 1, 0, 0, 0, 251, 249, 1, 0, 0, 0, 251, 252, 1, 0, 0, 0, 252, 19, 1, 0, 0, 0, 253, 251, 1, 0, 0, 0, 254, 255, 6, 10, -1, 0, 255, 263, 3, 68, 34, 0, 256, 263, 3, 58, 29, 0, 257, 263, 3, 22, 11, 0, 258, 259, 5, 42, 0, 0, 259, 260, 3, 10, 5, 0, 260, 261, 5, 49, 0, 0, 261, 263, 1, 0, 0, 0, 262, 254, 1, 0, 0, 0, 262, 256, 1, 0, 0, 0, 262, 257, 1, 0, 0, 0, 262, 258, 1, 0, 0, 0, 263, 269, 1, 0, 0, 0, 264, 265, 10, 1, 0, 0, 265, 266, 5, 32, 0, 0, 266, 268, 3, 26, 13, 0, 267, 264, 1, 0, 0, 0, 268, 271, 1, 0, 0, 0, 269, 267, 1, 0, 0, 0, 269, 270, 1, 0, 0, 0, 270, 21, 1, 0, 0, 0, 271, 269, 1, 0, 0, 0, 272, 273, 3, 24, 12, 0, 273, 283, 5, 42, 0, 0, 274, 284, 5, 60, 0, 0, 275, 280, 3, 10, 5, 0, 276, 277, 5, 33, 0, 0, 277, 279, 3, 10, 5, 0, 278, 276, 1, 0, 0, 0, 279, 282, 1, 0, 0, 0, 280, 278, 1, 0, 0, 0, 280, 281, 1, 0, 0, 0, 281, 284, 1, 0, 0, 0, 282, 280, 1, 0, 0, 0, 283, 274, 1, 0, 0, 0, 283, 275, 1, 0, 0, 0, 283, 284, 1, 0, 0, 0, 284, 285, 1, 0, 0, 0, 285, 286, 5, 49, 0, 0, 286, 23, 1, 0, 0, 0, 287, 290, 5, 63, 0, 0, 288, 290, 3, 72, 36, 0, 289, 287, 1, 0, 0, 0, 289, 288, 1, 0, 0, 0, 290, 25, 1, 0, 0, 0, 291, 292, 3, 64, 32, 0, 292, 27, 1, 0, 0, 0, 293, 294, 5, 12, 0, 0, 294, 295, 3, 30, 15, 0, 295, 29, 1, 0, 0, 0, 296, 301, 3, 32, 16, 0, 297, 298, 5, 33, 0, 0, 298, 300, 3, 32, 16, 0, 299, 297, 1, 0, 0, 0, 300, 303, 1, 0, 0, 0, 301, 299, 1, 0, 0, 0, 301, 302, 1, 0, 0, 0, 302, 31, 1, 0, 0, 0, 303, 301, 1, 0, 0, 0, 304, 305, 3, 58, 29, 0, 305, 306, 5, 31, 0, 0, 306, 308, 1, 0, 0, 0, 307, 304, 1, 0, 0, 0, 307, 308, 1, 0, 0, 0, 308, 309, 1, 0, 0, 0, 309, 310, 3, 10, 5, 0, 310, 33, 1, 0, 0, 0, 311, 312, 5, 6, 0, 0, 312, 317, 3, 36, 18, 0, 313, 314, 5, 33, 0, 0, 314, 316, 3, 36, 18, 0, 315, 313, 1, 0, 0, 0, 316, 319, 1, 0, 0, 0, 317, 315, 1, 0, 0, 0, 317, 318, 1, 0, 0, 0, 318, 321, 1, 0, 0, 0, 319, 317, 1, 0, 0, 0, 320, 322, 3, 42, 21, 0, 321, 320, 1, 0, 0, 0, 321, 322, 1, 0, 0, 0, 322, 35, 1, 0, 0, 0, 323, 324, 3, 38, 19, 0, 324, 325, 5, 104, 0, 0, 325, 327, 1, 0, 0, 0, 326, 323, 1, 0, 0, 0, 326, 327, 1, 0, 0, 0, 327, 328, 1, 0, 0, 0, 328, 329, 3, 40, 20, 0, 329, 37, 1, 0, 0, 0, 330, 331, 5, 76, 0, 0, 331, 39, 1, 0, 0, 0, 332, 333, 7, 2, 0, 0, 333, 41, 1, 0, 0, 0, 334, 337, 3, 44, 22, 0, 335, 337, 3, 46, 23, 0, 336, 334, 1, 0, 0, 0, 336, 335, 1, 0, 0, 0, 337, 43, 1, 0, 0, 0, 338, 339, 5, 75, 0, 0, 339, 344, 5, 76, 0, 0, 340, 341, 5, 33, 0, 0, 341, 343, 5, 76, 0, 0, 342, 340, 1, 0, 0, 0, 343, 346, 1, 0, 0, 0, 344, 342, 1, 0, 0, 0, 344, 345, 1, 0, 0, 0, 345, 45, 1, 0, 0, 0, 346, 344, 1, 0, 0, 0, 347, 348, 5, 65, 0, 0, 348, 349, 3, 44, 22, 0, 349, 350, 5, 66, 0, 0, 350, 47, 1, 0, 0, 0, 351, 352, 5, 19, 0, 0, 352, 357, 3, 36, 18, 0, 353, 354, 5, 33, 0, 0, 354, 356, 3, 36, 18, 0, 355, 353, 1, 0, 0, 0, 356, 359, 1, 0, 0, 0, 357, 355, 1, 0, 0, 0, 357, 358, 1, 0, 0, 0, 358, 361, 1, 0, 0, 0, 359, 357, 1, 0, 0, 0, 360, 362, 3, 54, 27, 0, 361, 360, 1, 0, 0, 0, 361, 362, 1, 0, 0, 0, 362, 365, 1, 0, 0, 0, 363, 364, 5, 28, 0, 0, 364, 366, 3, 30, 15, 0, 365, 363, 1, 0, 0, 0, 365, 366, 1, 0, 0, 0, 366, 49, 1, 0, 0, 0, 367, 368, 5, 4, 0, 0, 368, 369, 3, 30, 15, 0, 369, 51, 1, 0, 0, 0, 370, 372, 5, 15, 0, 0, 371, 373, 3, 54, 27, 0, 372, 371, 1, 0, 0, 0, 372, 373, 1, 0, 0, 0, 373, 376, 1, 0, 0, 0, 374, 375, 5, 28, 0, 0, 375, 377, 3, 30, 15, 0, 376, 374, 1, 0, 0, 0, 376, 377, 1, 0, 0, 0, 377, 53, 1, 0, 0, 0, 378, 383, 3, 56, 28, 0, 379, 380, 5, 33, 0, 0, 380, 382, 3, 56, 28, 0, 381, 379, 1, 0, 0, 0, 382, 385, 1, 0, 0, 0, 383, 381, 1, 0, 0, 0, 383, 384, 1, 0, 0, 0, 384, 55, 1, 0, 0, 0, 385, 383, 1, 0, 0, 0, 386, 389, 3, 32, 16, 0, 387, 388, 5, 16, 0, 0, 388, 390, 3, 10, 5, 0, 389, 387, 1, 0, 0, 0, 389, 390, 1, 0, 0, 0, 390, 57, 1, 0, 0, 0, 391, 396, 3, 72, 36, 0, 392, 393, 5, 35, 0, 0, 393, 395, 3, 72, 36, 0, 394, 392, 1, 0, 0, 0, 395, 398, 1, 0, 0, 0, 396, 394, 1, 0, 0, 0, 396, 397, 1, 0, 0, 0, 397, 59, 1, 0, 0, 0, 398, 396, 1, 0, 0, 0, 399, 404, 3, 66, 33, 0, 400, 401, 5, 35, 0, 0, 401, 403, 3, 66, 33, 0, 402, 400, 1, 0, 0, 0, 403, 406, 1, 0, 0, 0, 404, 402, 1, 0, 0, 0, 404, 405, 1, 0, 0, 0, 405, 61, 1, 0, 0, 0, 406, 404, 1, 0, 0, 0, 407, 412, 3, 60, 30, 0, 408, 409, 5, 33, 0, 0, 409, 411, 3, 60, 30, 0, 410, 408, 1, 0, 0, 0, 411, 414, 1, 0, 0, 0, 412, 410, 1, 0, 0, 0, 412, 413, 1, 0, 0, 0, 413, 63, 1, 0, 0, 0, 414, 412, 1, 0, 0, 0, 415, 416, 7, 3, 0, 0, 416, 65, 1, 0, 0, 0, 417, 421, 5, 80, 0, 0, 418, 419, 4, 33, 10, 0, 419, 421, 3, 70, 35, 0, 420, 417, 1, 0, 0, 0, 420, 418, 1, 0, 0, 0, 421, 67, 1, 0, 0, 0, 422, 465, 5, 44, 0, 0, 423, 424, 3, 104, 52, 0, 424, 425, 5, 67, 0, 0, 425, 465, 1, 0, 0, 0, 426, 465, 3, 102, 51, 0, 427, 465, 3, 104, 52, 0, 428, 465, 3, 98, 49, 0, 429, 465, 3, 70, 35, 0, 430, 465, 3, 106, 53, 0, 431, 432, 5, 65, 0, 0, 432, 437, 3, 100, 50, 0, 433, 434, 5, 33, 0, 0, 434, 436, 3, 100, 50, 0, 435, 433, 1, 0, 0, 0, 436, 439, 1, 0, 0, 0, 437, 435, 1, 0, 0, 0, 437, 438, 1, 0, 0, 0, 438, 440, 1, 0, 0, 0, 439, 437, 1, 0, 0, 0, 440, 441, 5, 66, 0, 0, 441, 465, 1, 0, 0, 0, 442, 443, 5, 65, 0, 0, 443, 448, 3, 98, 49, 0, 444, 445, 5, 33, 0, 0, 445, 447, 3, 98, 49, 0, 446, 444, 1, 0, 0, 0, 447, 450, 1, 0, 0, 0, 448, 446, 1, 0, 0, 0, 448, 449, 1, 0, 0, 0, 449, 451, 1, 0, 0, 0, 450, 448, 1, 0, 0, 0, 451, 452, 5, 66, 0, 0, 452, 465, 1, 0, 0, 0, 453, 454, 5, 65, 0, 0, 454, 459, 3, 106, 53, 0, 455, 456, 5, 33, 0, 0, 456, 458, 3, 106, 53, 0, 457, 455, 1, 0, 0, 0, 458, 461, 1, 0, 0, 0, 459, 457, 1, 0, 0, 0, 459, 460, 1, 0, 0, 0, 460, 462, 1, 0, 0, 0, 461, 459, 1, 0, 0, 0, 462, 463, 5, 66, 0, 0, 463, 465, 1, 0, 0, 0, 464, 422, 1, 0, 0, 0, 464, 423, 1, 0, 0, 0, 464, 426, 1, 0, 0, 0, 464, 427, 1, 0, 0, 0, 464, 428, 1, 0, 0, 0, 464, 429, 1, 0, 0, 0, 464, 430, 1, 0, 0, 0, 464, 431, 1, 0, 0, 0, 464, 442, 1, 0, 0, 0, 464, 453, 1, 0, 0, 0, 465, 69, 1, 0, 0, 0, 466, 469, 5, 47, 0, 0, 467, 469, 5, 64, 0, 0, 468, 466, 1, 0, 0, 0, 468, 467, 1, 0, 0, 0, 469, 71, 1, 0, 0, 0, 470, 474, 3, 64, 32, 0, 471, 472, 4, 36, 11, 0, 472, 474, 3, 70, 35, 0, 473, 470, 1, 0, 0, 0, 473, 471, 1, 0, 0, 0, 474, 73, 1, 0, 0, 0, 475, 476, 5, 9, 0, 0, 476, 477, 5, 26, 0, 0, 477, 75, 1, 0, 0, 0, 478, 479, 5, 14, 0, 0, 479, 484, 3, 78, 39, 0, 480, 481, 5, 33, 0, 0, 481, 483, 3, 78, 39, 0, 482, 480, 1, 0, 0, 0, 483, 486, 1, 0, 0, 0, 484, 482, 1, 0, 0, 0, 484, 485, 1, 0, 0, 0, 485, 77, 1, 0, 0, 0, 486, 484, 1, 0, 0, 0, 487, 489, 3, 10, 5, 0, 488, 490, 7, 4, 0, 0, 489, 488, 1, 0, 0, 0, 489, 490, 1, 0, 0, 0, 490, 493, 1, 0, 0, 0, 491, 492, 5, 45, 0, 0, 492, 494, 7, 5, 0, 0, 493, 491, 1, 0, 0, 0, 493, 494, 1, 0, 0, 0, 494, 79, 1, 0, 0, 0, 495, 496, 5, 8, 0, 0, 496, 497, 3, 62, 31, 0, 497, 81, 1, 0, 0, 0, 498, 499, 5, 2, 0, 0, 499, 500, 3, 62, 31, 0, 500, 83, 1, 0, 0, 0, 501, 502, 5, 11, 0, 0, 502, 507, 3, 86, 43, 0, 503, 504, 5, 33, 0, 0, 504, 506, 3, 86, 43, 0, 505, 503, 1, 0, 0, 0, 506, 509, 1, 0, 0, 0, 507, 505, 1, 0, 0, 0, 507, 508, 1, 0, 0, 0, 508, 85, 1, 0, 0, 0, 509, 507, 1, 0, 0, 0, 510, 511, 3, 60, 30, 0, 511, 512, 5, 84, 0, 0, 512, 513, 3, 60, 30, 0, 513, 87, 1, 0, 0, 0, 514, 515, 5, 1, 0, 0, 515, 516, 3, 20, 10, 0, 516, 518, 3, 106, 53, 0, 517, 519, 3, 94, 47, 0, 518, 517, 1, 0, 0, 0, 518, 519, 1, 0, 0, 0, 519, 89, 1, 0, 0, 0, 520, 521, 5, 7, 0, 0, 521, 522, 3, 20, 10, 0, 522, 523, 3, 106, 53, 0, 523, 91, 1, 0, 0, 0, 524, 525, 5, 10, 0, 0, 525, 526, 3, 58, 29, 0, 526, 93, 1, 0, 0, 0, 527, 532, 3, 96, 48, 0, 528, 529, 5, 33, 0, 0, 529, 531, 3, 96, 48, 0, 530, 528, 1, 0, 0, 0, 531, 534, 1, 0, 0, 0, 532, 530, 1, 0, 0, 0, 532, 533, 1, 0, 0, 0, 533, 95, 1, 0, 0, 0, 534, 532, 1, 0, 0, 0, 535, 536, 3, 64, 32, 0, 536, 537, 5, 31, 0, 0, 537, 538, 3, 68, 34, 0, 538, 97, 1, 0, 0, 0, 539, 540, 7, 6, 0, 0, 540, 99, 1, 0, 0, 0, 541, 544, 3, 102, 51, 0, 542, 544, 3, 104, 52, 0, 543, 541, 1, 0, 0, 0, 543, 542, 1, 0, 0, 0, 544, 101, 1, 0, 0, 0, 545, 547, 7, 0, 0, 0, 546, 545, 1, 0, 0, 0, 546, 547, 1, 0, 0, 0, 547, 548, 1, 0, 0, 0, 548, 549, 5, 27, 0, 0, 549, 103, 1, 0, 0, 0, 550, 552, 7, 0, 0, 0, 551, 550, 1, 0, 0, 0, 551, 552, 1, 0, 0, 0, 552, 553, 1, 0, 0, 0, 553, 554, 5, 26, 0, 0, 554, 105, 1, 0, 0, 0, 555, 556, 5, 25, 0, 0, 556, 107, 1, 0, 0, 0, 557, 558, 7, 7, 0, 0, 558, 109, 1, 0, 0, 0, 559, 560, 5, 5, 0, 0, 560, 561, 3, 112, 56, 0, 561, 111, 1, 0, 0, 0, 562, 563, 5, 65, 0, 0, 563, 564, 3, 2, 1, 0, 564, 565, 5, 66, 0, 0, 565, 113, 1, 0, 0, 0, 566, 567, 5, 13, 0, 0, 567, 568, 5, 100, 0, 0, 568, 115, 1, 0, 0, 0, 569, 570, 5, 3, 0, 0, 570, 573, 5, 90, 0, 0, 571, 572, 5, 88, 0, 0, 572, 574, 3, 60, 30, 0, 573, 571, 1, 0, 0, 0, 573, 574, 1, 0, 0, 0, 574, 584, 1, 0, 0, 0, 575, 576, 5, 89, 0, 0, 576, 581, 3, 118, 59, 0, 577, 578, 5, 33, 0, 0, 578, 580, 3, 118, 59, 0, 579, 577, 1, 0, 0, 0, 580, 583, 1, 0, 0, 0, 581, 579, 1, 0, 0, 0, 581, 582, 1, 0, 0, 0, 582, 585, 1, 0, 0, 0, 583, 581, 1, 0, 0, 0, 584, 575, 1, 0, 0, 0, 584, 585, 1, 0, 0, 0, 585, 117, 1, 0, 0, 0, 586, 587, 3, 60, 30, 0, 587, 588, 5, 31, 0, 0, 588, 590, 1, 0, 0, 0, 589, 586, 1, 0, 0, 0, 589, 590, 1, 0, 0, 0, 590, 591, 1, 0, 0, 0, 591, 592, 3, 60, 30, 0, 592, 119, 1, 0, 0, 0, 593, 594, 5, 18, 0, 0, 594, 595, 3, 36, 18, 0, 595, 596, 5, 88, 0, 0, 596, 597, 3, 62, 31, 0, 597, 121, 1, 0, 0, 0, 598, 599, 5, 17, 0, 0, 599, 602, 3, 54, 27, 0, 600, 601, 5, 28, 0, 0, 601, 603, 3, 30, 15, 0, 602, 600, 1, 0, 0, 0, 602, 603, 1, 0, 0, 0, 603, 123, 1, 0, 0, 0, 59, 135, 144, 162, 174, 183, 191, 197, 205, 207, 212, 219, 224, 235, 241, 249, 251, 262, 269, 280, 283, 289, 301, 307, 317, 321, 326, 336, 344, 357, 361, 365, 372, 376, 383, 389, 396, 404, 412, 420, 437, 448, 459, 464, 468, 473, 484, 489, 493, 507, 518, 532, 543, 546, 551, 573, 581, 584, 589, 602] \ No newline at end of file +[4, 1, 119, 603, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 2, 8, 7, 8, 2, 9, 7, 9, 2, 10, 7, 10, 2, 11, 7, 11, 2, 12, 7, 12, 2, 13, 7, 13, 2, 14, 7, 14, 2, 15, 7, 15, 2, 16, 7, 16, 2, 17, 7, 17, 2, 18, 7, 18, 2, 19, 7, 19, 2, 20, 7, 20, 2, 21, 7, 21, 2, 22, 7, 22, 2, 23, 7, 23, 2, 24, 7, 24, 2, 25, 7, 25, 2, 26, 7, 26, 2, 27, 7, 27, 2, 28, 7, 28, 2, 29, 7, 29, 2, 30, 7, 30, 2, 31, 7, 31, 2, 32, 7, 32, 2, 33, 7, 33, 2, 34, 7, 34, 2, 35, 7, 35, 2, 36, 7, 36, 2, 37, 7, 37, 2, 38, 7, 38, 2, 39, 7, 39, 2, 40, 7, 40, 2, 41, 7, 41, 2, 42, 7, 42, 2, 43, 7, 43, 2, 44, 7, 44, 2, 45, 7, 45, 2, 46, 7, 46, 2, 47, 7, 47, 2, 48, 7, 48, 2, 49, 7, 49, 2, 50, 7, 50, 2, 51, 7, 51, 2, 52, 7, 52, 2, 53, 7, 53, 2, 54, 7, 54, 2, 55, 7, 55, 2, 56, 7, 56, 2, 57, 7, 57, 2, 58, 7, 58, 2, 59, 7, 59, 2, 60, 7, 60, 2, 61, 7, 61, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 5, 1, 134, 8, 1, 10, 1, 12, 1, 137, 9, 1, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 3, 2, 145, 8, 2, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 3, 3, 163, 8, 3, 1, 4, 1, 4, 1, 4, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 3, 5, 175, 8, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 5, 5, 182, 8, 5, 10, 5, 12, 5, 185, 9, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 3, 5, 192, 8, 5, 1, 5, 1, 5, 1, 5, 1, 5, 3, 5, 198, 8, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 5, 5, 206, 8, 5, 10, 5, 12, 5, 209, 9, 5, 1, 6, 1, 6, 3, 6, 213, 8, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 3, 6, 220, 8, 6, 1, 6, 1, 6, 1, 6, 3, 6, 225, 8, 6, 1, 7, 1, 7, 1, 7, 1, 7, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 3, 8, 236, 8, 8, 1, 9, 1, 9, 1, 9, 1, 9, 3, 9, 242, 8, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 5, 9, 250, 8, 9, 10, 9, 12, 9, 253, 9, 9, 1, 10, 1, 10, 1, 10, 1, 10, 1, 10, 1, 10, 1, 10, 1, 10, 3, 10, 263, 8, 10, 1, 10, 1, 10, 1, 10, 5, 10, 268, 8, 10, 10, 10, 12, 10, 271, 9, 10, 1, 11, 1, 11, 1, 11, 1, 11, 1, 11, 1, 11, 5, 11, 279, 8, 11, 10, 11, 12, 11, 282, 9, 11, 3, 11, 284, 8, 11, 1, 11, 1, 11, 1, 12, 1, 12, 1, 13, 1, 13, 1, 14, 1, 14, 1, 14, 1, 15, 1, 15, 1, 15, 5, 15, 298, 8, 15, 10, 15, 12, 15, 301, 9, 15, 1, 16, 1, 16, 1, 16, 3, 16, 306, 8, 16, 1, 16, 1, 16, 1, 17, 1, 17, 1, 17, 1, 17, 5, 17, 314, 8, 17, 10, 17, 12, 17, 317, 9, 17, 1, 17, 3, 17, 320, 8, 17, 1, 18, 1, 18, 1, 18, 3, 18, 325, 8, 18, 1, 18, 1, 18, 1, 19, 1, 19, 1, 20, 1, 20, 1, 21, 1, 21, 3, 21, 335, 8, 21, 1, 22, 1, 22, 1, 22, 1, 22, 5, 22, 341, 8, 22, 10, 22, 12, 22, 344, 9, 22, 1, 23, 1, 23, 1, 23, 1, 23, 1, 24, 1, 24, 1, 24, 1, 24, 5, 24, 354, 8, 24, 10, 24, 12, 24, 357, 9, 24, 1, 24, 3, 24, 360, 8, 24, 1, 24, 1, 24, 3, 24, 364, 8, 24, 1, 25, 1, 25, 1, 25, 1, 26, 1, 26, 3, 26, 371, 8, 26, 1, 26, 1, 26, 3, 26, 375, 8, 26, 1, 27, 1, 27, 1, 27, 5, 27, 380, 8, 27, 10, 27, 12, 27, 383, 9, 27, 1, 28, 1, 28, 1, 28, 3, 28, 388, 8, 28, 1, 29, 1, 29, 1, 29, 5, 29, 393, 8, 29, 10, 29, 12, 29, 396, 9, 29, 1, 30, 1, 30, 1, 30, 5, 30, 401, 8, 30, 10, 30, 12, 30, 404, 9, 30, 1, 31, 1, 31, 1, 31, 5, 31, 409, 8, 31, 10, 31, 12, 31, 412, 9, 31, 1, 32, 1, 32, 1, 33, 1, 33, 1, 33, 3, 33, 419, 8, 33, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 5, 34, 434, 8, 34, 10, 34, 12, 34, 437, 9, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 5, 34, 445, 8, 34, 10, 34, 12, 34, 448, 9, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 5, 34, 456, 8, 34, 10, 34, 12, 34, 459, 9, 34, 1, 34, 1, 34, 3, 34, 463, 8, 34, 1, 35, 1, 35, 3, 35, 467, 8, 35, 1, 36, 1, 36, 1, 36, 3, 36, 472, 8, 36, 1, 37, 1, 37, 1, 37, 1, 38, 1, 38, 1, 38, 1, 38, 5, 38, 481, 8, 38, 10, 38, 12, 38, 484, 9, 38, 1, 39, 1, 39, 3, 39, 488, 8, 39, 1, 39, 1, 39, 3, 39, 492, 8, 39, 1, 40, 1, 40, 1, 40, 1, 41, 1, 41, 1, 41, 1, 42, 1, 42, 1, 42, 1, 42, 5, 42, 504, 8, 42, 10, 42, 12, 42, 507, 9, 42, 1, 43, 1, 43, 1, 43, 1, 43, 1, 44, 1, 44, 1, 44, 1, 44, 3, 44, 517, 8, 44, 1, 45, 1, 45, 1, 45, 1, 45, 1, 46, 1, 46, 1, 46, 1, 47, 1, 47, 1, 47, 5, 47, 529, 8, 47, 10, 47, 12, 47, 532, 9, 47, 1, 48, 1, 48, 1, 48, 1, 48, 1, 49, 1, 49, 1, 50, 1, 50, 3, 50, 542, 8, 50, 1, 51, 3, 51, 545, 8, 51, 1, 51, 1, 51, 1, 52, 3, 52, 550, 8, 52, 1, 52, 1, 52, 1, 53, 1, 53, 1, 54, 1, 54, 1, 55, 1, 55, 1, 55, 1, 56, 1, 56, 1, 56, 1, 56, 1, 57, 1, 57, 1, 57, 1, 58, 1, 58, 1, 58, 1, 58, 3, 58, 572, 8, 58, 1, 58, 1, 58, 1, 58, 1, 58, 5, 58, 578, 8, 58, 10, 58, 12, 58, 581, 9, 58, 3, 58, 583, 8, 58, 1, 59, 1, 59, 1, 59, 3, 59, 588, 8, 59, 1, 59, 1, 59, 1, 60, 1, 60, 1, 60, 1, 60, 1, 60, 1, 61, 1, 61, 1, 61, 1, 61, 3, 61, 601, 8, 61, 1, 61, 0, 4, 2, 10, 18, 20, 62, 0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 34, 36, 38, 40, 42, 44, 46, 48, 50, 52, 54, 56, 58, 60, 62, 64, 66, 68, 70, 72, 74, 76, 78, 80, 82, 84, 86, 88, 90, 92, 94, 96, 98, 100, 102, 104, 106, 108, 110, 112, 114, 116, 118, 120, 122, 0, 8, 1, 0, 59, 60, 1, 0, 61, 63, 2, 0, 26, 26, 76, 76, 1, 0, 67, 68, 2, 0, 31, 31, 35, 35, 2, 0, 38, 38, 41, 41, 2, 0, 37, 37, 51, 51, 2, 0, 52, 52, 54, 58, 628, 0, 124, 1, 0, 0, 0, 2, 127, 1, 0, 0, 0, 4, 144, 1, 0, 0, 0, 6, 162, 1, 0, 0, 0, 8, 164, 1, 0, 0, 0, 10, 197, 1, 0, 0, 0, 12, 224, 1, 0, 0, 0, 14, 226, 1, 0, 0, 0, 16, 235, 1, 0, 0, 0, 18, 241, 1, 0, 0, 0, 20, 262, 1, 0, 0, 0, 22, 272, 1, 0, 0, 0, 24, 287, 1, 0, 0, 0, 26, 289, 1, 0, 0, 0, 28, 291, 1, 0, 0, 0, 30, 294, 1, 0, 0, 0, 32, 305, 1, 0, 0, 0, 34, 309, 1, 0, 0, 0, 36, 324, 1, 0, 0, 0, 38, 328, 1, 0, 0, 0, 40, 330, 1, 0, 0, 0, 42, 334, 1, 0, 0, 0, 44, 336, 1, 0, 0, 0, 46, 345, 1, 0, 0, 0, 48, 349, 1, 0, 0, 0, 50, 365, 1, 0, 0, 0, 52, 368, 1, 0, 0, 0, 54, 376, 1, 0, 0, 0, 56, 384, 1, 0, 0, 0, 58, 389, 1, 0, 0, 0, 60, 397, 1, 0, 0, 0, 62, 405, 1, 0, 0, 0, 64, 413, 1, 0, 0, 0, 66, 418, 1, 0, 0, 0, 68, 462, 1, 0, 0, 0, 70, 466, 1, 0, 0, 0, 72, 471, 1, 0, 0, 0, 74, 473, 1, 0, 0, 0, 76, 476, 1, 0, 0, 0, 78, 485, 1, 0, 0, 0, 80, 493, 1, 0, 0, 0, 82, 496, 1, 0, 0, 0, 84, 499, 1, 0, 0, 0, 86, 508, 1, 0, 0, 0, 88, 512, 1, 0, 0, 0, 90, 518, 1, 0, 0, 0, 92, 522, 1, 0, 0, 0, 94, 525, 1, 0, 0, 0, 96, 533, 1, 0, 0, 0, 98, 537, 1, 0, 0, 0, 100, 541, 1, 0, 0, 0, 102, 544, 1, 0, 0, 0, 104, 549, 1, 0, 0, 0, 106, 553, 1, 0, 0, 0, 108, 555, 1, 0, 0, 0, 110, 557, 1, 0, 0, 0, 112, 560, 1, 0, 0, 0, 114, 564, 1, 0, 0, 0, 116, 567, 1, 0, 0, 0, 118, 587, 1, 0, 0, 0, 120, 591, 1, 0, 0, 0, 122, 596, 1, 0, 0, 0, 124, 125, 3, 2, 1, 0, 125, 126, 5, 0, 0, 1, 126, 1, 1, 0, 0, 0, 127, 128, 6, 1, -1, 0, 128, 129, 3, 4, 2, 0, 129, 135, 1, 0, 0, 0, 130, 131, 10, 1, 0, 0, 131, 132, 5, 25, 0, 0, 132, 134, 3, 6, 3, 0, 133, 130, 1, 0, 0, 0, 134, 137, 1, 0, 0, 0, 135, 133, 1, 0, 0, 0, 135, 136, 1, 0, 0, 0, 136, 3, 1, 0, 0, 0, 137, 135, 1, 0, 0, 0, 138, 145, 3, 110, 55, 0, 139, 145, 3, 34, 17, 0, 140, 145, 3, 28, 14, 0, 141, 145, 3, 114, 57, 0, 142, 143, 4, 2, 1, 0, 143, 145, 3, 48, 24, 0, 144, 138, 1, 0, 0, 0, 144, 139, 1, 0, 0, 0, 144, 140, 1, 0, 0, 0, 144, 141, 1, 0, 0, 0, 144, 142, 1, 0, 0, 0, 145, 5, 1, 0, 0, 0, 146, 163, 3, 50, 25, 0, 147, 163, 3, 8, 4, 0, 148, 163, 3, 80, 40, 0, 149, 163, 3, 74, 37, 0, 150, 163, 3, 52, 26, 0, 151, 163, 3, 76, 38, 0, 152, 163, 3, 82, 41, 0, 153, 163, 3, 84, 42, 0, 154, 163, 3, 88, 44, 0, 155, 163, 3, 90, 45, 0, 156, 163, 3, 116, 58, 0, 157, 163, 3, 92, 46, 0, 158, 159, 4, 3, 2, 0, 159, 163, 3, 122, 61, 0, 160, 161, 4, 3, 3, 0, 161, 163, 3, 120, 60, 0, 162, 146, 1, 0, 0, 0, 162, 147, 1, 0, 0, 0, 162, 148, 1, 0, 0, 0, 162, 149, 1, 0, 0, 0, 162, 150, 1, 0, 0, 0, 162, 151, 1, 0, 0, 0, 162, 152, 1, 0, 0, 0, 162, 153, 1, 0, 0, 0, 162, 154, 1, 0, 0, 0, 162, 155, 1, 0, 0, 0, 162, 156, 1, 0, 0, 0, 162, 157, 1, 0, 0, 0, 162, 158, 1, 0, 0, 0, 162, 160, 1, 0, 0, 0, 163, 7, 1, 0, 0, 0, 164, 165, 5, 16, 0, 0, 165, 166, 3, 10, 5, 0, 166, 9, 1, 0, 0, 0, 167, 168, 6, 5, -1, 0, 168, 169, 5, 44, 0, 0, 169, 198, 3, 10, 5, 8, 170, 198, 3, 16, 8, 0, 171, 198, 3, 12, 6, 0, 172, 174, 3, 16, 8, 0, 173, 175, 5, 44, 0, 0, 174, 173, 1, 0, 0, 0, 174, 175, 1, 0, 0, 0, 175, 176, 1, 0, 0, 0, 176, 177, 5, 39, 0, 0, 177, 178, 5, 43, 0, 0, 178, 183, 3, 16, 8, 0, 179, 180, 5, 34, 0, 0, 180, 182, 3, 16, 8, 0, 181, 179, 1, 0, 0, 0, 182, 185, 1, 0, 0, 0, 183, 181, 1, 0, 0, 0, 183, 184, 1, 0, 0, 0, 184, 186, 1, 0, 0, 0, 185, 183, 1, 0, 0, 0, 186, 187, 5, 50, 0, 0, 187, 198, 1, 0, 0, 0, 188, 189, 3, 16, 8, 0, 189, 191, 5, 40, 0, 0, 190, 192, 5, 44, 0, 0, 191, 190, 1, 0, 0, 0, 191, 192, 1, 0, 0, 0, 192, 193, 1, 0, 0, 0, 193, 194, 5, 45, 0, 0, 194, 198, 1, 0, 0, 0, 195, 196, 4, 5, 4, 0, 196, 198, 3, 14, 7, 0, 197, 167, 1, 0, 0, 0, 197, 170, 1, 0, 0, 0, 197, 171, 1, 0, 0, 0, 197, 172, 1, 0, 0, 0, 197, 188, 1, 0, 0, 0, 197, 195, 1, 0, 0, 0, 198, 207, 1, 0, 0, 0, 199, 200, 10, 5, 0, 0, 200, 201, 5, 30, 0, 0, 201, 206, 3, 10, 5, 6, 202, 203, 10, 4, 0, 0, 203, 204, 5, 47, 0, 0, 204, 206, 3, 10, 5, 5, 205, 199, 1, 0, 0, 0, 205, 202, 1, 0, 0, 0, 206, 209, 1, 0, 0, 0, 207, 205, 1, 0, 0, 0, 207, 208, 1, 0, 0, 0, 208, 11, 1, 0, 0, 0, 209, 207, 1, 0, 0, 0, 210, 212, 3, 16, 8, 0, 211, 213, 5, 44, 0, 0, 212, 211, 1, 0, 0, 0, 212, 213, 1, 0, 0, 0, 213, 214, 1, 0, 0, 0, 214, 215, 5, 42, 0, 0, 215, 216, 3, 106, 53, 0, 216, 225, 1, 0, 0, 0, 217, 219, 3, 16, 8, 0, 218, 220, 5, 44, 0, 0, 219, 218, 1, 0, 0, 0, 219, 220, 1, 0, 0, 0, 220, 221, 1, 0, 0, 0, 221, 222, 5, 49, 0, 0, 222, 223, 3, 106, 53, 0, 223, 225, 1, 0, 0, 0, 224, 210, 1, 0, 0, 0, 224, 217, 1, 0, 0, 0, 225, 13, 1, 0, 0, 0, 226, 227, 3, 58, 29, 0, 227, 228, 5, 24, 0, 0, 228, 229, 3, 68, 34, 0, 229, 15, 1, 0, 0, 0, 230, 236, 3, 18, 9, 0, 231, 232, 3, 18, 9, 0, 232, 233, 3, 108, 54, 0, 233, 234, 3, 18, 9, 0, 234, 236, 1, 0, 0, 0, 235, 230, 1, 0, 0, 0, 235, 231, 1, 0, 0, 0, 236, 17, 1, 0, 0, 0, 237, 238, 6, 9, -1, 0, 238, 242, 3, 20, 10, 0, 239, 240, 7, 0, 0, 0, 240, 242, 3, 18, 9, 3, 241, 237, 1, 0, 0, 0, 241, 239, 1, 0, 0, 0, 242, 251, 1, 0, 0, 0, 243, 244, 10, 2, 0, 0, 244, 245, 7, 1, 0, 0, 245, 250, 3, 18, 9, 3, 246, 247, 10, 1, 0, 0, 247, 248, 7, 0, 0, 0, 248, 250, 3, 18, 9, 2, 249, 243, 1, 0, 0, 0, 249, 246, 1, 0, 0, 0, 250, 253, 1, 0, 0, 0, 251, 249, 1, 0, 0, 0, 251, 252, 1, 0, 0, 0, 252, 19, 1, 0, 0, 0, 253, 251, 1, 0, 0, 0, 254, 255, 6, 10, -1, 0, 255, 263, 3, 68, 34, 0, 256, 263, 3, 58, 29, 0, 257, 263, 3, 22, 11, 0, 258, 259, 5, 43, 0, 0, 259, 260, 3, 10, 5, 0, 260, 261, 5, 50, 0, 0, 261, 263, 1, 0, 0, 0, 262, 254, 1, 0, 0, 0, 262, 256, 1, 0, 0, 0, 262, 257, 1, 0, 0, 0, 262, 258, 1, 0, 0, 0, 263, 269, 1, 0, 0, 0, 264, 265, 10, 1, 0, 0, 265, 266, 5, 33, 0, 0, 266, 268, 3, 26, 13, 0, 267, 264, 1, 0, 0, 0, 268, 271, 1, 0, 0, 0, 269, 267, 1, 0, 0, 0, 269, 270, 1, 0, 0, 0, 270, 21, 1, 0, 0, 0, 271, 269, 1, 0, 0, 0, 272, 273, 3, 24, 12, 0, 273, 283, 5, 43, 0, 0, 274, 284, 5, 61, 0, 0, 275, 280, 3, 10, 5, 0, 276, 277, 5, 34, 0, 0, 277, 279, 3, 10, 5, 0, 278, 276, 1, 0, 0, 0, 279, 282, 1, 0, 0, 0, 280, 278, 1, 0, 0, 0, 280, 281, 1, 0, 0, 0, 281, 284, 1, 0, 0, 0, 282, 280, 1, 0, 0, 0, 283, 274, 1, 0, 0, 0, 283, 275, 1, 0, 0, 0, 283, 284, 1, 0, 0, 0, 284, 285, 1, 0, 0, 0, 285, 286, 5, 50, 0, 0, 286, 23, 1, 0, 0, 0, 287, 288, 3, 72, 36, 0, 288, 25, 1, 0, 0, 0, 289, 290, 3, 64, 32, 0, 290, 27, 1, 0, 0, 0, 291, 292, 5, 12, 0, 0, 292, 293, 3, 30, 15, 0, 293, 29, 1, 0, 0, 0, 294, 299, 3, 32, 16, 0, 295, 296, 5, 34, 0, 0, 296, 298, 3, 32, 16, 0, 297, 295, 1, 0, 0, 0, 298, 301, 1, 0, 0, 0, 299, 297, 1, 0, 0, 0, 299, 300, 1, 0, 0, 0, 300, 31, 1, 0, 0, 0, 301, 299, 1, 0, 0, 0, 302, 303, 3, 58, 29, 0, 303, 304, 5, 32, 0, 0, 304, 306, 1, 0, 0, 0, 305, 302, 1, 0, 0, 0, 305, 306, 1, 0, 0, 0, 306, 307, 1, 0, 0, 0, 307, 308, 3, 10, 5, 0, 308, 33, 1, 0, 0, 0, 309, 310, 5, 6, 0, 0, 310, 315, 3, 36, 18, 0, 311, 312, 5, 34, 0, 0, 312, 314, 3, 36, 18, 0, 313, 311, 1, 0, 0, 0, 314, 317, 1, 0, 0, 0, 315, 313, 1, 0, 0, 0, 315, 316, 1, 0, 0, 0, 316, 319, 1, 0, 0, 0, 317, 315, 1, 0, 0, 0, 318, 320, 3, 42, 21, 0, 319, 318, 1, 0, 0, 0, 319, 320, 1, 0, 0, 0, 320, 35, 1, 0, 0, 0, 321, 322, 3, 38, 19, 0, 322, 323, 5, 24, 0, 0, 323, 325, 1, 0, 0, 0, 324, 321, 1, 0, 0, 0, 324, 325, 1, 0, 0, 0, 325, 326, 1, 0, 0, 0, 326, 327, 3, 40, 20, 0, 327, 37, 1, 0, 0, 0, 328, 329, 5, 76, 0, 0, 329, 39, 1, 0, 0, 0, 330, 331, 7, 2, 0, 0, 331, 41, 1, 0, 0, 0, 332, 335, 3, 44, 22, 0, 333, 335, 3, 46, 23, 0, 334, 332, 1, 0, 0, 0, 334, 333, 1, 0, 0, 0, 335, 43, 1, 0, 0, 0, 336, 337, 5, 75, 0, 0, 337, 342, 5, 76, 0, 0, 338, 339, 5, 34, 0, 0, 339, 341, 5, 76, 0, 0, 340, 338, 1, 0, 0, 0, 341, 344, 1, 0, 0, 0, 342, 340, 1, 0, 0, 0, 342, 343, 1, 0, 0, 0, 343, 45, 1, 0, 0, 0, 344, 342, 1, 0, 0, 0, 345, 346, 5, 65, 0, 0, 346, 347, 3, 44, 22, 0, 347, 348, 5, 66, 0, 0, 348, 47, 1, 0, 0, 0, 349, 350, 5, 19, 0, 0, 350, 355, 3, 36, 18, 0, 351, 352, 5, 34, 0, 0, 352, 354, 3, 36, 18, 0, 353, 351, 1, 0, 0, 0, 354, 357, 1, 0, 0, 0, 355, 353, 1, 0, 0, 0, 355, 356, 1, 0, 0, 0, 356, 359, 1, 0, 0, 0, 357, 355, 1, 0, 0, 0, 358, 360, 3, 54, 27, 0, 359, 358, 1, 0, 0, 0, 359, 360, 1, 0, 0, 0, 360, 363, 1, 0, 0, 0, 361, 362, 5, 29, 0, 0, 362, 364, 3, 30, 15, 0, 363, 361, 1, 0, 0, 0, 363, 364, 1, 0, 0, 0, 364, 49, 1, 0, 0, 0, 365, 366, 5, 4, 0, 0, 366, 367, 3, 30, 15, 0, 367, 51, 1, 0, 0, 0, 368, 370, 5, 15, 0, 0, 369, 371, 3, 54, 27, 0, 370, 369, 1, 0, 0, 0, 370, 371, 1, 0, 0, 0, 371, 374, 1, 0, 0, 0, 372, 373, 5, 29, 0, 0, 373, 375, 3, 30, 15, 0, 374, 372, 1, 0, 0, 0, 374, 375, 1, 0, 0, 0, 375, 53, 1, 0, 0, 0, 376, 381, 3, 56, 28, 0, 377, 378, 5, 34, 0, 0, 378, 380, 3, 56, 28, 0, 379, 377, 1, 0, 0, 0, 380, 383, 1, 0, 0, 0, 381, 379, 1, 0, 0, 0, 381, 382, 1, 0, 0, 0, 382, 55, 1, 0, 0, 0, 383, 381, 1, 0, 0, 0, 384, 387, 3, 32, 16, 0, 385, 386, 5, 16, 0, 0, 386, 388, 3, 10, 5, 0, 387, 385, 1, 0, 0, 0, 387, 388, 1, 0, 0, 0, 388, 57, 1, 0, 0, 0, 389, 394, 3, 72, 36, 0, 390, 391, 5, 36, 0, 0, 391, 393, 3, 72, 36, 0, 392, 390, 1, 0, 0, 0, 393, 396, 1, 0, 0, 0, 394, 392, 1, 0, 0, 0, 394, 395, 1, 0, 0, 0, 395, 59, 1, 0, 0, 0, 396, 394, 1, 0, 0, 0, 397, 402, 3, 66, 33, 0, 398, 399, 5, 36, 0, 0, 399, 401, 3, 66, 33, 0, 400, 398, 1, 0, 0, 0, 401, 404, 1, 0, 0, 0, 402, 400, 1, 0, 0, 0, 402, 403, 1, 0, 0, 0, 403, 61, 1, 0, 0, 0, 404, 402, 1, 0, 0, 0, 405, 410, 3, 60, 30, 0, 406, 407, 5, 34, 0, 0, 407, 409, 3, 60, 30, 0, 408, 406, 1, 0, 0, 0, 409, 412, 1, 0, 0, 0, 410, 408, 1, 0, 0, 0, 410, 411, 1, 0, 0, 0, 411, 63, 1, 0, 0, 0, 412, 410, 1, 0, 0, 0, 413, 414, 7, 3, 0, 0, 414, 65, 1, 0, 0, 0, 415, 419, 5, 80, 0, 0, 416, 417, 4, 33, 10, 0, 417, 419, 3, 70, 35, 0, 418, 415, 1, 0, 0, 0, 418, 416, 1, 0, 0, 0, 419, 67, 1, 0, 0, 0, 420, 463, 5, 45, 0, 0, 421, 422, 3, 104, 52, 0, 422, 423, 5, 67, 0, 0, 423, 463, 1, 0, 0, 0, 424, 463, 3, 102, 51, 0, 425, 463, 3, 104, 52, 0, 426, 463, 3, 98, 49, 0, 427, 463, 3, 70, 35, 0, 428, 463, 3, 106, 53, 0, 429, 430, 5, 65, 0, 0, 430, 435, 3, 100, 50, 0, 431, 432, 5, 34, 0, 0, 432, 434, 3, 100, 50, 0, 433, 431, 1, 0, 0, 0, 434, 437, 1, 0, 0, 0, 435, 433, 1, 0, 0, 0, 435, 436, 1, 0, 0, 0, 436, 438, 1, 0, 0, 0, 437, 435, 1, 0, 0, 0, 438, 439, 5, 66, 0, 0, 439, 463, 1, 0, 0, 0, 440, 441, 5, 65, 0, 0, 441, 446, 3, 98, 49, 0, 442, 443, 5, 34, 0, 0, 443, 445, 3, 98, 49, 0, 444, 442, 1, 0, 0, 0, 445, 448, 1, 0, 0, 0, 446, 444, 1, 0, 0, 0, 446, 447, 1, 0, 0, 0, 447, 449, 1, 0, 0, 0, 448, 446, 1, 0, 0, 0, 449, 450, 5, 66, 0, 0, 450, 463, 1, 0, 0, 0, 451, 452, 5, 65, 0, 0, 452, 457, 3, 106, 53, 0, 453, 454, 5, 34, 0, 0, 454, 456, 3, 106, 53, 0, 455, 453, 1, 0, 0, 0, 456, 459, 1, 0, 0, 0, 457, 455, 1, 0, 0, 0, 457, 458, 1, 0, 0, 0, 458, 460, 1, 0, 0, 0, 459, 457, 1, 0, 0, 0, 460, 461, 5, 66, 0, 0, 461, 463, 1, 0, 0, 0, 462, 420, 1, 0, 0, 0, 462, 421, 1, 0, 0, 0, 462, 424, 1, 0, 0, 0, 462, 425, 1, 0, 0, 0, 462, 426, 1, 0, 0, 0, 462, 427, 1, 0, 0, 0, 462, 428, 1, 0, 0, 0, 462, 429, 1, 0, 0, 0, 462, 440, 1, 0, 0, 0, 462, 451, 1, 0, 0, 0, 463, 69, 1, 0, 0, 0, 464, 467, 5, 48, 0, 0, 465, 467, 5, 64, 0, 0, 466, 464, 1, 0, 0, 0, 466, 465, 1, 0, 0, 0, 467, 71, 1, 0, 0, 0, 468, 472, 3, 64, 32, 0, 469, 470, 4, 36, 11, 0, 470, 472, 3, 70, 35, 0, 471, 468, 1, 0, 0, 0, 471, 469, 1, 0, 0, 0, 472, 73, 1, 0, 0, 0, 473, 474, 5, 9, 0, 0, 474, 475, 5, 27, 0, 0, 475, 75, 1, 0, 0, 0, 476, 477, 5, 14, 0, 0, 477, 482, 3, 78, 39, 0, 478, 479, 5, 34, 0, 0, 479, 481, 3, 78, 39, 0, 480, 478, 1, 0, 0, 0, 481, 484, 1, 0, 0, 0, 482, 480, 1, 0, 0, 0, 482, 483, 1, 0, 0, 0, 483, 77, 1, 0, 0, 0, 484, 482, 1, 0, 0, 0, 485, 487, 3, 10, 5, 0, 486, 488, 7, 4, 0, 0, 487, 486, 1, 0, 0, 0, 487, 488, 1, 0, 0, 0, 488, 491, 1, 0, 0, 0, 489, 490, 5, 46, 0, 0, 490, 492, 7, 5, 0, 0, 491, 489, 1, 0, 0, 0, 491, 492, 1, 0, 0, 0, 492, 79, 1, 0, 0, 0, 493, 494, 5, 8, 0, 0, 494, 495, 3, 62, 31, 0, 495, 81, 1, 0, 0, 0, 496, 497, 5, 2, 0, 0, 497, 498, 3, 62, 31, 0, 498, 83, 1, 0, 0, 0, 499, 500, 5, 11, 0, 0, 500, 505, 3, 86, 43, 0, 501, 502, 5, 34, 0, 0, 502, 504, 3, 86, 43, 0, 503, 501, 1, 0, 0, 0, 504, 507, 1, 0, 0, 0, 505, 503, 1, 0, 0, 0, 505, 506, 1, 0, 0, 0, 506, 85, 1, 0, 0, 0, 507, 505, 1, 0, 0, 0, 508, 509, 3, 60, 30, 0, 509, 510, 5, 84, 0, 0, 510, 511, 3, 60, 30, 0, 511, 87, 1, 0, 0, 0, 512, 513, 5, 1, 0, 0, 513, 514, 3, 20, 10, 0, 514, 516, 3, 106, 53, 0, 515, 517, 3, 94, 47, 0, 516, 515, 1, 0, 0, 0, 516, 517, 1, 0, 0, 0, 517, 89, 1, 0, 0, 0, 518, 519, 5, 7, 0, 0, 519, 520, 3, 20, 10, 0, 520, 521, 3, 106, 53, 0, 521, 91, 1, 0, 0, 0, 522, 523, 5, 10, 0, 0, 523, 524, 3, 58, 29, 0, 524, 93, 1, 0, 0, 0, 525, 530, 3, 96, 48, 0, 526, 527, 5, 34, 0, 0, 527, 529, 3, 96, 48, 0, 528, 526, 1, 0, 0, 0, 529, 532, 1, 0, 0, 0, 530, 528, 1, 0, 0, 0, 530, 531, 1, 0, 0, 0, 531, 95, 1, 0, 0, 0, 532, 530, 1, 0, 0, 0, 533, 534, 3, 64, 32, 0, 534, 535, 5, 32, 0, 0, 535, 536, 3, 68, 34, 0, 536, 97, 1, 0, 0, 0, 537, 538, 7, 6, 0, 0, 538, 99, 1, 0, 0, 0, 539, 542, 3, 102, 51, 0, 540, 542, 3, 104, 52, 0, 541, 539, 1, 0, 0, 0, 541, 540, 1, 0, 0, 0, 542, 101, 1, 0, 0, 0, 543, 545, 7, 0, 0, 0, 544, 543, 1, 0, 0, 0, 544, 545, 1, 0, 0, 0, 545, 546, 1, 0, 0, 0, 546, 547, 5, 28, 0, 0, 547, 103, 1, 0, 0, 0, 548, 550, 7, 0, 0, 0, 549, 548, 1, 0, 0, 0, 549, 550, 1, 0, 0, 0, 550, 551, 1, 0, 0, 0, 551, 552, 5, 27, 0, 0, 552, 105, 1, 0, 0, 0, 553, 554, 5, 26, 0, 0, 554, 107, 1, 0, 0, 0, 555, 556, 7, 7, 0, 0, 556, 109, 1, 0, 0, 0, 557, 558, 5, 5, 0, 0, 558, 559, 3, 112, 56, 0, 559, 111, 1, 0, 0, 0, 560, 561, 5, 65, 0, 0, 561, 562, 3, 2, 1, 0, 562, 563, 5, 66, 0, 0, 563, 113, 1, 0, 0, 0, 564, 565, 5, 13, 0, 0, 565, 566, 5, 100, 0, 0, 566, 115, 1, 0, 0, 0, 567, 568, 5, 3, 0, 0, 568, 571, 5, 90, 0, 0, 569, 570, 5, 88, 0, 0, 570, 572, 3, 60, 30, 0, 571, 569, 1, 0, 0, 0, 571, 572, 1, 0, 0, 0, 572, 582, 1, 0, 0, 0, 573, 574, 5, 89, 0, 0, 574, 579, 3, 118, 59, 0, 575, 576, 5, 34, 0, 0, 576, 578, 3, 118, 59, 0, 577, 575, 1, 0, 0, 0, 578, 581, 1, 0, 0, 0, 579, 577, 1, 0, 0, 0, 579, 580, 1, 0, 0, 0, 580, 583, 1, 0, 0, 0, 581, 579, 1, 0, 0, 0, 582, 573, 1, 0, 0, 0, 582, 583, 1, 0, 0, 0, 583, 117, 1, 0, 0, 0, 584, 585, 3, 60, 30, 0, 585, 586, 5, 32, 0, 0, 586, 588, 1, 0, 0, 0, 587, 584, 1, 0, 0, 0, 587, 588, 1, 0, 0, 0, 588, 589, 1, 0, 0, 0, 589, 590, 3, 60, 30, 0, 590, 119, 1, 0, 0, 0, 591, 592, 5, 18, 0, 0, 592, 593, 3, 36, 18, 0, 593, 594, 5, 88, 0, 0, 594, 595, 3, 62, 31, 0, 595, 121, 1, 0, 0, 0, 596, 597, 5, 17, 0, 0, 597, 600, 3, 54, 27, 0, 598, 599, 5, 29, 0, 0, 599, 601, 3, 30, 15, 0, 600, 598, 1, 0, 0, 0, 600, 601, 1, 0, 0, 0, 601, 123, 1, 0, 0, 0, 58, 135, 144, 162, 174, 183, 191, 197, 205, 207, 212, 219, 224, 235, 241, 249, 251, 262, 269, 280, 283, 299, 305, 315, 319, 324, 334, 342, 355, 359, 363, 370, 374, 381, 387, 394, 402, 410, 418, 435, 446, 457, 462, 466, 471, 482, 487, 491, 505, 516, 530, 541, 544, 549, 571, 579, 582, 587, 600] \ No newline at end of file diff --git a/packages/kbn-esql-ast/src/antlr/esql_parser.tokens b/packages/kbn-esql-ast/src/antlr/esql_parser.tokens index 4d1f426289149..3dd1a2c754038 100644 --- a/packages/kbn-esql-ast/src/antlr/esql_parser.tokens +++ b/packages/kbn-esql-ast/src/antlr/esql_parser.tokens @@ -21,46 +21,46 @@ UNKNOWN_CMD=20 LINE_COMMENT=21 MULTILINE_COMMENT=22 WS=23 -PIPE=24 -QUOTED_STRING=25 -INTEGER_LITERAL=26 -DECIMAL_LITERAL=27 -BY=28 -AND=29 -ASC=30 -ASSIGN=31 -CAST_OP=32 -COMMA=33 -DESC=34 -DOT=35 -FALSE=36 -FIRST=37 -IN=38 -IS=39 -LAST=40 -LIKE=41 -LP=42 -NOT=43 -NULL=44 -NULLS=45 -OR=46 -PARAM=47 -RLIKE=48 -RP=49 -TRUE=50 -EQ=51 -CIEQ=52 -NEQ=53 -LT=54 -LTE=55 -GT=56 -GTE=57 -PLUS=58 -MINUS=59 -ASTERISK=60 -SLASH=61 -PERCENT=62 -MATCH=63 +COLON=24 +PIPE=25 +QUOTED_STRING=26 +INTEGER_LITERAL=27 +DECIMAL_LITERAL=28 +BY=29 +AND=30 +ASC=31 +ASSIGN=32 +CAST_OP=33 +COMMA=34 +DESC=35 +DOT=36 +FALSE=37 +FIRST=38 +IN=39 +IS=40 +LAST=41 +LIKE=42 +LP=43 +NOT=44 +NULL=45 +NULLS=46 +OR=47 +PARAM=48 +RLIKE=49 +RP=50 +TRUE=51 +EQ=52 +CIEQ=53 +NEQ=54 +LT=55 +LTE=56 +GT=57 +GTE=58 +PLUS=59 +MINUS=60 +ASTERISK=61 +SLASH=62 +PERCENT=63 NAMED_OR_POSITIONAL_PARAM=64 OPENING_BRACKET=65 CLOSING_BRACKET=66 @@ -101,23 +101,22 @@ INFO=100 SHOW_LINE_COMMENT=101 SHOW_MULTILINE_COMMENT=102 SHOW_WS=103 -COLON=104 -SETTING=105 -SETTING_LINE_COMMENT=106 -SETTTING_MULTILINE_COMMENT=107 -SETTING_WS=108 -LOOKUP_LINE_COMMENT=109 -LOOKUP_MULTILINE_COMMENT=110 -LOOKUP_WS=111 -LOOKUP_FIELD_LINE_COMMENT=112 -LOOKUP_FIELD_MULTILINE_COMMENT=113 -LOOKUP_FIELD_WS=114 -METRICS_LINE_COMMENT=115 -METRICS_MULTILINE_COMMENT=116 -METRICS_WS=117 -CLOSING_METRICS_LINE_COMMENT=118 -CLOSING_METRICS_MULTILINE_COMMENT=119 -CLOSING_METRICS_WS=120 +SETTING=104 +SETTING_LINE_COMMENT=105 +SETTTING_MULTILINE_COMMENT=106 +SETTING_WS=107 +LOOKUP_LINE_COMMENT=108 +LOOKUP_MULTILINE_COMMENT=109 +LOOKUP_WS=110 +LOOKUP_FIELD_LINE_COMMENT=111 +LOOKUP_FIELD_MULTILINE_COMMENT=112 +LOOKUP_FIELD_WS=113 +METRICS_LINE_COMMENT=114 +METRICS_MULTILINE_COMMENT=115 +METRICS_WS=116 +CLOSING_METRICS_LINE_COMMENT=117 +CLOSING_METRICS_MULTILINE_COMMENT=118 +CLOSING_METRICS_WS=119 'dissect'=1 'drop'=2 'enrich'=3 @@ -134,47 +133,46 @@ CLOSING_METRICS_WS=120 'sort'=14 'stats'=15 'where'=16 -'|'=24 -'by'=28 -'and'=29 -'asc'=30 -'='=31 -'::'=32 -','=33 -'desc'=34 -'.'=35 -'false'=36 -'first'=37 -'in'=38 -'is'=39 -'last'=40 -'like'=41 -'('=42 -'not'=43 -'null'=44 -'nulls'=45 -'or'=46 -'?'=47 -'rlike'=48 -')'=49 -'true'=50 -'=='=51 -'=~'=52 -'!='=53 -'<'=54 -'<='=55 -'>'=56 -'>='=57 -'+'=58 -'-'=59 -'*'=60 -'/'=61 -'%'=62 -'match'=63 +':'=24 +'|'=25 +'by'=29 +'and'=30 +'asc'=31 +'='=32 +'::'=33 +','=34 +'desc'=35 +'.'=36 +'false'=37 +'first'=38 +'in'=39 +'is'=40 +'last'=41 +'like'=42 +'('=43 +'not'=44 +'null'=45 +'nulls'=46 +'or'=47 +'?'=48 +'rlike'=49 +')'=50 +'true'=51 +'=='=52 +'=~'=53 +'!='=54 +'<'=55 +'<='=56 +'>'=57 +'>='=58 +'+'=59 +'-'=60 +'*'=61 +'/'=62 +'%'=63 ']'=66 'metadata'=75 'as'=84 'on'=88 'with'=89 'info'=100 -':'=104 diff --git a/packages/kbn-esql-ast/src/antlr/esql_parser.ts b/packages/kbn-esql-ast/src/antlr/esql_parser.ts index b0af12e1ebc1e..4dc0c5c628e37 100644 --- a/packages/kbn-esql-ast/src/antlr/esql_parser.ts +++ b/packages/kbn-esql-ast/src/antlr/esql_parser.ts @@ -51,46 +51,46 @@ export default class esql_parser extends parser_config { public static readonly LINE_COMMENT = 21; public static readonly MULTILINE_COMMENT = 22; public static readonly WS = 23; - public static readonly PIPE = 24; - public static readonly QUOTED_STRING = 25; - public static readonly INTEGER_LITERAL = 26; - public static readonly DECIMAL_LITERAL = 27; - public static readonly BY = 28; - public static readonly AND = 29; - public static readonly ASC = 30; - public static readonly ASSIGN = 31; - public static readonly CAST_OP = 32; - public static readonly COMMA = 33; - public static readonly DESC = 34; - public static readonly DOT = 35; - public static readonly FALSE = 36; - public static readonly FIRST = 37; - public static readonly IN = 38; - public static readonly IS = 39; - public static readonly LAST = 40; - public static readonly LIKE = 41; - public static readonly LP = 42; - public static readonly NOT = 43; - public static readonly NULL = 44; - public static readonly NULLS = 45; - public static readonly OR = 46; - public static readonly PARAM = 47; - public static readonly RLIKE = 48; - public static readonly RP = 49; - public static readonly TRUE = 50; - public static readonly EQ = 51; - public static readonly CIEQ = 52; - public static readonly NEQ = 53; - public static readonly LT = 54; - public static readonly LTE = 55; - public static readonly GT = 56; - public static readonly GTE = 57; - public static readonly PLUS = 58; - public static readonly MINUS = 59; - public static readonly ASTERISK = 60; - public static readonly SLASH = 61; - public static readonly PERCENT = 62; - public static readonly MATCH = 63; + public static readonly COLON = 24; + public static readonly PIPE = 25; + public static readonly QUOTED_STRING = 26; + public static readonly INTEGER_LITERAL = 27; + public static readonly DECIMAL_LITERAL = 28; + public static readonly BY = 29; + public static readonly AND = 30; + public static readonly ASC = 31; + public static readonly ASSIGN = 32; + public static readonly CAST_OP = 33; + public static readonly COMMA = 34; + public static readonly DESC = 35; + public static readonly DOT = 36; + public static readonly FALSE = 37; + public static readonly FIRST = 38; + public static readonly IN = 39; + public static readonly IS = 40; + public static readonly LAST = 41; + public static readonly LIKE = 42; + public static readonly LP = 43; + public static readonly NOT = 44; + public static readonly NULL = 45; + public static readonly NULLS = 46; + public static readonly OR = 47; + public static readonly PARAM = 48; + public static readonly RLIKE = 49; + public static readonly RP = 50; + public static readonly TRUE = 51; + public static readonly EQ = 52; + public static readonly CIEQ = 53; + public static readonly NEQ = 54; + public static readonly LT = 55; + public static readonly LTE = 56; + public static readonly GT = 57; + public static readonly GTE = 58; + public static readonly PLUS = 59; + public static readonly MINUS = 60; + public static readonly ASTERISK = 61; + public static readonly SLASH = 62; + public static readonly PERCENT = 63; public static readonly NAMED_OR_POSITIONAL_PARAM = 64; public static readonly OPENING_BRACKET = 65; public static readonly CLOSING_BRACKET = 66; @@ -131,23 +131,22 @@ export default class esql_parser extends parser_config { public static readonly SHOW_LINE_COMMENT = 101; public static readonly SHOW_MULTILINE_COMMENT = 102; public static readonly SHOW_WS = 103; - public static readonly COLON = 104; - public static readonly SETTING = 105; - public static readonly SETTING_LINE_COMMENT = 106; - public static readonly SETTTING_MULTILINE_COMMENT = 107; - public static readonly SETTING_WS = 108; - public static readonly LOOKUP_LINE_COMMENT = 109; - public static readonly LOOKUP_MULTILINE_COMMENT = 110; - public static readonly LOOKUP_WS = 111; - public static readonly LOOKUP_FIELD_LINE_COMMENT = 112; - public static readonly LOOKUP_FIELD_MULTILINE_COMMENT = 113; - public static readonly LOOKUP_FIELD_WS = 114; - public static readonly METRICS_LINE_COMMENT = 115; - public static readonly METRICS_MULTILINE_COMMENT = 116; - public static readonly METRICS_WS = 117; - public static readonly CLOSING_METRICS_LINE_COMMENT = 118; - public static readonly CLOSING_METRICS_MULTILINE_COMMENT = 119; - public static readonly CLOSING_METRICS_WS = 120; + public static readonly SETTING = 104; + public static readonly SETTING_LINE_COMMENT = 105; + public static readonly SETTTING_MULTILINE_COMMENT = 106; + public static readonly SETTING_WS = 107; + public static readonly LOOKUP_LINE_COMMENT = 108; + public static readonly LOOKUP_MULTILINE_COMMENT = 109; + public static readonly LOOKUP_WS = 110; + public static readonly LOOKUP_FIELD_LINE_COMMENT = 111; + public static readonly LOOKUP_FIELD_MULTILINE_COMMENT = 112; + public static readonly LOOKUP_FIELD_WS = 113; + public static readonly METRICS_LINE_COMMENT = 114; + public static readonly METRICS_MULTILINE_COMMENT = 115; + public static readonly METRICS_WS = 116; + public static readonly CLOSING_METRICS_LINE_COMMENT = 117; + public static readonly CLOSING_METRICS_MULTILINE_COMMENT = 118; + public static readonly CLOSING_METRICS_WS = 119; public static override readonly EOF = Token.EOF; public static readonly RULE_singleStatement = 0; public static readonly RULE_query = 1; @@ -224,26 +223,26 @@ export default class esql_parser extends parser_config { null, null, null, null, null, null, - "'|'", null, + "':'", "'|'", null, null, - "'by'", "'and'", - "'asc'", "'='", - "'::'", "','", - "'desc'", "'.'", - "'false'", "'first'", - "'in'", "'is'", - "'last'", "'like'", - "'('", "'not'", - "'null'", "'nulls'", - "'or'", "'?'", - "'rlike'", "')'", - "'true'", "'=='", - "'=~'", "'!='", - "'<'", "'<='", - "'>'", "'>='", - "'+'", "'-'", - "'*'", "'/'", - "'%'", "'match'", + null, "'by'", + "'and'", "'asc'", + "'='", "'::'", + "','", "'desc'", + "'.'", "'false'", + "'first'", "'in'", + "'is'", "'last'", + "'like'", "'('", + "'not'", "'null'", + "'nulls'", "'or'", + "'?'", "'rlike'", + "')'", "'true'", + "'=='", "'=~'", + "'!='", "'<'", + "'<='", "'>'", + "'>='", "'+'", + "'-'", "'*'", + "'/'", "'%'", null, null, "']'", null, null, null, @@ -262,9 +261,7 @@ export default class esql_parser extends parser_config { null, null, null, null, null, null, - "'info'", null, - null, null, - "':'" ]; + "'info'" ]; public static readonly symbolicNames: (string | null)[] = [ null, "DISSECT", "DROP", "ENRICH", "EVAL", "EXPLAIN", @@ -280,8 +277,8 @@ export default class esql_parser extends parser_config { "UNKNOWN_CMD", "LINE_COMMENT", "MULTILINE_COMMENT", - "WS", "PIPE", - "QUOTED_STRING", + "WS", "COLON", + "PIPE", "QUOTED_STRING", "INTEGER_LITERAL", "DECIMAL_LITERAL", "BY", "AND", @@ -302,7 +299,7 @@ export default class esql_parser extends parser_config { "GTE", "PLUS", "MINUS", "ASTERISK", "SLASH", "PERCENT", - "MATCH", "NAMED_OR_POSITIONAL_PARAM", + "NAMED_OR_POSITIONAL_PARAM", "OPENING_BRACKET", "CLOSING_BRACKET", "UNQUOTED_IDENTIFIER", @@ -339,7 +336,7 @@ export default class esql_parser extends parser_config { "INFO", "SHOW_LINE_COMMENT", "SHOW_MULTILINE_COMMENT", "SHOW_WS", - "COLON", "SETTING", + "SETTING", "SETTING_LINE_COMMENT", "SETTTING_MULTILINE_COMMENT", "SETTING_WS", @@ -767,7 +764,7 @@ export default class esql_parser extends parser_config { this.state = 174; this._errHandler.sync(this); _la = this._input.LA(1); - if (_la===43) { + if (_la===44) { { this.state = 173; this.match(esql_parser.NOT); @@ -783,7 +780,7 @@ export default class esql_parser extends parser_config { this.state = 183; this._errHandler.sync(this); _la = this._input.LA(1); - while (_la===33) { + while (_la===34) { { { this.state = 179; @@ -812,7 +809,7 @@ export default class esql_parser extends parser_config { this.state = 191; this._errHandler.sync(this); _la = this._input.LA(1); - if (_la===43) { + if (_la===44) { { this.state = 190; this.match(esql_parser.NOT); @@ -921,7 +918,7 @@ export default class esql_parser extends parser_config { this.state = 212; this._errHandler.sync(this); _la = this._input.LA(1); - if (_la===43) { + if (_la===44) { { this.state = 211; this.match(esql_parser.NOT); @@ -942,7 +939,7 @@ export default class esql_parser extends parser_config { this.state = 219; this._errHandler.sync(this); _la = this._input.LA(1); - if (_la===43) { + if (_la===44) { { this.state = 218; this.match(esql_parser.NOT); @@ -979,11 +976,11 @@ export default class esql_parser extends parser_config { this.enterOuterAlt(localctx, 1); { this.state = 226; - this.valueExpression(); + localctx._fieldExp = this.qualifiedName(); this.state = 227; - this.match(esql_parser.MATCH); + this.match(esql_parser.COLON); this.state = 228; - localctx._queryString = this.string_(); + localctx._queryString = this.constant(); } } catch (re) { @@ -1085,7 +1082,7 @@ export default class esql_parser extends parser_config { this.state = 239; (localctx as ArithmeticUnaryContext)._operator = this._input.LT(1); _la = this._input.LA(1); - if(!(_la===58 || _la===59)) { + if(!(_la===59 || _la===60)) { (localctx as ArithmeticUnaryContext)._operator = this._errHandler.recoverInline(this); } else { @@ -1123,7 +1120,7 @@ export default class esql_parser extends parser_config { this.state = 244; (localctx as ArithmeticBinaryContext)._operator = this._input.LT(1); _la = this._input.LA(1); - if(!(((((_la - 60)) & ~0x1F) === 0 && ((1 << (_la - 60)) & 7) !== 0))) { + if(!(((((_la - 61)) & ~0x1F) === 0 && ((1 << (_la - 61)) & 7) !== 0))) { (localctx as ArithmeticBinaryContext)._operator = this._errHandler.recoverInline(this); } else { @@ -1146,7 +1143,7 @@ export default class esql_parser extends parser_config { this.state = 247; (localctx as ArithmeticBinaryContext)._operator = this._input.LT(1); _la = this._input.LA(1); - if(!(_la===58 || _la===59)) { + if(!(_la===59 || _la===60)) { (localctx as ArithmeticBinaryContext)._operator = this._errHandler.recoverInline(this); } else { @@ -1318,7 +1315,7 @@ export default class esql_parser extends parser_config { this.state = 280; this._errHandler.sync(this); _la = this._input.LA(1); - while (_la===33) { + while (_la===34) { { { this.state = 276; @@ -1358,23 +1355,10 @@ export default class esql_parser extends parser_config { let localctx: FunctionNameContext = new FunctionNameContext(this, this._ctx, this.state); this.enterRule(localctx, 24, esql_parser.RULE_functionName); try { - this.state = 289; - this._errHandler.sync(this); - switch ( this._interp.adaptivePredict(this._input, 20, this._ctx) ) { - case 1: - this.enterOuterAlt(localctx, 1); - { - this.state = 287; - this.match(esql_parser.MATCH); - } - break; - case 2: - this.enterOuterAlt(localctx, 2); - { - this.state = 288; - this.identifierOrParameter(); - } - break; + this.enterOuterAlt(localctx, 1); + { + this.state = 287; + this.identifierOrParameter(); } } catch (re) { @@ -1399,7 +1383,7 @@ export default class esql_parser extends parser_config { localctx = new ToDataTypeContext(this, localctx); this.enterOuterAlt(localctx, 1); { - this.state = 291; + this.state = 289; this.identifier(); } } @@ -1424,9 +1408,9 @@ export default class esql_parser extends parser_config { try { this.enterOuterAlt(localctx, 1); { - this.state = 293; + this.state = 291; this.match(esql_parser.ROW); - this.state = 294; + this.state = 292; this.fields(); } } @@ -1452,25 +1436,25 @@ export default class esql_parser extends parser_config { let _alt: number; this.enterOuterAlt(localctx, 1); { - this.state = 296; + this.state = 294; this.field(); - this.state = 301; + this.state = 299; this._errHandler.sync(this); - _alt = this._interp.adaptivePredict(this._input, 21, this._ctx); + _alt = this._interp.adaptivePredict(this._input, 20, this._ctx); while (_alt !== 2 && _alt !== ATN.INVALID_ALT_NUMBER) { if (_alt === 1) { { { - this.state = 297; + this.state = 295; this.match(esql_parser.COMMA); - this.state = 298; + this.state = 296; this.field(); } } } - this.state = 303; + this.state = 301; this._errHandler.sync(this); - _alt = this._interp.adaptivePredict(this._input, 21, this._ctx); + _alt = this._interp.adaptivePredict(this._input, 20, this._ctx); } } } @@ -1495,19 +1479,19 @@ export default class esql_parser extends parser_config { try { this.enterOuterAlt(localctx, 1); { - this.state = 307; + this.state = 305; this._errHandler.sync(this); - switch ( this._interp.adaptivePredict(this._input, 22, this._ctx) ) { + switch ( this._interp.adaptivePredict(this._input, 21, this._ctx) ) { case 1: { - this.state = 304; + this.state = 302; this.qualifiedName(); - this.state = 305; + this.state = 303; this.match(esql_parser.ASSIGN); } break; } - this.state = 309; + this.state = 307; this.booleanExpression(0); } } @@ -1533,34 +1517,34 @@ export default class esql_parser extends parser_config { let _alt: number; this.enterOuterAlt(localctx, 1); { - this.state = 311; + this.state = 309; this.match(esql_parser.FROM); - this.state = 312; + this.state = 310; this.indexPattern(); - this.state = 317; + this.state = 315; this._errHandler.sync(this); - _alt = this._interp.adaptivePredict(this._input, 23, this._ctx); + _alt = this._interp.adaptivePredict(this._input, 22, this._ctx); while (_alt !== 2 && _alt !== ATN.INVALID_ALT_NUMBER) { if (_alt === 1) { { { - this.state = 313; + this.state = 311; this.match(esql_parser.COMMA); - this.state = 314; + this.state = 312; this.indexPattern(); } } } - this.state = 319; + this.state = 317; this._errHandler.sync(this); - _alt = this._interp.adaptivePredict(this._input, 23, this._ctx); + _alt = this._interp.adaptivePredict(this._input, 22, this._ctx); } - this.state = 321; + this.state = 319; this._errHandler.sync(this); - switch ( this._interp.adaptivePredict(this._input, 24, this._ctx) ) { + switch ( this._interp.adaptivePredict(this._input, 23, this._ctx) ) { case 1: { - this.state = 320; + this.state = 318; this.metadata(); } break; @@ -1588,19 +1572,19 @@ export default class esql_parser extends parser_config { try { this.enterOuterAlt(localctx, 1); { - this.state = 326; + this.state = 324; this._errHandler.sync(this); - switch ( this._interp.adaptivePredict(this._input, 25, this._ctx) ) { + switch ( this._interp.adaptivePredict(this._input, 24, this._ctx) ) { case 1: { - this.state = 323; + this.state = 321; this.clusterString(); - this.state = 324; + this.state = 322; this.match(esql_parser.COLON); } break; } - this.state = 328; + this.state = 326; this.indexString(); } } @@ -1625,7 +1609,7 @@ export default class esql_parser extends parser_config { try { this.enterOuterAlt(localctx, 1); { - this.state = 330; + this.state = 328; this.match(esql_parser.UNQUOTED_SOURCE); } } @@ -1651,9 +1635,9 @@ export default class esql_parser extends parser_config { try { this.enterOuterAlt(localctx, 1); { - this.state = 332; + this.state = 330; _la = this._input.LA(1); - if(!(_la===25 || _la===76)) { + if(!(_la===26 || _la===76)) { this._errHandler.recoverInline(this); } else { @@ -1681,20 +1665,20 @@ export default class esql_parser extends parser_config { let localctx: MetadataContext = new MetadataContext(this, this._ctx, this.state); this.enterRule(localctx, 42, esql_parser.RULE_metadata); try { - this.state = 336; + this.state = 334; this._errHandler.sync(this); switch (this._input.LA(1)) { case 75: this.enterOuterAlt(localctx, 1); { - this.state = 334; + this.state = 332; this.metadataOption(); } break; case 65: this.enterOuterAlt(localctx, 2); { - this.state = 335; + this.state = 333; this.deprecated_metadata(); } break; @@ -1724,27 +1708,27 @@ export default class esql_parser extends parser_config { let _alt: number; this.enterOuterAlt(localctx, 1); { - this.state = 338; + this.state = 336; this.match(esql_parser.METADATA); - this.state = 339; + this.state = 337; this.match(esql_parser.UNQUOTED_SOURCE); - this.state = 344; + this.state = 342; this._errHandler.sync(this); - _alt = this._interp.adaptivePredict(this._input, 27, this._ctx); + _alt = this._interp.adaptivePredict(this._input, 26, this._ctx); while (_alt !== 2 && _alt !== ATN.INVALID_ALT_NUMBER) { if (_alt === 1) { { { - this.state = 340; + this.state = 338; this.match(esql_parser.COMMA); - this.state = 341; + this.state = 339; this.match(esql_parser.UNQUOTED_SOURCE); } } } - this.state = 346; + this.state = 344; this._errHandler.sync(this); - _alt = this._interp.adaptivePredict(this._input, 27, this._ctx); + _alt = this._interp.adaptivePredict(this._input, 26, this._ctx); } } } @@ -1769,11 +1753,11 @@ export default class esql_parser extends parser_config { try { this.enterOuterAlt(localctx, 1); { - this.state = 347; + this.state = 345; this.match(esql_parser.OPENING_BRACKET); - this.state = 348; + this.state = 346; this.metadataOption(); - this.state = 349; + this.state = 347; this.match(esql_parser.CLOSING_BRACKET); } } @@ -1799,46 +1783,46 @@ export default class esql_parser extends parser_config { let _alt: number; this.enterOuterAlt(localctx, 1); { - this.state = 351; + this.state = 349; this.match(esql_parser.DEV_METRICS); - this.state = 352; + this.state = 350; this.indexPattern(); - this.state = 357; + this.state = 355; this._errHandler.sync(this); - _alt = this._interp.adaptivePredict(this._input, 28, this._ctx); + _alt = this._interp.adaptivePredict(this._input, 27, this._ctx); while (_alt !== 2 && _alt !== ATN.INVALID_ALT_NUMBER) { if (_alt === 1) { { { - this.state = 353; + this.state = 351; this.match(esql_parser.COMMA); - this.state = 354; + this.state = 352; this.indexPattern(); } } } - this.state = 359; + this.state = 357; this._errHandler.sync(this); - _alt = this._interp.adaptivePredict(this._input, 28, this._ctx); + _alt = this._interp.adaptivePredict(this._input, 27, this._ctx); } - this.state = 361; + this.state = 359; this._errHandler.sync(this); - switch ( this._interp.adaptivePredict(this._input, 29, this._ctx) ) { + switch ( this._interp.adaptivePredict(this._input, 28, this._ctx) ) { case 1: { - this.state = 360; + this.state = 358; localctx._aggregates = this.aggFields(); } break; } - this.state = 365; + this.state = 363; this._errHandler.sync(this); - switch ( this._interp.adaptivePredict(this._input, 30, this._ctx) ) { + switch ( this._interp.adaptivePredict(this._input, 29, this._ctx) ) { case 1: { - this.state = 363; + this.state = 361; this.match(esql_parser.BY); - this.state = 364; + this.state = 362; localctx._grouping = this.fields(); } break; @@ -1866,9 +1850,9 @@ export default class esql_parser extends parser_config { try { this.enterOuterAlt(localctx, 1); { - this.state = 367; + this.state = 365; this.match(esql_parser.EVAL); - this.state = 368; + this.state = 366; this.fields(); } } @@ -1893,26 +1877,26 @@ export default class esql_parser extends parser_config { try { this.enterOuterAlt(localctx, 1); { - this.state = 370; + this.state = 368; this.match(esql_parser.STATS); - this.state = 372; + this.state = 370; this._errHandler.sync(this); - switch ( this._interp.adaptivePredict(this._input, 31, this._ctx) ) { + switch ( this._interp.adaptivePredict(this._input, 30, this._ctx) ) { case 1: { - this.state = 371; + this.state = 369; localctx._stats = this.aggFields(); } break; } - this.state = 376; + this.state = 374; this._errHandler.sync(this); - switch ( this._interp.adaptivePredict(this._input, 32, this._ctx) ) { + switch ( this._interp.adaptivePredict(this._input, 31, this._ctx) ) { case 1: { - this.state = 374; + this.state = 372; this.match(esql_parser.BY); - this.state = 375; + this.state = 373; localctx._grouping = this.fields(); } break; @@ -1941,25 +1925,25 @@ export default class esql_parser extends parser_config { let _alt: number; this.enterOuterAlt(localctx, 1); { - this.state = 378; + this.state = 376; this.aggField(); - this.state = 383; + this.state = 381; this._errHandler.sync(this); - _alt = this._interp.adaptivePredict(this._input, 33, this._ctx); + _alt = this._interp.adaptivePredict(this._input, 32, this._ctx); while (_alt !== 2 && _alt !== ATN.INVALID_ALT_NUMBER) { if (_alt === 1) { { { - this.state = 379; + this.state = 377; this.match(esql_parser.COMMA); - this.state = 380; + this.state = 378; this.aggField(); } } } - this.state = 385; + this.state = 383; this._errHandler.sync(this); - _alt = this._interp.adaptivePredict(this._input, 33, this._ctx); + _alt = this._interp.adaptivePredict(this._input, 32, this._ctx); } } } @@ -1984,16 +1968,16 @@ export default class esql_parser extends parser_config { try { this.enterOuterAlt(localctx, 1); { - this.state = 386; + this.state = 384; this.field(); - this.state = 389; + this.state = 387; this._errHandler.sync(this); - switch ( this._interp.adaptivePredict(this._input, 34, this._ctx) ) { + switch ( this._interp.adaptivePredict(this._input, 33, this._ctx) ) { case 1: { - this.state = 387; + this.state = 385; this.match(esql_parser.WHERE); - this.state = 388; + this.state = 386; this.booleanExpression(0); } break; @@ -2022,25 +2006,25 @@ export default class esql_parser extends parser_config { let _alt: number; this.enterOuterAlt(localctx, 1); { - this.state = 391; + this.state = 389; this.identifierOrParameter(); - this.state = 396; + this.state = 394; this._errHandler.sync(this); - _alt = this._interp.adaptivePredict(this._input, 35, this._ctx); + _alt = this._interp.adaptivePredict(this._input, 34, this._ctx); while (_alt !== 2 && _alt !== ATN.INVALID_ALT_NUMBER) { if (_alt === 1) { { { - this.state = 392; + this.state = 390; this.match(esql_parser.DOT); - this.state = 393; + this.state = 391; this.identifierOrParameter(); } } } - this.state = 398; + this.state = 396; this._errHandler.sync(this); - _alt = this._interp.adaptivePredict(this._input, 35, this._ctx); + _alt = this._interp.adaptivePredict(this._input, 34, this._ctx); } } } @@ -2066,25 +2050,25 @@ export default class esql_parser extends parser_config { let _alt: number; this.enterOuterAlt(localctx, 1); { - this.state = 399; + this.state = 397; this.identifierPattern(); - this.state = 404; + this.state = 402; this._errHandler.sync(this); - _alt = this._interp.adaptivePredict(this._input, 36, this._ctx); + _alt = this._interp.adaptivePredict(this._input, 35, this._ctx); while (_alt !== 2 && _alt !== ATN.INVALID_ALT_NUMBER) { if (_alt === 1) { { { - this.state = 400; + this.state = 398; this.match(esql_parser.DOT); - this.state = 401; + this.state = 399; this.identifierPattern(); } } } - this.state = 406; + this.state = 404; this._errHandler.sync(this); - _alt = this._interp.adaptivePredict(this._input, 36, this._ctx); + _alt = this._interp.adaptivePredict(this._input, 35, this._ctx); } } } @@ -2110,25 +2094,25 @@ export default class esql_parser extends parser_config { let _alt: number; this.enterOuterAlt(localctx, 1); { - this.state = 407; + this.state = 405; this.qualifiedNamePattern(); - this.state = 412; + this.state = 410; this._errHandler.sync(this); - _alt = this._interp.adaptivePredict(this._input, 37, this._ctx); + _alt = this._interp.adaptivePredict(this._input, 36, this._ctx); while (_alt !== 2 && _alt !== ATN.INVALID_ALT_NUMBER) { if (_alt === 1) { { { - this.state = 408; + this.state = 406; this.match(esql_parser.COMMA); - this.state = 409; + this.state = 407; this.qualifiedNamePattern(); } } } - this.state = 414; + this.state = 412; this._errHandler.sync(this); - _alt = this._interp.adaptivePredict(this._input, 37, this._ctx); + _alt = this._interp.adaptivePredict(this._input, 36, this._ctx); } } } @@ -2154,7 +2138,7 @@ export default class esql_parser extends parser_config { try { this.enterOuterAlt(localctx, 1); { - this.state = 415; + this.state = 413; _la = this._input.LA(1); if(!(_la===67 || _la===68)) { this._errHandler.recoverInline(this); @@ -2184,24 +2168,24 @@ export default class esql_parser extends parser_config { let localctx: IdentifierPatternContext = new IdentifierPatternContext(this, this._ctx, this.state); this.enterRule(localctx, 66, esql_parser.RULE_identifierPattern); try { - this.state = 420; + this.state = 418; this._errHandler.sync(this); - switch ( this._interp.adaptivePredict(this._input, 38, this._ctx) ) { + switch ( this._interp.adaptivePredict(this._input, 37, this._ctx) ) { case 1: this.enterOuterAlt(localctx, 1); { - this.state = 417; + this.state = 415; this.match(esql_parser.ID_PATTERN); } break; case 2: this.enterOuterAlt(localctx, 2); { - this.state = 418; + this.state = 416; if (!(this.isDevVersion())) { throw this.createFailedPredicateException("this.isDevVersion()"); } - this.state = 419; + this.state = 417; this.parameter(); } break; @@ -2227,14 +2211,14 @@ export default class esql_parser extends parser_config { this.enterRule(localctx, 68, esql_parser.RULE_constant); let _la: number; try { - this.state = 464; + this.state = 462; this._errHandler.sync(this); - switch ( this._interp.adaptivePredict(this._input, 42, this._ctx) ) { + switch ( this._interp.adaptivePredict(this._input, 41, this._ctx) ) { case 1: localctx = new NullLiteralContext(this, localctx); this.enterOuterAlt(localctx, 1); { - this.state = 422; + this.state = 420; this.match(esql_parser.NULL); } break; @@ -2242,9 +2226,9 @@ export default class esql_parser extends parser_config { localctx = new QualifiedIntegerLiteralContext(this, localctx); this.enterOuterAlt(localctx, 2); { - this.state = 423; + this.state = 421; this.integerValue(); - this.state = 424; + this.state = 422; this.match(esql_parser.UNQUOTED_IDENTIFIER); } break; @@ -2252,7 +2236,7 @@ export default class esql_parser extends parser_config { localctx = new DecimalLiteralContext(this, localctx); this.enterOuterAlt(localctx, 3); { - this.state = 426; + this.state = 424; this.decimalValue(); } break; @@ -2260,7 +2244,7 @@ export default class esql_parser extends parser_config { localctx = new IntegerLiteralContext(this, localctx); this.enterOuterAlt(localctx, 4); { - this.state = 427; + this.state = 425; this.integerValue(); } break; @@ -2268,7 +2252,7 @@ export default class esql_parser extends parser_config { localctx = new BooleanLiteralContext(this, localctx); this.enterOuterAlt(localctx, 5); { - this.state = 428; + this.state = 426; this.booleanValue(); } break; @@ -2276,7 +2260,7 @@ export default class esql_parser extends parser_config { localctx = new InputParameterContext(this, localctx); this.enterOuterAlt(localctx, 6); { - this.state = 429; + this.state = 427; this.parameter(); } break; @@ -2284,7 +2268,7 @@ export default class esql_parser extends parser_config { localctx = new StringLiteralContext(this, localctx); this.enterOuterAlt(localctx, 7); { - this.state = 430; + this.state = 428; this.string_(); } break; @@ -2292,27 +2276,27 @@ export default class esql_parser extends parser_config { localctx = new NumericArrayLiteralContext(this, localctx); this.enterOuterAlt(localctx, 8); { - this.state = 431; + this.state = 429; this.match(esql_parser.OPENING_BRACKET); - this.state = 432; + this.state = 430; this.numericValue(); - this.state = 437; + this.state = 435; this._errHandler.sync(this); _la = this._input.LA(1); - while (_la===33) { + while (_la===34) { { { - this.state = 433; + this.state = 431; this.match(esql_parser.COMMA); - this.state = 434; + this.state = 432; this.numericValue(); } } - this.state = 439; + this.state = 437; this._errHandler.sync(this); _la = this._input.LA(1); } - this.state = 440; + this.state = 438; this.match(esql_parser.CLOSING_BRACKET); } break; @@ -2320,27 +2304,27 @@ export default class esql_parser extends parser_config { localctx = new BooleanArrayLiteralContext(this, localctx); this.enterOuterAlt(localctx, 9); { - this.state = 442; + this.state = 440; this.match(esql_parser.OPENING_BRACKET); - this.state = 443; + this.state = 441; this.booleanValue(); - this.state = 448; + this.state = 446; this._errHandler.sync(this); _la = this._input.LA(1); - while (_la===33) { + while (_la===34) { { { - this.state = 444; + this.state = 442; this.match(esql_parser.COMMA); - this.state = 445; + this.state = 443; this.booleanValue(); } } - this.state = 450; + this.state = 448; this._errHandler.sync(this); _la = this._input.LA(1); } - this.state = 451; + this.state = 449; this.match(esql_parser.CLOSING_BRACKET); } break; @@ -2348,27 +2332,27 @@ export default class esql_parser extends parser_config { localctx = new StringArrayLiteralContext(this, localctx); this.enterOuterAlt(localctx, 10); { - this.state = 453; + this.state = 451; this.match(esql_parser.OPENING_BRACKET); - this.state = 454; + this.state = 452; this.string_(); - this.state = 459; + this.state = 457; this._errHandler.sync(this); _la = this._input.LA(1); - while (_la===33) { + while (_la===34) { { { - this.state = 455; + this.state = 453; this.match(esql_parser.COMMA); - this.state = 456; + this.state = 454; this.string_(); } } - this.state = 461; + this.state = 459; this._errHandler.sync(this); _la = this._input.LA(1); } - this.state = 462; + this.state = 460; this.match(esql_parser.CLOSING_BRACKET); } break; @@ -2393,14 +2377,14 @@ export default class esql_parser extends parser_config { let localctx: ParameterContext = new ParameterContext(this, this._ctx, this.state); this.enterRule(localctx, 70, esql_parser.RULE_parameter); try { - this.state = 468; + this.state = 466; this._errHandler.sync(this); switch (this._input.LA(1)) { - case 47: + case 48: localctx = new InputParamContext(this, localctx); this.enterOuterAlt(localctx, 1); { - this.state = 466; + this.state = 464; this.match(esql_parser.PARAM); } break; @@ -2408,7 +2392,7 @@ export default class esql_parser extends parser_config { localctx = new InputNamedOrPositionalParamContext(this, localctx); this.enterOuterAlt(localctx, 2); { - this.state = 467; + this.state = 465; this.match(esql_parser.NAMED_OR_POSITIONAL_PARAM); } break; @@ -2435,24 +2419,24 @@ export default class esql_parser extends parser_config { let localctx: IdentifierOrParameterContext = new IdentifierOrParameterContext(this, this._ctx, this.state); this.enterRule(localctx, 72, esql_parser.RULE_identifierOrParameter); try { - this.state = 473; + this.state = 471; this._errHandler.sync(this); - switch ( this._interp.adaptivePredict(this._input, 44, this._ctx) ) { + switch ( this._interp.adaptivePredict(this._input, 43, this._ctx) ) { case 1: this.enterOuterAlt(localctx, 1); { - this.state = 470; + this.state = 468; this.identifier(); } break; case 2: this.enterOuterAlt(localctx, 2); { - this.state = 471; + this.state = 469; if (!(this.isDevVersion())) { throw this.createFailedPredicateException("this.isDevVersion()"); } - this.state = 472; + this.state = 470; this.parameter(); } break; @@ -2479,9 +2463,9 @@ export default class esql_parser extends parser_config { try { this.enterOuterAlt(localctx, 1); { - this.state = 475; + this.state = 473; this.match(esql_parser.LIMIT); - this.state = 476; + this.state = 474; this.match(esql_parser.INTEGER_LITERAL); } } @@ -2507,27 +2491,27 @@ export default class esql_parser extends parser_config { let _alt: number; this.enterOuterAlt(localctx, 1); { - this.state = 478; + this.state = 476; this.match(esql_parser.SORT); - this.state = 479; + this.state = 477; this.orderExpression(); - this.state = 484; + this.state = 482; this._errHandler.sync(this); - _alt = this._interp.adaptivePredict(this._input, 45, this._ctx); + _alt = this._interp.adaptivePredict(this._input, 44, this._ctx); while (_alt !== 2 && _alt !== ATN.INVALID_ALT_NUMBER) { if (_alt === 1) { { { - this.state = 480; + this.state = 478; this.match(esql_parser.COMMA); - this.state = 481; + this.state = 479; this.orderExpression(); } } } - this.state = 486; + this.state = 484; this._errHandler.sync(this); - _alt = this._interp.adaptivePredict(this._input, 45, this._ctx); + _alt = this._interp.adaptivePredict(this._input, 44, this._ctx); } } } @@ -2553,17 +2537,17 @@ export default class esql_parser extends parser_config { try { this.enterOuterAlt(localctx, 1); { - this.state = 487; + this.state = 485; this.booleanExpression(0); - this.state = 489; + this.state = 487; this._errHandler.sync(this); - switch ( this._interp.adaptivePredict(this._input, 46, this._ctx) ) { + switch ( this._interp.adaptivePredict(this._input, 45, this._ctx) ) { case 1: { - this.state = 488; + this.state = 486; localctx._ordering = this._input.LT(1); _la = this._input.LA(1); - if(!(_la===30 || _la===34)) { + if(!(_la===31 || _la===35)) { localctx._ordering = this._errHandler.recoverInline(this); } else { @@ -2573,17 +2557,17 @@ export default class esql_parser extends parser_config { } break; } - this.state = 493; + this.state = 491; this._errHandler.sync(this); - switch ( this._interp.adaptivePredict(this._input, 47, this._ctx) ) { + switch ( this._interp.adaptivePredict(this._input, 46, this._ctx) ) { case 1: { - this.state = 491; + this.state = 489; this.match(esql_parser.NULLS); - this.state = 492; + this.state = 490; localctx._nullOrdering = this._input.LT(1); _la = this._input.LA(1); - if(!(_la===37 || _la===40)) { + if(!(_la===38 || _la===41)) { localctx._nullOrdering = this._errHandler.recoverInline(this); } else { @@ -2616,9 +2600,9 @@ export default class esql_parser extends parser_config { try { this.enterOuterAlt(localctx, 1); { - this.state = 495; + this.state = 493; this.match(esql_parser.KEEP); - this.state = 496; + this.state = 494; this.qualifiedNamePatterns(); } } @@ -2643,9 +2627,9 @@ export default class esql_parser extends parser_config { try { this.enterOuterAlt(localctx, 1); { - this.state = 498; + this.state = 496; this.match(esql_parser.DROP); - this.state = 499; + this.state = 497; this.qualifiedNamePatterns(); } } @@ -2671,27 +2655,27 @@ export default class esql_parser extends parser_config { let _alt: number; this.enterOuterAlt(localctx, 1); { - this.state = 501; + this.state = 499; this.match(esql_parser.RENAME); - this.state = 502; + this.state = 500; this.renameClause(); - this.state = 507; + this.state = 505; this._errHandler.sync(this); - _alt = this._interp.adaptivePredict(this._input, 48, this._ctx); + _alt = this._interp.adaptivePredict(this._input, 47, this._ctx); while (_alt !== 2 && _alt !== ATN.INVALID_ALT_NUMBER) { if (_alt === 1) { { { - this.state = 503; + this.state = 501; this.match(esql_parser.COMMA); - this.state = 504; + this.state = 502; this.renameClause(); } } } - this.state = 509; + this.state = 507; this._errHandler.sync(this); - _alt = this._interp.adaptivePredict(this._input, 48, this._ctx); + _alt = this._interp.adaptivePredict(this._input, 47, this._ctx); } } } @@ -2716,11 +2700,11 @@ export default class esql_parser extends parser_config { try { this.enterOuterAlt(localctx, 1); { - this.state = 510; + this.state = 508; localctx._oldName = this.qualifiedNamePattern(); - this.state = 511; + this.state = 509; this.match(esql_parser.AS); - this.state = 512; + this.state = 510; localctx._newName = this.qualifiedNamePattern(); } } @@ -2745,18 +2729,18 @@ export default class esql_parser extends parser_config { try { this.enterOuterAlt(localctx, 1); { - this.state = 514; + this.state = 512; this.match(esql_parser.DISSECT); - this.state = 515; + this.state = 513; this.primaryExpression(0); - this.state = 516; + this.state = 514; this.string_(); - this.state = 518; + this.state = 516; this._errHandler.sync(this); - switch ( this._interp.adaptivePredict(this._input, 49, this._ctx) ) { + switch ( this._interp.adaptivePredict(this._input, 48, this._ctx) ) { case 1: { - this.state = 517; + this.state = 515; this.commandOptions(); } break; @@ -2784,11 +2768,11 @@ export default class esql_parser extends parser_config { try { this.enterOuterAlt(localctx, 1); { - this.state = 520; + this.state = 518; this.match(esql_parser.GROK); - this.state = 521; + this.state = 519; this.primaryExpression(0); - this.state = 522; + this.state = 520; this.string_(); } } @@ -2813,9 +2797,9 @@ export default class esql_parser extends parser_config { try { this.enterOuterAlt(localctx, 1); { - this.state = 524; + this.state = 522; this.match(esql_parser.MV_EXPAND); - this.state = 525; + this.state = 523; this.qualifiedName(); } } @@ -2841,25 +2825,25 @@ export default class esql_parser extends parser_config { let _alt: number; this.enterOuterAlt(localctx, 1); { - this.state = 527; + this.state = 525; this.commandOption(); - this.state = 532; + this.state = 530; this._errHandler.sync(this); - _alt = this._interp.adaptivePredict(this._input, 50, this._ctx); + _alt = this._interp.adaptivePredict(this._input, 49, this._ctx); while (_alt !== 2 && _alt !== ATN.INVALID_ALT_NUMBER) { if (_alt === 1) { { { - this.state = 528; + this.state = 526; this.match(esql_parser.COMMA); - this.state = 529; + this.state = 527; this.commandOption(); } } } - this.state = 534; + this.state = 532; this._errHandler.sync(this); - _alt = this._interp.adaptivePredict(this._input, 50, this._ctx); + _alt = this._interp.adaptivePredict(this._input, 49, this._ctx); } } } @@ -2884,11 +2868,11 @@ export default class esql_parser extends parser_config { try { this.enterOuterAlt(localctx, 1); { - this.state = 535; + this.state = 533; this.identifier(); - this.state = 536; + this.state = 534; this.match(esql_parser.ASSIGN); - this.state = 537; + this.state = 535; this.constant(); } } @@ -2914,9 +2898,9 @@ export default class esql_parser extends parser_config { try { this.enterOuterAlt(localctx, 1); { - this.state = 539; + this.state = 537; _la = this._input.LA(1); - if(!(_la===36 || _la===50)) { + if(!(_la===37 || _la===51)) { this._errHandler.recoverInline(this); } else { @@ -2944,20 +2928,20 @@ export default class esql_parser extends parser_config { let localctx: NumericValueContext = new NumericValueContext(this, this._ctx, this.state); this.enterRule(localctx, 100, esql_parser.RULE_numericValue); try { - this.state = 543; + this.state = 541; this._errHandler.sync(this); - switch ( this._interp.adaptivePredict(this._input, 51, this._ctx) ) { + switch ( this._interp.adaptivePredict(this._input, 50, this._ctx) ) { case 1: this.enterOuterAlt(localctx, 1); { - this.state = 541; + this.state = 539; this.decimalValue(); } break; case 2: this.enterOuterAlt(localctx, 2); { - this.state = 542; + this.state = 540; this.integerValue(); } break; @@ -2985,14 +2969,14 @@ export default class esql_parser extends parser_config { try { this.enterOuterAlt(localctx, 1); { - this.state = 546; + this.state = 544; this._errHandler.sync(this); _la = this._input.LA(1); - if (_la===58 || _la===59) { + if (_la===59 || _la===60) { { - this.state = 545; + this.state = 543; _la = this._input.LA(1); - if(!(_la===58 || _la===59)) { + if(!(_la===59 || _la===60)) { this._errHandler.recoverInline(this); } else { @@ -3002,7 +2986,7 @@ export default class esql_parser extends parser_config { } } - this.state = 548; + this.state = 546; this.match(esql_parser.DECIMAL_LITERAL); } } @@ -3028,14 +3012,14 @@ export default class esql_parser extends parser_config { try { this.enterOuterAlt(localctx, 1); { - this.state = 551; + this.state = 549; this._errHandler.sync(this); _la = this._input.LA(1); - if (_la===58 || _la===59) { + if (_la===59 || _la===60) { { - this.state = 550; + this.state = 548; _la = this._input.LA(1); - if(!(_la===58 || _la===59)) { + if(!(_la===59 || _la===60)) { this._errHandler.recoverInline(this); } else { @@ -3045,7 +3029,7 @@ export default class esql_parser extends parser_config { } } - this.state = 553; + this.state = 551; this.match(esql_parser.INTEGER_LITERAL); } } @@ -3070,7 +3054,7 @@ export default class esql_parser extends parser_config { try { this.enterOuterAlt(localctx, 1); { - this.state = 555; + this.state = 553; this.match(esql_parser.QUOTED_STRING); } } @@ -3096,9 +3080,9 @@ export default class esql_parser extends parser_config { try { this.enterOuterAlt(localctx, 1); { - this.state = 557; + this.state = 555; _la = this._input.LA(1); - if(!(((((_la - 51)) & ~0x1F) === 0 && ((1 << (_la - 51)) & 125) !== 0))) { + if(!(((((_la - 52)) & ~0x1F) === 0 && ((1 << (_la - 52)) & 125) !== 0))) { this._errHandler.recoverInline(this); } else { @@ -3128,9 +3112,9 @@ export default class esql_parser extends parser_config { try { this.enterOuterAlt(localctx, 1); { - this.state = 559; + this.state = 557; this.match(esql_parser.EXPLAIN); - this.state = 560; + this.state = 558; this.subqueryExpression(); } } @@ -3155,11 +3139,11 @@ export default class esql_parser extends parser_config { try { this.enterOuterAlt(localctx, 1); { - this.state = 562; + this.state = 560; this.match(esql_parser.OPENING_BRACKET); - this.state = 563; + this.state = 561; this.query(0); - this.state = 564; + this.state = 562; this.match(esql_parser.CLOSING_BRACKET); } } @@ -3185,9 +3169,9 @@ export default class esql_parser extends parser_config { localctx = new ShowInfoContext(this, localctx); this.enterOuterAlt(localctx, 1); { - this.state = 566; + this.state = 564; this.match(esql_parser.SHOW); - this.state = 567; + this.state = 565; this.match(esql_parser.INFO); } } @@ -3213,48 +3197,48 @@ export default class esql_parser extends parser_config { let _alt: number; this.enterOuterAlt(localctx, 1); { - this.state = 569; + this.state = 567; this.match(esql_parser.ENRICH); - this.state = 570; + this.state = 568; localctx._policyName = this.match(esql_parser.ENRICH_POLICY_NAME); - this.state = 573; + this.state = 571; this._errHandler.sync(this); - switch ( this._interp.adaptivePredict(this._input, 54, this._ctx) ) { + switch ( this._interp.adaptivePredict(this._input, 53, this._ctx) ) { case 1: { - this.state = 571; + this.state = 569; this.match(esql_parser.ON); - this.state = 572; + this.state = 570; localctx._matchField = this.qualifiedNamePattern(); } break; } - this.state = 584; + this.state = 582; this._errHandler.sync(this); - switch ( this._interp.adaptivePredict(this._input, 56, this._ctx) ) { + switch ( this._interp.adaptivePredict(this._input, 55, this._ctx) ) { case 1: { - this.state = 575; + this.state = 573; this.match(esql_parser.WITH); - this.state = 576; + this.state = 574; this.enrichWithClause(); - this.state = 581; + this.state = 579; this._errHandler.sync(this); - _alt = this._interp.adaptivePredict(this._input, 55, this._ctx); + _alt = this._interp.adaptivePredict(this._input, 54, this._ctx); while (_alt !== 2 && _alt !== ATN.INVALID_ALT_NUMBER) { if (_alt === 1) { { { - this.state = 577; + this.state = 575; this.match(esql_parser.COMMA); - this.state = 578; + this.state = 576; this.enrichWithClause(); } } } - this.state = 583; + this.state = 581; this._errHandler.sync(this); - _alt = this._interp.adaptivePredict(this._input, 55, this._ctx); + _alt = this._interp.adaptivePredict(this._input, 54, this._ctx); } } break; @@ -3282,19 +3266,19 @@ export default class esql_parser extends parser_config { try { this.enterOuterAlt(localctx, 1); { - this.state = 589; + this.state = 587; this._errHandler.sync(this); - switch ( this._interp.adaptivePredict(this._input, 57, this._ctx) ) { + switch ( this._interp.adaptivePredict(this._input, 56, this._ctx) ) { case 1: { - this.state = 586; + this.state = 584; localctx._newName = this.qualifiedNamePattern(); - this.state = 587; + this.state = 585; this.match(esql_parser.ASSIGN); } break; } - this.state = 591; + this.state = 589; localctx._enrichField = this.qualifiedNamePattern(); } } @@ -3319,13 +3303,13 @@ export default class esql_parser extends parser_config { try { this.enterOuterAlt(localctx, 1); { - this.state = 593; + this.state = 591; this.match(esql_parser.DEV_LOOKUP); - this.state = 594; + this.state = 592; localctx._tableName = this.indexPattern(); - this.state = 595; + this.state = 593; this.match(esql_parser.ON); - this.state = 596; + this.state = 594; localctx._matchFields = this.qualifiedNamePatterns(); } } @@ -3350,18 +3334,18 @@ export default class esql_parser extends parser_config { try { this.enterOuterAlt(localctx, 1); { - this.state = 598; + this.state = 596; this.match(esql_parser.DEV_INLINESTATS); - this.state = 599; + this.state = 597; localctx._stats = this.aggFields(); - this.state = 602; + this.state = 600; this._errHandler.sync(this); - switch ( this._interp.adaptivePredict(this._input, 58, this._ctx) ) { + switch ( this._interp.adaptivePredict(this._input, 57, this._ctx) ) { case 1: { - this.state = 600; + this.state = 598; this.match(esql_parser.BY); - this.state = 601; + this.state = 599; localctx._grouping = this.fields(); } break; @@ -3469,7 +3453,7 @@ export default class esql_parser extends parser_config { return true; } - public static readonly _serializedATN: number[] = [4,1,120,605,2,0,7,0, + public static readonly _serializedATN: number[] = [4,1,119,603,2,0,7,0, 2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5,2,6,7,6,2,7,7,7,2,8,7,8,2,9,7,9, 2,10,7,10,2,11,7,11,2,12,7,12,2,13,7,13,2,14,7,14,2,15,7,15,2,16,7,16,2, 17,7,17,2,18,7,18,2,19,7,19,2,20,7,20,2,21,7,21,2,22,7,22,2,23,7,23,2,24, @@ -3489,184 +3473,183 @@ export default class esql_parser extends parser_config { 9,1,9,5,9,250,8,9,10,9,12,9,253,9,9,1,10,1,10,1,10,1,10,1,10,1,10,1,10, 1,10,3,10,263,8,10,1,10,1,10,1,10,5,10,268,8,10,10,10,12,10,271,9,10,1, 11,1,11,1,11,1,11,1,11,1,11,5,11,279,8,11,10,11,12,11,282,9,11,3,11,284, - 8,11,1,11,1,11,1,12,1,12,3,12,290,8,12,1,13,1,13,1,14,1,14,1,14,1,15,1, - 15,1,15,5,15,300,8,15,10,15,12,15,303,9,15,1,16,1,16,1,16,3,16,308,8,16, - 1,16,1,16,1,17,1,17,1,17,1,17,5,17,316,8,17,10,17,12,17,319,9,17,1,17,3, - 17,322,8,17,1,18,1,18,1,18,3,18,327,8,18,1,18,1,18,1,19,1,19,1,20,1,20, - 1,21,1,21,3,21,337,8,21,1,22,1,22,1,22,1,22,5,22,343,8,22,10,22,12,22,346, - 9,22,1,23,1,23,1,23,1,23,1,24,1,24,1,24,1,24,5,24,356,8,24,10,24,12,24, - 359,9,24,1,24,3,24,362,8,24,1,24,1,24,3,24,366,8,24,1,25,1,25,1,25,1,26, - 1,26,3,26,373,8,26,1,26,1,26,3,26,377,8,26,1,27,1,27,1,27,5,27,382,8,27, - 10,27,12,27,385,9,27,1,28,1,28,1,28,3,28,390,8,28,1,29,1,29,1,29,5,29,395, - 8,29,10,29,12,29,398,9,29,1,30,1,30,1,30,5,30,403,8,30,10,30,12,30,406, - 9,30,1,31,1,31,1,31,5,31,411,8,31,10,31,12,31,414,9,31,1,32,1,32,1,33,1, - 33,1,33,3,33,421,8,33,1,34,1,34,1,34,1,34,1,34,1,34,1,34,1,34,1,34,1,34, - 1,34,1,34,1,34,5,34,436,8,34,10,34,12,34,439,9,34,1,34,1,34,1,34,1,34,1, - 34,1,34,5,34,447,8,34,10,34,12,34,450,9,34,1,34,1,34,1,34,1,34,1,34,1,34, - 5,34,458,8,34,10,34,12,34,461,9,34,1,34,1,34,3,34,465,8,34,1,35,1,35,3, - 35,469,8,35,1,36,1,36,1,36,3,36,474,8,36,1,37,1,37,1,37,1,38,1,38,1,38, - 1,38,5,38,483,8,38,10,38,12,38,486,9,38,1,39,1,39,3,39,490,8,39,1,39,1, - 39,3,39,494,8,39,1,40,1,40,1,40,1,41,1,41,1,41,1,42,1,42,1,42,1,42,5,42, - 506,8,42,10,42,12,42,509,9,42,1,43,1,43,1,43,1,43,1,44,1,44,1,44,1,44,3, - 44,519,8,44,1,45,1,45,1,45,1,45,1,46,1,46,1,46,1,47,1,47,1,47,5,47,531, - 8,47,10,47,12,47,534,9,47,1,48,1,48,1,48,1,48,1,49,1,49,1,50,1,50,3,50, - 544,8,50,1,51,3,51,547,8,51,1,51,1,51,1,52,3,52,552,8,52,1,52,1,52,1,53, - 1,53,1,54,1,54,1,55,1,55,1,55,1,56,1,56,1,56,1,56,1,57,1,57,1,57,1,58,1, - 58,1,58,1,58,3,58,574,8,58,1,58,1,58,1,58,1,58,5,58,580,8,58,10,58,12,58, - 583,9,58,3,58,585,8,58,1,59,1,59,1,59,3,59,590,8,59,1,59,1,59,1,60,1,60, - 1,60,1,60,1,60,1,61,1,61,1,61,1,61,3,61,603,8,61,1,61,0,4,2,10,18,20,62, - 0,2,4,6,8,10,12,14,16,18,20,22,24,26,28,30,32,34,36,38,40,42,44,46,48,50, - 52,54,56,58,60,62,64,66,68,70,72,74,76,78,80,82,84,86,88,90,92,94,96,98, - 100,102,104,106,108,110,112,114,116,118,120,122,0,8,1,0,58,59,1,0,60,62, - 2,0,25,25,76,76,1,0,67,68,2,0,30,30,34,34,2,0,37,37,40,40,2,0,36,36,50, - 50,2,0,51,51,53,57,631,0,124,1,0,0,0,2,127,1,0,0,0,4,144,1,0,0,0,6,162, - 1,0,0,0,8,164,1,0,0,0,10,197,1,0,0,0,12,224,1,0,0,0,14,226,1,0,0,0,16,235, - 1,0,0,0,18,241,1,0,0,0,20,262,1,0,0,0,22,272,1,0,0,0,24,289,1,0,0,0,26, - 291,1,0,0,0,28,293,1,0,0,0,30,296,1,0,0,0,32,307,1,0,0,0,34,311,1,0,0,0, - 36,326,1,0,0,0,38,330,1,0,0,0,40,332,1,0,0,0,42,336,1,0,0,0,44,338,1,0, - 0,0,46,347,1,0,0,0,48,351,1,0,0,0,50,367,1,0,0,0,52,370,1,0,0,0,54,378, - 1,0,0,0,56,386,1,0,0,0,58,391,1,0,0,0,60,399,1,0,0,0,62,407,1,0,0,0,64, - 415,1,0,0,0,66,420,1,0,0,0,68,464,1,0,0,0,70,468,1,0,0,0,72,473,1,0,0,0, - 74,475,1,0,0,0,76,478,1,0,0,0,78,487,1,0,0,0,80,495,1,0,0,0,82,498,1,0, - 0,0,84,501,1,0,0,0,86,510,1,0,0,0,88,514,1,0,0,0,90,520,1,0,0,0,92,524, - 1,0,0,0,94,527,1,0,0,0,96,535,1,0,0,0,98,539,1,0,0,0,100,543,1,0,0,0,102, - 546,1,0,0,0,104,551,1,0,0,0,106,555,1,0,0,0,108,557,1,0,0,0,110,559,1,0, - 0,0,112,562,1,0,0,0,114,566,1,0,0,0,116,569,1,0,0,0,118,589,1,0,0,0,120, - 593,1,0,0,0,122,598,1,0,0,0,124,125,3,2,1,0,125,126,5,0,0,1,126,1,1,0,0, - 0,127,128,6,1,-1,0,128,129,3,4,2,0,129,135,1,0,0,0,130,131,10,1,0,0,131, - 132,5,24,0,0,132,134,3,6,3,0,133,130,1,0,0,0,134,137,1,0,0,0,135,133,1, - 0,0,0,135,136,1,0,0,0,136,3,1,0,0,0,137,135,1,0,0,0,138,145,3,110,55,0, - 139,145,3,34,17,0,140,145,3,28,14,0,141,145,3,114,57,0,142,143,4,2,1,0, - 143,145,3,48,24,0,144,138,1,0,0,0,144,139,1,0,0,0,144,140,1,0,0,0,144,141, - 1,0,0,0,144,142,1,0,0,0,145,5,1,0,0,0,146,163,3,50,25,0,147,163,3,8,4,0, - 148,163,3,80,40,0,149,163,3,74,37,0,150,163,3,52,26,0,151,163,3,76,38,0, - 152,163,3,82,41,0,153,163,3,84,42,0,154,163,3,88,44,0,155,163,3,90,45,0, - 156,163,3,116,58,0,157,163,3,92,46,0,158,159,4,3,2,0,159,163,3,122,61,0, - 160,161,4,3,3,0,161,163,3,120,60,0,162,146,1,0,0,0,162,147,1,0,0,0,162, - 148,1,0,0,0,162,149,1,0,0,0,162,150,1,0,0,0,162,151,1,0,0,0,162,152,1,0, - 0,0,162,153,1,0,0,0,162,154,1,0,0,0,162,155,1,0,0,0,162,156,1,0,0,0,162, - 157,1,0,0,0,162,158,1,0,0,0,162,160,1,0,0,0,163,7,1,0,0,0,164,165,5,16, - 0,0,165,166,3,10,5,0,166,9,1,0,0,0,167,168,6,5,-1,0,168,169,5,43,0,0,169, - 198,3,10,5,8,170,198,3,16,8,0,171,198,3,12,6,0,172,174,3,16,8,0,173,175, - 5,43,0,0,174,173,1,0,0,0,174,175,1,0,0,0,175,176,1,0,0,0,176,177,5,38,0, - 0,177,178,5,42,0,0,178,183,3,16,8,0,179,180,5,33,0,0,180,182,3,16,8,0,181, - 179,1,0,0,0,182,185,1,0,0,0,183,181,1,0,0,0,183,184,1,0,0,0,184,186,1,0, - 0,0,185,183,1,0,0,0,186,187,5,49,0,0,187,198,1,0,0,0,188,189,3,16,8,0,189, - 191,5,39,0,0,190,192,5,43,0,0,191,190,1,0,0,0,191,192,1,0,0,0,192,193,1, - 0,0,0,193,194,5,44,0,0,194,198,1,0,0,0,195,196,4,5,4,0,196,198,3,14,7,0, - 197,167,1,0,0,0,197,170,1,0,0,0,197,171,1,0,0,0,197,172,1,0,0,0,197,188, - 1,0,0,0,197,195,1,0,0,0,198,207,1,0,0,0,199,200,10,5,0,0,200,201,5,29,0, - 0,201,206,3,10,5,6,202,203,10,4,0,0,203,204,5,46,0,0,204,206,3,10,5,5,205, - 199,1,0,0,0,205,202,1,0,0,0,206,209,1,0,0,0,207,205,1,0,0,0,207,208,1,0, - 0,0,208,11,1,0,0,0,209,207,1,0,0,0,210,212,3,16,8,0,211,213,5,43,0,0,212, - 211,1,0,0,0,212,213,1,0,0,0,213,214,1,0,0,0,214,215,5,41,0,0,215,216,3, - 106,53,0,216,225,1,0,0,0,217,219,3,16,8,0,218,220,5,43,0,0,219,218,1,0, - 0,0,219,220,1,0,0,0,220,221,1,0,0,0,221,222,5,48,0,0,222,223,3,106,53,0, - 223,225,1,0,0,0,224,210,1,0,0,0,224,217,1,0,0,0,225,13,1,0,0,0,226,227, - 3,16,8,0,227,228,5,63,0,0,228,229,3,106,53,0,229,15,1,0,0,0,230,236,3,18, - 9,0,231,232,3,18,9,0,232,233,3,108,54,0,233,234,3,18,9,0,234,236,1,0,0, - 0,235,230,1,0,0,0,235,231,1,0,0,0,236,17,1,0,0,0,237,238,6,9,-1,0,238,242, - 3,20,10,0,239,240,7,0,0,0,240,242,3,18,9,3,241,237,1,0,0,0,241,239,1,0, - 0,0,242,251,1,0,0,0,243,244,10,2,0,0,244,245,7,1,0,0,245,250,3,18,9,3,246, - 247,10,1,0,0,247,248,7,0,0,0,248,250,3,18,9,2,249,243,1,0,0,0,249,246,1, - 0,0,0,250,253,1,0,0,0,251,249,1,0,0,0,251,252,1,0,0,0,252,19,1,0,0,0,253, - 251,1,0,0,0,254,255,6,10,-1,0,255,263,3,68,34,0,256,263,3,58,29,0,257,263, - 3,22,11,0,258,259,5,42,0,0,259,260,3,10,5,0,260,261,5,49,0,0,261,263,1, - 0,0,0,262,254,1,0,0,0,262,256,1,0,0,0,262,257,1,0,0,0,262,258,1,0,0,0,263, - 269,1,0,0,0,264,265,10,1,0,0,265,266,5,32,0,0,266,268,3,26,13,0,267,264, - 1,0,0,0,268,271,1,0,0,0,269,267,1,0,0,0,269,270,1,0,0,0,270,21,1,0,0,0, - 271,269,1,0,0,0,272,273,3,24,12,0,273,283,5,42,0,0,274,284,5,60,0,0,275, - 280,3,10,5,0,276,277,5,33,0,0,277,279,3,10,5,0,278,276,1,0,0,0,279,282, - 1,0,0,0,280,278,1,0,0,0,280,281,1,0,0,0,281,284,1,0,0,0,282,280,1,0,0,0, - 283,274,1,0,0,0,283,275,1,0,0,0,283,284,1,0,0,0,284,285,1,0,0,0,285,286, - 5,49,0,0,286,23,1,0,0,0,287,290,5,63,0,0,288,290,3,72,36,0,289,287,1,0, - 0,0,289,288,1,0,0,0,290,25,1,0,0,0,291,292,3,64,32,0,292,27,1,0,0,0,293, - 294,5,12,0,0,294,295,3,30,15,0,295,29,1,0,0,0,296,301,3,32,16,0,297,298, - 5,33,0,0,298,300,3,32,16,0,299,297,1,0,0,0,300,303,1,0,0,0,301,299,1,0, - 0,0,301,302,1,0,0,0,302,31,1,0,0,0,303,301,1,0,0,0,304,305,3,58,29,0,305, - 306,5,31,0,0,306,308,1,0,0,0,307,304,1,0,0,0,307,308,1,0,0,0,308,309,1, - 0,0,0,309,310,3,10,5,0,310,33,1,0,0,0,311,312,5,6,0,0,312,317,3,36,18,0, - 313,314,5,33,0,0,314,316,3,36,18,0,315,313,1,0,0,0,316,319,1,0,0,0,317, - 315,1,0,0,0,317,318,1,0,0,0,318,321,1,0,0,0,319,317,1,0,0,0,320,322,3,42, - 21,0,321,320,1,0,0,0,321,322,1,0,0,0,322,35,1,0,0,0,323,324,3,38,19,0,324, - 325,5,104,0,0,325,327,1,0,0,0,326,323,1,0,0,0,326,327,1,0,0,0,327,328,1, - 0,0,0,328,329,3,40,20,0,329,37,1,0,0,0,330,331,5,76,0,0,331,39,1,0,0,0, - 332,333,7,2,0,0,333,41,1,0,0,0,334,337,3,44,22,0,335,337,3,46,23,0,336, - 334,1,0,0,0,336,335,1,0,0,0,337,43,1,0,0,0,338,339,5,75,0,0,339,344,5,76, - 0,0,340,341,5,33,0,0,341,343,5,76,0,0,342,340,1,0,0,0,343,346,1,0,0,0,344, - 342,1,0,0,0,344,345,1,0,0,0,345,45,1,0,0,0,346,344,1,0,0,0,347,348,5,65, - 0,0,348,349,3,44,22,0,349,350,5,66,0,0,350,47,1,0,0,0,351,352,5,19,0,0, - 352,357,3,36,18,0,353,354,5,33,0,0,354,356,3,36,18,0,355,353,1,0,0,0,356, - 359,1,0,0,0,357,355,1,0,0,0,357,358,1,0,0,0,358,361,1,0,0,0,359,357,1,0, - 0,0,360,362,3,54,27,0,361,360,1,0,0,0,361,362,1,0,0,0,362,365,1,0,0,0,363, - 364,5,28,0,0,364,366,3,30,15,0,365,363,1,0,0,0,365,366,1,0,0,0,366,49,1, - 0,0,0,367,368,5,4,0,0,368,369,3,30,15,0,369,51,1,0,0,0,370,372,5,15,0,0, - 371,373,3,54,27,0,372,371,1,0,0,0,372,373,1,0,0,0,373,376,1,0,0,0,374,375, - 5,28,0,0,375,377,3,30,15,0,376,374,1,0,0,0,376,377,1,0,0,0,377,53,1,0,0, - 0,378,383,3,56,28,0,379,380,5,33,0,0,380,382,3,56,28,0,381,379,1,0,0,0, - 382,385,1,0,0,0,383,381,1,0,0,0,383,384,1,0,0,0,384,55,1,0,0,0,385,383, - 1,0,0,0,386,389,3,32,16,0,387,388,5,16,0,0,388,390,3,10,5,0,389,387,1,0, - 0,0,389,390,1,0,0,0,390,57,1,0,0,0,391,396,3,72,36,0,392,393,5,35,0,0,393, - 395,3,72,36,0,394,392,1,0,0,0,395,398,1,0,0,0,396,394,1,0,0,0,396,397,1, - 0,0,0,397,59,1,0,0,0,398,396,1,0,0,0,399,404,3,66,33,0,400,401,5,35,0,0, - 401,403,3,66,33,0,402,400,1,0,0,0,403,406,1,0,0,0,404,402,1,0,0,0,404,405, - 1,0,0,0,405,61,1,0,0,0,406,404,1,0,0,0,407,412,3,60,30,0,408,409,5,33,0, - 0,409,411,3,60,30,0,410,408,1,0,0,0,411,414,1,0,0,0,412,410,1,0,0,0,412, - 413,1,0,0,0,413,63,1,0,0,0,414,412,1,0,0,0,415,416,7,3,0,0,416,65,1,0,0, - 0,417,421,5,80,0,0,418,419,4,33,10,0,419,421,3,70,35,0,420,417,1,0,0,0, - 420,418,1,0,0,0,421,67,1,0,0,0,422,465,5,44,0,0,423,424,3,104,52,0,424, - 425,5,67,0,0,425,465,1,0,0,0,426,465,3,102,51,0,427,465,3,104,52,0,428, - 465,3,98,49,0,429,465,3,70,35,0,430,465,3,106,53,0,431,432,5,65,0,0,432, - 437,3,100,50,0,433,434,5,33,0,0,434,436,3,100,50,0,435,433,1,0,0,0,436, - 439,1,0,0,0,437,435,1,0,0,0,437,438,1,0,0,0,438,440,1,0,0,0,439,437,1,0, - 0,0,440,441,5,66,0,0,441,465,1,0,0,0,442,443,5,65,0,0,443,448,3,98,49,0, - 444,445,5,33,0,0,445,447,3,98,49,0,446,444,1,0,0,0,447,450,1,0,0,0,448, - 446,1,0,0,0,448,449,1,0,0,0,449,451,1,0,0,0,450,448,1,0,0,0,451,452,5,66, - 0,0,452,465,1,0,0,0,453,454,5,65,0,0,454,459,3,106,53,0,455,456,5,33,0, - 0,456,458,3,106,53,0,457,455,1,0,0,0,458,461,1,0,0,0,459,457,1,0,0,0,459, - 460,1,0,0,0,460,462,1,0,0,0,461,459,1,0,0,0,462,463,5,66,0,0,463,465,1, - 0,0,0,464,422,1,0,0,0,464,423,1,0,0,0,464,426,1,0,0,0,464,427,1,0,0,0,464, - 428,1,0,0,0,464,429,1,0,0,0,464,430,1,0,0,0,464,431,1,0,0,0,464,442,1,0, - 0,0,464,453,1,0,0,0,465,69,1,0,0,0,466,469,5,47,0,0,467,469,5,64,0,0,468, - 466,1,0,0,0,468,467,1,0,0,0,469,71,1,0,0,0,470,474,3,64,32,0,471,472,4, - 36,11,0,472,474,3,70,35,0,473,470,1,0,0,0,473,471,1,0,0,0,474,73,1,0,0, - 0,475,476,5,9,0,0,476,477,5,26,0,0,477,75,1,0,0,0,478,479,5,14,0,0,479, - 484,3,78,39,0,480,481,5,33,0,0,481,483,3,78,39,0,482,480,1,0,0,0,483,486, - 1,0,0,0,484,482,1,0,0,0,484,485,1,0,0,0,485,77,1,0,0,0,486,484,1,0,0,0, - 487,489,3,10,5,0,488,490,7,4,0,0,489,488,1,0,0,0,489,490,1,0,0,0,490,493, - 1,0,0,0,491,492,5,45,0,0,492,494,7,5,0,0,493,491,1,0,0,0,493,494,1,0,0, - 0,494,79,1,0,0,0,495,496,5,8,0,0,496,497,3,62,31,0,497,81,1,0,0,0,498,499, - 5,2,0,0,499,500,3,62,31,0,500,83,1,0,0,0,501,502,5,11,0,0,502,507,3,86, - 43,0,503,504,5,33,0,0,504,506,3,86,43,0,505,503,1,0,0,0,506,509,1,0,0,0, - 507,505,1,0,0,0,507,508,1,0,0,0,508,85,1,0,0,0,509,507,1,0,0,0,510,511, - 3,60,30,0,511,512,5,84,0,0,512,513,3,60,30,0,513,87,1,0,0,0,514,515,5,1, - 0,0,515,516,3,20,10,0,516,518,3,106,53,0,517,519,3,94,47,0,518,517,1,0, - 0,0,518,519,1,0,0,0,519,89,1,0,0,0,520,521,5,7,0,0,521,522,3,20,10,0,522, - 523,3,106,53,0,523,91,1,0,0,0,524,525,5,10,0,0,525,526,3,58,29,0,526,93, - 1,0,0,0,527,532,3,96,48,0,528,529,5,33,0,0,529,531,3,96,48,0,530,528,1, - 0,0,0,531,534,1,0,0,0,532,530,1,0,0,0,532,533,1,0,0,0,533,95,1,0,0,0,534, - 532,1,0,0,0,535,536,3,64,32,0,536,537,5,31,0,0,537,538,3,68,34,0,538,97, - 1,0,0,0,539,540,7,6,0,0,540,99,1,0,0,0,541,544,3,102,51,0,542,544,3,104, - 52,0,543,541,1,0,0,0,543,542,1,0,0,0,544,101,1,0,0,0,545,547,7,0,0,0,546, - 545,1,0,0,0,546,547,1,0,0,0,547,548,1,0,0,0,548,549,5,27,0,0,549,103,1, - 0,0,0,550,552,7,0,0,0,551,550,1,0,0,0,551,552,1,0,0,0,552,553,1,0,0,0,553, - 554,5,26,0,0,554,105,1,0,0,0,555,556,5,25,0,0,556,107,1,0,0,0,557,558,7, - 7,0,0,558,109,1,0,0,0,559,560,5,5,0,0,560,561,3,112,56,0,561,111,1,0,0, - 0,562,563,5,65,0,0,563,564,3,2,1,0,564,565,5,66,0,0,565,113,1,0,0,0,566, - 567,5,13,0,0,567,568,5,100,0,0,568,115,1,0,0,0,569,570,5,3,0,0,570,573, - 5,90,0,0,571,572,5,88,0,0,572,574,3,60,30,0,573,571,1,0,0,0,573,574,1,0, - 0,0,574,584,1,0,0,0,575,576,5,89,0,0,576,581,3,118,59,0,577,578,5,33,0, - 0,578,580,3,118,59,0,579,577,1,0,0,0,580,583,1,0,0,0,581,579,1,0,0,0,581, - 582,1,0,0,0,582,585,1,0,0,0,583,581,1,0,0,0,584,575,1,0,0,0,584,585,1,0, - 0,0,585,117,1,0,0,0,586,587,3,60,30,0,587,588,5,31,0,0,588,590,1,0,0,0, - 589,586,1,0,0,0,589,590,1,0,0,0,590,591,1,0,0,0,591,592,3,60,30,0,592,119, - 1,0,0,0,593,594,5,18,0,0,594,595,3,36,18,0,595,596,5,88,0,0,596,597,3,62, - 31,0,597,121,1,0,0,0,598,599,5,17,0,0,599,602,3,54,27,0,600,601,5,28,0, - 0,601,603,3,30,15,0,602,600,1,0,0,0,602,603,1,0,0,0,603,123,1,0,0,0,59, + 8,11,1,11,1,11,1,12,1,12,1,13,1,13,1,14,1,14,1,14,1,15,1,15,1,15,5,15,298, + 8,15,10,15,12,15,301,9,15,1,16,1,16,1,16,3,16,306,8,16,1,16,1,16,1,17,1, + 17,1,17,1,17,5,17,314,8,17,10,17,12,17,317,9,17,1,17,3,17,320,8,17,1,18, + 1,18,1,18,3,18,325,8,18,1,18,1,18,1,19,1,19,1,20,1,20,1,21,1,21,3,21,335, + 8,21,1,22,1,22,1,22,1,22,5,22,341,8,22,10,22,12,22,344,9,22,1,23,1,23,1, + 23,1,23,1,24,1,24,1,24,1,24,5,24,354,8,24,10,24,12,24,357,9,24,1,24,3,24, + 360,8,24,1,24,1,24,3,24,364,8,24,1,25,1,25,1,25,1,26,1,26,3,26,371,8,26, + 1,26,1,26,3,26,375,8,26,1,27,1,27,1,27,5,27,380,8,27,10,27,12,27,383,9, + 27,1,28,1,28,1,28,3,28,388,8,28,1,29,1,29,1,29,5,29,393,8,29,10,29,12,29, + 396,9,29,1,30,1,30,1,30,5,30,401,8,30,10,30,12,30,404,9,30,1,31,1,31,1, + 31,5,31,409,8,31,10,31,12,31,412,9,31,1,32,1,32,1,33,1,33,1,33,3,33,419, + 8,33,1,34,1,34,1,34,1,34,1,34,1,34,1,34,1,34,1,34,1,34,1,34,1,34,1,34,5, + 34,434,8,34,10,34,12,34,437,9,34,1,34,1,34,1,34,1,34,1,34,1,34,5,34,445, + 8,34,10,34,12,34,448,9,34,1,34,1,34,1,34,1,34,1,34,1,34,5,34,456,8,34,10, + 34,12,34,459,9,34,1,34,1,34,3,34,463,8,34,1,35,1,35,3,35,467,8,35,1,36, + 1,36,1,36,3,36,472,8,36,1,37,1,37,1,37,1,38,1,38,1,38,1,38,5,38,481,8,38, + 10,38,12,38,484,9,38,1,39,1,39,3,39,488,8,39,1,39,1,39,3,39,492,8,39,1, + 40,1,40,1,40,1,41,1,41,1,41,1,42,1,42,1,42,1,42,5,42,504,8,42,10,42,12, + 42,507,9,42,1,43,1,43,1,43,1,43,1,44,1,44,1,44,1,44,3,44,517,8,44,1,45, + 1,45,1,45,1,45,1,46,1,46,1,46,1,47,1,47,1,47,5,47,529,8,47,10,47,12,47, + 532,9,47,1,48,1,48,1,48,1,48,1,49,1,49,1,50,1,50,3,50,542,8,50,1,51,3,51, + 545,8,51,1,51,1,51,1,52,3,52,550,8,52,1,52,1,52,1,53,1,53,1,54,1,54,1,55, + 1,55,1,55,1,56,1,56,1,56,1,56,1,57,1,57,1,57,1,58,1,58,1,58,1,58,3,58,572, + 8,58,1,58,1,58,1,58,1,58,5,58,578,8,58,10,58,12,58,581,9,58,3,58,583,8, + 58,1,59,1,59,1,59,3,59,588,8,59,1,59,1,59,1,60,1,60,1,60,1,60,1,60,1,61, + 1,61,1,61,1,61,3,61,601,8,61,1,61,0,4,2,10,18,20,62,0,2,4,6,8,10,12,14, + 16,18,20,22,24,26,28,30,32,34,36,38,40,42,44,46,48,50,52,54,56,58,60,62, + 64,66,68,70,72,74,76,78,80,82,84,86,88,90,92,94,96,98,100,102,104,106,108, + 110,112,114,116,118,120,122,0,8,1,0,59,60,1,0,61,63,2,0,26,26,76,76,1,0, + 67,68,2,0,31,31,35,35,2,0,38,38,41,41,2,0,37,37,51,51,2,0,52,52,54,58,628, + 0,124,1,0,0,0,2,127,1,0,0,0,4,144,1,0,0,0,6,162,1,0,0,0,8,164,1,0,0,0,10, + 197,1,0,0,0,12,224,1,0,0,0,14,226,1,0,0,0,16,235,1,0,0,0,18,241,1,0,0,0, + 20,262,1,0,0,0,22,272,1,0,0,0,24,287,1,0,0,0,26,289,1,0,0,0,28,291,1,0, + 0,0,30,294,1,0,0,0,32,305,1,0,0,0,34,309,1,0,0,0,36,324,1,0,0,0,38,328, + 1,0,0,0,40,330,1,0,0,0,42,334,1,0,0,0,44,336,1,0,0,0,46,345,1,0,0,0,48, + 349,1,0,0,0,50,365,1,0,0,0,52,368,1,0,0,0,54,376,1,0,0,0,56,384,1,0,0,0, + 58,389,1,0,0,0,60,397,1,0,0,0,62,405,1,0,0,0,64,413,1,0,0,0,66,418,1,0, + 0,0,68,462,1,0,0,0,70,466,1,0,0,0,72,471,1,0,0,0,74,473,1,0,0,0,76,476, + 1,0,0,0,78,485,1,0,0,0,80,493,1,0,0,0,82,496,1,0,0,0,84,499,1,0,0,0,86, + 508,1,0,0,0,88,512,1,0,0,0,90,518,1,0,0,0,92,522,1,0,0,0,94,525,1,0,0,0, + 96,533,1,0,0,0,98,537,1,0,0,0,100,541,1,0,0,0,102,544,1,0,0,0,104,549,1, + 0,0,0,106,553,1,0,0,0,108,555,1,0,0,0,110,557,1,0,0,0,112,560,1,0,0,0,114, + 564,1,0,0,0,116,567,1,0,0,0,118,587,1,0,0,0,120,591,1,0,0,0,122,596,1,0, + 0,0,124,125,3,2,1,0,125,126,5,0,0,1,126,1,1,0,0,0,127,128,6,1,-1,0,128, + 129,3,4,2,0,129,135,1,0,0,0,130,131,10,1,0,0,131,132,5,25,0,0,132,134,3, + 6,3,0,133,130,1,0,0,0,134,137,1,0,0,0,135,133,1,0,0,0,135,136,1,0,0,0,136, + 3,1,0,0,0,137,135,1,0,0,0,138,145,3,110,55,0,139,145,3,34,17,0,140,145, + 3,28,14,0,141,145,3,114,57,0,142,143,4,2,1,0,143,145,3,48,24,0,144,138, + 1,0,0,0,144,139,1,0,0,0,144,140,1,0,0,0,144,141,1,0,0,0,144,142,1,0,0,0, + 145,5,1,0,0,0,146,163,3,50,25,0,147,163,3,8,4,0,148,163,3,80,40,0,149,163, + 3,74,37,0,150,163,3,52,26,0,151,163,3,76,38,0,152,163,3,82,41,0,153,163, + 3,84,42,0,154,163,3,88,44,0,155,163,3,90,45,0,156,163,3,116,58,0,157,163, + 3,92,46,0,158,159,4,3,2,0,159,163,3,122,61,0,160,161,4,3,3,0,161,163,3, + 120,60,0,162,146,1,0,0,0,162,147,1,0,0,0,162,148,1,0,0,0,162,149,1,0,0, + 0,162,150,1,0,0,0,162,151,1,0,0,0,162,152,1,0,0,0,162,153,1,0,0,0,162,154, + 1,0,0,0,162,155,1,0,0,0,162,156,1,0,0,0,162,157,1,0,0,0,162,158,1,0,0,0, + 162,160,1,0,0,0,163,7,1,0,0,0,164,165,5,16,0,0,165,166,3,10,5,0,166,9,1, + 0,0,0,167,168,6,5,-1,0,168,169,5,44,0,0,169,198,3,10,5,8,170,198,3,16,8, + 0,171,198,3,12,6,0,172,174,3,16,8,0,173,175,5,44,0,0,174,173,1,0,0,0,174, + 175,1,0,0,0,175,176,1,0,0,0,176,177,5,39,0,0,177,178,5,43,0,0,178,183,3, + 16,8,0,179,180,5,34,0,0,180,182,3,16,8,0,181,179,1,0,0,0,182,185,1,0,0, + 0,183,181,1,0,0,0,183,184,1,0,0,0,184,186,1,0,0,0,185,183,1,0,0,0,186,187, + 5,50,0,0,187,198,1,0,0,0,188,189,3,16,8,0,189,191,5,40,0,0,190,192,5,44, + 0,0,191,190,1,0,0,0,191,192,1,0,0,0,192,193,1,0,0,0,193,194,5,45,0,0,194, + 198,1,0,0,0,195,196,4,5,4,0,196,198,3,14,7,0,197,167,1,0,0,0,197,170,1, + 0,0,0,197,171,1,0,0,0,197,172,1,0,0,0,197,188,1,0,0,0,197,195,1,0,0,0,198, + 207,1,0,0,0,199,200,10,5,0,0,200,201,5,30,0,0,201,206,3,10,5,6,202,203, + 10,4,0,0,203,204,5,47,0,0,204,206,3,10,5,5,205,199,1,0,0,0,205,202,1,0, + 0,0,206,209,1,0,0,0,207,205,1,0,0,0,207,208,1,0,0,0,208,11,1,0,0,0,209, + 207,1,0,0,0,210,212,3,16,8,0,211,213,5,44,0,0,212,211,1,0,0,0,212,213,1, + 0,0,0,213,214,1,0,0,0,214,215,5,42,0,0,215,216,3,106,53,0,216,225,1,0,0, + 0,217,219,3,16,8,0,218,220,5,44,0,0,219,218,1,0,0,0,219,220,1,0,0,0,220, + 221,1,0,0,0,221,222,5,49,0,0,222,223,3,106,53,0,223,225,1,0,0,0,224,210, + 1,0,0,0,224,217,1,0,0,0,225,13,1,0,0,0,226,227,3,58,29,0,227,228,5,24,0, + 0,228,229,3,68,34,0,229,15,1,0,0,0,230,236,3,18,9,0,231,232,3,18,9,0,232, + 233,3,108,54,0,233,234,3,18,9,0,234,236,1,0,0,0,235,230,1,0,0,0,235,231, + 1,0,0,0,236,17,1,0,0,0,237,238,6,9,-1,0,238,242,3,20,10,0,239,240,7,0,0, + 0,240,242,3,18,9,3,241,237,1,0,0,0,241,239,1,0,0,0,242,251,1,0,0,0,243, + 244,10,2,0,0,244,245,7,1,0,0,245,250,3,18,9,3,246,247,10,1,0,0,247,248, + 7,0,0,0,248,250,3,18,9,2,249,243,1,0,0,0,249,246,1,0,0,0,250,253,1,0,0, + 0,251,249,1,0,0,0,251,252,1,0,0,0,252,19,1,0,0,0,253,251,1,0,0,0,254,255, + 6,10,-1,0,255,263,3,68,34,0,256,263,3,58,29,0,257,263,3,22,11,0,258,259, + 5,43,0,0,259,260,3,10,5,0,260,261,5,50,0,0,261,263,1,0,0,0,262,254,1,0, + 0,0,262,256,1,0,0,0,262,257,1,0,0,0,262,258,1,0,0,0,263,269,1,0,0,0,264, + 265,10,1,0,0,265,266,5,33,0,0,266,268,3,26,13,0,267,264,1,0,0,0,268,271, + 1,0,0,0,269,267,1,0,0,0,269,270,1,0,0,0,270,21,1,0,0,0,271,269,1,0,0,0, + 272,273,3,24,12,0,273,283,5,43,0,0,274,284,5,61,0,0,275,280,3,10,5,0,276, + 277,5,34,0,0,277,279,3,10,5,0,278,276,1,0,0,0,279,282,1,0,0,0,280,278,1, + 0,0,0,280,281,1,0,0,0,281,284,1,0,0,0,282,280,1,0,0,0,283,274,1,0,0,0,283, + 275,1,0,0,0,283,284,1,0,0,0,284,285,1,0,0,0,285,286,5,50,0,0,286,23,1,0, + 0,0,287,288,3,72,36,0,288,25,1,0,0,0,289,290,3,64,32,0,290,27,1,0,0,0,291, + 292,5,12,0,0,292,293,3,30,15,0,293,29,1,0,0,0,294,299,3,32,16,0,295,296, + 5,34,0,0,296,298,3,32,16,0,297,295,1,0,0,0,298,301,1,0,0,0,299,297,1,0, + 0,0,299,300,1,0,0,0,300,31,1,0,0,0,301,299,1,0,0,0,302,303,3,58,29,0,303, + 304,5,32,0,0,304,306,1,0,0,0,305,302,1,0,0,0,305,306,1,0,0,0,306,307,1, + 0,0,0,307,308,3,10,5,0,308,33,1,0,0,0,309,310,5,6,0,0,310,315,3,36,18,0, + 311,312,5,34,0,0,312,314,3,36,18,0,313,311,1,0,0,0,314,317,1,0,0,0,315, + 313,1,0,0,0,315,316,1,0,0,0,316,319,1,0,0,0,317,315,1,0,0,0,318,320,3,42, + 21,0,319,318,1,0,0,0,319,320,1,0,0,0,320,35,1,0,0,0,321,322,3,38,19,0,322, + 323,5,24,0,0,323,325,1,0,0,0,324,321,1,0,0,0,324,325,1,0,0,0,325,326,1, + 0,0,0,326,327,3,40,20,0,327,37,1,0,0,0,328,329,5,76,0,0,329,39,1,0,0,0, + 330,331,7,2,0,0,331,41,1,0,0,0,332,335,3,44,22,0,333,335,3,46,23,0,334, + 332,1,0,0,0,334,333,1,0,0,0,335,43,1,0,0,0,336,337,5,75,0,0,337,342,5,76, + 0,0,338,339,5,34,0,0,339,341,5,76,0,0,340,338,1,0,0,0,341,344,1,0,0,0,342, + 340,1,0,0,0,342,343,1,0,0,0,343,45,1,0,0,0,344,342,1,0,0,0,345,346,5,65, + 0,0,346,347,3,44,22,0,347,348,5,66,0,0,348,47,1,0,0,0,349,350,5,19,0,0, + 350,355,3,36,18,0,351,352,5,34,0,0,352,354,3,36,18,0,353,351,1,0,0,0,354, + 357,1,0,0,0,355,353,1,0,0,0,355,356,1,0,0,0,356,359,1,0,0,0,357,355,1,0, + 0,0,358,360,3,54,27,0,359,358,1,0,0,0,359,360,1,0,0,0,360,363,1,0,0,0,361, + 362,5,29,0,0,362,364,3,30,15,0,363,361,1,0,0,0,363,364,1,0,0,0,364,49,1, + 0,0,0,365,366,5,4,0,0,366,367,3,30,15,0,367,51,1,0,0,0,368,370,5,15,0,0, + 369,371,3,54,27,0,370,369,1,0,0,0,370,371,1,0,0,0,371,374,1,0,0,0,372,373, + 5,29,0,0,373,375,3,30,15,0,374,372,1,0,0,0,374,375,1,0,0,0,375,53,1,0,0, + 0,376,381,3,56,28,0,377,378,5,34,0,0,378,380,3,56,28,0,379,377,1,0,0,0, + 380,383,1,0,0,0,381,379,1,0,0,0,381,382,1,0,0,0,382,55,1,0,0,0,383,381, + 1,0,0,0,384,387,3,32,16,0,385,386,5,16,0,0,386,388,3,10,5,0,387,385,1,0, + 0,0,387,388,1,0,0,0,388,57,1,0,0,0,389,394,3,72,36,0,390,391,5,36,0,0,391, + 393,3,72,36,0,392,390,1,0,0,0,393,396,1,0,0,0,394,392,1,0,0,0,394,395,1, + 0,0,0,395,59,1,0,0,0,396,394,1,0,0,0,397,402,3,66,33,0,398,399,5,36,0,0, + 399,401,3,66,33,0,400,398,1,0,0,0,401,404,1,0,0,0,402,400,1,0,0,0,402,403, + 1,0,0,0,403,61,1,0,0,0,404,402,1,0,0,0,405,410,3,60,30,0,406,407,5,34,0, + 0,407,409,3,60,30,0,408,406,1,0,0,0,409,412,1,0,0,0,410,408,1,0,0,0,410, + 411,1,0,0,0,411,63,1,0,0,0,412,410,1,0,0,0,413,414,7,3,0,0,414,65,1,0,0, + 0,415,419,5,80,0,0,416,417,4,33,10,0,417,419,3,70,35,0,418,415,1,0,0,0, + 418,416,1,0,0,0,419,67,1,0,0,0,420,463,5,45,0,0,421,422,3,104,52,0,422, + 423,5,67,0,0,423,463,1,0,0,0,424,463,3,102,51,0,425,463,3,104,52,0,426, + 463,3,98,49,0,427,463,3,70,35,0,428,463,3,106,53,0,429,430,5,65,0,0,430, + 435,3,100,50,0,431,432,5,34,0,0,432,434,3,100,50,0,433,431,1,0,0,0,434, + 437,1,0,0,0,435,433,1,0,0,0,435,436,1,0,0,0,436,438,1,0,0,0,437,435,1,0, + 0,0,438,439,5,66,0,0,439,463,1,0,0,0,440,441,5,65,0,0,441,446,3,98,49,0, + 442,443,5,34,0,0,443,445,3,98,49,0,444,442,1,0,0,0,445,448,1,0,0,0,446, + 444,1,0,0,0,446,447,1,0,0,0,447,449,1,0,0,0,448,446,1,0,0,0,449,450,5,66, + 0,0,450,463,1,0,0,0,451,452,5,65,0,0,452,457,3,106,53,0,453,454,5,34,0, + 0,454,456,3,106,53,0,455,453,1,0,0,0,456,459,1,0,0,0,457,455,1,0,0,0,457, + 458,1,0,0,0,458,460,1,0,0,0,459,457,1,0,0,0,460,461,5,66,0,0,461,463,1, + 0,0,0,462,420,1,0,0,0,462,421,1,0,0,0,462,424,1,0,0,0,462,425,1,0,0,0,462, + 426,1,0,0,0,462,427,1,0,0,0,462,428,1,0,0,0,462,429,1,0,0,0,462,440,1,0, + 0,0,462,451,1,0,0,0,463,69,1,0,0,0,464,467,5,48,0,0,465,467,5,64,0,0,466, + 464,1,0,0,0,466,465,1,0,0,0,467,71,1,0,0,0,468,472,3,64,32,0,469,470,4, + 36,11,0,470,472,3,70,35,0,471,468,1,0,0,0,471,469,1,0,0,0,472,73,1,0,0, + 0,473,474,5,9,0,0,474,475,5,27,0,0,475,75,1,0,0,0,476,477,5,14,0,0,477, + 482,3,78,39,0,478,479,5,34,0,0,479,481,3,78,39,0,480,478,1,0,0,0,481,484, + 1,0,0,0,482,480,1,0,0,0,482,483,1,0,0,0,483,77,1,0,0,0,484,482,1,0,0,0, + 485,487,3,10,5,0,486,488,7,4,0,0,487,486,1,0,0,0,487,488,1,0,0,0,488,491, + 1,0,0,0,489,490,5,46,0,0,490,492,7,5,0,0,491,489,1,0,0,0,491,492,1,0,0, + 0,492,79,1,0,0,0,493,494,5,8,0,0,494,495,3,62,31,0,495,81,1,0,0,0,496,497, + 5,2,0,0,497,498,3,62,31,0,498,83,1,0,0,0,499,500,5,11,0,0,500,505,3,86, + 43,0,501,502,5,34,0,0,502,504,3,86,43,0,503,501,1,0,0,0,504,507,1,0,0,0, + 505,503,1,0,0,0,505,506,1,0,0,0,506,85,1,0,0,0,507,505,1,0,0,0,508,509, + 3,60,30,0,509,510,5,84,0,0,510,511,3,60,30,0,511,87,1,0,0,0,512,513,5,1, + 0,0,513,514,3,20,10,0,514,516,3,106,53,0,515,517,3,94,47,0,516,515,1,0, + 0,0,516,517,1,0,0,0,517,89,1,0,0,0,518,519,5,7,0,0,519,520,3,20,10,0,520, + 521,3,106,53,0,521,91,1,0,0,0,522,523,5,10,0,0,523,524,3,58,29,0,524,93, + 1,0,0,0,525,530,3,96,48,0,526,527,5,34,0,0,527,529,3,96,48,0,528,526,1, + 0,0,0,529,532,1,0,0,0,530,528,1,0,0,0,530,531,1,0,0,0,531,95,1,0,0,0,532, + 530,1,0,0,0,533,534,3,64,32,0,534,535,5,32,0,0,535,536,3,68,34,0,536,97, + 1,0,0,0,537,538,7,6,0,0,538,99,1,0,0,0,539,542,3,102,51,0,540,542,3,104, + 52,0,541,539,1,0,0,0,541,540,1,0,0,0,542,101,1,0,0,0,543,545,7,0,0,0,544, + 543,1,0,0,0,544,545,1,0,0,0,545,546,1,0,0,0,546,547,5,28,0,0,547,103,1, + 0,0,0,548,550,7,0,0,0,549,548,1,0,0,0,549,550,1,0,0,0,550,551,1,0,0,0,551, + 552,5,27,0,0,552,105,1,0,0,0,553,554,5,26,0,0,554,107,1,0,0,0,555,556,7, + 7,0,0,556,109,1,0,0,0,557,558,5,5,0,0,558,559,3,112,56,0,559,111,1,0,0, + 0,560,561,5,65,0,0,561,562,3,2,1,0,562,563,5,66,0,0,563,113,1,0,0,0,564, + 565,5,13,0,0,565,566,5,100,0,0,566,115,1,0,0,0,567,568,5,3,0,0,568,571, + 5,90,0,0,569,570,5,88,0,0,570,572,3,60,30,0,571,569,1,0,0,0,571,572,1,0, + 0,0,572,582,1,0,0,0,573,574,5,89,0,0,574,579,3,118,59,0,575,576,5,34,0, + 0,576,578,3,118,59,0,577,575,1,0,0,0,578,581,1,0,0,0,579,577,1,0,0,0,579, + 580,1,0,0,0,580,583,1,0,0,0,581,579,1,0,0,0,582,573,1,0,0,0,582,583,1,0, + 0,0,583,117,1,0,0,0,584,585,3,60,30,0,585,586,5,32,0,0,586,588,1,0,0,0, + 587,584,1,0,0,0,587,588,1,0,0,0,588,589,1,0,0,0,589,590,3,60,30,0,590,119, + 1,0,0,0,591,592,5,18,0,0,592,593,3,36,18,0,593,594,5,88,0,0,594,595,3,62, + 31,0,595,121,1,0,0,0,596,597,5,17,0,0,597,600,3,54,27,0,598,599,5,29,0, + 0,599,601,3,30,15,0,600,598,1,0,0,0,600,601,1,0,0,0,601,123,1,0,0,0,58, 135,144,162,174,183,191,197,205,207,212,219,224,235,241,249,251,262,269, - 280,283,289,301,307,317,321,326,336,344,357,361,365,372,376,383,389,396, - 404,412,420,437,448,459,464,468,473,484,489,493,507,518,532,543,546,551, - 573,581,584,589,602]; + 280,283,299,305,315,319,324,334,342,355,359,363,370,374,381,387,394,402, + 410,418,435,446,457,462,466,471,482,487,491,505,516,530,541,544,549,571, + 579,582,587,600]; private static __ATN: ATN; public static get _ATN(): ATN { @@ -4124,19 +4107,20 @@ export class RegexBooleanExpressionContext extends ParserRuleContext { export class MatchBooleanExpressionContext extends ParserRuleContext { - public _queryString!: StringContext; + public _fieldExp!: QualifiedNameContext; + public _queryString!: ConstantContext; constructor(parser?: esql_parser, parent?: ParserRuleContext, invokingState?: number) { super(parent, invokingState); this.parser = parser; } - public valueExpression(): ValueExpressionContext { - return this.getTypedRuleContext(ValueExpressionContext, 0) as ValueExpressionContext; + public COLON(): TerminalNode { + return this.getToken(esql_parser.COLON, 0); } - public MATCH(): TerminalNode { - return this.getToken(esql_parser.MATCH, 0); + public qualifiedName(): QualifiedNameContext { + return this.getTypedRuleContext(QualifiedNameContext, 0) as QualifiedNameContext; } - public string_(): StringContext { - return this.getTypedRuleContext(StringContext, 0) as StringContext; + public constant(): ConstantContext { + return this.getTypedRuleContext(ConstantContext, 0) as ConstantContext; } public get ruleIndex(): number { return esql_parser.RULE_matchBooleanExpression; @@ -4484,9 +4468,6 @@ export class FunctionNameContext extends ParserRuleContext { super(parent, invokingState); this.parser = parser; } - public MATCH(): TerminalNode { - return this.getToken(esql_parser.MATCH, 0); - } public identifierOrParameter(): IdentifierOrParameterContext { return this.getTypedRuleContext(IdentifierOrParameterContext, 0) as IdentifierOrParameterContext; } diff --git a/packages/kbn-esql-ast/src/builder/builder.ts b/packages/kbn-esql-ast/src/builder/builder.ts index 26b64a6312ee4..fae1981b454c2 100644 --- a/packages/kbn-esql-ast/src/builder/builder.ts +++ b/packages/kbn-esql-ast/src/builder/builder.ts @@ -9,17 +9,25 @@ /* eslint-disable @typescript-eslint/no-namespace */ +import { LeafPrinter } from '../pretty_print'; import { ESQLAstComment, + ESQLAstCommentMultiLine, + ESQLAstCommentSingleLine, ESQLAstQueryExpression, ESQLColumn, ESQLCommand, ESQLCommandOption, ESQLDecimalLiteral, + ESQLIdentifier, ESQLInlineCast, ESQLIntegerLiteral, ESQLList, ESQLLocation, + ESQLNamedParamLiteral, + ESQLParam, + ESQLPositionalParamLiteral, + ESQLOrderExpression, ESQLSource, } from '../types'; import { AstNodeParserFields, AstNodeTemplate, PartialFields } from './types'; @@ -63,17 +71,17 @@ export namespace Builder { }; }; - export const comment = ( - subtype: ESQLAstComment['subtype'], + export const comment = ( + subtype: S, text: string, - location: ESQLLocation - ): ESQLAstComment => { + location?: ESQLLocation + ): S extends 'multi-line' ? ESQLAstCommentMultiLine : ESQLAstCommentSingleLine => { return { type: 'comment', subtype, text, location, - }; + } as S extends 'multi-line' ? ESQLAstCommentMultiLine : ESQLAstCommentSingleLine; }; export namespace expression { @@ -118,16 +126,37 @@ export namespace Builder { }; export const column = ( - template: Omit, 'name' | 'quoted'>, + template: Omit, 'name' | 'quoted' | 'parts'>, fromParser?: Partial ): ESQLColumn => { - return { + const node: ESQLColumn = { ...template, ...Builder.parserFields(fromParser), + parts: template.args.map((arg) => + arg.type === 'identifier' ? arg.name : LeafPrinter.param(arg) + ), quoted: false, - name: template.parts.join('.'), + name: '', type: 'column', }; + + node.name = LeafPrinter.column(node); + + return node; + }; + + export const order = ( + operand: ESQLColumn, + template: Omit, 'name' | 'args'>, + fromParser?: Partial + ): ESQLOrderExpression => { + return { + ...template, + ...Builder.parserFields(fromParser), + name: '', + args: [operand], + type: 'order', + }; }; export const inlineCast = ( @@ -173,4 +202,65 @@ export namespace Builder { }; } } + + export const identifier = ( + template: AstNodeTemplate, + fromParser?: Partial + ): ESQLIdentifier => { + return { + ...template, + ...Builder.parserFields(fromParser), + type: 'identifier', + }; + }; + + export namespace param { + export const unnamed = (fromParser?: Partial): ESQLParam => { + const node = { + ...Builder.parserFields(fromParser), + name: '', + value: '', + paramType: 'unnamed', + type: 'literal', + literalType: 'param', + }; + + return node as ESQLParam; + }; + + export const named = ( + template: Omit, 'name' | 'literalType' | 'paramType'>, + fromParser?: Partial + ): ESQLNamedParamLiteral => { + const node: ESQLNamedParamLiteral = { + ...template, + ...Builder.parserFields(fromParser), + name: '', + type: 'literal', + literalType: 'param', + paramType: 'named', + }; + + return node; + }; + + export const positional = ( + template: Omit< + AstNodeTemplate, + 'name' | 'literalType' | 'paramType' + >, + fromParser?: Partial + ): ESQLPositionalParamLiteral => { + const node: ESQLPositionalParamLiteral = { + ...template, + ...Builder.parserFields(fromParser), + name: '', + type: 'literal', + literalType: 'param', + paramType: 'positional', + }; + + return node; + }; + } } diff --git a/packages/kbn-esql-ast/src/mutate/commands/from/metadata.test.ts b/packages/kbn-esql-ast/src/mutate/commands/from/metadata.test.ts index b6cb485395a6c..e4161994d224b 100644 --- a/packages/kbn-esql-ast/src/mutate/commands/from/metadata.test.ts +++ b/packages/kbn-esql-ast/src/mutate/commands/from/metadata.test.ts @@ -28,7 +28,13 @@ describe('commands.from.metadata', () => { expect(column).toMatchObject({ type: 'column', - parts: ['a'], + args: [ + { + type: 'identifier', + name: 'a', + }, + ], + // parts: ['a'], }); }); @@ -40,19 +46,39 @@ describe('commands.from.metadata', () => { expect(columns).toMatchObject([ { type: 'column', - parts: ['a'], + args: [ + { + type: 'identifier', + name: 'a', + }, + ], }, { type: 'column', - parts: ['b'], + args: [ + { + type: 'identifier', + name: 'b', + }, + ], }, { type: 'column', - parts: ['_id'], + args: [ + { + type: 'identifier', + name: '_id', + }, + ], }, { type: 'column', - parts: ['_lang'], + args: [ + { + type: 'identifier', + name: '_lang', + }, + ], }, ]); }); @@ -156,7 +182,6 @@ describe('commands.from.metadata', () => { it('return inserted `column` node, and parent `option` node', () => { const src1 = 'FROM index METADATA a'; const { root } = parse(src1); - const tuple = commands.from.metadata.insert(root, 'b'); expect(tuple).toMatchObject([ diff --git a/packages/kbn-esql-ast/src/mutate/commands/from/metadata.ts b/packages/kbn-esql-ast/src/mutate/commands/from/metadata.ts index 7f08fa2a5e946..4d637a1fd0570 100644 --- a/packages/kbn-esql-ast/src/mutate/commands/from/metadata.ts +++ b/packages/kbn-esql-ast/src/mutate/commands/from/metadata.ts @@ -74,7 +74,10 @@ export const find = ( } const predicate: Predicate<[ESQLColumn, unknown]> = ([field]) => - cmpArr(field.parts, fieldName as string[]); + cmpArr( + field.args.map((arg) => (arg.type === 'identifier' ? arg.name : '')), + fieldName as string[] + ); return findByPredicate(list(ast), predicate); }; @@ -106,7 +109,7 @@ export const removeByPredicate = ( option.args.splice(index, 1); if (option.args.length === 0) { - generic.removeCommandOption(ast, option); + generic.commands.options.remove(ast, option); } return tuple; @@ -128,7 +131,12 @@ export const remove = ( fieldName = [fieldName]; } - return removeByPredicate(ast, (field) => cmpArr(field.parts, fieldName as string[])); + return removeByPredicate(ast, (field) => + cmpArr( + field.args.map((arg) => (arg.type === 'identifier' ? arg.name : '')), + fieldName as string[] + ) + ); }; /** @@ -148,20 +156,21 @@ export const insert = ( fieldName: string | string[], index: number = -1 ): [column: ESQLColumn, option: ESQLCommandOption] | undefined => { - let option = generic.findCommandOptionByName(ast, 'from', 'metadata'); + let option = generic.commands.options.findByName(ast, 'from', 'metadata'); if (!option) { - const command = generic.findCommandByName(ast, 'from'); + const command = generic.commands.findByName(ast, 'from'); if (!command) { return; } - option = generic.appendCommandOption(command, 'metadata'); + option = generic.commands.options.append(command, 'metadata'); } const parts: string[] = typeof fieldName === 'string' ? [fieldName] : fieldName; - const column = Builder.expression.column({ parts }); + const args = parts.map((part) => Builder.identifier({ name: part })); + const column = Builder.expression.column({ args }); if (index === -1) { option.args.push(column); @@ -189,13 +198,18 @@ export const upsert = ( fieldName: string | string[], index: number = -1 ): [column: ESQLColumn, option: ESQLCommandOption] | undefined => { - const option = generic.findCommandOptionByName(ast, 'from', 'metadata'); + const option = generic.commands.options.findByName(ast, 'from', 'metadata'); if (option) { const parts = Array.isArray(fieldName) ? fieldName : [fieldName]; const existing = Walker.find( option, - (node) => node.type === 'column' && cmpArr(node.parts, parts) + (node) => + node.type === 'column' && + cmpArr( + node.args.map((arg) => (arg.type === 'identifier' ? arg.name : '')), + parts + ) ); if (existing) { return undefined; diff --git a/packages/kbn-esql-ast/src/mutate/commands/from/sources.ts b/packages/kbn-esql-ast/src/mutate/commands/from/sources.ts index da67500b5b0bd..c10096cec38d9 100644 --- a/packages/kbn-esql-ast/src/mutate/commands/from/sources.ts +++ b/packages/kbn-esql-ast/src/mutate/commands/from/sources.ts @@ -67,7 +67,7 @@ export const remove = ( return undefined; } - const success = generic.removeCommandArgument(ast, node); + const success = generic.commands.args.remove(ast, node); return success ? node : undefined; }; @@ -78,7 +78,7 @@ export const insert = ( clusterName?: string, index: number = -1 ): ESQLSource | undefined => { - const command = generic.findCommandByName(ast, 'from'); + const command = generic.commands.findByName(ast, 'from'); if (!command) { return; @@ -87,7 +87,7 @@ export const insert = ( const source = Builder.expression.indexSource(indexName, clusterName); if (index === -1) { - generic.appendCommandArgument(command, source); + generic.commands.args.append(command, source); } else { command.args.splice(index, 0, source); } diff --git a/packages/kbn-esql-ast/src/mutate/commands/index.ts b/packages/kbn-esql-ast/src/mutate/commands/index.ts index 0a779292e6eca..9e2599c493459 100644 --- a/packages/kbn-esql-ast/src/mutate/commands/index.ts +++ b/packages/kbn-esql-ast/src/mutate/commands/index.ts @@ -9,5 +9,6 @@ import * as from from './from'; import * as limit from './limit'; +import * as sort from './sort'; -export { from, limit }; +export { from, limit, sort }; diff --git a/packages/kbn-esql-ast/src/mutate/commands/limit/index.ts b/packages/kbn-esql-ast/src/mutate/commands/limit/index.ts index 937538e848328..f181a1d5f0cd4 100644 --- a/packages/kbn-esql-ast/src/mutate/commands/limit/index.ts +++ b/packages/kbn-esql-ast/src/mutate/commands/limit/index.ts @@ -19,7 +19,7 @@ import { Predicate } from '../../types'; * @returns A collection of "LIMIT" commands. */ export const list = (ast: ESQLAstQueryExpression): IterableIterator => { - return generic.listCommands(ast, (cmd) => cmd.name === 'limit'); + return generic.commands.list(ast, (cmd) => cmd.name === 'limit'); }; /** @@ -55,13 +55,13 @@ export const find = ( * @returns The removed "LIMIT" command, if any. */ export const remove = (ast: ESQLAstQueryExpression, index: number = 0): ESQLCommand | undefined => { - const command = generic.findCommandByName(ast, 'limit', index); + const command = generic.commands.findByName(ast, 'limit', index); if (!command) { return; } - const success = generic.removeCommand(ast, command); + const success = !!generic.commands.remove(ast, command); if (!success) { return; @@ -128,7 +128,7 @@ export const upsert = ( args: [literal], }); - generic.appendCommand(ast, command); + generic.commands.append(ast, command); return command; }; diff --git a/packages/kbn-esql-ast/src/mutate/commands/sort/index.test.ts b/packages/kbn-esql-ast/src/mutate/commands/sort/index.test.ts new file mode 100644 index 0000000000000..1342a059254fd --- /dev/null +++ b/packages/kbn-esql-ast/src/mutate/commands/sort/index.test.ts @@ -0,0 +1,527 @@ +/* + * Copyright 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 { parse } from '../../../parser'; +import * as commands from '..'; +import { BasicPrettyPrinter } from '../../../pretty_print'; +import { Builder } from '../../../builder'; + +describe('commands.sort', () => { + describe('.listCommands()', () => { + it('returns empty array, if there are no sort commands', () => { + const src = 'FROM index METADATA a'; + const { root } = parse(src); + const list = [...commands.sort.listCommands(root)]; + + expect(list.length).toBe(0); + }); + + it('returns all sort commands', () => { + const src = + 'FROM index | SORT a ASC, b DESC, c | LIMIT 123 | SORT d | EVAL 1 | SORT e NULLS FIRST, f NULLS LAST'; + const { root } = parse(src); + const list = [...commands.sort.listCommands(root)]; + + expect(list.length).toBe(3); + }); + + it('can skip given number of sort commands', () => { + const src = + 'FROM index | SORT a ASC, b DESC, c | LIMIT 123 | SORT d | EVAL 1 | SORT e NULLS FIRST, f NULLS LAST'; + const { root } = parse(src); + const list1 = [...commands.sort.listCommands(root, 1)]; + const list2 = [...commands.sort.listCommands(root, 2)]; + const list3 = [...commands.sort.listCommands(root, 3)]; + const list4 = [...commands.sort.listCommands(root, 111)]; + + expect(list1.length).toBe(2); + expect(list2.length).toBe(1); + expect(list3.length).toBe(0); + expect(list4.length).toBe(0); + }); + }); + + describe('.list()', () => { + it('returns empty array, if there are no sort commands', () => { + const src = 'FROM index METADATA a'; + const { root } = parse(src); + const list = [...commands.sort.list(root)]; + + expect(list.length).toBe(0); + }); + + it('returns a single column expression', () => { + const src = 'FROM index | SORT a'; + const { root } = parse(src); + const list = [...commands.sort.list(root)].map(([node]) => node); + + expect(list.length).toBe(1); + expect(list[0]).toMatchObject({ + type: 'column', + name: 'a', + }); + }); + + it('returns a single order expression', () => { + const src = 'FROM index | SORT a ASC'; + const { root } = parse(src); + const list = [...commands.sort.list(root)].map(([node]) => node); + + expect(list.length).toBe(1); + expect(list[0]).toMatchObject({ + type: 'order', + args: [ + { + type: 'column', + name: 'a', + }, + ], + }); + }); + + it('returns all sort command expressions', () => { + const src = + 'FROM index | SORT a ASC, b DESC, c | LIMIT 123 | SORT d | EVAL 1 | SORT e NULLS FIRST, f NULLS LAST'; + const { root } = parse(src); + const list = [...commands.sort.list(root)].map(([node]) => node); + + expect(list).toMatchObject([ + { + type: 'order', + args: [ + { + type: 'column', + name: 'a', + }, + ], + }, + { + type: 'order', + args: [ + { + type: 'column', + name: 'b', + }, + ], + }, + { + type: 'column', + name: 'c', + }, + { + type: 'column', + name: 'd', + }, + { + type: 'order', + args: [ + { + type: 'column', + name: 'e', + }, + ], + }, + { + type: 'order', + args: [ + { + type: 'column', + name: 'f', + }, + ], + }, + ]); + }); + + it('can skip one order expression', () => { + const src = 'FROM index | SORT b DESC, a ASC'; + const { root } = parse(src); + const list = [...commands.sort.list(root, 1)].map(([node]) => node); + + expect(list.length).toBe(1); + expect(list[0]).toMatchObject({ + type: 'order', + args: [ + { + type: 'column', + name: 'a', + }, + ], + }); + }); + }); + + describe('.find()', () => { + it('returns undefined if sort expression is not found', () => { + const src = 'FROM index | WHERE a = b | LIMIT 123'; + const { root } = parse(src); + const node = commands.sort.find(root, 'abc'); + + expect(node).toBe(undefined); + }); + + it('can find a single sort expression', () => { + const src = 'FROM index | SORT a'; + const { root } = parse(src); + const [node] = commands.sort.find(root, 'a')!; + + expect(node).toMatchObject({ + type: 'column', + name: 'a', + }); + }); + + it('can find a single sort (order) expression', () => { + const src = 'FROM index | SORT b ASC'; + const { root } = parse(src); + const [node] = commands.sort.find(root, 'b')!; + + expect(node).toMatchObject({ + type: 'order', + args: [ + { + type: 'column', + name: 'b', + }, + ], + }); + }); + + it('can find a column and specific order expressions among other such expressions', () => { + const src = + 'FROM index | SORT a, b ASC | STATS agg() | SORT c DESC, d, e NULLS FIRST | LIMIT 10'; + const { root } = parse(src); + const [node1] = commands.sort.find(root, 'b')!; + const [node2] = commands.sort.find(root, 'd')!; + + expect(node1).toMatchObject({ + type: 'order', + args: [ + { + type: 'column', + name: 'b', + }, + ], + }); + expect(node2).toMatchObject({ + type: 'column', + name: 'd', + }); + }); + + it('can select second order expression with the same name', () => { + const src = 'FROM index | SORT b ASC | STATS agg() | SORT b DESC'; + const { root } = parse(src); + const [node] = commands.sort.find(root, 'b', 1)!; + + expect(node).toMatchObject({ + type: 'order', + order: 'DESC', + args: [ + { + type: 'column', + name: 'b', + }, + ], + }); + }); + + it('can find multipart columns', () => { + const src = 'FROM index | SORT hello, b.a ASC, a.b, c, c.d | STATS agg() | SORT b DESC'; + const { root } = parse(src); + const [node1] = commands.sort.find(root, ['b', 'a'])!; + const [node2] = commands.sort.find(root, ['a', 'b'])!; + + expect(node1).toMatchObject({ + type: 'order', + order: 'ASC', + args: [ + { + type: 'column', + args: [{ name: 'b' }, { name: 'a' }], + }, + ], + }); + expect(node2).toMatchObject({ + type: 'column', + args: [{ name: 'a' }, { name: 'b' }], + }); + }); + + it('returns the parent sort command of the found order expression', () => { + const src = 'FROM index | SORT hello, b.a ASC, a.b, c, c.d | STATS agg() | SORT b DESC'; + const { root } = parse(src); + const [node1, command1] = commands.sort.find(root, ['b', 'a'])!; + const [node2, command2] = commands.sort.find(root, ['a', 'b'])!; + + expect(command1).toBe(command2); + expect(!!command1.args.find((arg) => arg === node1)).toBe(true); + expect(!!command2.args.find((arg) => arg === node2)).toBe(true); + }); + }); + + describe('.remove()', () => { + it('can remove a column from a list', () => { + const src1 = 'FROM a, b, c | SORT a, b, c'; + const { root } = parse(src1); + const src2 = BasicPrettyPrinter.print(root); + + expect(src2).toBe('FROM a, b, c | SORT a, b, c'); + + commands.sort.remove(root, 'b'); + + const src3 = BasicPrettyPrinter.print(root); + + expect(src3).toBe('FROM a, b, c | SORT a, c'); + }); + + it('can remove an order expression from a list', () => { + const src1 = 'FROM a, b, c | SORT a, b ASC, c'; + const { root } = parse(src1); + const src2 = BasicPrettyPrinter.print(root); + + expect(src2).toBe('FROM a, b, c | SORT a, b ASC, c'); + + commands.sort.remove(root, 'b'); + + const src3 = BasicPrettyPrinter.print(root); + + expect(src3).toBe('FROM a, b, c | SORT a, c'); + }); + + it('does nothing if column does not exist', () => { + const src1 = 'FROM a, b, c | SORT a, c'; + const { root } = parse(src1); + const src2 = BasicPrettyPrinter.print(root); + + expect(src2).toBe('FROM a, b, c | SORT a, c'); + + commands.sort.remove(root, 'b'); + commands.sort.remove(root, 'd'); + + const src3 = BasicPrettyPrinter.print(root); + + expect(src3).toBe('FROM a, b, c | SORT a, c'); + }); + + it('can remove the sort expression at specific index', () => { + const src1 = 'FROM index | SORT a, b, c | LIMIT 1 | SORT a, b, c | LIMIT 2 | SORT a, b, c'; + const { root } = parse(src1); + const src2 = BasicPrettyPrinter.print(root); + + expect(src2).toBe( + 'FROM index | SORT a, b, c | LIMIT 1 | SORT a, b, c | LIMIT 2 | SORT a, b, c' + ); + + commands.sort.remove(root, 'a', 1); + commands.sort.remove(root, 'c', 1); + commands.sort.remove(root, 'b', 2); + + const src3 = BasicPrettyPrinter.print(root); + + expect(src3).toBe('FROM index | SORT a, b, c | LIMIT 1 | SORT b | LIMIT 2 | SORT a, c'); + }); + + it('removes SORT command, if it is left empty', () => { + const src1 = 'FROM index | SORT a, b, c | LIMIT 1 | SORT a, b, c | LIMIT 2 | SORT a, b, c'; + const { root } = parse(src1); + const src2 = BasicPrettyPrinter.print(root); + + expect(src2).toBe( + 'FROM index | SORT a, b, c | LIMIT 1 | SORT a, b, c | LIMIT 2 | SORT a, b, c' + ); + + commands.sort.remove(root, 'c', 1); + commands.sort.remove(root, 'b', 1); + commands.sort.remove(root, 'a', 1); + + const src3 = BasicPrettyPrinter.print(root); + + expect(src3).toBe('FROM index | SORT a, b, c | LIMIT 1 | LIMIT 2 | SORT a, b, c'); + }); + + it('can remove by matching parts', () => { + const src1 = 'FROM a, b, c | SORT a, b.c, d.e NULLS FIRST, e'; + const { root } = parse(src1); + const src2 = BasicPrettyPrinter.print(root); + + expect(src2).toBe('FROM a, b, c | SORT a, b.c, d.e NULLS FIRST, e'); + + commands.sort.remove(root, ['b', 'c']); + + const src3 = BasicPrettyPrinter.print(root); + + expect(src3).toBe('FROM a, b, c | SORT a, d.e NULLS FIRST, e'); + + commands.sort.remove(root, ['d', 'e']); + + const src4 = BasicPrettyPrinter.print(root); + + expect(src4).toBe('FROM a, b, c | SORT a, e'); + }); + }); + + describe('.insertIntoCommand()', () => { + it('can insert a sorting condition into the first existing SORT command', () => { + const src1 = 'FROM a, b, c | SORT s1, s2'; + const { root } = parse(src1); + const src2 = BasicPrettyPrinter.print(root); + + expect(src2).toBe('FROM a, b, c | SORT s1, s2'); + + const command = commands.sort.getCommand(root)!; + commands.sort.insertIntoCommand(command, 's3'); + + const src3 = BasicPrettyPrinter.print(root); + + expect(src3).toBe('FROM a, b, c | SORT s1, s2, s3'); + }); + + it('can prepend a sorting condition with options into the first existing SORT command', () => { + const src1 = 'FROM a, b, c | SORT s1, s2'; + const { root } = parse(src1); + const src2 = BasicPrettyPrinter.print(root); + + expect(src2).toBe('FROM a, b, c | SORT s1, s2'); + + const command = commands.sort.getCommand(root)!; + commands.sort.insertIntoCommand( + command, + { parts: ['address', 'street🙃'], order: 'ASC', nulls: 'NULLS FIRST' }, + 0 + ); + + const src3 = BasicPrettyPrinter.print(root); + + expect(src3).toBe('FROM a, b, c | SORT address.`street🙃` ASC NULLS FIRST, s1, s2'); + }); + + it('can insert a sorting condition into specific sorting command into specific position', () => { + const src1 = 'FROM a, b, c | SORT a1, a2 | SORT b1, /* HERE */ b3 | SORT c1, c2'; + const { root } = parse(src1); + const src2 = BasicPrettyPrinter.print(root); + + expect(src2).toBe('FROM a, b, c | SORT a1, a2 | SORT b1, b3 | SORT c1, c2'); + + const command = commands.sort.getCommand(root, 1)!; + commands.sort.insertIntoCommand(command, 'b2', 1); + + const src3 = BasicPrettyPrinter.print(root); + + expect(src3).toBe('FROM a, b, c | SORT a1, a2 | SORT b1, b2, b3 | SORT c1, c2'); + }); + }); + + describe('.insertExpression()', () => { + it('can insert a sorting condition into the first existing SORT command', () => { + const src1 = 'FROM a, b, c | SORT s1, s2'; + const { root } = parse(src1); + const src2 = BasicPrettyPrinter.print(root); + + expect(src2).toBe('FROM a, b, c | SORT s1, s2'); + + commands.sort.insertExpression(root, 's3'); + + const src3 = BasicPrettyPrinter.print(root); + + expect(src3).toBe('FROM a, b, c | SORT s1, s2, s3'); + }); + + it('can insert a sorting condition into specific sorting command into specific position', () => { + const src1 = 'FROM a, b, c | SORT a1, a2 | SORT b1, /* HERE */ b3 | SORT c1, c2'; + const { root } = parse(src1); + const src2 = BasicPrettyPrinter.print(root); + + expect(src2).toBe('FROM a, b, c | SORT a1, a2 | SORT b1, b3 | SORT c1, c2'); + + commands.sort.insertExpression(root, 'b2', 1, 1); + + const src3 = BasicPrettyPrinter.print(root); + + expect(src3).toBe('FROM a, b, c | SORT a1, a2 | SORT b1, b2, b3 | SORT c1, c2'); + }); + + it('when no positional arguments are provided append the column to the first SORT command', () => { + const src1 = 'FROM a, b, c | SORT a1, a2 | SORT b1, b2 | SORT c1, c2'; + const { root } = parse(src1); + const src2 = BasicPrettyPrinter.print(root); + + expect(src2).toBe('FROM a, b, c | SORT a1, a2 | SORT b1, b2 | SORT c1, c2'); + + commands.sort.insertExpression(root, 'a3'); + + const src3 = BasicPrettyPrinter.print(root); + + expect(src3).toBe('FROM a, b, c | SORT a1, a2, a3 | SORT b1, b2 | SORT c1, c2'); + }); + + it('when no SORT command found, inserts a new SORT command', () => { + const src1 = 'FROM a, b, c | LIMIT 10'; + const { root } = parse(src1); + const src2 = BasicPrettyPrinter.print(root); + + expect(src2).toBe('FROM a, b, c | LIMIT 10'); + + commands.sort.insertExpression(root, ['i18n', 'language', 'locale']); + + const src3 = BasicPrettyPrinter.print(root); + + expect(src3).toBe('FROM a, b, c | LIMIT 10 | SORT i18n.language.locale'); + }); + + it('can change the sorting order', () => { + const src1 = 'FROM a, b, c | SORT a ASC'; + const { root } = parse(src1); + const src2 = BasicPrettyPrinter.print(root); + + expect(src2).toBe('FROM a, b, c | SORT a ASC'); + + commands.sort.insertExpression(root, { parts: 'a', order: 'DESC' }); + commands.sort.remove(root, 'a', 0); + + const src3 = BasicPrettyPrinter.print(root); + + expect(src3).toBe('FROM a, b, c | SORT a DESC'); + }); + }); + + describe('.insertCommand()', () => { + it('can append a new SORT command', () => { + const src1 = 'FROM a, b, c | SORT s1, s2'; + const { root } = parse(src1); + const src2 = BasicPrettyPrinter.print(root); + + expect(src2).toBe('FROM a, b, c | SORT s1, s2'); + + commands.sort.insertCommand(root, 's3'); + + const src3 = BasicPrettyPrinter.print(root); + + expect(src3).toBe('FROM a, b, c | SORT s1, s2 | SORT s3'); + }); + + it('can insert a SORT command before a LIMIT command (and add a comment)', () => { + const src1 = 'FROM a, b, c | LIMIT 10'; + const { root } = parse(src1); + const src2 = BasicPrettyPrinter.print(root); + + expect(src2).toBe('FROM a, b, c | LIMIT 10'); + + const [_, column] = commands.sort.insertCommand(root, 'b', 1); + + column.formatting = { + right: [Builder.comment('multi-line', ' we sort by "b" ')], + }; + + const src3 = BasicPrettyPrinter.print(root); + + expect(src3).toBe('FROM a, b, c | SORT b /* we sort by "b" */ | LIMIT 10'); + }); + }); +}); diff --git a/packages/kbn-esql-ast/src/mutate/commands/sort/index.ts b/packages/kbn-esql-ast/src/mutate/commands/sort/index.ts new file mode 100644 index 0000000000000..d2f64e5b6a1cb --- /dev/null +++ b/packages/kbn-esql-ast/src/mutate/commands/sort/index.ts @@ -0,0 +1,321 @@ +/* + * Copyright 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 { Builder } from '../../../builder'; +import { + ESQLAstQueryExpression, + ESQLColumn, + ESQLCommand, + ESQLOrderExpression, +} from '../../../types'; +import { Visitor } from '../../../visitor'; +import { Predicate } from '../../types'; +import * as util from '../../util'; +import * as generic from '../../generic'; + +export type SortExpression = ESQLOrderExpression | ESQLColumn; + +/** + * This "template" allows the developer to easily specify a new sort expression + * AST node, for example: + * + * ```ts + * // as a simple string + * 'column_name' + * + * // column with nested fields + * ['column_name', 'nested_field'] + * + * // as an object with additional options + * { parts: 'column_name', order: 'ASC', nulls: 'NULLS FIRST' } + * { parts: ['column_name', 'nested_field'], order: 'DESC', nulls: 'NULLS LAST' } + * ``` + */ +export type NewSortExpressionTemplate = + | string + | string[] + | { + parts: string | string[]; + order?: ESQLOrderExpression['order']; + nulls?: ESQLOrderExpression['nulls']; + }; + +const createSortExpression = ( + template: string | string[] | NewSortExpressionTemplate +): SortExpression => { + const parts: string[] = + typeof template === 'string' + ? [template] + : Array.isArray(template) + ? template + : typeof template.parts === 'string' + ? [template.parts] + : template.parts; + const identifiers = parts.map((part) => Builder.identifier({ name: part })); + const column = Builder.expression.column({ + args: identifiers, + }); + + if (typeof template === 'string' || Array.isArray(template)) { + return column; + } + + const order = Builder.expression.order(column, { + order: template.order ?? '', + nulls: template.nulls ?? '', + }); + + return order; +}; + +/** + * Iterates through all sort commands starting from the beginning of the query. + * You can specify the `skip` parameter to skip a given number of sort commands. + * + * @param ast The root of the AST. + * @param skip Number of sort commands to skip. + * @returns Iterator through all sort commands. + */ +export const listCommands = ( + ast: ESQLAstQueryExpression, + skip: number = 0 +): IterableIterator => { + return new Visitor() + .on('visitSortCommand', function* (ctx): IterableIterator { + if (skip) { + skip--; + } else { + yield ctx.node; + } + }) + .on('visitCommand', function* (): IterableIterator {}) + .on('visitQuery', function* (ctx): IterableIterator { + for (const command of ctx.visitCommands()) { + yield* command; + } + }) + .visitQuery(ast); +}; + +/** + * Returns the Nth SORT command found in the query. + * + * @param ast The root of the AST. + * @param index The index (N) of the sort command to return. + * @returns The sort command found in the AST, if any. + */ +export const getCommand = ( + ast: ESQLAstQueryExpression, + index: number = 0 +): ESQLCommand | undefined => { + for (const command of listCommands(ast, index)) { + return command; + } +}; + +/** + * Returns an iterator for all sort expressions (columns and order expressions) + * in the query. You can specify the `skip` parameter to skip a given number of + * expressions. + * + * @param ast The root of the AST. + * @param skip Number of sort expressions to skip. + * @returns Iterator through sort expressions (columns and order expressions). + */ +export const list = ( + ast: ESQLAstQueryExpression, + skip: number = 0 +): IterableIterator<[sortExpression: SortExpression, sortCommand: ESQLCommand]> => { + return new Visitor() + .on('visitSortCommand', function* (ctx): IterableIterator<[SortExpression, ESQLCommand]> { + for (const argument of ctx.arguments()) { + if (argument.type === 'order' || argument.type === 'column') { + if (skip) { + skip--; + } else { + yield [argument, ctx.node]; + } + } + } + }) + .on('visitCommand', function* (): IterableIterator<[SortExpression, ESQLCommand]> {}) + .on('visitQuery', function* (ctx): IterableIterator<[SortExpression, ESQLCommand]> { + for (const command of ctx.visitCommands()) { + yield* command; + } + }) + .visitQuery(ast); +}; + +/** + * Finds the Nts sort expression that matches the predicate. + * + * @param ast The root of the AST. + * @param predicate A function that returns true if the sort expression matches + * the predicate. + * @param index The index of the sort expression to return. If not specified, + * the first sort expression that matches the predicate will be returned. + * @returns The sort expressions and sort command 2-tuple that matches the + * predicate, if any. + */ +export const findByPredicate = ( + ast: ESQLAstQueryExpression, + predicate: Predicate<[sortExpression: SortExpression, sortCommand: ESQLCommand]>, + index?: number +): [sortExpression: SortExpression, sortCommand: ESQLCommand] | undefined => { + return util.findByPredicate(list(ast, index), predicate); +}; + +/** + * Finds the Nth sort expression that matches the sort expression by column + * name. The `parts` argument allows to specify an array of nested field names. + * + * @param ast The root of the AST. + * @param parts A string or an array of strings representing the column name. + * @returns The sort expressions and sort command 2-tuple that matches the + * predicate, if any. + */ +export const find = ( + ast: ESQLAstQueryExpression, + parts: string | string[], + index: number = 0 +): [sortExpression: SortExpression, sortCommand: ESQLCommand] | undefined => { + const arrParts = typeof parts === 'string' ? [parts] : parts; + + return findByPredicate(ast, ([node]) => { + let isMatch = false; + if (node.type === 'column') { + isMatch = util.cmpArr( + node.args.map((arg) => (arg.type === 'identifier' ? arg.name : '')), + arrParts + ); + } else if (node.type === 'order') { + const columnParts = (node.args[0] as ESQLColumn)?.args; + + if (Array.isArray(columnParts)) { + isMatch = util.cmpArr( + columnParts.map((arg) => (arg.type === 'identifier' ? arg.name : '')), + arrParts + ); + } + } + + if (isMatch) { + index--; + if (index < 0) { + return true; + } + } + + return false; + }); +}; + +/** + * Removes the Nth sort expression that matches the sort expression by column + * name. The `parts` argument allows to specify an array of nested field names. + * + * @param ast The root of the AST. + * @param parts A string or an array of strings representing the column name. + * @param index The index of the sort expression to remove. + * @returns The sort expressions and sort command 2-tuple that was removed, if any. + */ +export const remove = ( + ast: ESQLAstQueryExpression, + parts: string | string[], + index?: number +): [sortExpression: SortExpression, sortCommand: ESQLCommand] | undefined => { + const tuple = find(ast, parts, index); + + if (!tuple) { + return undefined; + } + + const [node] = tuple; + const cmd = generic.commands.args.remove(ast, node); + + if (cmd) { + if (!cmd.args.length) { + generic.commands.remove(ast, cmd); + } + } + + return cmd ? tuple : undefined; +}; + +/** + * Inserts a new sort expression into the specified SORT command at the + * specified argument position. + * + * @param sortCommand The SORT command to insert the new sort expression into. + * @param template The sort expression template. + * @param index Argument position in the command argument list. + * @returns The inserted sort expression. + */ +export const insertIntoCommand = ( + sortCommand: ESQLCommand, + template: NewSortExpressionTemplate, + index?: number +): SortExpression => { + const expression = createSortExpression(template); + + generic.commands.args.insert(sortCommand, expression, index); + + return expression; +}; + +/** + * Creates a new sort expression node and inserts it into the specified SORT + * command at the specified argument position. If not sort command is found, a + * new one is created and appended to the end of the query. + * + * @param ast The root AST node. + * @param parts ES|QL column name parts. + * @param index The new column name position in command argument list. + * @param sortCommandIndex The index of the SORT command in the AST. E.g. 0 is the + * first SORT command in the AST. + * @returns The inserted column AST node. + */ +export const insertExpression = ( + ast: ESQLAstQueryExpression, + template: NewSortExpressionTemplate, + index: number = -1, + sortCommandIndex: number = 0 +): SortExpression => { + let command: ESQLCommand | undefined = getCommand(ast, sortCommandIndex); + + if (!command) { + command = Builder.command({ name: 'sort' }); + generic.commands.append(ast, command); + } + + return insertIntoCommand(command, template, index); +}; + +/** + * Inserts a new SORT command with a single sort expression as its sole argument. + * You can specify the position to insert the command at. + * + * @param ast The root of the AST. + * @param template The sort expression template. + * @param index The position to insert the sort expression at. + * @returns The inserted sort expression and the command it was inserted into. + */ +export const insertCommand = ( + ast: ESQLAstQueryExpression, + template: NewSortExpressionTemplate, + index: number = -1 +): [ESQLCommand, SortExpression] => { + const expression = createSortExpression(template); + const command = Builder.command({ name: 'sort', args: [expression] }); + + generic.commands.insert(ast, command, index); + + return [command, expression]; +}; diff --git a/packages/kbn-esql-ast/src/mutate/generic.ts b/packages/kbn-esql-ast/src/mutate/generic.ts deleted file mode 100644 index f27b0e2ae399f..0000000000000 --- a/packages/kbn-esql-ast/src/mutate/generic.ts +++ /dev/null @@ -1,287 +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", 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 { isOptionNode } from '../ast/util'; -import { Builder } from '../builder'; -import { - ESQLAstQueryExpression, - ESQLCommand, - ESQLCommandOption, - ESQLProperNode, - ESQLSingleAstItem, -} from '../types'; -import { Visitor } from '../visitor'; -import { Predicate } from './types'; - -/** - * Returns an iterator for all command AST nodes in the query. If a predicate is - * provided, only commands that satisfy the predicate will be returned. - * - * @param ast Root AST node to search for commands. - * @param predicate Optional predicate to filter commands. - * @returns A list of commands found in the AST. - */ -export const listCommands = ( - ast: ESQLAstQueryExpression, - predicate?: Predicate -): IterableIterator => { - return new Visitor() - .on('visitQuery', function* (ctx): IterableIterator { - for (const cmd of ctx.commands()) { - if (!predicate || predicate(cmd)) { - yield cmd; - } - } - }) - .visitQuery(ast); -}; - -/** - * Returns the first command AST node at a given index in the query that - * satisfies the predicate. If no index is provided, the first command found - * will be returned. - * - * @param ast Root AST node to search for commands. - * @param predicate Optional predicate to filter commands. - * @param index The index of the command to return. - * @returns The command found in the AST, if any. - */ -export const findCommand = ( - ast: ESQLAstQueryExpression, - predicate?: Predicate, - index: number = 0 -): ESQLCommand | undefined => { - for (const cmd of listCommands(ast, predicate)) { - if (!index) { - return cmd; - } - - index--; - } - - return undefined; -}; - -/** - * Returns the first command option AST node that satisfies the predicate. - * - * @param command The command AST node to search for options. - * @param predicate The predicate to filter options. - * @returns The option found in the command, if any. - */ -export const findCommandOption = ( - command: ESQLCommand, - predicate: Predicate -): ESQLCommandOption | undefined => { - return new Visitor() - .on('visitCommand', (ctx): ESQLCommandOption | undefined => { - for (const opt of ctx.options()) { - if (predicate(opt)) { - return opt; - } - } - - return undefined; - }) - .visitCommand(command); -}; - -/** - * Returns the first command AST node at a given index with a given name in the - * query. If no index is provided, the first command found will be returned. - * - * @param ast Root AST node to search for commands. - * @param commandName The name of the command to find. - * @param index The index of the command to return. - * @returns The command found in the AST, if any. - */ -export const findCommandByName = ( - ast: ESQLAstQueryExpression, - commandName: string, - index: number = 0 -): ESQLCommand | undefined => { - return findCommand(ast, (cmd) => cmd.name === commandName, index); -}; - -/** - * Returns the first command option AST node with a given name in the query. - * - * @param ast The root AST node to search for command options. - * @param commandName Command name to search for. - * @param optionName Option name to search for. - * @returns The option found in the command, if any. - */ -export const findCommandOptionByName = ( - ast: ESQLAstQueryExpression, - commandName: string, - optionName: string -): ESQLCommandOption | undefined => { - const command = findCommand(ast, (cmd) => cmd.name === commandName); - - if (!command) { - return undefined; - } - - return findCommandOption(command, (opt) => opt.name === optionName); -}; - -/** - * Adds a new command to the query AST node. - * - * @param ast The root AST node to append the command to. - * @param command The command AST node to append. - */ -export const appendCommand = (ast: ESQLAstQueryExpression, command: ESQLCommand): void => { - ast.commands.push(command); -}; - -/** - * Inserts a command option into the command's arguments list. The option can - * be specified as a string or an AST node. - * - * @param command The command AST node to insert the option into. - * @param option The option to insert. - * @returns The inserted option. - */ -export const appendCommandOption = ( - command: ESQLCommand, - option: string | ESQLCommandOption -): ESQLCommandOption => { - if (typeof option === 'string') { - option = Builder.option({ name: option }); - } - - command.args.push(option); - - return option; -}; - -export const appendCommandArgument = ( - command: ESQLCommand, - expression: ESQLSingleAstItem -): number => { - if (expression.type === 'option') { - command.args.push(expression); - return command.args.length - 1; - } - - const index = command.args.findIndex((arg) => isOptionNode(arg)); - - if (index > -1) { - command.args.splice(index, 0, expression); - return index; - } - - command.args.push(expression); - return command.args.length - 1; -}; - -export const removeCommand = (ast: ESQLAstQueryExpression, command: ESQLCommand): boolean => { - const cmds = ast.commands; - const length = cmds.length; - - for (let i = 0; i < length; i++) { - if (cmds[i] === command) { - cmds.splice(i, 1); - return true; - } - } - - return false; -}; - -/** - * Removes the first command option from the command's arguments list that - * satisfies the predicate. - * - * @param command The command AST node to remove the option from. - * @param predicate The predicate to filter options. - * @returns The removed option, if any. - */ -export const removeCommandOption = ( - ast: ESQLAstQueryExpression, - option: ESQLCommandOption -): boolean => { - return new Visitor() - .on('visitCommandOption', (ctx): boolean => { - return ctx.node === option; - }) - .on('visitCommand', (ctx): boolean => { - let target: undefined | ESQLCommandOption; - - for (const opt of ctx.options()) { - if (opt === option) { - target = opt; - break; - } - } - - if (!target) { - return false; - } - - const index = ctx.node.args.indexOf(target); - - if (index === -1) { - return false; - } - - ctx.node.args.splice(index, 1); - - return true; - }) - .on('visitQuery', (ctx): boolean => { - for (const success of ctx.visitCommands()) { - if (success) { - return true; - } - } - - return false; - }) - .visitQuery(ast); -}; - -/** - * Searches all command arguments in the query AST node and removes the node - * from the command's arguments list. - * - * @param ast The root AST node to search for command arguments. - * @param node The argument AST node to remove. - * @returns Returns true if the argument was removed, false otherwise. - */ -export const removeCommandArgument = ( - ast: ESQLAstQueryExpression, - node: ESQLProperNode -): boolean => { - return new Visitor() - .on('visitCommand', (ctx): boolean => { - const args = ctx.node.args; - const length = args.length; - - for (let i = 0; i < length; i++) { - if (args[i] === node) { - args.splice(i, 1); - return true; - } - } - - return false; - }) - .on('visitQuery', (ctx): boolean => { - for (const success of ctx.visitCommands()) { - if (success) { - return true; - } - } - - return false; - }) - .visitQuery(ast); -}; diff --git a/packages/kbn-esql-ast/src/mutate/generic/commands/args/index.test.ts b/packages/kbn-esql-ast/src/mutate/generic/commands/args/index.test.ts new file mode 100644 index 0000000000000..e687c4528dd7d --- /dev/null +++ b/packages/kbn-esql-ast/src/mutate/generic/commands/args/index.test.ts @@ -0,0 +1,132 @@ +/* + * Copyright 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 { Builder } from '../../../../builder'; +import { parse } from '../../../../parser'; +import { BasicPrettyPrinter } from '../../../../pretty_print'; +import * as generic from '../..'; + +describe('generic.commands.args', () => { + describe('.insert()', () => { + it('can insert at the end of the list', () => { + const src = 'FROM index | LIMIT 10'; + const { root } = parse(src); + const command = generic.commands.findByName(root, 'from', 0); + + generic.commands.args.insert( + command!, + Builder.expression.source({ name: 'test', sourceType: 'index' }), + 123 + ); + + const src2 = BasicPrettyPrinter.print(root); + + expect(src2).toBe('FROM index, test | LIMIT 10'); + }); + + it('can insert at the beginning of the list', () => { + const src = 'FROM index | LIMIT 10'; + const { root } = parse(src); + const command = generic.commands.findByName(root, 'from', 0); + + generic.commands.args.insert( + command!, + Builder.expression.source({ name: 'test', sourceType: 'index' }), + 0 + ); + + const src2 = BasicPrettyPrinter.print(root); + + expect(src2).toBe('FROM test, index | LIMIT 10'); + }); + + it('can insert in the middle of the list', () => { + const src = 'FROM index1, index2 | LIMIT 10'; + const { root } = parse(src); + const command = generic.commands.findByName(root, 'from', 0); + + generic.commands.args.insert( + command!, + Builder.expression.source({ name: 'test', sourceType: 'index' }), + 1 + ); + + const src2 = BasicPrettyPrinter.print(root); + + expect(src2).toBe('FROM index1, test, index2 | LIMIT 10'); + }); + + describe('with option present', () => { + it('can insert at the end of the list', () => { + const src = 'FROM index METADATA _id | LIMIT 10'; + const { root } = parse(src); + const command = generic.commands.findByName(root, 'from', 0); + + generic.commands.args.insert( + command!, + Builder.expression.source({ name: 'test', sourceType: 'index' }), + 123 + ); + + const src2 = BasicPrettyPrinter.print(root); + + expect(src2).toBe('FROM index, test METADATA _id | LIMIT 10'); + }); + + it('can insert at the beginning of the list', () => { + const src = 'FROM index METADATA _id | LIMIT 10'; + const { root } = parse(src); + const command = generic.commands.findByName(root, 'from', 0); + + generic.commands.args.insert( + command!, + Builder.expression.source({ name: 'test', sourceType: 'index' }), + 0 + ); + + const src2 = BasicPrettyPrinter.print(root); + + expect(src2).toBe('FROM test, index METADATA _id | LIMIT 10'); + }); + + it('can insert in the middle of the list', () => { + const src = 'FROM index1, index2 METADATA _id | LIMIT 10'; + const { root } = parse(src); + const command = generic.commands.findByName(root, 'from', 0); + + generic.commands.args.insert( + command!, + Builder.expression.source({ name: 'test', sourceType: 'index' }), + 1 + ); + + const src2 = BasicPrettyPrinter.print(root); + + expect(src2).toBe('FROM index1, test, index2 METADATA _id | LIMIT 10'); + }); + }); + }); + + describe('.append()', () => { + it('can append and argument', () => { + const src = 'FROM index METADATA _id | LIMIT 10'; + const { root } = parse(src); + const command = generic.commands.findByName(root, 'from', 0); + + generic.commands.args.append( + command!, + Builder.expression.source({ name: 'test', sourceType: 'index' }) + ); + + const src2 = BasicPrettyPrinter.print(root); + + expect(src2).toBe('FROM index, test METADATA _id | LIMIT 10'); + }); + }); +}); diff --git a/packages/kbn-esql-ast/src/mutate/generic/commands/args/index.ts b/packages/kbn-esql-ast/src/mutate/generic/commands/args/index.ts new file mode 100644 index 0000000000000..7072c38a5f1a8 --- /dev/null +++ b/packages/kbn-esql-ast/src/mutate/generic/commands/args/index.ts @@ -0,0 +1,86 @@ +/* + * Copyright 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 { isOptionNode } from '../../../../ast/util'; +import { + ESQLAstQueryExpression, + ESQLCommand, + ESQLProperNode, + ESQLSingleAstItem, +} from '../../../../types'; +import { Visitor } from '../../../../visitor'; + +export const insert = ( + command: ESQLCommand, + expression: ESQLSingleAstItem, + index: number = -1 +): number => { + if (expression.type === 'option') { + command.args.push(expression); + return command.args.length - 1; + } + + let mainArgumentCount = command.args.findIndex((arg) => isOptionNode(arg)); + + if (mainArgumentCount < 0) { + mainArgumentCount = command.args.length; + } + if (index === -1) { + index = mainArgumentCount; + } + if (index > mainArgumentCount) { + index = mainArgumentCount; + } + + command.args.splice(index, 0, expression); + + return mainArgumentCount + 1; +}; + +export const append = (command: ESQLCommand, expression: ESQLSingleAstItem): number => { + return insert(command, expression, -1); +}; + +/** + * Searches all command arguments in the query AST node and removes the node + * from the command's arguments list. + * + * @param ast The root AST node to search for command arguments. + * @param node The argument AST node to remove. + * @returns Returns the command that the argument was removed from, if any. + */ +export const remove = ( + ast: ESQLAstQueryExpression, + node: ESQLProperNode +): ESQLCommand | undefined => { + return new Visitor() + .on('visitCommand', (ctx): ESQLCommand | undefined => { + const args = ctx.node.args; + const length = args.length; + + for (let i = 0; i < length; i++) { + if (args[i] === node) { + args.splice(i, 1); + return ctx.node; + } + } + + return undefined; + }) + .on('visitQuery', (ctx): ESQLCommand | undefined => { + for (const cmd of ctx.visitCommands()) { + if (cmd) { + return cmd; + } + } + + return undefined; + }) + .visitQuery(ast); +}; diff --git a/packages/kbn-esql-ast/src/mutate/generic.test.ts b/packages/kbn-esql-ast/src/mutate/generic/commands/index.test.ts similarity index 54% rename from packages/kbn-esql-ast/src/mutate/generic.test.ts rename to packages/kbn-esql-ast/src/mutate/generic/commands/index.test.ts index 0109ff838ffda..b35d0b6415247 100644 --- a/packages/kbn-esql-ast/src/mutate/generic.test.ts +++ b/packages/kbn-esql-ast/src/mutate/generic/commands/index.test.ts @@ -7,26 +7,26 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { parse } from '../parser'; -import { BasicPrettyPrinter } from '../pretty_print'; -import * as generic from './generic'; +import { parse } from '../../../parser'; +import { BasicPrettyPrinter } from '../../../pretty_print'; +import * as generic from '..'; -describe('generic', () => { - describe('.listCommands()', () => { +describe('generic.commands', () => { + describe('.list()', () => { it('lists all commands', () => { const src = 'FROM index | WHERE a == b | LIMIT 123'; const { root } = parse(src); - const commands = [...generic.listCommands(root)].map((cmd) => cmd.name); + const commands = [...generic.commands.list(root)].map((cmd) => cmd.name); expect(commands).toEqual(['from', 'where', 'limit']); }); }); - describe('.findCommand()', () => { + describe('.find()', () => { it('can the first command', () => { const src = 'FROM index | WHERE a == b | LIMIT 123'; const { root } = parse(src); - const command = generic.findCommand(root, (cmd) => cmd.name === 'from'); + const command = generic.commands.find(root, (cmd) => cmd.name === 'from'); expect(command).toMatchObject({ type: 'command', @@ -42,7 +42,7 @@ describe('generic', () => { it('can the last command', () => { const src = 'FROM index | WHERE a == b | LIMIT 123'; const { root } = parse(src); - const command = generic.findCommand(root, (cmd) => cmd.name === 'limit'); + const command = generic.commands.find(root, (cmd) => cmd.name === 'limit'); expect(command).toMatchObject({ type: 'command', @@ -58,7 +58,7 @@ describe('generic', () => { it('find the specific of multiple commands', () => { const src = 'FROM index | WHERE a == b | LIMIT 1 | LIMIT 2 | LIMIT 3'; const { root } = parse(src); - const command = generic.findCommand( + const command = generic.commands.find( root, (cmd) => cmd.name === 'limit' && (cmd.args?.[0] as any).value === 2 ); @@ -76,34 +76,13 @@ describe('generic', () => { }); }); - describe('.findCommandOptionByName()', () => { - it('can the find a command option', () => { - const src = 'FROM index METADATA _score'; - const { root } = parse(src); - const option = generic.findCommandOptionByName(root, 'from', 'metadata'); - - expect(option).toMatchObject({ - type: 'option', - name: 'metadata', - }); - }); - - it('returns undefined if there is no option', () => { - const src = 'FROM index'; - const { root } = parse(src); - const option = generic.findCommandOptionByName(root, 'from', 'metadata'); - - expect(option).toBe(undefined); - }); - }); - - describe('.removeCommand()', () => { + describe('.remove()', () => { it('can remove the last command', () => { const src = 'FROM index | LIMIT 10'; const { root } = parse(src); - const command = generic.findCommandByName(root, 'limit', 0); + const command = generic.commands.findByName(root, 'limit', 0); - generic.removeCommand(root, command!); + generic.commands.remove(root, command!); const src2 = BasicPrettyPrinter.print(root); @@ -113,9 +92,9 @@ describe('generic', () => { it('can remove the second command out of 3 with the same name', () => { const src = 'FROM index | LIMIT 1 | LIMIT 2 | LIMIT 3'; const { root } = parse(src); - const command = generic.findCommandByName(root, 'limit', 1); + const command = generic.commands.findByName(root, 'limit', 1); - generic.removeCommand(root, command!); + generic.commands.remove(root, command!); const src2 = BasicPrettyPrinter.print(root); @@ -125,29 +104,15 @@ describe('generic', () => { it('can remove all commands', () => { const src = 'FROM index | WHERE a == b | LIMIT 123'; const { root } = parse(src); - const cmd1 = generic.findCommandByName(root, 'where'); - const cmd2 = generic.findCommandByName(root, 'limit'); - const cmd3 = generic.findCommandByName(root, 'from'); + const cmd1 = generic.commands.findByName(root, 'where'); + const cmd2 = generic.commands.findByName(root, 'limit'); + const cmd3 = generic.commands.findByName(root, 'from'); - generic.removeCommand(root, cmd1!); - generic.removeCommand(root, cmd2!); - generic.removeCommand(root, cmd3!); + generic.commands.remove(root, cmd1!); + generic.commands.remove(root, cmd2!); + generic.commands.remove(root, cmd3!); expect(root.commands.length).toBe(0); }); }); - - describe('.removeCommandOption()', () => { - it('can remove existing command option', () => { - const src = 'FROM index METADATA _score'; - const { root } = parse(src); - const option = generic.findCommandOptionByName(root, 'from', 'metadata'); - - generic.removeCommandOption(root, option!); - - const src2 = BasicPrettyPrinter.print(root); - - expect(src2).toBe('FROM index'); - }); - }); }); diff --git a/packages/kbn-esql-ast/src/mutate/generic/commands/index.ts b/packages/kbn-esql-ast/src/mutate/generic/commands/index.ts new file mode 100644 index 0000000000000..0582bb592edb8 --- /dev/null +++ b/packages/kbn-esql-ast/src/mutate/generic/commands/index.ts @@ -0,0 +1,131 @@ +/* + * Copyright 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 { ESQLAstQueryExpression, ESQLCommand } from '../../../types'; +import { Visitor } from '../../../visitor'; +import { Predicate } from '../../types'; + +export * as args from './args'; +export * as options from './options'; + +/** + * Returns an iterator for all command AST nodes in the query. If a predicate is + * provided, only commands that satisfy the predicate will be returned. + * + * @param ast Root AST node to search for commands. + * @param predicate Optional predicate to filter commands. + * @returns A list of commands found in the AST. + */ +export const list = ( + ast: ESQLAstQueryExpression, + predicate?: Predicate +): IterableIterator => { + return new Visitor() + .on('visitQuery', function* (ctx): IterableIterator { + for (const cmd of ctx.commands()) { + if (!predicate || predicate(cmd)) { + yield cmd; + } + } + }) + .visitQuery(ast); +}; + +/** + * Returns the first command AST node at a given index in the query that + * satisfies the predicate. If no index is provided, the first command found + * will be returned. + * + * @param ast Root AST node to search for commands. + * @param predicate Optional predicate to filter commands. + * @param index The index of the command to return. + * @returns The command found in the AST, if any. + */ +export const find = ( + ast: ESQLAstQueryExpression, + predicate?: Predicate, + index: number = 0 +): ESQLCommand | undefined => { + for (const cmd of list(ast, predicate)) { + if (!index) { + return cmd; + } + + index--; + } + + return undefined; +}; + +/** + * Returns the first command AST node at a given index with a given name in the + * query. If no index is provided, the first command found will be returned. + * + * @param ast Root AST node to search for commands. + * @param commandName The name of the command to find. + * @param index The index of the command to return. + * @returns The command found in the AST, if any. + */ +export const findByName = ( + ast: ESQLAstQueryExpression, + commandName: string, + index: number = 0 +): ESQLCommand | undefined => { + return find(ast, (cmd) => cmd.name === commandName, index); +}; + +/** + * Inserts a new command into the query AST node at the specified index. If the + * `index` is out of bounds, the command will be appended to the end of the + * command list. + * + * @param ast The root AST node. + * @param command The command AST node to insert. + * @param index The index to insert the command at. + * @returns The index the command was inserted at. + */ +export const insert = ( + ast: ESQLAstQueryExpression, + command: ESQLCommand, + index: number = Infinity +): number => { + const commands = ast.commands; + + if (index > commands.length || index < 0) { + index = commands.length; + } + + commands.splice(index, 0, command); + + return index; +}; + +/** + * Adds a new command to the query AST node. + * + * @param ast The root AST node to append the command to. + * @param command The command AST node to append. + */ +export const append = (ast: ESQLAstQueryExpression, command: ESQLCommand): void => { + ast.commands.push(command); +}; + +export const remove = (ast: ESQLAstQueryExpression, command: ESQLCommand): boolean => { + const cmds = ast.commands; + const length = cmds.length; + + for (let i = 0; i < length; i++) { + if (cmds[i] === command) { + cmds.splice(i, 1); + return true; + } + } + + return false; +}; diff --git a/packages/kbn-esql-ast/src/mutate/generic/commands/options/index.test.ts b/packages/kbn-esql-ast/src/mutate/generic/commands/options/index.test.ts new file mode 100644 index 0000000000000..00c3ee90eccdd --- /dev/null +++ b/packages/kbn-esql-ast/src/mutate/generic/commands/options/index.test.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { parse } from '../../../../parser'; +import { BasicPrettyPrinter } from '../../../../pretty_print'; +import * as generic from '../..'; + +describe('generic.commands.options', () => { + describe('.findByName()', () => { + it('can the find a command option', () => { + const src = 'FROM index METADATA _score'; + const { root } = parse(src); + const option = generic.commands.options.findByName(root, 'from', 'metadata'); + + expect(option).toMatchObject({ + type: 'option', + name: 'metadata', + }); + }); + + it('returns undefined if there is no option', () => { + const src = 'FROM index'; + const { root } = parse(src); + const option = generic.commands.options.findByName(root, 'from', 'metadata'); + + expect(option).toBe(undefined); + }); + }); + + describe('.remove()', () => { + it('can remove existing command option', () => { + const src = 'FROM index METADATA _score'; + const { root } = parse(src); + const option = generic.commands.options.findByName(root, 'from', 'metadata'); + + generic.commands.options.remove(root, option!); + + const src2 = BasicPrettyPrinter.print(root); + + expect(src2).toBe('FROM index'); + }); + }); +}); diff --git a/packages/kbn-esql-ast/src/mutate/generic/commands/options/index.ts b/packages/kbn-esql-ast/src/mutate/generic/commands/options/index.ts new file mode 100644 index 0000000000000..b9b2bac452e31 --- /dev/null +++ b/packages/kbn-esql-ast/src/mutate/generic/commands/options/index.ts @@ -0,0 +1,130 @@ +/* + * Copyright 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 { Builder } from '../../../../builder'; +import { ESQLAstQueryExpression, ESQLCommand, ESQLCommandOption } from '../../../../types'; +import { Visitor } from '../../../../visitor'; +import { Predicate } from '../../../types'; +import * as commands from '..'; + +/** + * Returns the first command option AST node that satisfies the predicate. + * + * @param command The command AST node to search for options. + * @param predicate The predicate to filter options. + * @returns The option found in the command, if any. + */ +export const find = ( + command: ESQLCommand, + predicate: Predicate +): ESQLCommandOption | undefined => { + return new Visitor() + .on('visitCommand', (ctx): ESQLCommandOption | undefined => { + for (const opt of ctx.options()) { + if (predicate(opt)) { + return opt; + } + } + + return undefined; + }) + .visitCommand(command); +}; + +/** + * Returns the first command option AST node with a given name in the query. + * + * @param ast The root AST node to search for command options. + * @param commandName Command name to search for. + * @param optionName Option name to search for. + * @returns The option found in the command, if any. + */ +export const findByName = ( + ast: ESQLAstQueryExpression, + commandName: string, + optionName: string +): ESQLCommandOption | undefined => { + const command = commands.find(ast, (cmd) => cmd.name === commandName); + + if (!command) { + return undefined; + } + + return find(command, (opt) => opt.name === optionName); +}; + +/** + * Inserts a command option into the command's arguments list. The option can + * be specified as a string or an AST node. + * + * @param command The command AST node to insert the option into. + * @param option The option to insert. + * @returns The inserted option. + */ +export const append = ( + command: ESQLCommand, + option: string | ESQLCommandOption +): ESQLCommandOption => { + if (typeof option === 'string') { + option = Builder.option({ name: option }); + } + + command.args.push(option); + + return option; +}; + +/** + * Removes the first command option from the command's arguments list that + * satisfies the predicate. + * + * @param command The command AST node to remove the option from. + * @param predicate The predicate to filter options. + * @returns The removed option, if any. + */ +export const remove = (ast: ESQLAstQueryExpression, option: ESQLCommandOption): boolean => { + return new Visitor() + .on('visitCommandOption', (ctx): boolean => { + return ctx.node === option; + }) + .on('visitCommand', (ctx): boolean => { + let target: undefined | ESQLCommandOption; + + for (const opt of ctx.options()) { + if (opt === option) { + target = opt; + break; + } + } + + if (!target) { + return false; + } + + const index = ctx.node.args.indexOf(target); + + if (index === -1) { + return false; + } + + ctx.node.args.splice(index, 1); + + return true; + }) + .on('visitQuery', (ctx): boolean => { + for (const success of ctx.visitCommands()) { + if (success) { + return true; + } + } + + return false; + }) + .visitQuery(ast); +}; diff --git a/src/plugins/discover/public/context_awareness/profile_providers/example/example_root_pofile/index.ts b/packages/kbn-esql-ast/src/mutate/generic/index.ts similarity index 88% rename from src/plugins/discover/public/context_awareness/profile_providers/example/example_root_pofile/index.ts rename to packages/kbn-esql-ast/src/mutate/generic/index.ts index 0c13a49d17d7a..e7f26b9340af7 100644 --- a/src/plugins/discover/public/context_awareness/profile_providers/example/example_root_pofile/index.ts +++ b/packages/kbn-esql-ast/src/mutate/generic/index.ts @@ -7,4 +7,4 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { createExampleRootProfileProvider } from './profile'; +export * as commands from './commands'; diff --git a/packages/kbn-esql-ast/src/parser/__tests__/columns.test.ts b/packages/kbn-esql-ast/src/parser/__tests__/columns.test.ts index 38e98104d41bd..f235005a2c10f 100644 --- a/packages/kbn-esql-ast/src/parser/__tests__/columns.test.ts +++ b/packages/kbn-esql-ast/src/parser/__tests__/columns.test.ts @@ -10,21 +10,86 @@ import { parse } from '..'; describe('Column Identifier Expressions', () => { + it('can parse star column as function argument', () => { + const text = 'ROW fn(*)'; + const { root } = parse(text); + + expect(root.commands).toMatchObject([ + { + type: 'command', + name: 'row', + args: [ + { + type: 'function', + name: 'fn', + args: [ + { + type: 'column', + args: [ + { + type: 'identifier', + name: '*', + }, + ], + }, + ], + }, + ], + }, + ]); + }); + + it('can parse a single identifier', () => { + const text = 'ROW hello'; + const { root } = parse(text); + + expect(root.commands).toMatchObject([ + { + type: 'command', + args: [ + { + type: 'column', + args: [ + { + type: 'identifier', + name: 'hello', + }, + ], + }, + ], + }, + ]); + }); + it('can parse un-quoted identifiers', () => { const text = 'ROW a, b.c'; - const { ast } = parse(text); + const { root } = parse(text); - expect(ast).toMatchObject([ + expect(root.commands).toMatchObject([ { type: 'command', args: [ { type: 'column', - parts: ['a'], + args: [ + { + type: 'identifier', + name: 'a', + }, + ], }, { type: 'column', - parts: ['b', 'c'], + args: [ + { + type: 'identifier', + name: 'b', + }, + { + type: 'identifier', + name: 'c', + }, + ], }, ], }, @@ -33,23 +98,50 @@ describe('Column Identifier Expressions', () => { it('can parse quoted identifiers', () => { const text = 'ROW `a`, `b`.c, `d`.`👍`.`123``123`'; - const { ast } = parse(text); + const { root } = parse(text); - expect(ast).toMatchObject([ + expect(root.commands).toMatchObject([ { type: 'command', args: [ { type: 'column', - parts: ['a'], + args: [ + { + type: 'identifier', + name: 'a', + }, + ], }, { type: 'column', - parts: ['b', 'c'], + args: [ + { + type: 'identifier', + name: 'b', + }, + { + type: 'identifier', + name: 'c', + }, + ], }, { type: 'column', - parts: ['d', '👍', '123`123'], + args: [ + { + type: 'identifier', + name: 'd', + }, + { + type: 'identifier', + name: '👍', + }, + { + type: 'identifier', + name: '123`123', + }, + ], }, ], }, @@ -58,15 +150,28 @@ describe('Column Identifier Expressions', () => { it('can mix quoted and un-quoted identifiers', () => { const text = 'ROW part1.part2.`part``3️⃣`'; - const { ast } = parse(text); + const { root } = parse(text); - expect(ast).toMatchObject([ + expect(root.commands).toMatchObject([ { type: 'command', args: [ { type: 'column', - parts: ['part1', 'part2', 'part`3️⃣'], + args: [ + { + type: 'identifier', + name: 'part1', + }, + { + type: 'identifier', + name: 'part2', + }, + { + type: 'identifier', + name: 'part`3️⃣', + }, + ], }, ], }, @@ -75,19 +180,189 @@ describe('Column Identifier Expressions', () => { it('in KEEP command', () => { const text = 'FROM a | KEEP a.b'; - const { ast } = parse(text); + const { root } = parse(text); - expect(ast).toMatchObject([ + expect(root.commands).toMatchObject([ {}, { type: 'command', args: [ { type: 'column', - parts: ['a', 'b'], + args: [ + { + type: 'identifier', + name: 'a', + }, + { + type: 'identifier', + name: 'b', + }, + ], }, ], }, ]); }); + + describe('params', () => { + it('can parse named param as a single param node', () => { + const text = 'ROW ?test'; + const { root } = parse(text); + + expect(root.commands).toMatchObject([ + { + type: 'command', + args: [ + { + type: 'literal', + literalType: 'param', + paramType: 'named', + value: 'test', + }, + ], + }, + ]); + }); + + it('can parse nested named params as column', () => { + const text = 'ROW ?test1.?test2'; + const { root } = parse(text); + + expect(root.commands).toMatchObject([ + { + type: 'command', + args: [ + { + type: 'column', + args: [ + { + type: 'literal', + literalType: 'param', + paramType: 'named', + value: 'test1', + }, + { + type: 'literal', + literalType: 'param', + paramType: 'named', + value: 'test2', + }, + ], + }, + ], + }, + ]); + }); + + it('can mix param and identifier in column name', () => { + const text = 'ROW ?par.id'; + const { root } = parse(text); + + expect(root.commands).toMatchObject([ + { + type: 'command', + args: [ + { + type: 'column', + args: [ + { + type: 'literal', + literalType: 'param', + paramType: 'named', + value: 'par', + }, + { + type: 'identifier', + name: 'id', + }, + ], + }, + ], + }, + ]); + }); + + it('can mix param and identifier in column name - 2', () => { + const text = 'ROW `😱`.?par'; + const { root } = parse(text); + + expect(root.commands).toMatchObject([ + { + type: 'command', + args: [ + { + type: 'column', + args: [ + { + type: 'identifier', + name: '😱', + }, + { + type: 'literal', + literalType: 'param', + paramType: 'named', + value: 'par', + }, + ], + }, + ], + }, + ]); + }); + + it('supports all three different param types', () => { + const text = 'ROW ?.?name.?123'; + const { root } = parse(text); + + expect(root.commands).toMatchObject([ + { + type: 'command', + args: [ + { + type: 'column', + args: [ + { + type: 'literal', + literalType: 'param', + paramType: 'unnamed', + }, + { + type: 'literal', + literalType: 'param', + paramType: 'named', + value: 'name', + }, + { + type: 'literal', + literalType: 'param', + paramType: 'positional', + value: 123, + }, + ], + }, + ], + }, + ]); + }); + + it('parses DROP command args as "column" nodes', () => { + const text = 'FROM index | DROP any#Char$Field'; + const { root } = parse(text); + + expect(root.commands).toMatchObject([ + { type: 'command' }, + { + type: 'command', + name: 'drop', + args: [ + { + type: 'column', + name: 'any', + }, + ], + }, + ]); + }); + }); }); diff --git a/packages/kbn-esql-ast/src/parser/__tests__/function.test.ts b/packages/kbn-esql-ast/src/parser/__tests__/function.test.ts index 9d822f78f9333..486feae97f98c 100644 --- a/packages/kbn-esql-ast/src/parser/__tests__/function.test.ts +++ b/packages/kbn-esql-ast/src/parser/__tests__/function.test.ts @@ -8,6 +8,7 @@ */ import { parse } from '..'; +import { EsqlQuery } from '../../query'; import { Walker } from '../../walker'; describe('function AST nodes', () => { @@ -69,6 +70,103 @@ describe('function AST nodes', () => { }, ]); }); + + it('parses out function name as identifier node', () => { + const query = 'ROW fn(1, 2, 3)'; + const { ast, errors } = parse(query); + + expect(errors.length).toBe(0); + expect(ast).toMatchObject([ + { + type: 'command', + name: 'row', + args: [ + { + type: 'function', + name: 'fn', + operator: { + type: 'identifier', + name: 'fn', + }, + }, + ], + }, + ]); + }); + + it('parses out function name as named param', () => { + const query = 'ROW ?insert_here(1, 2, 3)'; + const { ast, errors } = parse(query); + + expect(errors.length).toBe(0); + expect(ast).toMatchObject([ + { + type: 'command', + name: 'row', + args: [ + { + type: 'function', + name: '?insert_here', + operator: { + type: 'literal', + literalType: 'param', + paramType: 'named', + value: 'insert_here', + }, + }, + ], + }, + ]); + }); + + it('parses out function name as unnamed param', () => { + const query = 'ROW ?(1, 2, 3)'; + const { ast, errors } = parse(query); + + expect(errors.length).toBe(0); + expect(ast).toMatchObject([ + { + type: 'command', + name: 'row', + args: [ + { + type: 'function', + name: '?', + operator: { + type: 'literal', + literalType: 'param', + paramType: 'unnamed', + }, + }, + ], + }, + ]); + }); + + it('parses out function name as positional param', () => { + const query = 'ROW ?30035(1, 2, 3)'; + const { ast, errors } = parse(query); + + expect(errors.length).toBe(0); + expect(ast).toMatchObject([ + { + type: 'command', + name: 'row', + args: [ + { + type: 'function', + name: '?30035', + operator: { + type: 'literal', + literalType: 'param', + paramType: 'positional', + value: 30035, + }, + }, + ], + }, + ]); + }); }); describe('"unary-expression"', () => { @@ -226,3 +324,86 @@ describe('function AST nodes', () => { }); }); }); + +describe('location', () => { + const getFunctionTexts = (src: string) => { + const query = EsqlQuery.fromSrc(src); + const functions = Walker.matchAll(query.ast, { type: 'function' }); + const texts: string[] = functions.map((fn) => { + return [...src].slice(fn.location.min, fn.location.max + 1).join(''); + }); + + return texts; + }; + + it('correctly cuts out function source texts', () => { + const texts = getFunctionTexts( + 'FROM index | LIMIT 1 | STATS agg() | LIMIT 2 | STATS max(a, b, c), max2(d.e)' + ); + + expect(texts).toEqual(['agg()', 'max(a, b, c)', 'max2(d.e)']); + }); + + it('functions in binary expressions', () => { + const texts = getFunctionTexts('FROM index | STATS foo = agg(f1) + agg(f2), a.b = agg(f3)'); + + expect(texts).toEqual([ + 'foo = agg(f1) + agg(f2)', + 'agg(f1) + agg(f2)', + 'agg(f1)', + 'agg(f2)', + 'a.b = agg(f3)', + 'agg(f3)', + ]); + }); + + it('with the simplest comment after function name identifier', () => { + const texts1 = getFunctionTexts('FROM index | STATS agg/* */(1)'); + expect(texts1).toEqual(['agg/* */(1)']); + + const texts2 = getFunctionTexts('FROM index | STATS agg/* A */(a)'); + expect(texts2).toEqual(['agg/* A */(a)']); + + const texts3 = getFunctionTexts('FROM index | STATS agg /* A */ (*)'); + expect(texts3).toEqual(['agg /* A */ (*)']); + }); + + it('with the simplest emoji comment after function name identifier', () => { + const texts = getFunctionTexts('FROM index | STATS agg/* 😎 */(*)'); + expect(texts).toEqual(['agg/* 😎 */(*)']); + }); + + it('with the simplest emoji comment after function name identifier, followed by another arg', () => { + const texts = getFunctionTexts('FROM index | STATS agg/* 😎 */(*), abc'); + expect(texts).toEqual(['agg/* 😎 */(*)']); + }); + + it('simple emoji comment twice', () => { + const texts = getFunctionTexts('FROM index | STATS agg/* 😎 */(*), max/* 😎 */(*)'); + expect(texts).toEqual(['agg/* 😎 */(*)', 'max/* 😎 */(*)']); + }); + + it('with comment and emoji after function name identifier', () => { + const texts = getFunctionTexts('FROM index | STATS agg /* haha 😅 */ (*)'); + + expect(texts).toEqual(['agg /* haha 😅 */ (*)']); + }); + + it('with comment inside argument list', () => { + const texts = getFunctionTexts('FROM index | STATS agg ( /* haha 😅 */ )'); + + expect(texts).toEqual(['agg ( /* haha 😅 */ )']); + }); + + it('with emoji and comment in argument lists', () => { + const texts = getFunctionTexts( + 'FROM index | STATS agg( /* haha 😅 */ max(foo), bar, baz), test( /* asdf */ * /* asdf */)' + ); + + expect(texts).toEqual([ + 'agg( /* haha 😅 */ max(foo), bar, baz)', + 'max(foo)', + 'test( /* asdf */ * /* asdf */)', + ]); + }); +}); diff --git a/packages/kbn-esql-ast/src/parser/factories.ts b/packages/kbn-esql-ast/src/parser/factories.ts index 0fffb3a970e4c..311dcced8a617 100644 --- a/packages/kbn-esql-ast/src/parser/factories.ts +++ b/packages/kbn-esql-ast/src/parser/factories.ts @@ -11,7 +11,13 @@ * In case of changes in the grammar, this script should be updated: esql_update_ast_script.js */ -import type { Token, ParserRuleContext, TerminalNode, RecognitionException } from 'antlr4'; +import type { + Token, + ParserRuleContext, + TerminalNode, + RecognitionException, + ParseTree, +} from 'antlr4'; import { IndexPatternContext, QualifiedNameContext, @@ -21,6 +27,11 @@ import { type IntegerValueContext, type QualifiedIntegerLiteralContext, QualifiedNamePatternContext, + FunctionContext, + IdentifierContext, + InputParamContext, + InputNamedOrPositionalParamContext, + IdentifierOrParameterContext, } from '../antlr/esql_parser'; import { DOUBLE_TICKS_REGEX, SINGLE_BACKTICK, TICKS_REGEX } from './constants'; import type { @@ -42,6 +53,8 @@ import type { ESQLNumericLiteral, ESQLOrderExpression, InlineCastingType, + ESQLFunctionCallExpression, + ESQLIdentifier, } from '../types'; import { parseIdentifier, getPosition } from './helpers'; import { Builder, type AstNodeParserFields } from '../builder'; @@ -201,22 +214,80 @@ export function createFunction( return node; } +export const createFunctionCall = (ctx: FunctionContext): ESQLFunctionCallExpression => { + const functionExpressionCtx = ctx.functionExpression(); + const functionName = functionExpressionCtx.functionName(); + const node: ESQLFunctionCallExpression = { + type: 'function', + subtype: 'variadic-call', + name: functionName.getText().toLowerCase(), + text: ctx.getText(), + location: getPosition(ctx.start, ctx.stop), + args: [], + incomplete: Boolean(ctx.exception), + }; + + const identifierOrParameter = functionName.identifierOrParameter(); + + if (identifierOrParameter instanceof IdentifierOrParameterContext) { + const operator = createIdentifierOrParam(identifierOrParameter); + + if (operator) { + node.operator = operator; + } + } + + return node; +}; + +export const createIdentifierOrParam = (ctx: IdentifierOrParameterContext) => { + const identifier = ctx.identifier(); + if (identifier) { + return createIdentifier(identifier); + } else { + const parameter = ctx.parameter(); + if (parameter) { + return createParam(parameter); + } + } +}; + +export const createIdentifier = (identifier: IdentifierContext): ESQLIdentifier => { + const text = identifier.getText(); + const name = parseIdentifier(text); + + return Builder.identifier({ name }, createParserFields(identifier)); +}; + +export const createParam = (ctx: ParseTree) => { + if (ctx instanceof InputParamContext) { + return Builder.param.unnamed(createParserFields(ctx)); + } else if (ctx instanceof InputNamedOrPositionalParamContext) { + const text = ctx.getText(); + const value = text.slice(1); + const valueAsNumber = Number(value); + const isPositional = String(valueAsNumber) === value; + const parserFields = createParserFields(ctx); + + if (isPositional) { + return Builder.param.positional({ value: valueAsNumber }, parserFields); + } else { + return Builder.param.named({ value }, parserFields); + } + } +}; + export const createOrderExpression = ( ctx: ParserRuleContext, - arg: ESQLAstItem, + arg: ESQLColumn, order: ESQLOrderExpression['order'], nulls: ESQLOrderExpression['nulls'] ) => { - const node: ESQLOrderExpression = { - type: 'order', - name: '', - order, - nulls, - args: [arg], - text: ctx.getText(), - location: getPosition(ctx.start, ctx.stop), - incomplete: Boolean(ctx.exception), - }; + const node = Builder.expression.order( + arg as ESQLColumn, + { order, nulls }, + createParserFields(ctx) + ); return node; }; @@ -412,42 +483,68 @@ export function createSource( export function createColumnStar(ctx: TerminalNode): ESQLColumn { const text = ctx.getText(); - - return { - type: 'column', - name: text, - parts: [text], + const parserFields = { text, location: getPosition(ctx.symbol), incomplete: ctx.getText() === '', quoted: false, }; + const node = Builder.expression.column( + { args: [Builder.identifier({ name: '*' }, parserFields)] }, + parserFields + ); + + node.name = text; + + return node; } export function createColumn(ctx: ParserRuleContext): ESQLColumn { - const parts: string[] = []; + const args: ESQLColumn['args'] = []; + if (ctx instanceof QualifiedNamePatternContext) { - parts.push( - ...ctx.identifierPattern_list().map((identifier) => parseIdentifier(identifier.getText())) - ); + const list = ctx.identifierPattern_list(); + + for (const identifier of list) { + const name = parseIdentifier(identifier.getText()); + const node = Builder.identifier({ name }, createParserFields(identifier)); + + args.push(node); + } } else if (ctx instanceof QualifiedNameContext) { - parts.push( - ...ctx.identifierOrParameter_list().map((identifier) => parseIdentifier(identifier.getText())) - ); + const list = ctx.identifierOrParameter_list(); + + for (const item of list) { + if (item instanceof IdentifierOrParameterContext) { + const node = createIdentifierOrParam(item); + + if (node) { + args.push(node); + } + } + } } else { - parts.push(sanitizeIdentifierString(ctx)); + const name = sanitizeIdentifierString(ctx); + const node = Builder.identifier({ name }, createParserFields(ctx)); + + args.push(node); } + const text = sanitizeIdentifierString(ctx); const hasQuotes = Boolean(getQuotedText(ctx) || isQuoted(ctx.getText())); - return { - type: 'column' as const, - name: text, - parts, - text: ctx.getText(), - location: getPosition(ctx.start, ctx.stop), - incomplete: Boolean(ctx.exception || text === ''), - quoted: hasQuotes, - }; + const column = Builder.expression.column( + { args }, + { + text: ctx.getText(), + location: getPosition(ctx.start, ctx.stop), + incomplete: Boolean(ctx.exception || text === ''), + } + ); + + column.name = text; + column.quoted = hasQuotes; + + return column; } export function createOption(name: string, ctx: ParserRuleContext): ESQLCommandOption { diff --git a/packages/kbn-esql-ast/src/parser/formatting.ts b/packages/kbn-esql-ast/src/parser/formatting.ts index 492e8a76ddeac..f7c556da63008 100644 --- a/packages/kbn-esql-ast/src/parser/formatting.ts +++ b/packages/kbn-esql-ast/src/parser/formatting.ts @@ -173,6 +173,10 @@ const attachCommentDecoration = ( ) => { const commentConsumesWholeLine = !comment.hasContentToLeft && !comment.hasContentToRight; + if (!comment.node.location) { + return; + } + if (commentConsumesWholeLine) { const node = Visitor.findNodeAtOrAfter(ast, comment.node.location.max - 1); diff --git a/packages/kbn-esql-ast/src/parser/helpers.ts b/packages/kbn-esql-ast/src/parser/helpers.ts index f11cb396f2980..528176684418f 100644 --- a/packages/kbn-esql-ast/src/parser/helpers.ts +++ b/packages/kbn-esql-ast/src/parser/helpers.ts @@ -41,17 +41,16 @@ export const formatIdentifierParts = (parts: string[]): string => parts.map(formatIdentifier).join('.'); export const getPosition = ( - token: Pick | null, - lastToken?: Pick | undefined + start: Pick | null, + stop?: Pick | undefined ) => { - if (!token || token.start < 0) { + if (!start || start.start < 0) { return { min: 0, max: 0 }; } - const endFirstToken = token.stop > -1 ? Math.max(token.stop + 1, token.start) : undefined; - const endLastToken = lastToken?.stop; + const endFirstToken = start.stop > -1 ? Math.max(start.stop + 1, start.start) : undefined; return { - min: token.start, - max: endLastToken ?? endFirstToken ?? Infinity, + min: start.start, + max: stop?.stop ?? endFirstToken ?? Infinity, }; }; diff --git a/packages/kbn-esql-ast/src/parser/walkers.ts b/packages/kbn-esql-ast/src/parser/walkers.ts index df10161f68bf8..60d69a17bb1c7 100644 --- a/packages/kbn-esql-ast/src/parser/walkers.ts +++ b/packages/kbn-esql-ast/src/parser/walkers.ts @@ -60,8 +60,6 @@ import { type ValueExpressionContext, ValueExpressionDefaultContext, InlineCastContext, - InputNamedOrPositionalParamContext, - InputParamContext, IndexPatternContext, InlinestatsCommandContext, } from '../antlr/esql_parser'; @@ -86,8 +84,9 @@ import { createInlineCast, createUnknownItem, createOrderExpression, + createFunctionCall, + createParam, } from './factories'; -import { getPosition } from './helpers'; import { ESQLLiteral, @@ -97,9 +96,6 @@ import { ESQLAstItem, ESQLAstField, ESQLInlineCast, - ESQLUnnamedParamLiteral, - ESQLPositionalParamLiteral, - ESQLNamedParamLiteral, ESQLOrderExpression, } from '../types'; import { firstItem, lastItem } from '../visitor/utils'; @@ -390,50 +386,8 @@ function getConstant(ctx: ConstantContext): ESQLAstItem { const values: ESQLLiteral[] = []; for (const child of ctx.children) { - if (child instanceof InputParamContext) { - const literal: ESQLUnnamedParamLiteral = { - type: 'literal', - literalType: 'param', - paramType: 'unnamed', - text: ctx.getText(), - name: '', - value: '', - location: getPosition(ctx.start, ctx.stop), - incomplete: Boolean(ctx.exception), - }; - values.push(literal); - } else if (child instanceof InputNamedOrPositionalParamContext) { - const text = child.getText(); - const value = text.slice(1); - const valueAsNumber = Number(value); - const isPositional = String(valueAsNumber) === value; - - if (isPositional) { - const literal: ESQLPositionalParamLiteral = { - type: 'literal', - literalType: 'param', - paramType: 'positional', - value: valueAsNumber, - text, - name: '', - location: getPosition(ctx.start, ctx.stop), - incomplete: Boolean(ctx.exception), - }; - values.push(literal); - } else { - const literal: ESQLNamedParamLiteral = { - type: 'literal', - literalType: 'param', - paramType: 'named', - value, - text, - name: '', - location: getPosition(ctx.start, ctx.stop), - incomplete: Boolean(ctx.exception), - }; - values.push(literal); - } - } + const param = createParam(child); + if (param) values.push(param); } return values; @@ -478,15 +432,7 @@ export function visitPrimaryExpression(ctx: PrimaryExpressionContext): ESQLAstIt } if (ctx instanceof FunctionContext) { const functionExpressionCtx = ctx.functionExpression(); - const functionNameContext = functionExpressionCtx.functionName().MATCH() - ? functionExpressionCtx.functionName().MATCH() - : functionExpressionCtx.functionName().identifierOrParameter(); - const fn = createFunction( - functionNameContext.getText().toLowerCase(), - ctx, - undefined, - 'variadic-call' - ); + const fn = createFunctionCall(ctx); const asteriskArg = functionExpressionCtx.ASTERISK() ? createColumnStar(functionExpressionCtx.ASTERISK()!) : undefined; @@ -671,7 +617,7 @@ const visitOrderExpression = (ctx: OrderExpressionContext): ESQLOrderExpression return arg; } - return createOrderExpression(ctx, arg, order, nulls); + return createOrderExpression(ctx, arg as ESQLColumn, order, nulls); }; export function visitOrderExpressions( diff --git a/packages/kbn-esql-ast/src/pretty_print/leaf_printer.ts b/packages/kbn-esql-ast/src/pretty_print/leaf_printer.ts index 3c12de90e4454..b413234cbe263 100644 --- a/packages/kbn-esql-ast/src/pretty_print/leaf_printer.ts +++ b/packages/kbn-esql-ast/src/pretty_print/leaf_printer.ts @@ -12,6 +12,7 @@ import { ESQLAstCommentMultiLine, ESQLColumn, ESQLLiteral, + ESQLParamLiteral, ESQLSource, ESQLTimeInterval, } from '../types'; @@ -27,20 +28,37 @@ export const LeafPrinter = { source: (node: ESQLSource) => node.name, column: (node: ESQLColumn) => { - const parts: string[] = node.parts; + const args = node.args; let formatted = ''; - for (const part of parts) { - if (formatted.length > 0) { - formatted += '.'; - } - if (regexUnquotedIdPattern.test(part)) { - formatted += part; - } else { - // Escape backticks "`" with double backticks "``". - const escaped = part.replace(/`/g, '``'); - formatted += '`' + escaped + '`'; + for (const arg of args) { + switch (arg.type) { + case 'identifier': { + const name = arg.name; + + if (formatted.length > 0) { + formatted += '.'; + } + if (regexUnquotedIdPattern.test(name)) { + formatted += name; + } else { + // Escape backticks "`" with double backticks "``". + const escaped = name.replace(/`/g, '``'); + formatted += '`' + escaped + '`'; + } + + break; + } + case 'literal': { + if (formatted.length > 0) { + formatted += '.'; + } + + formatted += LeafPrinter.literal(arg); + + break; + } } } @@ -56,13 +74,7 @@ export const LeafPrinter = { return String(node.value).toUpperCase() === 'TRUE' ? 'TRUE' : 'FALSE'; } case 'param': { - switch (node.paramType) { - case 'named': - case 'positional': - return '?' + node.value; - default: - return '?'; - } + return LeafPrinter.param(node); } case 'keyword': { return String(node.value); @@ -82,6 +94,16 @@ export const LeafPrinter = { } }, + param: (node: ESQLParamLiteral) => { + switch (node.paramType) { + case 'named': + case 'positional': + return '?' + node.value; + default: + return '?'; + } + }, + timeInterval: (node: ESQLTimeInterval) => { const { quantity, unit } = node; diff --git a/packages/kbn-esql-ast/src/types.ts b/packages/kbn-esql-ast/src/types.ts index 0df75ee2e8f24..2a8513fc2ced1 100644 --- a/packages/kbn-esql-ast/src/types.ts +++ b/packages/kbn-esql-ast/src/types.ts @@ -26,6 +26,7 @@ export type ESQLSingleAstItem = | ESQLTimeInterval | ESQLList | ESQLLiteral + | ESQLIdentifier | ESQLCommandMode | ESQLInlineCast | ESQLOrderExpression @@ -132,6 +133,11 @@ export interface ESQLFunction< */ subtype?: Subtype; + /** + * A node representing the function or operator being called. + */ + operator?: ESQLIdentifier | ESQLParamLiteral; + args: ESQLAstItem[]; } @@ -270,6 +276,15 @@ export interface ESQLSource extends ESQLAstBaseItem { export interface ESQLColumn extends ESQLAstBaseItem { type: 'column'; + /** + * A ES|QL column name can be composed of multiple parts, + * e.g: part1.part2.`part``3️⃣`.?param. Where parts can be quoted, or not + * quoted, or even be a parameter. + * + * The args list contains the parts of the column name. + */ + args: Array; + /** * An identifier can be composed of multiple parts, e.g: part1.part2.`part``3️⃣`. * This property contains the parsed unquoted parts of the identifier. @@ -363,6 +378,10 @@ export interface ESQLNamedParamLiteral extends ESQLParamLiteral<'named'> { value: string; } +export interface ESQLIdentifier extends ESQLAstBaseItem { + type: 'identifier'; +} + export const isESQLNamedParamLiteral = (node: ESQLAstItem): node is ESQLNamedParamLiteral => isESQLAstBaseItem(node) && (node as ESQLNamedParamLiteral).literalType === 'param' && @@ -376,6 +395,11 @@ export interface ESQLPositionalParamLiteral extends ESQLParamLiteral<'positional value: number; } +export type ESQLParam = + | ESQLUnnamedParamLiteral + | ESQLNamedParamLiteral + | ESQLPositionalParamLiteral; + export interface ESQLMessage { type: 'error' | 'warning'; text: string; @@ -404,7 +428,7 @@ export interface ESQLAstGenericComment; diff --git a/packages/kbn-esql-ast/src/walker/walker.test.ts b/packages/kbn-esql-ast/src/walker/walker.test.ts index 980e1499e62aa..49c50a0f7fa5d 100644 --- a/packages/kbn-esql-ast/src/walker/walker.test.ts +++ b/packages/kbn-esql-ast/src/walker/walker.test.ts @@ -812,6 +812,112 @@ describe('Walker.params()', () => { }, ]); }); + + test('can collect params from column names', () => { + const query = 'ROW ?a.?b'; + const { ast } = parse(query); + const params = Walker.params(ast); + + expect(params).toMatchObject([ + { + type: 'literal', + literalType: 'param', + paramType: 'named', + value: 'a', + }, + { + type: 'literal', + literalType: 'param', + paramType: 'named', + value: 'b', + }, + ]); + }); + + test('can collect params from column names, where first part is not a param', () => { + const query = 'ROW a.?b'; + const { ast } = parse(query); + const params = Walker.params(ast); + + expect(params).toMatchObject([ + { + type: 'literal', + literalType: 'param', + paramType: 'named', + value: 'b', + }, + ]); + }); + + test('can collect all types of param from column name', () => { + const query = 'ROW ?.?0.?a'; + const { ast } = parse(query); + const params = Walker.params(ast); + + expect(params).toMatchObject([ + { + type: 'literal', + literalType: 'param', + paramType: 'unnamed', + }, + { + type: 'literal', + literalType: 'param', + paramType: 'positional', + value: 0, + }, + { + type: 'literal', + literalType: 'param', + paramType: 'named', + value: 'a', + }, + ]); + }); + + test('can collect params from function names', () => { + const query = 'FROM a | STATS ?lala()'; + const { ast } = parse(query); + const params = Walker.params(ast); + + expect(params).toMatchObject([ + { + type: 'literal', + literalType: 'param', + paramType: 'named', + value: 'lala', + }, + ]); + }); + + test('can collect params from function names (unnamed)', () => { + const query = 'FROM a | STATS ?()'; + const { ast } = parse(query); + const params = Walker.params(ast); + + expect(params).toMatchObject([ + { + type: 'literal', + literalType: 'param', + paramType: 'unnamed', + }, + ]); + }); + + test('can collect params from function names (positional)', () => { + const query = 'FROM a | STATS agg(test), ?123()'; + const { ast } = parse(query); + const params = Walker.params(ast); + + expect(params).toMatchObject([ + { + type: 'literal', + literalType: 'param', + paramType: 'positional', + value: 123, + }, + ]); + }); }); describe('Walker.find()', () => { diff --git a/packages/kbn-esql-ast/src/walker/walker.ts b/packages/kbn-esql-ast/src/walker/walker.ts index dbbbc3b090f29..f3b6de91649b7 100644 --- a/packages/kbn-esql-ast/src/walker/walker.ts +++ b/packages/kbn-esql-ast/src/walker/walker.ts @@ -20,6 +20,7 @@ import type { ESQLCommandMode, ESQLCommandOption, ESQLFunction, + ESQLIdentifier, ESQLInlineCast, ESQLList, ESQLLiteral, @@ -49,6 +50,7 @@ export interface WalkerOptions { visitTimeIntervalLiteral?: (node: ESQLTimeInterval) => void; visitInlineCast?: (node: ESQLInlineCast) => void; visitUnknown?: (node: ESQLUnknownItem) => void; + visitIdentifier?: (node: ESQLIdentifier) => void; /** * Called for any node type that does not have a specific visitor. @@ -346,11 +348,27 @@ export class Walker { } } + public walkColumn(node: ESQLColumn): void { + const { options } = this; + const { args } = node; + + (options.visitColumn ?? options.visitAny)?.(node); + + if (args) { + for (const value of args) { + this.walkAstItem(value); + } + } + } + public walkFunction(node: ESQLFunction): void { const { options } = this; (options.visitFunction ?? options.visitAny)?.(node); const args = node.args; const length = args.length; + + if (node.operator) this.walkAstItem(node.operator); + for (let i = 0; i < length; i++) { const arg = args[i]; this.walkAstItem(arg); @@ -393,7 +411,7 @@ export class Walker { break; } case 'column': { - (options.visitColumn ?? options.visitAny)?.(node); + this.walkColumn(node); break; } case 'literal': { @@ -412,6 +430,10 @@ export class Walker { (options.visitInlineCast ?? options.visitAny)?.(node); break; } + case 'identifier': { + (options.visitIdentifier ?? options.visitAny)?.(node); + break; + } case 'unknown': { (options.visitUnknown ?? options.visitAny)?.(node); break; diff --git a/packages/kbn-esql-editor/src/esql_editor.tsx b/packages/kbn-esql-editor/src/esql_editor.tsx index 97340dc20d422..e8ca582ac5229 100644 --- a/packages/kbn-esql-editor/src/esql_editor.tsx +++ b/packages/kbn-esql-editor/src/esql_editor.tsx @@ -336,7 +336,7 @@ export const ESQLEditor = memo(function ESQLEditor({ const sources = await memoizedSources(dataViews, core).result; return sources; }, - getFieldsFor: async ({ query: queryToExecute }: { query?: string } | undefined = {}) => { + getColumnsFor: async ({ query: queryToExecute }: { query?: string } | undefined = {}) => { if (queryToExecute) { // ES|QL with limit 0 returns only the columns and is more performant const esqlQuery = { diff --git a/packages/kbn-esql-validation-autocomplete/src/__tests__/helpers.ts b/packages/kbn-esql-validation-autocomplete/src/__tests__/helpers.ts index abac86ab0e323..2f46356acee37 100644 --- a/packages/kbn-esql-validation-autocomplete/src/__tests__/helpers.ts +++ b/packages/kbn-esql-validation-autocomplete/src/__tests__/helpers.ts @@ -56,7 +56,7 @@ export const policies = [ export function getCallbackMocks() { return { - getFieldsFor: jest.fn(async ({ query }) => { + getColumnsFor: jest.fn(async ({ query }) => { if (/enrich/.test(query)) { return enrichFields; } diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.stats.test.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.stats.test.ts index b3884f5cb96be..829c12f7dabba 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.stats.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.stats.test.ts @@ -9,8 +9,8 @@ import { FieldType, FunctionReturnType } from '../../definitions/types'; import { ESQL_COMMON_NUMERIC_TYPES, ESQL_NUMBER_TYPES } from '../../shared/esql_types'; +import { getDateHistogramCompletionItem } from '../commands/stats/util'; import { allStarConstant } from '../complete_items'; -import { getAddDateHistogramSnippet } from '../factories'; import { roundParameterTypes } from './constants'; import { setup, @@ -71,7 +71,7 @@ describe('autocomplete.suggest', () => { test('on space after aggregate field', async () => { const { assertSuggestions } = await setup(); - await assertSuggestions('from a | stats a=min(b) /', ['BY $0', ',', '| ']); + await assertSuggestions('from a | stats a=min(b) /', ['BY ', ', ', '| ']); }); test('on space after aggregate field with comma', async () => { @@ -184,7 +184,7 @@ describe('autocomplete.suggest', () => { test('when typing right paren', async () => { const { assertSuggestions } = await setup(); - await assertSuggestions('from a | stats a = min(b)/ | sort b', ['BY $0', ',', '| ']); + await assertSuggestions('from a | stats a = min(b)/ | sort b', ['BY ', ', ', '| ']); }); test('increments suggested variable name counter', async () => { @@ -192,9 +192,8 @@ describe('autocomplete.suggest', () => { await assertSuggestions('from a | eval var0=round(b), var1=round(c) | stats /', [ 'var2 = ', + // TODO verify that this change is ok ...allAggFunctions, - 'var0', - 'var1', ...allEvaFunctions, ]); await assertSuggestions('from a | stats var0=min(b),var1=c,/', [ @@ -210,7 +209,7 @@ describe('autocomplete.suggest', () => { const { assertSuggestions } = await setup(); const expected = [ 'var0 = ', - getAddDateHistogramSnippet(), + getDateHistogramCompletionItem(), ...getFieldNamesByType('any').map((field) => `${field} `), ...allEvaFunctions, ...allGroupingFunctions, @@ -224,7 +223,7 @@ describe('autocomplete.suggest', () => { test('on space after grouping field', async () => { const { assertSuggestions } = await setup(); - await assertSuggestions('from a | stats a=c by d /', [',', '| ']); + await assertSuggestions('from a | stats a=c by d /', [', ', '| ']); }); test('after comma "," in grouping fields', async () => { @@ -233,7 +232,7 @@ describe('autocomplete.suggest', () => { const fields = getFieldNamesByType('any').map((field) => `${field} `); await assertSuggestions('from a | stats a=c by d, /', [ 'var0 = ', - getAddDateHistogramSnippet(), + getDateHistogramCompletionItem(), ...fields, ...allEvaFunctions, ...allGroupingFunctions, @@ -245,7 +244,7 @@ describe('autocomplete.suggest', () => { ]); await assertSuggestions('from a | stats avg(b) by c, /', [ 'var0 = ', - getAddDateHistogramSnippet(), + getDateHistogramCompletionItem(), ...fields, ...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }), ...allGroupingFunctions, @@ -262,17 +261,16 @@ describe('autocomplete.suggest', () => { ...getFunctionSignaturesByReturnType('eval', ['integer', 'double', 'long'], { scalar: true, }), - ...allGroupingFunctions, ]); await assertSuggestions('from a | stats avg(b) by var0 = /', [ - getAddDateHistogramSnippet(), + getDateHistogramCompletionItem(), ...getFieldNamesByType('any').map((field) => `${field} `), ...allEvaFunctions, ...allGroupingFunctions, ]); await assertSuggestions('from a | stats avg(b) by c, var0 = /', [ - getAddDateHistogramSnippet(), + getDateHistogramCompletionItem(), ...getFieldNamesByType('any').map((field) => `${field} `), ...allEvaFunctions, ...allGroupingFunctions, @@ -282,21 +280,17 @@ describe('autocomplete.suggest', () => { test('on space after expression right hand side operand', async () => { const { assertSuggestions } = await setup(); - await assertSuggestions('from a | stats avg(b) by doubleField % 2 /', [',', '| ']); - await assertSuggestions('from a | stats avg(b) by doubleField % 2 /', [',', '| '], { + await assertSuggestions('from a | stats avg(b) by doubleField % 2 /', [', ', '| '], { triggerCharacter: ' ', }); - await assertSuggestions( - 'from a | stats var0 = AVG(doubleField) BY var1 = BUCKET(dateField, 1 day)/', - [',', '| ', '+ $0', '- $0'] - ); await assertSuggestions( 'from a | stats var0 = AVG(doubleField) BY var1 = BUCKET(dateField, 1 day) /', - [',', '| ', '+ $0', '- $0'], + [', ', '| '], { triggerCharacter: ' ' } ); }); + test('on space within bucket()', async () => { const { assertSuggestions } = await setup(); await assertSuggestions('from a | stats avg(b) by BUCKET(/, 50, ?_tstart, ?_tend)', [ @@ -330,6 +324,29 @@ describe('autocomplete.suggest', () => { const suggestions = await suggest('from a | stats count(/)'); expect(suggestions).toContain(allStarConstant); }); + + describe('date histogram snippet', () => { + test('uses histogramBarTarget preference when available', async () => { + const { suggest } = await setup(); + const histogramBarTarget = Math.random() * 100; + const expectedCompletionItem = getDateHistogramCompletionItem(histogramBarTarget); + + const suggestions = await suggest('FROM a | STATS BY /', { + callbacks: { getPreferences: () => Promise.resolve({ histogramBarTarget }) }, + }); + + expect(suggestions).toContainEqual(expectedCompletionItem); + }); + + test('defaults gracefully', async () => { + const { suggest } = await setup(); + const expectedCompletionItem = getDateHistogramCompletionItem(); + + const suggestions = await suggest('FROM a | STATS BY /'); + + expect(suggestions).toContainEqual(expectedCompletionItem); + }); + }); }); }); }); diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.where.test.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.where.test.ts new file mode 100644 index 0000000000000..3345f7646e2ff --- /dev/null +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.where.test.ts @@ -0,0 +1,334 @@ +/* + * Copyright 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 { ESQL_COMMON_NUMERIC_TYPES } from '../../shared/esql_types'; +import { pipeCompleteItem } from '../complete_items'; +import { getDateLiterals } from '../factories'; +import { log10ParameterTypes, powParameterTypes } from './constants'; +import { + attachTriggerCommand, + fields, + getFieldNamesByType, + getFunctionSignaturesByReturnType, + setup, +} from './helpers'; + +describe('WHERE ', () => { + const allEvalFns = getFunctionSignaturesByReturnType('where', 'any', { + scalar: true, + }); + test('beginning an expression', async () => { + const { assertSuggestions } = await setup(); + + await assertSuggestions('from a | where /', [ + ...getFieldNamesByType('any') + .map((field) => `${field} `) + .map(attachTriggerCommand), + ...allEvalFns, + ]); + await assertSuggestions( + 'from a | eval var0 = 1 | where /', + [ + ...getFieldNamesByType('any') + .map((name) => `${name} `) + .map(attachTriggerCommand), + attachTriggerCommand('var0 '), + ...allEvalFns, + ], + { + callbacks: { + getColumnsFor: () => Promise.resolve([...fields, { name: 'var0', type: 'integer' }]), + }, + } + ); + }); + + describe('within the expression', () => { + test('after a field name', async () => { + const { assertSuggestions } = await setup(); + + await assertSuggestions('from a | where keywordField /', [ + // all functions compatible with a keywordField type + ...getFunctionSignaturesByReturnType( + 'where', + 'boolean', + { + builtin: true, + }, + undefined, + ['and', 'or', 'not'] + ), + ]); + }); + + test('suggests dates after a comparison with a date', async () => { + const { assertSuggestions } = await setup(); + + const expectedComparisonWithDateSuggestions = [ + ...getDateLiterals(), + ...getFieldNamesByType(['date']), + // all functions compatible with a keywordField type + ...getFunctionSignaturesByReturnType('where', ['date'], { scalar: true }), + ]; + await assertSuggestions( + 'from a | where dateField == /', + expectedComparisonWithDateSuggestions + ); + + await assertSuggestions( + 'from a | where dateField < /', + expectedComparisonWithDateSuggestions + ); + + await assertSuggestions( + 'from a | where dateField >= /', + expectedComparisonWithDateSuggestions + ); + }); + + test('after a comparison with a string field', async () => { + const { assertSuggestions } = await setup(); + + const expectedComparisonWithTextFieldSuggestions = [ + ...getFieldNamesByType(['text', 'keyword', 'ip', 'version']), + ...getFunctionSignaturesByReturnType('where', ['text', 'keyword', 'ip', 'version'], { + scalar: true, + }), + ]; + + await assertSuggestions( + 'from a | where textField >= /', + expectedComparisonWithTextFieldSuggestions + ); + await assertSuggestions( + 'from a | where textField >= textField/', + expectedComparisonWithTextFieldSuggestions + ); + }); + + test('after a logical operator', async () => { + const { assertSuggestions } = await setup(); + + for (const op of ['and', 'or']) { + await assertSuggestions(`from a | where keywordField >= keywordField ${op} /`, [ + ...getFieldNamesByType('any'), + ...getFunctionSignaturesByReturnType('where', 'any', { scalar: true }), + ]); + await assertSuggestions(`from a | where keywordField >= keywordField ${op} doubleField /`, [ + ...getFunctionSignaturesByReturnType('where', 'boolean', { builtin: true }, ['double']), + ]); + await assertSuggestions( + `from a | where keywordField >= keywordField ${op} doubleField == /`, + [ + ...getFieldNamesByType(ESQL_COMMON_NUMERIC_TYPES), + ...getFunctionSignaturesByReturnType('where', ESQL_COMMON_NUMERIC_TYPES, { + scalar: true, + }), + ] + ); + } + }); + + test('suggests operators after a field name', async () => { + const { assertSuggestions } = await setup(); + + await assertSuggestions('from a | stats a=avg(doubleField) | where a /', [ + ...getFunctionSignaturesByReturnType('where', 'any', { builtin: true, skipAssign: true }, [ + 'double', + ]), + ]); + }); + + test('accounts for fields lost in previous commands', async () => { + const { assertSuggestions } = await setup(); + + // Mind this test: suggestion is aware of previous commands when checking for fields + // in this case the doubleField has been wiped by the STATS command and suggest cannot find it's type + await assertSuggestions('from a | stats a=avg(doubleField) | where doubleField /', [], { + callbacks: { getColumnsFor: () => Promise.resolve([{ name: 'a', type: 'double' }]) }, + }); + }); + + test('suggests function arguments', async () => { + const { assertSuggestions } = await setup(); + + // The editor automatically inject the final bracket, so it is not useful to test with just open bracket + await assertSuggestions( + 'from a | where log10(/)', + [ + ...getFieldNamesByType(log10ParameterTypes), + ...getFunctionSignaturesByReturnType( + 'where', + log10ParameterTypes, + { scalar: true }, + undefined, + ['log10'] + ), + ], + { triggerCharacter: '(' } + ); + await assertSuggestions( + 'from a | WHERE pow(doubleField, /)', + [ + ...getFieldNamesByType(powParameterTypes), + ...getFunctionSignaturesByReturnType( + 'where', + powParameterTypes, + { scalar: true }, + undefined, + ['pow'] + ), + ], + { triggerCharacter: ',' } + ); + }); + + test('suggests boolean and numeric operators after a numeric function result', async () => { + const { assertSuggestions } = await setup(); + + await assertSuggestions('from a | where log10(doubleField) /', [ + ...getFunctionSignaturesByReturnType('where', 'double', { builtin: true }, ['double']), + ...getFunctionSignaturesByReturnType('where', 'boolean', { builtin: true }, ['double']), + ]); + }); + + test('suggestions after NOT', async () => { + const { assertSuggestions } = await setup(); + await assertSuggestions('from index | WHERE keywordField not /', [ + 'LIKE $0', + 'RLIKE $0', + 'IN $0', + ]); + await assertSuggestions('from index | WHERE keywordField NOT /', [ + 'LIKE $0', + 'RLIKE $0', + 'IN $0', + ]); + await assertSuggestions('from index | WHERE not /', [ + ...getFieldNamesByType('boolean').map((name) => attachTriggerCommand(`${name} `)), + ...getFunctionSignaturesByReturnType('where', 'boolean', { scalar: true }), + ]); + await assertSuggestions('FROM index | WHERE NOT ENDS_WITH(keywordField, "foo") /', [ + ...getFunctionSignaturesByReturnType('where', 'boolean', { builtin: true }, ['boolean']), + pipeCompleteItem, + ]); + await assertSuggestions('from index | WHERE keywordField IS NOT/', [ + '!= $0', + '== $0', + 'AND $0', + 'IN $0', + 'IS NOT NULL', + 'IS NULL', + 'NOT', + 'OR $0', + '| ', + ]); + + await assertSuggestions('from index | WHERE keywordField IS NOT /', [ + '!= $0', + '== $0', + 'AND $0', + 'IN $0', + 'IS NOT NULL', + 'IS NULL', + 'NOT', + 'OR $0', + '| ', + ]); + }); + + test('suggestions after IN', async () => { + const { assertSuggestions } = await setup(); + + await assertSuggestions('from index | WHERE doubleField in /', ['( $0 )']); + await assertSuggestions('from index | WHERE doubleField not in /', ['( $0 )']); + await assertSuggestions( + 'from index | WHERE doubleField not in (/)', + [ + ...getFieldNamesByType('double').filter((name) => name !== 'doubleField'), + ...getFunctionSignaturesByReturnType('where', 'double', { scalar: true }), + ], + { triggerCharacter: '(' } + ); + await assertSuggestions('from index | WHERE doubleField in ( `any#Char$Field`, /)', [ + ...getFieldNamesByType('double').filter( + (name) => name !== '`any#Char$Field`' && name !== 'doubleField' + ), + ...getFunctionSignaturesByReturnType('where', 'double', { scalar: true }), + ]); + await assertSuggestions('from index | WHERE doubleField not in ( `any#Char$Field`, /)', [ + ...getFieldNamesByType('double').filter( + (name) => name !== '`any#Char$Field`' && name !== 'doubleField' + ), + ...getFunctionSignaturesByReturnType('where', 'double', { scalar: true }), + ]); + }); + + test('suggestions after IS (NOT) NULL', async () => { + const { assertSuggestions } = await setup(); + + await assertSuggestions('FROM index | WHERE tags.keyword IS NULL /', [ + 'AND $0', + 'OR $0', + '| ', + ]); + + await assertSuggestions('FROM index | WHERE tags.keyword IS NOT NULL /', [ + 'AND $0', + 'OR $0', + '| ', + ]); + }); + + test('suggestions after an arithmetic expression', async () => { + const { assertSuggestions } = await setup(); + + await assertSuggestions('FROM index | WHERE doubleField + doubleField /', [ + ...getFunctionSignaturesByReturnType('where', 'any', { builtin: true, skipAssign: true }, [ + 'double', + ]), + ]); + }); + + test('pipe suggestion after complete expression', async () => { + const { suggest } = await setup(); + expect(await suggest('from index | WHERE doubleField != doubleField /')).toContainEqual( + expect.objectContaining({ + label: '|', + }) + ); + }); + + test('attaches ranges', async () => { + const { suggest } = await setup(); + + const suggestions = await suggest('FROM index | WHERE doubleField IS N/'); + + expect(suggestions).toContainEqual( + expect.objectContaining({ + text: 'IS NOT NULL', + rangeToReplace: { + start: 32, + end: 36, + }, + }) + ); + + expect(suggestions).toContainEqual( + expect.objectContaining({ + text: 'IS NULL', + rangeToReplace: { + start: 32, + end: 36, + }, + }) + ); + }); + }); +}); diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.suggest.eval.test.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.suggest.eval.test.ts index 81fd8f7f43902..5c67bfedbae75 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.suggest.eval.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.suggest.eval.test.ts @@ -535,17 +535,6 @@ describe('autocomplete.suggest', () => { { triggerCharacter: ' ' } ); await assertSuggestions('from a | eval a = 1 year /', [',', '| ', 'IS NOT NULL', 'IS NULL']); - await assertSuggestions('from a | eval a = 1 day + 2 /', [',', '| ']); - await assertSuggestions( - 'from a | eval 1 day + 2 /', - [ - ...dateSuggestions, - ...getFunctionSignaturesByReturnType('eval', 'any', { builtin: true, skipAssign: true }, [ - 'integer', - ]), - ], - { triggerCharacter: ' ' } - ); await assertSuggestions( 'from a | eval var0=date_trunc(/)', [ diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.suggest.test.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.suggest.test.ts index 51302d0d4cde5..c7bf9079f9155 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.suggest.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.suggest.test.ts @@ -42,6 +42,6 @@ describe('autocomplete.suggest', () => { await suggest('sHoW ?'); await suggest('row ? |'); - expect(callbacks.getFieldsFor.mock.calls.length).toBe(0); + expect(callbacks.getColumnsFor.mock.calls.length).toBe(0); }); }); diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/helpers.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/helpers.ts index fa16a3df7026f..9964fc96d00ca 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/helpers.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/helpers.ts @@ -8,7 +8,7 @@ */ import { camelCase } from 'lodash'; -import { getAstAndSyntaxErrors } from '@kbn/esql-ast'; +import { parse } from '@kbn/esql-ast'; import { scalarFunctionDefinitions } from '../../definitions/generated/scalar_functions'; import { builtinFunctions } from '../../definitions/builtin'; import { aggregationFunctionDefinitions } from '../../definitions/generated/aggregation_functions'; @@ -177,7 +177,7 @@ export function getFunctionSignaturesByReturnType( ({ returnType }) => expectedReturnType.includes('any') || expectedReturnType.includes(returnType as string) ); - if (!filteredByReturnType.length) { + if (!filteredByReturnType.length && !expectedReturnType.includes('any')) { return false; } if (paramsTypes?.length) { @@ -244,7 +244,17 @@ export function getDateLiteralsByFieldType(_requestedType: FieldType | FieldType } export function createCustomCallbackMocks( - customFields?: ESQLRealField[], + /** + * Columns that will come from Elasticsearch since the last command + * e.g. the test case may be `FROM index | EVAL foo = 1 | KEEP /` + * + * In this case, the columns available for the KEEP command will be the ones + * that were available after the EVAL command + * + * `FROM index | EVAL foo = 1 | LIMIT 0` will be used to fetch columns. The response + * will include "foo" as a column. + */ + customColumnsSinceLastCommand?: ESQLRealField[], customSources?: Array<{ name: string; hidden: boolean }>, customPolicies?: Array<{ name: string; @@ -253,11 +263,11 @@ export function createCustomCallbackMocks( enrichFields: string[]; }> ) { - const finalFields = customFields || fields; + const finalColumnsSinceLastCommand = customColumnsSinceLastCommand || fields; const finalSources = customSources || indexes; const finalPolicies = customPolicies || policies; return { - getFieldsFor: jest.fn(async () => finalFields), + getColumnsFor: jest.fn(async () => finalColumnsSinceLastCommand), getSources: jest.fn(async () => finalSources), getPolicies: jest.fn(async () => finalPolicies), }; @@ -302,7 +312,7 @@ export const setup = async (caret = '/') => { querySansCaret, pos, ctx, - getAstAndSyntaxErrors, + (_query: string | undefined) => parse(_query, { withFormatting: true }), opts.callbacks ?? callbacks ); }; diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/hidden_functions_and_commands.test.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/hidden_functions_and_commands.test.ts index cc4562f999fe3..02cc79c326792 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/hidden_functions_and_commands.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/hidden_functions_and_commands.test.ts @@ -50,6 +50,7 @@ describe('hidden functions', () => { expect(suggestedFunctions).toContain('VISIBLE_FUNCTION($0)'); expect(suggestedFunctions).not.toContain('HIDDEN_FUNCTION($0)'); }); + it('does not suggest hidden agg functions', async () => { setTestFunctions([ { diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/suggestions_in_comments.test.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/suggestions_in_comments.test.ts new file mode 100644 index 0000000000000..7f97409ea6341 --- /dev/null +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/suggestions_in_comments.test.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { setup } from './helpers'; + +describe('suggestions in comments', () => { + it('does not suggest in single-line comments', async () => { + const { assertSuggestions } = await setup('^'); + await assertSuggestions('FROM index | EVAL // hey there ^', []); + }); + + it('does not suggest in multi-line comments', async () => { + const { assertSuggestions } = await setup('^'); + await assertSuggestions('FROM index | EVAL /* ^ */', []); + await assertSuggestions('FROM index | EVAL /* (^) */', []); + }); + + it('does not suggest in incomplete multi-line comments', async () => { + const { assertSuggestions } = await setup('^'); + assertSuggestions('FROM index | EVAL /* ^', []); + }); + + test('suggests next to comments', async () => { + const { suggest } = await setup('^'); + expect((await suggest('FROM index | EVAL ^/* */')).length).toBeGreaterThan(0); + expect((await suggest('FROM index | EVAL /* */^')).length).toBeGreaterThan(0); + expect((await suggest('FROM index | EVAL ^// a comment')).length).toBeGreaterThan(0); + expect((await suggest('FROM index | EVAL // a comment\n^')).length).toBeGreaterThan(0); + }); + + test('handles multiple comments', async () => { + const { assertSuggestions } = await setup('^'); + assertSuggestions('FROM index | EVAL /* comment1 */ x + /* comment2 ^ */ 1', []); + assertSuggestions('FROM index | EVAL /* ^ comment1 */ x + /* comment2 ^ */ 1', []); + assertSuggestions('FROM index | EVAL /* comment1 */ x + /* comment2 */ 1 // comment3 ^', []); + }); +}); diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts index deb4592428089..9a99cdc9a8c68 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts @@ -11,13 +11,7 @@ import { suggest } from './autocomplete'; import { scalarFunctionDefinitions } from '../definitions/generated/scalar_functions'; import { timeUnitsToSuggest } from '../definitions/literals'; import { commandDefinitions as unmodifiedCommandDefinitions } from '../definitions/commands'; -import { - getAddDateHistogramSnippet, - getDateLiterals, - getSafeInsertText, - TIME_SYSTEM_PARAMS, - TRIGGER_SUGGESTION_COMMAND, -} from './factories'; +import { getSafeInsertText, TIME_SYSTEM_PARAMS, TRIGGER_SUGGESTION_COMMAND } from './factories'; import { camelCase } from 'lodash'; import { getAstAndSyntaxErrors } from '@kbn/esql-ast'; import { @@ -31,11 +25,13 @@ import { TIME_PICKER_SUGGESTION, setup, attachTriggerCommand, + SuggestOptions, + fields, } from './__tests__/helpers'; import { METADATA_FIELDS } from '../shared/constants'; -import { ESQL_COMMON_NUMERIC_TYPES, ESQL_STRING_TYPES } from '../shared/esql_types'; -import { log10ParameterTypes, powParameterTypes } from './__tests__/constants'; +import { ESQL_STRING_TYPES } from '../shared/esql_types'; import { getRecommendedQueries } from './recommended_queries/templates'; +import { getDateHistogramCompletionItem } from './commands/stats/util'; const commandDefinitions = unmodifiedCommandDefinitions.filter(({ hidden }) => !hidden); @@ -128,149 +124,6 @@ describe('autocomplete', () => { } }); - describe('where', () => { - const allEvalFns = getFunctionSignaturesByReturnType('where', 'any', { - scalar: true, - }); - testSuggestions('from a | where /', [ - ...getFieldNamesByType('any').map((field) => `${field} `), - ...allEvalFns, - ]); - testSuggestions('from a | eval var0 = 1 | where /', [ - ...getFieldNamesByType('any').map((name) => `${name} `), - 'var0', - ...allEvalFns, - ]); - testSuggestions('from a | where keywordField /', [ - // all functions compatible with a keywordField type - ...getFunctionSignaturesByReturnType( - 'where', - 'boolean', - { - builtin: true, - }, - undefined, - ['and', 'or', 'not'] - ), - ]); - - const expectedComparisonWithDateSuggestions = [ - ...getDateLiterals(), - ...getFieldNamesByType(['date']), - // all functions compatible with a keywordField type - ...getFunctionSignaturesByReturnType('where', ['date'], { scalar: true }), - ]; - testSuggestions('from a | where dateField == /', expectedComparisonWithDateSuggestions); - - testSuggestions('from a | where dateField < /', expectedComparisonWithDateSuggestions); - - testSuggestions('from a | where dateField >= /', expectedComparisonWithDateSuggestions); - - const expectedComparisonWithTextFieldSuggestions = [ - ...getFieldNamesByType(['text', 'keyword', 'ip', 'version']), - ...getFunctionSignaturesByReturnType('where', ['text', 'keyword', 'ip', 'version'], { - scalar: true, - }), - ]; - testSuggestions('from a | where textField >= /', expectedComparisonWithTextFieldSuggestions); - testSuggestions( - 'from a | where textField >= textField/', - expectedComparisonWithTextFieldSuggestions - ); - for (const op of ['and', 'or']) { - testSuggestions(`from a | where keywordField >= keywordField ${op} /`, [ - ...getFieldNamesByType('any'), - ...getFunctionSignaturesByReturnType('where', 'any', { scalar: true }), - ]); - testSuggestions(`from a | where keywordField >= keywordField ${op} doubleField /`, [ - ...getFunctionSignaturesByReturnType('where', 'boolean', { builtin: true }, ['double']), - ]); - testSuggestions(`from a | where keywordField >= keywordField ${op} doubleField == /`, [ - ...getFieldNamesByType(ESQL_COMMON_NUMERIC_TYPES), - ...getFunctionSignaturesByReturnType('where', ESQL_COMMON_NUMERIC_TYPES, { - scalar: true, - }), - ]); - } - testSuggestions('from a | stats a=avg(doubleField) | where a /', [ - ...getFunctionSignaturesByReturnType('where', 'any', { builtin: true, skipAssign: true }, [ - 'double', - ]), - ]); - // Mind this test: suggestion is aware of previous commands when checking for fields - // in this case the doubleField has been wiped by the STATS command and suggest cannot find it's type - // @TODO: verify this is the correct behaviour in this case or if we want a "generic" suggestion anyway - testSuggestions( - 'from a | stats a=avg(doubleField) | where doubleField /', - [], - undefined, - // make the fields suggest aware of the previous STATS, leave the other callbacks untouched - [[{ name: 'a', type: 'double' }], undefined, undefined] - ); - // The editor automatically inject the final bracket, so it is not useful to test with just open bracket - testSuggestions( - 'from a | where log10(/)', - [ - ...getFieldNamesByType(log10ParameterTypes), - ...getFunctionSignaturesByReturnType( - 'where', - log10ParameterTypes, - { scalar: true }, - undefined, - ['log10'] - ), - ], - '(' - ); - testSuggestions('from a | where log10(doubleField) /', [ - ...getFunctionSignaturesByReturnType('where', 'double', { builtin: true }, ['double']), - ...getFunctionSignaturesByReturnType('where', 'boolean', { builtin: true }, ['double']), - ]); - testSuggestions( - 'from a | WHERE pow(doubleField, /)', - [ - ...getFieldNamesByType(powParameterTypes), - ...getFunctionSignaturesByReturnType( - 'where', - powParameterTypes, - { scalar: true }, - undefined, - ['pow'] - ), - ], - ',' - ); - - testSuggestions('from index | WHERE keywordField not /', ['LIKE $0', 'RLIKE $0', 'IN $0']); - testSuggestions('from index | WHERE keywordField NOT /', ['LIKE $0', 'RLIKE $0', 'IN $0']); - testSuggestions('from index | WHERE not /', [ - ...getFieldNamesByType('boolean'), - ...getFunctionSignaturesByReturnType('eval', 'boolean', { scalar: true }), - ]); - testSuggestions('from index | WHERE doubleField in /', ['( $0 )']); - testSuggestions('from index | WHERE doubleField not in /', ['( $0 )']); - testSuggestions( - 'from index | WHERE doubleField not in (/)', - [ - ...getFieldNamesByType('double').filter((name) => name !== 'doubleField'), - ...getFunctionSignaturesByReturnType('where', 'double', { scalar: true }), - ], - '(' - ); - testSuggestions('from index | WHERE doubleField in ( `any#Char$Field`, /)', [ - ...getFieldNamesByType('double').filter( - (name) => name !== '`any#Char$Field`' && name !== 'doubleField' - ), - ...getFunctionSignaturesByReturnType('where', 'double', { scalar: true }), - ]); - testSuggestions('from index | WHERE doubleField not in ( `any#Char$Field`, /)', [ - ...getFieldNamesByType('double').filter( - (name) => name !== '`any#Char$Field`' && name !== 'doubleField' - ), - ...getFunctionSignaturesByReturnType('where', 'double', { scalar: true }), - ]); - }); - describe('grok', () => { const constantPattern = '"%{WORD:firstWord}"'; const subExpressions = [ @@ -385,24 +238,56 @@ describe('autocomplete', () => { '```````round(doubleField) + 1```` + 1`` + 1`', '```````````````round(doubleField) + 1```````` + 1```` + 1`` + 1`', '```````````````````````````````round(doubleField) + 1```````````````` + 1```````` + 1```` + 1`` + 1`', + ], + undefined, + [ + [ + ...fields, + // the following non-field columns will come over the wire as part of the response + { + name: 'round(doubleField) + 1', + type: 'double', + }, + { + name: '`round(doubleField) + 1` + 1', + type: 'double', + }, + { + name: '```round(doubleField) + 1`` + 1` + 1', + type: 'double', + }, + { + name: '```````round(doubleField) + 1```` + 1`` + 1` + 1', + type: 'double', + }, + { + name: '```````````````round(doubleField) + 1```````` + 1```` + 1`` + 1` + 1', + type: 'double', + }, + ], ] ); it('should not suggest already-used fields and variables', async () => { const { suggest: suggestTest } = await setup(); - const getSuggestions = async (query: string) => - (await suggestTest(query)).map((value) => value.text); + const getSuggestions = async (query: string, opts?: SuggestOptions) => + (await suggestTest(query, opts)).map((value) => value.text); - expect(await getSuggestions('from a_index | EVAL foo = 1 | KEEP /')).toContain('foo'); - expect(await getSuggestions('from a_index | EVAL foo = 1 | KEEP foo, /')).not.toContain( - 'foo' - ); - expect(await getSuggestions('from a_index | EVAL foo = 1 | KEEP /')).toContain( + expect( + await getSuggestions('from a_index | EVAL foo = 1 | KEEP /', { + callbacks: { getColumnsFor: () => [...fields, { name: 'foo', type: 'integer' }] }, + }) + ).toContain('foo'); + expect( + await getSuggestions('from a_index | EVAL foo = 1 | KEEP foo, /', { + callbacks: { getColumnsFor: () => [...fields, { name: 'foo', type: 'integer' }] }, + }) + ).not.toContain('foo'); + + expect(await getSuggestions('from a_index | KEEP /')).toContain('doubleField'); + expect(await getSuggestions('from a_index | KEEP doubleField, /')).not.toContain( 'doubleField' ); - expect( - await getSuggestions('from a_index | EVAL foo = 1 | KEEP doubleField, /') - ).not.toContain('doubleField'); }); }); } @@ -504,7 +389,7 @@ describe('autocomplete', () => { }); describe('callbacks', () => { - it('should send the fields query without the last command', async () => { + it('should send the columns query without the last command', async () => { const callbackMocks = createCustomCallbackMocks(undefined, undefined, undefined); const statement = 'from a | drop keywordField | eval var0 = abs(doubleField) '; const triggerOffset = statement.lastIndexOf(' '); @@ -516,7 +401,7 @@ describe('autocomplete', () => { async (text) => (text ? getAstAndSyntaxErrors(text) : { ast: [], errors: [] }), callbackMocks ); - expect(callbackMocks.getFieldsFor).toHaveBeenCalledWith({ + expect(callbackMocks.getColumnsFor).toHaveBeenCalledWith({ query: 'from a | drop keywordField', }); }); @@ -532,7 +417,7 @@ describe('autocomplete', () => { async (text) => (text ? getAstAndSyntaxErrors(text) : { ast: [], errors: [] }), callbackMocks ); - expect(callbackMocks.getFieldsFor).toHaveBeenCalledWith({ query: 'from a' }); + expect(callbackMocks.getColumnsFor).toHaveBeenCalledWith({ query: 'from a' }); }); }); @@ -703,12 +588,12 @@ describe('autocomplete', () => { ]); // STATS argument BY - testSuggestions('FROM index1 | STATS AVG(booleanField) B/', ['BY $0', ',', '| ']); + testSuggestions('FROM index1 | STATS AVG(booleanField) B/', ['BY ', ', ', '| ']); // STATS argument BY expression testSuggestions('FROM index1 | STATS field BY f/', [ 'var0 = ', - getAddDateHistogramSnippet(), + getDateHistogramCompletionItem(), ...getFunctionSignaturesByReturnType('stats', 'any', { grouping: true, scalar: true }), ...getFieldNamesByType('any').map((field) => `${field} `), ]); @@ -732,6 +617,21 @@ describe('autocomplete', () => { ['and', 'or', 'not'] ) ); + + // WHERE function + testSuggestions( + 'FROM index1 | WHERE ABS(integerField) i/', + getFunctionSignaturesByReturnType( + 'where', + 'any', + { + builtin: true, + skipAssign: true, + }, + ['integer'], + ['and', 'or', 'not'] + ) + ); }); describe('advancing the cursor and opening the suggestion menu automatically ✨', () => { @@ -1038,8 +938,8 @@ describe('autocomplete', () => { // STATS argument BY testSuggestions('FROM a | STATS AVG(numberField) /', [ - ',', - attachAsSnippet(attachTriggerCommand('BY $0')), + ', ', + attachTriggerCommand('BY '), attachTriggerCommand('| '), ]); @@ -1056,7 +956,7 @@ describe('autocomplete', () => { 'by' ); testSuggestions('FROM a | STATS AVG(numberField) BY /', [ - getAddDateHistogramSnippet(), + getDateHistogramCompletionItem(), attachTriggerCommand('var0 = '), ...getFieldNamesByType('any') .map((field) => `${field} `) @@ -1066,7 +966,7 @@ describe('autocomplete', () => { // STATS argument BY assignment (checking field suggestions) testSuggestions('FROM a | STATS AVG(numberField) BY var0 = /', [ - getAddDateHistogramSnippet(), + getDateHistogramCompletionItem(), ...getFieldNamesByType('any') .map((field) => `${field} `) .map(attachTriggerCommand), @@ -1261,27 +1161,35 @@ describe('autocomplete', () => { describe('Replacement ranges are attached when needed', () => { testSuggestions('FROM a | WHERE doubleField IS NOT N/', [ - { text: 'IS NOT NULL', rangeToReplace: { start: 28, end: 35 } }, - { text: 'IS NULL', rangeToReplace: { start: 36, end: 36 } }, + { text: 'IS NOT NULL', rangeToReplace: { start: 28, end: 36 } }, + { text: 'IS NULL', rangeToReplace: { start: 37, end: 37 } }, '!= $0', '== $0', 'IN $0', 'AND $0', 'NOT', 'OR $0', + // pipe doesn't make sense here, but Monaco will filter it out. + // see https://github.com/elastic/kibana/issues/199401 for an explanation + // of why this happens + '| ', ]); testSuggestions('FROM a | WHERE doubleField IS N/', [ - { text: 'IS NOT NULL', rangeToReplace: { start: 28, end: 31 } }, - { text: 'IS NULL', rangeToReplace: { start: 28, end: 31 } }, - { text: '!= $0', rangeToReplace: { start: 32, end: 32 } }, + { text: 'IS NOT NULL', rangeToReplace: { start: 28, end: 32 } }, + { text: 'IS NULL', rangeToReplace: { start: 28, end: 32 } }, + { text: '!= $0', rangeToReplace: { start: 33, end: 33 } }, '== $0', 'IN $0', 'AND $0', 'NOT', 'OR $0', + // pipe doesn't make sense here, but Monaco will filter it out. + // see https://github.com/elastic/kibana/issues/199401 for an explanation + // of why this happens + '| ', ]); testSuggestions('FROM a | EVAL doubleField IS NOT N/', [ - { text: 'IS NOT NULL', rangeToReplace: { start: 27, end: 34 } }, + { text: 'IS NOT NULL', rangeToReplace: { start: 27, end: 35 } }, 'IS NULL', '!= $0', '== $0', @@ -1290,6 +1198,7 @@ describe('autocomplete', () => { 'NOT', 'OR $0', ]); + describe('dot-separated field names', () => { testSuggestions( 'FROM a | KEEP field.nam/', diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts index 98a26b0c8dd4b..bae10b4c321f4 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts @@ -14,19 +14,16 @@ import type { ESQLCommand, ESQLCommandOption, ESQLFunction, - ESQLLiteral, ESQLSingleAstItem, } from '@kbn/esql-ast'; -import { i18n } from '@kbn/i18n'; import { ESQL_NUMBER_TYPES, isNumericType } from '../shared/esql_types'; -import type { EditorContext, ItemKind, SuggestionRawDefinition, GetFieldsByTypeFn } from './types'; +import type { EditorContext, ItemKind, SuggestionRawDefinition, GetColumnsByTypeFn } from './types'; import { getColumnForASTNode, getCommandDefinition, getCommandOption, getFunctionDefinition, - getLastCharFromTrimmed, - isArrayType, + getLastNonWhitespaceChar, isAssignment, isAssignmentComplete, isColumnItem, @@ -49,6 +46,8 @@ import { getColumnByName, sourceExists, findFinalWord, + getAllCommands, + getExpressionType, } from '../shared/helpers'; import { collectVariables, excludeVariablesFromCurrentCommand } from '../shared/variables'; import type { ESQLPolicy, ESQLRealField, ESQLVariable, ReferenceMaps } from '../validation/types'; @@ -56,11 +55,8 @@ import { allStarConstant, colonCompleteItem, commaCompleteItem, - commandAutocompleteDefinitions, getAssignmentDefinitionCompletitionItem, - getBuiltinCompatibleFunctionDefinition, - getNextTokenForNot, - listCompleteItem, + getCommandAutocompleteDefinitions, pipeCompleteItem, semiColonCompleteItem, } from './complete_items'; @@ -68,9 +64,9 @@ import { buildFieldsDefinitions, buildPoliciesDefinitions, buildSourcesDefinitions, - buildNewVarDefinition, + getNewVariableSuggestion, buildNoPoliciesAvailableDefinition, - getCompatibleFunctionDefinition, + getFunctionSuggestions, buildMatchingFieldsDefinition, getCompatibleLiterals, buildConstantsDefinitions, @@ -81,7 +77,8 @@ import { getDateLiterals, buildFieldsDefinitionsWithMetadata, TRIGGER_SUGGESTION_COMMAND, - getAddDateHistogramSnippet, + getOperatorSuggestions, + getSuggestionsAfterNot, } from './factories'; import { EDITOR_MARKER, METADATA_FIELDS } from '../shared/constants'; import { getAstContext, removeMarkerArgFromArgsList } from '../shared/context'; @@ -94,25 +91,21 @@ import { import { ESQLCallbacks, ESQLSourceResult } from '../shared/types'; import { getFunctionsToIgnoreForStats, - getOverlapRange, getQueryForFields, getSourcesFromCommands, - getSupportedTypesForBinaryOperators, isAggFunctionUsedAlready, removeQuoteForSuggestedSources, getValidSignaturesAndTypesToSuggestNext, + handleFragment, + getFieldsOrFunctionsSuggestions, + pushItUpInTheList, + extractTypeFromASTArg, + getSuggestionsToRightOfOperatorExpression, + checkFunctionInvocationComplete, } from './helper'; -import { getSortPos } from './commands/sort/helper'; -import { - FunctionParameter, - FunctionReturnType, - SupportedDataType, - isParameterType, - isReturnType, -} from '../definitions/types'; +import { FunctionParameter, isParameterType } from '../definitions/types'; import { metadataOption } from '../definitions/options'; import { comparisonFunctions } from '../definitions/builtin'; -import { countBracketsUnclosed } from '../shared/helpers'; import { getRecommendedQueriesSuggestions } from './recommended_queries/suggestions'; type GetFieldsMapFn = () => Promise>; @@ -165,6 +158,11 @@ export async function suggest( const { ast } = await astProvider(correctedQuery); const astContext = getAstContext(innerText, ast, offset); + + if (astContext.type === 'comment') { + return []; + } + // build the correct query to fetch the list of fields const queryForFields = getQueryForFields( buildQueryUntilPreviousCommand(ast, correctedQuery), @@ -181,7 +179,7 @@ export async function suggest( if (astContext.type === 'newCommand') { // propose main commands here // filter source commands if already defined - const suggestions = commandAutocompleteDefinitions; + const suggestions = getCommandAutocompleteDefinitions(getAllCommands()); if (!ast.length) { // Display the recommended queries if there are no commands (empty state) const recommendedQueriesSuggestions: SuggestionRawDefinition[] = []; @@ -209,9 +207,7 @@ export async function suggest( } if (astContext.type === 'expression') { - // suggest next possible argument, or option - // otherwise a variable - return getExpressionSuggestionsByType( + return getSuggestionsWithinCommandExpression( innerText, ast, astContext, @@ -219,7 +215,8 @@ export async function suggest( getFieldsByType, getFieldsMap, getPolicies, - getPolicyMetadata + getPolicyMetadata, + resourceRetriever?.getPreferences ); } if (astContext.type === 'setting') { @@ -242,8 +239,7 @@ export async function suggest( { option, ...rest }, getFieldsByType, getFieldsMap, - getPolicyMetadata, - resourceRetriever?.getPreferences + getPolicyMetadata ); } } @@ -275,7 +271,7 @@ export async function suggest( export function getFieldsByTypeRetriever( queryString: string, resourceRetriever?: ESQLCallbacks -): { getFieldsByType: GetFieldsByTypeFn; getFieldsMap: GetFieldsMapFn } { +): { getFieldsByType: GetColumnsByTypeFn; getFieldsMap: GetFieldsMapFn } { const helpers = getFieldsByTypeHelper(queryString, resourceRetriever); return { getFieldsByType: async ( @@ -324,16 +320,12 @@ function findNewVariable(variables: Map) { function workoutBuiltinOptions( nodeArg: ESQLAstItem, references: Pick -): { skipAssign: boolean; commandsToInclude?: string[] } { - const commandsToInclude = - (isSingleItem(nodeArg) && nodeArg.text?.toLowerCase().trim().endsWith('null')) ?? false - ? ['and', 'or'] - : undefined; - +): { ignored?: string[] } { // skip assign operator if it's a function or an existing field to avoid promoting shadowing return { - skipAssign: Boolean(!isColumnItem(nodeArg) || getColumnForASTNode(nodeArg, references)), - commandsToInclude, + ignored: Boolean(!isColumnItem(nodeArg) || getColumnForASTNode(nodeArg, references)) + ? ['='] + : undefined, }; } @@ -343,42 +335,19 @@ function areCurrentArgsValid( references: Pick ) { // unfortunately here we need to bake some command-specific logic - if (command.name === 'stats') { - if (node) { - // consider the following expressions not complete yet - // ... | stats a - // ... | stats a = - if (isColumnItem(node) || (isAssignment(node) && !isAssignmentComplete(node))) { - return false; - } - } - } if (command.name === 'eval') { if (node) { if (isFunctionItem(node)) { if (isAssignment(node)) { return isAssignmentComplete(node); } else { - return isFunctionArgComplete(node, references).complete; + return checkFunctionInvocationComplete(node, (expression) => + getExpressionType(expression, references.fields, references.variables) + ).complete; } } } } - if (command.name === 'where') { - if (node) { - if ( - isColumnItem(node) || - (isFunctionItem(node) && !isFunctionArgComplete(node, references).complete) - ) { - return false; - } else { - return ( - extractTypeFromASTArg(node, references) === - getCommandDefinition(command.name).signature.params[0].type - ); - } - } - } if (command.name === 'rename') { if (node) { if (isColumnItem(node)) { @@ -389,82 +358,6 @@ function areCurrentArgsValid( return true; } -export function extractTypeFromASTArg( - arg: ESQLAstItem, - references: Pick -): - | ESQLLiteral['literalType'] - | SupportedDataType - | FunctionReturnType - | 'timeInterval' - | string // @TODO remove this - | undefined { - if (Array.isArray(arg)) { - return extractTypeFromASTArg(arg[0], references); - } - if (isColumnItem(arg) || isLiteralItem(arg)) { - if (isLiteralItem(arg)) { - return arg.literalType; - } - if (isColumnItem(arg)) { - const hit = getColumnForASTNode(arg, references); - if (hit) { - return hit.type; - } - } - } - if (isTimeIntervalItem(arg)) { - return arg.type; - } - if (isFunctionItem(arg)) { - const fnDef = getFunctionDefinition(arg.name); - if (fnDef) { - // @TODO: improve this to better filter down the correct return type based on existing arguments - // just mind that this can be highly recursive... - return fnDef.signatures[0].returnType; - } - } -} - -// @TODO: refactor this to be shared with validation -function isFunctionArgComplete( - arg: ESQLFunction, - references: Pick -) { - const fnDefinition = getFunctionDefinition(arg.name); - if (!fnDefinition) { - return { complete: false }; - } - const cleanedArgs = removeMarkerArgFromArgsList(arg)!.args; - const argLengthCheck = fnDefinition.signatures.some((def) => { - if (def.minParams && cleanedArgs.length >= def.minParams) { - return true; - } - if (cleanedArgs.length === def.params.length) { - return true; - } - return cleanedArgs.length >= def.params.filter(({ optional }) => !optional).length; - }); - if (!argLengthCheck) { - return { complete: false, reason: 'fewArgs' }; - } - if (fnDefinition.name === 'in' && Array.isArray(arg.args[1]) && !arg.args[1].length) { - return { complete: false, reason: 'fewArgs' }; - } - const hasCorrectTypes = fnDefinition.signatures.some((def) => { - return arg.args.every((a, index) => { - return ( - (fnDefinition.name.endsWith('null') && def.params[index].type === 'any') || - def.params[index].type === extractTypeFromASTArg(a, references) - ); - }); - }); - if (!hasCorrectTypes) { - return { complete: false, reason: 'wrongTypes' }; - } - return { complete: true }; -} - function extractArgMeta( commandOrOption: ESQLCommand | ESQLCommandOption, node: ESQLSingleAstItem | undefined @@ -484,6 +377,63 @@ function extractArgMeta( return { argIndex, prevIndex, lastArg, nodeArg }; } +async function getSuggestionsWithinCommandExpression( + innerText: string, + commands: ESQLCommand[], + { + command, + option, + node, + }: { + command: ESQLCommand; + option: ESQLCommandOption | undefined; + node: ESQLSingleAstItem | undefined; + }, + getSources: () => Promise, + getColumnsByType: GetColumnsByTypeFn, + getFieldsMap: GetFieldsMapFn, + getPolicies: GetPoliciesFn, + getPolicyMetadata: GetPolicyMetadataFn, + getPreferences?: () => Promise<{ histogramBarTarget: number } | undefined> +) { + const commandDef = getCommandDefinition(command.name); + + // collect all fields + variables to suggest + const fieldsMap: Map = await getFieldsMap(); + const anyVariables = collectVariables(commands, fieldsMap, innerText); + + const references = { fields: fieldsMap, variables: anyVariables }; + if (commandDef.suggest) { + // The new path. + return commandDef.suggest( + innerText, + command, + getColumnsByType, + (col: string) => Boolean(getColumnByName(col, references)), + () => findNewVariable(anyVariables), + (expression: ESQLAstItem | undefined) => + getExpressionType(expression, references.fields, references.variables), + getPreferences + ); + } else { + // The deprecated path. + return getExpressionSuggestionsByType( + innerText, + commands, + { command, option, node }, + getSources, + getColumnsByType, + getFieldsMap, + getPolicies, + getPolicyMetadata + ); + } +} + +/** + * @deprecated — this generic logic will be replaced with the command-specific suggest functions + * from each command definition. + */ async function getExpressionSuggestionsByType( innerText: string, commands: ESQLCommand[], @@ -497,7 +447,7 @@ async function getExpressionSuggestionsByType( node: ESQLSingleAstItem | undefined; }, getSources: () => Promise, - getFieldsByType: GetFieldsByTypeFn, + getFieldsByType: GetColumnsByTypeFn, getFieldsMap: GetFieldsMapFn, getPolicies: GetPoliciesFn, getPolicyMetadata: GetPolicyMetadataFn @@ -505,6 +455,15 @@ async function getExpressionSuggestionsByType( const commandDef = getCommandDefinition(command.name); const { argIndex, prevIndex, lastArg, nodeArg } = extractArgMeta(command, node); + // collect all fields + variables to suggest + const fieldsMap: Map = await getFieldsMap(); + const anyVariables = collectVariables(commands, fieldsMap, innerText); + + const references = { fields: fieldsMap, variables: anyVariables }; + if (!commandDef.signature || !commandDef.options) { + return []; + } + // TODO - this is a workaround because it was too difficult to handle this case in a generic way :( if (commandDef.name === 'from' && node && isSourceItem(node) && /\s/.test(node.name)) { // FROM " " @@ -537,7 +496,7 @@ async function getExpressionSuggestionsByType( command.args.filter((arg) => isOptionItem(arg)) as ESQLCommandOption[] ).map(({ name }) => ({ name, - index: commandDef.options.findIndex(({ name: defName }) => defName === name), + index: commandDef.options!.findIndex(({ name: defName }) => defName === name), })); const optionsAvailable = commandDef.options.filter(({ name }, index) => { const optArg = optionsAlreadyDeclared.find(({ name: optionName }) => optionName === name); @@ -577,23 +536,12 @@ async function getExpressionSuggestionsByType( } } - // collect all fields + variables to suggest - const fieldsMap: Map = await (argDef ? getFieldsMap() : new Map()); - const anyVariables = collectVariables(commands, fieldsMap, innerText); - const previousWord = findPreviousWord(innerText); // enrich with assignment has some special rules who are handled somewhere else const canHaveAssignments = ['eval', 'stats', 'row'].includes(command.name) && !comparisonFunctions.map((fn) => fn.name).includes(previousWord); - const references = { fields: fieldsMap, variables: anyVariables }; - if (command.name === 'sort') { - return await suggestForSortCmd(innerText, getFieldsByType, (col) => - Boolean(getColumnByName(col, references)) - ); - } - const suggestions: SuggestionRawDefinition[] = []; // When user types and accepts autocomplete suggestion, and cursor is placed at the end of a valid field @@ -616,7 +564,7 @@ async function getExpressionSuggestionsByType( // ... | ROW field NOT // ... | EVAL field NOT // there's not way to know the type of the field here, so suggest anything - suggestions.push(...getNextTokenForNot(command.name, option?.name, 'any')); + suggestions.push(...getSuggestionsAfterNot()); } else { // i.e. // ... | ROW @@ -624,7 +572,7 @@ async function getExpressionSuggestionsByType( // ... | STATS ..., // ... | EVAL // ... | EVAL ..., - suggestions.push(buildNewVarDefinition(findNewVariable(anyVariables))); + suggestions.push(getNewVariableSuggestion(findNewVariable(anyVariables))); } } } @@ -704,13 +652,11 @@ async function getExpressionSuggestionsByType( const nodeArgType = extractTypeFromASTArg(nodeArg, references); if (isParameterType(nodeArgType)) { suggestions.push( - ...getBuiltinCompatibleFunctionDefinition( - command.name, - undefined, - nodeArgType, - undefined, - workoutBuiltinOptions(nodeArg, references) - ) + ...getOperatorSuggestions({ + command: command.name, + leftParamType: nodeArgType, + ignored: workoutBuiltinOptions(nodeArg, references).ignored, + }) ); } else { suggestions.push(getAssignmentDefinitionCompletitionItem()); @@ -741,9 +687,7 @@ async function getExpressionSuggestionsByType( )) ); if (['show', 'meta'].includes(command.name)) { - suggestions.push( - ...getBuiltinCompatibleFunctionDefinition(command.name, undefined, 'any') - ); + suggestions.push(...getOperatorSuggestions({ command: command.name })); } } } @@ -757,13 +701,11 @@ async function getExpressionSuggestionsByType( const [rightArg] = nodeArg.args[1] as [ESQLSingleAstItem]; const nodeArgType = extractTypeFromASTArg(rightArg, references); suggestions.push( - ...getBuiltinCompatibleFunctionDefinition( - command.name, - undefined, - isParameterType(nodeArgType) ? nodeArgType : 'any', - undefined, - workoutBuiltinOptions(rightArg, references) - ) + ...getOperatorSuggestions({ + command: command.name, + leftParamType: isParameterType(nodeArgType) ? nodeArgType : 'any', + ignored: workoutBuiltinOptions(nodeArg, references).ignored, + }) ); if (isNumericType(nodeArgType) && isLiteralItem(rightArg)) { // ... EVAL var = 1 @@ -795,18 +737,16 @@ async function getExpressionSuggestionsByType( )) ); } else { - const nodeArgType = extractTypeFromASTArg(nodeArg, references); suggestions.push( - ...(await getBuiltinFunctionNextArgument( - innerText, - command, - option, - argDef, - nodeArg, - (nodeArgType as string) || 'any', - references, - getFieldsByType - )) + ...(await getSuggestionsToRightOfOperatorExpression({ + queryText: innerText, + commandName: command.name, + optionName: option?.name, + rootOperator: nodeArg, + getExpressionType: (expression) => + getExpressionType(expression, references.fields, references.variables), + getColumnsByType: getFieldsByType, + })) ); if (nodeArg.args.some(isTimeIntervalItem)) { const lastFnArg = nodeArg.args[nodeArg.args.length - 1]; @@ -846,7 +786,7 @@ async function getExpressionSuggestionsByType( // i.e. // ... | WHERE field NOT // there's not way to know the type of the field here, so suggest anything - suggestions.push(...getNextTokenForNot(command.name, option?.name, 'any')); + suggestions.push(...getSuggestionsAfterNot()); } else { // ... | // In this case start suggesting something not strictly based on type @@ -893,28 +833,25 @@ async function getExpressionSuggestionsByType( ); } else { suggestions.push( - ...(await getBuiltinFunctionNextArgument( - innerText, - command, - option, - argDef, - nodeArg, - nodeArgType as string, - references, - getFieldsByType - )) + ...(await getSuggestionsToRightOfOperatorExpression({ + queryText: innerText, + commandName: command.name, + optionName: option?.name, + rootOperator: nodeArg, + getExpressionType: (expression) => + getExpressionType(expression, references.fields, references.variables), + getColumnsByType: getFieldsByType, + })) ); } } else if (isParameterType(nodeArgType)) { // i.e. ... | field suggestions.push( - ...getBuiltinCompatibleFunctionDefinition( - command.name, - undefined, - nodeArgType, - undefined, - workoutBuiltinOptions(nodeArg, references) - ) + ...getOperatorSuggestions({ + command: command.name, + leftParamType: nodeArgType, + ignored: workoutBuiltinOptions(nodeArg, references).ignored, + }) ); } } @@ -1067,200 +1004,6 @@ async function getExpressionSuggestionsByType( return uniqBy(suggestions, (suggestion) => suggestion.text); } -async function getBuiltinFunctionNextArgument( - queryText: string, - command: ESQLCommand, - option: ESQLCommandOption | undefined, - argDef: { type: string }, - nodeArg: ESQLFunction, - nodeArgType: string, - references: Pick, - getFieldsByType: GetFieldsByTypeFn -) { - const suggestions = []; - const isFnComplete = isFunctionArgComplete(nodeArg, references); - - if (isFnComplete.complete) { - // i.e. ... | field > 0 - // i.e. ... | field + otherN - suggestions.push( - ...getBuiltinCompatibleFunctionDefinition( - command.name, - option?.name, - isParameterType(nodeArgType) ? nodeArgType : 'any', - undefined, - workoutBuiltinOptions(nodeArg, references) - ) - ); - } else { - // i.e. ... | field >= - // i.e. ... | field + - // i.e. ... | field and - - // Because it's an incomplete function, need to extract the type of the current argument - // and suggest the next argument based on types - - // pick the last arg and check its type to verify whether is incomplete for the given function - const cleanedArgs = removeMarkerArgFromArgsList(nodeArg)!.args; - const nestedType = extractTypeFromASTArg(nodeArg.args[cleanedArgs.length - 1], references); - - if (isFnComplete.reason === 'fewArgs') { - const fnDef = getFunctionDefinition(nodeArg.name); - if ( - fnDef?.signatures.every(({ params }) => - params.some(({ type }) => isArrayType(type as string)) - ) - ) { - suggestions.push(listCompleteItem); - } else { - const finalType = nestedType || nodeArgType || 'any'; - const supportedTypes = getSupportedTypesForBinaryOperators(fnDef, finalType as string); - - suggestions.push( - ...(await getFieldsOrFunctionsSuggestions( - // this is a special case with AND/OR - // expression AND/OR - // technically another boolean value should be suggested, but it is a better experience - // to actually suggest a wider set of fields/functions - finalType === 'boolean' && getFunctionDefinition(nodeArg.name)?.type === 'builtin' - ? ['any'] - : (supportedTypes as string[]), - command.name, - option?.name, - getFieldsByType, - { - functions: true, - fields: true, - variables: references.variables, - } - )) - ); - } - } - if (isFnComplete.reason === 'wrongTypes') { - if (nestedType) { - // suggest something to complete the builtin function - if ( - nestedType !== argDef.type && - isParameterType(nestedType) && - isReturnType(argDef.type) - ) { - suggestions.push( - ...getBuiltinCompatibleFunctionDefinition( - command.name, - undefined, - nestedType, - [argDef.type], - workoutBuiltinOptions(nodeArg, references) - ) - ); - } - } - } - } - return suggestions.map((s) => { - const overlap = getOverlapRange(queryText, s.text); - const offset = overlap.start === overlap.end ? 1 : 0; - return { - ...s, - rangeToReplace: { - start: overlap.start + offset, - end: overlap.end + offset, - }, - }; - }); -} - -function pushItUpInTheList(suggestions: SuggestionRawDefinition[], shouldPromote: boolean) { - if (!shouldPromote) { - return suggestions; - } - return suggestions.map(({ sortText, ...rest }) => ({ - ...rest, - sortText: `1${sortText}`, - })); -} - -/** - * TODO — split this into distinct functions, one for fields, one for functions, one for literals - */ -async function getFieldsOrFunctionsSuggestions( - types: string[], - commandName: string, - optionName: string | undefined, - getFieldsByType: GetFieldsByTypeFn, - { - functions, - fields, - variables, - literals = false, - }: { - functions: boolean; - fields: boolean; - variables?: Map; - literals?: boolean; - }, - { - ignoreFn = [], - ignoreColumns = [], - }: { - ignoreFn?: string[]; - ignoreColumns?: string[]; - } = {} -): Promise { - const filteredFieldsByType = pushItUpInTheList( - (await (fields - ? getFieldsByType(types, ignoreColumns, { - advanceCursor: commandName === 'sort', - openSuggestions: commandName === 'sort', - }) - : [])) as SuggestionRawDefinition[], - functions - ); - - const filteredVariablesByType: string[] = []; - if (variables) { - for (const variable of variables.values()) { - if ( - (types.includes('any') || types.includes(variable[0].type)) && - !ignoreColumns.includes(variable[0].name) - ) { - filteredVariablesByType.push(variable[0].name); - } - } - // due to a bug on the ES|QL table side, filter out fields list with underscored variable names (??) - // avg( numberField ) => avg_numberField_ - const ALPHANUMERIC_REGEXP = /[^a-zA-Z\d]/g; - if ( - filteredVariablesByType.length && - filteredVariablesByType.some((v) => ALPHANUMERIC_REGEXP.test(v)) - ) { - for (const variable of filteredVariablesByType) { - const underscoredName = variable.replace(ALPHANUMERIC_REGEXP, '_'); - const index = filteredFieldsByType.findIndex( - ({ label }) => underscoredName === label || `_${underscoredName}_` === label - ); - if (index >= 0) { - filteredFieldsByType.splice(index); - } - } - } - } - // could also be in stats (bucket) but our autocomplete is not great yet - const displayDateSuggestions = types.includes('date') && ['where', 'eval'].includes(commandName); - - const suggestions = filteredFieldsByType.concat( - displayDateSuggestions ? getDateLiterals() : [], - functions ? getCompatibleFunctionDefinition(commandName, optionName, types, ignoreFn) : [], - variables - ? pushItUpInTheList(buildVariablesDefinitions(filteredVariablesByType), functions) - : [], - literals ? getCompatibleLiterals(commandName, types) : [] - ); - - return suggestions; -} - const addCommaIf = (condition: boolean, text: string) => (condition ? `${text},` : text); async function getFunctionArgsSuggestions( @@ -1275,7 +1018,7 @@ async function getFunctionArgsSuggestions( option: ESQLCommandOption | undefined; node: ESQLFunction; }, - getFieldsByType: GetFieldsByTypeFn, + getFieldsByType: GetColumnsByTypeFn, getFieldsMap: GetFieldsMapFn, getPolicyMetadata: GetPolicyMetadataFn, fullText: string, @@ -1431,12 +1174,14 @@ async function getFunctionArgsSuggestions( // Functions suggestions.push( - ...getCompatibleFunctionDefinition( - command.name, - option?.name, - canBeBooleanCondition ? ['any'] : (getTypesFromParamDefs(typesToSuggestNext) as string[]), - fnToIgnore - ).map((suggestion) => ({ + ...getFunctionSuggestions({ + command: command.name, + option: option?.name, + returnTypes: canBeBooleanCondition + ? ['any'] + : (getTypesFromParamDefs(typesToSuggestNext) as string[]), + ignored: fnToIgnore, + }).map((suggestion) => ({ ...suggestion, text: addCommaIf(shouldAddComma, suggestion.text), })) @@ -1504,7 +1249,7 @@ async function getListArgsSuggestions( command: ESQLCommand; node: ESQLSingleAstItem | undefined; }, - getFieldsByType: GetFieldsByTypeFn, + getFieldsByType: GetColumnsByTypeFn, getFieldsMaps: GetFieldsMapFn, getPolicyMetadata: GetPolicyMetadataFn ) { @@ -1559,16 +1304,16 @@ async function getSettingArgsSuggestions( command: ESQLCommand; node: ESQLSingleAstItem | undefined; }, - getFieldsByType: GetFieldsByTypeFn, + getFieldsByType: GetColumnsByTypeFn, getFieldsMaps: GetFieldsMapFn, getPolicyMetadata: GetPolicyMetadataFn ) { const suggestions = []; - const settingDefs = getCommandDefinition(command.name).modes; + const settingDefs = getCommandDefinition(command.name).modes || []; if (settingDefs.length) { - const lastChar = getLastCharFromTrimmed(innerText); + const lastChar = getLastNonWhitespaceChar(innerText); const matchingSettingDefs = settingDefs.filter(({ prefix }) => lastChar === prefix); if (matchingSettingDefs.length) { // COMMAND _ @@ -1578,6 +1323,10 @@ async function getSettingArgsSuggestions( return suggestions; } +/** + * @deprecated — this will disappear when https://github.com/elastic/kibana/issues/195418 is complete + * because "options" will be handled in imperative command-specific routines instead of being independent. + */ async function getOptionArgsSuggestions( innerText: string, commands: ESQLCommand[], @@ -1590,28 +1339,21 @@ async function getOptionArgsSuggestions( option: ESQLCommandOption; node: ESQLSingleAstItem | undefined; }, - getFieldsByType: GetFieldsByTypeFn, + getFieldsByType: GetColumnsByTypeFn, getFieldsMaps: GetFieldsMapFn, - getPolicyMetadata: GetPolicyMetadataFn, - getPreferences?: () => Promise<{ histogramBarTarget: number } | undefined> + getPolicyMetadata: GetPolicyMetadataFn ) { - let preferences: { histogramBarTarget: number } | undefined; - if (getPreferences) { - preferences = await getPreferences(); - } - const optionDef = getCommandOption(option.name); - const { nodeArg, argIndex, lastArg } = extractArgMeta(option, node); + if (!optionDef || !optionDef.signature) { + return []; + } + const { nodeArg, lastArg } = extractArgMeta(option, node); const suggestions = []; const isNewExpression = isRestartingExpression(innerText) || option.args.length === 0; const fieldsMap = await getFieldsMaps(); const anyVariables = collectVariables(commands, fieldsMap, innerText); - const references = { - fields: fieldsMap, - variables: anyVariables, - }; if (command.name === 'enrich') { if (option.name === 'on') { // if it's a new expression, suggest fields to match on @@ -1653,7 +1395,7 @@ async function getOptionArgsSuggestions( ); if (isNewExpression || noCaseCompare(findPreviousWord(innerText), 'WITH')) { - suggestions.push(buildNewVarDefinition(findNewVariable(anyEnhancedVariables))); + suggestions.push(getNewVariableSuggestion(findNewVariable(anyEnhancedVariables))); } // make sure to remove the marker arg from the assign fn @@ -1693,10 +1435,7 @@ async function getOptionArgsSuggestions( // ... | ENRICH ... WITH a // effectively only assign will apper suggestions.push( - ...pushItUpInTheList( - getBuiltinCompatibleFunctionDefinition(command.name, undefined, 'any'), - true - ) + ...pushItUpInTheList(getOperatorSuggestions({ command: command.name }), true) ); } @@ -1776,53 +1515,6 @@ async function getOptionArgsSuggestions( } } - if (command.name === 'stats') { - const argDef = optionDef?.signature.params[argIndex]; - - const nodeArgType = extractTypeFromASTArg(nodeArg, references); - // These cases can happen here, so need to identify each and provide the right suggestion - // i.e. ... | STATS ... BY field + - // i.e. ... | STATS ... BY field >= - - if (nodeArgType) { - if (isFunctionItem(nodeArg) && !isFunctionArgComplete(nodeArg, references).complete) { - suggestions.push( - ...(await getBuiltinFunctionNextArgument( - innerText, - command, - option, - { type: argDef?.type || 'unknown' }, - nodeArg, - nodeArgType as string, - { - fields: references.fields, - // you can't use a variable defined - // in the stats command in the by clause - variables: new Map(), - }, - getFieldsByType - )) - ); - } - } - - // If it's a complete expression then propose some final suggestions - if ( - (!nodeArgType && - option.name === 'by' && - option.args.length && - !isNewExpression && - !isAssignment(lastArg)) || - (isAssignment(lastArg) && isAssignmentComplete(lastArg)) - ) { - suggestions.push( - ...getFinalSuggestions({ - comma: optionDef?.signature.multipleParams ?? option.name === 'by', - }) - ); - } - } - if (optionDef) { if (!suggestions.length) { const argDefIndex = optionDef.signature.multipleParams @@ -1848,287 +1540,8 @@ async function getOptionArgsSuggestions( openSuggestions: true, })) ); - // Checks if cursor is still within function () - // by checking if the marker editor/cursor is within an unclosed parenthesis - const canHaveAssignment = countBracketsUnclosed('(', innerText) === 0; - - if (option.name === 'by') { - // Add quick snippet for for stats ... by bucket(<>) - if (command.name === 'stats' && canHaveAssignment) { - suggestions.push({ - label: i18n.translate( - 'kbn-esql-validation-autocomplete.esql.autocomplete.addDateHistogram', - { - defaultMessage: 'Add date histogram', - } - ), - text: getAddDateHistogramSnippet(preferences?.histogramBarTarget), - asSnippet: true, - kind: 'Issue', - detail: i18n.translate( - 'kbn-esql-validation-autocomplete.esql.autocomplete.addDateHistogramDetail', - { - defaultMessage: 'Add date histogram using bucket()', - } - ), - sortText: '1A', - command: TRIGGER_SUGGESTION_COMMAND, - } as SuggestionRawDefinition); - } - - suggestions.push( - ...(await getFieldsOrFunctionsSuggestions( - types[0] === 'column' ? ['any'] : types, - command.name, - option.name, - getFieldsByType, - { - functions: true, - fields: false, - }, - { ignoreFn: canHaveAssignment ? [] : ['bucket', 'case'] } - )) - ); - } - - if (command.name === 'stats' && isNewExpression && canHaveAssignment) { - suggestions.push(buildNewVarDefinition(findNewVariable(anyVariables))); - } } } } return suggestions; } - -/** - * This function handles the logic to suggest completions - * for a given fragment of text in a generic way. A good example is - * a field name. - * - * When typing a field name, there are 2 scenarios - * - * 1. field name is incomplete (includes the empty string) - * KEEP / - * KEEP fie/ - * - * 2. field name is complete - * KEEP field/ - * - * This function provides a framework for detecting and handling both scenarios in a clean way. - * - * @param innerText - the query text before the current cursor position - * @param isFragmentComplete — return true if the fragment is complete - * @param getSuggestionsForIncomplete — gets suggestions for an incomplete fragment - * @param getSuggestionsForComplete - gets suggestions for a complete fragment - * @returns - */ -function handleFragment( - innerText: string, - isFragmentComplete: (fragment: string) => boolean, - getSuggestionsForIncomplete: ( - fragment: string, - rangeToReplace?: { start: number; end: number } - ) => SuggestionRawDefinition[] | Promise, - getSuggestionsForComplete: ( - fragment: string, - rangeToReplace: { start: number; end: number } - ) => SuggestionRawDefinition[] | Promise -): SuggestionRawDefinition[] | Promise { - /** - * @TODO — this string manipulation is crude and can't support all cases - * Checking for a partial word and computing the replacement range should - * really be done using the AST node, but we'll have to refactor further upstream - * to make that available. This is a quick fix to support the most common case. - */ - const fragment = findFinalWord(innerText); - if (!fragment) { - return getSuggestionsForIncomplete(''); - } else { - const rangeToReplace = { - start: innerText.length - fragment.length + 1, - end: innerText.length + 1, - }; - if (isFragmentComplete(fragment)) { - return getSuggestionsForComplete(fragment, rangeToReplace); - } else { - return getSuggestionsForIncomplete(fragment, rangeToReplace); - } - } -} - -const sortModifierSuggestions = { - ASC: { - label: 'ASC', - text: 'ASC', - detail: '', - kind: 'Keyword', - sortText: '1-ASC', - command: TRIGGER_SUGGESTION_COMMAND, - } as SuggestionRawDefinition, - DESC: { - label: 'DESC', - text: 'DESC', - detail: '', - kind: 'Keyword', - sortText: '1-DESC', - command: TRIGGER_SUGGESTION_COMMAND, - } as SuggestionRawDefinition, - NULLS_FIRST: { - label: 'NULLS FIRST', - text: 'NULLS FIRST', - detail: '', - kind: 'Keyword', - sortText: '2-NULLS FIRST', - command: TRIGGER_SUGGESTION_COMMAND, - } as SuggestionRawDefinition, - NULLS_LAST: { - label: 'NULLS LAST', - text: 'NULLS LAST', - detail: '', - kind: 'Keyword', - sortText: '2-NULLS LAST', - command: TRIGGER_SUGGESTION_COMMAND, - } as SuggestionRawDefinition, -}; - -export const suggestForSortCmd = async ( - innerText: string, - getFieldsByType: GetFieldsByTypeFn, - columnExists: (column: string) => boolean -): Promise => { - const prependSpace = (s: SuggestionRawDefinition) => ({ ...s, text: ' ' + s.text }); - - const { pos, nulls } = getSortPos(innerText); - - switch (pos) { - case 'space2': { - return [ - sortModifierSuggestions.ASC, - sortModifierSuggestions.DESC, - sortModifierSuggestions.NULLS_FIRST, - sortModifierSuggestions.NULLS_LAST, - pipeCompleteItem, - { ...commaCompleteItem, text: ', ', command: TRIGGER_SUGGESTION_COMMAND }, - ]; - } - case 'order': { - return handleFragment( - innerText, - (fragment) => ['ASC', 'DESC'].some((completeWord) => noCaseCompare(completeWord, fragment)), - (_fragment, rangeToReplace) => { - return Object.values(sortModifierSuggestions).map((suggestion) => ({ - ...suggestion, - rangeToReplace, - })); - }, - (fragment, rangeToReplace) => { - return [ - { ...pipeCompleteItem, text: ' | ' }, - { ...commaCompleteItem, text: ', ' }, - prependSpace(sortModifierSuggestions.NULLS_FIRST), - prependSpace(sortModifierSuggestions.NULLS_LAST), - ].map((suggestion) => ({ - ...suggestion, - filterText: fragment, - text: fragment + suggestion.text, - rangeToReplace, - command: TRIGGER_SUGGESTION_COMMAND, - })); - } - ); - } - case 'space3': { - return [ - sortModifierSuggestions.NULLS_FIRST, - sortModifierSuggestions.NULLS_LAST, - pipeCompleteItem, - { ...commaCompleteItem, text: ', ', command: TRIGGER_SUGGESTION_COMMAND }, - ]; - } - case 'nulls': { - return handleFragment( - innerText, - (fragment) => - ['FIRST', 'LAST'].some((completeWord) => noCaseCompare(completeWord, fragment)), - (_fragment) => { - const end = innerText.length + 1; - const start = end - nulls.length; - return Object.values(sortModifierSuggestions).map((suggestion) => ({ - ...suggestion, - // we can't use the range generated by handleFragment here - // because it doesn't really support multi-word completions - rangeToReplace: { start, end }, - })); - }, - (fragment, rangeToReplace) => { - return [ - { ...pipeCompleteItem, text: ' | ' }, - { ...commaCompleteItem, text: ', ' }, - ].map((suggestion) => ({ - ...suggestion, - filterText: fragment, - text: fragment + suggestion.text, - rangeToReplace, - command: TRIGGER_SUGGESTION_COMMAND, - })); - } - ); - } - case 'space4': { - return [ - pipeCompleteItem, - { ...commaCompleteItem, text: ', ', command: TRIGGER_SUGGESTION_COMMAND }, - ]; - } - } - - const fieldSuggestions = await getFieldsByType('any', [], { - openSuggestions: true, - }); - const functionSuggestions = await getFieldsOrFunctionsSuggestions( - ['any'], - 'sort', - undefined, - getFieldsByType, - { - functions: true, - fields: false, - } - ); - - return await handleFragment( - innerText, - columnExists, - (_fragment: string, rangeToReplace?: { start: number; end: number }) => { - // SORT fie - return [ - ...pushItUpInTheList( - fieldSuggestions.map((suggestion) => ({ - ...suggestion, - command: TRIGGER_SUGGESTION_COMMAND, - rangeToReplace, - })), - true - ), - ...functionSuggestions, - ]; - }, - (fragment: string, rangeToReplace: { start: number; end: number }) => { - // SORT field - return [ - { ...pipeCompleteItem, text: ' | ' }, - { ...commaCompleteItem, text: ', ' }, - prependSpace(sortModifierSuggestions.ASC), - prependSpace(sortModifierSuggestions.DESC), - prependSpace(sortModifierSuggestions.NULLS_FIRST), - prependSpace(sortModifierSuggestions.NULLS_LAST), - ].map((s) => ({ - ...s, - filterText: fragment, - text: fragment + s.text, - command: TRIGGER_SUGGESTION_COMMAND, - rangeToReplace, - })); - } - ); -}; diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/drop/index.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/drop/index.ts new file mode 100644 index 0000000000000..43eb272ba203a --- /dev/null +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/drop/index.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", 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 { ESQLCommand } from '@kbn/esql-ast'; +import { + findPreviousWord, + getLastNonWhitespaceChar, + isColumnItem, + noCaseCompare, +} from '../../../shared/helpers'; +import type { GetColumnsByTypeFn, SuggestionRawDefinition } from '../../types'; +import { commaCompleteItem, pipeCompleteItem } from '../../complete_items'; +import { handleFragment } from '../../helper'; +import { TRIGGER_SUGGESTION_COMMAND } from '../../factories'; + +export async function suggest( + innerText: string, + command: ESQLCommand<'drop'>, + getColumnsByType: GetColumnsByTypeFn, + columnExists: (column: string) => boolean +): Promise { + if ( + /\s/.test(innerText[innerText.length - 1]) && + getLastNonWhitespaceChar(innerText) !== ',' && + !noCaseCompare(findPreviousWord(innerText), 'drop') + ) { + return [pipeCompleteItem, commaCompleteItem]; + } + + const alreadyDeclaredFields = command.args.filter(isColumnItem).map((arg) => arg.name); + const fieldSuggestions = await getColumnsByType('any', alreadyDeclaredFields); + + return handleFragment( + innerText, + (fragment) => columnExists(fragment), + (_fragment: string, rangeToReplace?: { start: number; end: number }) => { + // KEEP fie + return fieldSuggestions.map((suggestion) => ({ + ...suggestion, + text: suggestion.text, + command: TRIGGER_SUGGESTION_COMMAND, + rangeToReplace, + })); + }, + (fragment: string, rangeToReplace: { start: number; end: number }) => { + // KEEP field + const finalSuggestions = [{ ...pipeCompleteItem, text: ' | ' }]; + if (fieldSuggestions.length > 1) + // when we fix the editor marker, this should probably be checked against 0 instead of 1 + // this is because the last field in the AST is currently getting removed (because it contains + // the editor marker) so it is not included in the ignored list which is used to filter out + // existing fields above. + finalSuggestions.push({ ...commaCompleteItem, text: ', ' }); + + return finalSuggestions.map((s) => ({ + ...s, + filterText: fragment, + text: fragment + s.text, + command: TRIGGER_SUGGESTION_COMMAND, + rangeToReplace, + })); + } + ); +} diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/keep/index.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/keep/index.ts new file mode 100644 index 0000000000000..85d5c716b20a6 --- /dev/null +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/keep/index.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", 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 { ESQLCommand } from '@kbn/esql-ast'; +import { + findPreviousWord, + getLastNonWhitespaceChar, + isColumnItem, + noCaseCompare, +} from '../../../shared/helpers'; +import type { GetColumnsByTypeFn, SuggestionRawDefinition } from '../../types'; +import { commaCompleteItem, pipeCompleteItem } from '../../complete_items'; +import { handleFragment } from '../../helper'; +import { TRIGGER_SUGGESTION_COMMAND } from '../../factories'; + +export async function suggest( + innerText: string, + command: ESQLCommand<'keep'>, + getColumnsByType: GetColumnsByTypeFn, + columnExists: (column: string) => boolean +): Promise { + if ( + /\s/.test(innerText[innerText.length - 1]) && + getLastNonWhitespaceChar(innerText) !== ',' && + !noCaseCompare(findPreviousWord(innerText), 'keep') + ) { + return [pipeCompleteItem, commaCompleteItem]; + } + + const alreadyDeclaredFields = command.args.filter(isColumnItem).map((arg) => arg.name); + const fieldSuggestions = await getColumnsByType('any', alreadyDeclaredFields); + + return handleFragment( + innerText, + (fragment) => columnExists(fragment), + (_fragment: string, rangeToReplace?: { start: number; end: number }) => { + // KEEP fie + return fieldSuggestions.map((suggestion) => ({ + ...suggestion, + text: suggestion.text, + command: TRIGGER_SUGGESTION_COMMAND, + rangeToReplace, + })); + }, + (fragment: string, rangeToReplace: { start: number; end: number }) => { + // KEEP field + const finalSuggestions = [{ ...pipeCompleteItem, text: ' | ' }]; + if (fieldSuggestions.length > 1) + // when we fix the editor marker, this should probably be checked against 0 instead of 1 + // this is because the last field in the AST is currently getting removed (because it contains + // the editor marker) so it is not included in the ignored list which is used to filter out + // existing fields above. + finalSuggestions.push({ ...commaCompleteItem, text: ', ' }); + + return finalSuggestions.map((s) => ({ + ...s, + filterText: fragment, + text: fragment + s.text, + command: TRIGGER_SUGGESTION_COMMAND, + rangeToReplace, + })); + } + ); +} diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/sort/helper.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/sort/helper.ts index 96546eff7d391..63dea06667cd8 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/sort/helper.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/sort/helper.ts @@ -7,6 +7,9 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { TRIGGER_SUGGESTION_COMMAND } from '../../factories'; +import { SuggestionRawDefinition } from '../../types'; + const regexStart = /.+\|\s*so?r?(?t?)(.+,)?(?\s+)?/i; const regex = /.+\|\s*sort(.+,)?((?\s+)(?[^\s]+)(?\s*)(?(AS?C?)|(DE?S?C?))?(?\s*)(?NU?L?L?S? ?(FI?R?S?T?|LA?S?T?)?)?(?\s*))?/i; @@ -43,6 +46,41 @@ export interface SortCaretPosition { nulls: string; } +export const sortModifierSuggestions = { + ASC: { + label: 'ASC', + text: 'ASC', + detail: '', + kind: 'Keyword', + sortText: '1-ASC', + command: TRIGGER_SUGGESTION_COMMAND, + } as SuggestionRawDefinition, + DESC: { + label: 'DESC', + text: 'DESC', + detail: '', + kind: 'Keyword', + sortText: '1-DESC', + command: TRIGGER_SUGGESTION_COMMAND, + } as SuggestionRawDefinition, + NULLS_FIRST: { + label: 'NULLS FIRST', + text: 'NULLS FIRST', + detail: '', + kind: 'Keyword', + sortText: '2-NULLS FIRST', + command: TRIGGER_SUGGESTION_COMMAND, + } as SuggestionRawDefinition, + NULLS_LAST: { + label: 'NULLS LAST', + text: 'NULLS LAST', + detail: '', + kind: 'Keyword', + sortText: '2-NULLS LAST', + command: TRIGGER_SUGGESTION_COMMAND, + } as SuggestionRawDefinition, +}; + export const getSortPos = (query: string): SortCaretPosition => { const match = query.match(regex); let pos: SortCaretPosition['pos'] = 'none'; diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/sort/index.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/sort/index.ts new file mode 100644 index 0000000000000..61561dea96b72 --- /dev/null +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/sort/index.ts @@ -0,0 +1,159 @@ +/* + * Copyright 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 { ESQLCommand } from '@kbn/esql-ast'; +import { noCaseCompare } from '../../../shared/helpers'; +import { commaCompleteItem, pipeCompleteItem } from '../../complete_items'; +import { TRIGGER_SUGGESTION_COMMAND } from '../../factories'; +import { getFieldsOrFunctionsSuggestions, handleFragment, pushItUpInTheList } from '../../helper'; +import type { GetColumnsByTypeFn, SuggestionRawDefinition } from '../../types'; +import { getSortPos, sortModifierSuggestions } from './helper'; + +export async function suggest( + innerText: string, + _command: ESQLCommand<'sort'>, + getColumnsByType: GetColumnsByTypeFn, + columnExists: (column: string) => boolean +): Promise { + const prependSpace = (s: SuggestionRawDefinition) => ({ ...s, text: ' ' + s.text }); + + const { pos, nulls } = getSortPos(innerText); + + switch (pos) { + case 'space2': { + return [ + sortModifierSuggestions.ASC, + sortModifierSuggestions.DESC, + sortModifierSuggestions.NULLS_FIRST, + sortModifierSuggestions.NULLS_LAST, + pipeCompleteItem, + { ...commaCompleteItem, text: ', ', command: TRIGGER_SUGGESTION_COMMAND }, + ]; + } + case 'order': { + return handleFragment( + innerText, + (fragment) => ['ASC', 'DESC'].some((completeWord) => noCaseCompare(completeWord, fragment)), + (_fragment, rangeToReplace) => { + return Object.values(sortModifierSuggestions).map((suggestion) => ({ + ...suggestion, + rangeToReplace, + })); + }, + (fragment, rangeToReplace) => { + return [ + { ...pipeCompleteItem, text: ' | ' }, + { ...commaCompleteItem, text: ', ' }, + prependSpace(sortModifierSuggestions.NULLS_FIRST), + prependSpace(sortModifierSuggestions.NULLS_LAST), + ].map((suggestion) => ({ + ...suggestion, + filterText: fragment, + text: fragment + suggestion.text, + rangeToReplace, + command: TRIGGER_SUGGESTION_COMMAND, + })); + } + ); + } + case 'space3': { + return [ + sortModifierSuggestions.NULLS_FIRST, + sortModifierSuggestions.NULLS_LAST, + pipeCompleteItem, + { ...commaCompleteItem, text: ', ', command: TRIGGER_SUGGESTION_COMMAND }, + ]; + } + case 'nulls': { + return handleFragment( + innerText, + (fragment) => + ['FIRST', 'LAST'].some((completeWord) => noCaseCompare(completeWord, fragment)), + (_fragment) => { + const end = innerText.length + 1; + const start = end - nulls.length; + return Object.values(sortModifierSuggestions).map((suggestion) => ({ + ...suggestion, + // we can't use the range generated by handleFragment here + // because it doesn't really support multi-word completions + rangeToReplace: { start, end }, + })); + }, + (fragment, rangeToReplace) => { + return [ + { ...pipeCompleteItem, text: ' | ' }, + { ...commaCompleteItem, text: ', ' }, + ].map((suggestion) => ({ + ...suggestion, + filterText: fragment, + text: fragment + suggestion.text, + rangeToReplace, + command: TRIGGER_SUGGESTION_COMMAND, + })); + } + ); + } + case 'space4': { + return [ + pipeCompleteItem, + { ...commaCompleteItem, text: ', ', command: TRIGGER_SUGGESTION_COMMAND }, + ]; + } + } + + const fieldSuggestions = await getColumnsByType('any', [], { + openSuggestions: true, + }); + const functionSuggestions = await getFieldsOrFunctionsSuggestions( + ['any'], + 'sort', + undefined, + getColumnsByType, + { + functions: true, + fields: false, + } + ); + + return await handleFragment( + innerText, + columnExists, + (_fragment: string, rangeToReplace?: { start: number; end: number }) => { + // SORT fie + return [ + ...pushItUpInTheList( + fieldSuggestions.map((suggestion) => ({ + ...suggestion, + command: TRIGGER_SUGGESTION_COMMAND, + rangeToReplace, + })), + true + ), + ...functionSuggestions, + ]; + }, + (fragment: string, rangeToReplace: { start: number; end: number }) => { + // SORT field + return [ + { ...pipeCompleteItem, text: ' | ' }, + { ...commaCompleteItem, text: ', ' }, + prependSpace(sortModifierSuggestions.ASC), + prependSpace(sortModifierSuggestions.DESC), + prependSpace(sortModifierSuggestions.NULLS_FIRST), + prependSpace(sortModifierSuggestions.NULLS_LAST), + ].map((s) => ({ + ...s, + filterText: fragment, + text: fragment + s.text, + command: TRIGGER_SUGGESTION_COMMAND, + rangeToReplace, + })); + } + ); +} diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/stats/index.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/stats/index.ts new file mode 100644 index 0000000000000..ac70ac1a1a5ca --- /dev/null +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/stats/index.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", 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 { ESQLAstItem, ESQLCommand } from '@kbn/esql-ast'; +import { SupportedDataType } from '../../../definitions/types'; +import type { GetColumnsByTypeFn, SuggestionRawDefinition } from '../../types'; +import { + TRIGGER_SUGGESTION_COMMAND, + getNewVariableSuggestion, + getFunctionSuggestions, +} from '../../factories'; +import { commaCompleteItem, pipeCompleteItem } from '../../complete_items'; +import { pushItUpInTheList } from '../../helper'; +import { byCompleteItem, getDateHistogramCompletionItem, getPosition } from './util'; + +export async function suggest( + innerText: string, + command: ESQLCommand<'stats'>, + getColumnsByType: GetColumnsByTypeFn, + _columnExists: (column: string) => boolean, + getSuggestedVariableName: () => string, + _getExpressionType: (expression: ESQLAstItem | undefined) => SupportedDataType | 'unknown', + getPreferences?: () => Promise<{ histogramBarTarget: number } | undefined> +): Promise { + const pos = getPosition(innerText, command); + + const columnSuggestions = pushItUpInTheList( + await getColumnsByType('any', [], { advanceCursor: true, openSuggestions: true }), + true + ); + + switch (pos) { + case 'expression_without_assignment': + return [ + ...getFunctionSuggestions({ command: 'stats' }), + getNewVariableSuggestion(getSuggestedVariableName()), + ]; + + case 'expression_after_assignment': + return [...getFunctionSuggestions({ command: 'stats' })]; + + case 'expression_complete': + return [ + byCompleteItem, + pipeCompleteItem, + { ...commaCompleteItem, command: TRIGGER_SUGGESTION_COMMAND, text: ', ' }, + ]; + + case 'grouping_expression_after_assignment': + return [ + ...getFunctionSuggestions({ command: 'stats', option: 'by' }), + getDateHistogramCompletionItem((await getPreferences?.())?.histogramBarTarget), + ...columnSuggestions, + ]; + + case 'grouping_expression_without_assignment': + return [ + ...getFunctionSuggestions({ command: 'stats', option: 'by' }), + getDateHistogramCompletionItem((await getPreferences?.())?.histogramBarTarget), + ...columnSuggestions, + getNewVariableSuggestion(getSuggestedVariableName()), + ]; + + case 'grouping_expression_complete': + return [ + pipeCompleteItem, + { ...commaCompleteItem, command: TRIGGER_SUGGESTION_COMMAND, text: ', ' }, + ]; + + default: + return []; + } +} diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/stats/util.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/stats/util.ts new file mode 100644 index 0000000000000..c9abaa5c5408a --- /dev/null +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/stats/util.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { ESQLCommand } from '@kbn/esql-ast'; +import { i18n } from '@kbn/i18n'; +import { + findPreviousWord, + getLastNonWhitespaceChar, + isAssignment, + isAssignmentComplete, + isOptionItem, + noCaseCompare, +} from '../../../shared/helpers'; +import { SuggestionRawDefinition } from '../../types'; +import { TIME_SYSTEM_PARAMS, TRIGGER_SUGGESTION_COMMAND } from '../../factories'; + +/** + * Position of the caret in the sort command: +* +* ``` +* STATS [column1 =] expression1[, ..., [columnN =] expressionN] [BY [column1 =] grouping_expression1[, ..., grouping_expressionN]] + | | | | | | + | | expression_complete | | grouping_expression_complete + | expression_after_assignment | grouping_expression_after_assignment + expression_without_assignment grouping_expression_without_assignment + +* ``` +*/ +export type CaretPosition = + | 'expression_without_assignment' + | 'expression_after_assignment' + | 'expression_complete' + | 'grouping_expression_without_assignment' + | 'grouping_expression_after_assignment' + | 'grouping_expression_complete'; + +export const getPosition = (innerText: string, command: ESQLCommand): CaretPosition => { + const lastCommandArg = command.args[command.args.length - 1]; + + if (isOptionItem(lastCommandArg) && lastCommandArg.name === 'by') { + // in the BY clause + + const lastOptionArg = lastCommandArg.args[lastCommandArg.args.length - 1]; + if (isAssignment(lastOptionArg) && !isAssignmentComplete(lastOptionArg)) { + return 'grouping_expression_after_assignment'; + } + + if ( + getLastNonWhitespaceChar(innerText) === ',' || + noCaseCompare(findPreviousWord(innerText), 'by') + ) { + return 'grouping_expression_without_assignment'; + } else { + return 'grouping_expression_complete'; + } + } + + if (isAssignment(lastCommandArg) && !isAssignmentComplete(lastCommandArg)) { + return 'expression_after_assignment'; + } + + if ( + getLastNonWhitespaceChar(innerText) === ',' || + noCaseCompare(findPreviousWord(innerText), 'stats') + ) { + return 'expression_without_assignment'; + } else { + return 'expression_complete'; + } +}; + +export const byCompleteItem: SuggestionRawDefinition = { + label: 'BY', + text: 'BY ', + kind: 'Reference', + detail: 'By', + sortText: '1', + command: TRIGGER_SUGGESTION_COMMAND, +}; + +export const getDateHistogramCompletionItem: ( + histogramBarTarget?: number +) => SuggestionRawDefinition = (histogramBarTarget: number = 50) => ({ + label: i18n.translate('kbn-esql-validation-autocomplete.esql.autocomplete.addDateHistogram', { + defaultMessage: 'Add date histogram', + }), + text: `BUCKET($0, ${histogramBarTarget}, ${TIME_SYSTEM_PARAMS.join(', ')})`, + asSnippet: true, + kind: 'Issue', + detail: i18n.translate( + 'kbn-esql-validation-autocomplete.esql.autocomplete.addDateHistogramDetail', + { + defaultMessage: 'Add date histogram using bucket()', + } + ), + sortText: '1A', + command: TRIGGER_SUGGESTION_COMMAND, +}); diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/where/index.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/where/index.ts new file mode 100644 index 0000000000000..dc2ab341e961e --- /dev/null +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/where/index.ts @@ -0,0 +1,183 @@ +/* + * Copyright 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 { + Walker, + type ESQLAstItem, + type ESQLCommand, + type ESQLSingleAstItem, + type ESQLFunction, +} from '@kbn/esql-ast'; +import { logicalOperators } from '../../../definitions/builtin'; +import { isParameterType, type SupportedDataType } from '../../../definitions/types'; +import { isFunctionItem } from '../../../shared/helpers'; +import type { GetColumnsByTypeFn, SuggestionRawDefinition } from '../../types'; +import { + getFunctionSuggestions, + getOperatorSuggestion, + getOperatorSuggestions, + getSuggestionsAfterNot, +} from '../../factories'; +import { getOverlapRange, getSuggestionsToRightOfOperatorExpression } from '../../helper'; +import { getPosition } from './util'; +import { pipeCompleteItem } from '../../complete_items'; + +export async function suggest( + innerText: string, + command: ESQLCommand<'where'>, + getColumnsByType: GetColumnsByTypeFn, + _columnExists: (column: string) => boolean, + _getSuggestedVariableName: () => string, + getExpressionType: (expression: ESQLAstItem | undefined) => SupportedDataType | 'unknown', + _getPreferences?: () => Promise<{ histogramBarTarget: number } | undefined> +): Promise { + const suggestions: SuggestionRawDefinition[] = []; + + /** + * The logic for WHERE suggestions is basically the logic for expression suggestions. + * I assume we will eventually extract much of this to be a shared function among WHERE and EVAL + * and anywhere else the user can enter a generic expression. + */ + const expressionRoot = command.args[0] as ESQLSingleAstItem | undefined; + + switch (getPosition(innerText, command)) { + /** + * After a column name + */ + case 'after_column': + const columnType = getExpressionType(expressionRoot); + + if (!isParameterType(columnType)) { + break; + } + + suggestions.push( + ...getOperatorSuggestions({ + command: 'where', + leftParamType: columnType, + // no assignments allowed in WHERE + ignored: ['='], + }) + ); + break; + + /** + * After a complete (non-operator) function call + */ + case 'after_function': + const returnType = getExpressionType(expressionRoot); + + if (!isParameterType(returnType)) { + break; + } + + suggestions.push( + ...getOperatorSuggestions({ + command: 'where', + leftParamType: returnType, + ignored: ['='], + }) + ); + + break; + + /** + * After a NOT keyword + * + * the NOT function is a special operator that can be used in different ways, + * and not all these are mapped within the AST data structure: in particular + * NOT + * is an incomplete statement and it results in a missing AST node, so we need to detect + * from the query string itself + * + * (this comment was copied but seems to still apply) + */ + case 'after_not': + if (expressionRoot && isFunctionItem(expressionRoot) && expressionRoot.name === 'not') { + suggestions.push( + ...getFunctionSuggestions({ command: 'where', returnTypes: ['boolean'] }), + ...(await getColumnsByType('boolean', [], { advanceCursor: true, openSuggestions: true })) + ); + } else { + suggestions.push(...getSuggestionsAfterNot()); + } + + break; + + /** + * After an operator (e.g. AND, OR, IS NULL, +, etc.) + */ + case 'after_operator': + if (!expressionRoot) { + break; + } + + if (!isFunctionItem(expressionRoot) || expressionRoot.subtype === 'variadic-call') { + // this is already guaranteed in the getPosition function, but TypeScript doesn't know + break; + } + + let rightmostOperator = expressionRoot; + // get rightmost function + const walker = new Walker({ + visitFunction: (fn: ESQLFunction) => { + if (fn.location.min > rightmostOperator.location.min && fn.subtype !== 'variadic-call') + rightmostOperator = fn; + }, + }); + walker.walkFunction(expressionRoot); + + // See https://github.com/elastic/kibana/issues/199401 for an explanation of + // why this check has to be so convoluted + if (rightmostOperator.text.toLowerCase().trim().endsWith('null')) { + suggestions.push(...logicalOperators.map(getOperatorSuggestion)); + break; + } + + suggestions.push( + ...(await getSuggestionsToRightOfOperatorExpression({ + queryText: innerText, + commandName: 'where', + rootOperator: rightmostOperator, + preferredExpressionType: 'boolean', + getExpressionType, + getColumnsByType, + })) + ); + + break; + + case 'empty_expression': + const columnSuggestions = await getColumnsByType('any', [], { + advanceCursor: true, + openSuggestions: true, + }); + suggestions.push(...columnSuggestions, ...getFunctionSuggestions({ command: 'where' })); + + break; + } + + // Is this a complete expression of the right type? + // If so, we can call it done and suggest a pipe + if (getExpressionType(expressionRoot) === 'boolean') { + suggestions.push(pipeCompleteItem); + } + + return suggestions.map((s) => { + const overlap = getOverlapRange(innerText, s.text); + const offset = overlap.start === overlap.end ? 1 : 0; + return { + ...s, + rangeToReplace: { + start: overlap.start + offset, + end: overlap.end + offset, + }, + }; + }); +} diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/where/util.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/where/util.ts new file mode 100644 index 0000000000000..c969e7e37461f --- /dev/null +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/where/util.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", 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 { ESQLCommand, ESQLSingleAstItem } from '@kbn/esql-ast'; +import { isColumnItem, isFunctionItem } from '../../../shared/helpers'; + +export type CaretPosition = + | 'after_column' + | 'after_function' + | 'after_not' + | 'after_operator' + | 'empty_expression'; + +export const getPosition = (innerText: string, command: ESQLCommand): CaretPosition => { + const expressionRoot = command.args[0] as ESQLSingleAstItem | undefined; + + const endsWithNot = / not$/i.test(innerText.trimEnd()); + if ( + endsWithNot && + !( + expressionRoot && + isFunctionItem(expressionRoot) && + // See https://github.com/elastic/kibana/issues/199401 + // for more information on this check... + ['is null', 'is not null'].includes(expressionRoot.name) + ) + ) { + return 'after_not'; + } + + if (expressionRoot) { + if (isColumnItem(expressionRoot)) { + return 'after_column'; + } + + if (isFunctionItem(expressionRoot) && expressionRoot.subtype === 'variadic-call') { + return 'after_function'; + } + + if (isFunctionItem(expressionRoot) && expressionRoot.subtype !== 'variadic-call') { + return 'after_operator'; + } + } + + return 'empty_expression'; +}; diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/complete_items.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/complete_items.ts index 000c196b49e5e..0c448d4814f96 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/complete_items.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/complete_items.ts @@ -10,86 +10,22 @@ import { i18n } from '@kbn/i18n'; import type { ItemKind, SuggestionRawDefinition } from './types'; import { builtinFunctions } from '../definitions/builtin'; -import { getAllCommands } from '../shared/helpers'; import { - getSuggestionBuiltinDefinition, + getOperatorSuggestion, getSuggestionCommandDefinition, TRIGGER_SUGGESTION_COMMAND, - buildConstantsDefinitions, } from './factories'; -import { FunctionParameterType, FunctionReturnType } from '../definitions/types'; -import { getTestFunctions } from '../shared/test_functions'; +import { CommandDefinition } from '../definitions/types'; export function getAssignmentDefinitionCompletitionItem() { const assignFn = builtinFunctions.find(({ name }) => name === '=')!; - return getSuggestionBuiltinDefinition(assignFn); + return getOperatorSuggestion(assignFn); } -export const getNextTokenForNot = ( - command: string, - option: string | undefined, - argType: string -): SuggestionRawDefinition[] => { - const compatibleFunctions = builtinFunctions.filter( - ({ name, supportedCommands, supportedOptions, ignoreAsSuggestion }) => - !ignoreAsSuggestion && - !/not_/.test(name) && - (option ? supportedOptions?.includes(option) : supportedCommands.includes(command)) - ); - if (argType === 'string' || argType === 'any') { - // suggest IS, LIKE, RLIKE and TRUE/FALSE - return compatibleFunctions - .filter(({ name }) => name === 'like' || name === 'rlike' || name === 'in') - .map(getSuggestionBuiltinDefinition); - } - if (argType === 'boolean') { - // suggest IS, NOT and TRUE/FALSE - return [ - ...compatibleFunctions - .filter(({ name }) => name === 'in') - .map(getSuggestionBuiltinDefinition), - ...buildConstantsDefinitions(['true', 'false']), - ]; - } - return []; -}; - -export const getBuiltinCompatibleFunctionDefinition = ( - command: string, - option: string | undefined, - argType: FunctionParameterType, - returnTypes?: FunctionReturnType[], - { skipAssign, commandsToInclude }: { skipAssign?: boolean; commandsToInclude?: string[] } = {} -): SuggestionRawDefinition[] => { - const compatibleFunctions = [...builtinFunctions, ...getTestFunctions()].filter( - ({ name, supportedCommands, supportedOptions, signatures, ignoreAsSuggestion }) => - (command === 'where' && commandsToInclude ? commandsToInclude.indexOf(name) > -1 : true) && - !ignoreAsSuggestion && - (!skipAssign || name !== '=') && - (option ? supportedOptions?.includes(option) : supportedCommands.includes(command)) && - signatures.some( - ({ params }) => - !params.length || params.some((pArg) => pArg.type === argType || pArg.type === 'any') - ) - ); - if (!returnTypes) { - return compatibleFunctions.map(getSuggestionBuiltinDefinition); - } - return compatibleFunctions - .filter((mathDefinition) => - mathDefinition.signatures.some( - (signature) => - returnTypes[0] === 'unknown' || - returnTypes[0] === 'any' || - returnTypes.includes(signature.returnType) - ) - ) - .map(getSuggestionBuiltinDefinition); -}; - -export const commandAutocompleteDefinitions: SuggestionRawDefinition[] = getAllCommands() - .filter(({ hidden }) => !hidden) - .map(getSuggestionCommandDefinition); +export const getCommandAutocompleteDefinitions = ( + commands: Array> +): SuggestionRawDefinition[] => + commands.filter(({ hidden }) => !hidden).map(getSuggestionCommandDefinition); function buildCharCompleteItem( label: string, diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts index 85c8d035d33b1..88560f6d2f4c5 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts @@ -20,6 +20,7 @@ import { CommandDefinition, CommandOptionsDefinition, CommandModeDefinition, + FunctionParameterType, } from '../definitions/types'; import { shouldBeQuotedSource, getCommandDefinition, shouldBeQuotedText } from '../shared/helpers'; import { buildDocumentation, buildFunctionDocumentation } from './documentation_util'; @@ -27,6 +28,7 @@ import { DOUBLE_BACKTICK, SINGLE_TICK_REGEX } from '../shared/constants'; import { ESQLRealField } from '../validation/types'; import { isNumericType } from '../shared/esql_types'; import { getTestFunctions } from '../shared/test_functions'; +import { builtinFunctions } from '../definitions/builtin'; const allFunctions = memoize( () => @@ -39,10 +41,6 @@ const allFunctions = memoize( export const TIME_SYSTEM_PARAMS = ['?_tstart', '?_tend']; -export const getAddDateHistogramSnippet = (histogramBarTarget = 50) => { - return `BUCKET($0, ${histogramBarTarget}, ${TIME_SYSTEM_PARAMS.join(', ')})`; -}; - export const TRIGGER_SUGGESTION_COMMAND = { title: 'Trigger Suggestion Dialog', id: 'editor.action.triggerSuggest', @@ -61,7 +59,7 @@ function getSafeInsertSourceText(text: string) { return shouldBeQuotedSource(text) ? getQuotedText(text) : text; } -export function getSuggestionFunctionDefinition(fn: FunctionDefinition): SuggestionRawDefinition { +export function getFunctionSuggestion(fn: FunctionDefinition): SuggestionRawDefinition { const fullSignatures = getFunctionSignatures(fn, { capitalize: true, withTypes: true }); return { label: fn.name.toUpperCase(), @@ -79,7 +77,7 @@ export function getSuggestionFunctionDefinition(fn: FunctionDefinition): Suggest }; } -export function getSuggestionBuiltinDefinition(fn: FunctionDefinition): SuggestionRawDefinition { +export function getOperatorSuggestion(fn: FunctionDefinition): SuggestionRawDefinition { const hasArgs = fn.signatures.some(({ params }) => params.length > 1); return { label: fn.name.toUpperCase(), @@ -95,35 +93,97 @@ export function getSuggestionBuiltinDefinition(fn: FunctionDefinition): Suggesti }; } -export const getCompatibleFunctionDefinition = ( - command: string, - option: string | undefined, - returnTypes?: string[], - ignored: string[] = [] -): SuggestionRawDefinition[] => { - const fnSupportedByCommand = allFunctions() - .filter( - ({ name, supportedCommands, supportedOptions, ignoreAsSuggestion }) => - (option ? supportedOptions?.includes(option) : supportedCommands.includes(command)) && - !ignored.includes(name) && - !ignoreAsSuggestion - ) - .sort((a, b) => a.name.localeCompare(b.name)); - if (!returnTypes) { - return fnSupportedByCommand.map(getSuggestionFunctionDefinition); +interface FunctionFilterPredicates { + command?: string; + option?: string | undefined; + returnTypes?: string[]; + ignored?: string[]; +} + +export const filterFunctionDefinitions = ( + functions: FunctionDefinition[], + predicates: FunctionFilterPredicates | undefined +): FunctionDefinition[] => { + if (!predicates) { + return functions; } - return fnSupportedByCommand - .filter((mathDefinition) => - mathDefinition.signatures.some( - (signature) => - returnTypes[0] === 'any' || returnTypes.includes(signature.returnType as string) - ) - ) - .map(getSuggestionFunctionDefinition); + const { command, option, returnTypes, ignored = [] } = predicates; + return functions.filter( + ({ name, supportedCommands, supportedOptions, ignoreAsSuggestion, signatures }) => { + if (ignoreAsSuggestion) { + return false; + } + + if (ignored.includes(name)) { + return false; + } + + if (option && !supportedOptions?.includes(option)) { + return false; + } + + if (command && !supportedCommands.includes(command)) { + return false; + } + + if (returnTypes && !returnTypes.includes('any')) { + return signatures.some((signature) => returnTypes.includes(signature.returnType as string)); + } + + return true; + } + ); +}; + +/** + * Builds suggestions for functions based on the provided predicates. + * + * @param predicates a set of conditions that must be met for a function to be included in the suggestions + * @returns + */ +export const getFunctionSuggestions = ( + predicates?: FunctionFilterPredicates +): SuggestionRawDefinition[] => { + return filterFunctionDefinitions(allFunctions(), predicates).map(getFunctionSuggestion); +}; + +/** + * Builds suggestions for operators based on the provided predicates. + * + * @param predicates a set of conditions that must be met for an operator to be included in the suggestions + * @returns + */ +export const getOperatorSuggestions = ( + predicates?: FunctionFilterPredicates & { leftParamType?: FunctionParameterType } +): SuggestionRawDefinition[] => { + const filteredDefinitions = filterFunctionDefinitions( + getTestFunctions().length ? [...builtinFunctions, ...getTestFunctions()] : builtinFunctions, + predicates + ); + + // make sure the operator has at least one signature that matches + // the type of the existing left argument if provided (e.g. "doubleField ") + return ( + predicates?.leftParamType + ? filteredDefinitions.filter(({ signatures }) => + signatures.some( + ({ params }) => + !params.length || + params.some((pArg) => pArg.type === predicates?.leftParamType || pArg.type === 'any') + ) + ) + : filteredDefinitions + ).map(getOperatorSuggestion); +}; + +export const getSuggestionsAfterNot = (): SuggestionRawDefinition[] => { + return builtinFunctions + .filter(({ name }) => name === 'like' || name === 'rlike' || name === 'in') + .map(getOperatorSuggestion); }; export function getSuggestionCommandDefinition( - command: CommandDefinition + command: CommandDefinition ): SuggestionRawDefinition { const commandDefinition = getCommandDefinition(command.name); const commandSignature = getCommandSignature(commandDefinition); @@ -253,7 +313,7 @@ export const buildValueDefinitions = ( command: options?.advanceCursorAndOpenSuggestions ? TRIGGER_SUGGESTION_COMMAND : undefined, })); -export const buildNewVarDefinition = (label: string): SuggestionRawDefinition => { +export const getNewVariableSuggestion = (label: string): SuggestionRawDefinition => { return { label, text: `${label} = `, diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.test.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.test.ts new file mode 100644 index 0000000000000..c4133592c425d --- /dev/null +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.test.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { getOverlapRange } from './helper'; + +describe('getOverlapRange', () => { + it('should return the overlap range', () => { + expect(getOverlapRange('IS N', 'IS NOT NULL')).toEqual({ start: 1, end: 5 }); + expect(getOverlapRange('I', 'IS NOT NULL')).toEqual({ start: 1, end: 2 }); + }); + + it('full query', () => { + expect(getOverlapRange('FROM index | WHERE field IS N', 'IS NOT NULL')).toEqual({ + start: 26, + end: 30, + }); + }); +}); diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts index dd450e28b66a9..67ea324a1a69a 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts @@ -7,21 +7,47 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { ESQLAstItem, ESQLCommand, ESQLFunction, ESQLSource } from '@kbn/esql-ast'; +import type { + ESQLAstItem, + ESQLCommand, + ESQLFunction, + ESQLLiteral, + ESQLSource, +} from '@kbn/esql-ast'; import { uniqBy } from 'lodash'; -import type { FunctionDefinition } from '../definitions/types'; import { + isParameterType, + type FunctionDefinition, + type FunctionReturnType, + type SupportedDataType, + isReturnType, +} from '../definitions/types'; +import { + findFinalWord, + getColumnForASTNode, getFunctionDefinition, + isArrayType, isAssignment, + isColumnItem, isFunctionItem, + isIdentifier, isLiteralItem, + isTimeIntervalItem, } from '../shared/helpers'; -import type { SuggestionRawDefinition } from './types'; +import type { GetColumnsByTypeFn, SuggestionRawDefinition } from './types'; import { compareTypesWithLiterals } from '../shared/esql_types'; -import { TIME_SYSTEM_PARAMS } from './factories'; +import { + TIME_SYSTEM_PARAMS, + buildVariablesDefinitions, + getFunctionSuggestions, + getCompatibleLiterals, + getDateLiterals, + getOperatorSuggestions, +} from './factories'; import { EDITOR_MARKER } from '../shared/constants'; -import { extractTypeFromASTArg } from './autocomplete'; -import { ESQLRealField, ESQLVariable } from '../validation/types'; +import { ESQLRealField, ESQLVariable, ReferenceMaps } from '../validation/types'; +import { listCompleteItem } from './complete_items'; +import { removeMarkerArgFromArgsList } from '../shared/context'; function extractFunctionArgs(args: ESQLAstItem[]): ESQLFunction[] { return args.flatMap((arg) => (isAssignment(arg) ? arg.args[1] : arg)).filter(isFunctionItem); @@ -188,9 +214,10 @@ export function getOverlapRange( } } + // add one since Monaco columns are 1-based return { - start: Math.min(query.length - overlapLength + 1, query.length), - end: query.length, + start: query.length - overlapLength + 1, + end: query.length + 1, }; } @@ -272,3 +299,370 @@ export function getValidSignaturesAndTypesToSuggestNext( currentArg, }; } + +/** + * This function handles the logic to suggest completions + * for a given fragment of text in a generic way. A good example is + * a field name. + * + * When typing a field name, there are 2 scenarios + * + * 1. field name is incomplete (includes the empty string) + * KEEP / + * KEEP fie/ + * + * 2. field name is complete + * KEEP field/ + * + * This function provides a framework for detecting and handling both scenarios in a clean way. + * + * @param innerText - the query text before the current cursor position + * @param isFragmentComplete — return true if the fragment is complete + * @param getSuggestionsForIncomplete — gets suggestions for an incomplete fragment + * @param getSuggestionsForComplete - gets suggestions for a complete fragment + * @returns + */ +export function handleFragment( + innerText: string, + isFragmentComplete: (fragment: string) => boolean, + getSuggestionsForIncomplete: ( + fragment: string, + rangeToReplace?: { start: number; end: number } + ) => SuggestionRawDefinition[] | Promise, + getSuggestionsForComplete: ( + fragment: string, + rangeToReplace: { start: number; end: number } + ) => SuggestionRawDefinition[] | Promise +): SuggestionRawDefinition[] | Promise { + /** + * @TODO — this string manipulation is crude and can't support all cases + * Checking for a partial word and computing the replacement range should + * really be done using the AST node, but we'll have to refactor further upstream + * to make that available. This is a quick fix to support the most common case. + */ + const fragment = findFinalWord(innerText); + if (!fragment) { + return getSuggestionsForIncomplete(''); + } else { + const rangeToReplace = { + start: innerText.length - fragment.length + 1, + end: innerText.length + 1, + }; + if (isFragmentComplete(fragment)) { + return getSuggestionsForComplete(fragment, rangeToReplace); + } else { + return getSuggestionsForIncomplete(fragment, rangeToReplace); + } + } +} +/** + * TODO — split this into distinct functions, one for fields, one for functions, one for literals + */ +export async function getFieldsOrFunctionsSuggestions( + types: string[], + commandName: string, + optionName: string | undefined, + getFieldsByType: GetColumnsByTypeFn, + { + functions, + fields, + variables, + literals = false, + }: { + functions: boolean; + fields: boolean; + variables?: Map; + literals?: boolean; + }, + { + ignoreFn = [], + ignoreColumns = [], + }: { + ignoreFn?: string[]; + ignoreColumns?: string[]; + } = {} +): Promise { + const filteredFieldsByType = pushItUpInTheList( + (await (fields + ? getFieldsByType(types, ignoreColumns, { + advanceCursor: commandName === 'sort', + openSuggestions: commandName === 'sort', + }) + : [])) as SuggestionRawDefinition[], + functions + ); + + const filteredVariablesByType: string[] = []; + if (variables) { + for (const variable of variables.values()) { + if ( + (types.includes('any') || types.includes(variable[0].type)) && + !ignoreColumns.includes(variable[0].name) + ) { + filteredVariablesByType.push(variable[0].name); + } + } + // due to a bug on the ES|QL table side, filter out fields list with underscored variable names (??) + // avg( numberField ) => avg_numberField_ + const ALPHANUMERIC_REGEXP = /[^a-zA-Z\d]/g; + if ( + filteredVariablesByType.length && + filteredVariablesByType.some((v) => ALPHANUMERIC_REGEXP.test(v)) + ) { + for (const variable of filteredVariablesByType) { + const underscoredName = variable.replace(ALPHANUMERIC_REGEXP, '_'); + const index = filteredFieldsByType.findIndex( + ({ label }) => underscoredName === label || `_${underscoredName}_` === label + ); + if (index >= 0) { + filteredFieldsByType.splice(index); + } + } + } + } + // could also be in stats (bucket) but our autocomplete is not great yet + const displayDateSuggestions = types.includes('date') && ['where', 'eval'].includes(commandName); + + const suggestions = filteredFieldsByType.concat( + displayDateSuggestions ? getDateLiterals() : [], + functions + ? getFunctionSuggestions({ + command: commandName, + option: optionName, + returnTypes: types, + ignored: ignoreFn, + }) + : [], + variables + ? pushItUpInTheList(buildVariablesDefinitions(filteredVariablesByType), functions) + : [], + literals ? getCompatibleLiterals(commandName, types) : [] + ); + + return suggestions; +} + +export function pushItUpInTheList(suggestions: SuggestionRawDefinition[], shouldPromote: boolean) { + if (!shouldPromote) { + return suggestions; + } + return suggestions.map(({ sortText, ...rest }) => ({ + ...rest, + sortText: `1${sortText}`, + })); +} + +/** @deprecated — use getExpressionType instead (packages/kbn-esql-validation-autocomplete/src/shared/helpers.ts) */ +export function extractTypeFromASTArg( + arg: ESQLAstItem, + references: Pick +): + | ESQLLiteral['literalType'] + | SupportedDataType + | FunctionReturnType + | 'timeInterval' + | string // @TODO remove this + | undefined { + if (Array.isArray(arg)) { + return extractTypeFromASTArg(arg[0], references); + } + if (isLiteralItem(arg)) { + return arg.literalType; + } + if (isColumnItem(arg) || isIdentifier(arg)) { + const hit = getColumnForASTNode(arg, references); + if (hit) { + return hit.type; + } + } + if (isTimeIntervalItem(arg)) { + return arg.type; + } + if (isFunctionItem(arg)) { + const fnDef = getFunctionDefinition(arg.name); + if (fnDef) { + // @TODO: improve this to better filter down the correct return type based on existing arguments + // just mind that this can be highly recursive... + return fnDef.signatures[0].returnType; + } + } +} + +// @TODO: refactor this to be shared with validation +export function checkFunctionInvocationComplete( + func: ESQLFunction, + getExpressionType: (expression: ESQLAstItem) => SupportedDataType | 'unknown' +): { + complete: boolean; + reason?: 'tooFewArgs' | 'wrongTypes'; +} { + const fnDefinition = getFunctionDefinition(func.name); + if (!fnDefinition) { + return { complete: false }; + } + const cleanedArgs = removeMarkerArgFromArgsList(func)!.args; + const argLengthCheck = fnDefinition.signatures.some((def) => { + if (def.minParams && cleanedArgs.length >= def.minParams) { + return true; + } + if (cleanedArgs.length === def.params.length) { + return true; + } + return cleanedArgs.length >= def.params.filter(({ optional }) => !optional).length; + }); + if (!argLengthCheck) { + return { complete: false, reason: 'tooFewArgs' }; + } + if (fnDefinition.name === 'in' && Array.isArray(func.args[1]) && !func.args[1].length) { + return { complete: false, reason: 'tooFewArgs' }; + } + const hasCorrectTypes = fnDefinition.signatures.some((def) => { + return func.args.every((a, index) => { + return ( + (fnDefinition.name.endsWith('null') && def.params[index].type === 'any') || + def.params[index].type === getExpressionType(a) + ); + }); + }); + if (!hasCorrectTypes) { + return { complete: false, reason: 'wrongTypes' }; + } + return { complete: true }; +} + +/** + * This function is used to + * - suggest the next argument for an incomplete or incorrect binary operator expression (e.g. field > ) + * - suggest an operator to the right of a complete binary operator expression (e.g. field > 0 ) + * - suggest an operator to the right of a complete unary operator (e.g. field IS NOT NULL ) + * + * TODO — is this function doing too much? + */ +export async function getSuggestionsToRightOfOperatorExpression({ + queryText, + commandName, + optionName, + rootOperator: operator, + preferredExpressionType, + getExpressionType, + getColumnsByType, +}: { + queryText: string; + commandName: string; + optionName?: string; + rootOperator: ESQLFunction; + preferredExpressionType?: SupportedDataType; + getExpressionType: (expression: ESQLAstItem) => SupportedDataType | 'unknown'; + getColumnsByType: GetColumnsByTypeFn; +}) { + const suggestions = []; + const isFnComplete = checkFunctionInvocationComplete(operator, getExpressionType); + if (isFnComplete.complete) { + // i.e. ... | field > 0 + // i.e. ... | field + otherN + const operatorReturnType = getExpressionType(operator); + suggestions.push( + ...getOperatorSuggestions({ + command: commandName, + option: optionName, + // here we use the operator return type because we're suggesting operators that could + // accept the result of the existing operator as a left operand + leftParamType: + operatorReturnType === 'unknown' || operatorReturnType === 'unsupported' + ? 'any' + : operatorReturnType, + ignored: ['='], + }) + ); + } else { + // i.e. ... | field >= + // i.e. ... | field + + // i.e. ... | field and + + // Because it's an incomplete function, need to extract the type of the current argument + // and suggest the next argument based on types + + // pick the last arg and check its type to verify whether is incomplete for the given function + const cleanedArgs = removeMarkerArgFromArgsList(operator)!.args; + const leftArgType = getExpressionType(operator.args[cleanedArgs.length - 1]); + + if (isFnComplete.reason === 'tooFewArgs') { + const fnDef = getFunctionDefinition(operator.name); + if ( + fnDef?.signatures.every(({ params }) => + params.some(({ type }) => isArrayType(type as string)) + ) + ) { + suggestions.push(listCompleteItem); + } else { + const finalType = leftArgType || leftArgType || 'any'; + const supportedTypes = getSupportedTypesForBinaryOperators(fnDef, finalType as string); + + // this is a special case with AND/OR + // expression AND/OR + // technically another boolean value should be suggested, but it is a better experience + // to actually suggest a wider set of fields/functions + const typeToUse = + finalType === 'boolean' && getFunctionDefinition(operator.name)?.type === 'builtin' + ? ['any'] + : (supportedTypes as string[]); + + // TODO replace with fields callback + function suggestions + suggestions.push( + ...(await getFieldsOrFunctionsSuggestions( + typeToUse, + commandName, + optionName, + getColumnsByType, + { + functions: true, + fields: true, + } + )) + ); + } + } + + /** + * If the caller has supplied a preferred expression type, we can suggest operators that + * would move the user toward that expression type. + * + * e.g. if we have a preferred type of boolean and we have `timestamp > "2002" AND doubleField` + * this is an incorrect signature for AND because the left side is boolean and the right side is double + * + * Knowing that we prefer boolean expressions, we suggest operators that would accept doubleField as a left operand + * and also return a boolean value. + * + * I believe this is only used in WHERE and probably bears some rethinking. + */ + if (isFnComplete.reason === 'wrongTypes') { + if (leftArgType && preferredExpressionType) { + // suggest something to complete the operator + if ( + leftArgType !== preferredExpressionType && + isParameterType(leftArgType) && + isReturnType(preferredExpressionType) + ) { + suggestions.push( + ...getOperatorSuggestions({ + command: commandName, + leftParamType: leftArgType, + returnTypes: [preferredExpressionType], + }) + ); + } + } + } + } + return suggestions.map((s) => { + const overlap = getOverlapRange(queryText, s.text); + const offset = overlap.start === overlap.end ? 1 : 0; + return { + ...s, + rangeToReplace: { + start: overlap.start + offset, + end: overlap.end + offset, + }, + }; + }); +} diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/recommended_queries/suggestions.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/recommended_queries/suggestions.ts index fbcfbabb2b63c..29c598af93501 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/recommended_queries/suggestions.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/recommended_queries/suggestions.ts @@ -7,11 +7,11 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { SuggestionRawDefinition, GetFieldsByTypeFn } from '../types'; +import type { SuggestionRawDefinition, GetColumnsByTypeFn } from '../types'; import { getRecommendedQueries } from './templates'; export const getRecommendedQueriesSuggestions = async ( - getFieldsByType: GetFieldsByTypeFn, + getFieldsByType: GetColumnsByTypeFn, fromCommand: string = '' ): Promise => { const fieldSuggestions = await getFieldsByType('date', [], { diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/types.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/types.ts index 030bff4da181c..cbd6ead535932 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/types.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/types.ts @@ -81,7 +81,7 @@ export interface EditorContext { triggerKind: number; } -export type GetFieldsByTypeFn = ( +export type GetColumnsByTypeFn = ( type: string | string[], ignored?: string[], options?: { advanceCursor?: boolean; openSuggestions?: boolean; addComma?: boolean } diff --git a/packages/kbn-esql-validation-autocomplete/src/code_actions/actions.test.ts b/packages/kbn-esql-validation-autocomplete/src/code_actions/actions.test.ts index b608570854950..4563379642767 100644 --- a/packages/kbn-esql-validation-autocomplete/src/code_actions/actions.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/code_actions/actions.test.ts @@ -18,7 +18,7 @@ import type { ESQLCallbacks, PartialFieldsMetadataClient } from '../shared/types function getCallbackMocks(): jest.Mocked { return { - getFieldsFor: jest.fn, any>(async ({ query }) => { + getColumnsFor: jest.fn, any>(async ({ query }) => { if (/enrich/.test(query)) { const fields: ESQLRealField[] = [ { name: 'otherField', type: 'keyword' }, @@ -375,11 +375,11 @@ describe('quick fixes logic', () => { const statement = `FROM index | DROP any#Char$Field`; const { errors } = await validateQuery(statement, getAstAndSyntaxErrors, undefined, { ...callbackMocks, - getFieldsFor: undefined, + getColumnsFor: undefined, }); const edits = await getActions(statement, errors, getAstAndSyntaxErrors, undefined, { ...callbackMocks, - getFieldsFor: undefined, + getColumnsFor: undefined, }); expect(edits.length).toBe(0); }); @@ -400,7 +400,7 @@ describe('quick fixes logic', () => { const statement = `FROM index | DROP any#Char$Field`; const { errors } = await validateQuery(statement, getAstAndSyntaxErrors, undefined, { ...callbackMocks, - getFieldsFor: undefined, + getColumnsFor: undefined, getFieldsMetadata: undefined, }); const actions = await getActions( @@ -412,7 +412,7 @@ describe('quick fixes logic', () => { }, { ...callbackMocks, - getFieldsFor: undefined, + getColumnsFor: undefined, getFieldsMetadata: undefined, } ); @@ -435,7 +435,7 @@ describe('quick fixes logic', () => { ); try { await getActions(statement, errors, getAstAndSyntaxErrors, undefined, { - getFieldsFor: undefined, + getColumnsFor: undefined, getSources: undefined, getPolicies: undefined, }); @@ -460,7 +460,7 @@ describe('quick fixes logic', () => { getAstAndSyntaxErrors, { relaxOnMissingCallbacks: true }, { - getFieldsFor: undefined, + getColumnsFor: undefined, getSources: undefined, getPolicies: undefined, getFieldsMetadata: undefined, diff --git a/packages/kbn-esql-validation-autocomplete/src/code_actions/actions.ts b/packages/kbn-esql-validation-autocomplete/src/code_actions/actions.ts index 02627c5f1abdf..7c9d5d7ae8ba2 100644 --- a/packages/kbn-esql-validation-autocomplete/src/code_actions/actions.ts +++ b/packages/kbn-esql-validation-autocomplete/src/code_actions/actions.ts @@ -20,6 +20,7 @@ import { getAllFunctions, getCommandDefinition, isColumnItem, + isIdentifier, isSourceItem, shouldBeQuotedText, } from '../shared/helpers'; @@ -138,7 +139,7 @@ function extractUnquotedFieldText( if (errorType === 'syntaxError') { // scope it down to column items for now const { node } = getAstContext(query, ast, possibleStart - 1); - if (node && isColumnItem(node)) { + if (node && (isColumnItem(node) || isIdentifier(node))) { return { start: node.location.min + 1, name: query.substring(node.location.min, end).trimEnd(), @@ -379,7 +380,7 @@ function inferCodeFromError( if (error.message.startsWith('SyntaxError: token recognition error at:')) { // scope it down to column items for now const { node } = getAstContext(rawText, ast, error.startColumn - 2); - return node && isColumnItem(node) ? 'quotableFields' : undefined; + return node && (isColumnItem(node) || isIdentifier(node)) ? 'quotableFields' : undefined; } } @@ -403,7 +404,7 @@ export async function getActions( const { getPolicies, getPolicyFields } = getPolicyRetriever(resourceRetriever); const callbacks = { - getFieldsByType: resourceRetriever?.getFieldsFor ? getFieldsByType : undefined, + getFieldsByType: resourceRetriever?.getColumnsFor ? getFieldsByType : undefined, getSources: resourceRetriever?.getSources ? getSources : undefined, getPolicies: resourceRetriever?.getPolicies ? getPolicies : undefined, getPolicyFields: resourceRetriever?.getPolicies ? getPolicyFields : undefined, diff --git a/packages/kbn-esql-validation-autocomplete/src/definitions/builtin.ts b/packages/kbn-esql-validation-autocomplete/src/definitions/builtin.ts index e71ed32e4c79d..3f5040efbcb10 100644 --- a/packages/kbn-esql-validation-autocomplete/src/definitions/builtin.ts +++ b/packages/kbn-esql-validation-autocomplete/src/definitions/builtin.ts @@ -528,7 +528,7 @@ const inFunctions: FunctionDefinition[] = [ ], })); -const logicFunctions: FunctionDefinition[] = [ +export const logicalOperators: FunctionDefinition[] = [ { name: 'and', description: i18n.translate('kbn-esql-validation-autocomplete.esql.definition.andDoc', { @@ -649,7 +649,7 @@ export const builtinFunctions: FunctionDefinition[] = [ ...comparisonFunctions, ...likeFunctions, ...inFunctions, - ...logicFunctions, + ...logicalOperators, ...nullFunctions, ...otherDefinitions, ]; diff --git a/packages/kbn-esql-validation-autocomplete/src/definitions/commands.ts b/packages/kbn-esql-validation-autocomplete/src/definitions/commands.ts index 54504ac1a2a18..950dac5e2d50b 100644 --- a/packages/kbn-esql-validation-autocomplete/src/definitions/commands.ts +++ b/packages/kbn-esql-validation-autocomplete/src/definitions/commands.ts @@ -20,6 +20,7 @@ import { isAssignment, isColumnItem, isFunctionItem, + isFunctionOperatorParam, isLiteralItem, } from '../shared/helpers'; import { ENRICH_MODES } from './settings'; @@ -32,6 +33,11 @@ import { withOption, } from './options'; import type { CommandDefinition } from './types'; +import { suggest as suggestForSort } from '../autocomplete/commands/sort'; +import { suggest as suggestForKeep } from '../autocomplete/commands/keep'; +import { suggest as suggestForDrop } from '../autocomplete/commands/drop'; +import { suggest as suggestForStats } from '../autocomplete/commands/stats'; +import { suggest as suggestForWhere } from '../autocomplete/commands/where'; const statsValidator = (command: ESQLCommand) => { const messages: ESQLMessage[] = []; @@ -68,7 +74,7 @@ const statsValidator = (command: ESQLCommand) => { function checkAggExistence(arg: ESQLFunction): boolean { // TODO the grouping function check may not // hold true for all future cases - if (isAggFunction(arg)) { + if (isAggFunction(arg) || isFunctionOperatorParam(arg)) { return true; } if (isOtherFunction(arg)) { @@ -148,7 +154,7 @@ const statsValidator = (command: ESQLCommand) => { } return messages; }; -export const commandDefinitions: CommandDefinition[] = [ +export const commandDefinitions: Array> = [ { name: 'row', description: i18n.translate('kbn-esql-validation-autocomplete.esql.definitions.rowDoc', { @@ -237,6 +243,7 @@ export const commandDefinitions: CommandDefinition[] = [ options: [byOption], modes: [], validate: statsValidator, + suggest: suggestForStats, }, { name: 'inlinestats', @@ -308,9 +315,11 @@ export const commandDefinitions: CommandDefinition[] = [ { name: 'keep', description: i18n.translate('kbn-esql-validation-autocomplete.esql.definitions.keepDoc', { - defaultMessage: 'Rearranges fields in the input table by applying the keep clauses in fields', + defaultMessage: + 'Rearranges fields in the Results table by applying the keep clauses in fields', }), examples: ['… | keep a', '… | keep a,b'], + suggest: suggestForKeep, options: [], modes: [], signature: { @@ -330,6 +339,7 @@ export const commandDefinitions: CommandDefinition[] = [ multipleParams: true, params: [{ name: 'column', type: 'column', wildcards: true }], }, + suggest: suggestForDrop, validate: (command: ESQLCommand) => { const messages: ESQLMessage[] = []; const wildcardItems = command.args.filter((arg) => isColumnItem(arg) && arg.name === '*'); @@ -386,7 +396,9 @@ export const commandDefinitions: CommandDefinition[] = [ multipleParams: true, params: [{ name: 'expression', type: 'any' }], }, + suggest: suggestForSort, }, + { name: 'where', description: i18n.translate('kbn-esql-validation-autocomplete.esql.definitions.whereDoc', { @@ -400,6 +412,7 @@ export const commandDefinitions: CommandDefinition[] = [ }, options: [], modes: [], + suggest: suggestForWhere, }, { name: 'dissect', 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 4b0ea8ee564ed..739a12095ac23 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 @@ -1294,7 +1294,7 @@ const dateDiffDefinition: FunctionDefinition = { validate: undefined, examples: [ 'ROW date1 = TO_DATETIME("2023-12-02T11:00:00.000Z"), date2 = TO_DATETIME("2023-12-02T11:00:00.001Z")\n| EVAL dd_ms = DATE_DIFF("microseconds", date1, date2)', - 'ROW end_23="2023-12-31T23:59:59.999Z"::DATETIME,\n start_24="2024-01-01T00:00:00.000Z"::DATETIME,\n end_24="2024-12-31T23:59:59.999"::DATETIME\n| EVAL end23_to_start24=DATE_DIFF("year", end_23, start_24)\n| EVAL end23_to_end24=DATE_DIFF("year", end_23, end_24)\n| EVAL start_to_end_24=DATE_DIFF("year", start_24, end_24)', + 'ROW end_23=TO_DATETIME("2023-12-31T23:59:59.999Z"),\n start_24=TO_DATETIME("2024-01-01T00:00:00.000Z"),\n end_24=TO_DATETIME("2024-12-31T23:59:59.999")\n| EVAL end23_to_start24=DATE_DIFF("year", end_23, start_24)\n| EVAL end23_to_end24=DATE_DIFF("year", end_23, end_24)\n| EVAL start_to_end_24=DATE_DIFF("year", start_24, end_24)', ], }; diff --git a/packages/kbn-esql-validation-autocomplete/src/definitions/helpers.ts b/packages/kbn-esql-validation-autocomplete/src/definitions/helpers.ts index 867c68ab4f1df..2b50c9da541ce 100644 --- a/packages/kbn-esql-validation-autocomplete/src/definitions/helpers.ts +++ b/packages/kbn-esql-validation-autocomplete/src/definitions/helpers.ts @@ -56,14 +56,13 @@ function handleAdditionalArgs( } export function getCommandSignature( - { name, signature, options, examples }: CommandDefinition, + { name, signature, options, examples }: CommandDefinition, { withTypes }: { withTypes: boolean } = { withTypes: true } ) { return { - declaration: `${name.toUpperCase()} ${printCommandArguments( - signature, - withTypes - )} ${options.map( + declaration: `${name.toUpperCase()} ${printCommandArguments(signature, withTypes)} ${( + options || [] + ).map( (option) => `${ option.wrapped ? option.wrapped[0] : '' @@ -76,7 +75,7 @@ export function getCommandSignature( } function printCommandArguments( - { multipleParams, params }: CommandDefinition['signature'], + { multipleParams, params }: CommandDefinition['signature'], withTypes: boolean ): string { return `${params.map((arg) => printCommandArgument(arg, withTypes)).join(', `')}${ @@ -87,7 +86,7 @@ function printCommandArguments( } function printCommandArgument( - param: CommandDefinition['signature']['params'][number], + param: CommandDefinition['signature']['params'][number], withTypes: boolean ): string { if (!withTypes) { diff --git a/packages/kbn-esql-validation-autocomplete/src/definitions/types.ts b/packages/kbn-esql-validation-autocomplete/src/definitions/types.ts index dee08766745df..a86811f535f8b 100644 --- a/packages/kbn-esql-validation-autocomplete/src/definitions/types.ts +++ b/packages/kbn-esql-validation-autocomplete/src/definitions/types.ts @@ -7,7 +7,14 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { ESQLCommand, ESQLCommandOption, ESQLFunction, ESQLMessage } from '@kbn/esql-ast'; +import type { + ESQLAstItem, + ESQLCommand, + ESQLCommandOption, + ESQLFunction, + ESQLMessage, +} from '@kbn/esql-ast'; +import { GetColumnsByTypeFn, SuggestionRawDefinition } from '../autocomplete/types'; /** * All supported field types in ES|QL. This is all the types @@ -158,14 +165,24 @@ export interface FunctionDefinition { validate?: (fnDef: ESQLFunction) => ESQLMessage[]; } -export interface CommandBaseDefinition { - name: string; +export interface CommandBaseDefinition { + name: CommandName; alias?: string; description: string; /** * Whether to show or hide in autocomplete suggestion list */ hidden?: boolean; + suggest?: ( + innerText: string, + command: ESQLCommand, + getColumnsByType: GetColumnsByTypeFn, + columnExists: (column: string) => boolean, + getSuggestedVariableName: () => string, + getExpressionType: (expression: ESQLAstItem | undefined) => SupportedDataType | 'unknown', + getPreferences?: () => Promise<{ histogramBarTarget: number } | undefined> + ) => Promise; + /** @deprecated this property will disappear in the future */ signature: { multipleParams: boolean; // innerTypes here is useful to drill down the type in case of "column" @@ -183,7 +200,8 @@ export interface CommandBaseDefinition { }; } -export interface CommandOptionsDefinition extends CommandBaseDefinition { +export interface CommandOptionsDefinition + extends CommandBaseDefinition { wrapped?: string[]; optional: boolean; skipCommonValidation?: boolean; @@ -201,12 +219,15 @@ export interface CommandModeDefinition { prefix?: string; } -export interface CommandDefinition extends CommandBaseDefinition { - options: CommandOptionsDefinition[]; +export interface CommandDefinition + extends CommandBaseDefinition { examples: string[]; validate?: (option: ESQLCommand) => ESQLMessage[]; - modes: CommandModeDefinition[]; hasRecommendedQueries?: boolean; + /** @deprecated this property will disappear in the future */ + modes: CommandModeDefinition[]; + /** @deprecated this property will disappear in the future */ + options: CommandOptionsDefinition[]; } export interface Literals { diff --git a/packages/kbn-esql-validation-autocomplete/src/shared/context.ts b/packages/kbn-esql-validation-autocomplete/src/shared/context.ts index 1c2e9075e95ff..cc7c36abf64f7 100644 --- a/packages/kbn-esql-validation-autocomplete/src/shared/context.ts +++ b/packages/kbn-esql-validation-autocomplete/src/shared/context.ts @@ -7,24 +7,26 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { - ESQLAstItem, - ESQLSingleAstItem, - ESQLAst, - ESQLFunction, - ESQLCommand, - ESQLCommandOption, - ESQLCommandMode, +import { + type ESQLAstItem, + type ESQLSingleAstItem, + type ESQLAst, + type ESQLFunction, + type ESQLCommand, + type ESQLCommandOption, + type ESQLCommandMode, + Walker, } from '@kbn/esql-ast'; import { ENRICH_MODES } from '../definitions/settings'; import { EDITOR_MARKER } from './constants'; import { isOptionItem, isColumnItem, - getFunctionDefinition, isSourceItem, isSettingItem, pipePrecedesCurrentWord, + getFunctionDefinition, + isIdentifier, } from './helpers'; function findNode(nodes: ESQLAstItem[], offset: number): ESQLSingleAstItem | undefined { @@ -86,7 +88,9 @@ function findCommandSubType( function isMarkerNode(node: ESQLSingleAstItem | undefined): boolean { return Boolean( - node && (isColumnItem(node) || isSourceItem(node)) && node.name.endsWith(EDITOR_MARKER) + node && + (isColumnItem(node) || isIdentifier(node) || isSourceItem(node)) && + node.name.endsWith(EDITOR_MARKER) ); } @@ -133,6 +137,7 @@ function findAstPosition(ast: ESQLAst, offset: number) { function isNotEnrichClauseAssigment(node: ESQLFunction, command: ESQLCommand) { return node.name !== '=' && command.name !== 'enrich'; } + function isBuiltinFunction(node: ESQLFunction) { return getFunctionDefinition(node.name)?.type === 'builtin'; } @@ -151,6 +156,20 @@ function isBuiltinFunction(node: ESQLFunction) { * * "newCommand": the cursor is at the beginning of a new command (i.e. `command1 | command2 | `) */ export function getAstContext(queryString: string, ast: ESQLAst, offset: number) { + let inComment = false; + + Walker.visitComments(ast, (node) => { + if (node.location && node.location.min <= offset && node.location.max > offset) { + inComment = true; + } + }); + + if (inComment) { + return { + type: 'comment' as const, + }; + } + const { command, option, setting, node } = findAstPosition(ast, offset); if (node) { if (node.type === 'literal' && node.literalType === 'keyword') { @@ -162,15 +181,18 @@ export function getAstContext(queryString: string, ast: ESQLAst, offset: number) // command ... a in ( ) return { type: 'list' as const, command, node, option, setting }; } - if (isNotEnrichClauseAssigment(node, command) && !isBuiltinFunction(node)) { + if ( + isNotEnrichClauseAssigment(node, command) && + // Temporarily mangling the logic here to let operators + // be handled as functions for the stats command. + // I expect this to simplify once https://github.com/elastic/kibana/issues/195418 + // is complete + !(isBuiltinFunction(node) && command.name !== 'stats') + ) { // command ... fn( ) return { type: 'function' as const, command, node, option, setting }; } } - if (node.type === 'option' || option) { - // command ... by - return { type: 'option' as const, command, node, option, setting }; - } // for now it's only an enrich thing if (node.type === 'source' && node.text === ENRICH_MODES.prefix) { // command _ @@ -182,7 +204,8 @@ export function getAstContext(queryString: string, ast: ESQLAst, offset: number) return { type: 'newCommand' as const, command: undefined, node, option, setting }; } - if (command && isOptionItem(command.args[command.args.length - 1])) { + // TODO — remove this option branch once https://github.com/elastic/kibana/issues/195418 is complete + if (command && isOptionItem(command.args[command.args.length - 1]) && command.name !== 'stats') { if (option) { return { type: 'option' as const, command, node, option, setting }; } diff --git a/packages/kbn-esql-validation-autocomplete/src/shared/helpers.test.ts b/packages/kbn-esql-validation-autocomplete/src/shared/helpers.test.ts index e2e6397005e22..f880143108ce6 100644 --- a/packages/kbn-esql-validation-autocomplete/src/shared/helpers.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/shared/helpers.test.ts @@ -8,7 +8,7 @@ */ import { parse } from '@kbn/esql-ast'; -import { getExpressionType, shouldBeQuotedSource } from './helpers'; +import { getBracketsToClose, getExpressionType, shouldBeQuotedSource } from './helpers'; import { SupportedDataType } from '../definitions/types'; import { setTestFunctions } from './test_functions'; @@ -57,6 +57,10 @@ describe('getExpressionType', () => { return root.commands[1].args[0]; }; + test('empty expression', () => { + expect(getExpressionType(getASTForExpression(''))).toBe('unknown'); + }); + describe('literal expressions', () => { const cases: Array<{ expression: string; expectedType: SupportedDataType }> = [ { @@ -289,6 +293,19 @@ describe('getExpressionType', () => { it('supports COUNT(*)', () => { expect(getExpressionType(getASTForExpression('COUNT(*)'))).toBe('long'); }); + + it('accounts for the "any" parameter type', () => { + setTestFunctions([ + { + type: 'eval', + name: 'test', + description: 'Test function', + supportedCommands: ['eval'], + signatures: [{ params: [{ name: 'arg', type: 'any' }], returnType: 'keyword' }], + }, + ]); + expect(getExpressionType(getASTForExpression('test(1)'))).toBe('keyword'); + }); }); describe('lists', () => { @@ -324,3 +341,16 @@ describe('getExpressionType', () => { ); }); }); + +describe('getBracketsToClose', () => { + it('returns the number of brackets to close', () => { + expect(getBracketsToClose('foo(bar(baz')).toEqual([')', ')']); + expect(getBracketsToClose('foo(bar[baz')).toEqual([']', ')']); + expect(getBracketsToClose('foo(bar[baz"bap')).toEqual(['"', ']', ')']); + expect( + getBracketsToClose( + 'from a | eval case(integerField < 0, "negative", integerField > 0, "positive", ' + ) + ).toEqual([')']); + }); +}); diff --git a/packages/kbn-esql-validation-autocomplete/src/shared/helpers.ts b/packages/kbn-esql-validation-autocomplete/src/shared/helpers.ts index 18d6ae6faa246..2c864a487026c 100644 --- a/packages/kbn-esql-validation-autocomplete/src/shared/helpers.ts +++ b/packages/kbn-esql-validation-autocomplete/src/shared/helpers.ts @@ -7,18 +7,24 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { - ESQLAstItem, - ESQLColumn, - ESQLCommandMode, - ESQLCommandOption, - ESQLFunction, - ESQLLiteral, - ESQLSingleAstItem, - ESQLSource, - ESQLTimeInterval, +import { + Walker, + type ESQLAstItem, + type ESQLColumn, + type ESQLCommandMode, + type ESQLCommandOption, + type ESQLFunction, + type ESQLLiteral, + type ESQLSingleAstItem, + type ESQLSource, + type ESQLTimeInterval, } from '@kbn/esql-ast'; -import { ESQLInlineCast, ESQLParamLiteral } from '@kbn/esql-ast/src/types'; +import { + ESQLIdentifier, + ESQLInlineCast, + ESQLParamLiteral, + ESQLProperNode, +} from '@kbn/esql-ast/src/types'; import { aggregationFunctionDefinitions } from '../definitions/generated/aggregation_functions'; import { builtinFunctions } from '../definitions/builtin'; import { commandDefinitions } from '../definitions/commands'; @@ -78,6 +84,10 @@ export function isColumnItem(arg: ESQLAstItem): arg is ESQLColumn { return isSingleItem(arg) && arg.type === 'column'; } +export function isIdentifier(arg: ESQLAstItem): arg is ESQLIdentifier { + return isSingleItem(arg) && arg.type === 'identifier'; +} + export function isLiteralItem(arg: ESQLAstItem): arg is ESQLLiteral { return isSingleItem(arg) && arg.type === 'literal'; } @@ -132,7 +142,7 @@ export function isSourceCommand({ label }: { label: string }) { } let fnLookups: Map | undefined; -let commandLookups: Map | undefined; +let commandLookups: Map> | undefined; function buildFunctionLookup() { // we always refresh if we have test functions @@ -197,7 +207,7 @@ export function getFunctionDefinition(name: string) { const unwrapStringLiteralQuotes = (value: string) => value.slice(1, -1); -function buildCommandLookup() { +function buildCommandLookup(): Map> { if (!commandLookups) { commandLookups = commandDefinitions.reduce((memo, def) => { memo.set(def.name, def); @@ -205,12 +215,12 @@ function buildCommandLookup() { memo.set(def.alias, def); } return memo; - }, new Map()); + }, new Map>()); } - return commandLookups; + return commandLookups!; } -export function getCommandDefinition(name: string): CommandDefinition { +export function getCommandDefinition(name: string): CommandDefinition { return buildCommandLookup().get(name.toLowerCase())!; } @@ -218,7 +228,7 @@ export function getAllCommands() { return Array.from(buildCommandLookup().values()); } -export function getCommandOption(optionName: CommandOptionsDefinition['name']) { +export function getCommandOption(optionName: CommandOptionsDefinition['name']) { return [byOption, metadataOption, asOption, onOption, withOption, appendSeparatorOption].find( ({ name }) => name === optionName ); @@ -254,10 +264,11 @@ function doesLiteralMatchParameterType(argType: FunctionParameterType, item: ESQ * This function returns the variable or field matching a column */ export function getColumnForASTNode( - column: ESQLColumn, + node: ESQLColumn | ESQLIdentifier, { fields, variables }: Pick ): ESQLRealField | ESQLVariable | undefined { - return getColumnByName(column.parts.join('.'), { fields, variables }); + const formatted = node.type === 'identifier' ? node.name : node.parts.join('.'); + return getColumnByName(formatted, { fields, variables }); } /** @@ -438,7 +449,10 @@ export function checkFunctionArgMatchesDefinition( parentCommand?: string ) { const argType = parameterDefinition.type; - if (argType === 'any' || isParam(arg)) { + if (argType === 'any') { + return true; + } + if (isParam(arg)) { return true; } if (arg.type === 'literal') { @@ -465,7 +479,8 @@ export function checkFunctionArgMatchesDefinition( const wrappedTypes: Array<(typeof validHit)['type']> = Array.isArray(validHit.type) ? validHit.type : [validHit.type]; - return wrappedTypes.some((ct) => ct === argType || ct === 'null'); + + return wrappedTypes.some((ct) => ct === argType || ct === 'null' || ct === 'unknown'); } if (arg.type === 'inlineCast') { const lowerArgType = argType?.toLowerCase(); @@ -543,20 +558,20 @@ export function isVariable( * * E.g. "`bytes`" will be "`bytes`" * - * @param column + * @param node * @returns */ -export const getQuotedColumnName = (column: ESQLColumn) => - column.quoted ? column.text : column.name; +export const getQuotedColumnName = (node: ESQLColumn | ESQLIdentifier) => + node.type === 'identifier' ? node.name : node.quoted ? node.text : node.name; /** * TODO - consider calling lookupColumn under the hood of this function. Seems like they should really do the same thing. */ export function getColumnExists( - column: ESQLColumn, + node: ESQLColumn | ESQLIdentifier, { fields, variables }: Pick ) { - const columnName = column.parts.join('.'); + const columnName = node.type === 'identifier' ? node.name : node.parts.join('.'); if (fields.has(columnName) || variables.has(columnName)) { return true; } @@ -599,7 +614,7 @@ export function pipePrecedesCurrentWord(text: string) { return characterPrecedesCurrentWord(text, '|'); } -export function getLastCharFromTrimmed(text: string) { +export function getLastNonWhitespaceChar(text: string) { return text[text.trimEnd().length - 1]; } @@ -607,7 +622,7 @@ export function getLastCharFromTrimmed(text: string) { * Are we after a comma? i.e. STATS fieldA, */ export function isRestartingExpression(text: string) { - return getLastCharFromTrimmed(text) === ',' || characterPrecedesCurrentWord(text, ','); + return getLastNonWhitespaceChar(text) === ',' || characterPrecedesCurrentWord(text, ','); } export function findPreviousWord(text: string) { @@ -615,6 +630,10 @@ export function findPreviousWord(text: string) { return words[words.length - 2]; } +export function endsInWhitespace(text: string) { + return /\s$/.test(text); +} + /** * Returns the word at the end of the text if there is one. * @param text @@ -645,32 +664,77 @@ export const isParam = (x: unknown): x is ESQLParamLiteral => (x as ESQLParamLiteral).type === 'literal' && (x as ESQLParamLiteral).literalType === 'param'; +export const isFunctionOperatorParam = (fn: ESQLFunction): boolean => + !!fn.operator && isParam(fn.operator); + +/** + * Returns `true` if the function is an aggregation function or a function + * name is a parameter, which potentially could be an aggregation function. + */ +export const isMaybeAggFunction = (fn: ESQLFunction): boolean => + isAggFunction(fn) || isFunctionOperatorParam(fn); + +export const isParametrized = (node: ESQLProperNode): boolean => Walker.params(node).length > 0; + /** * Compares two strings in a case-insensitive manner */ export const noCaseCompare = (a: string, b: string) => a.toLowerCase() === b.toLowerCase(); /** - * This function count the number of unclosed brackets in order to - * locally fix the queryString to generate a valid AST + * This function returns a list of closing brackets that can be appended to + * a partial query to make it valid. + +* locally fix the queryString to generate a valid AST * A known limitation of this is that is not aware of commas "," or pipes "|" * so it is not yet helpful on a multiple commands errors (a workaround it to pass each command here...) - * @param bracketType * @param text * @returns */ -export function countBracketsUnclosed(bracketType: '(' | '[' | '"' | '"""', text: string) { +export function getBracketsToClose(text: string) { const stack = []; - const closingBrackets = { '(': ')', '[': ']', '"': '"', '"""': '"""' }; + const pairs: Record = { '"""': '"""', '/*': '*/', '(': ')', '[': ']', '"': '"' }; + const pairsReversed: Record = { + '"""': '"""', + '*/': '/*', + ')': '(', + ']': '[', + '"': '"', + }; + for (let i = 0; i < text.length; i++) { - const substr = text.substring(i, i + bracketType.length); - if (substr === closingBrackets[bracketType] && stack.length) { - stack.pop(); - } else if (substr === bracketType) { - stack.push(bracketType); + for (const openBracket in pairs) { + if (!Object.hasOwn(pairs, openBracket)) { + continue; + } + + const substr = text.slice(i, i + openBracket.length); + if (substr === openBracket) { + stack.push(substr); + break; + } else if (pairsReversed[substr] && pairsReversed[substr] === stack[stack.length - 1]) { + stack.pop(); + break; + } } } - return stack.length; + return stack.reverse().map((bracket) => pairs[bracket]); +} + +/** + * This function counts the number of unclosed parentheses + * @param text + */ +export function countUnclosedParens(text: string) { + let unclosedCount = 0; + for (let i = 0; i < text.length; i++) { + if (text[i] === ')' && unclosedCount > 0) { + unclosedCount--; + } else if (text[i] === '(') { + unclosedCount++; + } + } + return unclosedCount; } /** @@ -685,37 +749,22 @@ export function countBracketsUnclosed(bracketType: '(' | '[' | '"' | '"""', text export function correctQuerySyntax(_query: string, context: EditorContext) { let query = _query; // check if all brackets are closed, otherwise close them - const unclosedRoundBrackets = countBracketsUnclosed('(', query); - const unclosedSquaredBrackets = countBracketsUnclosed('[', query); - const unclosedQuotes = countBracketsUnclosed('"', query); - const unclosedTripleQuotes = countBracketsUnclosed('"""', query); + const bracketsToAppend = getBracketsToClose(query); + const unclosedRoundBracketCount = bracketsToAppend.filter((bracket) => bracket === ')').length; // if it's a comma by the user or a forced trigger by a function argument suggestion // add a marker to make the expression still valid const charThatNeedMarkers = [',', ':']; if ( (context.triggerCharacter && charThatNeedMarkers.includes(context.triggerCharacter)) || // monaco.editor.CompletionTriggerKind['Invoke'] === 0 - (context.triggerKind === 0 && unclosedRoundBrackets === 0) || + (context.triggerKind === 0 && unclosedRoundBracketCount === 0) || (context.triggerCharacter === ' ' && isMathFunction(query, query.length)) || isComma(query.trimEnd()[query.trimEnd().length - 1]) ) { query += EDITOR_MARKER; } - // if there are unclosed brackets, close them - if (unclosedRoundBrackets || unclosedSquaredBrackets || unclosedQuotes) { - for (const [char, count] of [ - ['"""', unclosedTripleQuotes], - ['"', unclosedQuotes], - [')', unclosedRoundBrackets], - [']', unclosedSquaredBrackets], - ]) { - if (count) { - // inject the closing brackets - query += Array(count).fill(char).join(''); - } - } - } + query += bracketsToAppend.join(''); return query; } @@ -760,10 +809,14 @@ export function getParamAtPosition( * Determines the type of the expression */ export function getExpressionType( - root: ESQLAstItem, + root: ESQLAstItem | undefined, fields?: Map, variables?: Map ): SupportedDataType | 'unknown' { + if (!root) { + return 'unknown'; + } + if (!isSingleItem(root)) { if (root.length === 0) { return 'unknown'; @@ -860,7 +913,8 @@ export function getExpressionType( const param = getParamAtPosition(signature, i); return ( param && - (param.type === argType || + (param.type === 'any' || + param.type === argType || (argType === 'keyword' && ['date', 'date_period'].includes(param.type))) ); }); diff --git a/packages/kbn-esql-validation-autocomplete/src/shared/resources_helpers.ts b/packages/kbn-esql-validation-autocomplete/src/shared/resources_helpers.ts index a4da3907a4d6b..5e7d951d8bdbf 100644 --- a/packages/kbn-esql-validation-autocomplete/src/shared/resources_helpers.ts +++ b/packages/kbn-esql-validation-autocomplete/src/shared/resources_helpers.ts @@ -36,7 +36,7 @@ export function getFieldsByTypeHelper(queryText: string, resourceRetriever?: ESQ const getFields = async () => { const metadata = await getEcsMetadata(); if (!cacheFields.size && queryText) { - const fieldsOfType = await resourceRetriever?.getFieldsFor?.({ query: queryText }); + const fieldsOfType = await resourceRetriever?.getColumnsFor?.({ query: queryText }); const fieldsWithMetadata = enrichFieldsWithECSInfo(fieldsOfType || [], metadata); for (const field of fieldsWithMetadata || []) { cacheFields.set(field.name, field); diff --git a/packages/kbn-esql-validation-autocomplete/src/shared/types.ts b/packages/kbn-esql-validation-autocomplete/src/shared/types.ts index bc1e1d337e4b3..1caa2c480864e 100644 --- a/packages/kbn-esql-validation-autocomplete/src/shared/types.ts +++ b/packages/kbn-esql-validation-autocomplete/src/shared/types.ts @@ -39,7 +39,7 @@ export interface ESQLSourceResult { export interface ESQLCallbacks { getSources?: CallbackFn<{}, ESQLSourceResult>; - getFieldsFor?: CallbackFn<{ query: string }, ESQLRealField>; + getColumnsFor?: CallbackFn<{ query: string }, ESQLRealField>; getPolicies?: CallbackFn< {}, { name: string; sourceIndices: string[]; matchField: string; enrichFields: string[] } diff --git a/packages/kbn-esql-validation-autocomplete/src/validation/__tests__/callbacks.test.ts b/packages/kbn-esql-validation-autocomplete/src/validation/__tests__/callbacks.test.ts index aaa7a3d88f5ca..61c0455fa1b0d 100644 --- a/packages/kbn-esql-validation-autocomplete/src/validation/__tests__/callbacks.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/validation/__tests__/callbacks.test.ts @@ -19,7 +19,7 @@ describe('FROM', () => { await validate('SHOW'); await validate('ROW \t'); - expect(callbacks.getFieldsFor.mock.calls.length).toBe(0); + expect(callbacks.getColumnsFor.mock.calls.length).toBe(0); }); test('loads fields with FROM source when commands after pipe present', async () => { @@ -27,6 +27,6 @@ describe('FROM', () => { await validate('FROM kibana_ecommerce METADATA _id | eval'); - expect(callbacks.getFieldsFor.mock.calls.length).toBe(1); + expect(callbacks.getColumnsFor.mock.calls.length).toBe(1); }); }); diff --git a/packages/kbn-esql-validation-autocomplete/src/validation/__tests__/functions.test.ts b/packages/kbn-esql-validation-autocomplete/src/validation/__tests__/functions.test.ts index ec0fbe5395334..d0d2a1bfec0a4 100644 --- a/packages/kbn-esql-validation-autocomplete/src/validation/__tests__/functions.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/validation/__tests__/functions.test.ts @@ -552,11 +552,7 @@ describe('function validation', () => { | EVAL foo = TEST1(1.) | EVAL TEST2(foo) | EVAL TEST3(foo)`, - [ - 'Argument of [test1] must be [keyword], found value [1.] type [double]', - 'Argument of [test2] must be [keyword], found value [foo] type [unknown]', - 'Argument of [test3] must be [long], found value [foo] type [unknown]', - ] + ['Argument of [test1] must be [keyword], found value [1.] type [double]'] ); }); diff --git a/packages/kbn-esql-validation-autocomplete/src/validation/__tests__/test_suites/validation.command.inlinestats.ts b/packages/kbn-esql-validation-autocomplete/src/validation/__tests__/test_suites/validation.command.inlinestats.ts index c1c7340da78f8..a8fa55128251c 100644 --- a/packages/kbn-esql-validation-autocomplete/src/validation/__tests__/test_suites/validation.command.inlinestats.ts +++ b/packages/kbn-esql-validation-autocomplete/src/validation/__tests__/test_suites/validation.command.inlinestats.ts @@ -126,10 +126,10 @@ export const validationStatsCommandTestSuite = (setup: helpers.Setup) => { const { expectErrors } = await setup(); await expectErrors('from a_index | INLINESTATS doubleField=', [ - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", ]); await expectErrors('from a_index | INLINESTATS doubleField=5 by ', [ - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", ]); await expectErrors('from a_index | INLINESTATS avg(doubleField) by wrongField', [ 'Unknown column [wrongField]', @@ -186,7 +186,7 @@ export const validationStatsCommandTestSuite = (setup: helpers.Setup) => { const { expectErrors } = await setup(); await expectErrors('from a_index | INLINESTATS by ', [ - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", ]); }); diff --git a/packages/kbn-esql-validation-autocomplete/src/validation/__tests__/test_suites/validation.command.metrics.ts b/packages/kbn-esql-validation-autocomplete/src/validation/__tests__/test_suites/validation.command.metrics.ts index 79dc4e21fe9d7..5384fdc136b4e 100644 --- a/packages/kbn-esql-validation-autocomplete/src/validation/__tests__/test_suites/validation.command.metrics.ts +++ b/packages/kbn-esql-validation-autocomplete/src/validation/__tests__/test_suites/validation.command.metrics.ts @@ -117,11 +117,11 @@ export const validationMetricsCommandTestSuite = (setup: helpers.Setup) => { await expectErrors('metrics a_index doubleField=', [ expect.any(String), - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", ]); await expectErrors('metrics a_index doubleField=5 by ', [ expect.any(String), - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", ]); }); diff --git a/packages/kbn-esql-validation-autocomplete/src/validation/__tests__/test_suites/validation.command.stats.ts b/packages/kbn-esql-validation-autocomplete/src/validation/__tests__/test_suites/validation.command.stats.ts index c499f2477e146..c250166b88968 100644 --- a/packages/kbn-esql-validation-autocomplete/src/validation/__tests__/test_suites/validation.command.stats.ts +++ b/packages/kbn-esql-validation-autocomplete/src/validation/__tests__/test_suites/validation.command.stats.ts @@ -117,10 +117,10 @@ export const validationStatsCommandTestSuite = (setup: helpers.Setup) => { const { expectErrors } = await setup(); await expectErrors('from a_index | stats doubleField=', [ - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", ]); await expectErrors('from a_index | stats doubleField=5 by ', [ - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", ]); await expectErrors('from a_index | stats avg(doubleField) by wrongField', [ 'Unknown column [wrongField]', @@ -176,7 +176,7 @@ export const validationStatsCommandTestSuite = (setup: helpers.Setup) => { const { expectErrors } = await setup(); await expectErrors('from a_index | stats by ', [ - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", ]); }); diff --git a/packages/kbn-esql-validation-autocomplete/src/validation/__tests__/validation.params.test.ts b/packages/kbn-esql-validation-autocomplete/src/validation/__tests__/validation.params.test.ts index bbb2867981425..e2de846651b07 100644 --- a/packages/kbn-esql-validation-autocomplete/src/validation/__tests__/validation.params.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/validation/__tests__/validation.params.test.ts @@ -42,3 +42,168 @@ test('allow params in WHERE command expressions', async () => { expect(res2).toMatchObject({ errors: [], warnings: [] }); expect(res3).toMatchObject({ errors: [], warnings: [] }); }); + +describe('allows named params', () => { + test('WHERE boolean expression can contain a param', async () => { + const { validate } = await setup(); + + const res0 = await validate('FROM index | STATS var = ?func(?field) | WHERE var >= ?value'); + expect(res0).toMatchObject({ errors: [], warnings: [] }); + + const res1 = await validate('FROM index | STATS var = ?param | WHERE var >= ?value'); + expect(res1).toMatchObject({ errors: [], warnings: [] }); + + const res2 = await validate('FROM index | STATS var = ?param | WHERE var >= ?value'); + expect(res2).toMatchObject({ errors: [], warnings: [] }); + + const res3 = await validate('FROM index | STATS var = ?param | WHERE ?value >= var'); + expect(res3).toMatchObject({ errors: [], warnings: [] }); + }); + + test('in column names', async () => { + const { validate } = await setup(); + + const res1 = await validate('ROW ?test'); + expect(res1).toMatchObject({ errors: [], warnings: [] }); + + const res2 = await validate('ROW ?test, ?one_more, ?asldfkjasldkfjasldkfj'); + expect(res2).toMatchObject({ errors: [], warnings: [] }); + }); + + test('in nested column names', async () => { + const { validate } = await setup(); + + const res1 = await validate('ROW ?test.?test2'); + expect(res1).toMatchObject({ errors: [], warnings: [] }); + + const res2 = await validate('ROW ?test, ?test.?test2.?test3'); + expect(res2).toMatchObject({ errors: [], warnings: [] }); + }); + + test('in nested column names, where first part is not a param', async () => { + const { validate } = await setup(); + + const res1 = await validate('ROW not_a_param.?test2'); + expect(res1).toMatchObject({ errors: [], warnings: [] }); + + const res2 = await validate('ROW not_a_param.?asdfasdfasdf, not_a_param.?test2.?test3'); + expect(res2).toMatchObject({ errors: [], warnings: [] }); + }); + + test('in function name, function arg, and column name in STATS command', async () => { + const { validate } = await setup(); + + const res1 = await validate('FROM index | STATS x = max(doubleField) BY textField'); + expect(res1).toMatchObject({ errors: [], warnings: [] }); + + const res2 = await validate('FROM index | STATS x = max(?param1) BY textField'); + expect(res2).toMatchObject({ errors: [], warnings: [] }); + + const res3 = await validate('FROM index | STATS x = max(?param1) BY ?param2'); + expect(res3).toMatchObject({ errors: [], warnings: [] }); + + const res4 = await validate('FROM index | STATS x = ?param3(?param1) BY ?param2'); + expect(res4).toMatchObject({ errors: [], warnings: [] }); + + const res5 = await validate( + 'FROM index | STATS x = ?param3(?param1, ?param4), y = ?param4(?param4, ?param4, ?param4) BY ?param2, ?param5' + ); + expect(res5).toMatchObject({ errors: [], warnings: [] }); + }); +}); + +describe('allows unnamed params', () => { + test('in column names', async () => { + const { validate } = await setup(); + + const res1 = await validate('ROW ?'); + expect(res1).toMatchObject({ errors: [], warnings: [] }); + }); + + test('in nested column names', async () => { + const { validate } = await setup(); + + const res1 = await validate('ROW ?.?'); + expect(res1).toMatchObject({ errors: [], warnings: [] }); + + const res2 = await validate('ROW ?, ?.?.?'); + expect(res2).toMatchObject({ errors: [], warnings: [] }); + }); + + test('in nested column names, where first part is not a param', async () => { + const { validate } = await setup(); + + const res1 = await validate('ROW not_a_param.?'); + expect(res1).toMatchObject({ errors: [], warnings: [] }); + + const res2 = await validate('ROW not_a_param.?, not_a_param.?.?'); + expect(res2).toMatchObject({ errors: [], warnings: [] }); + }); + + test('in function name, function arg, and column name in STATS command', async () => { + const { validate } = await setup(); + + const res1 = await validate('FROM index | STATS x = max(doubleField) BY textField'); + expect(res1).toMatchObject({ errors: [], warnings: [] }); + + const res2 = await validate('FROM index | STATS x = max(?) BY textField'); + expect(res2).toMatchObject({ errors: [], warnings: [] }); + + const res3 = await validate('FROM index | STATS x = max(?) BY ?'); + expect(res3).toMatchObject({ errors: [], warnings: [] }); + + const res4 = await validate('FROM index | STATS x = ?(?) BY ?'); + expect(res4).toMatchObject({ errors: [], warnings: [] }); + + const res5 = await validate('FROM index | STATS x = ?(?, ?), y = ?(?, ?, ?) BY ?, ?'); + expect(res5).toMatchObject({ errors: [], warnings: [] }); + }); +}); + +describe('allows positional params', () => { + test('in column names', async () => { + const { validate } = await setup(); + + const res1 = await validate('ROW ?0'); + expect(res1).toMatchObject({ errors: [], warnings: [] }); + }); + + test('in nested column names', async () => { + const { validate } = await setup(); + + const res1 = await validate('ROW ?0.?0'); + expect(res1).toMatchObject({ errors: [], warnings: [] }); + + const res2 = await validate('ROW ?0, ?0.?0.?0'); + expect(res2).toMatchObject({ errors: [], warnings: [] }); + }); + + test('in nested column names, where first part is not a param', async () => { + const { validate } = await setup(); + + const res1 = await validate('ROW not_a_param.?1'); + expect(res1).toMatchObject({ errors: [], warnings: [] }); + + const res2 = await validate('ROW not_a_param.?2, not_a_param.?3.?4'); + expect(res2).toMatchObject({ errors: [], warnings: [] }); + }); + + test('in function name, function arg, and column name in STATS command', async () => { + const { validate } = await setup(); + + const res1 = await validate('FROM index | STATS x = max(doubleField) BY textField'); + expect(res1).toMatchObject({ errors: [], warnings: [] }); + + const res2 = await validate('FROM index | STATS x = max(?0) BY textField'); + expect(res2).toMatchObject({ errors: [], warnings: [] }); + + const res3 = await validate('FROM index | STATS x = max(?0) BY ?0'); + expect(res3).toMatchObject({ errors: [], warnings: [] }); + + const res4 = await validate('FROM index | STATS x = ?1(?1) BY ?1'); + expect(res4).toMatchObject({ errors: [], warnings: [] }); + + const res5 = await validate('FROM index | STATS x = ?0(?0, ?0), y = ?2(?2, ?2, ?2) BY ?3, ?3'); + expect(res5).toMatchObject({ errors: [], warnings: [] }); + }); +}); diff --git a/packages/kbn-esql-validation-autocomplete/src/validation/errors.ts b/packages/kbn-esql-validation-autocomplete/src/validation/errors.ts index ae00f300c1878..0f82d7fe4aad9 100644 --- a/packages/kbn-esql-validation-autocomplete/src/validation/errors.ts +++ b/packages/kbn-esql-validation-autocomplete/src/validation/errors.ts @@ -15,6 +15,7 @@ import type { ESQLLocation, ESQLMessage, } from '@kbn/esql-ast'; +import { ESQLIdentifier } from '@kbn/esql-ast/src/types'; import type { ErrorTypes, ErrorValues } from './types'; function getMessageAndTypeFromId({ @@ -477,7 +478,7 @@ export const errors = { unknownFunction: (fn: ESQLFunction): ESQLMessage => errors.byId('unknownFunction', fn.location, fn), - unknownColumn: (column: ESQLColumn): ESQLMessage => + unknownColumn: (column: ESQLColumn | ESQLIdentifier): ESQLMessage => errors.byId('unknownColumn', column.location, { name: column.name, }), @@ -494,9 +495,12 @@ export const errors = { expression: fn.text, }), - unknownAggFunction: (col: ESQLColumn, type: string = 'FieldAttribute'): ESQLMessage => - errors.byId('unknownAggregateFunction', col.location, { - value: col.name, + unknownAggFunction: ( + node: ESQLColumn | ESQLIdentifier, + type: string = 'FieldAttribute' + ): ESQLMessage => + errors.byId('unknownAggregateFunction', node.location, { + value: node.name, type, }), diff --git a/packages/kbn-esql-validation-autocomplete/src/validation/esql_validation_meta_tests.json b/packages/kbn-esql-validation-autocomplete/src/validation/esql_validation_meta_tests.json index f1e71c9ff6a97..4767031d06813 100644 --- a/packages/kbn-esql-validation-autocomplete/src/validation/esql_validation_meta_tests.json +++ b/packages/kbn-esql-validation-autocomplete/src/validation/esql_validation_meta_tests.json @@ -223,7 +223,7 @@ { "query": "row", "error": [ - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" ], "warning": [] }, @@ -331,7 +331,7 @@ { "query": "row var = 1 in (", "error": [ - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", "Error: [in] function expects exactly 2 arguments, got 1." ], "warning": [] @@ -2645,7 +2645,7 @@ { "query": "from a_index | dissect", "error": [ - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" ], "warning": [] }, @@ -2666,8 +2666,7 @@ { "query": "from a_index | dissect textField .", "error": [ - "SyntaxError: mismatched input '' expecting {'?', NAMED_OR_POSITIONAL_PARAM, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", - "Unknown column [textField.]" + "SyntaxError: mismatched input '' expecting {'?', NAMED_OR_POSITIONAL_PARAM, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" ], "warning": [] }, @@ -2740,7 +2739,7 @@ { "query": "from a_index | grok", "error": [ - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" ], "warning": [] }, @@ -2761,8 +2760,7 @@ { "query": "from a_index | grok textField .", "error": [ - "SyntaxError: mismatched input '' expecting {'?', NAMED_OR_POSITIONAL_PARAM, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", - "Unknown column [textField.]" + "SyntaxError: mismatched input '' expecting {'?', NAMED_OR_POSITIONAL_PARAM, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" ], "warning": [] }, @@ -3542,21 +3540,21 @@ { "query": "from a_index | where *+ doubleField", "error": [ - "SyntaxError: extraneous input '*' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" + "SyntaxError: extraneous input '*' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" ], "warning": [] }, { "query": "from a_index | where /+ doubleField", "error": [ - "SyntaxError: extraneous input '/' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" + "SyntaxError: extraneous input '/' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" ], "warning": [] }, { "query": "from a_index | where %+ doubleField", "error": [ - "SyntaxError: extraneous input '%' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" + "SyntaxError: extraneous input '%' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" ], "warning": [] }, @@ -4443,7 +4441,7 @@ { "query": "from a_index | eval ", "error": [ - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" ], "warning": [] }, @@ -4486,7 +4484,7 @@ { "query": "from a_index | eval a=b, ", "error": [ - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", "Unknown column [b]" ], "warning": [] @@ -4513,7 +4511,7 @@ { "query": "from a_index | eval a=round(doubleField), ", "error": [ - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" ], "warning": [] }, @@ -5619,21 +5617,21 @@ { "query": "from a_index | eval *+ doubleField", "error": [ - "SyntaxError: extraneous input '*' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" + "SyntaxError: extraneous input '*' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" ], "warning": [] }, { "query": "from a_index | eval /+ doubleField", "error": [ - "SyntaxError: extraneous input '/' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" + "SyntaxError: extraneous input '/' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" ], "warning": [] }, { "query": "from a_index | eval %+ doubleField", "error": [ - "SyntaxError: extraneous input '%' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" + "SyntaxError: extraneous input '%' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" ], "warning": [] }, @@ -6957,7 +6955,7 @@ { "query": "from a_index | eval not", "error": [ - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", "Error: [not] function expects exactly one argument, got 0." ], "warning": [] @@ -6965,7 +6963,7 @@ { "query": "from a_index | eval in", "error": [ - "SyntaxError: mismatched input 'in' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" + "SyntaxError: mismatched input 'in' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" ], "warning": [] }, @@ -9014,7 +9012,7 @@ { "query": "from a_index | sort ", "error": [ - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" ], "warning": [] }, @@ -9033,7 +9031,7 @@ { "query": "from a_index | sort doubleField, ", "error": [ - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" ], "warning": [] }, diff --git a/packages/kbn-esql-validation-autocomplete/src/validation/validation.test.ts b/packages/kbn-esql-validation-autocomplete/src/validation/validation.test.ts index fae4ca16797cc..04f53e6de12bb 100644 --- a/packages/kbn-esql-validation-autocomplete/src/validation/validation.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/validation/validation.test.ts @@ -306,7 +306,7 @@ describe('validation logic', () => { describe('row', () => { testErrorsAndWarnings('row', [ - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", ]); testErrorsAndWarnings('row missing_column', ['Unknown column [missing_column]']); testErrorsAndWarnings('row fn()', ['Unknown function [fn]']); @@ -335,7 +335,7 @@ describe('validation logic', () => { "SyntaxError: mismatched input '' expecting '('", ]); testErrorsAndWarnings('row var = 1 in (', [ - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", 'Error: [in] function expects exactly 2 arguments, got 1.', ]); testErrorsAndWarnings('row var = 1 not in ', [ @@ -690,7 +690,7 @@ describe('validation logic', () => { describe('dissect', () => { testErrorsAndWarnings('from a_index | dissect', [ - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", ]); testErrorsAndWarnings('from a_index | dissect textField', [ "SyntaxError: missing QUOTED_STRING at ''", @@ -700,7 +700,6 @@ describe('validation logic', () => { ]); testErrorsAndWarnings('from a_index | dissect textField .', [ "SyntaxError: mismatched input '' expecting {'?', NAMED_OR_POSITIONAL_PARAM, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", - 'Unknown column [textField.]', ]); testErrorsAndWarnings('from a_index | dissect textField %a', [ "SyntaxError: mismatched input '%' expecting QUOTED_STRING", @@ -741,7 +740,7 @@ describe('validation logic', () => { describe('grok', () => { testErrorsAndWarnings('from a_index | grok', [ - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", ]); testErrorsAndWarnings('from a_index | grok textField', [ "SyntaxError: missing QUOTED_STRING at ''", @@ -751,7 +750,6 @@ describe('validation logic', () => { ]); testErrorsAndWarnings('from a_index | grok textField .', [ "SyntaxError: mismatched input '' expecting {'?', NAMED_OR_POSITIONAL_PARAM, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", - 'Unknown column [textField.]', ]); testErrorsAndWarnings('from a_index | grok textField %a', [ "SyntaxError: mismatched input '%' expecting QUOTED_STRING", @@ -826,7 +824,7 @@ describe('validation logic', () => { } for (const wrongOp of ['*', '/', '%']) { testErrorsAndWarnings(`from a_index | where ${wrongOp}+ doubleField`, [ - `SyntaxError: extraneous input '${wrongOp}' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}`, + `SyntaxError: extraneous input '${wrongOp}' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}`, ]); } @@ -899,7 +897,7 @@ describe('validation logic', () => { describe('eval', () => { testErrorsAndWarnings('from a_index | eval ', [ - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", ]); testErrorsAndWarnings('from a_index | eval textField ', []); testErrorsAndWarnings('from a_index | eval b = textField', []); @@ -912,7 +910,7 @@ describe('validation logic', () => { ]); testErrorsAndWarnings('from a_index | eval a=b', ['Unknown column [b]']); testErrorsAndWarnings('from a_index | eval a=b, ', [ - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", 'Unknown column [b]', ]); testErrorsAndWarnings('from a_index | eval a=round', ['Unknown column [round]']); @@ -921,7 +919,7 @@ describe('validation logic', () => { ]); testErrorsAndWarnings('from a_index | eval a=round(doubleField) ', []); testErrorsAndWarnings('from a_index | eval a=round(doubleField), ', [ - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", ]); testErrorsAndWarnings('from a_index | eval a=round(doubleField) + round(doubleField) ', []); testErrorsAndWarnings('from a_index | eval a=round(doubleField) + round(textField) ', [ @@ -984,7 +982,7 @@ describe('validation logic', () => { for (const wrongOp of ['*', '/', '%']) { testErrorsAndWarnings(`from a_index | eval ${wrongOp}+ doubleField`, [ - `SyntaxError: extraneous input '${wrongOp}' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}`, + `SyntaxError: extraneous input '${wrongOp}' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}`, ]); } testErrorsAndWarnings( @@ -1203,11 +1201,11 @@ describe('validation logic', () => { [] ); testErrorsAndWarnings('from a_index | eval not', [ - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", 'Error: [not] function expects exactly one argument, got 0.', ]); testErrorsAndWarnings('from a_index | eval in', [ - "SyntaxError: mismatched input 'in' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", + "SyntaxError: mismatched input 'in' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", ]); testErrorsAndWarnings('from a_index | eval textField in textField', [ @@ -1289,12 +1287,12 @@ describe('validation logic', () => { describe('sort', () => { testErrorsAndWarnings('from a_index | sort ', [ - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", ]); testErrorsAndWarnings('from a_index | sort "field" ', []); testErrorsAndWarnings('from a_index | sort wrongField ', ['Unknown column [wrongField]']); testErrorsAndWarnings('from a_index | sort doubleField, ', [ - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', 'match', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', '(', 'not', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, OPENING_BRACKET, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", ]); testErrorsAndWarnings('from a_index | sort doubleField, textField', []); for (const dir of ['desc', 'asc']) { @@ -1532,7 +1530,7 @@ describe('validation logic', () => { it(`should not fetch source and fields list when a row command is set`, async () => { const callbackMocks = getCallbackMocks(); await validateQuery(`row a = 1 | eval a`, getAstAndSyntaxErrors, undefined, callbackMocks); - expect(callbackMocks.getFieldsFor).not.toHaveBeenCalled(); + expect(callbackMocks.getColumnsFor).not.toHaveBeenCalled(); expect(callbackMocks.getSources).not.toHaveBeenCalled(); }); @@ -1545,7 +1543,7 @@ describe('validation logic', () => { it(`should not fetch source and fields for empty command`, async () => { const callbackMocks = getCallbackMocks(); await validateQuery(` `, getAstAndSyntaxErrors, undefined, callbackMocks); - expect(callbackMocks.getFieldsFor).not.toHaveBeenCalled(); + expect(callbackMocks.getColumnsFor).not.toHaveBeenCalled(); expect(callbackMocks.getSources).not.toHaveBeenCalled(); }); @@ -1559,8 +1557,8 @@ describe('validation logic', () => { ); expect(callbackMocks.getSources).not.toHaveBeenCalled(); expect(callbackMocks.getPolicies).toHaveBeenCalled(); - expect(callbackMocks.getFieldsFor).toHaveBeenCalledTimes(1); - expect(callbackMocks.getFieldsFor).toHaveBeenLastCalledWith({ + expect(callbackMocks.getColumnsFor).toHaveBeenCalledTimes(1); + expect(callbackMocks.getColumnsFor).toHaveBeenLastCalledWith({ query: `from enrich_index | keep otherField, yetAnotherField`, }); }); @@ -1575,8 +1573,8 @@ describe('validation logic', () => { ); expect(callbackMocks.getSources).not.toHaveBeenCalled(); expect(callbackMocks.getPolicies).not.toHaveBeenCalled(); - expect(callbackMocks.getFieldsFor).toHaveBeenCalledTimes(1); - expect(callbackMocks.getFieldsFor).toHaveBeenLastCalledWith({ + expect(callbackMocks.getColumnsFor).toHaveBeenCalledTimes(1); + expect(callbackMocks.getColumnsFor).toHaveBeenLastCalledWith({ query: 'show info', }); }); @@ -1591,8 +1589,8 @@ describe('validation logic', () => { ); expect(callbackMocks.getSources).toHaveBeenCalled(); expect(callbackMocks.getPolicies).toHaveBeenCalled(); - expect(callbackMocks.getFieldsFor).toHaveBeenCalledTimes(2); - expect(callbackMocks.getFieldsFor).toHaveBeenLastCalledWith({ + expect(callbackMocks.getColumnsFor).toHaveBeenCalledTimes(2); + expect(callbackMocks.getColumnsFor).toHaveBeenLastCalledWith({ query: `from enrich_index | keep otherField, yetAnotherField`, }); }); @@ -1604,7 +1602,7 @@ describe('validation logic', () => { getAstAndSyntaxErrors, undefined, { - getFieldsFor: undefined, + getColumnsFor: undefined, getSources: undefined, getPolicies: undefined, } @@ -1718,7 +1716,7 @@ describe('validation logic', () => { const contentByCallback = { getSources: /Unknown index/, getPolicies: /Unknown policy/, - getFieldsFor: /Unknown column|Argument of|it is unsupported or not indexed/, + getColumnsFor: /Unknown column|Argument of|it is unsupported or not indexed/, getPreferences: /Unknown/, getFieldsMetadata: /Unknown/, }; @@ -1761,7 +1759,7 @@ describe('validation logic', () => { }); // test excluding one callback at the time - it.each(['getSources', 'getFieldsFor', 'getPolicies'] as Array)( + it.each(['getSources', 'getColumnsFor', 'getPolicies'] as Array)( `should not error if %s is missing`, async (excludedCallback) => { const filteredTestCases = fixtures.testCases.filter((t) => @@ -1790,7 +1788,7 @@ describe('validation logic', () => { ); it('should work if no callback passed', async () => { - const excludedCallbacks = ['getSources', 'getPolicies', 'getFieldsFor'] as Array< + const excludedCallbacks = ['getSources', 'getPolicies', 'getColumnsFor'] as Array< keyof typeof ignoreErrorsMap >; for (const testCase of fixtures.testCases.filter((t) => diff --git a/packages/kbn-esql-validation-autocomplete/src/validation/validation.ts b/packages/kbn-esql-validation-autocomplete/src/validation/validation.ts index 9605da8460eed..b43a9e5c336b5 100644 --- a/packages/kbn-esql-validation-autocomplete/src/validation/validation.ts +++ b/packages/kbn-esql-validation-autocomplete/src/validation/validation.ts @@ -21,7 +21,7 @@ import { ESQLSource, walk, } from '@kbn/esql-ast'; -import type { ESQLAstField } from '@kbn/esql-ast/src/types'; +import type { ESQLAstField, ESQLIdentifier } from '@kbn/esql-ast/src/types'; import { CommandModeDefinition, CommandOptionsDefinition, @@ -54,6 +54,10 @@ import { getQuotedColumnName, isInlineCastItem, getSignaturesWithMatchingArity, + isIdentifier, + isFunctionOperatorParam, + isMaybeAggFunction, + isParametrized, } from '../shared/helpers'; import { collectVariables } from '../shared/variables'; import { getMessageFromId, errors } from './errors'; @@ -235,7 +239,7 @@ function validateFunctionColumnArg( parentCommand: string ) { const messages: ESQLMessage[] = []; - if (!isColumnItem(actualArg)) { + if (!(isColumnItem(actualArg) || isIdentifier(actualArg))) { return messages; } @@ -317,7 +321,7 @@ function removeInlineCasts(arg: ESQLAstItem): ESQLAstItem { } function validateFunction( - astFunction: ESQLFunction, + fn: ESQLFunction, parentCommand: string, parentOption: string | undefined, references: ReferenceMaps, @@ -326,16 +330,20 @@ function validateFunction( ): ESQLMessage[] { const messages: ESQLMessage[] = []; - if (astFunction.incomplete) { + if (fn.incomplete) { return messages; } - const fnDefinition = getFunctionDefinition(astFunction.name)!; - const isFnSupported = isSupportedFunction(astFunction.name, parentCommand, parentOption); + if (isFunctionOperatorParam(fn)) { + return messages; + } + + const fnDefinition = getFunctionDefinition(fn.name)!; + const isFnSupported = isSupportedFunction(fn.name, parentCommand, parentOption); if (!isFnSupported.supported) { if (isFnSupported.reason === 'unknownFunction') { - messages.push(errors.unknownFunction(astFunction)); + messages.push(errors.unknownFunction(fn)); } // for nested functions skip this check and make the nested check fail later on if (isFnSupported.reason === 'unsupportedFunction' && !isNested) { @@ -344,16 +352,16 @@ function validateFunction( ? getMessageFromId({ messageId: 'unsupportedFunctionForCommandOption', values: { - name: astFunction.name, + name: fn.name, command: parentCommand.toUpperCase(), option: parentOption.toUpperCase(), }, - locations: astFunction.location, + locations: fn.location, }) : getMessageFromId({ messageId: 'unsupportedFunctionForCommand', - values: { name: astFunction.name, command: parentCommand.toUpperCase() }, - locations: astFunction.location, + values: { name: fn.name, command: parentCommand.toUpperCase() }, + locations: fn.location, }) ); } @@ -361,7 +369,7 @@ function validateFunction( return messages; } } - const matchingSignatures = getSignaturesWithMatchingArity(fnDefinition, astFunction); + const matchingSignatures = getSignaturesWithMatchingArity(fnDefinition, fn); if (!matchingSignatures.length) { const { max, min } = getMaxMinNumberOfParams(fnDefinition); if (max === min) { @@ -369,24 +377,24 @@ function validateFunction( getMessageFromId({ messageId: 'wrongArgumentNumber', values: { - fn: astFunction.name, + fn: fn.name, numArgs: max, - passedArgs: astFunction.args.length, + passedArgs: fn.args.length, }, - locations: astFunction.location, + locations: fn.location, }) ); - } else if (astFunction.args.length > max) { + } else if (fn.args.length > max) { messages.push( getMessageFromId({ messageId: 'wrongArgumentNumberTooMany', values: { - fn: astFunction.name, + fn: fn.name, numArgs: max, - passedArgs: astFunction.args.length, - extraArgs: astFunction.args.length - max, + passedArgs: fn.args.length, + extraArgs: fn.args.length - max, }, - locations: astFunction.location, + locations: fn.location, }) ); } else { @@ -394,19 +402,19 @@ function validateFunction( getMessageFromId({ messageId: 'wrongArgumentNumberTooFew', values: { - fn: astFunction.name, + fn: fn.name, numArgs: min, - passedArgs: astFunction.args.length, - missingArgs: min - astFunction.args.length, + passedArgs: fn.args.length, + missingArgs: min - fn.args.length, }, - locations: astFunction.location, + locations: fn.location, }) ); } } // now perform the same check on all functions args - for (let i = 0; i < astFunction.args.length; i++) { - const arg = astFunction.args[i]; + for (let i = 0; i < fn.args.length; i++) { + const arg = fn.args[i]; const allMatchingArgDefinitionsAreConstantOnly = matchingSignatures.every((signature) => { return signature.params[i]?.constantOnly; @@ -446,7 +454,7 @@ function validateFunction( // use the nesting flag for now just for stats and metrics // TODO: revisit this part later on to make it more generic ['stats', 'inlinestats', 'metrics'].includes(parentCommand) - ? isNested || !isAssignment(astFunction) + ? isNested || !isAssignment(fn) : false ); @@ -454,7 +462,7 @@ function validateFunction( const consolidatedMessage = getMessageFromId({ messageId: 'expectedConstant', values: { - fn: astFunction.name, + fn: fn.name, given: subArg.text, }, locations: subArg.location, @@ -472,7 +480,7 @@ function validateFunction( } // check if the definition has some specific validation to apply: if (fnDefinition.validate) { - const payloads = fnDefinition.validate(astFunction); + const payloads = fnDefinition.validate(fn); if (payloads.length) { messages.push(...payloads); } @@ -481,7 +489,7 @@ function validateFunction( const failingSignatures: ESQLMessage[][] = []; for (const signature of matchingSignatures) { const failingSignature: ESQLMessage[] = []; - astFunction.args.forEach((outerArg, index) => { + fn.args.forEach((outerArg, index) => { const argDef = getParamAtPosition(signature, index); if ((!outerArg && argDef?.optional) || !argDef) { // that's ok, just skip it @@ -502,7 +510,7 @@ function validateFunction( validateInlineCastArg, ].flatMap((validateFn) => { return validateFn( - astFunction, + fn, arg, { ...argDef, @@ -521,7 +529,7 @@ function validateFunction( ? collapseWrongArgumentTypeMessages( messagesFromAllArgElements, outerArg, - astFunction.name, + fn.name, argDef.type as string, parentCommand, references @@ -599,10 +607,11 @@ function validateSetting( * recursively terminate at either a literal or an aggregate function. */ const isFunctionAggClosed = (fn: ESQLFunction): boolean => - isAggFunction(fn) || areFunctionArgsAggClosed(fn); + isMaybeAggFunction(fn) || areFunctionArgsAggClosed(fn); const areFunctionArgsAggClosed = (fn: ESQLFunction): boolean => - fn.args.every((arg) => isLiteralItem(arg) || (isFunctionItem(arg) && isFunctionAggClosed(arg))); + fn.args.every((arg) => isLiteralItem(arg) || (isFunctionItem(arg) && isFunctionAggClosed(arg))) || + isFunctionOperatorParam(fn); /** * Looks for first nested aggregate function in an aggregate function, recursively. @@ -610,7 +619,7 @@ const areFunctionArgsAggClosed = (fn: ESQLFunction): boolean => const findNestedAggFunctionInAggFunction = (agg: ESQLFunction): ESQLFunction | undefined => { for (const arg of agg.args) { if (isFunctionItem(arg)) { - return isAggFunction(arg) ? arg : findNestedAggFunctionInAggFunction(arg); + return isMaybeAggFunction(arg) ? arg : findNestedAggFunctionInAggFunction(arg); } } }; @@ -627,7 +636,7 @@ const findNestedAggFunction = ( fn: ESQLFunction, parentIsAgg: boolean = false ): ESQLFunction | undefined => { - if (isAggFunction(fn)) { + if (isMaybeAggFunction(fn)) { return parentIsAgg ? fn : findNestedAggFunctionInAggFunction(fn); } @@ -675,7 +684,7 @@ const validateAggregates = ( hasMissingAggregationFunctionError = true; messages.push(errors.noAggFunction(command, aggregate)); } - } else if (isColumnItem(aggregate)) { + } else if (isColumnItem(aggregate) || isIdentifier(aggregate)) { messages.push(errors.unknownAggFunction(aggregate)); } else { // Should never happen. @@ -834,14 +843,13 @@ function validateSource( } function validateColumnForCommand( - column: ESQLColumn, + column: ESQLColumn | ESQLIdentifier, commandName: string, references: ReferenceMaps ): ESQLMessage[] { const messages: ESQLMessage[] = []; - if (commandName === 'row') { - if (!references.variables.has(column.name)) { + if (!references.variables.has(column.name) && !isParametrized(column)) { messages.push(errors.unknownColumn(column)); } } else { @@ -990,7 +998,7 @@ function validateCommand(command: ESQLCommand, references: ReferenceMaps): ESQLM ) ); } - if (isColumnItem(arg)) { + if (isColumnItem(arg) || isIdentifier(arg)) { if (command.name === 'stats' || command.name === 'inlinestats') { messages.push(errors.unknownAggFunction(arg)); } else { @@ -1074,7 +1082,7 @@ function validateUnsupportedTypeFields(fields: Map) { } export const ignoreErrorsMap: Record = { - getFieldsFor: ['unknownColumn', 'wrongArgumentType', 'unsupportedFieldType'], + getColumnsFor: ['unknownColumn', 'wrongArgumentType', 'unsupportedFieldType'], getSources: ['unknownIndex'], getPolicies: ['unknownPolicy'], getPreferences: [], diff --git a/packages/kbn-event-annotation-components/components/annotation_editor_controls/annotation_editor_controls.tsx b/packages/kbn-event-annotation-components/components/annotation_editor_controls/annotation_editor_controls.tsx index fbef21087cffd..de230ac617987 100644 --- a/packages/kbn-event-annotation-components/components/annotation_editor_controls/annotation_editor_controls.tsx +++ b/packages/kbn-event-annotation-components/components/annotation_editor_controls/annotation_editor_controls.tsx @@ -386,7 +386,7 @@ const ConfigPanelGenericSwitch = ({ value: boolean; onChange: (event: EuiSwitchEvent) => void; }) => ( - + { } }; -const isRoleManagementExplicitlyEnabled = (args: string[]): boolean => { - const roleManagementArg = args.find((arg) => - arg.startsWith('--xpack.security.roleManagementEnabled=') - ); - - // Return true if the value is explicitly set to 'true', otherwise false - return roleManagementArg?.split('=')[1] === 'true' || false; -}; - export class ServerlessAuthProvider implements AuthProvider { private readonly projectType: string; private readonly roleManagementEnabled: boolean; private readonly rolesDefinitionPath: string; constructor(config: Config) { - const kbnServerArgs = config.get('kbnTestServer.serverArgs') as string[]; - this.projectType = kbnServerArgs.reduce((acc, arg) => { - const match = arg.match(/--serverless[=\s](\w+)/); - return acc + (match ? match[1] : ''); - }, '') as ServerlessProjectType; + const options = getopts(config.get('kbnTestServer.serverArgs'), { + boolean: ['xpack.security.roleManagementEnabled'], + default: { + 'xpack.security.roleManagementEnabled': false, + }, + }); + this.projectType = options.serverless as ServerlessProjectType; // Indicates whether role management was explicitly enabled using // the `--xpack.security.roleManagementEnabled=true` flag. - this.roleManagementEnabled = isRoleManagementExplicitlyEnabled(kbnServerArgs); + this.roleManagementEnabled = options['xpack.security.roleManagementEnabled']; if (!isServerlessProjectType(this.projectType)) { throw new Error(`Unsupported serverless projectType: ${this.projectType}`); diff --git a/packages/kbn-grid-layout/grid/grid_height_smoother.tsx b/packages/kbn-grid-layout/grid/grid_height_smoother.tsx index 7693fac72918a..960fe4f52e735 100644 --- a/packages/kbn-grid-layout/grid/grid_height_smoother.tsx +++ b/packages/kbn-grid-layout/grid/grid_height_smoother.tsx @@ -24,7 +24,7 @@ export const GridHeightSmoother = ({ gridLayoutStateManager.interactionEvent$, ]).subscribe(([dimensions, interactionEvent]) => { if (!smoothHeightRef.current) return; - if (!interactionEvent || interactionEvent.type === 'drop') { + if (!interactionEvent) { smoothHeightRef.current.style.height = `${dimensions.height}px`; return; } diff --git a/packages/kbn-grid-layout/grid/grid_layout.tsx b/packages/kbn-grid-layout/grid/grid_layout.tsx index 7f77a476579e9..c3f9521503107 100644 --- a/packages/kbn-grid-layout/grid/grid_layout.tsx +++ b/packages/kbn-grid-layout/grid/grid_layout.tsx @@ -7,82 +7,110 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useEffect, useState } from 'react'; -import { distinctUntilChanged, map, skip } from 'rxjs'; -import { v4 as uuidv4 } from 'uuid'; +import { cloneDeep } from 'lodash'; +import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react'; +import { combineLatest, distinctUntilChanged, filter, map, pairwise, skip } from 'rxjs'; import { GridHeightSmoother } from './grid_height_smoother'; import { GridRow } from './grid_row'; -import { GridLayoutData, GridSettings } from './types'; +import { GridLayoutApi, GridLayoutData, GridSettings } from './types'; +import { useGridLayoutApi } from './use_grid_layout_api'; import { useGridLayoutEvents } from './use_grid_layout_events'; import { useGridLayoutState } from './use_grid_layout_state'; +import { isLayoutEqual } from './utils/equality_checks'; -export const GridLayout = ({ - getCreationOptions, - renderPanelContents, -}: { +interface GridLayoutProps { getCreationOptions: () => { initialLayout: GridLayoutData; gridSettings: GridSettings }; renderPanelContents: (panelId: string) => React.ReactNode; -}) => { - const { gridLayoutStateManager, setDimensionsRef } = useGridLayoutState({ - getCreationOptions, - }); - useGridLayoutEvents({ gridLayoutStateManager }); + onLayoutChange: (newLayout: GridLayoutData) => void; +} - const [rowCount, setRowCount] = useState( - gridLayoutStateManager.gridLayout$.getValue().length - ); +export const GridLayout = forwardRef( + ({ getCreationOptions, renderPanelContents, onLayoutChange }, ref) => { + const { gridLayoutStateManager, setDimensionsRef } = useGridLayoutState({ + getCreationOptions, + }); + useGridLayoutEvents({ gridLayoutStateManager }); - useEffect(() => { - /** - * The only thing that should cause the entire layout to re-render is adding a new row; - * this subscription ensures this by updating the `rowCount` state when it changes. - */ - const rowCountSubscription = gridLayoutStateManager.gridLayout$ - .pipe( - skip(1), // we initialized `rowCount` above, so skip the initial emit - map((newLayout) => newLayout.length), - distinctUntilChanged() - ) - .subscribe((newRowCount) => { - setRowCount(newRowCount); - }); - return () => rowCountSubscription.unsubscribe(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const gridLayoutApi = useGridLayoutApi({ gridLayoutStateManager }); + useImperativeHandle(ref, () => gridLayoutApi, [gridLayoutApi]); - return ( - <> - -
{ - setDimensionsRef(divElement); - }} - > - {Array.from({ length: rowCount }, (_, rowIndex) => { - return ( - { - const currentLayout = gridLayoutStateManager.gridLayout$.value; - currentLayout[rowIndex].isCollapsed = !currentLayout[rowIndex].isCollapsed; - gridLayoutStateManager.gridLayout$.next(currentLayout); - }} - setInteractionEvent={(nextInteractionEvent) => { - if (nextInteractionEvent?.type === 'drop') { - gridLayoutStateManager.activePanel$.next(undefined); - } - gridLayoutStateManager.interactionEvent$.next(nextInteractionEvent); - }} - ref={(element) => (gridLayoutStateManager.rowRefs.current[rowIndex] = element)} - /> - ); - })} -
-
- - ); -}; + const [rowCount, setRowCount] = useState( + gridLayoutStateManager.gridLayout$.getValue().length + ); + + useEffect(() => { + /** + * The only thing that should cause the entire layout to re-render is adding a new row; + * this subscription ensures this by updating the `rowCount` state when it changes. + */ + const rowCountSubscription = gridLayoutStateManager.gridLayout$ + .pipe( + skip(1), // we initialized `rowCount` above, so skip the initial emit + map((newLayout) => newLayout.length), + distinctUntilChanged() + ) + .subscribe((newRowCount) => { + setRowCount(newRowCount); + }); + + const onLayoutChangeSubscription = combineLatest([ + gridLayoutStateManager.gridLayout$, + gridLayoutStateManager.interactionEvent$, + ]) + .pipe( + // if an interaction event is happening, then ignore any "draft" layout changes + filter(([_, event]) => !Boolean(event)), + // once no interaction event, create pairs of "old" and "new" layouts for comparison + map(([layout]) => layout), + pairwise() + ) + .subscribe(([layoutBefore, layoutAfter]) => { + if (!isLayoutEqual(layoutBefore, layoutAfter)) { + onLayoutChange(layoutAfter); + } + }); + + return () => { + rowCountSubscription.unsubscribe(); + onLayoutChangeSubscription.unsubscribe(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + +
{ + setDimensionsRef(divElement); + }} + > + {Array.from({ length: rowCount }, (_, rowIndex) => { + return ( + { + const newLayout = cloneDeep(gridLayoutStateManager.gridLayout$.value); + newLayout[rowIndex].isCollapsed = !newLayout[rowIndex].isCollapsed; + gridLayoutStateManager.gridLayout$.next(newLayout); + }} + setInteractionEvent={(nextInteractionEvent) => { + if (!nextInteractionEvent) { + gridLayoutStateManager.activePanel$.next(undefined); + } + gridLayoutStateManager.interactionEvent$.next(nextInteractionEvent); + }} + ref={(element) => (gridLayoutStateManager.rowRefs.current[rowIndex] = element)} + /> + ); + })} +
+
+ + ); + } +); diff --git a/packages/kbn-grid-layout/grid/grid_panel.tsx b/packages/kbn-grid-layout/grid/grid_panel.tsx index 64a4a2faff403..822cb2328c4a5 100644 --- a/packages/kbn-grid-layout/grid/grid_panel.tsx +++ b/packages/kbn-grid-layout/grid/grid_panel.tsx @@ -30,7 +30,7 @@ export const GridPanel = forwardRef< rowIndex: number; renderPanelContents: (panelId: string) => React.ReactNode; interactionStart: ( - type: PanelInteractionEvent['type'], + type: PanelInteractionEvent['type'] | 'drop', e: React.MouseEvent ) => void; gridLayoutStateManager: GridLayoutStateManager; @@ -190,6 +190,7 @@ export const GridPanel = forwardRef< 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; } diff --git a/packages/kbn-grid-layout/grid/grid_row.tsx b/packages/kbn-grid-layout/grid/grid_row.tsx index e797cd570550a..ff97b32efcdbc 100644 --- a/packages/kbn-grid-layout/grid/grid_row.tsx +++ b/packages/kbn-grid-layout/grid/grid_row.tsx @@ -91,7 +91,7 @@ export const GridRow = forwardRef< )}, ${rowHeight}px)`; const targetRow = interactionEvent?.targetRowIndex; - if (rowIndex === targetRow && interactionEvent?.type !== 'drop') { + if (rowIndex === targetRow && interactionEvent) { // apply "targetted row" styles const gridColor = transparentize(euiThemeVars.euiColorSuccess, 0.2); rowRef.style.backgroundPosition = `top -${gutterSize / 2}px left -${ @@ -122,7 +122,6 @@ export const GridRow = forwardRef< */ const rowStateSubscription = gridLayoutStateManager.gridLayout$ .pipe( - skip(1), // we are initializing all row state with a value, so skip the initial emit map((gridLayout) => { return { title: gridLayout[rowIndex].title, @@ -201,18 +200,22 @@ export const GridRow = forwardRef< if (!panelRef) return; const panelRect = panelRef.getBoundingClientRect(); - setInteractionEvent({ - type, - id: panelId, - panelDiv: panelRef, - targetRowIndex: rowIndex, - mouseOffsets: { - top: e.clientY - panelRect.top, - left: e.clientX - panelRect.left, - right: e.clientX - panelRect.right, - bottom: e.clientY - panelRect.bottom, - }, - }); + if (type === 'drop') { + setInteractionEvent(undefined); + } else { + setInteractionEvent({ + type, + id: panelId, + panelDiv: panelRef, + targetRowIndex: rowIndex, + mouseOffsets: { + top: e.clientY - panelRect.top, + left: e.clientX - panelRect.left, + right: e.clientX - panelRect.right, + bottom: e.clientY - panelRect.bottom, + }, + }); + } }} ref={(element) => { if (!gridLayoutStateManager.panelRefs.current[rowIndex]) { diff --git a/packages/kbn-grid-layout/grid/types.ts b/packages/kbn-grid-layout/grid/types.ts index 3a88eeb33baba..004669e69b186 100644 --- a/packages/kbn-grid-layout/grid/types.ts +++ b/packages/kbn-grid-layout/grid/types.ts @@ -9,11 +9,13 @@ import { BehaviorSubject } from 'rxjs'; import type { ObservedSize } from 'use-resize-observer/polyfilled'; + +import { SerializableRecord } from '@kbn/utility-types'; + export interface GridCoordinate { column: number; row: number; } - export interface GridRect extends GridCoordinate { width: number; height: number; @@ -57,8 +59,9 @@ export interface ActivePanel { } export interface GridLayoutStateManager { - gridDimensions$: BehaviorSubject; gridLayout$: BehaviorSubject; + + gridDimensions$: BehaviorSubject; runtimeSettings$: BehaviorSubject; activePanel$: BehaviorSubject; interactionEvent$: BehaviorSubject; @@ -74,7 +77,7 @@ export interface PanelInteractionEvent { /** * The type of interaction being performed. */ - type: 'drag' | 'resize' | 'drop'; + type: 'drag' | 'resize'; /** * The id of the panel being interacted with. @@ -102,3 +105,29 @@ export interface PanelInteractionEvent { bottom: number; }; } + +/** + * The external API provided through the GridLayout component + */ +export interface GridLayoutApi { + addPanel: (panelId: string, placementSettings: PanelPlacementSettings) => void; + removePanel: (panelId: string) => void; + replacePanel: (oldPanelId: string, newPanelId: string) => void; + + getPanelCount: () => number; + serializeState: () => GridLayoutData & SerializableRecord; +} + +// TODO: Remove from Dashboard plugin as part of https://github.com/elastic/kibana/issues/190446 +export enum PanelPlacementStrategy { + /** Place on the very top of the grid layout, add the height of this panel to all other panels. */ + placeAtTop = 'placeAtTop', + /** Look for the smallest y and x value where the default panel will fit. */ + findTopLeftMostOpenSpace = 'findTopLeftMostOpenSpace', +} + +export interface PanelPlacementSettings { + strategy?: PanelPlacementStrategy; + height: number; + width: number; +} diff --git a/packages/kbn-grid-layout/grid/use_grid_layout_api.ts b/packages/kbn-grid-layout/grid/use_grid_layout_api.ts new file mode 100644 index 0000000000000..1a950ee934174 --- /dev/null +++ b/packages/kbn-grid-layout/grid/use_grid_layout_api.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { useMemo } from 'react'; +import { cloneDeep } from 'lodash'; + +import { SerializableRecord } from '@kbn/utility-types'; + +import { GridLayoutApi, GridLayoutData, GridLayoutStateManager } from './types'; +import { compactGridRow } from './utils/resolve_grid_row'; +import { runPanelPlacementStrategy } from './utils/run_panel_placement'; + +export const useGridLayoutApi = ({ + gridLayoutStateManager, +}: { + gridLayoutStateManager: GridLayoutStateManager; +}): GridLayoutApi => { + const api: GridLayoutApi = useMemo(() => { + return { + addPanel: (panelId, placementSettings) => { + const currentLayout = gridLayoutStateManager.gridLayout$.getValue(); + const [firstRow, ...rest] = currentLayout; // currently, only adding panels to the first row is supported + const { columnCount: gridColumnCount } = gridLayoutStateManager.runtimeSettings$.getValue(); + const nextRow = runPanelPlacementStrategy( + firstRow, + { + id: panelId, + width: placementSettings.width, + height: placementSettings.height, + }, + gridColumnCount, + placementSettings?.strategy + ); + gridLayoutStateManager.gridLayout$.next([nextRow, ...rest]); + }, + + removePanel: (panelId) => { + const currentLayout = gridLayoutStateManager.gridLayout$.getValue(); + + // find the row where the panel exists and delete it from the corresponding panels object + let rowIndex = 0; + let updatedPanels; + for (rowIndex; rowIndex < currentLayout.length; rowIndex++) { + const row = currentLayout[rowIndex]; + if (Object.keys(row.panels).includes(panelId)) { + updatedPanels = { ...row.panels }; // prevent mutation of original panel object + delete updatedPanels[panelId]; + break; + } + } + + // if the panels were updated (i.e. the panel was successfully found and deleted), update the layout + if (updatedPanels) { + const newLayout = cloneDeep(currentLayout); + newLayout[rowIndex] = compactGridRow({ + ...newLayout[rowIndex], + panels: updatedPanels, + }); + gridLayoutStateManager.gridLayout$.next(newLayout); + } + }, + + replacePanel: (oldPanelId, newPanelId) => { + const currentLayout = gridLayoutStateManager.gridLayout$.getValue(); + + // find the row where the panel exists and update its ID to trigger a re-render + let rowIndex = 0; + let updatedPanels; + for (rowIndex; rowIndex < currentLayout.length; rowIndex++) { + const row = { ...currentLayout[rowIndex] }; + if (Object.keys(row.panels).includes(oldPanelId)) { + updatedPanels = { ...row.panels }; // prevent mutation of original panel object + const oldPanel = updatedPanels[oldPanelId]; + delete updatedPanels[oldPanelId]; + updatedPanels[newPanelId] = { ...oldPanel, id: newPanelId }; + break; + } + } + + // if the panels were updated (i.e. the panel was successfully found and replaced), update the layout + if (updatedPanels) { + const newLayout = cloneDeep(currentLayout); + newLayout[rowIndex].panels = updatedPanels; + gridLayoutStateManager.gridLayout$.next(newLayout); + } + }, + + getPanelCount: () => { + return gridLayoutStateManager.gridLayout$.getValue().reduce((prev, row) => { + return prev + Object.keys(row.panels).length; + }, 0); + }, + + serializeState: () => { + const currentLayout = gridLayoutStateManager.gridLayout$.getValue(); + return cloneDeep(currentLayout) as GridLayoutData & SerializableRecord; + }, + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return api; +}; 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 bd6343b9e5652..22dde2fe68ced 100644 --- a/packages/kbn-grid-layout/grid/use_grid_layout_events.ts +++ b/packages/kbn-grid-layout/grid/use_grid_layout_events.ts @@ -7,21 +7,11 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { useEffect, useRef } from 'react'; import deepEqual from 'fast-deep-equal'; - -import { resolveGridRow } from './resolve_grid_row'; -import { GridLayoutStateManager, GridPanelData } from './types'; - -export const isGridDataEqual = (a?: GridPanelData, b?: GridPanelData) => { - return ( - a?.id === b?.id && - a?.column === b?.column && - a?.row === b?.row && - a?.width === b?.width && - a?.height === b?.height - ); -}; +import { useEffect, useRef } from 'react'; +import { resolveGridRow } from './utils/resolve_grid_row'; +import { GridPanelData, GridLayoutStateManager } from './types'; +import { isGridDataEqual } from './utils/equality_checks'; export const useGridLayoutEvents = ({ gridLayoutStateManager, @@ -37,7 +27,7 @@ export const useGridLayoutEvents = ({ useEffect(() => { const { runtimeSettings$, interactionEvent$, gridLayout$ } = gridLayoutStateManager; const calculateUserEvent = (e: Event) => { - if (!interactionEvent$.value || interactionEvent$.value.type === 'drop') return; + if (!interactionEvent$.value) return; e.preventDefault(); e.stopPropagation(); diff --git a/packages/kbn-grid-layout/grid/utils/equality_checks.ts b/packages/kbn-grid-layout/grid/utils/equality_checks.ts new file mode 100644 index 0000000000000..6771baa3a1030 --- /dev/null +++ b/packages/kbn-grid-layout/grid/utils/equality_checks.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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, GridPanelData } from '../types'; + +export const isGridDataEqual = (a?: GridPanelData, b?: GridPanelData) => { + return ( + a?.id === b?.id && + a?.column === b?.column && + a?.row === b?.row && + a?.width === b?.width && + a?.height === b?.height + ); +}; + +export const isLayoutEqual = (a: GridLayoutData, b: GridLayoutData) => { + if (a.length !== b.length) return false; + + let isEqual = true; + for (let rowIndex = 0; rowIndex < a.length && isEqual; rowIndex++) { + const rowA = a[rowIndex]; + const rowB = b[rowIndex]; + + isEqual = + rowA.title === rowB.title && + rowA.isCollapsed === rowB.isCollapsed && + Object.keys(rowA.panels).length === Object.keys(rowB.panels).length; + + if (isEqual) { + for (const panelKey of Object.keys(rowA.panels)) { + isEqual = isGridDataEqual(rowA.panels[panelKey], rowB.panels[panelKey]); + if (!isEqual) break; + } + } + } + + return isEqual; +}; diff --git a/packages/kbn-grid-layout/grid/resolve_grid_row.ts b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts similarity index 96% rename from packages/kbn-grid-layout/grid/resolve_grid_row.ts rename to packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts index 4c300336c7617..3037a52c27c69 100644 --- a/packages/kbn-grid-layout/grid/resolve_grid_row.ts +++ b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { GridPanelData, GridRowData } from './types'; +import { GridPanelData, GridRowData } from '../types'; const collides = (panelA: GridPanelData, panelB: GridPanelData) => { if (panelA.id === panelB.id) return false; // same panel @@ -57,7 +57,7 @@ const getKeysInOrder = (rowData: GridRowData, draggedId?: string): string[] => { }); }; -const compactGridRow = (originalLayout: GridRowData) => { +export const compactGridRow = (originalLayout: GridRowData) => { const nextRowData = { ...originalLayout, panels: { ...originalLayout.panels } }; // compact all vertical space. const sortedKeysAfterMove = getKeysInOrder(nextRowData); diff --git a/packages/kbn-grid-layout/grid/utils/run_panel_placement.ts b/packages/kbn-grid-layout/grid/utils/run_panel_placement.ts new file mode 100644 index 0000000000000..69ecddd1f5ffb --- /dev/null +++ b/packages/kbn-grid-layout/grid/utils/run_panel_placement.ts @@ -0,0 +1,116 @@ +/* + * Copyright 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 { i18n } from '@kbn/i18n'; +import { GridRowData } from '../..'; +import { GridPanelData, PanelPlacementStrategy } from '../types'; +import { compactGridRow, resolveGridRow } from './resolve_grid_row'; + +export const runPanelPlacementStrategy = ( + originalRowData: GridRowData, + newPanel: Omit, + columnCount: number, + strategy: PanelPlacementStrategy = PanelPlacementStrategy.findTopLeftMostOpenSpace +): GridRowData => { + const nextRowData = { ...originalRowData, panels: { ...originalRowData.panels } }; // prevent mutation of original row object + switch (strategy) { + case PanelPlacementStrategy.placeAtTop: + // move all other panels down by the height of the new panel to make room for the new panel + Object.keys(nextRowData.panels).forEach((key) => { + const panel = nextRowData.panels[key]; + panel.row += newPanel.height; + }); + + // some panels might need to be pushed back up because they are now floating - so, compact the row + return compactGridRow({ + ...nextRowData, + // place the new panel at the top left corner, since there is now space + panels: { ...nextRowData.panels, [newPanel.id]: { ...newPanel, row: 0, column: 0 } }, + }); + + case PanelPlacementStrategy.findTopLeftMostOpenSpace: + // find the max row + let maxRow = -1; + const currentPanelsArray = Object.values(nextRowData.panels); + currentPanelsArray.forEach((panel) => { + maxRow = Math.max(panel.row + panel.height, maxRow); + }); + + // handle case of empty grid by placing the panel at the top left corner + if (maxRow < 0) { + return { + ...nextRowData, + panels: { [newPanel.id]: { ...newPanel, row: 0, column: 0 } }, + }; + } + + // find a spot in the grid where the entire panel will fit + const { row, column } = (() => { + // create a 2D array representation of the grid filled with zeros + const grid = new Array(maxRow); + for (let y = 0; y < maxRow; y++) { + grid[y] = new Array(columnCount).fill(0); + } + + // fill in the 2D array with ones wherever a panel is + currentPanelsArray.forEach((panel) => { + for (let x = panel.column; x < panel.column + panel.width; x++) { + for (let y = panel.row; y < panel.row + panel.height; y++) { + grid[y][x] = 1; + } + } + }); + + // now find the first empty spot where there are enough zeros (unoccupied spaces) to fit the whole panel + for (let y = 0; y < maxRow; y++) { + for (let x = 0; x < columnCount; x++) { + if (grid[y][x] === 1) { + // space is filled, so skip this spot + continue; + } else { + for (let h = y; h < Math.min(y + newPanel.height, maxRow); h++) { + for (let w = x; w < Math.min(x + newPanel.width, columnCount); w++) { + const spaceIsEmpty = grid[h][w] === 0; + const fitsPanelWidth = w === x + newPanel.width - 1; + // if the panel is taller than any other panel in the current grid, it can still fit in the space, hence + // we check the minimum of maxY and the panel height. + const fitsPanelHeight = h === Math.min(y + newPanel.height - 1, maxRow - 1); + + if (spaceIsEmpty && fitsPanelWidth && fitsPanelHeight) { + // found an empty space where the entire panel will fit + return { column: x, row: y }; + } else if (grid[h][w] === 1) { + // x, y is already occupied - break out of the loop and move on to the next starting point + break; + } + } + } + } + } + } + + return { column: 0, row: maxRow }; + })(); + + // some panels might need to be pushed down to accomodate the height of the new panel; + // so, resolve the entire row to remove any potential collisions + return resolveGridRow({ + ...nextRowData, + // place the new panel at the top left corner, since there is now space + panels: { ...nextRowData.panels, [newPanel.id]: { ...newPanel, row, column } }, + }); + + default: + throw new Error( + i18n.translate('kbnGridLayout.panelPlacement.unknownStrategyError', { + defaultMessage: 'Unknown panel placement strategy: {strategy}', + values: { strategy }, + }) + ); + } +}; diff --git a/packages/kbn-grid-layout/index.ts b/packages/kbn-grid-layout/index.ts index 009b74573e895..924369fe5ab4c 100644 --- a/packages/kbn-grid-layout/index.ts +++ b/packages/kbn-grid-layout/index.ts @@ -8,4 +8,12 @@ */ export { GridLayout } from './grid/grid_layout'; -export type { GridLayoutData, GridPanelData, GridRowData, GridSettings } from './grid/types'; +export type { + GridLayoutApi, + GridLayoutData, + GridPanelData, + GridRowData, + GridSettings, +} from './grid/types'; + +export { isLayoutEqual } from './grid/utils/equality_checks'; diff --git a/packages/kbn-grid-layout/tsconfig.json b/packages/kbn-grid-layout/tsconfig.json index f0dd3232a42d5..14ab38ba76ba9 100644 --- a/packages/kbn-grid-layout/tsconfig.json +++ b/packages/kbn-grid-layout/tsconfig.json @@ -19,5 +19,6 @@ "kbn_references": [ "@kbn/ui-theme", "@kbn/i18n", + "@kbn/utility-types", ] } diff --git a/packages/kbn-kibana-manifest-schema/src/kibana_json_v2_schema.ts b/packages/kbn-kibana-manifest-schema/src/kibana_json_v2_schema.ts index 30682d763e0b0..a243dbb7598c1 100644 --- a/packages/kbn-kibana-manifest-schema/src/kibana_json_v2_schema.ts +++ b/packages/kbn-kibana-manifest-schema/src/kibana_json_v2_schema.ts @@ -49,18 +49,10 @@ export const MANIFEST_V2: JSONSchema = { `, }, group: { - enum: ['common', 'platform', 'observability', 'security', 'search'], + enum: ['platform', 'observability', 'security', 'search'], description: desc` Specifies the group to which this module pertains. `, - default: 'common', - }, - visibility: { - enum: ['private', 'shared'], - description: desc` - Specifies the visibility of this module, i.e. whether it can be accessed by everybody or only modules in the same group - `, - default: 'shared', }, devOnly: { type: 'boolean', @@ -112,6 +104,37 @@ export const MANIFEST_V2: JSONSchema = { type: 'string', }, }, + allOf: [ + { + if: { + properties: { group: { const: 'platform' } }, + }, + then: { + properties: { + visibility: { + enum: ['private', 'shared'], + description: desc` + Specifies the visibility of this module, i.e. whether it can be accessed by everybody or only modules in the same group + `, + default: 'shared', + }, + }, + required: ['visibility'], + }, + else: { + properties: { + visibility: { + const: 'private', + description: desc` + Specifies the visibility of this module, i.e. whether it can be accessed by everybody or only modules in the same group + `, + default: 'private', + }, + }, + required: ['visibility'], + }, + }, + ], oneOf: [ { type: 'object', diff --git a/packages/kbn-management/settings/setting_ids/index.ts b/packages/kbn-management/settings/setting_ids/index.ts index e926007f77f25..3505534239d13 100644 --- a/packages/kbn-management/settings/setting_ids/index.ts +++ b/packages/kbn-management/settings/setting_ids/index.ts @@ -34,7 +34,6 @@ export const HISTOGRAM_BAR_TARGET_ID = 'histogram:barTarget'; export const HISTOGRAM_MAX_BARS_ID = 'histogram:maxBars'; export const HISTORY_LIMIT_ID = 'history:limit'; export const META_FIELDS_ID = 'metaFields'; -export const METRICS_ALLOW_CHECKING_FOR_FAILED_SHARDS_ID = 'metrics:allowCheckingForFailedShards'; export const METRICS_ALLOW_STRING_INDICES_ID = 'metrics:allowStringIndices'; export const METRICS_MAX_BUCKETS_ID = 'metrics:max_buckets'; export const QUERY_ALLOW_LEADING_WILDCARDS_ID = 'query:allowLeadingWildcards'; @@ -83,7 +82,6 @@ export const DISCOVER_SAMPLE_SIZE_ID = 'discover:sampleSize'; export const DISCOVER_SEARCH_FIELDS_FROM_SOURCE_ID = 'discover:searchFieldsFromSource'; export const DISCOVER_SEARCH_ON_PAGE_LOAD_ID = 'discover:searchOnPageLoad'; export const DISCOVER_SHOW_FIELD_STATISTICS_ID = 'discover:showFieldStatistics'; -export const DISCOVER_SHOW_LEGACY_FIELD_TOP_VALUES_ID = 'discover:showLegacyFieldTopValues'; export const DISCOVER_SHOW_MULTI_FIELDS_ID = 'discover:showMultiFields'; export const DISCOVER_SORT_DEFAULT_ORDER_ID = 'discover:sort:defaultOrder'; export const DOC_TABLE_HIDE_TIME_COLUMNS_ID = 'doc_table:hideTimeColumn'; diff --git a/packages/kbn-monaco/src/esql/lib/esql_theme.ts b/packages/kbn-monaco/src/esql/lib/esql_theme.ts index bf5e2c597eb6c..330e55de86155 100644 --- a/packages/kbn-monaco/src/esql/lib/esql_theme.ts +++ b/packages/kbn-monaco/src/esql/lib/esql_theme.ts @@ -74,7 +74,6 @@ export const buildESQlTheme = (): monaco.editor.IStandaloneThemeData => ({ 'asc', 'desc', 'nulls_order', - 'match', ], euiThemeVars.euiColorAccentText, true // isBold diff --git a/packages/kbn-monaco/src/esql/worker/esql_worker.ts b/packages/kbn-monaco/src/esql/worker/esql_worker.ts index 82d6c75f8f621..18ce300acfc2f 100644 --- a/packages/kbn-monaco/src/esql/worker/esql_worker.ts +++ b/packages/kbn-monaco/src/esql/worker/esql_worker.ts @@ -43,7 +43,7 @@ export class ESQLWorker implements BaseWorkerDefinition { } getAst(text: string | undefined) { - const rawAst = parse(text); + const rawAst = parse(text, { withFormatting: true }); return { ast: rawAst.root.commands, errors: rawAst.errors.map(inlineToMonacoErrors), diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 7936e52ccbf18..b0357853720cb 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -2,7 +2,7 @@ pageLoadAssetSize: actions: 20000 advancedSettings: 27596 aiAssistantManagementSelection: 19146 - aiops: 10000 + aiops: 16000 alerting: 106936 apm: 64385 banners: 17946 @@ -138,7 +138,7 @@ pageLoadAssetSize: screenshotMode: 17856 screenshotting: 22870 searchAssistant: 19831 - searchConnectors: 30000 + searchConnectors: 65000 searchHomepage: 19831 searchIndices: 20519 searchInferenceEndpoints: 20470 diff --git a/packages/kbn-optimizer/src/worker/webpack.config.ts b/packages/kbn-optimizer/src/worker/webpack.config.ts index 96a17a6ae7229..b5da9566878e1 100644 --- a/packages/kbn-optimizer/src/worker/webpack.config.ts +++ b/packages/kbn-optimizer/src/worker/webpack.config.ts @@ -280,6 +280,14 @@ export function getWebpackConfig( plugins: ['@babel/plugin-transform-logical-assignment-operators'], }, }, + { + test: /node_modules[\/\\]launchdarkly[^\/\\]+[\/\\].*.js$/, + loaders: 'babel-loader', + options: { + envName: worker.dist ? 'production' : 'development', + presets: [BABEL_PRESET], + }, + }, { test: /\.(html|md|txt|tmpl)$/, use: { diff --git a/packages/kbn-repo-packages/modern/package.js b/packages/kbn-repo-packages/modern/package.js index 3ec33a69e841a..08d16fcf468ec 100644 --- a/packages/kbn-repo-packages/modern/package.js +++ b/packages/kbn-repo-packages/modern/package.js @@ -44,7 +44,7 @@ class Package { * @param {Package} b */ static sorter(a, b) { - return a.manifest.id.localeCompare(b.manifest.id); + return a.manifest.id.localeCompare(b.manifest.id, 'en'); } /** diff --git a/packages/kbn-router-to-openapispec/src/__snapshots__/generate_oas.test.ts.snap b/packages/kbn-router-to-openapispec/src/__snapshots__/generate_oas.test.ts.snap index fef935624ae64..1c1f5bed02a0e 100644 --- a/packages/kbn-router-to-openapispec/src/__snapshots__/generate_oas.test.ts.snap +++ b/packages/kbn-router-to-openapispec/src/__snapshots__/generate_oas.test.ts.snap @@ -44,7 +44,7 @@ Object { "paths": Object { "/foo/{id}": Object { "get": Object { - "operationId": "%2Ffoo%2F%7Bid%7D#0", + "operationId": "get-foo-id", "parameters": Array [ Object { "description": "The version of the API to use", @@ -138,7 +138,7 @@ Object { "/bar": Object { "get": Object { "deprecated": true, - "operationId": "%2Fbar#0", + "operationId": "get-bar", "parameters": Array [ Object { "description": "The version of the API to use", @@ -231,7 +231,7 @@ OK response oas-test-version-2", "/foo/{id}/{path*}": Object { "delete": Object { "description": "route description", - "operationId": "%2Ffoo%2F%7Bid%7D%2F%7Bpath*%7D#2", + "operationId": "delete-foo-id-path", "parameters": Array [ Object { "description": "The version of the API to use", @@ -269,7 +269,7 @@ OK response oas-test-version-2", }, "get": Object { "description": "route description", - "operationId": "%2Ffoo%2F%7Bid%7D%2F%7Bpath*%7D#0", + "operationId": "get-foo-id-path", "parameters": Array [ Object { "description": "The version of the API to use", @@ -415,7 +415,7 @@ OK response oas-test-version-2", }, "post": Object { "description": "route description", - "operationId": "%2Ffoo%2F%7Bid%7D%2F%7Bpath*%7D#1", + "operationId": "post-foo-id-path", "parameters": Array [ Object { "description": "The version of the API to use", @@ -573,7 +573,7 @@ OK response oas-test-version-2", "/no-xsrf/{id}/{path*}": Object { "post": Object { "deprecated": true, - "operationId": "%2Fno-xsrf%2F%7Bid%7D%2F%7Bpath*%7D#1", + "operationId": "post-no-xsrf-id-path-2", "parameters": Array [ Object { "description": "The version of the API to use", @@ -725,7 +725,7 @@ Object { "paths": Object { "/recursive": Object { "get": Object { - "operationId": "%2Frecursive#0", + "operationId": "get-recursive", "parameters": Array [ Object { "description": "The version of the API to use", @@ -808,7 +808,7 @@ Object { "paths": Object { "/foo/{id}": Object { "get": Object { - "operationId": "%2Ffoo%2F%7Bid%7D#0", + "operationId": "get-foo-id", "parameters": Array [ Object { "description": "The version of the API to use", @@ -846,7 +846,7 @@ Object { }, "/test": Object { "get": Object { - "operationId": "%2Ftest#0", + "operationId": "get-test", "parameters": Array [ Object { "description": "The version of the API to use", diff --git a/packages/kbn-router-to-openapispec/src/extract_authz_description.test.ts b/packages/kbn-router-to-openapispec/src/extract_authz_description.test.ts index 8da2324e68f02..308f0a7686597 100644 --- a/packages/kbn-router-to-openapispec/src/extract_authz_description.test.ts +++ b/packages/kbn-router-to-openapispec/src/extract_authz_description.test.ts @@ -33,7 +33,9 @@ describe('extractAuthzDescription', () => { }, }; const description = extractAuthzDescription(routeSecurity); - expect(description).toBe('[Authz] Route required privileges: ALL of [manage_spaces].'); + expect(description).toBe( + '[Required authorization] Route required privileges: ALL of [manage_spaces].' + ); }); it('should return route authz description for privilege groups', () => { @@ -44,7 +46,9 @@ describe('extractAuthzDescription', () => { }, }; const description = extractAuthzDescription(routeSecurity); - expect(description).toBe('[Authz] Route required privileges: ALL of [console].'); + expect(description).toBe( + '[Required authorization] Route required privileges: ALL of [console].' + ); } { const routeSecurity: RouteSecurity = { @@ -58,7 +62,7 @@ describe('extractAuthzDescription', () => { }; const description = extractAuthzDescription(routeSecurity); expect(description).toBe( - '[Authz] Route required privileges: ANY of [manage_spaces OR taskmanager].' + '[Required authorization] Route required privileges: ANY of [manage_spaces OR taskmanager].' ); } { @@ -74,7 +78,7 @@ describe('extractAuthzDescription', () => { }; const description = extractAuthzDescription(routeSecurity); expect(description).toBe( - '[Authz] Route required privileges: ALL of [console, filesManagement] AND ANY of [manage_spaces OR taskmanager].' + '[Required authorization] Route required privileges: ALL of [console, filesManagement] AND ANY of [manage_spaces OR taskmanager].' ); } }); diff --git a/packages/kbn-router-to-openapispec/src/extract_authz_description.ts b/packages/kbn-router-to-openapispec/src/extract_authz_description.ts index 4cd6875913780..7979188f2641e 100644 --- a/packages/kbn-router-to-openapispec/src/extract_authz_description.ts +++ b/packages/kbn-router-to-openapispec/src/extract_authz_description.ts @@ -56,5 +56,5 @@ export const extractAuthzDescription = (routeSecurity: InternalRouteSecurity | u return `Route required privileges: ${getPrivilegesDescription(allRequired, anyRequired)}.`; }; - return `[Authz] ${getDescriptionForRoute()}`; + return `[Required authorization] ${getDescriptionForRoute()}`; }; diff --git a/packages/kbn-router-to-openapispec/src/generate_oas.test.fixture.ts b/packages/kbn-router-to-openapispec/src/generate_oas.test.fixture.ts index b3f20da38915b..f4ba66f992134 100644 --- a/packages/kbn-router-to-openapispec/src/generate_oas.test.fixture.ts +++ b/packages/kbn-router-to-openapispec/src/generate_oas.test.fixture.ts @@ -35,7 +35,7 @@ export const sharedOas = { get: { deprecated: true, 'x-discontinued': 'route discontinued version or date', - operationId: '%2Fbar#0', + operationId: 'get-bar', parameters: [ { description: 'The version of the API to use', @@ -154,7 +154,7 @@ export const sharedOas = { '/foo/{id}/{path*}': { get: { description: 'route description', - operationId: '%2Ffoo%2F%7Bid%7D%2F%7Bpath*%7D#0', + operationId: 'get-foo-id-path', parameters: [ { description: 'The version of the API to use', @@ -278,7 +278,7 @@ export const sharedOas = { }, post: { description: 'route description', - operationId: '%2Ffoo%2F%7Bid%7D%2F%7Bpath*%7D#1', + operationId: 'post-foo-id-path', parameters: [ { description: 'The version of the API to use', diff --git a/packages/kbn-router-to-openapispec/src/generate_oas.ts b/packages/kbn-router-to-openapispec/src/generate_oas.ts index 8bc3333193624..9c7423147721b 100644 --- a/packages/kbn-router-to-openapispec/src/generate_oas.ts +++ b/packages/kbn-router-to-openapispec/src/generate_oas.ts @@ -10,10 +10,9 @@ import type { CoreVersionedRouter, Router } from '@kbn/core-http-router-server-internal'; import type { OpenAPIV3 } from 'openapi-types'; import { OasConverter } from './oas_converter'; -import { createOperationIdCounter } from './operation_id_counter'; import { processRouter } from './process_router'; import { processVersionedRouter } from './process_versioned_router'; -import { buildGlobalTags } from './util'; +import { buildGlobalTags, createOpIdGenerator } from './util'; export const openApiVersion = '3.0.0'; @@ -40,8 +39,8 @@ export const generateOpenApiDocument = ( ): OpenAPIV3.Document => { const { filters } = opts; const converter = new OasConverter(); - const getOpId = createOperationIdCounter(); const paths: OpenAPIV3.PathsObject = {}; + const getOpId = createOpIdGenerator(); for (const router of appRouters.routers) { const result = processRouter(router, converter, getOpId, filters); Object.assign(paths, result.paths); diff --git a/packages/kbn-router-to-openapispec/src/operation_id_counter.test.ts b/packages/kbn-router-to-openapispec/src/operation_id_counter.test.ts deleted file mode 100644 index dbc4bf5956d69..0000000000000 --- a/packages/kbn-router-to-openapispec/src/operation_id_counter.test.ts +++ /dev/null @@ -1,32 +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", 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 { createOperationIdCounter } from './operation_id_counter'; - -test('empty case', () => { - const opIdCounter = createOperationIdCounter(); - expect(opIdCounter('')).toBe('#0'); -}); - -test('other cases', () => { - const opIdCounter = createOperationIdCounter(); - const tests = [ - ['/', '%2F#0'], - ['/api/cool', '%2Fapi%2Fcool#0'], - ['/api/cool', '%2Fapi%2Fcool#1'], - ['/api/cool', '%2Fapi%2Fcool#2'], - ['/api/cool/{variable}', '%2Fapi%2Fcool%2F%7Bvariable%7D#0'], - ['/api/cool/{optionalVariable?}', '%2Fapi%2Fcool%2F%7BoptionalVariable%3F%7D#0'], - ['/api/cool/{optionalVariable?}', '%2Fapi%2Fcool%2F%7BoptionalVariable%3F%7D#1'], - ]; - - tests.forEach(([input, expected]) => { - expect(opIdCounter(input)).toBe(expected); - }); -}); diff --git a/packages/kbn-router-to-openapispec/src/operation_id_counter.ts b/packages/kbn-router-to-openapispec/src/operation_id_counter.ts deleted file mode 100644 index 2d576b1ca67c3..0000000000000 --- a/packages/kbn-router-to-openapispec/src/operation_id_counter.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", 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 type OperationIdCounter = (name: string) => string; - -export const createOperationIdCounter = () => { - const operationIdCounters = new Map(); - return (name: string): string => { - name = encodeURIComponent(name); - // Aliases an operationId to ensure it is unique across - // multiple method+path combinations sharing a name. - // "search" -> "search#0", "search#1", etc. - const operationIdCount = operationIdCounters.get(name) ?? 0; - const aliasedName = name + '#' + operationIdCount.toString(); - operationIdCounters.set(name, operationIdCount + 1); - return aliasedName; - }; -}; diff --git a/packages/kbn-router-to-openapispec/src/process_router.test.ts b/packages/kbn-router-to-openapispec/src/process_router.test.ts index 96a10b25d648a..2ce135a378789 100644 --- a/packages/kbn-router-to-openapispec/src/process_router.test.ts +++ b/packages/kbn-router-to-openapispec/src/process_router.test.ts @@ -10,9 +10,9 @@ import { schema } from '@kbn/config-schema'; import { Router } from '@kbn/core-http-router-server-internal'; import { OasConverter } from './oas_converter'; -import { createOperationIdCounter } from './operation_id_counter'; import { extractResponses, processRouter } from './process_router'; import { type InternalRouterRoute } from './type'; +import { createOpIdGenerator } from './util'; describe('extractResponses', () => { let oasConverter: OasConverter; @@ -86,18 +86,21 @@ describe('processRouter', () => { const testRouter = { getRoutes: () => [ { + method: 'get', path: '/foo', options: { access: 'internal', deprecated: true, discontinued: 'discontinued router' }, handler: jest.fn(), validationSchemas: { request: { body: schema.object({}) } }, }, { + method: 'get', path: '/bar', options: {}, handler: jest.fn(), validationSchemas: { request: { body: schema.object({}) } }, }, { + method: 'get', path: '/baz', options: {}, handler: jest.fn(), @@ -121,31 +124,55 @@ describe('processRouter', () => { }, }, }, + { + path: '/quux', + method: 'post', + options: { + description: 'This a test route description.', + }, + handler: jest.fn(), + validationSchemas: { request: { body: schema.object({}) } }, + security: { + authz: { + requiredPrivileges: [ + 'manage_spaces', + { + allRequired: ['taskmanager'], + anyRequired: ['console'], + }, + ], + }, + }, + }, ], } as unknown as Router; it('only provides routes for version 2023-10-31', () => { - const result1 = processRouter(testRouter, new OasConverter(), createOperationIdCounter(), { + const result1 = processRouter(testRouter, new OasConverter(), createOpIdGenerator(), { version: '2023-10-31', }); - expect(Object.keys(result1.paths!)).toHaveLength(4); + expect(Object.keys(result1.paths!)).toHaveLength(5); - const result2 = processRouter(testRouter, new OasConverter(), createOperationIdCounter(), { + const result2 = processRouter(testRouter, new OasConverter(), createOpIdGenerator(), { version: '2024-10-31', }); expect(Object.keys(result2.paths!)).toHaveLength(0); }); it('updates description with privileges required', () => { - const result = processRouter(testRouter, new OasConverter(), createOperationIdCounter(), { + const result = processRouter(testRouter, new OasConverter(), createOpIdGenerator(), { version: '2023-10-31', }); expect(result.paths['/qux']?.post).toBeDefined(); expect(result.paths['/qux']?.post?.description).toEqual( - '[Authz] Route required privileges: ALL of [manage_spaces, taskmanager] AND ANY of [console].' + '[Required authorization] Route required privileges: ALL of [manage_spaces, taskmanager] AND ANY of [console].' + ); + + expect(result.paths['/quux']?.post?.description).toEqual( + 'This a test route description.

[Required authorization] Route required privileges: ALL of [manage_spaces, taskmanager] AND ANY of [console].' ); }); }); diff --git a/packages/kbn-router-to-openapispec/src/process_router.ts b/packages/kbn-router-to-openapispec/src/process_router.ts index af4371c3b5313..35e8f125914a6 100644 --- a/packages/kbn-router-to-openapispec/src/process_router.ts +++ b/packages/kbn-router-to-openapispec/src/process_router.ts @@ -24,8 +24,8 @@ import { mergeResponseContent, prepareRoutes, setXState, + GetOpId, } from './util'; -import type { OperationIdCounter } from './operation_id_counter'; import type { GenerateOpenApiDocumentOptionsFilters } from './generate_oas'; import type { CustomOperationObject, InternalRouterRoute } from './type'; import { extractAuthzDescription } from './extract_authz_description'; @@ -33,7 +33,7 @@ import { extractAuthzDescription } from './extract_authz_description'; export const processRouter = ( appRouter: Router, converter: OasConverter, - getOpId: OperationIdCounter, + getOpId: GetOpId, filters?: GenerateOpenApiDocumentOptionsFilters ) => { const paths: OpenAPIV3.PathsObject = {}; @@ -64,11 +64,13 @@ export const processRouter = ( parameters.push(...pathObjects, ...queryObjects); } - let description = ''; + let description = `${route.options.description ?? ''}`; if (route.security) { const authzDescription = extractAuthzDescription(route.security); - description = `${route.options.description ?? ''}${authzDescription ?? ''}`; + description += `${route.options.description && authzDescription ? `

` : ''}${ + authzDescription ?? '' + }`; } const hasDeprecations = !!route.options.deprecated; @@ -76,7 +78,6 @@ export const processRouter = ( summary: route.options.summary ?? '', tags: route.options.tags ? extractTags(route.options.tags) : [], ...(description ? { description } : {}), - ...(route.options.description ? { description: route.options.description } : {}), ...(hasDeprecations ? { deprecated: true } : {}), ...(route.options.discontinued ? { 'x-discontinued': route.options.discontinued } : {}), requestBody: !!validationSchemas?.body @@ -90,7 +91,7 @@ export const processRouter = ( : undefined, responses: extractResponses(route, converter), parameters, - operationId: getOpId(route.path), + operationId: getOpId({ path: route.path, method: route.method }), }; setXState(route.options.availability, operation); diff --git a/packages/kbn-router-to-openapispec/src/process_versioned_router.test.ts b/packages/kbn-router-to-openapispec/src/process_versioned_router.test.ts index 3738c207f1f78..b7a4827e4f365 100644 --- a/packages/kbn-router-to-openapispec/src/process_versioned_router.test.ts +++ b/packages/kbn-router-to-openapispec/src/process_versioned_router.test.ts @@ -11,13 +11,13 @@ import { schema } from '@kbn/config-schema'; import type { CoreVersionedRouter } from '@kbn/core-http-router-server-internal'; import { get } from 'lodash'; import { OasConverter } from './oas_converter'; -import { createOperationIdCounter } from './operation_id_counter'; import { processVersionedRouter, extractVersionedResponses, extractVersionedRequestBodies, } from './process_versioned_router'; import { VersionedRouterRoute } from '@kbn/core-http-server'; +import { createOpIdGenerator } from './util'; let oasConverter: OasConverter; beforeEach(() => { @@ -125,7 +125,7 @@ describe('processVersionedRouter', () => { const baseCase = processVersionedRouter( { getRoutes: () => [createTestRoute()] } as unknown as CoreVersionedRouter, new OasConverter(), - createOperationIdCounter(), + createOpIdGenerator(), {} ); @@ -137,7 +137,7 @@ describe('processVersionedRouter', () => { const filteredCase = processVersionedRouter( { getRoutes: () => [createTestRoute()] } as unknown as CoreVersionedRouter, new OasConverter(), - createOperationIdCounter(), + createOpIdGenerator(), { version: '2023-10-31' } ); expect(Object.keys(get(filteredCase, 'paths["/foo"].get.responses.200.content')!)).toEqual([ @@ -149,7 +149,7 @@ describe('processVersionedRouter', () => { const results = processVersionedRouter( { getRoutes: () => [createTestRoute()] } as unknown as CoreVersionedRouter, new OasConverter(), - createOperationIdCounter(), + createOpIdGenerator(), {} ); expect(results.paths['/foo']).toBeDefined(); @@ -157,7 +157,7 @@ describe('processVersionedRouter', () => { expect(results.paths['/foo']!.get).toBeDefined(); expect(results.paths['/foo']!.get!.description).toBe( - '[Authz] Route required privileges: ALL of [manage_spaces].' + 'This is a test route description.

[Required authorization] Route required privileges: ALL of [manage_spaces].' ); }); }); @@ -176,6 +176,7 @@ const createTestRoute: () => VersionedRouterRoute = () => ({ requiredPrivileges: ['manage_spaces'], }, }, + description: 'This is a test route description.', }, handlers: [ { diff --git a/packages/kbn-router-to-openapispec/src/process_versioned_router.ts b/packages/kbn-router-to-openapispec/src/process_versioned_router.ts index 5dad5677c94ac..eab2dfef78a21 100644 --- a/packages/kbn-router-to-openapispec/src/process_versioned_router.ts +++ b/packages/kbn-router-to-openapispec/src/process_versioned_router.ts @@ -18,7 +18,6 @@ import { extractAuthzDescription } from './extract_authz_description'; import type { GenerateOpenApiDocumentOptionsFilters } from './generate_oas'; import type { OasConverter } from './oas_converter'; import { isReferenceObject } from './oas_converter/common'; -import type { OperationIdCounter } from './operation_id_counter'; import { prepareRoutes, getPathParameters, @@ -30,12 +29,13 @@ import { mergeResponseContent, getXsrfHeaderForMethod, setXState, + GetOpId, } from './util'; export const processVersionedRouter = ( appRouter: CoreVersionedRouter, converter: OasConverter, - getOpId: OperationIdCounter, + getOpId: GetOpId, filters?: GenerateOpenApiDocumentOptionsFilters ) => { const routes = prepareRoutes(appRouter.getRoutes(), filters); @@ -90,12 +90,13 @@ export const processVersionedRouter = ( ...queryObjects, ]; } - - let description = ''; + let description = `${route.options.description ?? ''}`; if (route.options.security) { const authzDescription = extractAuthzDescription(route.options.security); - description = `${route.options.description ?? ''}${authzDescription ?? ''}`; + description += `${route.options.description && authzDescription ? '

' : ''}${ + authzDescription ?? '' + }`; } const hasBody = Boolean(extractValidationSchemaFromVersionedHandler(handler)?.request?.body); @@ -107,7 +108,6 @@ export const processVersionedRouter = ( summary: route.options.summary ?? '', tags: route.options.options?.tags ? extractTags(route.options.options.tags) : [], ...(description ? { description } : {}), - ...(route.options.description ? { description: route.options.description } : {}), ...(hasDeprecations ? { deprecated: true } : {}), ...(route.options.discontinued ? { 'x-discontinued': route.options.discontinued } : {}), requestBody: hasBody @@ -121,7 +121,7 @@ export const processVersionedRouter = ( ? extractVersionedResponse(handler, converter, contentType) : extractVersionedResponses(route, converter, contentType), parameters, - operationId: getOpId(route.path), + operationId: getOpId({ path: route.path, method: route.method }), }; setXState(route.options.options?.availability, operation); diff --git a/packages/kbn-router-to-openapispec/src/util.test.ts b/packages/kbn-router-to-openapispec/src/util.test.ts index abbb605df79e5..f9692e57e1f50 100644 --- a/packages/kbn-router-to-openapispec/src/util.test.ts +++ b/packages/kbn-router-to-openapispec/src/util.test.ts @@ -15,6 +15,8 @@ import { mergeResponseContent, prepareRoutes, getPathParameters, + createOpIdGenerator, + GetOpId, } from './util'; import { assignToPaths, extractTags } from './util'; @@ -260,3 +262,83 @@ describe('getPathParameters', () => { expect(getPathParameters(input)).toEqual(output); }); }); + +describe('createOpIdGenerator', () => { + let getOpId: GetOpId; + beforeEach(() => { + getOpId = createOpIdGenerator(); + }); + test('empty', () => { + expect(() => getOpId({ method: '', path: '/asd' })).toThrow(/Must provide method and path/); + expect(() => getOpId({ method: 'get', path: '' })).toThrow(/Must provide method and path/); + expect(() => getOpId({ method: '', path: '' })).toThrow(/Must provide method and path/); + }); + test('disambiguate', () => { + expect(getOpId({ method: 'get', path: '/test' })).toBe('get-test'); + expect(getOpId({ method: 'get', path: '/test' })).toBe('get-test-2'); + expect(getOpId({ method: 'get', path: '/test' })).toBe('get-test-3'); + expect(getOpId({ method: 'get', path: '/test' })).toBe('get-test-4'); + }); + test.each([ + { input: { method: 'GET', path: '/api/file' }, output: 'get-file' }, + { input: { method: 'GET', path: '///api/file///' }, output: 'get-file' }, + { input: { method: 'POST', path: '/internal/api/file' }, output: 'post-file' }, + { input: { method: 'PUT', path: '/internal/file' }, output: 'put-file' }, + { input: { method: 'Put', path: 'fOO/fILe' }, output: 'put-foo-file' }, + { + input: { method: 'delete', path: '/api/my/really/cool/domain/resource' }, + output: 'delete-my-really-cool-domain-resource', + }, + { + input: { + method: 'delete', + path: '/api/my/really/cool/domain/resource', + }, + output: 'delete-my-really-cool-domain-resource', + }, + { + input: { + method: 'get', + path: '/api/my/resource/{id}', + }, + output: 'get-my-resource-id', + }, + { + input: { + method: 'get', + path: '/api/my/resource/{id}/{type?}', + }, + output: 'get-my-resource-id-type', + }, + { + input: { + method: 'get', + path: '/api/my/resource/{id?}', + }, + output: 'get-my-resource-id', + }, + { + input: { + method: 'get', + path: '/api/my/resource/{path*}', + }, + output: 'get-my-resource-path', + }, + { + input: { + method: 'get', + path: '/api/my/underscore_resource', + }, + output: 'get-my-underscore-resource', + }, + { + input: { + method: 'get', + path: '/api/my/_underscore_resource', + }, + output: 'get-my-underscore-resource', + }, + ])('$input.method $input.path -> $output', ({ input, output }) => { + expect(getOpId(input)).toBe(output); + }); +}); diff --git a/packages/kbn-router-to-openapispec/src/util.ts b/packages/kbn-router-to-openapispec/src/util.ts index beefbebc0aec7..a5718fa92120f 100644 --- a/packages/kbn-router-to-openapispec/src/util.ts +++ b/packages/kbn-router-to-openapispec/src/util.ts @@ -166,10 +166,10 @@ export const getXsrfHeaderForMethod = ( ]; }; -export function setXState( +export const setXState = ( availability: RouteConfigOptions['availability'], operation: CustomOperationObject -): void { +): void => { if (availability) { if (availability.stability === 'experimental') { operation['x-state'] = 'Technical Preview'; @@ -178,4 +178,45 @@ export function setXState( operation['x-state'] = 'Beta'; } } -} +}; + +export type GetOpId = (input: { path: string; method: string }) => string; + +/** + * Best effort to generate operation IDs from route values + */ +export const createOpIdGenerator = (): GetOpId => { + const idMap = new Map(); + return function getOpId({ path, method }) { + if (!method || !path) { + throw new Error( + `Must provide method and path, received: method: "${method}", path: "${path}"` + ); + } + + path = path + .trim() + .replace(/^[\/]+/, '') + .replace(/[\/]+$/, '') + .toLowerCase(); + + const removePrefixes = ['internal/api/', 'internal/', 'api/']; // longest to shortest + for (const prefix of removePrefixes) { + if (path.startsWith(prefix)) { + path = path.substring(prefix.length); + break; + } + } + + path = path + .replace(/[\{\}\?\*]/g, '') // remove special chars + .replace(/[\/_]/g, '-') // everything else to dashes + .replace(/[-]+/g, '-'); // single dashes + + const opId = `${method.toLowerCase()}-${path}`; + + const cachedCount = idMap.get(opId) ?? 0; + idMap.set(opId, cachedCount + 1); + return cachedCount > 0 ? `${opId}-${cachedCount + 1}` : opId; + }; +}; diff --git a/packages/kbn-screenshotting-server/src/paths.ts b/packages/kbn-screenshotting-server/src/paths.ts index 9e8200c0839ab..e4c5a89d77627 100644 --- a/packages/kbn-screenshotting-server/src/paths.ts +++ b/packages/kbn-screenshotting-server/src/paths.ts @@ -46,10 +46,10 @@ export class ChromiumArchivePaths { platform: 'darwin', architecture: 'x64', archiveFilename: 'chrome-mac.zip', - archiveChecksum: '0a3d18efd00b3406f66139a673616b4b2b4b00323776678cb82295996f5a6733', - binaryChecksum: '8bcdaa973ee11110f6b70eaac2418fda3bb64446cf37f964fce331cdc8907a20', + archiveChecksum: '04f0132019c15660eea0b9d261fd14940c33b625c253689fcb5b09d58c4dbfe7', + binaryChecksum: 'a3ada6874ee052c096f09481fba75fcdabb96a8a9ad94a96949946a2485feccf', binaryRelativePath: 'chrome-mac/Chromium.app/Contents/MacOS/Chromium', - revision: 1331485, // 1331488 is not available for Mac_x64 + revision: 1355985, location: 'common', archivePath: 'Mac', isPreInstalled: false, @@ -58,10 +58,10 @@ export class ChromiumArchivePaths { platform: 'darwin', architecture: 'arm64', archiveFilename: 'chrome-mac.zip', - archiveChecksum: '426eddf16acb88b9446a91de53cc4364c7d487414248f33e30f68cf488cea0c0', - binaryChecksum: '827931739bfdd2b6790a81d5ade8886c159cd051581d79b84d1ede447293e9cf', + archiveChecksum: '6c75bb645696aed0e60b17e0e50423b97d21ca11f2c5cdfbaf17edbf582cec94', + binaryChecksum: '2f819f59379917056e07d640f75b1dbe22a830c2655e32ab0543013b7198c139', binaryRelativePath: 'chrome-mac/Chromium.app/Contents/MacOS/Chromium', - revision: 1331488, + revision: 1355985, location: 'common', archivePath: 'Mac_Arm', isPreInstalled: false, @@ -69,22 +69,22 @@ export class ChromiumArchivePaths { { platform: 'linux', architecture: 'x64', - archiveFilename: 'chromium-fe621c5-locales-linux_x64.zip', - archiveChecksum: '12ce2e0eac184072dfcbc7a267328e3eb7fbe10a682997f4111c0378f2397341', - binaryChecksum: '670481cfa8db209401106cd23051009d390c03608724d0822a12c8c0a92b4c25', + archiveFilename: 'chromium-53ac076-locales-linux_x64.zip', + archiveChecksum: '50424bf105710d184198484a8a666db414627596002dacf80e83b00c8da71115', + binaryChecksum: 'afbc87a7f946bd6df763ffffb38dd4d75ee50c28ba705ac177dc893030d20206', binaryRelativePath: 'headless_shell-linux_x64/headless_shell', - revision: 1331488, + revision: 1356013, location: 'custom', isPreInstalled: true, }, { platform: 'linux', architecture: 'arm64', - archiveFilename: 'chromium-fe621c5-locales-linux_arm64.zip', - archiveChecksum: 'f7333eaff5235046c8775f0c1a0b7395b7ebc2e054ea638710cf511c4b6f9daf', - binaryChecksum: '8a3a3371b3d04f4b0880b137a3611c223e0d8e65a218943cb7be1ec4a91f5e35', + archiveFilename: 'chromium-53ac076-locales-linux_arm64.zip', + archiveChecksum: '24ffa183a6bf355209f3960a2377a1f8cc75aef093fe1934fcc72d2a5f9a274b', + binaryChecksum: 'db1c0226e03dfc26a6d61e02a885912906529e8477ac3214962b160d1e99f25c', binaryRelativePath: 'headless_shell-linux_arm64/headless_shell', - revision: 1331488, + revision: 1356013, location: 'custom', isPreInstalled: true, }, @@ -92,10 +92,10 @@ export class ChromiumArchivePaths { platform: 'win32', architecture: 'x64', archiveFilename: 'chrome-win.zip', - archiveChecksum: 'fa62be702f55f37e455bab4291c59ceb40e81e1922d30cf9453a4ee176b909bc', - binaryChecksum: '1345e66583bad1a1f16885f381d1173de8bf931487da9ba155e1b58bf23b2c66', + archiveChecksum: 'f86aadca5d1ab02fc05b580f23a30ee02d34bd348f9a3f0032b7117027676727', + binaryChecksum: 'b7b98dd681dfea2333a0136ba5788e38010730bb2e42eafa291b16931f00449d', binaryRelativePath: path.join('chrome-win', 'chrome.exe'), - revision: 1331487, // 1331488 is not available for win32 + revision: 1355984, location: 'common', archivePath: 'Win', isPreInstalled: true, diff --git a/packages/kbn-search-api-panels/components/language_client_panel.tsx b/packages/kbn-search-api-panels/components/language_client_panel.tsx index 2f89c8da7578c..2c07d3118d943 100644 --- a/packages/kbn-search-api-panels/components/language_client_panel.tsx +++ b/packages/kbn-search-api-panels/components/language_client_panel.tsx @@ -62,8 +62,12 @@ export const LanguageClientPanel: React.FC = ({ width={euiTheme.size.xl} /> - -
{language.name}
+ + {language.name}
diff --git a/x-pack/plugins/search_connectors/common/connectors.ts b/packages/kbn-search-connectors/constants/connectors.ts similarity index 50% rename from x-pack/plugins/search_connectors/common/connectors.ts rename to packages/kbn-search-connectors/constants/connectors.ts index b0bc5564e9750..ad5c716234133 100644 --- a/x-pack/plugins/search_connectors/common/connectors.ts +++ b/packages/kbn-search-connectors/constants/connectors.ts @@ -1,42 +1,207 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { i18n } from '@kbn/i18n'; +import { + ConnectorClientSideDefinition, + ConnectorServerSideDefinition, +} from '../types/connector_definition'; -export interface ConnectorServerSideDefinition { - categories?: string[]; - description?: string; - iconPath: string; - isBeta: boolean; - isNative: boolean; - isTechPreview?: boolean; - keywords: string[]; - name: string; - serviceType: string; -} +import { docLinks } from './doc_links'; + +// needs to be a function because, docLinks are only populated with actual +// documentation links in browser after SearchConnectorsPlugin starts +export const getConnectorsDict = (): Record => ({ + azure_blob_storage: { + docsUrl: docLinks.connectorsAzureBlobStorage, + externalAuthDocsUrl: 'https://learn.microsoft.com/azure/storage/common/authorize-data-access', + externalDocsUrl: 'https://learn.microsoft.com/azure/storage/blobs/', + platinumOnly: true, + }, + box: { + docsUrl: docLinks.connectorsBox, + externalAuthDocsUrl: '', + externalDocsUrl: '', + platinumOnly: true, + }, + confluence: { + docsUrl: docLinks.connectorsConfluence, + externalAuthDocsUrl: '', + externalDocsUrl: '', + platinumOnly: true, + }, + custom: { + docsUrl: docLinks.connectors, + externalAuthDocsUrl: '', + externalDocsUrl: '', + }, + dropbox: { + docsUrl: docLinks.connectorsDropbox, + externalAuthDocsUrl: '', + externalDocsUrl: '', + platinumOnly: true, + }, + github: { + docsUrl: docLinks.connectorsGithub, + externalAuthDocsUrl: '', + externalDocsUrl: '', + platinumOnly: true, + }, + gmail: { + docsUrl: docLinks.connectorsGmail, + externalAuthDocsUrl: '', + externalDocsUrl: '', + platinumOnly: true, + }, + google_cloud_storage: { + docsUrl: docLinks.connectorsGoogleCloudStorage, + externalAuthDocsUrl: 'https://cloud.google.com/storage/docs/authentication', + externalDocsUrl: 'https://cloud.google.com/storage/docs', + platinumOnly: true, + }, + google_drive: { + docsUrl: docLinks.connectorsGoogleDrive, + externalAuthDocsUrl: 'https://cloud.google.com/iam/docs/service-account-overview', + externalDocsUrl: 'https://developers.google.com/drive', + platinumOnly: true, + }, + jira: { + docsUrl: docLinks.connectorsJira, + externalAuthDocsUrl: '', + externalDocsUrl: '', + platinumOnly: true, + }, + microsoft_teams: { + docsUrl: docLinks.connectorsTeams, + externalAuthDocsUrl: '', + externalDocsUrl: '', + platinumOnly: true, + }, + mongodb: { + docsUrl: docLinks.connectorsMongoDB, + externalAuthDocsUrl: 'https://www.mongodb.com/docs/atlas/app-services/authentication/', + externalDocsUrl: 'https://www.mongodb.com/docs/', + platinumOnly: true, + }, + mssql: { + docsUrl: docLinks.connectorsMicrosoftSQL, + externalAuthDocsUrl: + 'https://learn.microsoft.com/sql/relational-databases/security/authentication-access/getting-started-with-database-engine-permissions', + externalDocsUrl: 'https://learn.microsoft.com/sql/', + platinumOnly: true, + }, + mysql: { + docsUrl: docLinks.connectorsMySQL, + externalDocsUrl: 'https://dev.mysql.com/doc/', + platinumOnly: true, + }, + network_drive: { + docsUrl: docLinks.connectorsNetworkDrive, + externalAuthDocsUrl: '', + externalDocsUrl: '', + platinumOnly: true, + }, + notion: { + docsUrl: docLinks.connectorsNotion, + externalAuthDocsUrl: '', + externalDocsUrl: '', + platinumOnly: true, + }, + onedrive: { + docsUrl: docLinks.connectorsOneDrive, + externalAuthDocsUrl: '', + externalDocsUrl: '', + platinumOnly: true, + }, + oracle: { + docsUrl: docLinks.connectorsOracle, + externalAuthDocsUrl: + 'https://docs.oracle.com/en/database/oracle/oracle-database/19/dbseg/index.html', + externalDocsUrl: 'https://docs.oracle.com/database/oracle/oracle-database/', + platinumOnly: true, + }, + outlook: { + docsUrl: docLinks.connectorsOutlook, + externalAuthDocsUrl: '', + externalDocsUrl: '', + platinumOnly: true, + }, + postgresql: { + docsUrl: docLinks.connectorsPostgreSQL, + externalAuthDocsUrl: 'https://www.postgresql.org/docs/15/auth-methods.html', + externalDocsUrl: 'https://www.postgresql.org/docs/', + platinumOnly: true, + }, + redis: { + docsUrl: docLinks.connectorsRedis, + externalAuthDocsUrl: '', + externalDocsUrl: '', + platinumOnly: true, + }, + s3: { + docsUrl: docLinks.connectorsS3, + externalAuthDocsUrl: 'https://docs.aws.amazon.com/s3/index.html', + externalDocsUrl: '', + platinumOnly: true, + }, + salesforce: { + docsUrl: docLinks.connectorsSalesforce, + externalAuthDocsUrl: '', + externalDocsUrl: '', + platinumOnly: true, + }, + servicenow: { + docsUrl: docLinks.connectorsServiceNow, + externalAuthDocsUrl: '', + externalDocsUrl: '', + platinumOnly: true, + }, + sharepoint_online: { + docsUrl: docLinks.connectorsSharepointOnline, + externalAuthDocsUrl: '', + externalDocsUrl: '', + platinumOnly: true, + }, + sharepoint_server: { + docsUrl: docLinks.connectorsSharepoint, + externalAuthDocsUrl: '', + externalDocsUrl: '', + platinumOnly: true, + }, + slack: { + docsUrl: docLinks.connectorsSlack, + externalAuthDocsUrl: '', + externalDocsUrl: '', + platinumOnly: true, + }, + zoom: { + docsUrl: docLinks.connectorsZoom, + externalAuthDocsUrl: '', + externalDocsUrl: '', + platinumOnly: true, + }, +}); /* The consumer should host these icons and transform the iconPath into something usable * Enterprise Search and Serverless Search do this right now */ - export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ { categories: ['search', 'elastic_stack', 'custom', 'connector', 'connector_client'], - description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.azureBlob.description', - { - defaultMessage: 'Search over your content on Azure Blob Storage.', - } - ), + description: i18n.translate('searchConnectors.content.nativeConnectors.azureBlob.description', { + defaultMessage: 'Search over your content on Azure Blob Storage.', + }), iconPath: 'azure_blob_storage.svg', isBeta: false, isNative: true, keywords: ['cloud', 'azure', 'blob', 's3', 'connector'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.azureBlob.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.azureBlob.name', { defaultMessage: 'Azure Blob Storage', }), serviceType: 'azure_blob_storage', @@ -44,7 +209,7 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ { categories: ['search', 'elastic_stack', 'custom', 'connector', 'connector_client'], description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.confluence.description', + 'searchConnectors.content.nativeConnectors.confluence.description', { defaultMessage: 'Search over your content on Confluence Cloud.', } @@ -53,7 +218,7 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ isBeta: false, isNative: true, keywords: ['confluence', 'cloud', 'connector'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.confluence.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.confluence.name', { defaultMessage: 'Confluence Cloud & Server', }), serviceType: 'confluence', @@ -61,7 +226,7 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ { categories: ['search', 'elastic_stack', 'custom', 'connector', 'connector_client'], description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.confluenceDataCenter.description', + 'searchConnectors.content.nativeConnectors.confluenceDataCenter.description', { defaultMessage: 'Search over your content on Confluence Data Center.', } @@ -71,45 +236,36 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ isNative: true, isTechPreview: true, keywords: ['confluence', 'data', 'center', 'connector'], - name: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.confluence_data_center.name', - { - defaultMessage: 'Confluence Data Center', - } - ), + name: i18n.translate('searchConnectors.content.nativeConnectors.confluence_data_center.name', { + defaultMessage: 'Confluence Data Center', + }), serviceType: 'confluence', }, { categories: ['search', 'elastic_stack', 'datastore', 'connector', 'connector_client'], - description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.dropbox.description', - { - defaultMessage: 'Search over your files and folders stored on Dropbox.', - } - ), + description: i18n.translate('searchConnectors.content.nativeConnectors.dropbox.description', { + defaultMessage: 'Search over your files and folders stored on Dropbox.', + }), iconPath: 'dropbox.svg', isBeta: false, isNative: true, isTechPreview: false, keywords: ['dropbox', 'connector'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.dropbox.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.dropbox.name', { defaultMessage: 'Dropbox', }), serviceType: 'dropbox', }, { categories: ['search', 'elastic_stack', 'custom', 'connector', 'connector_client', 'jira'], - description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.jira.description', - { - defaultMessage: 'Search over your content on Jira Cloud.', - } - ), + description: i18n.translate('searchConnectors.content.nativeConnectors.jira.description', { + defaultMessage: 'Search over your content on Jira Cloud.', + }), iconPath: 'jira_cloud.svg', isBeta: false, isNative: true, keywords: ['jira', 'cloud', 'connector'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.jira.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.jira.name', { defaultMessage: 'Jira Cloud', }), serviceType: 'jira', @@ -117,7 +273,7 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ { categories: ['search', 'elastic_stack', 'custom', 'connector', 'connector_client', 'jira'], description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.jiraServer.description', + 'searchConnectors.content.nativeConnectors.jiraServer.description', { defaultMessage: 'Search over your content on Jira Server.', } @@ -126,7 +282,7 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ isBeta: false, isNative: false, keywords: ['jira', 'server', 'connector'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.jiraServer.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.jiraServer.name', { defaultMessage: 'Jira Server', }), serviceType: 'jira', @@ -134,7 +290,7 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ { categories: ['search', 'elastic_stack', 'custom', 'connector', 'connector_client'], description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.jiraDataCenter.description', + 'searchConnectors.content.nativeConnectors.jiraDataCenter.description', { defaultMessage: 'Search over your content on Jira Data Center.', } @@ -144,24 +300,21 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ isTechPreview: true, isNative: true, keywords: ['jira', 'data', 'center', 'connector'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.jira_data_center.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.jira_data_center.name', { defaultMessage: 'Jira Data Center', }), serviceType: 'jira', }, { categories: ['search', 'elastic_stack', 'connector', 'connector_client'], - description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.github.description', - { - defaultMessage: 'Search over your projects and repos on GitHub.', - } - ), + description: i18n.translate('searchConnectors.content.nativeConnectors.github.description', { + defaultMessage: 'Search over your projects and repos on GitHub.', + }), iconPath: 'github.svg', isBeta: false, isNative: true, keywords: ['github', 'cloud', 'connector'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.github.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.github.name', { defaultMessage: 'GitHub & GitHub Enterprise Server', }), serviceType: 'github', @@ -169,7 +322,7 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ { categories: ['search', 'elastic_stack', 'custom', 'connector', 'connector_client'], description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.googleCloud.description', + 'searchConnectors.content.nativeConnectors.googleCloud.description', { defaultMessage: 'Search over your content on Google Cloud Storage.', } @@ -178,7 +331,7 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ isBeta: false, isNative: true, keywords: ['google', 'cloud', 'blob', 's3', 'connector'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.googleCloud.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.googleCloud.name', { defaultMessage: 'Google Cloud Storage', }), serviceType: 'google_cloud_storage', @@ -186,7 +339,7 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ { categories: ['search', 'elastic_stack', 'custom', 'connector', 'connector_client'], description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.googleDrive.description', + 'searchConnectors.content.nativeConnectors.googleDrive.description', { defaultMessage: 'Search over your content on Google Drive.', } @@ -195,24 +348,21 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ isBeta: false, isNative: true, keywords: ['google', 'drive', 'connector'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.googleDrive.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.googleDrive.name', { defaultMessage: 'Google Drive', }), serviceType: 'google_drive', }, { categories: ['search', 'elastic_stack', 'custom', 'connector', 'connector_client'], - description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.graphQL.description', - { - defaultMessage: 'Search over your content with GraphQL.', - } - ), + description: i18n.translate('searchConnectors.content.nativeConnectors.graphQL.description', { + defaultMessage: 'Search over your content with GraphQL.', + }), iconPath: 'graphql.svg', isBeta: false, isNative: false, keywords: ['graphql', 'connector'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.graphQL.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.graphQL.name', { defaultMessage: 'GraphQL', }), serviceType: 'graphql', @@ -220,58 +370,49 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ }, { categories: ['search', 'datastore', 'elastic_stack', 'connector', 'connector_client'], - description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.mongoDB.description', - { - defaultMessage: 'Search over your MongoDB content.', - } - ), + description: i18n.translate('searchConnectors.content.nativeConnectors.mongoDB.description', { + defaultMessage: 'Search over your MongoDB content.', + }), iconPath: 'mongodb.svg', isBeta: false, isNative: true, keywords: ['mongo', 'mongodb', 'database', 'nosql', 'connector'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.mongodb.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.mongodb.name', { defaultMessage: 'MongoDB', }), serviceType: 'mongodb', }, { categories: ['search', 'datastore', 'elastic_stack', 'connector', 'connector_client'], - description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.mysql.description', - { - defaultMessage: 'Search over your MySQL content.', - } - ), + description: i18n.translate('searchConnectors.content.nativeConnectors.mysql.description', { + defaultMessage: 'Search over your MySQL content.', + }), iconPath: 'mysql.svg', isBeta: false, isNative: true, keywords: ['mysql', 'sql', 'database', 'connector'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.mysql.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.mysql.name', { defaultMessage: 'MySQL', }), serviceType: 'mysql', }, { categories: ['search', 'custom', 'elastic_stack', 'datastore', 'connector', 'connector_client'], - description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.msSql.description', - { - defaultMessage: 'Search over your content on Microsoft SQL Server.', - } - ), + description: i18n.translate('searchConnectors.content.nativeConnectors.msSql.description', { + defaultMessage: 'Search over your content on Microsoft SQL Server.', + }), iconPath: 'mssql.svg', isBeta: false, isNative: true, keywords: ['mssql', 'microsoft', 'sql', 'database', 'connector'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.microsoftSQL.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.microsoftSQL.name', { defaultMessage: 'Microsoft SQL', }), serviceType: 'mssql', }, { description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.netowkrDrive.description', + 'searchConnectors.content.nativeConnectors.netowkrDrive.description', { defaultMessage: 'Search over your Network Drive content.', } @@ -281,31 +422,28 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ isBeta: false, isNative: true, keywords: ['network', 'drive', 'file', 'directory', 'connector'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.networkDrive.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.networkDrive.name', { defaultMessage: 'Network drive', }), serviceType: 'network_drive', }, { categories: ['search', 'elastic_stack', 'custom', 'connector', 'connector_client'], - description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.notion.description', - { - defaultMessage: 'Search over your content on Notion.', - } - ), + description: i18n.translate('searchConnectors.content.nativeConnectors.notion.description', { + defaultMessage: 'Search over your content on Notion.', + }), iconPath: 'notion.svg', isBeta: false, isNative: true, keywords: ['notion', 'connector'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.notion.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.notion.name', { defaultMessage: 'Notion', }), serviceType: 'notion', }, { description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.postgreSQL.description', + 'searchConnectors.content.nativeConnectors.postgreSQL.description', { defaultMessage: 'Search over your content on PostgreSQL.', } @@ -315,25 +453,22 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ isBeta: false, isNative: true, keywords: ['postgresql', 'sql', 'database', 'connector'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.postgresql.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.postgresql.name', { defaultMessage: 'PostgreSQL', }), serviceType: 'postgresql', }, { categories: ['search', 'elastic_stack', 'custom', 'connector', 'connector_client'], - description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.redis.description', - { - defaultMessage: 'Search over your content on Redis.', - } - ), + description: i18n.translate('searchConnectors.content.nativeConnectors.redis.description', { + defaultMessage: 'Search over your content on Redis.', + }), iconPath: 'redis.svg', isBeta: false, isNative: false, isTechPreview: true, keywords: ['redis', 'connector'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.redis.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.redis.name', { defaultMessage: 'Redis', }), serviceType: 'redis', @@ -341,7 +476,7 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ { categories: ['search', 'elastic_stack', 'connector', 'connector_client'], description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.salesforce.description', + 'searchConnectors.content.nativeConnectors.salesforce.description', { defaultMessage: 'Search over your content on Salesforce.', } @@ -350,7 +485,7 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ isBeta: false, isNative: true, keywords: ['salesforce', 'cloud', 'connector'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.salesforce.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.salesforce.name', { defaultMessage: 'Salesforce', }), serviceType: 'salesforce', @@ -358,7 +493,7 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ { categories: ['search', 'elastic_stack', 'custom', 'datastore', 'connector', 'connector_client'], description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.salesforceSandbox.description', + 'searchConnectors.content.nativeConnectors.salesforceSandbox.description', { defaultMessage: 'Search over your content on Salesforce Sandbox.', } @@ -367,7 +502,7 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ isBeta: false, isNative: true, keywords: ['salesforce', 'cloud', 'connector', 'sandbox'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.salesforceBox.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.salesforceBox.name', { defaultMessage: 'Salesforce Sandbox', }), serviceType: 'salesforce', @@ -375,7 +510,7 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ { categories: ['search', 'elastic_stack', 'connector', 'connector_client'], description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.serviceNow.description', + 'searchConnectors.content.nativeConnectors.serviceNow.description', { defaultMessage: 'Search over your content on ServiceNow.', } @@ -385,7 +520,7 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ isNative: true, isTechPreview: false, keywords: ['servicenow', 'cloud', 'connector'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.serviceNow.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.serviceNow.name', { defaultMessage: 'ServiceNow', }), serviceType: 'servicenow', @@ -393,7 +528,7 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ { categories: ['search', 'elastic_stack', 'connector', 'connector_client'], description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.sharepointOnline.description', + 'searchConnectors.content.nativeConnectors.sharepointOnline.description', { defaultMessage: 'Search over your content on SharePoint Online.', } @@ -403,24 +538,21 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ isNative: true, isTechPreview: false, keywords: ['sharepoint', 'office365', 'cloud', 'connector'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.sharepointOnline.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.sharepointOnline.name', { defaultMessage: 'Sharepoint Online', }), serviceType: 'sharepoint_online', }, { categories: ['search', 'elastic_stack', 'connector', 'connector_client'], - description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.gmail.description', - { - defaultMessage: 'Search over your content on Gmail.', - } - ), + description: i18n.translate('searchConnectors.content.nativeConnectors.gmail.description', { + defaultMessage: 'Search over your content on Gmail.', + }), iconPath: 'gmail.svg', isBeta: false, isNative: true, keywords: ['gmail', 'connector'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.gmail.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.gmail.name', { defaultMessage: 'Gmail', }), serviceType: 'gmail', @@ -428,7 +560,7 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ { categories: ['search', 'elastic_stack', 'connector', 'connector_client'], description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.openTextDocumentum.description', + 'searchConnectors.content.nativeConnectors.openTextDocumentum.description', { defaultMessage: 'Search over your content on OpenText Documentum.', } @@ -438,50 +570,41 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ isNative: false, isTechPreview: true, keywords: ['opentext', 'documentum', 'connector'], - name: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.openTextDocumentum.name', - { - defaultMessage: 'OpenText Documentum', - } - ), + name: i18n.translate('searchConnectors.content.nativeConnectors.openTextDocumentum.name', { + defaultMessage: 'OpenText Documentum', + }), serviceType: 'opentext_documentum', }, { categories: ['search', 'elastic_stack', 'custom', 'datastore', 'connector', 'connector_client'], - description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.oracle.description', - { - defaultMessage: 'Search over your content on Oracle.', - } - ), + description: i18n.translate('searchConnectors.content.nativeConnectors.oracle.description', { + defaultMessage: 'Search over your content on Oracle.', + }), iconPath: 'oracle.svg', isBeta: false, isNative: true, keywords: ['oracle', 'sql', 'database', 'connector'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.oracle.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.oracle.name', { defaultMessage: 'Oracle', }), serviceType: 'oracle', }, { categories: ['search', 'elastic_stack', 'custom', 'datastore', 'connector', 'connector_client'], - description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.oneDrive.description', - { - defaultMessage: 'Search over your content on OneDrive.', - } - ), + description: i18n.translate('searchConnectors.content.nativeConnectors.oneDrive.description', { + defaultMessage: 'Search over your content on OneDrive.', + }), iconPath: 'onedrive.svg', isBeta: false, isNative: true, keywords: ['network', 'drive', 'file', 'connector'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.oneDrive.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.oneDrive.name', { defaultMessage: 'OneDrive', }), serviceType: 'onedrive', }, { - description: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.s3.description', { + description: i18n.translate('searchConnectors.content.nativeConnectors.s3.description', { defaultMessage: 'Search over your content on Amazon S3.', }), categories: ['search', 'datastore', 'elastic_stack', 'connector', 'connector_client'], @@ -489,25 +612,22 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ isBeta: false, isNative: true, keywords: ['s3', 'cloud', 'amazon', 'connector'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.s3.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.s3.name', { defaultMessage: 'S3', }), serviceType: 's3', }, { - description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.slack.description', - { - defaultMessage: 'Search over your content on Slack.', - } - ), + description: i18n.translate('searchConnectors.content.nativeConnectors.slack.description', { + defaultMessage: 'Search over your content on Slack.', + }), categories: ['search', 'elastic_stack', 'connector', 'connector_client'], iconPath: 'slack.svg', isBeta: false, isNative: true, isTechPreview: true, keywords: ['slack', 'connector'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.slack.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.slack.name', { defaultMessage: 'Slack', }), serviceType: 'slack', @@ -515,7 +635,7 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ { categories: ['search', 'elastic_stack', 'custom', 'connector', 'connector_client'], description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.sharepointServer.description', + 'searchConnectors.content.nativeConnectors.sharepointServer.description', { defaultMessage: 'Search over your content on SharePoint Server.', } @@ -525,14 +645,14 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ isNative: true, isTechPreview: false, keywords: ['sharepoint', 'cloud', 'connector'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.sharepointServer.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.sharepointServer.name', { defaultMessage: 'Sharepoint Server', }), serviceType: 'sharepoint_server', }, { categories: ['search', 'elastic_stack', 'custom', 'connector', 'connector_client', 'box'], - description: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.box.description', { + description: i18n.translate('searchConnectors.content.nativeConnectors.box.description', { defaultMessage: 'Search over your content on Box.', }), iconPath: 'box.svg', @@ -540,60 +660,51 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ isNative: true, isTechPreview: true, keywords: ['cloud', 'box'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.box.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.box.name', { defaultMessage: 'Box', }), serviceType: 'box', }, { - description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.outlook.description', - { - defaultMessage: 'Search over your content on Outlook.', - } - ), + description: i18n.translate('searchConnectors.content.nativeConnectors.outlook.description', { + defaultMessage: 'Search over your content on Outlook.', + }), categories: ['search', 'elastic_stack', 'custom', 'connector', 'connector_client', 'outlook'], iconPath: 'outlook.svg', isBeta: false, isNative: true, keywords: ['outlook', 'connector'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.outlook.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.outlook.name', { defaultMessage: 'Outlook', }), serviceType: 'outlook', }, { categories: ['search', 'elastic_stack', 'custom', 'connector', 'connector_client', 'teams'], - description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.teams.description', - { - defaultMessage: 'Search over your content on Teams.', - } - ), + description: i18n.translate('searchConnectors.content.nativeConnectors.teams.description', { + defaultMessage: 'Search over your content on Teams.', + }), iconPath: 'teams.svg', isBeta: false, isNative: true, isTechPreview: true, keywords: ['teams', 'connector'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.teams.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.teams.name', { defaultMessage: 'Teams', }), serviceType: 'microsoft_teams', }, { categories: ['search', 'elastic_stack', 'custom', 'connector', 'connector_client', 'zoom'], - description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.zoom.description', - { - defaultMessage: 'Search over your content on Zoom.', - } - ), + description: i18n.translate('searchConnectors.content.nativeConnectors.zoom.description', { + defaultMessage: 'Search over your content on Zoom.', + }), iconPath: 'zoom.svg', isBeta: false, isNative: true, isTechPreview: true, keywords: ['zoom', 'connector'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.zoom.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.zoom.name', { defaultMessage: 'Zoom', }), serviceType: 'zoom', @@ -601,7 +712,7 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ { categories: ['search', 'custom', 'elastic_stack', 'connector', 'connector_client'], description: i18n.translate( - 'searchConnectorsPlugin.content.nativeConnectors.customConnector.description', + 'searchConnectors.content.nativeConnectors.customConnector.description', { defaultMessage: 'Search over data stored on custom data sources.', } @@ -610,7 +721,7 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ isBeta: false, isNative: false, keywords: ['custom', 'connector', 'code'], - name: i18n.translate('searchConnectorsPlugin.content.nativeConnectors.customConnector.name', { + name: i18n.translate('searchConnectors.content.nativeConnectors.customConnector.name', { defaultMessage: 'Customized connector', }), serviceType: '', diff --git a/x-pack/plugins/search_connectors/common/doc_links.ts b/packages/kbn-search-connectors/constants/doc_links.ts similarity index 90% rename from x-pack/plugins/search_connectors/common/doc_links.ts rename to packages/kbn-search-connectors/constants/doc_links.ts index 0c5edc1a07ca7..db4dc3870e5c4 100644 --- a/x-pack/plugins/search_connectors/common/doc_links.ts +++ b/packages/kbn-search-connectors/constants/doc_links.ts @@ -1,8 +1,10 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 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 { DocLinks } from '@kbn/doc-links'; diff --git a/packages/kbn-unified-field-list/src/services/field_stats_text_based/index.ts b/packages/kbn-search-connectors/constants/index.ts similarity index 86% rename from packages/kbn-unified-field-list/src/services/field_stats_text_based/index.ts rename to packages/kbn-search-connectors/constants/index.ts index 8915a30bf4f41..6019d3d61be2f 100644 --- a/packages/kbn-unified-field-list/src/services/field_stats_text_based/index.ts +++ b/packages/kbn-search-connectors/constants/index.ts @@ -7,4 +7,5 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { loadFieldStatsTextBased } from './load_field_stats_text_based'; +export * from './connectors'; +export * from './doc_links'; diff --git a/packages/kbn-search-connectors/index.ts b/packages/kbn-search-connectors/index.ts index 2418d0f4d557d..5f45a1bd8ffb6 100644 --- a/packages/kbn-search-connectors/index.ts +++ b/packages/kbn-search-connectors/index.ts @@ -16,6 +16,7 @@ export const CONNECTORS_ACCESS_CONTROL_INDEX_PREFIX = '.search-acl-filter-'; export const CRAWLER_SERVICE_TYPE = 'elastic-crawler'; export * from './components'; +export * from './constants'; export * from './lib'; export * from './types'; export * from './utils'; diff --git a/packages/kbn-search-connectors/tsconfig.json b/packages/kbn-search-connectors/tsconfig.json index cb54e57748e94..4aebaeb1fcb13 100644 --- a/packages/kbn-search-connectors/tsconfig.json +++ b/packages/kbn-search-connectors/tsconfig.json @@ -25,5 +25,6 @@ "@kbn/i18n-react", "@kbn/test-jest-helpers", "@kbn/std", + "@kbn/doc-links", ] } diff --git a/packages/kbn-search-connectors/types/connector_definition.ts b/packages/kbn-search-connectors/types/connector_definition.ts new file mode 100644 index 0000000000000..a2dccf6554959 --- /dev/null +++ b/packages/kbn-search-connectors/types/connector_definition.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 interface ConnectorClientSideDefinition { + docsUrl?: string; + externalAuthDocsUrl?: string; + externalDocsUrl: string; + platinumOnly?: boolean; +} + +export interface ConnectorServerSideDefinition { + categories?: string[]; + description?: string; + iconPath: string; + isBeta: boolean; + isNative: boolean; + isTechPreview?: boolean; + keywords: string[]; + name: string; + serviceType: string; +} + +export type ConnectorDefinition = ConnectorClientSideDefinition & ConnectorServerSideDefinition; diff --git a/packages/kbn-search-connectors/types/index.ts b/packages/kbn-search-connectors/types/index.ts index ca5c483ab51df..aaf98748cfcbd 100644 --- a/packages/kbn-search-connectors/types/index.ts +++ b/packages/kbn-search-connectors/types/index.ts @@ -7,6 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +export * from './connector_definition'; export * from './connectors'; export * from './connectors_api'; export * from './connector_stats'; diff --git a/packages/kbn-search-connectors/types/native_connectors.ts b/packages/kbn-search-connectors/types/native_connectors.ts index f9719cb84b801..c5ef7beab0ba5 100644 --- a/packages/kbn-search-connectors/types/native_connectors.ts +++ b/packages/kbn-search-connectors/types/native_connectors.ts @@ -11,54 +11,64 @@ import { i18n } from '@kbn/i18n'; import { DisplayType, FeatureName, FieldType, NativeConnector } from './connectors'; -const USERNAME_LABEL = i18n.translate('searchConnectors.nativeConnectors.usernameLabel', { +// assigning these to a local var significantly improves bundle size +// because it reduces references to the imported modules. +const { translate } = i18n; +const { TEXTBOX, TEXTAREA, NUMERIC, TOGGLE, DROPDOWN } = DisplayType; +const { + SYNC_RULES, + INCREMENTAL_SYNC, + DOCUMENT_LEVEL_SECURITY, + FILTERING_ADVANCED_CONFIG, + FILTERING_RULES, +} = FeatureName; +const { STRING, LIST, INTEGER, BOOLEAN } = FieldType; + +const USERNAME_LABEL = translate('searchConnectors.nativeConnectors.usernameLabel', { defaultMessage: 'Username', }); -const PASSWORD_LABEL = i18n.translate('searchConnectors.nativeConnectors.passwordLabel', { +const PASSWORD_LABEL = translate('searchConnectors.nativeConnectors.passwordLabel', { defaultMessage: 'Password', }); -const ENABLE_SSL_LABEL = i18n.translate('searchConnectors.nativeConnectors.enableSSL.label', { +const ENABLE_SSL_LABEL = translate('searchConnectors.nativeConnectors.enableSSL.label', { defaultMessage: 'Enable SSL', }); -const SSL_CERTIFICATE_LABEL = i18n.translate( - 'searchConnectors.nativeConnectors.sslCertificate.label', - { - defaultMessage: 'SSL certificate', - } -); +const SSL_CERTIFICATE_LABEL = translate('searchConnectors.nativeConnectors.sslCertificate.label', { + defaultMessage: 'SSL certificate', +}); -const RETRIES_PER_REQUEST_LABEL = i18n.translate( +const RETRIES_PER_REQUEST_LABEL = translate( 'searchConnectors.nativeConnectors.retriesPerRequest.label', { defaultMessage: 'Retries per request', } ); -const ADVANCED_RULES_IGNORED_LABEL = i18n.translate( +const ADVANCED_RULES_IGNORED_LABEL = translate( 'searchConnectors.nativeConnectors.advancedRulesIgnored.label', { defaultMessage: 'This configurable field is ignored when Advanced Sync Rules are used.', } ); -const MAX_CONCURRENT_DOWNLOADS_LABEL = i18n.translate( +const MAX_CONCURRENT_DOWNLOADS_LABEL = translate( 'searchConnectors.nativeConnectors.nativeConnectors.maximumConcurrentLabel', { defaultMessage: 'Maximum concurrent downloads', } ); -const USE_TEXT_EXTRACTION_SERVICE_LABEL = i18n.translate( +const USE_TEXT_EXTRACTION_SERVICE_LABEL = translate( 'searchConnectors.nativeConnectors.textExtractionService.label', { defaultMessage: 'Use text extraction service', } ); -const USE_TEXT_EXTRACTION_SERVICE_TOOLTIP = i18n.translate( +const USE_TEXT_EXTRACTION_SERVICE_TOOLTIP = translate( 'searchConnectors.nativeConnectors.textExtractionService.tooltip', { defaultMessage: @@ -67,7 +77,7 @@ const USE_TEXT_EXTRACTION_SERVICE_TOOLTIP = i18n.translate( } ); -const ENABLE_DOCUMENT_LEVEL_SECURITY_LABEL = i18n.translate( +const ENABLE_DOCUMENT_LEVEL_SECURITY_LABEL = translate( 'searchConnectors.nativeConnectors.enableDLS.label', { defaultMessage: 'Enable document level security', @@ -75,21 +85,21 @@ const ENABLE_DOCUMENT_LEVEL_SECURITY_LABEL = i18n.translate( ); const getEnableDocumentLevelSecurityTooltip = (serviceName: string) => - i18n.translate('searchConnectors.nativeConnectors.enableDLS.tooltip', { + translate('searchConnectors.nativeConnectors.enableDLS.tooltip', { defaultMessage: 'Document level security ensures identities and permissions set in {serviceName} are maintained in Elasticsearch. This enables you to restrict and personalize read-access users and groups have to documents in this index. Access control syncs ensure this metadata is kept up to date in your Elasticsearch documents.', values: { serviceName }, }); -const DATABASE_LABEL = i18n.translate('searchConnectors.nativeConnectors.databaseLabel', { +const DATABASE_LABEL = translate('searchConnectors.nativeConnectors.databaseLabel', { defaultMessage: 'Database', }); -const SCHEMA_LABEL = i18n.translate('searchConnectors.nativeConnectors.schemaLabel', { +const SCHEMA_LABEL = translate('searchConnectors.nativeConnectors.schemaLabel', { defaultMessage: 'Schema', }); -const PORT_LABEL = i18n.translate('searchConnectors.nativeConnectors.portLabel', { +const PORT_LABEL = translate('searchConnectors.nativeConnectors.portLabel', { defaultMessage: 'Port', }); @@ -103,19 +113,16 @@ export const NATIVE_CONNECTOR_DEFINITIONS: Record Account -> Settings -> Customer Id', }), - type: FieldType.STRING, + type: STRING, ui_restrictions: [], validations: [], value: '', @@ -1355,24 +1341,21 @@ export const NATIVE_CONNECTOR_DEFINITIONS: Record { expect(warnings).toEqual([]); }); + + it('should not include warnings when there is no _clusters or _shards information', () => { + const warnings = extractWarnings( + { + took: 46, + all_columns: [{ name: 'field1', type: 'string' }], + columns: [{ name: 'field1', type: 'string' }], + values: [['value1']], + } as ESQLSearchResponse, + mockInspectorService, + mockRequestAdapter, + 'My request' + ); + + expect(warnings).toEqual([]); + }); }); describe('remote clusters', () => { diff --git a/packages/kbn-search-response-warnings/src/extract_warnings.ts b/packages/kbn-search-response-warnings/src/extract_warnings.ts index 7d53a954f7715..58e963c239b12 100644 --- a/packages/kbn-search-response-warnings/src/extract_warnings.ts +++ b/packages/kbn-search-response-warnings/src/extract_warnings.ts @@ -8,6 +8,7 @@ */ import { estypes } from '@elastic/elasticsearch'; +import type { ESQLSearchResponse } from '@kbn/es-types'; import type { Start as InspectorStartContract, RequestAdapter } from '@kbn/inspector-plugin/public'; import type { SearchResponseWarning } from './types'; @@ -15,7 +16,7 @@ import type { SearchResponseWarning } from './types'; * @internal */ export function extractWarnings( - rawResponse: estypes.SearchResponse, + rawResponse: estypes.SearchResponse | ESQLSearchResponse, inspectorService: InspectorStartContract, requestAdapter: RequestAdapter, requestName: string, @@ -23,11 +24,13 @@ export function extractWarnings( ): SearchResponseWarning[] { const warnings: SearchResponseWarning[] = []; + // ES|QL supports _clusters in case of CCS but doesnt support _shards and timed_out (yet) const isPartial = rawResponse._clusters ? rawResponse._clusters.partial > 0 || rawResponse._clusters.skipped > 0 || rawResponse._clusters.running > 0 - : rawResponse.timed_out || rawResponse._shards.failed > 0; + : ('timed_out' in rawResponse && rawResponse.timed_out) || + ('_shards' in rawResponse && rawResponse._shards.failed > 0); if (isPartial) { warnings.push({ type: 'incomplete', @@ -39,9 +42,10 @@ export function extractWarnings( status: 'partial', indices: '', took: rawResponse.took, - timed_out: rawResponse.timed_out, - _shards: rawResponse._shards, - failures: rawResponse._shards.failures, + timed_out: 'timed_out' in rawResponse && rawResponse.timed_out, + ...('_shards' in rawResponse + ? { _shards: rawResponse._shards, failures: rawResponse._shards.failures } + : {}), }, }, openInInspector: () => { diff --git a/packages/kbn-search-response-warnings/tsconfig.json b/packages/kbn-search-response-warnings/tsconfig.json index 6823ef5abf8a1..1f87892403a61 100644 --- a/packages/kbn-search-response-warnings/tsconfig.json +++ b/packages/kbn-search-response-warnings/tsconfig.json @@ -10,6 +10,7 @@ "@kbn/core", "@kbn/react-kibana-mount", "@kbn/core-i18n-browser", + "@kbn/es-types", ], "exclude": ["target/**/*"] } diff --git a/packages/kbn-test/src/auth/saml_auth.ts b/packages/kbn-test/src/auth/saml_auth.ts index 0ec264d66d425..601fcf8301d9a 100644 --- a/packages/kbn-test/src/auth/saml_auth.ts +++ b/packages/kbn-test/src/auth/saml_auth.ts @@ -10,6 +10,7 @@ import { createSAMLResponse as createMockedSAMLResponse } from '@kbn/mock-idp-utils'; import { ToolingLog } from '@kbn/tooling-log'; import axios, { AxiosResponse } from 'axios'; +import util from 'util'; import * as cheerio from 'cheerio'; import { Cookie, parse as parseCookie } from 'tough-cookie'; import Url from 'url'; @@ -253,23 +254,26 @@ export const finishSAMLHandshake = async ({ }) => { const encodedResponse = encodeURIComponent(samlResponse); const url = kbnHost + '/api/security/saml/callback'; + const request = { + url, + method: 'post', + data: `SAMLResponse=${encodedResponse}`, + headers: { + 'content-type': 'application/x-www-form-urlencoded', + ...(sid ? { Cookie: `sid=${sid}` } : {}), + }, + validateStatus: () => true, + maxRedirects: 0, + }; let authResponse: AxiosResponse; try { - authResponse = await axios.request({ - url, - method: 'post', - data: `SAMLResponse=${encodedResponse}`, - headers: { - 'content-type': 'application/x-www-form-urlencoded', - ...(sid ? { Cookie: `sid=${sid}` } : {}), - }, - validateStatus: () => true, - maxRedirects: 0, - }); + authResponse = await axios.request(request); } catch (ex) { log.error('Failed to call SAML callback'); cleanException(url, ex); + // Logging the `Cookie: sid=xxxx` header is safe here since it’s an intermediate, non-authenticated cookie that cannot be reused if leaked. + log.error(`Request sent: ${util.inspect(request)}`); throw ex; } diff --git a/packages/kbn-test/src/auth/session_manager.test.ts b/packages/kbn-test/src/auth/session_manager.test.ts index 98c0181141e54..4b20581eced4c 100644 --- a/packages/kbn-test/src/auth/session_manager.test.ts +++ b/packages/kbn-test/src/auth/session_manager.test.ts @@ -172,7 +172,7 @@ describe('SamlSessionManager', () => { describe('for cloud session', () => { const hostOptions = { protocol: 'https' as 'http' | 'https', - hostname: 'cloud', + hostname: 'my-test-deployment.test.elastic.cloud', username: 'elastic', password: 'changeme', }; @@ -328,4 +328,31 @@ describe('SamlSessionManager', () => { expect(createCloudSAMLSessionMock.mock.calls).toHaveLength(0); }); }); + + describe(`for cloud session with 'isCloud' set to false`, () => { + const hostOptions = { + protocol: 'http' as 'http' | 'https', + hostname: 'my-test-deployment.test.elastic.cloud', + username: 'elastic', + password: 'changeme', + }; + const samlSessionManagerOptions = { + hostOptions, + isCloud: false, + log, + cloudUsersFilePath, + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('should throw an error when kbnHost points to a Cloud instance', () => { + const kbnHost = `${hostOptions.protocol}://${hostOptions.hostname}`; + expect(() => new SamlSessionManager(samlSessionManagerOptions)).toThrow( + `SamlSessionManager: 'isCloud' was set to false, but 'kbnHost' appears to be a Cloud instance: ${kbnHost} +Set env variable 'TEST_CLOUD=1' to run FTR against your Cloud deployment` + ); + }); + }); }); diff --git a/packages/kbn-test/src/auth/session_manager.ts b/packages/kbn-test/src/auth/session_manager.ts index e376135296bd7..ba411aaa21891 100644 --- a/packages/kbn-test/src/auth/session_manager.ts +++ b/packages/kbn-test/src/auth/session_manager.ts @@ -54,7 +54,6 @@ export class SamlSessionManager { private readonly cloudUsersFilePath: string; constructor(options: SamlSessionManagerOptions) { - this.isCloud = options.isCloud; this.log = options.log; const hostOptionsWithoutAuth = { protocol: options.hostOptions.protocol, @@ -62,6 +61,7 @@ export class SamlSessionManager { port: options.hostOptions.port, }; this.kbnHost = Url.format(hostOptionsWithoutAuth); + this.isCloud = options.isCloud; this.kbnClient = new KbnClient({ log: this.log, url: Url.format({ @@ -73,6 +73,22 @@ export class SamlSessionManager { this.sessionCache = new Map(); this.roleToUserMap = new Map(); this.supportedRoles = options.supportedRoles; + this.validateCloudSetting(); + } + + /** + * Validates if the 'kbnHost' points to Cloud, even if 'isCloud' was set to false + */ + private validateCloudSetting() { + const cloudSubDomains = ['elastic.cloud', 'foundit.no', 'cloud.es.io', 'elastic-cloud.com']; + const isCloudHost = cloudSubDomains.some((domain) => this.kbnHost.endsWith(domain)); + + if (!this.isCloud && isCloudHost) { + throw new Error( + `SamlSessionManager: 'isCloud' was set to false, but 'kbnHost' appears to be a Cloud instance: ${this.kbnHost} +Set env variable 'TEST_CLOUD=1' to run FTR against your Cloud deployment` + ); + } } /** diff --git a/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts b/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts index 724cf5bc2b25e..964359cdb7ee5 100644 --- a/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts +++ b/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts @@ -11,6 +11,7 @@ import Url from 'url'; import { resolve } from 'path'; import type { ToolingLog } from '@kbn/tooling-log'; import getPort from 'get-port'; +import getopts from 'getopts'; import { REPO_ROOT } from '@kbn/repo-info'; import type { ArtifactLicense, ServerlessProjectType } from '@kbn/es'; import { isServerlessProjectType, extractAndArchiveLogs } from '@kbn/es/src/utils'; @@ -196,12 +197,8 @@ function getESServerlessOptions( (config.get('kbnTestServer.serverArgs') as string[])) || []; - const projectType = kbnServerArgs - .filter((arg) => arg.startsWith('--serverless')) - .reduce((acc, arg) => { - const match = arg.match(/--serverless[=\s](\w+)/); - return acc + (match ? match[1] : ''); - }, '') as ServerlessProjectType; + const options = getopts(kbnServerArgs); + const projectType = options.serverless as ServerlessProjectType; if (!isServerlessProjectType(projectType)) { throw new Error(`Unsupported serverless projectType: ${projectType}`); diff --git a/packages/kbn-test/src/jest/mocks/react_dom_client_mock.ts b/packages/kbn-test/src/jest/mocks/react_dom_client_mock.ts new file mode 100644 index 0000000000000..4e24481458767 --- /dev/null +++ b/packages/kbn-test/src/jest/mocks/react_dom_client_mock.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 {}; diff --git a/packages/kbn-test/src/jest/resolver.js b/packages/kbn-test/src/jest/resolver.js index 8f985e9463962..a3303ecf17e45 100644 --- a/packages/kbn-test/src/jest/resolver.js +++ b/packages/kbn-test/src/jest/resolver.js @@ -51,6 +51,20 @@ module.exports = (request, options) => { }); } + if (request === '@launchdarkly/js-sdk-common') { + return resolve.sync('@launchdarkly/js-sdk-common/dist/cjs/index.cjs', { + basedir: options.basedir, + extensions: options.extensions, + }); + } + + // This is a workaround to run tests with React 17 and the latest @testing-library/react + // This will be removed once we upgrade to React 18 and start transitioning to the Concurrent Mode + // Tracking issue to clean this up https://github.com/elastic/kibana/issues/199100 + if (request === 'react-dom/client') { + return Path.resolve(__dirname, 'mocks/react_dom_client_mock.ts'); + } + if (request === `elastic-apm-node`) { return APM_AGENT_MOCK; } diff --git a/packages/kbn-test/src/jest/setup/react_testing_library.js b/packages/kbn-test/src/jest/setup/react_testing_library.js index a04ee097a5ec7..1444aa41949ef 100644 --- a/packages/kbn-test/src/jest/setup/react_testing_library.js +++ b/packages/kbn-test/src/jest/setup/react_testing_library.js @@ -19,3 +19,34 @@ import { configure } from '@testing-library/react'; // instead of default 'data-testid', use kibana's 'data-test-subj' configure({ testIdAttribute: 'data-test-subj', asyncUtilTimeout: 4500 }); + +/* eslint-env jest */ + +// This is a workaround to run tests with React 17 and the latest @testing-library/react +// Tracking issue to clean this up https://github.com/elastic/kibana/issues/199100 +jest.mock('@testing-library/react', () => { + const actual = jest.requireActual('@testing-library/react'); + + return { + ...actual, + render: (ui, options) => actual.render(ui, { ...options, legacyRoot: true }), + renderHook: (render, options) => actual.renderHook(render, { ...options, legacyRoot: true }), + }; +}); + +// This is a workaround to run tests with React 17 and the latest @testing-library/react +// And prevent the act warnings that were supposed to be muted by @testing-library +// The testing library mutes the act warnings in some cases by setting IS_REACT_ACT_ENVIRONMENT which is React@18 feature https://github.com/testing-library/react-testing-library/pull/1137/ +// Using this console override we're muting the act warnings as well +// Tracking issue to clean this up https://github.com/elastic/kibana/issues/199100 +// NOTE: we're not muting all the act warnings but only those that testing-library wanted to mute +const originalConsoleError = console.error; +console.error = (...args) => { + if (global.IS_REACT_ACT_ENVIRONMENT === false) { + if (args[0].includes('Warning: An update to %s inside a test was not wrapped in act')) { + return; + } + } + + originalConsoleError(...args); +}; diff --git a/packages/kbn-test/src/kbn_client/kbn_client_saved_objects.ts b/packages/kbn-test/src/kbn_client/kbn_client_saved_objects.ts index d5483f1fe0f9f..0b6ba0be80fab 100644 --- a/packages/kbn-test/src/kbn_client/kbn_client_saved_objects.ts +++ b/packages/kbn-test/src/kbn_client/kbn_client_saved_objects.ts @@ -109,6 +109,7 @@ const STANDARD_LIST_TYPES = [ 'synthetics-monitor', 'uptime-dynamic-settings', 'synthetics-privates-locations', + 'synthetics-private-location', 'osquery-saved-query', 'osquery-pack', diff --git a/packages/kbn-unified-data-table/src/components/__snapshots__/data_table_columns.test.tsx.snap b/packages/kbn-unified-data-table/src/components/__snapshots__/data_table_columns.test.tsx.snap index bd28bfc354f9f..fd1ad71558aa5 100644 --- a/packages/kbn-unified-data-table/src/components/__snapshots__/data_table_columns.test.tsx.snap +++ b/packages/kbn-unified-data-table/src/components/__snapshots__/data_table_columns.test.tsx.snap @@ -48,6 +48,9 @@ Array [ test , "displayAsText": "extension", + "displayHeaderCellProps": Object { + "className": "unifiedDataTable__headerCell", + }, "id": "extension", "isSortable": true, "schema": "string", @@ -332,6 +335,9 @@ Array [ headerRowHeight={5} />, "displayAsText": "message", + "displayHeaderCellProps": Object { + "className": "unifiedDataTable__headerCell", + }, "id": "message", "isSortable": true, "schema": "string", @@ -589,6 +595,9 @@ Array [ headerRowHeight={5} />, "displayAsText": "timestamp", + "displayHeaderCellProps": Object { + "className": "unifiedDataTable__headerCell", + }, "id": "timestamp", "initialWidth": 212, "isSortable": true, @@ -837,6 +846,9 @@ Array [ showColumnTokens={true} />, "displayAsText": "extension", + "displayHeaderCellProps": Object { + "className": "unifiedDataTable__headerCell", + }, "id": "extension", "isSortable": false, "schema": "string", @@ -1082,6 +1094,9 @@ Array [ showColumnTokens={true} />, "displayAsText": "message", + "displayHeaderCellProps": Object { + "className": "unifiedDataTable__headerCell", + }, "id": "message", "isSortable": false, "schema": "string", @@ -1372,6 +1387,9 @@ Array [ showColumnTokens={true} />, "displayAsText": "extension", + "displayHeaderCellProps": Object { + "className": "unifiedDataTable__headerCell", + }, "id": "extension", "isSortable": false, "schema": "string", @@ -1655,6 +1673,9 @@ Array [ showColumnTokens={true} />, "displayAsText": "message", + "displayHeaderCellProps": Object { + "className": "unifiedDataTable__headerCell", + }, "id": "message", "isSortable": false, "schema": "string", @@ -1819,6 +1840,9 @@ Array [ headerRowHeight={5} />, "displayAsText": "extension", + "displayHeaderCellProps": Object { + "className": "unifiedDataTable__headerCell", + }, "id": "extension", "isSortable": false, "schema": "string", @@ -1976,6 +2000,9 @@ Array [ headerRowHeight={5} />, "displayAsText": "message", + "displayHeaderCellProps": Object { + "className": "unifiedDataTable__headerCell", + }, "id": "message", "isSortable": false, "schema": "string", @@ -2191,6 +2218,9 @@ Array [ headerRowHeight={5} />, "displayAsText": "timestamp", + "displayHeaderCellProps": Object { + "className": "unifiedDataTable__headerCell", + }, "id": "timestamp", "initialWidth": 212, "isSortable": true, @@ -2396,6 +2426,9 @@ Array [ headerRowHeight={5} />, "displayAsText": "extension", + "displayHeaderCellProps": Object { + "className": "unifiedDataTable__headerCell", + }, "id": "extension", "isSortable": false, "schema": "string", @@ -2598,6 +2631,9 @@ Array [ headerRowHeight={5} />, "displayAsText": "message", + "displayHeaderCellProps": Object { + "className": "unifiedDataTable__headerCell", + }, "id": "message", "isSortable": false, "schema": "string", @@ -2823,6 +2859,9 @@ Array [ headerRowHeight={5} />, "displayAsText": "timestamp", + "displayHeaderCellProps": Object { + "className": "unifiedDataTable__headerCell", + }, "id": "timestamp", "initialWidth": 212, "isSortable": true, @@ -3043,6 +3082,9 @@ Array [ headerRowHeight={5} />, "displayAsText": "extension", + "displayHeaderCellProps": Object { + "className": "unifiedDataTable__headerCell", + }, "id": "extension", "isSortable": true, "schema": "string", @@ -3262,6 +3304,9 @@ Array [ headerRowHeight={5} />, "displayAsText": "message", + "displayHeaderCellProps": Object { + "className": "unifiedDataTable__headerCell", + }, "id": "message", "isSortable": true, "schema": "string", diff --git a/packages/kbn-unified-data-table/src/components/data_table.scss b/packages/kbn-unified-data-table/src/components/data_table.scss index 6093659d487d6..a9d8f26a3c68a 100644 --- a/packages/kbn-unified-data-table/src/components/data_table.scss +++ b/packages/kbn-unified-data-table/src/components/data_table.scss @@ -40,14 +40,6 @@ background: transparent; } - .euiDataGridHeaderCell { - align-items: start; - - .euiPopover[class*='euiDataGridHeaderCell__popover'] { - align-self: center; - } - } - .euiDataGrid--bordersHorizontal .euiDataGridHeader { border-top: none; } @@ -101,6 +93,20 @@ } } +// Custom styles for data grid header cell. +// It can also be inside a portal (outside of `unifiedDataTable__inner`) when dragged. +.unifiedDataTable__headerCell { + align-items: start; + + .euiDataGridHeaderCell__draggableIcon { + padding-block: calc($euiSizeXS / 2); // to align with a token height + } + + .euiDataGridHeaderCell__button { + margin-block: -$euiSizeXS; // to override Eui value for Density "Expanded" + } +} + .unifiedDataTable__table { flex-grow: 1; flex-shrink: 1; diff --git a/packages/kbn-unified-data-table/src/components/data_table.test.tsx b/packages/kbn-unified-data-table/src/components/data_table.test.tsx index 13304d4661cc0..f440c2845adaa 100644 --- a/packages/kbn-unified-data-table/src/components/data_table.test.tsx +++ b/packages/kbn-unified-data-table/src/components/data_table.test.tsx @@ -1354,5 +1354,49 @@ describe('UnifiedDataTable', () => { }, EXTENDED_JEST_TIMEOUT ); + + it( + 'should have columnVisibility configuration', + async () => { + const component = await getComponent({ + ...getProps(), + columns: ['message'], + canDragAndDropColumns: true, + }); + expect(component.find(EuiDataGrid).last().prop('columnVisibility')).toMatchInlineSnapshot(` + Object { + "canDragAndDropColumns": true, + "setVisibleColumns": [Function], + "visibleColumns": Array [ + "@timestamp", + "message", + ], + } + `); + }, + EXTENDED_JEST_TIMEOUT + ); + + it( + 'should disable drag&drop if Summary is present', + async () => { + const component = await getComponent({ + ...getProps(), + columns: [], + canDragAndDropColumns: true, + }); + expect(component.find(EuiDataGrid).last().prop('columnVisibility')).toMatchInlineSnapshot(` + Object { + "canDragAndDropColumns": false, + "setVisibleColumns": [Function], + "visibleColumns": Array [ + "@timestamp", + "_source", + ], + } + `); + }, + EXTENDED_JEST_TIMEOUT + ); }); }); diff --git a/packages/kbn-unified-data-table/src/components/data_table.tsx b/packages/kbn-unified-data-table/src/components/data_table.tsx index 662c8526dd567..a22ee8317be2f 100644 --- a/packages/kbn-unified-data-table/src/components/data_table.tsx +++ b/packages/kbn-unified-data-table/src/components/data_table.tsx @@ -137,6 +137,10 @@ export interface UnifiedDataTableProps { * Field tokens could be rendered in column header next to the field name. */ showColumnTokens?: boolean; + /** + * Set to true to allow users to drag and drop columns for reordering + */ + canDragAndDropColumns?: boolean; /** * Optional value for providing configuration setting for UnifiedDataTable header row height */ @@ -425,6 +429,7 @@ export const UnifiedDataTable = ({ columns, columnsMeta, showColumnTokens, + canDragAndDropColumns, configHeaderRowHeight, headerRowHeightState, onUpdateHeaderRowHeight, @@ -870,13 +875,20 @@ export const UnifiedDataTable = ({ const schemaDetectors = useMemo(() => getSchemaDetectors(), []); const columnsVisibility = useMemo( () => ({ + canDragAndDropColumns: defaultColumns ? false : canDragAndDropColumns, visibleColumns, setVisibleColumns: (newColumns: string[]) => { const dontModifyColumns = !shouldPrependTimeFieldColumn(newColumns); onSetColumns(newColumns, dontModifyColumns); }, }), - [visibleColumns, onSetColumns, shouldPrependTimeFieldColumn] + [ + visibleColumns, + onSetColumns, + shouldPrependTimeFieldColumn, + canDragAndDropColumns, + defaultColumns, + ] ); const canSetExpandedDoc = Boolean(setExpandedDoc && !!renderDocumentView); diff --git a/packages/kbn-unified-data-table/src/components/data_table_columns.tsx b/packages/kbn-unified-data-table/src/components/data_table_columns.tsx index 985a5db9f2178..8f1503ade8a7c 100644 --- a/packages/kbn-unified-data-table/src/components/data_table_columns.tsx +++ b/packages/kbn-unified-data-table/src/components/data_table_columns.tsx @@ -258,6 +258,7 @@ function buildEuiGridColumn({ }, cellActions, visibleCellActions, + displayHeaderCellProps: { className: 'unifiedDataTable__headerCell' }, }; if (column.id === dataView.timeFieldName) { diff --git a/packages/kbn-unified-field-list/src/components/field_stats/field_stats.test.tsx b/packages/kbn-unified-field-list/src/components/field_stats/field_stats.test.tsx index 951602334a622..beb0e1f05e1b8 100644 --- a/packages/kbn-unified-field-list/src/components/field_stats/field_stats.test.tsx +++ b/packages/kbn-unified-field-list/src/components/field_stats/field_stats.test.tsx @@ -830,4 +830,14 @@ describe('UnifiedFieldList FieldStats', () => { expect(wrapper.text()).toBe('Summarymin29674max36821994Calculated from 5000 sample records.'); }); + + it('should not request field stats for ES|QL query', async () => { + const wrapper = await mountComponent( + + ); + + expect(loadFieldStats).toHaveBeenCalledTimes(0); + + expect(wrapper.text()).toBe('Analysis is not available for this field.'); + }); }); diff --git a/packages/kbn-unified-field-list/src/components/field_stats/field_stats.tsx b/packages/kbn-unified-field-list/src/components/field_stats/field_stats.tsx index 8eada232cdeaf..58ff36069dd8c 100755 --- a/packages/kbn-unified-field-list/src/components/field_stats/field_stats.tsx +++ b/packages/kbn-unified-field-list/src/components/field_stats/field_stats.tsx @@ -42,7 +42,6 @@ import { canProvideNumberSummaryForField, } from '../../utils/can_provide_stats'; import { loadFieldStats } from '../../services/field_stats'; -import { loadFieldStatsTextBased } from '../../services/field_stats_text_based'; import type { AddFieldFilterHandler } from '../../types'; import { FieldTopValues, @@ -136,7 +135,7 @@ const FieldStatsComponent: React.FC = ({ const [dataView, changeDataView] = useState(null); const abortControllerRef = useRef(null); const isCanceledRef = useRef(false); - const isTextBased = !!query && isOfAggregateQueryType(query); + const isEsqlQuery = !!query && isOfAggregateQueryType(query); const setState: typeof changeState = useCallback( (nextState) => { @@ -178,6 +177,12 @@ const FieldStatsComponent: React.FC = ({ setDataView(loadedDataView); + if (isEsqlQuery) { + // Not supported yet for ES|QL queries + // Previous implementation was removed in https://github.com/elastic/kibana/pull/198948/ + return; + } + if (state.isLoading) { return; } @@ -187,32 +192,17 @@ const FieldStatsComponent: React.FC = ({ abortControllerRef.current?.abort(); abortControllerRef.current = new AbortController(); - const results = isTextBased - ? await loadFieldStatsTextBased({ - services: { data }, - dataView: loadedDataView, - field, - fromDate, - toDate, - baseQuery: query, - abortController: abortControllerRef.current, - }) - : await loadFieldStats({ - services: { data }, - dataView: loadedDataView, - field, - fromDate, - toDate, - dslQuery: - dslQuery ?? - buildEsQuery( - loadedDataView, - query ?? [], - filters ?? [], - getEsQueryConfig(uiSettings) - ), - abortController: abortControllerRef.current, - }); + const results = await loadFieldStats({ + services: { data }, + dataView: loadedDataView, + field, + fromDate, + toDate, + dslQuery: + dslQuery ?? + buildEsQuery(loadedDataView, query ?? [], filters ?? [], getEsQueryConfig(uiSettings)), + abortController: abortControllerRef.current, + }); abortControllerRef.current = null; @@ -297,7 +287,7 @@ const FieldStatsComponent: React.FC = ({ let title = <>; function combineWithTitleAndFooter(el: React.ReactElement) { - const countsElement = getCountsElement(state, services, isTextBased, dataTestSubject); + const countsElement = getCountsElement(state, services, isEsqlQuery, dataTestSubject); return ( <> @@ -319,7 +309,7 @@ const FieldStatsComponent: React.FC = ({ ); } - if (!canProvideStatsForField(field, isTextBased)) { + if (!canProvideStatsForField(field, isEsqlQuery)) { const messageNoAnalysis = ( = ({ : messageNoAnalysis; } - if (canProvideNumberSummaryForField(field, isTextBased) && isNumberSummaryValid(numberSummary)) { + if (canProvideNumberSummaryForField(field, isEsqlQuery) && isNumberSummaryValid(numberSummary)) { title = (
@@ -563,21 +553,19 @@ const FieldStatsComponent: React.FC = ({ function getCountsElement( state: FieldStatsState, services: FieldStatsServices, - isTextBased: boolean, + isEsqlQuery: boolean, dataTestSubject: string ): JSX.Element { const dataTestSubjDocsCount = 'unifiedFieldStats-statsFooter-docsCount'; const { fieldFormats } = services; - const { totalDocuments, sampledValues, sampledDocuments, topValues } = state; + const { totalDocuments, sampledDocuments } = state; - if (!totalDocuments) { + if (!totalDocuments || isEsqlQuery) { return <>; } - let labelElement; - - if (isTextBased) { - labelElement = topValues?.areExamples ? ( + const labelElement = + sampledDocuments && sampledDocuments < totalDocuments ? ( ) : ( {fieldFormats .getDefaultInstance(KBN_FIELD_TYPES.NUMBER, [ES_FIELD_TYPES.INTEGER]) - .convert(sampledValues)} + .convert(totalDocuments)} ), }} /> ); - } else { - labelElement = - sampledDocuments && sampledDocuments < totalDocuments ? ( - - {fieldFormats - .getDefaultInstance(KBN_FIELD_TYPES.NUMBER, [ES_FIELD_TYPES.INTEGER]) - .convert(sampledDocuments)} - - ), - }} - /> - ) : ( - - {fieldFormats - .getDefaultInstance(KBN_FIELD_TYPES.NUMBER, [ES_FIELD_TYPES.INTEGER]) - .convert(totalDocuments)} - - ), - }} - /> - ); - } return ( diff --git a/packages/kbn-unified-field-list/src/containers/unified_field_list_item/field_list_item.tsx b/packages/kbn-unified-field-list/src/containers/unified_field_list_item/field_list_item.tsx index 7864976c1180f..b139e7b5685c5 100644 --- a/packages/kbn-unified-field-list/src/containers/unified_field_list_item/field_list_item.tsx +++ b/packages/kbn-unified-field-list/src/containers/unified_field_list_item/field_list_item.tsx @@ -32,7 +32,7 @@ import type { UnifiedFieldListSidebarContainerStateService, AddFieldFilterHandler, } from '../../types'; -import { canProvideStatsForFieldTextBased } from '../../utils/can_provide_stats'; +import { canProvideStatsForEsqlField } from '../../utils/can_provide_stats'; interface GetCommonFieldItemButtonPropsParams { stateService: UnifiedFieldListSidebarContainerStateService; @@ -405,7 +405,7 @@ function UnifiedFieldListItemComponent({ /> )} renderContent={ - (searchMode === 'text-based' && canProvideStatsForFieldTextBased(field)) || + (searchMode === 'text-based' && canProvideStatsForEsqlField(field)) || searchMode === 'documents' ? renderPopover : undefined diff --git a/packages/kbn-unified-field-list/src/services/field_examples_calculator/field_examples_calculator.test.ts b/packages/kbn-unified-field-list/src/services/field_examples_calculator/field_examples_calculator.test.ts index 6e14c16f7e42c..7e77bd7852726 100644 --- a/packages/kbn-unified-field-list/src/services/field_examples_calculator/field_examples_calculator.test.ts +++ b/packages/kbn-unified-field-list/src/services/field_examples_calculator/field_examples_calculator.test.ts @@ -223,7 +223,7 @@ describe('fieldExamplesCalculator', function () { values: getFieldValues(hits, dataView.fields.getByName('extension')!, dataView), field: dataView.fields.getByName('extension')!, count: 3, - isTextBased: false, + isEsqlQuery: false, }; }); @@ -286,33 +286,19 @@ describe('fieldExamplesCalculator', function () { expect(getFieldExampleBuckets(params).sampledValues).toBe(5); }); - it('works for text-based', function () { - const result = getFieldExampleBuckets({ - values: [['a'], ['b'], ['a'], ['a']], - field: { name: 'message', type: 'string', esTypes: ['text'] } as DataViewField, - isTextBased: true, - }); - expect(result).toMatchInlineSnapshot(` - Object { - "buckets": Array [ - Object { - "count": 3, - "key": "a", - }, - Object { - "count": 1, - "key": "b", - }, - ], - "sampledDocuments": 4, - "sampledValues": 4, - } - `); + it('should not work for ES|QL', function () { + expect(() => + getFieldExampleBuckets({ + values: [['a'], ['b'], ['a'], ['a']], + field: { name: 'message', type: 'string', esTypes: ['text'] } as DataViewField, + isEsqlQuery: true, + }) + ).toThrowError(); expect(() => getFieldExampleBuckets({ values: [['a'], ['b'], ['a'], ['a']], field: { name: 'message', type: 'string', esTypes: ['keyword'] } as DataViewField, - isTextBased: true, + isEsqlQuery: true, }) ).toThrowError(); }); diff --git a/packages/kbn-unified-field-list/src/services/field_examples_calculator/field_examples_calculator.ts b/packages/kbn-unified-field-list/src/services/field_examples_calculator/field_examples_calculator.ts index e4413f3be7fe2..55d0c30b58e34 100644 --- a/packages/kbn-unified-field-list/src/services/field_examples_calculator/field_examples_calculator.ts +++ b/packages/kbn-unified-field-list/src/services/field_examples_calculator/field_examples_calculator.ts @@ -23,7 +23,7 @@ export interface FieldValueCountsParams { values: FieldHitValue[]; field: DataViewField; count?: number; - isTextBased: boolean; + isEsqlQuery: boolean; } export function getFieldExampleBuckets(params: FieldValueCountsParams, formatter?: FieldFormat) { @@ -31,7 +31,7 @@ export function getFieldExampleBuckets(params: FieldValueCountsParams, formatter count: DEFAULT_SIMPLE_EXAMPLES_SIZE, }); - if (!canProvideExamplesForField(params.field, params.isTextBased)) { + if (!canProvideExamplesForField(params.field, params.isEsqlQuery)) { throw new Error( `Analysis is not available this field type: "${params.field.type}". Field name: "${params.field.name}"` ); diff --git a/packages/kbn-unified-field-list/src/services/field_stats/field_stats_utils.ts b/packages/kbn-unified-field-list/src/services/field_stats/field_stats_utils.ts index 309f5f054683b..57a7d0be8fda9 100644 --- a/packages/kbn-unified-field-list/src/services/field_stats/field_stats_utils.ts +++ b/packages/kbn-unified-field-list/src/services/field_stats/field_stats_utils.ts @@ -416,7 +416,7 @@ export async function getSimpleExamples( values: getFieldValues(simpleExamplesResult.hits.hits, field, dataView), field, count: DEFAULT_SIMPLE_EXAMPLES_SIZE, - isTextBased: false, + isEsqlQuery: false, }, formatter ); diff --git a/packages/kbn-unified-field-list/src/services/field_stats_text_based/field_stats_utils_text_based.test.ts b/packages/kbn-unified-field-list/src/services/field_stats_text_based/field_stats_utils_text_based.test.ts deleted file mode 100644 index 553fdd749941f..0000000000000 --- a/packages/kbn-unified-field-list/src/services/field_stats_text_based/field_stats_utils_text_based.test.ts +++ /dev/null @@ -1,129 +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", 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 { DataViewField } from '@kbn/data-views-plugin/common'; -import { buildSearchFilter, fetchAndCalculateFieldStats } from './field_stats_utils_text_based'; - -describe('fieldStatsUtilsTextBased', function () { - describe('buildSearchFilter()', () => { - it('should create a time range filter', () => { - expect( - buildSearchFilter({ - timeFieldName: 'timestamp', - fromDate: '2022-12-05T23:00:00.000Z', - toDate: '2023-01-05T09:33:05.359Z', - }) - ).toMatchInlineSnapshot(` - Object { - "range": Object { - "timestamp": Object { - "format": "strict_date_optional_time", - "gte": "2022-12-05T23:00:00.000Z", - "lte": "2023-01-05T09:33:05.359Z", - }, - }, - } - `); - }); - it('should not create a time range filter', () => { - expect( - buildSearchFilter({ - timeFieldName: undefined, - fromDate: '2022-12-05T23:00:00.000Z', - toDate: '2023-01-05T09:33:05.359Z', - }) - ).toBeNull(); - }); - }); - - describe('fetchAndCalculateFieldStats()', () => { - it('should provide top values', async () => { - const searchHandler = jest.fn().mockResolvedValue({ - values: [ - [3, 'a'], - [1, 'b'], - ], - }); - expect( - await fetchAndCalculateFieldStats({ - searchHandler, - esqlBaseQuery: 'from logs* | limit 1000', - field: { name: 'message', type: 'string', esTypes: ['keyword'] } as DataViewField, - }) - ).toMatchInlineSnapshot(` - Object { - "sampledDocuments": 4, - "sampledValues": 4, - "topValues": Object { - "buckets": Array [ - Object { - "count": 3, - "key": "a", - }, - Object { - "count": 1, - "key": "b", - }, - ], - }, - "totalDocuments": 4, - } - `); - expect(searchHandler).toHaveBeenCalledWith( - expect.objectContaining({ - query: - 'from logs* | limit 1000\n| WHERE `message` IS NOT NULL\n | STATS `message_terms` = count(`message`) BY `message`\n | SORT `message_terms` DESC\n | LIMIT 10', - }) - ); - }); - - it('should provide text examples', async () => { - const searchHandler = jest.fn().mockResolvedValue({ - values: [[['programming', 'cool']], ['elastic', 'cool']], - }); - expect( - await fetchAndCalculateFieldStats({ - searchHandler, - esqlBaseQuery: 'from logs* | limit 1000', - field: { name: 'message', type: 'string', esTypes: ['text'] } as DataViewField, - }) - ).toMatchInlineSnapshot(` - Object { - "sampledDocuments": 2, - "sampledValues": 4, - "topValues": Object { - "areExamples": true, - "buckets": Array [ - Object { - "count": 2, - "key": "cool", - }, - Object { - "count": 1, - "key": "elastic", - }, - Object { - "count": 1, - "key": "programming", - }, - ], - }, - "totalDocuments": 2, - } - `); - - expect(searchHandler).toHaveBeenCalledWith( - expect.objectContaining({ - query: - 'from logs* | limit 1000\n| WHERE `message` IS NOT NULL\n | KEEP `message`\n | LIMIT 100', - }) - ); - }); - }); -}); diff --git a/packages/kbn-unified-field-list/src/services/field_stats_text_based/field_stats_utils_text_based.ts b/packages/kbn-unified-field-list/src/services/field_stats_text_based/field_stats_utils_text_based.ts deleted file mode 100644 index b64d26b0cbb59..0000000000000 --- a/packages/kbn-unified-field-list/src/services/field_stats_text_based/field_stats_utils_text_based.ts +++ /dev/null @@ -1,156 +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", 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 { ESQLSearchResponse } from '@kbn/es-types'; -import { appendToESQLQuery } from '@kbn/esql-utils'; -import type { DataViewField } from '@kbn/data-views-plugin/common'; -import type { FieldStatsResponse } from '../../types'; -import { - DEFAULT_TOP_VALUES_SIZE, - DEFAULT_SIMPLE_EXAMPLES_SIZE, - SIMPLE_EXAMPLES_FETCH_SIZE, -} from '../../constants'; -import { - canProvideStatsForFieldTextBased, - canProvideTopValuesForFieldTextBased, - canProvideExamplesForField, -} from '../../utils/can_provide_stats'; -import { getFieldExampleBuckets } from '../field_examples_calculator'; - -export type SearchHandlerTextBased = ({ query }: { query: string }) => Promise; - -export function buildSearchFilter({ - timeFieldName, - fromDate, - toDate, -}: { - timeFieldName?: string; - fromDate: string; - toDate: string; -}) { - return timeFieldName - ? { - range: { - [timeFieldName]: { - gte: fromDate, - lte: toDate, - format: 'strict_date_optional_time', - }, - }, - } - : null; -} - -interface FetchAndCalculateFieldStatsParams { - searchHandler: SearchHandlerTextBased; - field: DataViewField; - esqlBaseQuery: string; -} - -export async function fetchAndCalculateFieldStats(params: FetchAndCalculateFieldStatsParams) { - const { field } = params; - if (!canProvideStatsForFieldTextBased(field)) { - return {}; - } - if (field.type === 'boolean') { - return await getStringTopValues(params, 3); - } - if (canProvideTopValuesForFieldTextBased(field)) { - return await getStringTopValues(params); - } - if (canProvideExamplesForField(field, true)) { - return await getSimpleTextExamples(params); - } - - return {}; -} - -export async function getStringTopValues( - params: FetchAndCalculateFieldStatsParams, - size = DEFAULT_TOP_VALUES_SIZE -): Promise> { - const { searchHandler, field, esqlBaseQuery } = params; - const safeEsqlFieldName = getSafeESQLFieldName(field.name); - const safeEsqlFieldNameTerms = getSafeESQLFieldName(`${field.name}_terms`); - const esqlQuery = appendToESQLQuery( - esqlBaseQuery, - `| WHERE ${safeEsqlFieldName} IS NOT NULL - | STATS ${safeEsqlFieldNameTerms} = count(${safeEsqlFieldName}) BY ${safeEsqlFieldName} - | SORT ${safeEsqlFieldNameTerms} DESC - | LIMIT ${size}` - ); - - const result = await searchHandler({ query: esqlQuery }); - const values = result?.values as Array<[number, string]>; - - if (!values?.length) { - return {}; - } - - const sampledValues = values?.reduce((acc: number, row) => acc + row[0], 0); - - const topValues = { - buckets: values.map((value) => ({ - count: value[0], - key: value[1], - })), - }; - - return { - totalDocuments: sampledValues, - sampledDocuments: sampledValues, - sampledValues, - topValues, - }; -} - -export async function getSimpleTextExamples( - params: FetchAndCalculateFieldStatsParams -): Promise> { - const { searchHandler, field, esqlBaseQuery } = params; - const safeEsqlFieldName = getSafeESQLFieldName(field.name); - const esqlQuery = appendToESQLQuery( - esqlBaseQuery, - `| WHERE ${safeEsqlFieldName} IS NOT NULL - | KEEP ${safeEsqlFieldName} - | LIMIT ${SIMPLE_EXAMPLES_FETCH_SIZE}` - ); - - const result = await searchHandler({ query: esqlQuery }); - const values = ((result?.values as Array<[string | string[]]>) || []).map((value) => - Array.isArray(value) && value.length === 1 ? value[0] : value - ); - - if (!values?.length) { - return {}; - } - - const sampledDocuments = values?.length; - - const fieldExampleBuckets = getFieldExampleBuckets({ - values, - field, - count: DEFAULT_SIMPLE_EXAMPLES_SIZE, - isTextBased: true, - }); - - return { - totalDocuments: sampledDocuments, - sampledDocuments: fieldExampleBuckets.sampledDocuments, - sampledValues: fieldExampleBuckets.sampledValues, - topValues: { - buckets: fieldExampleBuckets.buckets, - areExamples: true, - }, - }; -} - -function getSafeESQLFieldName(str: string): string { - return `\`${str}\``; -} diff --git a/packages/kbn-unified-field-list/src/services/field_stats_text_based/load_field_stats_text_based.ts b/packages/kbn-unified-field-list/src/services/field_stats_text_based/load_field_stats_text_based.ts deleted file mode 100644 index 5f77f15906896..0000000000000 --- a/packages/kbn-unified-field-list/src/services/field_stats_text_based/load_field_stats_text_based.ts +++ /dev/null @@ -1,89 +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", 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 { DataView, DataViewField } from '@kbn/data-views-plugin/common'; -import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; -import type { AggregateQuery } from '@kbn/es-query'; -import { getESQLWithSafeLimit, getESQLResults } from '@kbn/esql-utils'; -import type { FieldStatsResponse } from '../../types'; -import { - buildSearchFilter, - SearchHandlerTextBased, - fetchAndCalculateFieldStats, -} from './field_stats_utils_text_based'; -import { ESQL_SAFE_LIMIT } from '../../constants'; - -interface FetchFieldStatsParamsTextBased { - services: { - data: DataPublicPluginStart; - }; - dataView: DataView; - field: DataViewField; - fromDate: string; - toDate: string; - baseQuery: AggregateQuery; - abortController?: AbortController; -} - -export type LoadFieldStatsTextBasedHandler = ( - params: FetchFieldStatsParamsTextBased -) => Promise>; - -/** - * Loads and aggregates stats data for an ES|QL query field - * @param services - * @param dataView - * @param field - * @param fromDate - * @param toDate - * @param baseQuery - * @param abortController - */ -export const loadFieldStatsTextBased: LoadFieldStatsTextBasedHandler = async ({ - services, - dataView, - field, - fromDate, - toDate, - baseQuery, - abortController, -}) => { - const { data } = services; - - try { - if (!dataView?.id || !field?.type) { - return {}; - } - - const searchHandler: SearchHandlerTextBased = async ({ query }) => { - const filter = buildSearchFilter({ timeFieldName: dataView.timeFieldName, fromDate, toDate }); - const result = await getESQLResults({ - esqlQuery: query, - filter, - search: data.search.search, - signal: abortController?.signal, - timeRange: { from: fromDate, to: toDate }, - }); - return result.response; - }; - - if (!('esql' in baseQuery)) { - throw new Error('query must be of type AggregateQuery'); - } - - return await fetchAndCalculateFieldStats({ - searchHandler, - field, - esqlBaseQuery: getESQLWithSafeLimit(baseQuery.esql, ESQL_SAFE_LIMIT), - }); - } catch (error) { - // console.error(error); - throw new Error('Could not provide field stats', { cause: error }); - } -}; diff --git a/packages/kbn-unified-field-list/src/utils/can_provide_stats.test.ts b/packages/kbn-unified-field-list/src/utils/can_provide_stats.test.ts index 297e1e26c8c56..c27a44494a3e7 100644 --- a/packages/kbn-unified-field-list/src/utils/can_provide_stats.test.ts +++ b/packages/kbn-unified-field-list/src/utils/can_provide_stats.test.ts @@ -10,7 +10,7 @@ import { canProvideStatsForField, canProvideExamplesForField, - canProvideStatsForFieldTextBased, + canProvideStatsForEsqlField, } from './can_provide_stats'; import type { DataViewField } from '@kbn/data-views-plugin/common'; import { stubLogstashDataView as dataView } from '@kbn/data-views-plugin/common/data_view.stub'; @@ -34,40 +34,12 @@ describe('can_provide_stats', function () { ); }); - it('works for text based columns', function () { + it('should not work for ES|QL columns', function () { expect( canProvideStatsForField( { name: 'message', type: 'string', esTypes: ['text'] } as DataViewField, true ) - ).toBe(true); - expect( - canProvideStatsForField( - { name: 'message', type: 'string', esTypes: ['keyword'] } as DataViewField, - true - ) - ).toBe(true); - expect( - canProvideStatsForField({ name: 'message', type: 'number' } as DataViewField, true) - ).toBe(true); - expect( - canProvideStatsForField({ name: 'message', type: 'boolean' } as DataViewField, true) - ).toBe(true); - expect(canProvideStatsForField({ name: 'message', type: 'ip' } as DataViewField, true)).toBe( - true - ); - expect( - canProvideStatsForField({ name: 'message', type: 'geo_point' } as DataViewField, true) - ).toBe(true); - expect( - canProvideStatsForField( - { name: '_id', type: 'string', esTypes: ['keyword'] } as DataViewField, - true - ) - ).toBe(true); - - expect( - canProvideStatsForField({ name: 'message', type: 'date' } as DataViewField, true) ).toBe(false); }); }); @@ -82,83 +54,24 @@ describe('can_provide_stats', function () { ); }); - it('works for text based columns', function () { + it('should not work for ES|QL columns', function () { expect( canProvideExamplesForField( { name: 'message', type: 'string', esTypes: ['text'] } as DataViewField, true ) - ).toBe(true); - expect( - canProvideExamplesForField( - { name: 'message', type: 'string', esTypes: ['keyword'] } as DataViewField, - true - ) - ).toBe(false); - expect( - canProvideExamplesForField({ name: 'message', type: 'number' } as DataViewField, true) - ).toBe(false); - expect( - canProvideExamplesForField({ name: 'message', type: 'boolean' } as DataViewField, true) ).toBe(false); - expect( - canProvideExamplesForField({ name: 'message', type: 'ip' } as DataViewField, true) - ).toBe(false); - expect( - canProvideExamplesForField({ name: 'message', type: 'geo_point' } as DataViewField, true) - ).toBe(true); - expect( - canProvideExamplesForField({ name: 'message', type: 'date' } as DataViewField, true) - ).toBe(false); - expect( - canProvideStatsForField( - { name: '_id', type: 'string', esTypes: ['keyword'] } as DataViewField, - true - ) - ).toBe(true); }); - describe('canProvideStatsForFieldTextBased', function () { - it('works for text based columns', function () { + describe('canProvideStatsForEsqlField', function () { + it('should not work for ES|QL columns', function () { expect( - canProvideStatsForFieldTextBased({ + canProvideStatsForEsqlField({ name: 'message', type: 'string', esTypes: ['text'], } as DataViewField) - ).toBe(true); - expect( - canProvideStatsForFieldTextBased({ - name: 'message', - type: 'string', - esTypes: ['keyword'], - } as DataViewField) - ).toBe(true); - expect( - canProvideStatsForFieldTextBased({ name: 'message', type: 'number' } as DataViewField) - ).toBe(true); - expect( - canProvideStatsForFieldTextBased({ name: 'message', type: 'boolean' } as DataViewField) - ).toBe(true); - expect( - canProvideStatsForFieldTextBased({ name: 'message', type: 'ip' } as DataViewField) - ).toBe(true); - expect( - canProvideStatsForFieldTextBased({ name: 'message', type: 'ip_range' } as DataViewField) ).toBe(false); - expect( - canProvideStatsForFieldTextBased({ name: 'message', type: 'geo_point' } as DataViewField) - ).toBe(true); - expect( - canProvideStatsForFieldTextBased({ name: 'message', type: 'date' } as DataViewField) - ).toBe(false); - expect( - canProvideStatsForFieldTextBased({ - name: '_id', - type: 'string', - esTypes: ['keyword'], - } as DataViewField) - ).toBe(true); }); }); }); diff --git a/packages/kbn-unified-field-list/src/utils/can_provide_stats.ts b/packages/kbn-unified-field-list/src/utils/can_provide_stats.ts index e84137fa17f2e..c3fd9734de5e3 100644 --- a/packages/kbn-unified-field-list/src/utils/can_provide_stats.ts +++ b/packages/kbn-unified-field-list/src/utils/can_provide_stats.ts @@ -9,22 +9,22 @@ import type { DataViewField } from '@kbn/data-views-plugin/common'; -export function canProvideStatsForField(field: DataViewField, isTextBased: boolean): boolean { - if (isTextBased) { - return canProvideStatsForFieldTextBased(field); +export function canProvideStatsForField(field: DataViewField, isEsqlQuery: boolean): boolean { + if (isEsqlQuery) { + return false; } return ( - (field.aggregatable && canProvideAggregatedStatsForField(field, isTextBased)) || + (field.aggregatable && canProvideAggregatedStatsForField(field, isEsqlQuery)) || ((!field.aggregatable || field.type === 'geo_point' || field.type === 'geo_shape') && - canProvideExamplesForField(field, isTextBased)) + canProvideExamplesForField(field, isEsqlQuery)) ); } export function canProvideAggregatedStatsForField( field: DataViewField, - isTextBased: boolean + isEsqlQuery: boolean ): boolean { - if (isTextBased) { + if (isEsqlQuery) { return false; } return !( @@ -39,20 +39,17 @@ export function canProvideAggregatedStatsForField( export function canProvideNumberSummaryForField( field: DataViewField, - isTextBased: boolean + isEsqlQuery: boolean ): boolean { - if (isTextBased) { + if (isEsqlQuery) { return false; } return field.timeSeriesMetric === 'counter'; } -export function canProvideExamplesForField(field: DataViewField, isTextBased: boolean): boolean { - if (isTextBased) { - return ( - (field.type === 'string' && !canProvideTopValuesForFieldTextBased(field)) || - ['geo_point', 'geo_shape'].includes(field.type) - ); +export function canProvideExamplesForField(field: DataViewField, isEsqlQuery: boolean): boolean { + if (isEsqlQuery) { + return false; } if (field.name === '_score') { return false; @@ -69,17 +66,6 @@ export function canProvideExamplesForField(field: DataViewField, isTextBased: bo ].includes(field.type); } -export function canProvideTopValuesForFieldTextBased(field: DataViewField): boolean { - if (field.name === '_id') { - return false; - } - const esTypes = field.esTypes?.[0]; - return ( - Boolean(field.type === 'string' && esTypes && ['keyword', 'version'].includes(esTypes)) || - ['keyword', 'version', 'ip', 'number', 'boolean'].includes(field.type) - ); -} - -export function canProvideStatsForFieldTextBased(field: DataViewField): boolean { - return canProvideTopValuesForFieldTextBased(field) || canProvideExamplesForField(field, true); +export function canProvideStatsForEsqlField(field: DataViewField): boolean { + return false; } diff --git a/packages/kbn-unified-field-list/tsconfig.json b/packages/kbn-unified-field-list/tsconfig.json index 830e56ac6ab00..54b67143b7c7b 100644 --- a/packages/kbn-unified-field-list/tsconfig.json +++ b/packages/kbn-unified-field-list/tsconfig.json @@ -32,7 +32,6 @@ "@kbn/shared-ux-button-toolbar", "@kbn/field-utils", "@kbn/visualization-utils", - "@kbn/esql-utils", "@kbn/search-types", "@kbn/fields-metadata-plugin", "@kbn/ui-theme" diff --git a/packages/kbn-visualization-utils/index.ts b/packages/kbn-visualization-utils/index.ts index 5d4dfecc0ae29..1773a04db76d9 100644 --- a/packages/kbn-visualization-utils/index.ts +++ b/packages/kbn-visualization-utils/index.ts @@ -11,3 +11,6 @@ export { getTimeZone } from './src/get_timezone'; export { getLensAttributesFromSuggestion } from './src/get_lens_attributes'; export { TooltipWrapper } from './src/tooltip_wrapper'; export { useDebouncedValue } from './src/debounced_value'; +export { ChartType } from './src/types'; +export { getDatasourceId } from './src/get_datasource_id'; +export { mapVisToChartType } from './src/map_vis_to_chart_type'; diff --git a/packages/kbn-visualization-utils/src/get_datasource_id.ts b/packages/kbn-visualization-utils/src/get_datasource_id.ts new file mode 100644 index 0000000000000..c87d08f8e3e27 --- /dev/null +++ b/packages/kbn-visualization-utils/src/get_datasource_id.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", 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 const getDatasourceId = (datasourceStates: Record) => { + const datasourceId: 'formBased' | 'textBased' | undefined = [ + 'formBased' as const, + 'textBased' as const, + ].find((key) => Boolean(datasourceStates[key])); + + return datasourceId; +}; diff --git a/packages/kbn-visualization-utils/src/map_vis_to_chart_type.ts b/packages/kbn-visualization-utils/src/map_vis_to_chart_type.ts new file mode 100644 index 0000000000000..288202f4b999f --- /dev/null +++ b/packages/kbn-visualization-utils/src/map_vis_to_chart_type.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", 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 { ChartType, LensVisualizationType } from './types'; + +type ValueOf = T[keyof T]; +type LensToChartMap = { + [K in ValueOf]: ChartType; +}; +const lensTypesToChartTypes: LensToChartMap = { + [LensVisualizationType.XY]: ChartType.XY, + [LensVisualizationType.Metric]: ChartType.Metric, + [LensVisualizationType.LegacyMetric]: ChartType.Metric, + [LensVisualizationType.Pie]: ChartType.Pie, + [LensVisualizationType.Heatmap]: ChartType.Heatmap, + [LensVisualizationType.Gauge]: ChartType.Gauge, + [LensVisualizationType.Datatable]: ChartType.Table, +}; +function isLensVisualizationType(value: string): value is LensVisualizationType { + return Object.values(LensVisualizationType).includes(value as LensVisualizationType); +} +export const mapVisToChartType = (visualizationType: string) => { + if (isLensVisualizationType(visualizationType)) { + return lensTypesToChartTypes[visualizationType]; + } +}; diff --git a/packages/kbn-visualization-utils/src/types.ts b/packages/kbn-visualization-utils/src/types.ts index 0337c3349332b..cd73cbea20631 100644 --- a/packages/kbn-visualization-utils/src/types.ts +++ b/packages/kbn-visualization-utils/src/types.ts @@ -43,3 +43,30 @@ export interface Suggestion { changeType: TableChangeType; keptLayerIds: string[]; } + +export enum ChartType { + XY = 'XY', + Gauge = 'Gauge', + Bar = 'Bar', + Line = 'Line', + Area = 'Area', + Donut = 'Donut', + Heatmap = 'Heatmap', + Metric = 'Metric', + Treemap = 'Treemap', + Tagcloud = 'Tagcloud', + Waffle = 'Waffle', + Pie = 'Pie', + Mosaic = 'Mosaic', + Table = 'Table', +} + +export enum LensVisualizationType { + XY = 'lnsXY', + Metric = 'lnsMetric', + Pie = 'lnsPie', + Heatmap = 'lnsHeatmap', + Gauge = 'lnsGauge', + Datatable = 'lnsDatatable', + LegacyMetric = 'lnsLegacyMetric', +} diff --git a/packages/shared-ux/chrome/navigation/__jest__/active_node.test.tsx b/packages/shared-ux/chrome/navigation/__jest__/active_node.test.tsx index af162996f5f85..9ed3604dcdb8a 100644 --- a/packages/shared-ux/chrome/navigation/__jest__/active_node.test.tsx +++ b/packages/shared-ux/chrome/navigation/__jest__/active_node.test.tsx @@ -76,7 +76,7 @@ describe('Active node', () => { ]; const { findByTestId } = renderNavigation({ - navTreeDef: of({ body: navigationBody }), + navTreeDef: of({ id: 'es', body: navigationBody }), services: { activeNodes$: getActiveNodes$() }, }); diff --git a/packages/shared-ux/chrome/navigation/__jest__/build_nav_tree.test.tsx b/packages/shared-ux/chrome/navigation/__jest__/build_nav_tree.test.tsx index 15baf09d04dc3..4196dd3eca21c 100644 --- a/packages/shared-ux/chrome/navigation/__jest__/build_nav_tree.test.tsx +++ b/packages/shared-ux/chrome/navigation/__jest__/build_nav_tree.test.tsx @@ -21,6 +21,7 @@ describe('builds navigation tree', () => { test('render reference UI and build the navigation tree', async () => { const { findByTestId } = renderNavigation({ navTreeDef: of({ + id: 'es', body: [ { id: 'group1', @@ -107,6 +108,7 @@ describe('builds navigation tree', () => { { const { findByTestId, unmount } = renderNavigation({ navTreeDef: of({ + id: 'es', body: [accordionNode], }), services: { navigateToUrl }, @@ -121,6 +123,7 @@ describe('builds navigation tree', () => { { const { findByTestId } = renderNavigation({ navTreeDef: of({ + id: 'es', body: [ { ...accordionNode, @@ -165,6 +168,7 @@ describe('builds navigation tree', () => { // Side nav is collapsed const { queryAllByTestId, unmount } = renderNavigation({ navTreeDef: of({ + id: 'es', body: [nodes], }), services: { isSideNavCollapsed: true }, @@ -180,6 +184,7 @@ describe('builds navigation tree', () => { // Side nav is not collapsed const { queryAllByTestId, unmount } = renderNavigation({ navTreeDef: of({ + id: 'es', body: [nodes], }), services: { isSideNavCollapsed: false }, // No conversion to accordion @@ -195,6 +200,7 @@ describe('builds navigation tree', () => { // Panel opener with a link const { queryAllByTestId, unmount } = renderNavigation({ navTreeDef: of({ + id: 'es', body: [ { ...nodes, @@ -238,6 +244,7 @@ describe('builds navigation tree', () => { const { findByTestId } = renderNavigation({ navTreeDef: of({ + id: 'es', body: [node], }), services: { navigateToUrl, eventTracker: new EventTracker({ reportEvent }) }, @@ -276,6 +283,7 @@ describe('builds navigation tree', () => { const { findByTestId } = renderNavigation({ navTreeDef: of({ + id: 'es', body: [node], }), services: { navigateToUrl }, @@ -290,6 +298,7 @@ describe('builds navigation tree', () => { test('should not render the group if it does not have children', async () => { const navTree: NavigationTreeDefinitionUI = { + id: 'es', body: [ { id: 'root', @@ -338,6 +347,7 @@ describe('builds navigation tree', () => { ]); const navTree: NavigationTreeDefinitionUI = { + id: 'es', body: [{ type: 'recentlyAccessed' }], }; @@ -364,6 +374,7 @@ describe('builds navigation tree', () => { ]); const navTree: NavigationTreeDefinitionUI = { + id: 'es', body: [{ type: 'recentlyAccessed' }], }; diff --git a/packages/shared-ux/chrome/navigation/__jest__/panel.test.tsx b/packages/shared-ux/chrome/navigation/__jest__/panel.test.tsx index 01a1112ca7d1a..59c3463fb7326 100644 --- a/packages/shared-ux/chrome/navigation/__jest__/panel.test.tsx +++ b/packages/shared-ux/chrome/navigation/__jest__/panel.test.tsx @@ -21,6 +21,7 @@ import { renderNavigation } from './utils'; describe('Panel', () => { test('should render group as panel opener', async () => { const navigationTree: NavigationTreeDefinitionUI = { + id: 'es', body: [ { id: 'root', @@ -60,6 +61,7 @@ describe('Panel', () => { test('should not render group if all children are hidden', async () => { const navigationTree: NavigationTreeDefinitionUI = { + id: 'es', body: [ { id: 'root', @@ -146,6 +148,7 @@ describe('Panel', () => { ]); const navTree: NavigationTreeDefinitionUI = { + id: 'es', body: [ { id: 'root', @@ -196,6 +199,7 @@ describe('Panel', () => { describe('auto generated content', () => { test('should rendre block groups with title', async () => { const navTree: NavigationTreeDefinitionUI = { + id: 'es', body: [ { id: 'root', @@ -262,6 +266,7 @@ describe('Panel', () => { test('should rendre block groups without title', async () => { const navTree: NavigationTreeDefinitionUI = { + id: 'es', body: [ { id: 'root', @@ -327,6 +332,7 @@ describe('Panel', () => { test('should rendre accordion groups', async () => { const navTree: NavigationTreeDefinitionUI = { + id: 'es', body: [ { id: 'root', diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/feedback_btn.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/feedback_btn.tsx index 3cc2fca2d8f81..182838d88b5d6 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/components/feedback_btn.tsx +++ b/packages/shared-ux/chrome/navigation/src/ui/components/feedback_btn.tsx @@ -10,11 +10,20 @@ import { EuiButton, EuiCallOut, useEuiTheme, EuiText, EuiSpacer } from '@elastic/eui'; import React, { FC, useState } from 'react'; import { i18n } from '@kbn/i18n'; +import type { SolutionId } from '@kbn/core-chrome-browser'; -const feedbackUrl = 'https://ela.st/nav-feedback'; +const feedbackUrls: { [id in SolutionId]: string } = { + es: 'https://ela.st/search-nav-feedback', + oblt: 'https://ela.st/o11y-nav-feedback', + security: 'https://ela.st/security-nav-feedback', +}; const FEEDBACK_BTN_KEY = 'core.chrome.sideNav.feedbackBtn'; -export const FeedbackBtn: FC = () => { +interface Props { + solutionId: SolutionId; +} + +export const FeedbackBtn: FC = ({ solutionId }) => { const { euiTheme } = useEuiTheme(); const [showCallOut, setShowCallOut] = useState( sessionStorage.getItem(FEEDBACK_BTN_KEY) !== 'hidden' @@ -26,7 +35,7 @@ export const FeedbackBtn: FC = () => { }; const onClick = () => { - window.open(feedbackUrl, '_blank'); + window.open(feedbackUrls[solutionId], '_blank'); onDismiss(); }; diff --git a/packages/shared-ux/chrome/navigation/src/ui/navigation.stories.tsx b/packages/shared-ux/chrome/navigation/src/ui/navigation.stories.tsx index 6100dceaa2499..c90ec6a72152f 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/navigation.stories.tsx +++ b/packages/shared-ux/chrome/navigation/src/ui/navigation.stories.tsx @@ -91,6 +91,7 @@ const NavigationWrapper: FC, 'c }; const groupExamplesNavigationTree: NavigationTreeDefinitionUI = { + id: 'es', body: [ // My custom project { @@ -257,6 +258,7 @@ export const GroupsExamples = (args: NavigationServices) => { }; const navigationTree: NavigationTreeDefinitionUI = { + id: 'es', body: [ // My custom project { @@ -568,6 +570,7 @@ const panelContentProvider: ContentProvider = (id: string) => { }; const navigationTreeWithPanels: NavigationTreeDefinitionUI = { + id: 'es', body: [ // My custom project { diff --git a/packages/shared-ux/chrome/navigation/src/ui/navigation.tsx b/packages/shared-ux/chrome/navigation/src/ui/navigation.tsx index 688ee1e709e15..80365bd16133f 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/navigation.tsx +++ b/packages/shared-ux/chrome/navigation/src/ui/navigation.tsx @@ -52,7 +52,8 @@ const NavigationComp: FC = ({ navigationTree$, dataTestSubj, panelContent useNavigationService(); const activeNodes = useObservable(activeNodes$, []); - const navigationTree = useObservable(navigationTree$, { body: [] }); + const navigationTree = useObservable(navigationTree$, { id: 'es', body: [] }); + const { id: solutionId } = navigationTree; const isFeedbackBtnVisible = useObservable(isFeedbackBtnVisible$, false); const contextValue = useMemo( @@ -95,7 +96,7 @@ const NavigationComp: FC = ({ navigationTree$, dataTestSubj, panelContent {renderNodes(navigationTree.body)} {isFeedbackBtnVisible && ( - + )} diff --git a/packages/shared-ux/page/analytics_no_data/impl/kibana.jsonc b/packages/shared-ux/page/analytics_no_data/impl/kibana.jsonc index b8690de58bdb9..45c7a028be286 100644 --- a/packages/shared-ux/page/analytics_no_data/impl/kibana.jsonc +++ b/packages/shared-ux/page/analytics_no_data/impl/kibana.jsonc @@ -1,5 +1,7 @@ { "type": "shared-browser", "id": "@kbn/shared-ux-page-analytics-no-data", - "owner": "@elastic/appex-sharedux" + "owner": "@elastic/appex-sharedux", + "group": "platform", + "visibility": "private" } diff --git a/packages/shared-ux/page/analytics_no_data/mocks/kibana.jsonc b/packages/shared-ux/page/analytics_no_data/mocks/kibana.jsonc index cde1666e15f14..e7d570e4239e6 100644 --- a/packages/shared-ux/page/analytics_no_data/mocks/kibana.jsonc +++ b/packages/shared-ux/page/analytics_no_data/mocks/kibana.jsonc @@ -1,5 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-page-analytics-no-data-mocks", - "owner": "@elastic/appex-sharedux" + "owner": "@elastic/appex-sharedux", + "group": "platform", + "visibility": "private" } diff --git a/packages/shared-ux/page/analytics_no_data/types/kibana.jsonc b/packages/shared-ux/page/analytics_no_data/types/kibana.jsonc index df5498181fe69..fd1740c0d757e 100644 --- a/packages/shared-ux/page/analytics_no_data/types/kibana.jsonc +++ b/packages/shared-ux/page/analytics_no_data/types/kibana.jsonc @@ -1,5 +1,7 @@ { "type": "shared-browser", "id": "@kbn/shared-ux-page-analytics-no-data-types", - "owner": "@elastic/appex-sharedux" + "owner": "@elastic/appex-sharedux", + "group": "platform", + "visibility": "private" } diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index aedc9e3364c8f..7fcf5dada3273 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -73,7 +73,7 @@ describe('checking migration metadata changes on all registered SO types', () => "canvas-element": "cdedc2123eb8a1506b87a56b0bcce60f4ec08bc8", "canvas-workpad": "9d82aafb19586b119e5c9382f938abe28c26ca5c", "canvas-workpad-template": "c077b0087346776bb3542b51e1385d172cb24179", - "cases": "2392189ed338857d4815a4cef6051f9ad124d39d", + "cases": "5433a9f1277f8f17bbc4fd20d33b1fc6d997931e", "cases-comments": "5cb0a421588831c2a950e50f486048b8aabbae25", "cases-configure": "44ed7b8e0f44df39516b8870589b89e32224d2bf", "cases-connector-mappings": "f9d1ac57e484e69506c36a8051e4d61f4a8cfd25", @@ -165,6 +165,7 @@ describe('checking migration metadata changes on all registered SO types', () => "synthetics-dynamic-settings": "4b40a93eb3e222619bf4e7fe34a9b9e7ab91a0a7", "synthetics-monitor": "5ceb25b6249bd26902c9b34273c71c3dce06dbea", "synthetics-param": "3ebb744e5571de678b1312d5c418c8188002cf5e", + "synthetics-private-location": "8cecc9e4f39637d2f8244eb7985c0690ceab24be", "synthetics-privates-locations": "f53d799d5c9bc8454aaa32c6abc99a899b025d5c", "tag": "e2544392fe6563e215bb677abc8b01c2601ef2dc", "task": "3c89a7c918d5b896a5f8800f06e9114ad7e7aea3", diff --git a/src/core/server/integration_tests/saved_objects/migrations/group3/incompatible_cluster_routing_allocation.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group3/incompatible_cluster_routing_allocation.test.ts index 56488f571ef16..8213c880c0fa4 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group3/incompatible_cluster_routing_allocation.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group3/incompatible_cluster_routing_allocation.test.ts @@ -121,7 +121,7 @@ describe('incompatible_cluster_routing_allocation', () => { await root.preboot(); await root.setup(); - root.start().catch(() => { + const startPromise = root.start().catch(() => { // Silent catch because the test might be done and call shutdown before starting is completed, causing unwanted thrown errors. }); @@ -165,6 +165,7 @@ describe('incompatible_cluster_routing_allocation', () => { { retryAttempts: 100, retryDelayMs: 500 } ); + await startPromise; // Wait for start phase to complete before shutting down await root.shutdown(); }); }); diff --git a/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts index e95a82e63d0ff..ba06073e454a9 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts @@ -139,6 +139,7 @@ const previouslyRegisteredTypes = [ 'synthetics-monitor', 'synthetics-param', 'synthetics-privates-locations', + 'synthetics-private-location', 'tag', 'task', 'telemetry', diff --git a/src/core/server/integration_tests/saved_objects/routes/import.test.ts b/src/core/server/integration_tests/saved_objects/routes/import.test.ts index 917f7f1642e8c..bf1fae4967e95 100644 --- a/src/core/server/integration_tests/saved_objects/routes/import.test.ts +++ b/src/core/server/integration_tests/saved_objects/routes/import.test.ts @@ -13,7 +13,6 @@ import supertest from 'supertest'; import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks'; import type { ICoreUsageStatsClient } from '@kbn/core-usage-data-base-server-internal'; -import type { Logger, LogLevelId } from '@kbn/logging'; import { coreUsageStatsClientMock, coreUsageDataServiceMock, @@ -28,6 +27,7 @@ import { type InternalSavedObjectsRequestHandlerContext, } from '@kbn/core-saved-objects-server-internal'; import { setupServer, createExportableType } from '@kbn/core-test-helpers-test-utils'; +import { loggerMock, type MockedLogger } from '@kbn/logging-mocks'; type SetupServerReturn = Awaited>; @@ -41,6 +41,7 @@ describe(`POST ${URL}`, () => { let httpSetup: SetupServerReturn['httpSetup']; let handlerContext: SetupServerReturn['handlerContext']; let savedObjectsClient: ReturnType; + let mockLogger: MockedLogger; const emptyResponse = { saved_objects: [], total: 0, per_page: 0, page: 0 }; const mockIndexPattern = { @@ -57,20 +58,10 @@ describe(`POST ${URL}`, () => { references: [], managed: false, }; - const mockLogger: jest.Mocked = { - debug: jest.fn(), - info: jest.fn(), - error: jest.fn(), - warn: jest.fn(), - trace: jest.fn(), - fatal: jest.fn(), - log: jest.fn(), - isLevelEnabled: jest.fn((level: LogLevelId) => true), - get: jest.fn(() => mockLogger), - }; beforeEach(async () => { ({ server, httpSetup, handlerContext } = await setupServer()); + mockLogger = loggerMock.create(); handlerContext.savedObjects.typeRegistry.getImportableAndExportableTypes.mockReturnValue( allowedTypes.map(createExportableType) ); diff --git a/src/dev/build/tasks/os_packages/docker_generator/run.ts b/src/dev/build/tasks/os_packages/docker_generator/run.ts index a3925b3a04f24..7a64ada1bfff9 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/run.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/run.ts @@ -51,7 +51,7 @@ export async function runDockerGenerator( */ if (flags.baseImage === 'wolfi') baseImageName = - 'docker.elastic.co/wolfi/chainguard-base:latest@sha256:18153942f0d6e97bc6131cd557c7ed3be6e892846a5df0760896eb8d15b1b236'; + 'docker.elastic.co/wolfi/chainguard-base:latest@sha256:26caa6beaee2bbf739a82e91a35173892dfe888d0a744b9e46cdc19a90d8656f'; let imageFlavor = ''; if (flags.baseImage === 'ubi') imageFlavor += `-ubi`; diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/hardening_manifest.yaml b/src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/hardening_manifest.yaml index 287739cc03fea..d1d76367b4ec3 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/hardening_manifest.yaml +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/hardening_manifest.yaml @@ -42,39 +42,47 @@ resources: value: 0ce56bde1853fed3e53282505bac65707385275a27816c29712ab04c187aa249797c82c58759b2b36c210d4e2683eda92359d739a8045cb8385c2c34d37cc9e1 # List of project maintainers maintainers: + # AppEx Operations Members + - email: 'brad.white@elastic.co' + name: 'Brad White' + username: 'brad.white' + cht_member: false - email: 'jon@elastic.co' name: 'Jonathan Budzenski' username: 'jbudz' cht_member: false - - email: 'brad.white@elastic.co' - name: 'Brad White' - username: 'brad.white' + # AppEx Platform Security Members + - email: 'aleh.zasypkin@elastic.co' + name: 'Aleh Zasypkin' + username: 'azasypkin' + cht_member: false + - email: 'larry.gregory@elastic.co' + name: 'Larry Gregory' + username: 'legrego' + cht_member: false + # InfoSec Members + - email: 'abby.zumstein@elastic.co' + name: 'Abby Zumstein' + username: 'azumstein' + cht_member: false + - email: 'arsalan.khan@elastic.co' + name: 'Arsalan Khan' + username: 'khanarsalan' + cht_member: false + - email: 'iaroslava.zhomir@elastic.co' + name: 'Slava Zhomir' + username: 'slava-elastic' + cht_member: false + - email: 'ryan.kam@elastic.co' + name: 'Ryan Kam' + username: 'ryankam' + cht_member: false + - email: 'saumya.shree@elastic.co' + name: 'Saumya Shree' + username: 'shreesaumya' cht_member: false + # CHT Members - email: 'klepal_alexander@bah.com' name: 'Alexander Klepal' username: 'alexander.klepal' - cht_member: true - - cht_member: false - email: larry.gregory@elastic.co - name: Larry Gregory - username: legrego - - cht_member: false - email: aleh.zasypkin@elastic.co - name: Aleh Zasypkin - username: azasypkin - - cht_member: false - email: kurt.greiner@elastic.co - name: Kurt Greiner - username: kc13greiner - - cht_member: false - email: jeramy.soucy@elastic.co - name: Jeramy Soucy - username: jeramysoucy - - cht_member: false - email: sid.mantri@elastic.co - name: Sid Mantri - username: sidmantri - - cht_member: false - email: elena.shostak@elastic.co - name: Elena Shostak - username: elena.shostak + cht_member: true \ No newline at end of file diff --git a/src/dev/i18n_tools/bin/run_i18n_check.ts b/src/dev/i18n_tools/bin/run_i18n_check.ts index 9d7265f84520f..f25257c3ec51b 100644 --- a/src/dev/i18n_tools/bin/run_i18n_check.ts +++ b/src/dev/i18n_tools/bin/run_i18n_check.ts @@ -9,7 +9,6 @@ import { Listr } from 'listr2'; import { run } from '@kbn/dev-cli-runner'; -import { ToolingLog } from '@kbn/tooling-log'; import { getTimeReporter } from '@kbn/ci-stats-reporter'; import { isFailError } from '@kbn/dev-cli-errors'; import { I18nCheckTaskContext, MessageDescriptor } from '../types'; @@ -24,13 +23,7 @@ import { import { TaskReporter } from '../utils/task_reporter'; import { flagFailError, isDefined, undefinedOrBoolean } from '../utils/verify_bin_flags'; -const toolingLog = new ToolingLog({ - level: 'info', - writeTo: process.stdout, -}); - const runStartTime = Date.now(); -const reportTime = getTimeReporter(toolingLog, 'scripts/i18n_check'); const skipOnNoTranslations = ({ config }: I18nCheckTaskContext) => !config?.translations.length && 'No translations found.'; @@ -50,9 +43,13 @@ run( namespace: namespace, fix = false, path, + silent, + quiet, }, log, }) => { + const reportTime = getTimeReporter(log, 'scripts/i18n_check'); + if ( fix && (isDefined(ignoreIncompatible) || @@ -113,7 +110,7 @@ run( }, { title: 'Checking Untracked i18n Messages outside defined namespaces', - enabled: (_) => !ignoreUntracked || !!(filterNamespaces && filterNamespaces.length), + enabled: (_) => !ignoreUntracked && !!(filterNamespaces && filterNamespaces.length), task: (context, task) => checkUntrackedNamespacesTask(context, task, { rootPaths }), }, { @@ -131,13 +128,15 @@ run( { concurrent: false, exitOnError: true, - renderer: process.env.CI ? 'verbose' : ('default' as any), + forceTTY: false, + renderer: + ((silent || quiet) && 'silent') || (process.env.CI ? 'verbose' : ('default' as any)), } ); try { const messages: Map = new Map(); - const taskReporter = new TaskReporter({ toolingLog }); + const taskReporter = new TaskReporter({ toolingLog: log }); await list.run({ messages, taskReporter }); reportTime(runStartTime, 'total', { @@ -150,6 +149,7 @@ run( reportTime(runStartTime, 'error', { success: false, }); + log.error(error); } else { log.error('Unhandled exception!'); log.error(error); diff --git a/src/dev/i18n_tools/tasks/validate_translation_files/remove_outdated_translations.ts b/src/dev/i18n_tools/tasks/validate_translation_files/remove_outdated_translations.ts index a4815c17cac91..e4f9aae6b5277 100644 --- a/src/dev/i18n_tools/tasks/validate_translation_files/remove_outdated_translations.ts +++ b/src/dev/i18n_tools/tasks/validate_translation_files/remove_outdated_translations.ts @@ -67,26 +67,31 @@ const removeOutdatedMessages = ( 'outdatedMessages' | 'updatedMessages', Array<[string, string | { message: string }]> > => { - const outdatedMessages: Array<[string, string | { message: string }]> = []; - let updatedMessages = translationMessages; + return translationMessages.reduce( + (acc, [translatedId, translatedMessage]) => { + const messageDescriptor = extractedMessages.find(({ id }) => id === translatedId); + // removed from codebase + if (!messageDescriptor) { + acc.outdatedMessages.push([translatedId, translatedMessage]); + return acc; + } - updatedMessages = translationMessages.filter(([translatedId, translatedMessage]) => { - const messageDescriptor = extractedMessages.find(({ id }) => id === translatedId); - if (!messageDescriptor?.hasValuesObject) { - return true; - } - try { - verifyMessageDescriptor( - typeof translatedMessage === 'string' ? translatedMessage : translatedMessage.message, - messageDescriptor - ); - return true; - } catch (err) { - outdatedMessages.push([translatedId, translatedMessage]); - // failed to verify message against latest descriptor. remove from file. - return false; - } - }); + try { + verifyMessageDescriptor( + typeof translatedMessage === 'string' ? translatedMessage : translatedMessage.message, + messageDescriptor + ); + acc.updatedMessages.push([translatedId, translatedMessage]); + } catch (err) { + // failed to verify message against latest descriptor. remove from file. + acc.outdatedMessages.push([translatedId, translatedMessage]); + } - return { updatedMessages, outdatedMessages }; + return acc; + }, + { updatedMessages: [], outdatedMessages: [] } as Record< + 'outdatedMessages' | 'updatedMessages', + Array<[string, string | { message: string }]> + > + ); }; diff --git a/src/dev/license_checker/config.ts b/src/dev/license_checker/config.ts index 3a45c6394f20f..8609eef92a268 100644 --- a/src/dev/license_checker/config.ts +++ b/src/dev/license_checker/config.ts @@ -87,7 +87,7 @@ export const LICENSE_OVERRIDES = { 'jsts@1.6.2': ['Eclipse Distribution License - v 1.0'], // cf. https://github.com/bjornharrtell/jsts '@mapbox/jsonlint-lines-primitives@2.0.2': ['MIT'], // license in readme https://github.com/tmcw/jsonlint '@elastic/ems-client@8.5.3': ['Elastic License 2.0'], - '@elastic/eui@97.2.0': ['Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0'], + '@elastic/eui@97.3.0': ['Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0'], 'language-subtag-registry@0.3.21': ['CC-BY-4.0'], // retired ODC‑By license https://github.com/mattcg/language-subtag-registry 'buffers@0.1.1': ['MIT'], // license in importing module https://www.npmjs.com/package/binary '@bufbuild/protobuf@1.2.1': ['Apache-2.0'], // license (Apache-2.0 AND BSD-3-Clause) diff --git a/src/plugins/advanced_settings/kibana.jsonc b/src/plugins/advanced_settings/kibana.jsonc index c0a338935a590..795827e204aa0 100644 --- a/src/plugins/advanced_settings/kibana.jsonc +++ b/src/plugins/advanced_settings/kibana.jsonc @@ -1,11 +1,16 @@ { "type": "plugin", "id": "@kbn/advanced-settings-plugin", - "owner": "@elastic/appex-sharedux @elastic/kibana-management", + "owner": [ + "@elastic/appex-sharedux", + "@elastic/kibana-management" + ], + "group": "platform", + "visibility": "private", "plugin": { "id": "advancedSettings", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "management" ], @@ -13,7 +18,6 @@ "home", "usageCollection" ], - "requiredBundles": [ - ] + "requiredBundles": [] } -} +} \ No newline at end of file diff --git a/src/plugins/ai_assistant_management/selection/kibana.jsonc b/src/plugins/ai_assistant_management/selection/kibana.jsonc index 2e653bb391c34..74640423685a9 100644 --- a/src/plugins/ai_assistant_management/selection/kibana.jsonc +++ b/src/plugins/ai_assistant_management/selection/kibana.jsonc @@ -1,16 +1,29 @@ { "type": "plugin", "id": "@kbn/ai-assistant-management-plugin", - "owner": "@elastic/obs-knowledge-team", + "owner": [ + "@elastic/obs-knowledge-team" + ], + // This should probably be platform. While the code owner is currently observability, the package is a platform AI assistant selector. + "group": "platform", + "visibility": "shared", "plugin": { "id": "aiAssistantManagementSelection", - "server": true, "browser": true, - "requiredPlugins": ["management"], - "optionalPlugins": ["home", "serverless", "features"], - "requiredBundles": ["kibanaReact"], + "server": true, "configPath": [ "aiAssistantManagementSelection" ], - }, + "requiredPlugins": [ + "management" + ], + "optionalPlugins": [ + "home", + "serverless", + "features" + ], + "requiredBundles": [ + "kibanaReact" + ] + } } diff --git a/src/plugins/bfetch/kibana.jsonc b/src/plugins/bfetch/kibana.jsonc index 97d9571238296..39a8866f3b79b 100644 --- a/src/plugins/bfetch/kibana.jsonc +++ b/src/plugins/bfetch/kibana.jsonc @@ -1,14 +1,18 @@ { "type": "plugin", "id": "@kbn/bfetch-plugin", - "owner": "@elastic/appex-sharedux", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "shared", "description": "Considering using bfetch capabilities when fetching large amounts of data. This services supports batching HTTP requests and streaming responses back.", "plugin": { "id": "bfetch", - "server": true, "browser": true, + "server": true, "requiredBundles": [ "kibanaUtils" ] } -} +} \ No newline at end of file diff --git a/src/plugins/chart_expressions/common/kibana.jsonc b/src/plugins/chart_expressions/common/kibana.jsonc index 546179cce219c..f3d05f4a0581e 100644 --- a/src/plugins/chart_expressions/common/kibana.jsonc +++ b/src/plugins/chart_expressions/common/kibana.jsonc @@ -1,5 +1,9 @@ { "type": "shared-common", "id": "@kbn/chart-expressions-common", - "owner": "@elastic/kibana-visualizations" -} + "owner": [ + "@elastic/kibana-visualizations" + ], + "group": "platform", + "visibility": "shared" +} \ No newline at end of file diff --git a/src/plugins/chart_expressions/expression_gauge/kibana.jsonc b/src/plugins/chart_expressions/expression_gauge/kibana.jsonc index 6f3182e033d6a..70d29fec6336a 100644 --- a/src/plugins/chart_expressions/expression_gauge/kibana.jsonc +++ b/src/plugins/chart_expressions/expression_gauge/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/expression-gauge-plugin", - "owner": "@elastic/kibana-visualizations", + "owner": [ + "@elastic/kibana-visualizations" + ], + "group": "platform", + "visibility": "shared", "description": "Expression Gauge plugin adds a `gauge` renderer and function to the expression plugin. The renderer will display the `gauge` chart.", "plugin": { "id": "expressionGauge", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "expressions", "fieldFormats", @@ -25,4 +29,4 @@ "common" ] } -} +} \ No newline at end of file diff --git a/src/plugins/chart_expressions/expression_heatmap/kibana.jsonc b/src/plugins/chart_expressions/expression_heatmap/kibana.jsonc index aca569c8f606d..5852e882efe5d 100644 --- a/src/plugins/chart_expressions/expression_heatmap/kibana.jsonc +++ b/src/plugins/chart_expressions/expression_heatmap/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/expression-heatmap-plugin", - "owner": "@elastic/kibana-visualizations", + "owner": [ + "@elastic/kibana-visualizations" + ], + "group": "platform", + "visibility": "shared", "description": "Expression Heatmap plugin adds a `heatmap` renderer and function to the expression plugin. The renderer will display the `heatmap` chart.", "plugin": { "id": "expressionHeatmap", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "expressions", "fieldFormats", @@ -25,4 +29,4 @@ "common" ] } -} +} \ No newline at end of file diff --git a/src/plugins/chart_expressions/expression_legacy_metric/kibana.jsonc b/src/plugins/chart_expressions/expression_legacy_metric/kibana.jsonc index b0d916119fd73..88fdca99e016b 100644 --- a/src/plugins/chart_expressions/expression_legacy_metric/kibana.jsonc +++ b/src/plugins/chart_expressions/expression_legacy_metric/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/expression-legacy-metric-vis-plugin", - "owner": "@elastic/kibana-visualizations", + "owner": [ + "@elastic/kibana-visualizations" + ], + "group": "platform", + "visibility": "shared", "description": "Adds a `metric` renderer and function to the expression plugin. The renderer will display the `legacy metric` chart.", "plugin": { "id": "expressionLegacyMetricVis", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "expressions", "fieldFormats", @@ -22,4 +26,4 @@ "kibanaUtils" ] } -} +} \ No newline at end of file diff --git a/src/plugins/chart_expressions/expression_metric/kibana.jsonc b/src/plugins/chart_expressions/expression_metric/kibana.jsonc index c8c6f6b0c8565..2f65e12b11999 100644 --- a/src/plugins/chart_expressions/expression_metric/kibana.jsonc +++ b/src/plugins/chart_expressions/expression_metric/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/expression-metric-vis-plugin", - "owner": "@elastic/kibana-visualizations", + "owner": [ + "@elastic/kibana-visualizations" + ], + "group": "platform", + "visibility": "shared", "description": "Adds a `metric` renderer and function to the expression plugin. The renderer will display the `metric` chart.", "plugin": { "id": "expressionMetricVis", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "expressions", "fieldFormats", @@ -22,4 +26,4 @@ "kibanaUtils" ] } -} +} \ No newline at end of file diff --git a/src/plugins/chart_expressions/expression_partition_vis/kibana.jsonc b/src/plugins/chart_expressions/expression_partition_vis/kibana.jsonc index f69f934fc3005..3ac2e44a23d97 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/kibana.jsonc +++ b/src/plugins/chart_expressions/expression_partition_vis/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/expression-partition-vis-plugin", - "owner": "@elastic/kibana-visualizations", + "owner": [ + "@elastic/kibana-visualizations" + ], + "group": "platform", + "visibility": "shared", "description": "Expression Partition Visualization plugin adds a `partitionVis` renderer and `pieVis`, `mosaicVis`, `treemapVis`, `waffleVis` functions to the expression plugin. The renderer will display the `pie`, `waffle`, `treemap` and `mosaic` charts.", "plugin": { "id": "expressionPartitionVis", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "charts", "data", @@ -25,4 +29,4 @@ "common" ] } -} +} \ No newline at end of file diff --git a/src/plugins/chart_expressions/expression_tagcloud/kibana.jsonc b/src/plugins/chart_expressions/expression_tagcloud/kibana.jsonc index 4cb1898caaf43..a6b71200a4620 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/kibana.jsonc +++ b/src/plugins/chart_expressions/expression_tagcloud/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/expression-tagcloud-plugin", - "owner": "@elastic/kibana-visualizations", + "owner": [ + "@elastic/kibana-visualizations" + ], + "group": "platform", + "visibility": "shared", "description": "Expression Tagcloud plugin adds a `tagcloud` renderer and function to the expression plugin. The renderer will display the `Wordcloud` chart.", "plugin": { "id": "expressionTagcloud", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "data", "expressions", @@ -25,4 +29,4 @@ "common" ] } -} +} \ No newline at end of file diff --git a/src/plugins/chart_expressions/expression_xy/kibana.jsonc b/src/plugins/chart_expressions/expression_xy/kibana.jsonc index 80a414b5e4d0a..7f819610d7e32 100644 --- a/src/plugins/chart_expressions/expression_xy/kibana.jsonc +++ b/src/plugins/chart_expressions/expression_xy/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/expression-xy-plugin", - "owner": "@elastic/kibana-visualizations", + "owner": [ + "@elastic/kibana-visualizations" + ], + "group": "platform", + "visibility": "shared", "description": "Expression XY plugin adds a `xy` renderer and function to the expression plugin. The renderer will display the `xy` chart.", "plugin": { "id": "expressionXY", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "expressions", "charts", @@ -21,4 +25,4 @@ ], "requiredBundles": [] } -} +} \ No newline at end of file diff --git a/src/plugins/charts/kibana.jsonc b/src/plugins/charts/kibana.jsonc index 8c00cd40f4ad3..16475bdda3b9f 100644 --- a/src/plugins/charts/kibana.jsonc +++ b/src/plugins/charts/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/charts-plugin", - "owner": "@elastic/kibana-visualizations", + "owner": [ + "@elastic/kibana-visualizations" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "charts", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "expressions", "data" @@ -14,4 +18,4 @@ "common" ] } -} +} \ No newline at end of file diff --git a/src/plugins/console/kibana.jsonc b/src/plugins/console/kibana.jsonc index ae0cac514b67d..a57eb8f3eb3d1 100644 --- a/src/plugins/console/kibana.jsonc +++ b/src/plugins/console/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/console-plugin", - "owner": "@elastic/kibana-management", + "owner": [ + "@elastic/kibana-management" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "console", - "server": true, "browser": true, + "server": true, "configPath": [ "console" ], @@ -23,4 +27,4 @@ "kibanaUtils" ] } -} +} \ No newline at end of file diff --git a/src/plugins/console/public/services/autocomplete.ts b/src/plugins/console/public/services/autocomplete.ts index d7c4d56d5b704..32ef978c8d587 100644 --- a/src/plugins/console/public/services/autocomplete.ts +++ b/src/plugins/console/public/services/autocomplete.ts @@ -53,7 +53,10 @@ export class AutocompleteInfo { case ENTITIES.INDICES: const includeAliases = true; const collaborator = this.mapping; - return () => this.alias.getIndices(includeAliases, collaborator); + return () => [ + ...this.alias.getIndices(includeAliases, collaborator), + ...this.dataStream.getDataStreams(), + ]; case ENTITIES.FIELDS: return this.mapping.getMappings( context.indices, diff --git a/src/plugins/console/server/routes/api/console/autocomplete_entities/index.ts b/src/plugins/console/server/routes/api/console/autocomplete_entities/index.ts index bf63f2048d66f..0dacd8e93cc9b 100644 --- a/src/plugins/console/server/routes/api/console/autocomplete_entities/index.ts +++ b/src/plugins/console/server/routes/api/console/autocomplete_entities/index.ts @@ -43,7 +43,10 @@ const getAliases = async (settings: SettingsToRetrieve, esClient: IScopedCluster const getDataStreams = async (settings: SettingsToRetrieve, esClient: IScopedClusterClient) => { if (settings.dataStreams) { - const dataStreams = await esClient.asCurrentUser.indices.getDataStream(); + const dataStreams = await esClient.asCurrentUser.indices.getDataStream({ + name: '*', + expand_wildcards: 'all', + }); return dataStreams; } // If the user doesn't want autocomplete suggestions, then clear any that exist. diff --git a/src/plugins/content_management/kibana.jsonc b/src/plugins/content_management/kibana.jsonc index 7ebfe75180658..a2d43504b52b2 100644 --- a/src/plugins/content_management/kibana.jsonc +++ b/src/plugins/content_management/kibana.jsonc @@ -1,14 +1,18 @@ { "type": "plugin", "id": "@kbn/content-management-plugin", - "owner": "@elastic/appex-sharedux", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "shared", "description": "Content management app", "plugin": { "id": "contentManagement", - "server": true, "browser": true, + "server": true, "optionalPlugins": [ "usageCollection" ] } -} +} \ No newline at end of file diff --git a/src/plugins/controls/common/constants.ts b/src/plugins/controls/common/constants.ts index e375a7b2315bc..d1434d4df2ae0 100644 --- a/src/plugins/controls/common/constants.ts +++ b/src/plugins/controls/common/constants.ts @@ -7,11 +7,25 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { ControlGroupChainingSystem } from './control_group'; import { ControlLabelPosition, ControlWidth } from './types'; -export const DEFAULT_CONTROL_WIDTH: ControlWidth = 'medium'; +export const CONTROL_WIDTH_OPTIONS = { SMALL: 'small', MEDIUM: 'medium', LARGE: 'large' } as const; +export const CONTROL_LABEL_POSITION_OPTIONS = { ONE_LINE: 'oneLine', TWO_LINE: 'twoLine' } as const; +export const CONTROL_CHAINING_OPTIONS = { NONE: 'NONE', HIERARCHICAL: 'HIERARCHICAL' } as const; +export const DEFAULT_CONTROL_WIDTH: ControlWidth = CONTROL_WIDTH_OPTIONS.MEDIUM; +export const DEFAULT_CONTROL_LABEL_POSITION: ControlLabelPosition = + CONTROL_LABEL_POSITION_OPTIONS.ONE_LINE; export const DEFAULT_CONTROL_GROW: boolean = true; -export const DEFAULT_CONTROL_LABEL_POSITION: ControlLabelPosition = 'oneLine'; +export const DEFAULT_CONTROL_CHAINING: ControlGroupChainingSystem = + CONTROL_CHAINING_OPTIONS.HIERARCHICAL; +export const DEFAULT_IGNORE_PARENT_SETTINGS = { + ignoreFilters: false, + ignoreQuery: false, + ignoreTimerange: false, + ignoreValidations: false, +} as const; +export const DEFAULT_AUTO_APPLY_SELECTIONS = true; export const TIME_SLIDER_CONTROL = 'timeSlider'; export const RANGE_SLIDER_CONTROL = 'rangeSliderControl'; diff --git a/src/plugins/controls/common/control_group/types.ts b/src/plugins/controls/common/control_group/types.ts index eb47d8b13eb79..ff1e4455046b8 100644 --- a/src/plugins/controls/common/control_group/types.ts +++ b/src/plugins/controls/common/control_group/types.ts @@ -9,10 +9,12 @@ import { DataViewField } from '@kbn/data-views-plugin/common'; import { ControlLabelPosition, DefaultControlState, ParentIgnoreSettings } from '../types'; +import { CONTROL_CHAINING_OPTIONS } from '../constants'; export const CONTROL_GROUP_TYPE = 'control_group'; -export type ControlGroupChainingSystem = 'HIERARCHICAL' | 'NONE'; +export type ControlGroupChainingSystem = + (typeof CONTROL_CHAINING_OPTIONS)[keyof typeof CONTROL_CHAINING_OPTIONS]; export type FieldFilterPredicate = (f: DataViewField) => boolean; @@ -45,15 +47,11 @@ export interface ControlGroupRuntimeState { - panelsJSON: string; // stringified version of ControlSerializedState - ignoreParentSettingsJSON: string; - // In runtime state, we refer to this property as `labelPosition`; - // to avoid migrations, we will continue to refer to this property as `controlStyle` in the serialized state - controlStyle: ControlLabelPosition; - // In runtime state, we refer to the inverse of this property as `autoApplySelections` - // to avoid migrations, we will continue to refer to this property as `showApplySelections` in the serialized state - showApplySelections?: boolean; + extends Omit { + // In runtime state, we refer to this property as `initialChildControlState`, but in + // the serialized state we transform the state object into an array of state objects + // to make it easier for API consumers to add new controls without specifying a uuid key. + controls: Array; } /** diff --git a/src/plugins/controls/common/index.ts b/src/plugins/controls/common/index.ts index dd9c56778bb68..031d3b348272f 100644 --- a/src/plugins/controls/common/index.ts +++ b/src/plugins/controls/common/index.ts @@ -17,9 +17,15 @@ export type { } from './types'; export { + DEFAULT_CONTROL_CHAINING, DEFAULT_CONTROL_GROW, DEFAULT_CONTROL_LABEL_POSITION, DEFAULT_CONTROL_WIDTH, + DEFAULT_IGNORE_PARENT_SETTINGS, + DEFAULT_AUTO_APPLY_SELECTIONS, + CONTROL_WIDTH_OPTIONS, + CONTROL_CHAINING_OPTIONS, + CONTROL_LABEL_POSITION_OPTIONS, OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL, TIME_SLIDER_CONTROL, diff --git a/src/plugins/controls/common/types.ts b/src/plugins/controls/common/types.ts index d3a6261aeb9da..d38ca80cb3815 100644 --- a/src/plugins/controls/common/types.ts +++ b/src/plugins/controls/common/types.ts @@ -7,12 +7,16 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export type ControlWidth = 'small' | 'medium' | 'large'; -export type ControlLabelPosition = 'twoLine' | 'oneLine'; +import { SerializableRecord } from '@kbn/utility-types'; +import { CONTROL_LABEL_POSITION_OPTIONS, CONTROL_WIDTH_OPTIONS } from './constants'; + +export type ControlWidth = (typeof CONTROL_WIDTH_OPTIONS)[keyof typeof CONTROL_WIDTH_OPTIONS]; +export type ControlLabelPosition = + (typeof CONTROL_LABEL_POSITION_OPTIONS)[keyof typeof CONTROL_LABEL_POSITION_OPTIONS]; export type TimeSlice = [number, number]; -export interface ParentIgnoreSettings { +export interface ParentIgnoreSettings extends SerializableRecord { ignoreFilters?: boolean; ignoreQuery?: boolean; ignoreTimerange?: boolean; diff --git a/src/plugins/controls/kibana.jsonc b/src/plugins/controls/kibana.jsonc index add8c14ee3391..76fb9f7960412 100644 --- a/src/plugins/controls/kibana.jsonc +++ b/src/plugins/controls/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/controls-plugin", - "owner": "@elastic/kibana-presentation", + "owner": [ + "@elastic/kibana-presentation" + ], + "group": "platform", + "visibility": "shared", "description": "The Controls Plugin contains embeddable components intended to create a simple query interface for end users, and a powerful editing suite that allows dashboard authors to build controls", "plugin": { "id": "controls", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "presentationUtil", "embeddable", @@ -15,7 +19,9 @@ "unifiedSearch", "uiActions" ], - "extraPublicDirs": ["common"], - "requiredBundles": [] + "requiredBundles": [], + "extraPublicDirs": [ + "common" + ] } -} +} \ No newline at end of file diff --git a/src/plugins/controls/public/control_group/control_group_renderer/control_group_renderer.tsx b/src/plugins/controls/public/control_group/control_group_renderer/control_group_renderer.tsx index 6a50c60c4e597..1a05d4b25e22c 100644 --- a/src/plugins/controls/public/control_group/control_group_renderer/control_group_renderer.tsx +++ b/src/plugins/controls/public/control_group/control_group_renderer/control_group_renderer.tsx @@ -19,8 +19,11 @@ import { useSearchApi, type ViewMode as ViewModeType } from '@kbn/presentation-p import type { ControlGroupApi } from '../..'; import { CONTROL_GROUP_TYPE, + DEFAULT_CONTROL_LABEL_POSITION, type ControlGroupRuntimeState, type ControlGroupSerializedState, + DEFAULT_CONTROL_CHAINING, + DEFAULT_AUTO_APPLY_SELECTIONS, } from '../../../common'; import { type ControlGroupStateBuilder, @@ -136,16 +139,19 @@ export const ControlGroupRenderer = ({ ...initialState, editorConfig, }); - const state = { - ...omit(initialState, ['initialChildControlState', 'ignoreParentSettings']), + const state: ControlGroupSerializedState = { + ...omit(initialState, ['initialChildControlState']), editorConfig, - controlStyle: initialState?.labelPosition, - panelsJSON: JSON.stringify(initialState?.initialChildControlState ?? {}), - ignoreParentSettingsJSON: JSON.stringify(initialState?.ignoreParentSettings ?? {}), + autoApplySelections: initialState?.autoApplySelections ?? DEFAULT_AUTO_APPLY_SELECTIONS, + labelPosition: initialState?.labelPosition ?? DEFAULT_CONTROL_LABEL_POSITION, + chainingSystem: initialState?.chainingSystem ?? DEFAULT_CONTROL_CHAINING, + controls: Object.entries(initialState?.initialChildControlState ?? {}).map( + ([controlId, value]) => ({ ...value, id: controlId }) + ), }; if (!cancelled) { - setSerializedState(state as ControlGroupSerializedState); + setSerializedState(state); } })(); return () => { diff --git a/src/plugins/controls/public/control_group/get_control_group_factory.tsx b/src/plugins/controls/public/control_group/get_control_group_factory.tsx index 62af1d1f868a9..c8ee296d8a305 100644 --- a/src/plugins/controls/public/control_group/get_control_group_factory.tsx +++ b/src/plugins/controls/public/control_group/get_control_group_factory.tsx @@ -33,7 +33,11 @@ import type { ControlPanelsState, ParentIgnoreSettings, } from '../../common'; -import { CONTROL_GROUP_TYPE, DEFAULT_CONTROL_LABEL_POSITION } from '../../common'; +import { + CONTROL_GROUP_TYPE, + DEFAULT_CONTROL_CHAINING, + DEFAULT_CONTROL_LABEL_POSITION, +} from '../../common'; import { openDataControlEditor } from '../controls/data_controls/open_data_control_editor'; import { coreServices, dataViewsService } from '../services/kibana_services'; import { ControlGroup } from './components/control_group'; @@ -45,8 +49,6 @@ import { initSelectionsManager } from './selections_manager'; import type { ControlGroupApi } from './types'; import { deserializeControlGroup } from './utils/serialization_utils'; -const DEFAULT_CHAINING_SYSTEM = 'HIERARCHICAL'; - export const getControlGroupEmbeddableFactory = () => { const controlGroupEmbeddableFactory: ReactEmbeddableFactory< ControlGroupSerializedState, @@ -85,7 +87,7 @@ export const getControlGroupEmbeddableFactory = () => { }); const dataViews = new BehaviorSubject(undefined); const chainingSystem$ = new BehaviorSubject( - chainingSystem ?? DEFAULT_CHAINING_SYSTEM + chainingSystem ?? DEFAULT_CONTROL_CHAINING ); const ignoreParentSettings$ = new BehaviorSubject( ignoreParentSettings @@ -108,7 +110,7 @@ export const getControlGroupEmbeddableFactory = () => { chainingSystem: [ chainingSystem$, (next: ControlGroupChainingSystem) => chainingSystem$.next(next), - (a, b) => (a ?? DEFAULT_CHAINING_SYSTEM) === (b ?? DEFAULT_CHAINING_SYSTEM), + (a, b) => (a ?? DEFAULT_CONTROL_CHAINING) === (b ?? DEFAULT_CONTROL_CHAINING), ], ignoreParentSettings: [ ignoreParentSettings$, @@ -187,14 +189,14 @@ export const getControlGroupEmbeddableFactory = () => { }); }, serializeState: () => { - const { panelsJSON, references } = controlsManager.serializeControls(); + const { controls, references } = controlsManager.serializeControls(); return { rawState: { chainingSystem: chainingSystem$.getValue(), - controlStyle: labelPosition$.getValue(), - showApplySelections: !autoApplySelections$.getValue(), - ignoreParentSettingsJSON: JSON.stringify(ignoreParentSettings$.getValue()), - panelsJSON, + labelPosition: labelPosition$.getValue(), + autoApplySelections: autoApplySelections$.getValue(), + ignoreParentSettings: ignoreParentSettings$.getValue(), + controls, }, references, }; diff --git a/src/plugins/controls/public/control_group/init_controls_manager.ts b/src/plugins/controls/public/control_group/init_controls_manager.ts index ee020bf1fbd59..935845327131e 100644 --- a/src/plugins/controls/public/control_group/init_controls_manager.ts +++ b/src/plugins/controls/public/control_group/init_controls_manager.ts @@ -147,9 +147,8 @@ export function initControlsManager( }, serializeControls: () => { const references: Reference[] = []; - const explicitInputPanels: { - [panelId: string]: ControlPanelState & { explicitInput: object }; - } = {}; + + const controls: Array = []; controlsInOrder$.getValue().forEach(({ id }, index) => { const controlApi = getControlApi(id); @@ -166,18 +165,18 @@ export function initControlsManager( references.push(...controlReferences); } - explicitInputPanels[id] = { + controls.push({ grow, order: index, type: controlApi.type, width, - /** Re-add the `explicitInput` layer on serialize so control group saved object retains shape */ - explicitInput: { id, ...rest }, - }; + /** Re-add the `controlConfig` layer on serialize so control group saved object retains shape */ + controlConfig: { id, ...rest }, + }); }); return { - panelsJSON: JSON.stringify(explicitInputPanels), + controls, references, }; }, diff --git a/src/plugins/controls/public/control_group/utils/initialization_utils.ts b/src/plugins/controls/public/control_group/utils/initialization_utils.ts index ea785d05ac735..a35572387e1e1 100644 --- a/src/plugins/controls/public/control_group/utils/initialization_utils.ts +++ b/src/plugins/controls/public/control_group/utils/initialization_utils.ts @@ -7,17 +7,18 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { DEFAULT_CONTROL_LABEL_POSITION, type ControlGroupRuntimeState } from '../../../common'; +import { + type ControlGroupRuntimeState, + DEFAULT_CONTROL_CHAINING, + DEFAULT_CONTROL_LABEL_POSITION, + DEFAULT_AUTO_APPLY_SELECTIONS, + DEFAULT_IGNORE_PARENT_SETTINGS, +} from '../../../common'; export const getDefaultControlGroupRuntimeState = (): ControlGroupRuntimeState => ({ initialChildControlState: {}, labelPosition: DEFAULT_CONTROL_LABEL_POSITION, - chainingSystem: 'HIERARCHICAL', - autoApplySelections: true, - ignoreParentSettings: { - ignoreFilters: false, - ignoreQuery: false, - ignoreTimerange: false, - ignoreValidations: false, - }, + chainingSystem: DEFAULT_CONTROL_CHAINING, + autoApplySelections: DEFAULT_AUTO_APPLY_SELECTIONS, + ignoreParentSettings: DEFAULT_IGNORE_PARENT_SETTINGS, }); diff --git a/src/plugins/controls/public/control_group/utils/serialization_utils.ts b/src/plugins/controls/public/control_group/utils/serialization_utils.ts index ad7dea5827507..0a046244b732f 100644 --- a/src/plugins/controls/public/control_group/utils/serialization_utils.ts +++ b/src/plugins/controls/public/control_group/utils/serialization_utils.ts @@ -16,37 +16,31 @@ import { parseReferenceName } from '../../controls/data_controls/reference_name_ export const deserializeControlGroup = ( state: SerializedPanelState ): ControlGroupRuntimeState => { - const panels = JSON.parse(state.rawState.panelsJSON); - const ignoreParentSettings = JSON.parse(state.rawState.ignoreParentSettingsJSON); + const { controls } = state.rawState; + const controlsMap = Object.fromEntries(controls.map(({ id, ...rest }) => [id, rest])); /** Inject data view references into each individual control */ const references = state.references ?? []; references.forEach((reference) => { const referenceName = reference.name; const { controlId } = parseReferenceName(referenceName); - if (panels[controlId]) { - panels[controlId].dataViewId = reference.id; + if (controlsMap[controlId]) { + controlsMap[controlId].dataViewId = reference.id; } }); - /** Flatten the state of each panel by removing `explicitInput` */ - const flattenedPanels = Object.keys(panels).reduce((prev, panelId) => { - const currentPanel = panels[panelId]; - const currentPanelExplicitInput = panels[panelId].explicitInput; + /** Flatten the state of each control by removing `controlConfig` */ + const flattenedControls = Object.keys(controlsMap).reduce((prev, controlId) => { + const currentControl = controlsMap[controlId]; + const currentControlExplicitInput = controlsMap[controlId].controlConfig; return { ...prev, - [panelId]: { ...omit(currentPanel, 'explicitInput'), ...currentPanelExplicitInput }, + [controlId]: { ...omit(currentControl, 'controlConfig'), ...currentControlExplicitInput }, }; }, {}); return { - ...omit(state.rawState, ['panelsJSON', 'ignoreParentSettingsJSON']), - initialChildControlState: flattenedPanels, - ignoreParentSettings, - autoApplySelections: - typeof state.rawState.showApplySelections === 'boolean' - ? !state.rawState.showApplySelections - : true, // Rename "showApplySelections" to "autoApplySelections" - labelPosition: state.rawState.controlStyle, // Rename "controlStyle" to "labelPosition" + ...state.rawState, + initialChildControlState: flattenedControls, }; }; diff --git a/src/plugins/controls/server/control_group/control_group_migrations.test.ts b/src/plugins/controls/server/control_group/control_group_migrations.test.ts index 69b19225218e3..59643d3aa19c7 100644 --- a/src/plugins/controls/server/control_group/control_group_migrations.test.ts +++ b/src/plugins/controls/server/control_group/control_group_migrations.test.ts @@ -18,10 +18,8 @@ import { import { OptionsListControlState } from '../../common/options_list'; import { mockDataControlState, mockOptionsListControlState } from '../mocks'; import { removeHideExcludeAndHideExists } from './control_group_migrations'; -import { - SerializableControlGroupState, - getDefaultControlGroupState, -} from './control_group_persistence'; +import { getDefaultControlGroupState } from './control_group_persistence'; +import type { SerializableControlGroupState } from './types'; describe('migrate control group', () => { const getOptionsListControl = ( diff --git a/src/plugins/controls/server/control_group/control_group_migrations.ts b/src/plugins/controls/server/control_group/control_group_migrations.ts index a3d3d06aadafc..e737441cde717 100644 --- a/src/plugins/controls/server/control_group/control_group_migrations.ts +++ b/src/plugins/controls/server/control_group/control_group_migrations.ts @@ -14,7 +14,7 @@ import { type SerializedControlState, } from '../../common'; import { OptionsListControlState } from '../../common/options_list'; -import { SerializableControlGroupState } from './control_group_persistence'; +import { SerializableControlGroupState } from './types'; export const makeControlOrdersZeroBased = (state: SerializableControlGroupState) => { if ( diff --git a/src/plugins/controls/server/control_group/control_group_persistable_state.ts b/src/plugins/controls/server/control_group/control_group_persistable_state.ts index d59ffb2161934..9e880242df12b 100644 --- a/src/plugins/controls/server/control_group/control_group_persistable_state.ts +++ b/src/plugins/controls/server/control_group/control_group_persistable_state.ts @@ -20,7 +20,7 @@ import { makeControlOrdersZeroBased, removeHideExcludeAndHideExists, } from './control_group_migrations'; -import type { SerializableControlGroupState } from './control_group_persistence'; +import { SerializableControlGroupState } from './types'; const getPanelStatePrefix = (state: SerializedControlState) => `${state.explicitInput.id}:`; diff --git a/src/plugins/controls/server/control_group/control_group_persistence.ts b/src/plugins/controls/server/control_group/control_group_persistence.ts index e90aa850c6d1a..bcf61b3bcc1b2 100644 --- a/src/plugins/controls/server/control_group/control_group_persistence.ts +++ b/src/plugins/controls/server/control_group/control_group_persistence.ts @@ -9,37 +9,22 @@ import { SerializableRecord } from '@kbn/utility-types'; +import { ControlGroupSavedObjectState, SerializableControlGroupState } from './types'; import { + DEFAULT_CONTROL_CHAINING, DEFAULT_CONTROL_LABEL_POSITION, - type ControlGroupRuntimeState, - type ControlGroupSerializedState, - type ControlPanelState, - type SerializedControlState, + DEFAULT_IGNORE_PARENT_SETTINGS, + DEFAULT_AUTO_APPLY_SELECTIONS, } from '../../common'; export const getDefaultControlGroupState = (): SerializableControlGroupState => ({ panels: {}, labelPosition: DEFAULT_CONTROL_LABEL_POSITION, - chainingSystem: 'HIERARCHICAL', - autoApplySelections: true, - ignoreParentSettings: { - ignoreFilters: false, - ignoreQuery: false, - ignoreTimerange: false, - ignoreValidations: false, - }, + chainingSystem: DEFAULT_CONTROL_CHAINING, + autoApplySelections: DEFAULT_AUTO_APPLY_SELECTIONS, + ignoreParentSettings: DEFAULT_IGNORE_PARENT_SETTINGS, }); -// using SerializableRecord to force type to be read as serializable -export type SerializableControlGroupState = SerializableRecord & - Omit< - ControlGroupRuntimeState, - 'initialChildControlState' | 'ignoreParentSettings' | 'editorConfig' // editor config is not persisted - > & { - ignoreParentSettings: Record; - panels: Record> | {}; - }; - const safeJSONParse = (jsonString?: string): OutType | undefined => { if (!jsonString && typeof jsonString !== 'string') return; try { @@ -49,22 +34,26 @@ const safeJSONParse = (jsonString?: string): OutType | undefined => { } }; -export const controlGroupSerializedStateToSerializableRuntimeState = ( - serializedState: ControlGroupSerializedState +export const controlGroupSavedObjectStateToSerializableRuntimeState = ( + savedObjectState: ControlGroupSavedObjectState ): SerializableControlGroupState => { const defaultControlGroupInput = getDefaultControlGroupState(); return { - chainingSystem: serializedState?.chainingSystem, - labelPosition: serializedState?.controlStyle ?? defaultControlGroupInput.labelPosition, - autoApplySelections: !serializedState?.showApplySelections, - ignoreParentSettings: safeJSONParse(serializedState?.ignoreParentSettingsJSON) ?? {}, - panels: safeJSONParse(serializedState?.panelsJSON) ?? {}, + chainingSystem: + (savedObjectState?.chainingSystem as SerializableControlGroupState['chainingSystem']) ?? + defaultControlGroupInput.chainingSystem, + labelPosition: + (savedObjectState?.controlStyle as SerializableControlGroupState['labelPosition']) ?? + defaultControlGroupInput.labelPosition, + autoApplySelections: !savedObjectState?.showApplySelections, + ignoreParentSettings: safeJSONParse(savedObjectState?.ignoreParentSettingsJSON) ?? {}, + panels: safeJSONParse(savedObjectState?.panelsJSON) ?? {}, }; }; -export const serializableRuntimeStateToControlGroupSerializedState = ( +export const serializableRuntimeStateToControlGroupSavedObjectState = ( serializable: SerializableRecord // It is safe to treat this as SerializableControlGroupState -): ControlGroupSerializedState => { +): ControlGroupSavedObjectState => { return { controlStyle: serializable.labelPosition as SerializableControlGroupState['labelPosition'], chainingSystem: serializable.chainingSystem as SerializableControlGroupState['chainingSystem'], diff --git a/src/plugins/controls/server/control_group/control_group_telemetry.test.ts b/src/plugins/controls/server/control_group/control_group_telemetry.test.ts index da2800a7d744f..3647a23d36a17 100644 --- a/src/plugins/controls/server/control_group/control_group_telemetry.test.ts +++ b/src/plugins/controls/server/control_group/control_group_telemetry.test.ts @@ -7,16 +7,11 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { SerializableRecord } from '@kbn/utility-types'; -import { type ControlGroupSerializedState } from '../../common'; -import { - type ControlGroupTelemetry, - controlGroupTelemetry, - initializeControlGroupTelemetry, -} from './control_group_telemetry'; +import { controlGroupTelemetry, initializeControlGroupTelemetry } from './control_group_telemetry'; +import { ControlGroupSavedObjectState, ControlGroupTelemetry } from './types'; // controls attributes with all settings ignored + 3 options lists + hierarchical chaining + label above -const rawControlAttributes1: SerializableRecord & ControlGroupSerializedState = { +const rawControlAttributes1: ControlGroupSavedObjectState = { controlStyle: 'twoLine', chainingSystem: 'NONE', showApplySelections: true, @@ -27,7 +22,7 @@ const rawControlAttributes1: SerializableRecord & ControlGroupSerializedState = }; // controls attributes with some settings ignored + 2 range sliders, 1 time slider + No chaining + label inline -const rawControlAttributes2: SerializableRecord & ControlGroupSerializedState = { +const rawControlAttributes2: ControlGroupSavedObjectState = { controlStyle: 'oneLine', chainingSystem: 'NONE', showApplySelections: false, @@ -38,7 +33,7 @@ const rawControlAttributes2: SerializableRecord & ControlGroupSerializedState = }; // controls attributes with no settings ignored + 2 options lists, 1 range slider, 1 time slider + hierarchical chaining + label inline -const rawControlAttributes3: SerializableRecord & ControlGroupSerializedState = { +const rawControlAttributes3: ControlGroupSavedObjectState = { controlStyle: 'oneLine', chainingSystem: 'HIERARCHICAL', showApplySelections: false, diff --git a/src/plugins/controls/server/control_group/control_group_telemetry.ts b/src/plugins/controls/server/control_group/control_group_telemetry.ts index 21d1baf40116c..72944202b9550 100644 --- a/src/plugins/controls/server/control_group/control_group_telemetry.ts +++ b/src/plugins/controls/server/control_group/control_group_telemetry.ts @@ -9,31 +9,15 @@ import { PersistableStateService } from '@kbn/kibana-utils-plugin/common'; import { set } from '@kbn/safer-lodash-set'; -import type { ControlGroupSerializedState } from '../../common'; import { - type SerializableControlGroupState, - controlGroupSerializedStateToSerializableRuntimeState, + controlGroupSavedObjectStateToSerializableRuntimeState, getDefaultControlGroupState, } from './control_group_persistence'; - -export interface ControlGroupTelemetry { - total: number; - chaining_system: { - [key: string]: number; - }; - label_position: { - [key: string]: number; - }; - ignore_settings: { - [key: string]: number; - }; - by_type: { - [key: string]: { - total: number; - details: { [key: string]: number }; - }; - }; -} +import { + ControlGroupSavedObjectState, + ControlGroupTelemetry, + SerializableControlGroupState, +} from './types'; export const initializeControlGroupTelemetry = ( statsSoFar: Record @@ -113,8 +97,8 @@ export const controlGroupTelemetry: PersistableStateService['telemetry'] = ( const controlGroupStats = initializeControlGroupTelemetry(stats); const controlGroupState = { ...getDefaultControlGroupState(), - ...controlGroupSerializedStateToSerializableRuntimeState( - state as unknown as ControlGroupSerializedState + ...controlGroupSavedObjectStateToSerializableRuntimeState( + state as unknown as ControlGroupSavedObjectState ), }; if (!controlGroupState) return controlGroupStats; diff --git a/src/plugins/controls/server/control_group/types.ts b/src/plugins/controls/server/control_group/types.ts new file mode 100644 index 0000000000000..9aa0aaddc4a12 --- /dev/null +++ b/src/plugins/controls/server/control_group/types.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { SerializableRecord } from '@kbn/utility-types'; +import { ControlGroupRuntimeState, ControlPanelState, SerializedControlState } from '../../common'; + +// using SerializableRecord to force type to be read as serializable +export type SerializableControlGroupState = SerializableRecord & + Omit< + ControlGroupRuntimeState, + 'initialChildControlState' | 'editorConfig' // editor config is not persisted + > & { + panels: Record> | {}; + }; + +export type ControlGroupSavedObjectState = SerializableRecord & { + chainingSystem: SerializableControlGroupState['chainingSystem']; + controlStyle: SerializableControlGroupState['labelPosition']; + showApplySelections: boolean; + ignoreParentSettingsJSON: string; + panelsJSON: string; +}; + +export interface ControlGroupTelemetry { + total: number; + chaining_system: { + [key: string]: number; + }; + label_position: { + [key: string]: number; + }; + ignore_settings: { + [key: string]: number; + }; + by_type: { + [key: string]: { + total: number; + details: { [key: string]: number }; + }; + }; +} diff --git a/src/plugins/controls/server/index.ts b/src/plugins/controls/server/index.ts index 541d9e2a46204..40261f8a3013e 100644 --- a/src/plugins/controls/server/index.ts +++ b/src/plugins/controls/server/index.ts @@ -13,10 +13,9 @@ export const plugin = async () => { }; export { - controlGroupSerializedStateToSerializableRuntimeState, - serializableRuntimeStateToControlGroupSerializedState, + controlGroupSavedObjectStateToSerializableRuntimeState, + serializableRuntimeStateToControlGroupSavedObjectState, } from './control_group/control_group_persistence'; -export { - type ControlGroupTelemetry, - initializeControlGroupTelemetry, -} from './control_group/control_group_telemetry'; +export { initializeControlGroupTelemetry } from './control_group/control_group_telemetry'; + +export type { ControlGroupTelemetry } from './control_group/types'; diff --git a/src/plugins/controls/tsconfig.json b/src/plugins/controls/tsconfig.json index e1040faecc1b0..41ab33dc18969 100644 --- a/src/plugins/controls/tsconfig.json +++ b/src/plugins/controls/tsconfig.json @@ -38,7 +38,7 @@ "@kbn/field-formats-plugin", "@kbn/presentation-panel-plugin", "@kbn/shared-ux-utility", - "@kbn/std" + "@kbn/std", ], "exclude": ["target/**/*"] } diff --git a/src/plugins/custom_integrations/kibana.jsonc b/src/plugins/custom_integrations/kibana.jsonc index fd8a429c0d666..b42bc6a932ea8 100644 --- a/src/plugins/custom_integrations/kibana.jsonc +++ b/src/plugins/custom_integrations/kibana.jsonc @@ -1,14 +1,18 @@ { "type": "plugin", "id": "@kbn/custom-integrations-plugin", - "owner": "@elastic/fleet", + "owner": [ + "@elastic/fleet" + ], + "group": "platform", + "visibility": "shared", "description": "Add custom data integrations so they can be displayed in the Fleet integrations app", "plugin": { "id": "customIntegrations", - "server": true, "browser": true, + "server": true, "extraPublicDirs": [ "common" ] } -} +} \ No newline at end of file diff --git a/src/plugins/dashboard/common/bwc/types.ts b/src/plugins/dashboard/common/bwc/types.ts index b1b97fa31485d..ae409d143656b 100644 --- a/src/plugins/dashboard/common/bwc/types.ts +++ b/src/plugins/dashboard/common/bwc/types.ts @@ -9,7 +9,7 @@ import type { SavedObjectReference } from '@kbn/core/public'; import type { Serializable } from '@kbn/utility-types'; -import { GridData } from '../content_management'; +import type { GridData } from '../../server/dashboard_saved_object'; interface KibanaAttributes { kibanaSavedObjectMeta: { diff --git a/src/plugins/dashboard/common/content_management/constants.ts b/src/plugins/dashboard/common/content_management/constants.ts index 29c679872a9e0..978271680af12 100644 --- a/src/plugins/dashboard/common/content_management/constants.ts +++ b/src/plugins/dashboard/common/content_management/constants.ts @@ -7,6 +7,18 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export const LATEST_VERSION = 2; +export const LATEST_VERSION = 3; export const CONTENT_ID = 'dashboard'; + +export const DASHBOARD_GRID_COLUMN_COUNT = 48; +export const DEFAULT_PANEL_WIDTH = DASHBOARD_GRID_COLUMN_COUNT / 2; +export const DEFAULT_PANEL_HEIGHT = 15; + +export const DEFAULT_DASHBOARD_OPTIONS = { + hidePanelTitles: false, + useMargins: true, + syncColors: true, + syncCursor: true, + syncTooltips: true, +} as const; diff --git a/src/plugins/dashboard/common/content_management/index.ts b/src/plugins/dashboard/common/content_management/index.ts index d87d65a61d4f0..b87b54520d7ab 100644 --- a/src/plugins/dashboard/common/content_management/index.ts +++ b/src/plugins/dashboard/common/content_management/index.ts @@ -7,14 +7,13 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { LATEST_VERSION, CONTENT_ID } from './constants'; +export { + LATEST_VERSION, + CONTENT_ID, + DASHBOARD_GRID_COLUMN_COUNT, + DEFAULT_PANEL_HEIGHT, + DEFAULT_PANEL_WIDTH, + DEFAULT_DASHBOARD_OPTIONS, +} from './constants'; export type { DashboardContentType } from './types'; - -export type { - GridData, - DashboardItem, - DashboardCrudTypes, - DashboardAttributes, - SavedDashboardPanel, -} from './latest'; diff --git a/src/plugins/dashboard/common/content_management/v1/types.ts b/src/plugins/dashboard/common/content_management/v1/types.ts index 9b7c2973d9713..3b3317c0bd13e 100644 --- a/src/plugins/dashboard/common/content_management/v1/types.ts +++ b/src/plugins/dashboard/common/content_management/v1/types.ts @@ -14,7 +14,7 @@ import type { } from '@kbn/content-management-utils'; import { Serializable } from '@kbn/utility-types'; import { RefreshInterval } from '@kbn/data-plugin/common'; -import { ControlGroupSerializedState } from '@kbn/controls-plugin/common'; +import { ControlGroupChainingSystem, ControlLabelPosition } from '@kbn/controls-plugin/common'; import { DashboardContentType } from '../types'; @@ -62,10 +62,13 @@ export interface SavedDashboardPanel { version?: string; } -type ControlGroupAttributesV1 = Pick< - ControlGroupSerializedState, - 'panelsJSON' | 'chainingSystem' | 'controlStyle' | 'ignoreParentSettingsJSON' ->; +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type ControlGroupAttributesV1 = { + chainingSystem?: ControlGroupChainingSystem; + panelsJSON: string; // stringified version of ControlSerializedState + ignoreParentSettingsJSON: string; + controlStyle?: ControlLabelPosition; +}; /* eslint-disable-next-line @typescript-eslint/consistent-type-definitions */ export type DashboardAttributes = { @@ -77,7 +80,7 @@ export type DashboardAttributes = { description: string; panelsJSON: string; timeFrom?: string; - version: number; + version?: number; timeTo?: string; title: string; kibanaSavedObjectMeta: { diff --git a/src/plugins/dashboard/common/content_management/v2/index.ts b/src/plugins/dashboard/common/content_management/v2/index.ts index b0b10669699cf..bd687ff0dd609 100644 --- a/src/plugins/dashboard/common/content_management/v2/index.ts +++ b/src/plugins/dashboard/common/content_management/v2/index.ts @@ -8,4 +8,4 @@ */ export type { GridData, DashboardItem, SavedDashboardPanel } from '../v1/types'; // no changes made to types from v1 to v2 -export type { DashboardCrudTypes, DashboardAttributes } from './types'; +export type { ControlGroupAttributes, DashboardCrudTypes, DashboardAttributes } from './types'; diff --git a/src/plugins/dashboard/common/content_management/v2/types.ts b/src/plugins/dashboard/common/content_management/v2/types.ts index 3f009b749a2ab..ae2c2a798d813 100644 --- a/src/plugins/dashboard/common/content_management/v2/types.ts +++ b/src/plugins/dashboard/common/content_management/v2/types.ts @@ -12,21 +12,18 @@ import type { SavedObjectCreateOptions, SavedObjectUpdateOptions, } from '@kbn/content-management-utils'; -import { ControlGroupSerializedState } from '@kbn/controls-plugin/common'; import { DashboardContentType } from '../types'; -import { DashboardAttributes as DashboardAttributesV1 } from '../v1/types'; +import { + ControlGroupAttributesV1, + DashboardAttributes as DashboardAttributesV1, +} from '../v1/types'; -type ControlGroupAttributesV2 = Pick< - ControlGroupSerializedState, - | 'panelsJSON' - | 'chainingSystem' - | 'controlStyle' - | 'ignoreParentSettingsJSON' - | 'showApplySelections' ->; +export type ControlGroupAttributes = ControlGroupAttributesV1 & { + showApplySelections?: boolean; +}; export type DashboardAttributes = Omit & { - controlGroupInput?: ControlGroupAttributesV2; + controlGroupInput?: ControlGroupAttributes; }; export type DashboardCrudTypes = ContentManagementCrudTypes< diff --git a/src/plugins/dashboard/common/dashboard_container/types.ts b/src/plugins/dashboard/common/dashboard_container/types.ts index bcb7670f18e12..dd3f7302038c0 100644 --- a/src/plugins/dashboard/common/dashboard_container/types.ts +++ b/src/plugins/dashboard/common/dashboard_container/types.ts @@ -18,8 +18,7 @@ import type { Reference } from '@kbn/content-management-utils'; import { RefreshInterval } from '@kbn/data-plugin/common'; import { KibanaExecutionContext } from '@kbn/core-execution-context-common'; -import { DashboardOptions } from '../types'; -import { GridData } from '../content_management'; +import type { DashboardOptions, GridData } from '../../server/content_management'; export interface DashboardPanelMap { [key: string]: DashboardPanelState; diff --git a/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.test.ts b/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.test.ts index 689db61b0cb27..e9bd6aff0fe12 100644 --- a/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.test.ts +++ b/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.test.ts @@ -18,7 +18,8 @@ import { createInject, } from '../../dashboard_container/persistable_state/dashboard_container_references'; import { createEmbeddablePersistableStateServiceMock } from '@kbn/embeddable-plugin/common/mocks'; -import { DashboardAttributes } from '../../content_management'; +import type { DashboardAttributes, DashboardItem } from '../../../server/content_management'; +import { DashboardAttributesAndReferences } from '../../types'; const embeddablePersistableStateServiceMock = createEmbeddablePersistableStateServiceMock(); const dashboardInject = createInject(embeddablePersistableStateServiceMock); @@ -44,28 +45,37 @@ const deps: InjectExtractDeps = { }; const commonAttributes: DashboardAttributes = { - kibanaSavedObjectMeta: { searchSourceJSON: '' }, + kibanaSavedObjectMeta: { searchSource: {} }, timeRestore: false, - panelsJSON: '', version: 1, + options: { + hidePanelTitles: false, + useMargins: true, + syncColors: true, + syncCursor: true, + syncTooltips: true, + }, + panels: [], description: '', title: '', }; describe('extractReferences', () => { - test('extracts references from panelsJSON', () => { + test('extracts references from panels', () => { const doc = { id: '1', attributes: { ...commonAttributes, foo: true, - panelsJSON: JSON.stringify([ + panels: [ { panelIndex: 'panel-1', type: 'visualization', id: '1', title: 'Title 1', version: '7.9.1', + gridData: { x: 0, y: 0, w: 1, h: 1, i: 'panel-1' }, + panelConfig: {}, }, { panelIndex: 'panel-2', @@ -73,8 +83,10 @@ describe('extractReferences', () => { id: '2', title: 'Title 2', version: '7.9.1', + gridData: { x: 1, y: 1, w: 2, h: 2, i: 'panel-2' }, + panelConfig: {}, }, - ]), + ], }, references: [], }; @@ -86,9 +98,47 @@ describe('extractReferences', () => { "description": "", "foo": true, "kibanaSavedObjectMeta": Object { - "searchSourceJSON": "", + "searchSource": Object {}, + }, + "options": Object { + "hidePanelTitles": false, + "syncColors": true, + "syncCursor": true, + "syncTooltips": true, + "useMargins": true, }, - "panelsJSON": "[{\\"version\\":\\"7.9.1\\",\\"type\\":\\"visualization\\",\\"panelIndex\\":\\"panel-1\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 1\\",\\"panelRefName\\":\\"panel_panel-1\\"},{\\"version\\":\\"7.9.1\\",\\"type\\":\\"visualization\\",\\"panelIndex\\":\\"panel-2\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 2\\",\\"panelRefName\\":\\"panel_panel-2\\"}]", + "panels": Array [ + Object { + "gridData": Object { + "h": 1, + "i": "panel-1", + "w": 1, + "x": 0, + "y": 0, + }, + "panelConfig": Object {}, + "panelIndex": "panel-1", + "panelRefName": "panel_panel-1", + "title": "Title 1", + "type": "visualization", + "version": "7.9.1", + }, + Object { + "gridData": Object { + "h": 2, + "i": "panel-2", + "w": 2, + "x": 1, + "y": 1, + }, + "panelConfig": Object {}, + "panelIndex": "panel-2", + "panelRefName": "panel_panel-2", + "title": "Title 2", + "type": "visualization", + "version": "7.9.1", + }, + ], "timeRestore": false, "title": "", "version": 1, @@ -115,18 +165,18 @@ describe('extractReferences', () => { attributes: { ...commonAttributes, foo: true, - panelsJSON: JSON.stringify([ + panels: [ { id: '1', title: 'Title 1', version: '7.9.1', }, - ]), + ], }, references: [], - }; + } as unknown as DashboardAttributesAndReferences; expect(() => extractReferences(doc, deps)).toThrowErrorMatchingInlineSnapshot( - `"\\"type\\" attribute is missing from panel \\"undefined\\""` + `"\\"type\\" attribute is missing from panel \\"0\\""` ); }); @@ -136,25 +186,49 @@ describe('extractReferences', () => { attributes: { ...commonAttributes, foo: true, - panelsJSON: JSON.stringify([ + panels: [ { type: 'visualization', title: 'Title 1', version: '7.9.1', + gridData: { x: 0, y: 0, w: 1, h: 1, i: 'panel-1' }, + panelConfig: {}, }, - ]), + ], }, references: [], }; - expect(extractReferences(doc, deps)).toMatchInlineSnapshot(` + expect(extractReferences(doc as unknown as DashboardItem, deps)).toMatchInlineSnapshot(` Object { "attributes": Object { "description": "", "foo": true, "kibanaSavedObjectMeta": Object { - "searchSourceJSON": "", + "searchSource": Object {}, + }, + "options": Object { + "hidePanelTitles": false, + "syncColors": true, + "syncCursor": true, + "syncTooltips": true, + "useMargins": true, }, - "panelsJSON": "[{\\"version\\":\\"7.9.1\\",\\"type\\":\\"visualization\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 1\\"}]", + "panels": Array [ + Object { + "gridData": Object { + "h": 1, + "i": "panel-1", + "w": 1, + "x": 0, + "y": 0, + }, + "panelConfig": Object {}, + "panelIndex": "0", + "title": "Title 1", + "type": "visualization", + "version": "7.9.1", + }, + ], "timeRestore": false, "title": "", "version": 1, @@ -171,18 +245,26 @@ describe('injectReferences', () => { ...commonAttributes, id: '1', title: 'test', - panelsJSON: JSON.stringify([ + panels: [ { + type: 'visualization', panelRefName: 'panel_0', + panelIndex: '0', title: 'Title 1', version: '7.9.0', + gridData: { x: 0, y: 0, w: 1, h: 1, i: '0' }, + panelConfig: {}, }, { + type: 'visualization', panelRefName: 'panel_1', + panelIndex: '1', title: 'Title 2', version: '7.9.0', + gridData: { x: 1, y: 1, w: 2, h: 2, i: '1' }, + panelConfig: {}, }, - ]), + ], }; const references = [ { @@ -203,9 +285,47 @@ describe('injectReferences', () => { "description": "", "id": "1", "kibanaSavedObjectMeta": Object { - "searchSourceJSON": "", + "searchSource": Object {}, }, - "panelsJSON": "[{\\"version\\":\\"7.9.0\\",\\"type\\":\\"visualization\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 1\\",\\"id\\":\\"1\\"},{\\"version\\":\\"7.9.0\\",\\"type\\":\\"visualization\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 2\\",\\"id\\":\\"2\\"}]", + "options": Object { + "hidePanelTitles": false, + "syncColors": true, + "syncCursor": true, + "syncTooltips": true, + "useMargins": true, + }, + "panels": Array [ + Object { + "gridData": Object { + "h": 1, + "i": "0", + "w": 1, + "x": 0, + "y": 0, + }, + "id": "1", + "panelConfig": Object {}, + "panelIndex": "0", + "title": "Title 1", + "type": "visualization", + "version": "7.9.0", + }, + Object { + "gridData": Object { + "h": 2, + "i": "1", + "w": 2, + "x": 1, + "y": 1, + }, + "id": "2", + "panelConfig": Object {}, + "panelIndex": "1", + "title": "Title 2", + "type": "visualization", + "version": "7.9.0", + }, + ], "timeRestore": false, "title": "test", "version": 1, @@ -213,7 +333,7 @@ describe('injectReferences', () => { `); }); - test('skips when panelsJSON is missing', () => { + test('skips when panels is missing', () => { const attributes = { id: '1', title: 'test', @@ -222,31 +342,8 @@ describe('injectReferences', () => { expect(newAttributes).toMatchInlineSnapshot(` Object { "id": "1", - "panelsJSON": "[]", - "title": "test", - } - `); - }); - - test('skips when panelsJSON is not an array', () => { - const attributes = { - ...commonAttributes, - id: '1', - panelsJSON: '{}', - title: 'test', - }; - const newAttributes = injectReferences({ attributes, references: [] }, deps); - expect(newAttributes).toMatchInlineSnapshot(` - Object { - "description": "", - "id": "1", - "kibanaSavedObjectMeta": Object { - "searchSourceJSON": "", - }, - "panelsJSON": "[]", - "timeRestore": false, + "panels": Array [], "title": "test", - "version": 1, } `); }); @@ -256,15 +353,23 @@ describe('injectReferences', () => { ...commonAttributes, id: '1', title: 'test', - panelsJSON: JSON.stringify([ + panels: [ { + type: 'visualization', panelRefName: 'panel_0', + panelIndex: '0', title: 'Title 1', + gridData: { x: 0, y: 0, w: 1, h: 1, i: '0' }, + panelConfig: {}, }, { + type: 'visualization', + panelIndex: '1', title: 'Title 2', + gridData: { x: 1, y: 1, w: 2, h: 2, i: '1' }, + panelConfig: {}, }, - ]), + ], }; const references = [ { @@ -279,9 +384,46 @@ describe('injectReferences', () => { "description": "", "id": "1", "kibanaSavedObjectMeta": Object { - "searchSourceJSON": "", + "searchSource": Object {}, + }, + "options": Object { + "hidePanelTitles": false, + "syncColors": true, + "syncCursor": true, + "syncTooltips": true, + "useMargins": true, }, - "panelsJSON": "[{\\"type\\":\\"visualization\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 1\\",\\"id\\":\\"1\\"},{\\"embeddableConfig\\":{},\\"title\\":\\"Title 2\\"}]", + "panels": Array [ + Object { + "gridData": Object { + "h": 1, + "i": "0", + "w": 1, + "x": 0, + "y": 0, + }, + "id": "1", + "panelConfig": Object {}, + "panelIndex": "0", + "title": "Title 1", + "type": "visualization", + "version": undefined, + }, + Object { + "gridData": Object { + "h": 2, + "i": "1", + "w": 2, + "x": 1, + "y": 1, + }, + "panelConfig": Object {}, + "panelIndex": "1", + "title": "Title 2", + "type": "visualization", + "version": undefined, + }, + ], "timeRestore": false, "title": "test", "version": 1, @@ -294,12 +436,16 @@ describe('injectReferences', () => { ...commonAttributes, id: '1', title: 'test', - panelsJSON: JSON.stringify([ + panels: [ { + panelIndex: '0', panelRefName: 'panel_0', title: 'Title 1', + type: 'visualization', + gridData: { x: 0, y: 0, w: 1, h: 1, i: '0' }, + panelConfig: {}, }, - ]), + ], }; expect(() => injectReferences({ attributes, references: [] }, deps) diff --git a/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.ts b/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.ts index 1ede56a2b67a7..9b9290accb513 100644 --- a/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.ts +++ b/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.ts @@ -11,11 +11,11 @@ import type { Reference } from '@kbn/content-management-utils'; import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common/types'; import { - convertPanelMapToSavedPanels, - convertSavedPanelsToPanelMap, + convertPanelMapToPanelsArray, + convertPanelsArrayToPanelMap, } from '../../lib/dashboard_panel_converters'; import { DashboardAttributesAndReferences, ParsedDashboardAttributesWithType } from '../../types'; -import { DashboardAttributes, SavedDashboardPanel } from '../../content_management'; +import type { DashboardAttributes } from '../../../server/content_management'; import { createExtract, createInject, @@ -25,20 +25,12 @@ export interface InjectExtractDeps { embeddablePersistableStateService: EmbeddablePersistableStateService; } -function parseDashboardAttributesWithType( - attributes: DashboardAttributes -): ParsedDashboardAttributesWithType { - let parsedPanels = [] as SavedDashboardPanel[]; - if (typeof attributes.panelsJSON === 'string') { - const parsedJSON = JSON.parse(attributes.panelsJSON); - if (Array.isArray(parsedJSON)) { - parsedPanels = parsedJSON as SavedDashboardPanel[]; - } - } - +function parseDashboardAttributesWithType({ + panels, +}: DashboardAttributes): ParsedDashboardAttributesWithType { return { type: 'dashboard', - panels: convertSavedPanelsToPanelMap(parsedPanels), + panels: convertPanelsArrayToPanelMap(panels), } as ParsedDashboardAttributesWithType; } @@ -51,12 +43,12 @@ export function injectReferences( // inject references back into panels via the Embeddable persistable state service. const inject = createInject(deps.embeddablePersistableStateService); const injectedState = inject(parsedAttributes, references) as ParsedDashboardAttributesWithType; - const injectedPanels = convertPanelMapToSavedPanels(injectedState.panels); + const injectedPanels = convertPanelMapToPanelsArray(injectedState.panels); const newAttributes = { ...attributes, - panelsJSON: JSON.stringify(injectedPanels), - } as DashboardAttributes; + panels: injectedPanels, + }; return newAttributes; } @@ -81,12 +73,12 @@ export function extractReferences( references: Reference[]; state: ParsedDashboardAttributesWithType; }; - const extractedPanels = convertPanelMapToSavedPanels(extractedState.panels); + const extractedPanels = convertPanelMapToPanelsArray(extractedState.panels); const newAttributes = { ...attributes, - panelsJSON: JSON.stringify(extractedPanels), - } as DashboardAttributes; + panels: extractedPanels, + }; return { references: [...references, ...extractedReferences], diff --git a/src/plugins/dashboard/common/index.ts b/src/plugins/dashboard/common/index.ts index 8de0c49c41eec..be2cedf889e85 100644 --- a/src/plugins/dashboard/common/index.ts +++ b/src/plugins/dashboard/common/index.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export type { DashboardOptions, DashboardCapabilities, SharedDashboardState } from './types'; +export type { DashboardCapabilities, SharedDashboardState } from './types'; export type { DashboardPanelMap, @@ -16,9 +16,8 @@ export type { DashboardContainerByReferenceInput, } from './dashboard_container/types'; -export type { DashboardAttributes, SavedDashboardPanel } from './content_management'; - export { + type InjectExtractDeps, injectReferences, extractReferences, } from './dashboard_saved_object/persistable_state/dashboard_saved_object_references'; @@ -31,10 +30,8 @@ export { export { prefixReferencesFromPanel } from './dashboard_container/persistable_state/dashboard_container_references'; export { - convertPanelStateToSavedDashboardPanel, - convertSavedDashboardPanelToPanelState, - convertSavedPanelsToPanelMap, - convertPanelMapToSavedPanels, + convertPanelsArrayToPanelMap, + convertPanelMapToPanelsArray, } from './lib/dashboard_panel_converters'; export const UI_SETTINGS = { diff --git a/src/plugins/dashboard/common/lib/dashboard_panel_converters.ts b/src/plugins/dashboard/common/lib/dashboard_panel_converters.ts index a8c2d1f7c7b87..67317083b445d 100644 --- a/src/plugins/dashboard/common/lib/dashboard_panel_converters.ts +++ b/src/plugins/dashboard/common/lib/dashboard_panel_converters.ts @@ -9,77 +9,63 @@ import { v4 } from 'uuid'; import { omit } from 'lodash'; -import { EmbeddableInput, SavedObjectEmbeddableInput } from '@kbn/embeddable-plugin/common'; +import type { SavedObjectEmbeddableInput } from '@kbn/embeddable-plugin/common'; import type { Reference } from '@kbn/content-management-utils'; -import { DashboardPanelMap, DashboardPanelState } from '..'; -import { SavedDashboardPanel } from '../content_management'; +import type { DashboardPanelMap } from '..'; +import type { DashboardPanel } from '../../server/content_management'; + import { getReferencesForPanelId, prefixReferencesFromPanel, } from '../dashboard_container/persistable_state/dashboard_container_references'; -export function convertSavedDashboardPanelToPanelState< - TEmbeddableInput extends EmbeddableInput | SavedObjectEmbeddableInput = SavedObjectEmbeddableInput ->(savedDashboardPanel: SavedDashboardPanel): DashboardPanelState { - return { - type: savedDashboardPanel.type, - gridData: savedDashboardPanel.gridData, - panelRefName: savedDashboardPanel.panelRefName, - explicitInput: { - id: savedDashboardPanel.panelIndex, - ...(savedDashboardPanel.id !== undefined && { savedObjectId: savedDashboardPanel.id }), - ...(savedDashboardPanel.title !== undefined && { title: savedDashboardPanel.title }), - ...savedDashboardPanel.embeddableConfig, - } as TEmbeddableInput, - - /** - * Version information used to be stored in the panel until 8.11 when it was moved - * to live inside the explicit Embeddable Input. If version information is given here, we'd like to keep it. - * It will be removed on Dashboard save - */ - version: savedDashboardPanel.version, - }; -} - -export function convertPanelStateToSavedDashboardPanel( - panelState: DashboardPanelState, - removeLegacyVersion?: boolean -): SavedDashboardPanel { - const savedObjectId = (panelState.explicitInput as SavedObjectEmbeddableInput).savedObjectId; - return { - /** - * Version information used to be stored in the panel until 8.11 when it was moved to live inside the - * explicit Embeddable Input. If removeLegacyVersion is not passed, we'd like to keep this information for - * the time being. - */ - ...(!removeLegacyVersion ? { version: panelState.version } : {}), - - type: panelState.type, - gridData: panelState.gridData, - panelIndex: panelState.explicitInput.id, - embeddableConfig: omit(panelState.explicitInput, ['id', 'savedObjectId', 'title']), - ...(panelState.explicitInput.title !== undefined && { title: panelState.explicitInput.title }), - ...(savedObjectId !== undefined && { id: savedObjectId }), - ...(panelState.panelRefName !== undefined && { panelRefName: panelState.panelRefName }), - }; -} - -export const convertSavedPanelsToPanelMap = (panels?: SavedDashboardPanel[]): DashboardPanelMap => { +export const convertPanelsArrayToPanelMap = (panels?: DashboardPanel[]): DashboardPanelMap => { const panelsMap: DashboardPanelMap = {}; panels?.forEach((panel, idx) => { - panelsMap![panel.panelIndex ?? String(idx)] = convertSavedDashboardPanelToPanelState(panel); + const panelIndex = panel.panelIndex ?? String(idx); + panelsMap![panel.panelIndex ?? String(idx)] = { + type: panel.type, + gridData: panel.gridData, + panelRefName: panel.panelRefName, + explicitInput: { + id: panelIndex, + ...(panel.id !== undefined && { savedObjectId: panel.id }), + ...(panel.title !== undefined && { title: panel.title }), + ...panel.panelConfig, + }, + version: panel.version, + }; }); return panelsMap; }; -export const convertPanelMapToSavedPanels = ( +export const convertPanelMapToPanelsArray = ( panels: DashboardPanelMap, removeLegacyVersion?: boolean ) => { - return Object.values(panels).map((panel) => - convertPanelStateToSavedDashboardPanel(panel, removeLegacyVersion) - ); + return Object.values(panels).map((panelState) => { + const savedObjectId = (panelState.explicitInput as SavedObjectEmbeddableInput).savedObjectId; + const panelIndex = panelState.explicitInput.id; + return { + /** + * Version information used to be stored in the panel until 8.11 when it was moved to live inside the + * explicit Embeddable Input. If removeLegacyVersion is not passed, we'd like to keep this information for + * the time being. + */ + ...(!removeLegacyVersion ? { version: panelState.version } : {}), + + type: panelState.type, + gridData: panelState.gridData, + panelIndex, + panelConfig: omit(panelState.explicitInput, ['id', 'savedObjectId', 'title']), + ...(panelState.explicitInput.title !== undefined && { + title: panelState.explicitInput.title, + }), + ...(savedObjectId !== undefined && { id: savedObjectId }), + ...(panelState.panelRefName !== undefined && { panelRefName: panelState.panelRefName }), + }; + }); }; /** diff --git a/src/plugins/dashboard/common/types.ts b/src/plugins/dashboard/common/types.ts index b3b4b1e983b29..c8ecc237ed348 100644 --- a/src/plugins/dashboard/common/types.ts +++ b/src/plugins/dashboard/common/types.ts @@ -8,17 +8,9 @@ */ import type { Reference } from '@kbn/content-management-utils'; -import { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common'; -import { DashboardAttributes, SavedDashboardPanel } from './content_management'; -import { DashboardContainerInput, DashboardPanelMap } from './dashboard_container/types'; - -export interface DashboardOptions { - hidePanelTitles: boolean; - useMargins: boolean; - syncColors: boolean; - syncTooltips: boolean; - syncCursor: boolean; -} +import type { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common'; +import type { DashboardContainerInput, DashboardPanelMap } from './dashboard_container/types'; +import type { DashboardAttributes, DashboardPanel } from '../server/content_management'; export interface DashboardCapabilities { showWriteControls: boolean; @@ -32,7 +24,7 @@ export interface DashboardCapabilities { * For BWC reasons, dashboard state is stored with panels as an array instead of a map */ export type SharedDashboardState = Partial< - Omit & { panels: SavedDashboardPanel[] } + Omit & { panels: DashboardPanel[] } >; /** diff --git a/src/plugins/dashboard/kibana.jsonc b/src/plugins/dashboard/kibana.jsonc index d7b0f2c16e04b..9d47ab95c8872 100644 --- a/src/plugins/dashboard/kibana.jsonc +++ b/src/plugins/dashboard/kibana.jsonc @@ -1,17 +1,22 @@ { "type": "plugin", "id": "@kbn/dashboard-plugin", - "owner": "@elastic/kibana-presentation", + "owner": [ + "@elastic/kibana-presentation" + ], + "group": "platform", + "visibility": "shared", "description": "Adds the Dashboard app to Kibana", "plugin": { "id": "dashboard", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "data", "dataViews", "dataViewEditor", "embeddable", + "fieldFormats", "controls", "inspector", "navigation", @@ -46,4 +51,4 @@ "savedObjects" ] } -} +} \ No newline at end of file diff --git a/src/plugins/dashboard/public/dashboard_app/_dashboard_app_strings.ts b/src/plugins/dashboard/public/dashboard_app/_dashboard_app_strings.ts index 8a6e24a183f3a..9c6526ce3403e 100644 --- a/src/plugins/dashboard/public/dashboard_app/_dashboard_app_strings.ts +++ b/src/plugins/dashboard/public/dashboard_app/_dashboard_app_strings.ts @@ -78,21 +78,6 @@ export const unsavedChangesBadgeStrings = { }), }; -export const leaveConfirmStrings = { - getLeaveTitle: () => - i18n.translate('dashboard.appLeaveConfirmModal.unsavedChangesTitle', { - defaultMessage: 'Unsaved changes', - }), - getLeaveSubtitle: () => - i18n.translate('dashboard.appLeaveConfirmModal.unsavedChangesSubtitle', { - defaultMessage: 'Leave Dashboard with unsaved work?', - }), - getLeaveCancelButtonText: () => - i18n.translate('dashboard.appLeaveConfirmModal.cancelButtonLabel', { - defaultMessage: 'Cancel', - }), -}; - export const getCreateVisualizationButtonTitle = () => i18n.translate('dashboard.solutionToolbar.addPanelButtonLabel', { defaultMessage: 'Create visualization', diff --git a/src/plugins/dashboard/public/dashboard_app/hooks/use_observability_ai_assistant_context.tsx b/src/plugins/dashboard/public/dashboard_app/hooks/use_observability_ai_assistant_context.tsx index c20e8fcd1dc76..39ae4594d5bc8 100644 --- a/src/plugins/dashboard/public/dashboard_app/hooks/use_observability_ai_assistant_context.tsx +++ b/src/plugins/dashboard/public/dashboard_app/hooks/use_observability_ai_assistant_context.tsx @@ -114,9 +114,11 @@ export function useObservabilityAIAssistantContext({ }, metric: { type: 'object', + properties: {}, }, gauge: { type: 'object', + properties: {}, }, pie: { type: 'object', @@ -158,6 +160,7 @@ export function useObservabilityAIAssistantContext({ }, table: { type: 'object', + properties: {}, }, tagcloud: { type: 'object', diff --git a/src/plugins/dashboard/public/dashboard_app/locator/load_dashboard_history_location_state.ts b/src/plugins/dashboard/public/dashboard_app/locator/load_dashboard_history_location_state.ts index 73832625cf11f..99aa14fe6225f 100644 --- a/src/plugins/dashboard/public/dashboard_app/locator/load_dashboard_history_location_state.ts +++ b/src/plugins/dashboard/public/dashboard_app/locator/load_dashboard_history_location_state.ts @@ -10,7 +10,7 @@ import { ScopedHistory } from '@kbn/core-application-browser'; import { ForwardedDashboardState } from './locator'; -import { convertSavedPanelsToPanelMap, DashboardContainerInput } from '../../../common'; +import { convertPanelsArrayToPanelMap, DashboardContainerInput } from '../../../common'; export const loadDashboardHistoryLocationState = ( getScopedHistory: () => ScopedHistory @@ -28,6 +28,6 @@ export const loadDashboardHistoryLocationState = ( return { ...restOfState, - ...{ panels: convertSavedPanelsToPanelMap(panels) }, + ...{ panels: convertPanelsArrayToPanelMap(panels) }, }; }; diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.test.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.test.tsx index 41c4a55f6ab8d..de7a1584dc9bf 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.test.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.test.tsx @@ -8,7 +8,7 @@ */ import { Capabilities } from '@kbn/core/public'; -import { convertPanelMapToSavedPanels, DashboardContainerInput } from '../../../../common'; +import { convertPanelMapToPanelsArray, DashboardContainerInput } from '../../../../common'; import { DashboardLocatorParams } from '../../../dashboard_container'; import { shareService } from '../../../services/kibana_services'; @@ -143,7 +143,7 @@ describe('ShowShareModal', () => { ).locatorParams.params; const rawDashboardState = { ...unsavedDashboardState, - panels: convertPanelMapToSavedPanels(unsavedDashboardState.panels), + panels: convertPanelMapToPanelsArray(unsavedDashboardState.panels), }; unsavedStateKeys.forEach((key) => { expect(shareLocatorParams[key]).toStrictEqual( @@ -208,8 +208,8 @@ describe('ShowShareModal', () => { ).locatorParams.params; expect(shareLocatorParams.panels).toBeDefined(); - expect(shareLocatorParams.panels![0].embeddableConfig.changedKey1).toBe('changed'); - expect(shareLocatorParams.panels![1].embeddableConfig.changedKey2).toBe('definitely changed'); - expect(shareLocatorParams.panels![2].embeddableConfig.changedKey3).toBe('should still exist'); + expect(shareLocatorParams.panels![0].panelConfig.changedKey1).toBe('changed'); + expect(shareLocatorParams.panels![1].panelConfig.changedKey2).toBe('definitely changed'); + expect(shareLocatorParams.panels![2].panelConfig.changedKey3).toBe('should still exist'); }); }); diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx index 5dd56465de920..2e3690e40d4ee 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx @@ -19,7 +19,7 @@ import { ViewMode } from '@kbn/embeddable-plugin/public'; import { i18n } from '@kbn/i18n'; import { getStateFromKbnUrl, setStateToKbnUrl, unhashUrl } from '@kbn/kibana-utils-plugin/public'; -import { convertPanelMapToSavedPanels, DashboardPanelMap } from '../../../../common'; +import { convertPanelMapToPanelsArray, DashboardPanelMap } from '../../../../common'; import { DashboardLocatorParams } from '../../../dashboard_container'; import { getDashboardBackupService, @@ -151,7 +151,7 @@ export function ShowShareModal({ ...latestPanels, ...modifiedPanels, }; - return convertPanelMapToSavedPanels(allUnsavedPanelsMap); + return convertPanelMapToPanelsArray(allUnsavedPanelsMap); })(); if (unsavedDashboardState) { diff --git a/src/plugins/dashboard/public/dashboard_app/url/search_sessions_integration.ts b/src/plugins/dashboard/public/dashboard_app/url/search_sessions_integration.ts index e9ae3d6a15050..0fc8ce7173e6f 100644 --- a/src/plugins/dashboard/public/dashboard_app/url/search_sessions_integration.ts +++ b/src/plugins/dashboard/public/dashboard_app/url/search_sessions_integration.ts @@ -22,7 +22,7 @@ import type { ViewMode } from '@kbn/embeddable-plugin/common'; import { DASHBOARD_APP_LOCATOR } from '@kbn/deeplinks-analytics'; import { SEARCH_SESSION_ID } from '../../dashboard_constants'; import { DashboardLocatorParams } from '../../dashboard_container'; -import { convertPanelMapToSavedPanels } from '../../../common'; +import { convertPanelMapToPanelsArray } from '../../../common'; import { dataService } from '../../services/kibana_services'; import { DashboardApi } from '../../dashboard_api/types'; @@ -93,7 +93,7 @@ function getLocatorParams({ : undefined, panels: savedObjectId ? undefined - : (convertPanelMapToSavedPanels( + : (convertPanelMapToPanelsArray( dashboardApi.panels$.value ) as DashboardLocatorParams['panels']), }; diff --git a/src/plugins/dashboard/public/dashboard_app/url/url_utils.ts b/src/plugins/dashboard/public/dashboard_app/url/url_utils.ts index b748909eac9ac..87faf87b026f8 100644 --- a/src/plugins/dashboard/public/dashboard_app/url/url_utils.ts +++ b/src/plugins/dashboard/public/dashboard_app/url/url_utils.ts @@ -19,24 +19,29 @@ import { DashboardContainerInput, DashboardPanelMap, SharedDashboardState, - convertSavedPanelsToPanelMap, + convertPanelsArrayToPanelMap, } from '../../../common'; -import { SavedDashboardPanel } from '../../../common/content_management'; +import type { DashboardPanel } from '../../../server/content_management'; +import type { SavedDashboardPanel } from '../../../server/dashboard_saved_object'; import { DashboardApi } from '../../dashboard_api/types'; import { DASHBOARD_STATE_STORAGE_KEY, createDashboardEditUrl } from '../../dashboard_constants'; import { migrateLegacyQuery } from '../../services/dashboard_content_management_service/lib/load_dashboard_state'; import { coreServices } from '../../services/kibana_services'; import { getPanelTooOldErrorString } from '../_dashboard_app_strings'; +const panelIsLegacy = (panel: unknown): panel is SavedDashboardPanel => { + return (panel as SavedDashboardPanel).embeddableConfig !== undefined; +}; + /** * We no longer support loading panels from a version older than 7.3 in the URL. * @returns whether or not there is a panel in the URL state saved with a version before 7.3 */ -export const isPanelVersionTooOld = (panels: SavedDashboardPanel[]) => { +export const isPanelVersionTooOld = (panels: DashboardPanel[] | SavedDashboardPanel[]) => { for (const panel of panels) { if ( !panel.gridData || - !panel.embeddableConfig || + !((panel as DashboardPanel).panelConfig || (panel as SavedDashboardPanel).embeddableConfig) || (panel.version && semverSatisfies(panel.version, '<7.3')) ) return true; @@ -58,7 +63,19 @@ function getPanelsMap(appStateInUrl: SharedDashboardState): DashboardPanelMap | return undefined; } - return convertSavedPanelsToPanelMap(appStateInUrl.panels); + // convert legacy embeddableConfig keys to panelConfig + const panels = appStateInUrl.panels.map((panel) => { + if (panelIsLegacy(panel)) { + const { embeddableConfig, ...rest } = panel; + return { + ...rest, + panelConfig: embeddableConfig, + }; + } + return panel; + }); + + return convertPanelsArrayToPanelMap(panels); } /** diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/api/run_save_functions.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/api/run_save_functions.tsx index 444bac28c9e66..e5355bdb2988c 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/api/run_save_functions.tsx +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/api/run_save_functions.tsx @@ -27,6 +27,7 @@ import { DashboardPanelMap, prefixReferencesFromPanel, } from '../../../../common'; +import type { DashboardAttributes } from '../../../../server/content_management'; import { DASHBOARD_CONTENT_ID, SAVED_OBJECT_POST_TIME } from '../../../dashboard_constants'; import { SaveDashboardReturn, @@ -95,7 +96,11 @@ export async function runQuickSave(this: DashboardContainer) { const { rawState: controlGroupSerializedState, references: extractedReferences } = await controlGroupApi.serializeState(); controlGroupReferences = extractedReferences; - stateToSave = { ...stateToSave, controlGroupInput: controlGroupSerializedState }; + stateToSave = { + ...stateToSave, + controlGroupInput: + controlGroupSerializedState as unknown as DashboardAttributes['controlGroupInput'], + }; } const saveResult = await getDashboardContentManagementService().saveDashboardState({ @@ -186,7 +191,8 @@ export async function runInteractiveSave(this: DashboardContainer, interactionMo controlGroupReferences = references; dashboardStateToSave = { ...dashboardStateToSave, - controlGroupInput: controlGroupSerializedState, + controlGroupInput: + controlGroupSerializedState as unknown as DashboardAttributes['controlGroupInput'], }; } diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx index a6765732c064c..35137075befe4 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx @@ -25,7 +25,7 @@ import { v4 } from 'uuid'; import { METRIC_TYPE } from '@kbn/analytics'; import type { Reference } from '@kbn/content-management-utils'; -import { ControlGroupApi, ControlGroupSerializedState } from '@kbn/controls-plugin/public'; +import { ControlGroupApi } from '@kbn/controls-plugin/public'; import type { KibanaExecutionContext, OverlayRef } from '@kbn/core/public'; import { RefreshInterval } from '@kbn/data-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/public'; @@ -69,12 +69,8 @@ import { LocatorPublic } from '@kbn/share-plugin/common'; import { ExitFullScreenButtonKibanaProvider } from '@kbn/shared-ux-button-exit-full-screen'; import { DASHBOARD_CONTAINER_TYPE, DashboardApi, DashboardLocatorParams } from '../..'; -import { - DashboardAttributes, - DashboardContainerInput, - DashboardPanelMap, - DashboardPanelState, -} from '../../../common'; +import type { DashboardAttributes } from '../../../server/content_management'; +import { DashboardContainerInput, DashboardPanelMap, DashboardPanelState } from '../../../common'; import { getReferencesForControls, getReferencesForPanelId, @@ -887,15 +883,19 @@ export class DashboardContainer public getSerializedStateForControlGroup = () => { return { rawState: this.controlGroupInput - ? (this.controlGroupInput as ControlGroupSerializedState) - : ({ - controlStyle: 'oneLine', + ? this.controlGroupInput + : { + labelPosition: 'oneLine', chainingSystem: 'HIERARCHICAL', - showApplySelections: false, - panelsJSON: '{}', - ignoreParentSettingsJSON: - '{"ignoreFilters":false,"ignoreQuery":false,"ignoreTimerange":false,"ignoreValidations":false}', - } as ControlGroupSerializedState), + autoApplySelections: true, + controls: [], + ignoreParentSettings: { + ignoreFilters: false, + ignoreQuery: false, + ignoreTimerange: false, + ignoreValidations: false, + }, + }, references: getReferencesForControls(this.savedObjectReferences), }; }; diff --git a/src/plugins/dashboard/public/dashboard_container/panel_placement/place_clone_panel_strategy.ts b/src/plugins/dashboard/public/dashboard_container/panel_placement/place_clone_panel_strategy.ts index 8386df50717f3..bdf5a39df34b8 100644 --- a/src/plugins/dashboard/public/dashboard_container/panel_placement/place_clone_panel_strategy.ts +++ b/src/plugins/dashboard/public/dashboard_container/panel_placement/place_clone_panel_strategy.ts @@ -11,7 +11,7 @@ import { cloneDeep, forOwn } from 'lodash'; import { PanelNotFoundError } from '@kbn/embeddable-plugin/public'; import { DashboardPanelState } from '../../../common'; -import { GridData } from '../../../common/content_management'; +import type { GridData } from '../../../server/content_management'; import { PanelPlacementProps, PanelPlacementReturn } from './types'; import { DASHBOARD_GRID_COLUMN_COUNT } from '../../dashboard_constants'; @@ -109,9 +109,9 @@ export function placeClonePanel({ for (let j = position + 1; j < grid.length; j++) { originalPositionInTheGrid = grid[j].i; - const movedPanel = cloneDeep(otherPanels[originalPositionInTheGrid]); - movedPanel.gridData.y = movedPanel.gridData.y + diff; - otherPanels[originalPositionInTheGrid] = movedPanel; + const { gridData, ...movedPanel } = cloneDeep(otherPanels[originalPositionInTheGrid]); + const newGridData = { ...gridData, y: gridData.y + diff }; + otherPanels[originalPositionInTheGrid] = { ...movedPanel, gridData: newGridData }; } return { newPanelPlacement: bottomPlacement.grid, otherPanels }; } diff --git a/src/plugins/dashboard/public/dashboard_container/panel_placement/place_new_panel_strategies.ts b/src/plugins/dashboard/public/dashboard_container/panel_placement/place_new_panel_strategies.ts index 821a5e6eed1c3..a6c0aaba43467 100644 --- a/src/plugins/dashboard/public/dashboard_container/panel_placement/place_new_panel_strategies.ts +++ b/src/plugins/dashboard/public/dashboard_container/panel_placement/place_new_panel_strategies.ts @@ -20,9 +20,9 @@ export const runPanelPlacementStrategy = ( case PanelPlacementStrategy.placeAtTop: const otherPanels = { ...currentPanels }; for (const [id, panel] of Object.entries(currentPanels)) { - const currentPanel = cloneDeep(panel); - currentPanel.gridData.y = currentPanel.gridData.y + height; - otherPanels[id] = currentPanel; + const { gridData, ...currentPanel } = cloneDeep(panel); + const newGridData = { ...gridData, y: gridData.y + height }; + otherPanels[id] = { ...currentPanel, gridData: newGridData }; } return { newPanelPlacement: { x: 0, y: 0, w: width, h: height }, diff --git a/src/plugins/dashboard/public/dashboard_container/panel_placement/types.ts b/src/plugins/dashboard/public/dashboard_container/panel_placement/types.ts index df34a9158c11a..2dd826f9a5821 100644 --- a/src/plugins/dashboard/public/dashboard_container/panel_placement/types.ts +++ b/src/plugins/dashboard/public/dashboard_container/panel_placement/types.ts @@ -10,7 +10,7 @@ import { EmbeddableInput } from '@kbn/embeddable-plugin/public'; import { MaybePromise } from '@kbn/utility-types'; import { DashboardPanelState } from '../../../common'; -import { GridData } from '../../../common/content_management'; +import type { GridData } from '../../../server/content_management'; import { PanelPlacementStrategy } from '../../dashboard_constants'; export interface PanelPlacementSettings { diff --git a/src/plugins/dashboard/public/dashboard_container/state/dashboard_container_reducers.ts b/src/plugins/dashboard/public/dashboard_container/state/dashboard_container_reducers.ts index 0bb33a05c36ce..c0c39b0ffd284 100644 --- a/src/plugins/dashboard/public/dashboard_container/state/dashboard_container_reducers.ts +++ b/src/plugins/dashboard/public/dashboard_container/state/dashboard_container_reducers.ts @@ -9,6 +9,7 @@ import { PayloadAction } from '@reduxjs/toolkit'; +import { isFilterPinned } from '@kbn/es-query'; import { DashboardReduxState, DashboardStateFromSaveModal, @@ -94,13 +95,20 @@ export const dashboardContainerReducers = { * `timeRestore` is `false`, this causes unecessary data fetches for the control group. * 2) The view mode, since resetting should never impact this - sometimes the Dashboard saved objects * have this saved in and we don't want resetting to cause unexpected view mode changes. + * 3) Pinned filters. */ resetToLastSavedInput: ( state: DashboardReduxState, action: PayloadAction ) => { + const keepPinnedFilters = [ + ...state.explicitInput.filters.filter(isFilterPinned), + ...action.payload.filters, + ]; + state.explicitInput = { ...action.payload, + filters: keepPinnedFilters, ...(!state.explicitInput.timeRestore && { timeRange: state.explicitInput.timeRange }), viewMode: state.explicitInput.viewMode, }; diff --git a/src/plugins/dashboard/public/dashboard_container/types.ts b/src/plugins/dashboard/public/dashboard_container/types.ts index f0b6ea2621abd..cf307924e00fe 100644 --- a/src/plugins/dashboard/public/dashboard_container/types.ts +++ b/src/plugins/dashboard/public/dashboard_container/types.ts @@ -12,8 +12,8 @@ import type { ReduxEmbeddableState } from '@kbn/presentation-util-plugin/public' import { SerializableRecord } from '@kbn/utility-types'; import { ControlGroupRuntimeState } from '@kbn/controls-plugin/public'; -import type { DashboardContainerInput, DashboardOptions } from '../../common'; -import { SavedDashboardPanel } from '../../common/content_management'; +import type { DashboardContainerInput } from '../../common'; +import type { DashboardOptions, DashboardPanel } from '../../server/content_management'; export interface UnsavedPanelState { [key: string]: object | undefined; @@ -101,7 +101,7 @@ export type DashboardLocatorParams = Partial< /** * List of dashboard panels */ - panels?: Array; // used SerializableRecord here to force the GridData type to be read as serializable + panels?: Array; // used SerializableRecord here to force the GridData type to be read as serializable /** * Control group changes diff --git a/src/plugins/dashboard/public/dashboard_listing/dashboard_unsaved_listing.tsx b/src/plugins/dashboard/public/dashboard_listing/dashboard_unsaved_listing.tsx index 0e23583801309..04f40a199e83b 100644 --- a/src/plugins/dashboard/public/dashboard_listing/dashboard_unsaved_listing.tsx +++ b/src/plugins/dashboard/public/dashboard_listing/dashboard_unsaved_listing.tsx @@ -20,7 +20,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { ViewMode } from '@kbn/embeddable-plugin/public'; -import { DashboardAttributes } from '../../common/content_management'; +import type { DashboardAttributes } from '../../server/content_management'; import { DASHBOARD_PANELS_UNSAVED_ID, getDashboardBackupService, diff --git a/src/plugins/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.tsx b/src/plugins/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.tsx index 31bfa88120a5e..6c8c8f11d6a13 100644 --- a/src/plugins/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.tsx +++ b/src/plugins/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.tsx @@ -17,7 +17,7 @@ import { reportPerformanceMetricEvent } from '@kbn/ebt-tools'; import { ViewMode } from '@kbn/embeddable-plugin/public'; import { DashboardContainerInput } from '../../../common'; -import { DashboardItem } from '../../../common/content_management'; +import type { DashboardSearchOut } from '../../../server/content_management'; import { DASHBOARD_CONTENT_ID, SAVED_OBJECT_DELETE_TIME, @@ -42,7 +42,9 @@ type GetDetailViewLink = const SAVED_OBJECTS_LIMIT_SETTING = 'savedObjects:listingLimit'; const SAVED_OBJECTS_PER_PAGE_SETTING = 'savedObjects:perPage'; -const toTableListViewSavedObject = (hit: DashboardItem): DashboardSavedObjectUserContent => { +const toTableListViewSavedObject = ( + hit: DashboardSearchOut['hits'][number] +): DashboardSavedObjectUserContent => { const { title, description, timeRestore } = hit.attributes; return { type: 'dashboard', @@ -51,7 +53,7 @@ const toTableListViewSavedObject = (hit: DashboardItem): DashboardSavedObjectUse createdAt: hit.createdAt, createdBy: hit.createdBy, updatedBy: hit.updatedBy, - references: hit.references, + references: hit.references ?? [], managed: hit.managed, attributes: { title, diff --git a/src/plugins/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx b/src/plugins/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx index bf2be799dcc1f..47a84b620ede3 100644 --- a/src/plugins/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx @@ -33,7 +33,6 @@ import { dashboardManagedBadge, getDashboardBreadcrumb, getDashboardTitle, - leaveConfirmStrings, unsavedChangesBadgeStrings, } from '../dashboard_app/_dashboard_app_strings'; import { useDashboardMountContext } from '../dashboard_app/hooks/dashboard_mount_context'; @@ -48,7 +47,6 @@ import { getDashboardRecentlyAccessedService } from '../services/dashboard_recen import { coreServices, dataService, - embeddableService, navigationService, serverlessService, } from '../services/kibana_services'; @@ -195,16 +193,6 @@ export function InternalDashboardTopNav({ */ useEffect(() => { onAppLeave((actions) => { - if ( - viewMode === 'edit' && - hasUnsavedChanges && - !embeddableService.getStateTransfer().isTransferInProgress - ) { - return actions.confirm( - leaveConfirmStrings.getLeaveSubtitle(), - leaveConfirmStrings.getLeaveTitle() - ); - } return actions.default(); }); return () => { diff --git a/src/plugins/dashboard/public/services/dashboard_content_management_service/dashboard_content_management_cache.ts b/src/plugins/dashboard/public/services/dashboard_content_management_service/dashboard_content_management_cache.ts index 1b54f9dda9eb4..e72e3f23fdaba 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management_service/dashboard_content_management_cache.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management_service/dashboard_content_management_cache.ts @@ -8,14 +8,14 @@ */ import LRUCache from 'lru-cache'; -import { DashboardCrudTypes } from '../../../common/content_management'; +import type { DashboardGetOut } from '../../../server/content_management'; import { DASHBOARD_CACHE_SIZE, DASHBOARD_CACHE_TTL } from '../../dashboard_constants'; export class DashboardContentManagementCache { - private cache: LRUCache; + private cache: LRUCache; constructor() { - this.cache = new LRUCache({ + this.cache = new LRUCache({ max: DASHBOARD_CACHE_SIZE, maxAge: DASHBOARD_CACHE_TTL, }); @@ -27,7 +27,7 @@ export class DashboardContentManagementCache { } /** Add the fetched dashboard to the cache */ - public addDashboard({ item: dashboard, meta }: DashboardCrudTypes['GetOut']) { + public addDashboard({ item: dashboard, meta }: DashboardGetOut) { this.cache.set(dashboard.id, { meta, item: dashboard, diff --git a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/check_for_duplicate_dashboard_title.ts b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/check_for_duplicate_dashboard_title.ts index 0d12cb446129b..2865663dec3c0 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/check_for_duplicate_dashboard_title.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/check_for_duplicate_dashboard_title.ts @@ -7,8 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import type { DashboardSearchIn, DashboardSearchOut } from '../../../../server/content_management'; import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants'; -import { DashboardCrudTypes } from '../../../../common/content_management'; import { extractTitleAndCount } from '../../../dashboard_container/embeddable/api/lib/extract_title_and_count'; import { contentManagementService } from '../../kibana_services'; @@ -54,8 +54,8 @@ export async function checkForDuplicateDashboardTitle({ const [baseDashboardName] = extractTitleAndCount(title); const { hits } = await contentManagementService.client.search< - DashboardCrudTypes['SearchIn'], - DashboardCrudTypes['SearchOut'] + DashboardSearchIn, + DashboardSearchOut >({ contentTypeId: DASHBOARD_CONTENT_ID, query: { diff --git a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/delete_dashboards.ts b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/delete_dashboards.ts index 0be9355ddb606..976a5579b1988 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/delete_dashboards.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/delete_dashboards.ts @@ -7,18 +7,15 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { getDashboardContentManagementCache } from '..'; -import { DashboardCrudTypes } from '../../../../common/content_management'; +import type { DeleteIn, DeleteResult } from '@kbn/content-management-plugin/common'; import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants'; +import { getDashboardContentManagementCache } from '..'; import { contentManagementService } from '../../kibana_services'; export const deleteDashboards = async (ids: string[]) => { const deletePromises = ids.map((id) => { getDashboardContentManagementCache().deleteDashboard(id); - return contentManagementService.client.delete< - DashboardCrudTypes['DeleteIn'], - DashboardCrudTypes['DeleteOut'] - >({ + return contentManagementService.client.delete({ contentTypeId: DASHBOARD_CONTENT_ID, id, }); diff --git a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/find_dashboards.ts b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/find_dashboards.ts index 2f9a2c2e9a033..4afdefb8d13e1 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/find_dashboards.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/find_dashboards.ts @@ -10,17 +10,20 @@ import type { Reference } from '@kbn/content-management-utils'; import { SavedObjectError, SavedObjectsFindOptionsReference } from '@kbn/core/public'; -import { getDashboardContentManagementCache } from '..'; -import { +import type { DashboardAttributes, - DashboardCrudTypes, - DashboardItem, -} from '../../../../common/content_management'; + DashboardGetIn, + DashboardGetOut, + DashboardSearchIn, + DashboardSearchOut, + DashboardSearchOptions, +} from '../../../../server/content_management'; +import { getDashboardContentManagementCache } from '..'; import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants'; import { contentManagementService } from '../../kibana_services'; export interface SearchDashboardsArgs { - options?: DashboardCrudTypes['SearchIn']['options']; + options?: DashboardSearchOptions; hasNoReference?: SavedObjectsFindOptionsReference[]; hasReference?: SavedObjectsFindOptionsReference[]; search: string; @@ -29,7 +32,7 @@ export interface SearchDashboardsArgs { export interface SearchDashboardsResponse { total: number; - hits: DashboardItem[]; + hits: DashboardSearchOut['hits']; } export async function searchDashboards({ @@ -42,10 +45,7 @@ export async function searchDashboards({ const { hits, pagination: { total }, - } = await contentManagementService.client.search< - DashboardCrudTypes['SearchIn'], - DashboardCrudTypes['SearchOut'] - >({ + } = await contentManagementService.client.search({ contentTypeId: DASHBOARD_CONTENT_ID, query: { text: search ? `${search}*` : undefined, @@ -84,10 +84,7 @@ export async function findDashboardById(id: string): Promise({ + const response = await contentManagementService.client.get({ contentTypeId: DASHBOARD_CONTENT_ID, id, }); @@ -119,8 +116,8 @@ export async function findDashboardsByIds(ids: string[]): Promise { const { hits } = await contentManagementService.client.search< - DashboardCrudTypes['SearchIn'], - DashboardCrudTypes['SearchOut'] + DashboardSearchIn, + DashboardSearchOut >({ contentTypeId: DASHBOARD_CONTENT_ID, query: { diff --git a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/load_dashboard_state.ts b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/load_dashboard_state.ts index 17102e2fe7d0a..2694411ed001a 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/load_dashboard_state.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/load_dashboard_state.ts @@ -10,19 +10,15 @@ import { has } from 'lodash'; import { v4 as uuidv4 } from 'uuid'; -import { injectSearchSourceReferences, parseSearchSourceJSON } from '@kbn/data-plugin/public'; +import { injectSearchSourceReferences } from '@kbn/data-plugin/public'; import { ViewMode } from '@kbn/embeddable-plugin/public'; import { Filter, Query } from '@kbn/es-query'; import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/public'; import { cleanFiltersForSerialize } from '@kbn/presentation-util-plugin/public'; import { getDashboardContentManagementCache } from '..'; -import { - convertSavedPanelsToPanelMap, - injectReferences, - type DashboardOptions, -} from '../../../../common'; -import { DashboardCrudTypes } from '../../../../common/content_management'; +import { convertPanelsArrayToPanelMap, injectReferences } from '../../../../common'; +import type { DashboardGetIn, DashboardGetOut } from '../../../../server/content_management'; import { DASHBOARD_CONTENT_ID, DEFAULT_DASHBOARD_INPUT } from '../../../dashboard_constants'; import { contentManagementService, @@ -30,7 +26,11 @@ import { embeddableService, savedObjectsTaggingService, } from '../../kibana_services'; -import type { LoadDashboardFromSavedObjectProps, LoadDashboardReturn } from '../types'; +import type { + DashboardSearchSource, + LoadDashboardFromSavedObjectProps, + LoadDashboardReturn, +} from '../types'; import { convertNumberToDashboardVersion } from './dashboard_versioning'; import { migrateDashboardInput } from './migrate_dashboard_input'; @@ -72,8 +72,8 @@ export const loadDashboardState = async ({ /** * Load the saved object from Content Management */ - let rawDashboardContent: DashboardCrudTypes['GetOut']['item']; - let resolveMeta: DashboardCrudTypes['GetOut']['meta']; + let rawDashboardContent: DashboardGetOut['item']; + let resolveMeta: DashboardGetOut['meta']; const cachedDashboard = dashboardContentManagementCache.fetchDashboard(id); if (cachedDashboard) { @@ -82,7 +82,7 @@ export const loadDashboardState = async ({ } else { /** Otherwise, fetch and load the dashboard from the content management client, and add it to the cache */ const result = await contentManagementService.client - .get({ + .get({ contentTypeId: DASHBOARD_CONTENT_ID, id, }) @@ -127,14 +127,16 @@ export const loadDashboardState = async ({ /** * Create search source and pull filters and query from it. */ - const searchSourceJSON = attributes.kibanaSavedObjectMeta.searchSourceJSON; + let searchSourceValues = attributes.kibanaSavedObjectMeta.searchSource; const searchSource = await (async () => { - if (!searchSourceJSON) { + if (!searchSourceValues) { return await dataSearchService.searchSource.create(); } try { - let searchSourceValues = parseSearchSourceJSON(searchSourceJSON); - searchSourceValues = injectSearchSourceReferences(searchSourceValues as any, references); + searchSourceValues = injectSearchSourceReferences( + searchSourceValues as any, + references + ) as DashboardSearchSource; return await dataSearchService.searchSource.create(searchSourceValues); } catch (error: any) { return await dataSearchService.searchSource.create(); @@ -151,8 +153,8 @@ export const loadDashboardState = async ({ refreshInterval, description, timeRestore, - optionsJSON, - panelsJSON, + options, + panels, timeFrom, version, timeTo, @@ -167,11 +169,7 @@ export const loadDashboardState = async ({ } : undefined; - /** - * Parse panels and options from JSON - */ - const options: DashboardOptions = optionsJSON ? JSON.parse(optionsJSON) : undefined; - const panels = convertSavedPanelsToPanelMap(panelsJSON ? JSON.parse(panelsJSON) : []); + const panelMap = convertPanelsArrayToPanelMap(panels ?? []); const { dashboardInput, anyMigrationRun } = migrateDashboardInput({ ...DEFAULT_DASHBOARD_INPUT, @@ -183,7 +181,7 @@ export const loadDashboardState = async ({ description, timeRange, filters, - panels, + panels: panelMap, query, title, @@ -192,7 +190,7 @@ export const loadDashboardState = async ({ controlGroupInput: attributes.controlGroupInput, - version: convertNumberToDashboardVersion(version), + ...(version && { version: convertNumberToDashboardVersion(version) }), }); return { diff --git a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/save_dashboard_state.test.ts b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/save_dashboard_state.test.ts index 8327397a66068..7e35b0ec1c163 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/save_dashboard_state.test.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/save_dashboard_state.test.ts @@ -95,7 +95,7 @@ describe('Save dashboard state', () => { currentState: { ...getSampleDashboardInput(), title: 'BooThree', - panels: { idOne: { type: 'boop' } }, + panels: { aVerySpecialVeryUniqueId: { type: 'boop' } }, } as unknown as DashboardContainerInput, lastSavedId: 'Boogatoonie', saveOptions: { saveAsCopy: true }, @@ -106,7 +106,11 @@ describe('Save dashboard state', () => { expect(contentManagementService.client.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ - panelsJSON: expect.not.stringContaining('neverGonnaGetThisId'), + panels: expect.arrayContaining([ + expect.objectContaining({ + panelIndex: expect.not.stringContaining('aVerySpecialVeryUniqueId'), + }), + ]), }), }) ); diff --git a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/save_dashboard_state.ts b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/save_dashboard_state.ts index 283ed5eed7f5b..27e6a53da1f9a 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/save_dashboard_state.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/save_dashboard_state.ts @@ -13,9 +13,16 @@ import moment, { Moment } from 'moment'; import { extractSearchSourceReferences, RefreshInterval } from '@kbn/data-plugin/public'; import { isFilterPinned } from '@kbn/es-query'; +import type { SavedObjectReference } from '@kbn/core/server'; import { getDashboardContentManagementCache } from '..'; -import { convertPanelMapToSavedPanels, extractReferences } from '../../../../common'; -import { DashboardAttributes, DashboardCrudTypes } from '../../../../common/content_management'; +import { convertPanelMapToPanelsArray, extractReferences } from '../../../../common'; +import type { + DashboardAttributes, + DashboardCreateIn, + DashboardCreateOut, + DashboardUpdateIn, + DashboardUpdateOut, +} from '../../../../server/content_management'; import { generateNewPanelIds } from '../../../../common/lib/dashboard_panel_converters'; import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants'; import { LATEST_DASHBOARD_CONTAINER_VERSION } from '../../../dashboard_container'; @@ -28,7 +35,7 @@ import { embeddableService, savedObjectsTaggingService, } from '../../kibana_services'; -import { SaveDashboardProps, SaveDashboardReturn } from '../types'; +import { DashboardSearchSource, SaveDashboardProps, SaveDashboardReturn } from '../types'; import { convertDashboardVersionToNumber } from './dashboard_versioning'; export const convertTimeToUTCString = (time?: string | Moment): undefined | string => { @@ -88,33 +95,30 @@ export const saveDashboardState = async ({ // } - /** - * Stringify filters and query into search source JSON - */ - const { searchSourceJSON, searchSourceReferences } = await (async () => { - const searchSource = await dataSearchService.searchSource.create(); - searchSource.setField( + const { searchSource, searchSourceReferences } = await (async () => { + const searchSourceFields = await dataSearchService.searchSource.create(); + searchSourceFields.setField( 'filter', // save only unpinned filters filters.filter((filter) => !isFilterPinned(filter)) ); - searchSource.setField('query', query); - - const rawSearchSourceFields = searchSource.getSerializedFields(); - const [fields, references] = extractSearchSourceReferences(rawSearchSourceFields); - return { searchSourceReferences: references, searchSourceJSON: JSON.stringify(fields) }; + searchSourceFields.setField('query', query); + + const rawSearchSourceFields = searchSourceFields.getSerializedFields(); + const [fields, references] = extractSearchSourceReferences(rawSearchSourceFields) as [ + DashboardSearchSource, + SavedObjectReference[] + ]; + return { searchSourceReferences: references, searchSource: fields }; })(); - /** - * Stringify options and panels - */ - const optionsJSON = JSON.stringify({ + const options = { useMargins, syncColors, syncCursor, syncTooltips, hidePanelTitles, - }); - const panelsJSON = JSON.stringify(convertPanelMapToSavedPanels(panels, true)); + }; + const savedPanels = convertPanelMapToPanelsArray(panels, true); /** * Parse global time filter settings @@ -134,12 +138,12 @@ export const saveDashboardState = async ({ const rawDashboardAttributes: DashboardAttributes = { version: convertDashboardVersionToNumber(LATEST_DASHBOARD_CONTAINER_VERSION), controlGroupInput, - kibanaSavedObjectMeta: { searchSourceJSON }, + kibanaSavedObjectMeta: { searchSource }, description: description ?? '', refreshInterval, timeRestore, - optionsJSON, - panelsJSON, + options, + panels: savedPanels, timeFrom, title, timeTo, @@ -174,10 +178,7 @@ export const saveDashboardState = async ({ try { const result = idToSaveTo - ? await contentManagementService.client.update< - DashboardCrudTypes['UpdateIn'], - DashboardCrudTypes['UpdateOut'] - >({ + ? await contentManagementService.client.update({ id: idToSaveTo, contentTypeId: DASHBOARD_CONTENT_ID, data: attributes, @@ -187,10 +188,7 @@ export const saveDashboardState = async ({ mergeAttributes: false, }, }) - : await contentManagementService.client.create< - DashboardCrudTypes['CreateIn'], - DashboardCrudTypes['CreateOut'] - >({ + : await contentManagementService.client.create({ contentTypeId: DASHBOARD_CONTENT_ID, data: attributes, options: { diff --git a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/update_dashboard_meta.ts b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/update_dashboard_meta.ts index 2fd57738f17aa..90f31cfdc05c6 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/update_dashboard_meta.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/update_dashboard_meta.ts @@ -9,7 +9,7 @@ import { DashboardContainerInput } from '../../../../common'; import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants'; -import { DashboardCrudTypes } from '../../../../common/content_management'; +import type { DashboardUpdateIn, DashboardUpdateOut } from '../../../../server/content_management'; import { findDashboardsByIds } from './find_dashboards'; import { contentManagementService, savedObjectsTaggingService } from '../../kibana_services'; @@ -35,10 +35,7 @@ export const updateDashboardMeta = async ({ ? savedObjectsTaggingApi.ui.updateTagsReferences(dashboard.references, tags) : dashboard.references; - await contentManagementService.client.update< - DashboardCrudTypes['UpdateIn'], - DashboardCrudTypes['UpdateOut'] - >({ + await contentManagementService.client.update({ contentTypeId: DASHBOARD_CONTENT_ID, id, data: { title, description }, diff --git a/src/plugins/dashboard/public/services/dashboard_content_management_service/types.ts b/src/plugins/dashboard/public/services/dashboard_content_management_service/types.ts index 3294bb06c0d42..0f4fe1c86a56d 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management_service/types.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management_service/types.ts @@ -8,11 +8,12 @@ */ import type { Reference } from '@kbn/content-management-utils'; +import type { Query, SerializedSearchSourceFields } from '@kbn/data-plugin/common'; import { ControlGroupRuntimeState } from '@kbn/controls-plugin/public'; import { SavedObjectSaveOpts } from '@kbn/saved-objects-plugin/public'; import { DashboardContainerInput } from '../../../common'; -import { DashboardAttributes, DashboardCrudTypes } from '../../../common/content_management'; +import type { DashboardAttributes, DashboardGetOut } from '../../../server/content_management'; import { DashboardDuplicateTitleCheckProps } from './lib/check_for_duplicate_dashboard_title'; import { FindDashboardsByIdResponse, @@ -38,7 +39,7 @@ export interface LoadDashboardFromSavedObjectProps { id?: string; } -type DashboardResolveMeta = DashboardCrudTypes['GetOut']['meta']; +type DashboardResolveMeta = DashboardGetOut['meta']; export type SavedDashboardInput = DashboardContainerInput & { /** @@ -54,6 +55,10 @@ export type SavedDashboardInput = DashboardContainerInput & { controlGroupState?: Partial; }; +export type DashboardSearchSource = Omit & { + query?: Query; +}; + export interface LoadDashboardReturn { dashboardFound: boolean; newDashboardCreated?: boolean; diff --git a/src/plugins/dashboard/public/services/mocks.ts b/src/plugins/dashboard/public/services/mocks.ts index 255098ecd8196..c39c665ed55da 100644 --- a/src/plugins/dashboard/public/services/mocks.ts +++ b/src/plugins/dashboard/public/services/mocks.ts @@ -32,7 +32,8 @@ import { urlForwardingPluginMock } from '@kbn/url-forwarding-plugin/public/mocks import { visualizationsPluginMock } from '@kbn/visualizations-plugin/public/mocks'; import { setKibanaServices } from './kibana_services'; -import { DashboardAttributes, DashboardCapabilities } from '../../common'; +import { DashboardAttributes } from '../../server/content_management'; +import { DashboardCapabilities } from '../../common'; import { LoadDashboardReturn } from './dashboard_content_management_service/types'; import { SearchDashboardsResponse } from './dashboard_content_management_service/lib/find_dashboards'; diff --git a/src/plugins/dashboard/server/api/constants.ts b/src/plugins/dashboard/server/api/constants.ts new file mode 100644 index 0000000000000..458165d797869 --- /dev/null +++ b/src/plugins/dashboard/server/api/constants.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 const PUBLIC_API_VERSION = '2023-10-31'; +export const PUBLIC_API_CONTENT_MANAGEMENT_VERSION = 3; +export const PUBLIC_API_PATH = '/api/dashboards/dashboard'; diff --git a/src/plugins/dashboard/server/api/index.ts b/src/plugins/dashboard/server/api/index.ts new file mode 100644 index 0000000000000..ccf84609b2b10 --- /dev/null +++ b/src/plugins/dashboard/server/api/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { registerAPIRoutes } from './register_routes'; diff --git a/src/plugins/dashboard/server/api/register_routes.ts b/src/plugins/dashboard/server/api/register_routes.ts new file mode 100644 index 0000000000000..692942e1bd1bb --- /dev/null +++ b/src/plugins/dashboard/server/api/register_routes.ts @@ -0,0 +1,327 @@ +/* + * Copyright 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'; +import type { ContentManagementServerSetup } from '@kbn/content-management-plugin/server'; +import type { HttpServiceSetup } from '@kbn/core/server'; +import type { UsageCounter } from '@kbn/usage-collection-plugin/server'; +import type { Logger } from '@kbn/logging'; + +import { CONTENT_ID } from '../../common/content_management'; +import { + PUBLIC_API_PATH, + PUBLIC_API_VERSION, + PUBLIC_API_CONTENT_MANAGEMENT_VERSION, +} from './constants'; +import { + dashboardAttributesSchema, + dashboardGetResultSchema, + dashboardCreateResultSchema, + dashboardSearchResultsSchema, + referenceSchema, +} from '../content_management/v3'; + +interface RegisterAPIRoutesArgs { + http: HttpServiceSetup; + contentManagement: ContentManagementServerSetup; + restCounter?: UsageCounter; + logger: Logger; +} + +const TECHNICAL_PREVIEW_WARNING = + 'This functionality is in technical preview and may be changed or removed in a future release. Elastic will work to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.'; + +export function registerAPIRoutes({ + http, + contentManagement, + restCounter, + logger, +}: RegisterAPIRoutesArgs) { + const { versioned: versionedRouter } = http.createRouter(); + + // Create API route + const createRoute = versionedRouter.post({ + path: `${PUBLIC_API_PATH}/{id?}`, + access: 'public', + summary: 'Create a dashboard', + description: TECHNICAL_PREVIEW_WARNING, + options: { + tags: ['oas-tag:Dashboards'], + }, + }); + + createRoute.addVersion( + { + version: PUBLIC_API_VERSION, + validate: { + request: { + params: schema.object({ + id: schema.maybe(schema.string()), + }), + body: schema.object({ + attributes: dashboardAttributesSchema, + references: schema.maybe(schema.arrayOf(referenceSchema)), + spaces: schema.maybe(schema.arrayOf(schema.string())), + }), + }, + response: { + 200: { + body: () => dashboardCreateResultSchema, + }, + }, + }, + }, + async (ctx, req, res) => { + const { id } = req.params; + const { attributes, references, spaces: initialNamespaces } = req.body; + const client = contentManagement.contentClient + .getForRequest({ request: req, requestHandlerContext: ctx }) + .for(CONTENT_ID, PUBLIC_API_CONTENT_MANAGEMENT_VERSION); + let result; + try { + ({ result } = await client.create(attributes, { + id, + references, + initialNamespaces, + })); + } catch (e) { + if (e.isBoom && e.output.statusCode === 409) { + return res.conflict({ + body: { + message: `A dashboard with saved object ID ${id} already exists.`, + }, + }); + } + + if (e.isBoom && e.output.statusCode === 403) { + return res.forbidden(); + } + + return res.badRequest(); + } + + return res.ok({ body: result }); + } + ); + + // Update API route + + const updateRoute = versionedRouter.put({ + path: `${PUBLIC_API_PATH}/{id}`, + access: 'public', + summary: `Update an existing dashboard.`, + description: TECHNICAL_PREVIEW_WARNING, + options: { + tags: ['oas-tag:Dashboards'], + }, + }); + + updateRoute.addVersion( + { + version: PUBLIC_API_VERSION, + validate: { + request: { + params: schema.object({ + id: schema.string(), + }), + body: schema.object({ + attributes: dashboardAttributesSchema, + references: schema.maybe(schema.arrayOf(referenceSchema)), + }), + }, + response: { + 200: { + body: () => dashboardCreateResultSchema, + }, + }, + }, + }, + async (ctx, req, res) => { + const { attributes, references } = req.body; + const client = contentManagement.contentClient + .getForRequest({ request: req, requestHandlerContext: ctx }) + .for(CONTENT_ID, PUBLIC_API_CONTENT_MANAGEMENT_VERSION); + let result; + try { + ({ result } = await client.update(req.params.id, attributes, { references })); + } catch (e) { + if (e.isBoom && e.output.statusCode === 404) { + return res.notFound({ + body: { + message: `A dashboard with saved object ID ${req.params.id} was not found.`, + }, + }); + } + if (e.isBoom && e.output.statusCode === 403) { + return res.forbidden(); + } + return res.badRequest(e.message); + } + + return res.created({ body: result }); + } + ); + + // List API route + const listRoute = versionedRouter.get({ + path: `${PUBLIC_API_PATH}`, + access: 'public', + summary: `Get a list of dashboards.`, + description: TECHNICAL_PREVIEW_WARNING, + options: { + tags: ['oas-tag:Dashboards'], + }, + }); + + listRoute.addVersion( + { + version: PUBLIC_API_VERSION, + validate: { + request: { + query: schema.object({ + page: schema.number({ defaultValue: 1 }), + perPage: schema.maybe(schema.number()), + }), + }, + response: { + 200: { + body: () => + schema.object({ + items: schema.arrayOf(dashboardSearchResultsSchema), + total: schema.number(), + }), + }, + }, + }, + }, + async (ctx, req, res) => { + const { page, perPage: limit } = req.query; + const client = contentManagement.contentClient + .getForRequest({ request: req, requestHandlerContext: ctx }) + .for(CONTENT_ID, PUBLIC_API_CONTENT_MANAGEMENT_VERSION); + let result; + try { + // TODO add filtering + ({ result } = await client.search({ cursor: page.toString(), limit })); + } catch (e) { + if (e.isBoom && e.output.statusCode === 403) { + return res.forbidden(); + } + + return res.badRequest(); + } + + const body = { + items: result.hits, + total: result.pagination.total, + }; + return res.ok({ body }); + } + ); + + // Get API route + const getRoute = versionedRouter.get({ + path: `${PUBLIC_API_PATH}/{id}`, + access: 'public', + summary: `Get a dashboard.`, + description: TECHNICAL_PREVIEW_WARNING, + options: { + tags: ['oas-tag:Dashboards'], + }, + }); + + getRoute.addVersion( + { + version: PUBLIC_API_VERSION, + validate: { + request: { + params: schema.object({ + id: schema.string(), + }), + }, + response: { + 200: { + body: () => dashboardGetResultSchema, + }, + }, + }, + }, + async (ctx, req, res) => { + const client = contentManagement.contentClient + .getForRequest({ request: req, requestHandlerContext: ctx }) + .for(CONTENT_ID, PUBLIC_API_CONTENT_MANAGEMENT_VERSION); + let result; + try { + ({ result } = await client.get(req.params.id)); + } catch (e) { + if (e.isBoom && e.output.statusCode === 404) { + return res.notFound({ + body: { + message: `A dashboard with saved object ID ${req.params.id}] was not found.`, + }, + }); + } + + if (e.isBoom && e.output.statusCode === 403) { + return res.forbidden(); + } + + return res.badRequest(e.message); + } + + return res.ok({ body: result }); + } + ); + + // Delete API route + const deleteRoute = versionedRouter.delete({ + path: `${PUBLIC_API_PATH}/{id}`, + access: 'public', + summary: `Delete a dashboard.`, + description: TECHNICAL_PREVIEW_WARNING, + options: { + tags: ['oas-tag:Dashboards'], + }, + }); + + deleteRoute.addVersion( + { + version: PUBLIC_API_VERSION, + validate: { + request: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + }, + async (ctx, req, res) => { + const client = contentManagement.contentClient + .getForRequest({ request: req, requestHandlerContext: ctx }) + .for(CONTENT_ID, PUBLIC_API_CONTENT_MANAGEMENT_VERSION); + try { + await client.delete(req.params.id); + } catch (e) { + if (e.isBoom && e.output.statusCode === 404) { + return res.notFound({ + body: { + message: `A dashboard with saved object ID ${req.params.id} was not found.`, + }, + }); + } + if (e.isBoom && e.output.statusCode === 403) { + return res.forbidden(); + } + return res.badRequest(); + } + + return res.ok(); + } + ); +} diff --git a/src/plugins/dashboard/server/content_management/schema/cm_services.ts b/src/plugins/dashboard/server/content_management/cm_services.ts similarity index 94% rename from src/plugins/dashboard/server/content_management/schema/cm_services.ts rename to src/plugins/dashboard/server/content_management/cm_services.ts index 10fbbd7f44ba8..081d7ad8a39d4 100644 --- a/src/plugins/dashboard/server/content_management/schema/cm_services.ts +++ b/src/plugins/dashboard/server/content_management/cm_services.ts @@ -17,8 +17,10 @@ import type { import { serviceDefinition as v1 } from './v1'; import { serviceDefinition as v2 } from './v2'; +import { serviceDefinition as v3 } from './v3'; export const cmServicesDefinition: { [version: Version]: ServicesDefinition } = { 1: v1, 2: v2, + 3: v3, }; diff --git a/src/plugins/dashboard/server/content_management/dashboard_storage.ts b/src/plugins/dashboard/server/content_management/dashboard_storage.ts index 248979032132a..e65002802989f 100644 --- a/src/plugins/dashboard/server/content_management/dashboard_storage.ts +++ b/src/plugins/dashboard/server/content_management/dashboard_storage.ts @@ -7,23 +7,40 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { SOContentStorage, tagsToFindOptions } from '@kbn/content-management-utils'; -import { SavedObjectsFindOptions } from '@kbn/core-saved-objects-api-server'; +import Boom from '@hapi/boom'; +import { tagsToFindOptions } from '@kbn/content-management-utils'; +import { + SavedObjectsFindOptions, + SavedObjectsFindResult, +} from '@kbn/core-saved-objects-api-server'; import type { Logger } from '@kbn/logging'; -import { CONTENT_ID } from '../../common/content_management'; -import { cmServicesDefinition } from './schema/cm_services'; -import type { DashboardCrudTypes } from '../../common/content_management'; +import { CreateResult, DeleteResult, SearchQuery } from '@kbn/content-management-plugin/common'; +import { StorageContext } from '@kbn/content-management-plugin/server'; +import { DASHBOARD_SAVED_OBJECT_TYPE } from '../dashboard_saved_object'; +import { cmServicesDefinition } from './cm_services'; +import { DashboardSavedObjectAttributes } from '../dashboard_saved_object'; +import { itemAttrsToSavedObjectAttrs, savedObjectToItem } from './latest'; +import type { + DashboardAttributes, + DashboardItem, + DashboardCreateOut, + DashboardCreateOptions, + DashboardGetOut, + DashboardSearchOut, + DashboardUpdateOptions, + DashboardUpdateOut, + DashboardSearchOptions, +} from './latest'; const searchArgsToSOFindOptions = ( - args: DashboardCrudTypes['SearchIn'] + query: SearchQuery, + options: DashboardSearchOptions ): SavedObjectsFindOptions => { - const { query, contentTypeId, options } = args; - return { - type: contentTypeId, + type: DASHBOARD_SAVED_OBJECT_TYPE, searchFields: options?.onlyTitle ? ['title'] : ['title^3', 'description'], - fields: ['description', 'title', 'timeRestore'], + fields: options?.fields ?? ['title', 'description', 'timeRestore'], search: query.text, perPage: query.limit, page: query.cursor ? +query.cursor : undefined, @@ -32,7 +49,16 @@ const searchArgsToSOFindOptions = ( }; }; -export class DashboardStorage extends SOContentStorage { +const savedObjectClientFromRequest = async (ctx: StorageContext) => { + if (!ctx.requestHandlerContext) { + throw new Error('Storage context.requestHandlerContext missing.'); + } + + const { savedObjects } = await ctx.requestHandlerContext.core; + return savedObjects.client; +}; + +export class DashboardStorage { constructor({ logger, throwOnResultValidationError, @@ -40,26 +66,316 @@ export class DashboardStorage extends SOContentStorage { logger: Logger; throwOnResultValidationError: boolean; }) { - super({ - savedObjectType: CONTENT_ID, - cmServicesDefinition, - searchArgsToSOFindOptions, - enableMSearch: true, - allowedSavedObjectAttributes: [ - 'kibanaSavedObjectMeta', - 'controlGroupInput', - 'refreshInterval', - 'description', - 'timeRestore', - 'optionsJSON', - 'panelsJSON', - 'timeFrom', - 'version', - 'timeTo', - 'title', - ], - logger, - throwOnResultValidationError, - }); + this.logger = logger; + this.throwOnResultValidationError = throwOnResultValidationError ?? false; + this.mSearch = { + savedObjectType: DASHBOARD_SAVED_OBJECT_TYPE, + additionalSearchFields: [], + toItemResult: (ctx: StorageContext, savedObject: SavedObjectsFindResult): DashboardItem => { + const transforms = ctx.utils.getTransforms(cmServicesDefinition); + + const { item, error: itemError } = savedObjectToItem( + savedObject as SavedObjectsFindResult, + false + ); + if (itemError) { + throw Boom.badRequest(`Invalid response. ${itemError.message}`); + } + + const validationError = transforms.mSearch.out.result.validate(item); + if (validationError) { + if (this.throwOnResultValidationError) { + throw Boom.badRequest(`Invalid response. ${validationError.message}`); + } else { + this.logger.warn(`Invalid response. ${validationError.message}`); + } + } + + // Validate DB response and DOWN transform to the request version + const { value, error: resultError } = transforms.mSearch.out.result.down< + DashboardItem, + DashboardItem + >( + item, + undefined, // do not override version + { validate: false } // validation is done above + ); + + if (resultError) { + throw Boom.badRequest(`Invalid response. ${resultError.message}`); + } + + return value; + }, + }; + } + + private logger: Logger; + private throwOnResultValidationError: boolean; + + mSearch: { + savedObjectType: string; + toItemResult: (ctx: StorageContext, savedObject: SavedObjectsFindResult) => DashboardItem; + additionalSearchFields?: string[]; + }; + + async get(ctx: StorageContext, id: string): Promise { + const transforms = ctx.utils.getTransforms(cmServicesDefinition); + const soClient = await savedObjectClientFromRequest(ctx); + + // Save data in DB + const { + saved_object: savedObject, + alias_purpose: aliasPurpose, + alias_target_id: aliasTargetId, + outcome, + } = await soClient.resolve(DASHBOARD_SAVED_OBJECT_TYPE, id); + + const { item, error: itemError } = savedObjectToItem(savedObject, false); + if (itemError) { + throw Boom.badRequest(`Invalid response. ${itemError.message}`); + } + + const response = { item, meta: { aliasPurpose, aliasTargetId, outcome } }; + + const validationError = transforms.get.out.result.validate(response); + if (validationError) { + if (this.throwOnResultValidationError) { + throw Boom.badRequest(`Invalid response. ${validationError.message}`); + } else { + this.logger.warn(`Invalid response. ${validationError.message}`); + } + } + + // Validate response and DOWN transform to the request version + const { value, error: resultError } = transforms.get.out.result.down< + DashboardGetOut, + DashboardGetOut + >( + response, + undefined, // do not override version + { validate: false } // validation is done above + ); + + if (resultError) { + throw Boom.badRequest(`Invalid response. ${resultError.message}`); + } + + return value; + } + + async bulkGet(): Promise { + // Not implemented + throw new Error(`[bulkGet] has not been implemented. See DashboardStorage class.`); + } + + async create( + ctx: StorageContext, + data: DashboardAttributes, + options: DashboardCreateOptions + ): Promise { + const transforms = ctx.utils.getTransforms(cmServicesDefinition); + const soClient = await savedObjectClientFromRequest(ctx); + + // Validate input (data & options) & UP transform them to the latest version + const { value: dataToLatest, error: dataError } = transforms.create.in.data.up< + DashboardAttributes, + DashboardAttributes + >(data); + if (dataError) { + throw Boom.badRequest(`Invalid data. ${dataError.message}`); + } + + const { value: optionsToLatest, error: optionsError } = transforms.create.in.options.up< + DashboardCreateOptions, + DashboardCreateOptions + >(options); + if (optionsError) { + throw Boom.badRequest(`Invalid options. ${optionsError.message}`); + } + + const { attributes: soAttributes, error: attributesError } = + itemAttrsToSavedObjectAttrs(dataToLatest); + if (attributesError) { + throw Boom.badRequest(`Invalid data. ${attributesError.message}`); + } + + // Save data in DB + const savedObject = await soClient.create( + DASHBOARD_SAVED_OBJECT_TYPE, + soAttributes, + optionsToLatest + ); + + const { item, error: itemError } = savedObjectToItem(savedObject, false); + if (itemError) { + throw Boom.badRequest(`Invalid response. ${itemError.message}`); + } + + const validationError = transforms.create.out.result.validate({ item }); + if (validationError) { + if (this.throwOnResultValidationError) { + throw Boom.badRequest(`Invalid response. ${validationError.message}`); + } else { + this.logger.warn(`Invalid response. ${validationError.message}`); + } + } + + // Validate DB response and DOWN transform to the request version + const { value, error: resultError } = transforms.create.out.result.down< + CreateResult + >( + { item }, + undefined, // do not override version + { validate: false } // validation is done above + ); + + if (resultError) { + throw Boom.badRequest(`Invalid response. ${resultError.message}`); + } + + return value; + } + + async update( + ctx: StorageContext, + id: string, + data: DashboardAttributes, + options: DashboardUpdateOptions + ): Promise { + const transforms = ctx.utils.getTransforms(cmServicesDefinition); + const soClient = await savedObjectClientFromRequest(ctx); + + // Validate input (data & options) & UP transform them to the latest version + const { value: dataToLatest, error: dataError } = transforms.update.in.data.up< + DashboardAttributes, + DashboardAttributes + >(data); + if (dataError) { + throw Boom.badRequest(`Invalid data. ${dataError.message}`); + } + + const { value: optionsToLatest, error: optionsError } = transforms.update.in.options.up< + DashboardUpdateOptions, + DashboardUpdateOptions + >(options); + if (optionsError) { + throw Boom.badRequest(`Invalid options. ${optionsError.message}`); + } + + const { attributes: soAttributes, error: attributesError } = + itemAttrsToSavedObjectAttrs(dataToLatest); + if (attributesError) { + throw Boom.badRequest(`Invalid data. ${attributesError.message}`); + } + + // Save data in DB + const partialSavedObject = await soClient.update( + DASHBOARD_SAVED_OBJECT_TYPE, + id, + soAttributes, + optionsToLatest + ); + + const { item, error: itemError } = savedObjectToItem(partialSavedObject, true); + if (itemError) { + throw Boom.badRequest(`Invalid response. ${itemError.message}`); + } + + const validationError = transforms.update.out.result.validate({ item }); + if (validationError) { + if (this.throwOnResultValidationError) { + throw Boom.badRequest(`Invalid response. ${validationError.message}`); + } else { + this.logger.warn(`Invalid response. ${validationError.message}`); + } + } + + // Validate DB response and DOWN transform to the request version + const { value, error: resultError } = transforms.update.out.result.down< + DashboardUpdateOut, + DashboardUpdateOut + >( + { item }, + undefined, // do not override version + { validate: false } // validation is done above + ); + + if (resultError) { + throw Boom.badRequest(`Invalid response. ${resultError.message}`); + } + + return value; + } + + async delete( + ctx: StorageContext, + id: string, + // force is necessary to delete saved objects that exist in multiple namespaces + options?: { force: boolean } + ): Promise { + const soClient = await savedObjectClientFromRequest(ctx); + await soClient.delete(DASHBOARD_SAVED_OBJECT_TYPE, id, { force: options?.force ?? false }); + return { success: true }; + } + + async search( + ctx: StorageContext, + query: SearchQuery, + options: DashboardSearchOptions + ): Promise { + const transforms = ctx.utils.getTransforms(cmServicesDefinition); + const soClient = await savedObjectClientFromRequest(ctx); + + // Validate and UP transform the options + const { value: optionsToLatest, error: optionsError } = transforms.search.in.options.up< + DashboardSearchOptions, + DashboardSearchOptions + >(options); + if (optionsError) { + throw Boom.badRequest(`Invalid payload. ${optionsError.message}`); + } + + const soQuery = searchArgsToSOFindOptions(query, optionsToLatest); + // Execute the query in the DB + const soResponse = await soClient.find(soQuery); + const hits = soResponse.saved_objects + .map((so) => { + const { item } = savedObjectToItem(so, false, soQuery.fields); + return item; + }) + // Ignore any saved objects that failed to convert to items. + .filter((item) => item !== null); + const response = { + hits, + pagination: { + total: soResponse.total, + }, + }; + + const validationError = transforms.search.out.result.validate(response); + if (validationError) { + if (this.throwOnResultValidationError) { + throw Boom.badRequest(`Invalid response. ${validationError.message}`); + } else { + this.logger.warn(`Invalid response. ${validationError.message}`); + } + } + + // Validate the response and DOWN transform to the request version + const { value, error: resultError } = transforms.search.out.result.down< + DashboardSearchOut, + DashboardSearchOut + >( + response, + undefined, // do not override version + { validate: false } // validation is done above + ); + + if (resultError) { + throw Boom.badRequest(`Invalid response. ${resultError.message}`); + } + + return value; } } diff --git a/src/plugins/dashboard/server/content_management/index.ts b/src/plugins/dashboard/server/content_management/index.ts index 6539241912671..8ff43345aa9ce 100644 --- a/src/plugins/dashboard/server/content_management/index.ts +++ b/src/plugins/dashboard/server/content_management/index.ts @@ -7,4 +7,24 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +export type { + ControlGroupAttributes, + GridData, + DashboardPanel, + DashboardAttributes, + DashboardItem, + DashboardGetIn, + DashboardGetOut, + DashboardCreateIn, + DashboardCreateOut, + DashboardCreateOptions, + DashboardSearchIn, + DashboardSearchOut, + DashboardSearchOptions, + DashboardUpdateIn, + DashboardUpdateOut, + DashboardUpdateOptions, + DashboardOptions, +} from './latest'; + export { DashboardStorage } from './dashboard_storage'; diff --git a/src/plugins/dashboard/common/content_management/latest.ts b/src/plugins/dashboard/server/content_management/latest.ts similarity index 91% rename from src/plugins/dashboard/common/content_management/latest.ts rename to src/plugins/dashboard/server/content_management/latest.ts index 82b84de84f8bf..e35d4011f84f0 100644 --- a/src/plugins/dashboard/common/content_management/latest.ts +++ b/src/plugins/dashboard/server/content_management/latest.ts @@ -7,5 +7,5 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -// Latest version is 2 -export * from './v2'; +// Latest version is 3 +export * from './v3'; diff --git a/src/plugins/dashboard/server/content_management/schema/v1/cm_services.ts b/src/plugins/dashboard/server/content_management/v1/cm_services.ts similarity index 61% rename from src/plugins/dashboard/server/content_management/schema/v1/cm_services.ts rename to src/plugins/dashboard/server/content_management/v1/cm_services.ts index f54cf0add822a..0cee0bb23f450 100644 --- a/src/plugins/dashboard/server/content_management/schema/v1/cm_services.ts +++ b/src/plugins/dashboard/server/content_management/v1/cm_services.ts @@ -16,51 +16,7 @@ import { updateOptionsSchema, createResultSchema, } from '@kbn/content-management-utils'; - -export const controlGroupInputSchema = schema - .object({ - panelsJSON: schema.maybe(schema.string()), - controlStyle: schema.maybe(schema.string()), - chainingSystem: schema.maybe(schema.string()), - ignoreParentSettingsJSON: schema.maybe(schema.string()), - }) - .extends({}, { unknowns: 'ignore' }); - -export const dashboardAttributesSchema = schema.object( - { - // General - title: schema.string(), - description: schema.string({ defaultValue: '' }), - - // Search - kibanaSavedObjectMeta: schema.object({ - searchSourceJSON: schema.maybe(schema.string()), - }), - - // Time - timeRestore: schema.maybe(schema.boolean()), - timeFrom: schema.maybe(schema.string()), - timeTo: schema.maybe(schema.string()), - refreshInterval: schema.maybe( - schema.object({ - pause: schema.boolean(), - value: schema.number(), - display: schema.maybe(schema.string()), - section: schema.maybe(schema.number()), - }) - ), - - // Dashboard Content - controlGroupInput: schema.maybe(controlGroupInputSchema), - panelsJSON: schema.string({ defaultValue: '[]' }), - optionsJSON: schema.string({ defaultValue: '{}' }), - - // Legacy - hits: schema.maybe(schema.number()), - version: schema.maybe(schema.number()), - }, - { unknowns: 'forbid' } -); +import { dashboardAttributesSchema } from '../../dashboard_saved_object/schema/v1'; export const dashboardSavedObjectSchema = savedObjectSchema(dashboardAttributesSchema); @@ -84,8 +40,10 @@ const dashboardUpdateOptionsSchema = schema.object({ mergeAttributes: schema.maybe(updateOptionsSchema.mergeAttributes), }); -// Content management service definition. -// We need it for BWC support between different versions of the content +/** + * Content management service definition v1. + * Dashboard attributes in content management version v1 are tightly coupled with the v1 model version saved object schema. + */ export const serviceDefinition: ServicesDefinition = { get: { out: { diff --git a/src/plugins/dashboard/server/content_management/v1/index.ts b/src/plugins/dashboard/server/content_management/v1/index.ts new file mode 100644 index 0000000000000..163b952218bc8 --- /dev/null +++ b/src/plugins/dashboard/server/content_management/v1/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { serviceDefinition } from './cm_services'; diff --git a/src/plugins/dashboard/server/content_management/schema/v2/cm_services.ts b/src/plugins/dashboard/server/content_management/v2/cm_services.ts similarity index 70% rename from src/plugins/dashboard/server/content_management/schema/v2/cm_services.ts rename to src/plugins/dashboard/server/content_management/v2/cm_services.ts index 9e81945e4c718..3b560b8416731 100644 --- a/src/plugins/dashboard/server/content_management/schema/v2/cm_services.ts +++ b/src/plugins/dashboard/server/content_management/v2/cm_services.ts @@ -7,36 +7,23 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { schema } from '@kbn/config-schema'; import { createResultSchema, objectTypeToGetResultSchema, savedObjectSchema, } from '@kbn/content-management-utils'; import type { ContentManagementServicesDefinition as ServicesDefinition } from '@kbn/object-versioning'; -import { - controlGroupInputSchema as controlGroupInputSchemaV1, - dashboardAttributesSchema as dashboardAttributesSchemaV1, - serviceDefinition as serviceDefinitionV1, -} from '../v1'; - -export const dashboardAttributesSchema = dashboardAttributesSchemaV1.extends( - { - controlGroupInput: schema.maybe( - controlGroupInputSchemaV1.extends( - { - showApplySelections: schema.maybe(schema.boolean()), - }, - { unknowns: 'ignore' } - ) - ), - }, - { unknowns: 'ignore' } -); +import type { DashboardCrudTypes } from '../../../common/content_management/v2'; +import { serviceDefinition as serviceDefinitionV1 } from '../v1'; +import { dashboardAttributesOut as attributesTov3 } from '../v3'; +import { dashboardAttributesSchema } from '../../dashboard_saved_object/schema/v2'; export const dashboardSavedObjectSchema = savedObjectSchema(dashboardAttributesSchema); -// Content management service definition. +/** + * Content management service definition v2. + * Dashboard attributes in content management version v2 are tightly coupled with the v2 model version saved object schema. + */ export const serviceDefinition: ServicesDefinition = { get: { out: { @@ -50,6 +37,7 @@ export const serviceDefinition: ServicesDefinition = { ...serviceDefinitionV1?.create?.in, data: { schema: dashboardAttributesSchema, + up: (data: DashboardCrudTypes['CreateIn']['data']) => attributesTov3(data), }, }, out: { @@ -63,6 +51,7 @@ export const serviceDefinition: ServicesDefinition = { ...serviceDefinitionV1.update?.in, data: { schema: dashboardAttributesSchema, + up: (data: DashboardCrudTypes['UpdateIn']['data']) => attributesTov3(data), }, }, }, diff --git a/src/plugins/dashboard/server/content_management/v2/index.ts b/src/plugins/dashboard/server/content_management/v2/index.ts new file mode 100644 index 0000000000000..163b952218bc8 --- /dev/null +++ b/src/plugins/dashboard/server/content_management/v2/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { serviceDefinition } from './cm_services'; diff --git a/src/plugins/dashboard/server/content_management/v3/cm_services.ts b/src/plugins/dashboard/server/content_management/v3/cm_services.ts new file mode 100644 index 0000000000000..e086d1cc1460a --- /dev/null +++ b/src/plugins/dashboard/server/content_management/v3/cm_services.ts @@ -0,0 +1,539 @@ +/* + * Copyright 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 { v4 as uuidv4 } from 'uuid'; +import { schema, Type } from '@kbn/config-schema'; +import { createOptionsSchemas, updateOptionsSchema } from '@kbn/content-management-utils'; +import type { ContentManagementServicesDefinition as ServicesDefinition } from '@kbn/object-versioning'; +import { + type ControlGroupChainingSystem, + type ControlLabelPosition, + type ControlWidth, + CONTROL_CHAINING_OPTIONS, + CONTROL_LABEL_POSITION_OPTIONS, + CONTROL_WIDTH_OPTIONS, + DEFAULT_CONTROL_CHAINING, + DEFAULT_CONTROL_GROW, + DEFAULT_CONTROL_LABEL_POSITION, + DEFAULT_CONTROL_WIDTH, + DEFAULT_IGNORE_PARENT_SETTINGS, + DEFAULT_AUTO_APPLY_SELECTIONS, +} from '@kbn/controls-plugin/common'; +import { FilterStateStore } from '@kbn/es-query'; +import { SortDirection } from '@kbn/data-plugin/common/search'; +import { + DASHBOARD_GRID_COLUMN_COUNT, + DEFAULT_PANEL_HEIGHT, + DEFAULT_PANEL_WIDTH, + DEFAULT_DASHBOARD_OPTIONS, +} from '../../../common/content_management'; +import { getResultV3ToV2 } from './transform_utils'; + +const apiError = schema.object({ + error: schema.string(), + message: schema.string(), + statusCode: schema.number(), + metadata: schema.maybe(schema.object({}, { unknowns: 'allow' })), +}); + +// This schema should be provided by the controls plugin. Perhaps we can resolve this with the embeddable registry. +// See https://github.com/elastic/kibana/issues/192622 +export const controlGroupInputSchema = schema.object({ + controls: schema.arrayOf( + schema.object( + { + type: schema.string({ meta: { description: 'The type of the control panel.' } }), + controlConfig: schema.maybe(schema.recordOf(schema.string(), schema.any())), + id: schema.string({ + defaultValue: uuidv4(), + meta: { description: 'The unique ID of the control.' }, + }), + order: schema.number({ + meta: { + description: 'The order of the control panel in the control group.', + }, + }), + width: schema.oneOf( + Object.values(CONTROL_WIDTH_OPTIONS).map((value) => schema.literal(value)) as [ + Type + ], + { + defaultValue: DEFAULT_CONTROL_WIDTH, + meta: { description: 'Minimum width of the control panel in the control group.' }, + } + ), + grow: schema.boolean({ + defaultValue: DEFAULT_CONTROL_GROW, + meta: { description: 'Expand width of the control panel to fit available space.' }, + }), + }, + { unknowns: 'allow' } + ), + { + defaultValue: [], + meta: { description: 'An array of control panels and their state in the control group.' }, + } + ), + labelPosition: schema.oneOf( + Object.values(CONTROL_LABEL_POSITION_OPTIONS).map((value) => schema.literal(value)) as [ + Type + ], + { + defaultValue: DEFAULT_CONTROL_LABEL_POSITION, + meta: { + description: 'Position of the labels for controls. For example, "oneLine", "twoLine".', + }, + } + ), + chainingSystem: schema.oneOf( + Object.values(CONTROL_CHAINING_OPTIONS).map((value) => schema.literal(value)) as [ + Type + ], + { + defaultValue: DEFAULT_CONTROL_CHAINING, + meta: { + description: + 'The chaining strategy for multiple controls. For example, "HIERARCHICAL" or "NONE".', + }, + } + ), + enhancements: schema.maybe(schema.recordOf(schema.string(), schema.any())), + ignoreParentSettings: schema.object({ + ignoreFilters: schema.boolean({ + meta: { description: 'Ignore global filters in controls.' }, + defaultValue: DEFAULT_IGNORE_PARENT_SETTINGS.ignoreFilters, + }), + ignoreQuery: schema.boolean({ + meta: { description: 'Ignore the global query bar in controls.' }, + defaultValue: DEFAULT_IGNORE_PARENT_SETTINGS.ignoreQuery, + }), + ignoreTimerange: schema.boolean({ + meta: { description: 'Ignore the global time range in controls.' }, + defaultValue: DEFAULT_IGNORE_PARENT_SETTINGS.ignoreTimerange, + }), + ignoreValidations: schema.boolean({ + meta: { description: 'Ignore validations in controls.' }, + defaultValue: DEFAULT_IGNORE_PARENT_SETTINGS.ignoreValidations, + }), + }), + autoApplySelections: schema.boolean({ + meta: { description: 'Show apply selections button in controls.' }, + defaultValue: DEFAULT_AUTO_APPLY_SELECTIONS, + }), +}); + +const searchSourceSchema = schema.object( + { + type: schema.maybe(schema.string()), + query: schema.maybe( + schema.object({ + query: schema.oneOf([ + schema.string({ + meta: { + description: + 'A text-based query such as Kibana Query Language (KQL) or Lucene query language.', + }, + }), + schema.recordOf(schema.string(), schema.any()), + ]), + language: schema.string({ + meta: { description: 'The query language such as KQL or Lucene.' }, + }), + }) + ), + filter: schema.maybe( + schema.arrayOf( + schema.object( + { + meta: schema.object( + { + alias: schema.maybe(schema.nullable(schema.string())), + disabled: schema.maybe(schema.boolean()), + negate: schema.maybe(schema.boolean()), + controlledBy: schema.maybe(schema.string()), + group: schema.maybe(schema.string()), + index: schema.maybe(schema.string()), + isMultiIndex: schema.maybe(schema.boolean()), + type: schema.maybe(schema.string()), + key: schema.maybe(schema.string()), + params: schema.maybe(schema.any()), + value: schema.maybe(schema.string()), + field: schema.maybe(schema.string()), + }, + { unknowns: 'allow' } + ), + query: schema.maybe(schema.recordOf(schema.string(), schema.any())), + $state: schema.maybe( + schema.object({ + store: schema.oneOf( + [ + schema.literal(FilterStateStore.APP_STATE), + schema.literal(FilterStateStore.GLOBAL_STATE), + ], + { + meta: { + description: + "Denote whether a filter is specific to an application's context (e.g. 'appState') or whether it should be applied globally (e.g. 'globalState').", + }, + } + ), + }) + ), + }, + { meta: { description: 'A filter for the search source.' } } + ) + ) + ), + sort: schema.maybe( + schema.arrayOf( + schema.recordOf( + schema.string(), + schema.oneOf([ + schema.oneOf([schema.literal(SortDirection.asc), schema.literal(SortDirection.desc)]), + schema.object({ + order: schema.oneOf([ + schema.literal(SortDirection.asc), + schema.literal(SortDirection.desc), + ]), + format: schema.maybe(schema.string()), + }), + schema.object({ + order: schema.oneOf([ + schema.literal(SortDirection.asc), + schema.literal(SortDirection.desc), + ]), + numeric_type: schema.maybe( + schema.oneOf([ + schema.literal('double'), + schema.literal('long'), + schema.literal('date'), + schema.literal('date_nanos'), + ]) + ), + }), + ]) + ) + ) + ), + }, + /** + The Dashboard _should_ only ever uses the query and filters fields on the search + source. But we should be liberal in what we accept, so we allow unknowns. + */ + { defaultValue: {}, unknowns: 'allow' } +); + +export const gridDataSchema = schema.object({ + x: schema.number({ meta: { description: 'The x coordinate of the panel in grid units' } }), + y: schema.number({ meta: { description: 'The y coordinate of the panel in grid units' } }), + w: schema.number({ + defaultValue: DEFAULT_PANEL_WIDTH, + min: 1, + max: DASHBOARD_GRID_COLUMN_COUNT, + meta: { description: 'The width of the panel in grid units' }, + }), + h: schema.number({ + defaultValue: DEFAULT_PANEL_HEIGHT, + min: 1, + meta: { description: 'The height of the panel in grid units' }, + }), + i: schema.string({ + meta: { description: 'The unique identifier of the panel' }, + defaultValue: uuidv4(), + }), +}); + +export const panelSchema = schema.object({ + panelConfig: schema.object( + { + version: schema.maybe( + schema.string({ + meta: { description: 'The version of the embeddable in the panel.' }, + }) + ), + title: schema.maybe(schema.string({ meta: { description: 'The title of the panel' } })), + description: schema.maybe( + schema.string({ meta: { description: 'The description of the panel' } }) + ), + savedObjectId: schema.maybe( + schema.string({ + meta: { description: 'The unique id of the library item to construct the embeddable.' }, + }) + ), + hidePanelTitles: schema.maybe( + schema.boolean({ + defaultValue: false, + meta: { description: 'Set to true to hide the panel title in its container.' }, + }) + ), + enhancements: schema.maybe(schema.recordOf(schema.string(), schema.any())), + }, + { + unknowns: 'allow', + } + ), + id: schema.maybe( + schema.string({ meta: { description: 'The saved object id for by reference panels' } }) + ), + type: schema.string({ meta: { description: 'The embeddable type' } }), + panelRefName: schema.maybe(schema.string()), + gridData: gridDataSchema, + panelIndex: schema.string({ + meta: { description: 'The unique ID of the panel.' }, + defaultValue: schema.siblingRef('gridData.i'), + }), + title: schema.maybe(schema.string({ meta: { description: 'The title of the panel' } })), + version: schema.maybe( + schema.string({ + meta: { + description: + "The version was used to store Kibana version information from versions 7.3.0 -> 8.11.0. As of version 8.11.0, the versioning information is now per-embeddable-type and is stored on the embeddable's input. (panelConfig in this type).", + deprecated: true, + }, + }) + ), +}); + +export const optionsSchema = schema.object({ + hidePanelTitles: schema.boolean({ + defaultValue: DEFAULT_DASHBOARD_OPTIONS.hidePanelTitles, + meta: { description: 'Hide the panel titles in the dashboard.' }, + }), + useMargins: schema.boolean({ + defaultValue: DEFAULT_DASHBOARD_OPTIONS.useMargins, + meta: { description: 'Show margins between panels in the dashboard layout.' }, + }), + syncColors: schema.boolean({ + defaultValue: DEFAULT_DASHBOARD_OPTIONS.syncColors, + meta: { description: 'Synchronize colors between related panels in the dashboard.' }, + }), + syncTooltips: schema.boolean({ + defaultValue: DEFAULT_DASHBOARD_OPTIONS.syncTooltips, + meta: { description: 'Synchronize tooltips between related panels in the dashboard.' }, + }), + syncCursor: schema.boolean({ + defaultValue: DEFAULT_DASHBOARD_OPTIONS.syncCursor, + meta: { description: 'Synchronize cursor position between related panels in the dashboard.' }, + }), +}); + +// These are the attributes that are returned in search results +export const searchResultsAttributesSchema = schema.object({ + title: schema.string({ meta: { description: 'A human-readable title for the dashboard' } }), + description: schema.string({ defaultValue: '', meta: { description: 'A short description.' } }), + timeRestore: schema.boolean({ + defaultValue: false, + meta: { description: 'Whether to restore time upon viewing this dashboard' }, + }), +}); + +export const dashboardAttributesSchema = searchResultsAttributesSchema.extends({ + // Search + kibanaSavedObjectMeta: schema.object( + { + searchSource: schema.maybe(searchSourceSchema), + }, + { + meta: { + description: 'A container for various metadata', + }, + defaultValue: {}, + } + ), + // Time + timeFrom: schema.maybe( + schema.string({ meta: { description: 'An ISO string indicating when to restore time from' } }) + ), + timeTo: schema.maybe( + schema.string({ meta: { description: 'An ISO string indicating when to restore time from' } }) + ), + refreshInterval: schema.maybe( + schema.object( + { + pause: schema.boolean({ + meta: { + description: + 'Whether the refresh interval is set to be paused while viewing the dashboard.', + }, + }), + value: schema.number({ + meta: { + description: 'A numeric value indicating refresh frequency in milliseconds.', + }, + }), + display: schema.maybe( + schema.string({ + meta: { + description: + 'A human-readable string indicating the refresh frequency. No longer used.', + deprecated: true, + }, + }) + ), + section: schema.maybe( + schema.number({ + meta: { + description: 'No longer used.', // TODO what is this legacy property? + deprecated: true, + }, + }) + ), + }, + { + meta: { + description: 'A container for various refresh interval settings', + }, + } + ) + ), + + // Dashboard Content + controlGroupInput: schema.maybe(controlGroupInputSchema), + panels: schema.arrayOf(panelSchema, { defaultValue: [] }), + options: optionsSchema, + version: schema.maybe(schema.number({ meta: { deprecated: true } })), +}); + +export const referenceSchema = schema.object( + { + name: schema.string(), + type: schema.string(), + id: schema.string(), + }, + { unknowns: 'forbid' } +); + +export const dashboardItemSchema = schema.object( + { + id: schema.string(), + type: schema.string(), + version: schema.maybe(schema.string()), + createdAt: schema.maybe(schema.string()), + updatedAt: schema.maybe(schema.string()), + createdBy: schema.maybe(schema.string()), + updatedBy: schema.maybe(schema.string()), + managed: schema.maybe(schema.boolean()), + error: schema.maybe(apiError), + attributes: dashboardAttributesSchema, + references: schema.arrayOf(referenceSchema), + namespaces: schema.maybe(schema.arrayOf(schema.string())), + originId: schema.maybe(schema.string()), + }, + { unknowns: 'allow' } +); + +export const dashboardSearchResultsSchema = dashboardItemSchema.extends({ + attributes: searchResultsAttributesSchema, +}); + +export const dashboardSearchOptionsSchema = schema.maybe( + schema.object( + { + onlyTitle: schema.maybe(schema.boolean()), + fields: schema.maybe(schema.arrayOf(schema.string())), + kuery: schema.maybe(schema.string()), + cursor: schema.maybe(schema.number()), + limit: schema.maybe(schema.number()), + }, + { unknowns: 'forbid' } + ) +); + +export const dashboardCreateOptionsSchema = schema.object({ + id: schema.maybe(createOptionsSchemas.id), + overwrite: schema.maybe(createOptionsSchemas.overwrite), + references: schema.maybe(schema.arrayOf(referenceSchema)), + initialNamespaces: schema.maybe(createOptionsSchemas.initialNamespaces), +}); + +export const dashboardUpdateOptionsSchema = schema.object({ + references: schema.maybe(schema.arrayOf(referenceSchema)), + mergeAttributes: schema.maybe(updateOptionsSchema.mergeAttributes), +}); + +export const dashboardGetResultSchema = schema.object( + { + item: dashboardItemSchema, + meta: schema.object( + { + outcome: schema.oneOf([ + schema.literal('exactMatch'), + schema.literal('aliasMatch'), + schema.literal('conflict'), + ]), + aliasTargetId: schema.maybe(schema.string()), + aliasPurpose: schema.maybe( + schema.oneOf([ + schema.literal('savedObjectConversion'), + schema.literal('savedObjectImport'), + ]) + ), + }, + { unknowns: 'forbid' } + ), + }, + { unknowns: 'forbid' } +); + +export const dashboardCreateResultSchema = schema.object( + { + item: dashboardItemSchema, + }, + { unknowns: 'forbid' } +); + +export const serviceDefinition: ServicesDefinition = { + get: { + out: { + result: { + schema: dashboardGetResultSchema, + down: getResultV3ToV2, + }, + }, + }, + create: { + in: { + options: { + schema: dashboardCreateOptionsSchema, + }, + data: { + schema: dashboardAttributesSchema, + }, + }, + out: { + result: { + schema: dashboardCreateResultSchema, + }, + }, + }, + update: { + in: { + options: { + schema: dashboardUpdateOptionsSchema, + }, + data: { + schema: dashboardAttributesSchema, + }, + }, + }, + search: { + in: { + options: { + schema: dashboardSearchOptionsSchema, + }, + }, + }, + mSearch: { + out: { + result: { + schema: dashboardItemSchema, + }, + }, + }, +}; diff --git a/src/plugins/dashboard/server/content_management/v3/index.ts b/src/plugins/dashboard/server/content_management/v3/index.ts new file mode 100644 index 0000000000000..7be9313c3210e --- /dev/null +++ b/src/plugins/dashboard/server/content_management/v3/index.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 type { + ControlGroupAttributes, + GridData, + DashboardPanel, + DashboardAttributes, + DashboardItem, + DashboardGetIn, + DashboardGetOut, + DashboardCreateIn, + DashboardCreateOut, + DashboardCreateOptions, + DashboardSearchIn, + DashboardSearchOut, + DashboardSearchOptions, + DashboardUpdateIn, + DashboardUpdateOut, + DashboardUpdateOptions, + DashboardOptions, +} from './types'; +export { + serviceDefinition, + dashboardAttributesSchema, + dashboardGetResultSchema, + dashboardCreateResultSchema, + dashboardItemSchema, + dashboardSearchResultsSchema, + referenceSchema, +} from './cm_services'; +export { + dashboardAttributesOut, + itemAttrsToSavedObjectAttrs, + savedObjectToItem, +} from './transform_utils'; diff --git a/src/plugins/dashboard/server/content_management/v3/transform_utils.test.ts b/src/plugins/dashboard/server/content_management/v3/transform_utils.test.ts new file mode 100644 index 0000000000000..627f1c4211033 --- /dev/null +++ b/src/plugins/dashboard/server/content_management/v3/transform_utils.test.ts @@ -0,0 +1,551 @@ +/* + * Copyright 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 { SavedObject } from '@kbn/core-saved-objects-api-server'; +import type { + DashboardSavedObjectAttributes, + SavedDashboardPanel, +} from '../../dashboard_saved_object'; +import type { DashboardAttributes, DashboardItem } from './types'; +import { + dashboardAttributesOut, + getResultV3ToV2, + itemAttrsToSavedObjectAttrs, + savedObjectToItem, +} from './transform_utils'; +import { + DEFAULT_AUTO_APPLY_SELECTIONS, + DEFAULT_CONTROL_CHAINING, + DEFAULT_CONTROL_GROW, + DEFAULT_CONTROL_LABEL_POSITION, + DEFAULT_CONTROL_WIDTH, + DEFAULT_IGNORE_PARENT_SETTINGS, + ControlLabelPosition, + ControlGroupChainingSystem, + ControlWidth, +} from '@kbn/controls-plugin/common'; +import { DEFAULT_DASHBOARD_OPTIONS } from '../../../common/content_management'; + +describe('dashboardAttributesOut', () => { + const controlGroupInputControlsSo = { + explicitInput: { anyKey: 'some value' }, + type: 'type1', + order: 0, + }; + + const panelsSo: SavedDashboardPanel[] = [ + { + embeddableConfig: { enhancements: {} }, + gridData: { x: 0, y: 0, w: 10, h: 10, i: '1' }, + id: '1', + panelIndex: '1', + panelRefName: 'ref1', + title: 'title1', + type: 'type1', + version: '2', + }, + ]; + + it('should set default values if not provided', () => { + const input: DashboardSavedObjectAttributes = { + controlGroupInput: { + panelsJSON: JSON.stringify({ foo: controlGroupInputControlsSo }), + }, + panelsJSON: JSON.stringify(panelsSo), + optionsJSON: JSON.stringify({ + hidePanelTitles: false, + }), + kibanaSavedObjectMeta: {}, + title: 'my title', + description: 'my description', + }; + expect(dashboardAttributesOut(input)).toEqual({ + controlGroupInput: { + chainingSystem: DEFAULT_CONTROL_CHAINING, + labelPosition: DEFAULT_CONTROL_LABEL_POSITION, + ignoreParentSettings: DEFAULT_IGNORE_PARENT_SETTINGS, + autoApplySelections: DEFAULT_AUTO_APPLY_SELECTIONS, + controls: [ + { + controlConfig: { anyKey: 'some value' }, + grow: DEFAULT_CONTROL_GROW, + id: 'foo', + order: 0, + type: 'type1', + width: DEFAULT_CONTROL_WIDTH, + }, + ], + }, + description: 'my description', + kibanaSavedObjectMeta: {}, + options: { + ...DEFAULT_DASHBOARD_OPTIONS, + hidePanelTitles: false, + }, + panels: [ + { + panelConfig: { enhancements: {} }, + gridData: { x: 0, y: 0, w: 10, h: 10, i: '1' }, + id: '1', + panelIndex: '1', + panelRefName: 'ref1', + title: 'title1', + type: 'type1', + version: '2', + }, + ], + timeRestore: false, + title: 'my title', + }); + }); + + it('should transform full attributes correctly', () => { + const input: DashboardSavedObjectAttributes = { + controlGroupInput: { + panelsJSON: JSON.stringify({ + foo: { + ...controlGroupInputControlsSo, + grow: false, + width: 'small', + }, + }), + ignoreParentSettingsJSON: JSON.stringify({ ignoreFilters: true }), + controlStyle: 'twoLine', + chainingSystem: 'NONE', + showApplySelections: true, + }, + description: 'description', + kibanaSavedObjectMeta: { + searchSourceJSON: JSON.stringify({ query: { query: 'test', language: 'KQL' } }), + }, + optionsJSON: JSON.stringify({ + hidePanelTitles: true, + useMargins: false, + syncColors: false, + syncTooltips: false, + syncCursor: false, + }), + panelsJSON: JSON.stringify(panelsSo), + refreshInterval: { pause: true, value: 1000 }, + timeFrom: 'now-15m', + timeRestore: true, + timeTo: 'now', + title: 'title', + }; + expect(dashboardAttributesOut(input)).toEqual({ + controlGroupInput: { + chainingSystem: 'NONE', + labelPosition: 'twoLine', + ignoreParentSettings: { + ignoreFilters: true, + ignoreQuery: false, + ignoreTimerange: false, + ignoreValidations: false, + }, + autoApplySelections: false, + controls: [ + { + controlConfig: { + anyKey: 'some value', + }, + id: 'foo', + grow: false, + width: 'small', + order: 0, + type: 'type1', + }, + ], + }, + description: 'description', + kibanaSavedObjectMeta: { + searchSource: { query: { query: 'test', language: 'KQL' } }, + }, + options: { + hidePanelTitles: true, + useMargins: false, + syncColors: false, + syncTooltips: false, + syncCursor: false, + }, + panels: [ + { + panelConfig: { + enhancements: {}, + }, + gridData: { + x: 0, + y: 0, + w: 10, + h: 10, + i: '1', + }, + id: '1', + panelIndex: '1', + panelRefName: 'ref1', + title: 'title1', + type: 'type1', + version: '2', + }, + ], + refreshInterval: { + pause: true, + value: 1000, + }, + timeFrom: 'now-15m', + timeRestore: true, + timeTo: 'now', + title: 'title', + }); + }); +}); + +describe('itemAttrsToSavedObjectAttrs', () => { + it('should transform item attributes to saved object attributes correctly', () => { + const input: DashboardAttributes = { + controlGroupInput: { + chainingSystem: 'NONE', + labelPosition: 'twoLine', + controls: [ + { + controlConfig: { anyKey: 'some value' }, + grow: false, + id: 'foo', + order: 0, + type: 'type1', + width: 'small', + }, + ], + ignoreParentSettings: { + ignoreFilters: true, + ignoreQuery: true, + ignoreTimerange: true, + ignoreValidations: true, + }, + autoApplySelections: false, + }, + description: 'description', + kibanaSavedObjectMeta: { searchSource: { query: { query: 'test', language: 'KQL' } } }, + options: { + hidePanelTitles: true, + useMargins: false, + syncColors: false, + syncTooltips: false, + syncCursor: false, + }, + panels: [ + { + gridData: { x: 0, y: 0, w: 10, h: 10, i: '1' }, + id: '1', + panelConfig: { enhancements: {} }, + panelIndex: '1', + panelRefName: 'ref1', + title: 'title1', + type: 'type1', + version: '2', + }, + ], + timeRestore: true, + title: 'title', + refreshInterval: { pause: true, value: 1000 }, + timeFrom: 'now-15m', + timeTo: 'now', + }; + + const output = itemAttrsToSavedObjectAttrs(input); + expect(output).toMatchInlineSnapshot(` + Object { + "attributes": Object { + "controlGroupInput": Object { + "chainingSystem": "NONE", + "controlStyle": "twoLine", + "ignoreParentSettingsJSON": "{\\"ignoreFilters\\":true,\\"ignoreQuery\\":true,\\"ignoreTimerange\\":true,\\"ignoreValidations\\":true}", + "panelsJSON": "{\\"foo\\":{\\"grow\\":false,\\"order\\":0,\\"type\\":\\"type1\\",\\"width\\":\\"small\\",\\"explicitInput\\":{\\"anyKey\\":\\"some value\\",\\"id\\":\\"foo\\"}}}", + "showApplySelections": true, + }, + "description": "description", + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": "{\\"query\\":{\\"query\\":\\"test\\",\\"language\\":\\"KQL\\"}}", + }, + "optionsJSON": "{\\"hidePanelTitles\\":true,\\"useMargins\\":false,\\"syncColors\\":false,\\"syncTooltips\\":false,\\"syncCursor\\":false}", + "panelsJSON": "[{\\"id\\":\\"1\\",\\"panelRefName\\":\\"ref1\\",\\"title\\":\\"title1\\",\\"type\\":\\"type1\\",\\"version\\":\\"2\\",\\"embeddableConfig\\":{\\"enhancements\\":{}},\\"panelIndex\\":\\"1\\",\\"gridData\\":{\\"x\\":0,\\"y\\":0,\\"w\\":10,\\"h\\":10,\\"i\\":\\"1\\"}}]", + "refreshInterval": Object { + "pause": true, + "value": 1000, + }, + "timeFrom": "now-15m", + "timeRestore": true, + "timeTo": "now", + "title": "title", + }, + "error": null, + } + `); + }); + + it('should handle missing optional attributes', () => { + const input: DashboardAttributes = { + title: 'title', + description: 'my description', + timeRestore: false, + panels: [], + options: DEFAULT_DASHBOARD_OPTIONS, + kibanaSavedObjectMeta: {}, + }; + + const output = itemAttrsToSavedObjectAttrs(input); + expect(output).toMatchInlineSnapshot(` + Object { + "attributes": Object { + "description": "my description", + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": "{}", + }, + "optionsJSON": "{\\"hidePanelTitles\\":false,\\"useMargins\\":true,\\"syncColors\\":true,\\"syncCursor\\":true,\\"syncTooltips\\":true}", + "panelsJSON": "[]", + "timeRestore": false, + "title": "title", + }, + "error": null, + } + `); + }); +}); + +describe('savedObjectToItem', () => { + const commonSavedObject: SavedObject = { + references: [], + id: '3d8459d9-0f1a-403d-aa82-6d93713a54b5', + type: 'dashboard', + attributes: {}, + }; + + const getSavedObjectForAttributes = ( + attributes: DashboardSavedObjectAttributes + ): SavedObject => { + return { + ...commonSavedObject, + attributes, + }; + }; + it('should convert saved object to item with all attributes', () => { + const input = getSavedObjectForAttributes({ + title: 'title', + description: 'description', + timeRestore: true, + panelsJSON: JSON.stringify([ + { + embeddableConfig: { enhancements: {} }, + gridData: { x: 0, y: 0, w: 10, h: 10, i: '1' }, + id: '1', + panelIndex: '1', + panelRefName: 'ref1', + title: 'title1', + type: 'type1', + version: '2', + }, + ]), + optionsJSON: JSON.stringify({ + hidePanelTitles: true, + useMargins: false, + syncColors: false, + syncTooltips: false, + syncCursor: false, + }), + kibanaSavedObjectMeta: { + searchSourceJSON: '{"query":{"query":"test","language":"KQL"}}', + }, + }); + + const { item, error } = savedObjectToItem(input, false); + expect(error).toBeNull(); + expect(item).toEqual({ + ...commonSavedObject, + attributes: { + title: 'title', + description: 'description', + timeRestore: true, + panels: [ + { + panelConfig: { enhancements: {} }, + gridData: { x: 0, y: 0, w: 10, h: 10, i: '1' }, + id: '1', + panelIndex: '1', + panelRefName: 'ref1', + title: 'title1', + type: 'type1', + version: '2', + }, + ], + options: { + hidePanelTitles: true, + useMargins: false, + syncColors: false, + syncTooltips: false, + syncCursor: false, + }, + kibanaSavedObjectMeta: { + searchSource: { query: { query: 'test', language: 'KQL' } }, + }, + }, + }); + }); + + it('should handle missing optional attributes', () => { + const input = getSavedObjectForAttributes({ + title: 'title', + description: 'description', + timeRestore: false, + panelsJSON: '[]', + optionsJSON: '{}', + kibanaSavedObjectMeta: {}, + }); + + const { item, error } = savedObjectToItem(input, false); + expect(error).toBeNull(); + expect(item).toEqual({ + ...commonSavedObject, + attributes: { + title: 'title', + description: 'description', + timeRestore: false, + panels: [], + options: DEFAULT_DASHBOARD_OPTIONS, + kibanaSavedObjectMeta: {}, + }, + }); + }); + + it('should handle partial saved object', () => { + const input = { + ...commonSavedObject, + references: undefined, + attributes: { + title: 'title', + description: 'my description', + timeRestore: false, + }, + }; + + const { item, error } = savedObjectToItem(input, true, ['title', 'description']); + expect(error).toBeNull(); + expect(item).toEqual({ + ...commonSavedObject, + references: undefined, + attributes: { + title: 'title', + description: 'my description', + }, + }); + }); + + it('should return an error if attributes can not be parsed', () => { + const input = { + ...commonSavedObject, + references: undefined, + attributes: { + title: 'title', + panelsJSON: 'not stringified json', + }, + }; + const { item, error } = savedObjectToItem(input, true); + expect(item).toBeNull(); + expect(error).not.toBe(null); + }); +}); + +describe('getResultV3ToV2', () => { + const commonAttributes = { + description: 'description', + refreshInterval: { pause: true, value: 1000 }, + timeFrom: 'now-15m', + timeRestore: true, + timeTo: 'now', + title: 'title', + }; + it('should transform a v3 result to a v2 result with all attributes', () => { + const v3Result = { + meta: { outcome: 'exactMatch' as const }, + item: { + id: '1', + type: 'dashboard', + attributes: { + ...commonAttributes, + controlGroupInput: { + chainingSystem: 'NONE' as ControlGroupChainingSystem, + labelPosition: 'twoLine' as ControlLabelPosition, + controls: [ + { + controlConfig: { bizz: 'buzz' }, + grow: false, + order: 0, + id: 'foo', + type: 'type1', + width: 'small' as ControlWidth, + }, + ], + ignoreParentSettings: { + ignoreFilters: true, + ignoreQuery: true, + ignoreTimerange: true, + ignoreValidations: true, + }, + autoApplySelections: false, + }, + kibanaSavedObjectMeta: { searchSource: { query: { query: 'test', language: 'KQL' } } }, + options: { + hidePanelTitles: true, + useMargins: false, + syncColors: false, + syncCursor: false, + syncTooltips: false, + }, + panels: [ + { + id: '1', + type: 'visualization', + panelConfig: { title: 'my panel' }, + gridData: { x: 0, y: 0, w: 15, h: 15, i: 'foo' }, + panelIndex: 'foo', + }, + ], + }, + references: [], + }, + }; + + const output = getResultV3ToV2(v3Result); + + // Common attributes should match between v2 and v3 + expect(output.item.attributes).toMatchObject(commonAttributes); + expect(output.item.attributes.controlGroupInput).toMatchObject({ + chainingSystem: 'NONE', + controlStyle: 'twoLine', + showApplySelections: true, + }); + + // Check transformed attributes + expect(output.item.attributes.controlGroupInput!.panelsJSON).toMatchInlineSnapshot( + `"{\\"foo\\":{\\"grow\\":false,\\"order\\":0,\\"type\\":\\"type1\\",\\"width\\":\\"small\\",\\"explicitInput\\":{\\"bizz\\":\\"buzz\\",\\"id\\":\\"foo\\"}}}"` + ); + expect( + output.item.attributes.controlGroupInput!.ignoreParentSettingsJSON + ).toMatchInlineSnapshot( + `"{\\"ignoreFilters\\":true,\\"ignoreQuery\\":true,\\"ignoreTimerange\\":true,\\"ignoreValidations\\":true}"` + ); + expect(output.item.attributes.kibanaSavedObjectMeta.searchSourceJSON).toMatchInlineSnapshot( + `"{\\"query\\":{\\"query\\":\\"test\\",\\"language\\":\\"KQL\\"}}"` + ); + expect(output.item.attributes.optionsJSON).toMatchInlineSnapshot( + `"{\\"hidePanelTitles\\":true,\\"useMargins\\":false,\\"syncColors\\":false,\\"syncCursor\\":false,\\"syncTooltips\\":false}"` + ); + expect(output.item.attributes.panelsJSON).toMatchInlineSnapshot( + `"[{\\"id\\":\\"1\\",\\"type\\":\\"visualization\\",\\"embeddableConfig\\":{\\"title\\":\\"my panel\\"},\\"panelIndex\\":\\"foo\\",\\"gridData\\":{\\"x\\":0,\\"y\\":0,\\"w\\":15,\\"h\\":15,\\"i\\":\\"foo\\"}}]"` + ); + }); +}); diff --git a/src/plugins/dashboard/server/content_management/v3/transform_utils.ts b/src/plugins/dashboard/server/content_management/v3/transform_utils.ts new file mode 100644 index 0000000000000..843dd59f849f3 --- /dev/null +++ b/src/plugins/dashboard/server/content_management/v3/transform_utils.ts @@ -0,0 +1,365 @@ +/* + * Copyright 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 { v4 as uuidv4 } from 'uuid'; +import { pick } from 'lodash'; + +import type { Query } from '@kbn/es-query'; +import { + type ControlGroupChainingSystem, + type ControlLabelPosition, + type ControlPanelsState, + type SerializedControlState, + DEFAULT_AUTO_APPLY_SELECTIONS, + DEFAULT_CONTROL_CHAINING, + DEFAULT_CONTROL_GROW, + DEFAULT_CONTROL_LABEL_POSITION, + DEFAULT_CONTROL_WIDTH, + DEFAULT_IGNORE_PARENT_SETTINGS, +} from '@kbn/controls-plugin/common'; +import { SerializedSearchSourceFields, parseSearchSourceJSON } from '@kbn/data-plugin/common'; + +import type { SavedObject, SavedObjectReference } from '@kbn/core-saved-objects-api-server'; +import type { + ControlGroupAttributes, + DashboardAttributes, + DashboardGetOut, + DashboardItem, + DashboardOptions, + ItemAttrsToSavedObjectAttrsReturn, + PartialDashboardItem, + SavedObjectToItemReturn, +} from './types'; +import type { + DashboardSavedObjectAttributes, + SavedDashboardPanel, +} from '../../dashboard_saved_object'; +import type { + ControlGroupAttributes as ControlGroupAttributesV2, + DashboardCrudTypes as DashboardCrudTypesV2, +} from '../../../common/content_management/v2'; +import { DEFAULT_DASHBOARD_OPTIONS } from '../../../common/content_management'; + +function controlGroupInputOut( + controlGroupInput?: DashboardSavedObjectAttributes['controlGroupInput'] +): ControlGroupAttributes | undefined { + if (!controlGroupInput) { + return; + } + const { + panelsJSON, + ignoreParentSettingsJSON, + controlStyle = DEFAULT_CONTROL_LABEL_POSITION, + chainingSystem = DEFAULT_CONTROL_CHAINING, + showApplySelections = !DEFAULT_AUTO_APPLY_SELECTIONS, + } = controlGroupInput; + const controls = panelsJSON + ? Object.entries(JSON.parse(panelsJSON) as ControlPanelsState).map( + ([ + id, + { + explicitInput, + type, + grow = DEFAULT_CONTROL_GROW, + width = DEFAULT_CONTROL_WIDTH, + order, + }, + ]) => ({ + controlConfig: explicitInput, + id, + grow, + order, + type, + width, + }) + ) + : []; + + const { + ignoreFilters = DEFAULT_IGNORE_PARENT_SETTINGS.ignoreFilters, + ignoreQuery = DEFAULT_IGNORE_PARENT_SETTINGS.ignoreQuery, + ignoreTimerange = DEFAULT_IGNORE_PARENT_SETTINGS.ignoreTimerange, + ignoreValidations = DEFAULT_IGNORE_PARENT_SETTINGS.ignoreValidations, + } = ignoreParentSettingsJSON ? JSON.parse(ignoreParentSettingsJSON) : {}; + + // try to maintain a consistent (alphabetical) order of keys + return { + autoApplySelections: !showApplySelections, + chainingSystem: chainingSystem as ControlGroupChainingSystem, + controls, + labelPosition: controlStyle as ControlLabelPosition, + ignoreParentSettings: { ignoreFilters, ignoreQuery, ignoreTimerange, ignoreValidations }, + }; +} + +function kibanaSavedObjectMetaOut( + kibanaSavedObjectMeta: DashboardSavedObjectAttributes['kibanaSavedObjectMeta'] +): DashboardAttributes['kibanaSavedObjectMeta'] { + const { searchSourceJSON } = kibanaSavedObjectMeta; + if (!searchSourceJSON) { + return {}; + } + // Dashboards do not yet support ES|QL (AggregateQuery) in the search source + return { + searchSource: parseSearchSourceJSON(searchSourceJSON) as Omit< + SerializedSearchSourceFields, + 'query' + > & { query?: Query }, + }; +} + +function optionsOut(optionsJSON: string): DashboardAttributes['options'] { + const { + hidePanelTitles = DEFAULT_DASHBOARD_OPTIONS.hidePanelTitles, + useMargins = DEFAULT_DASHBOARD_OPTIONS.useMargins, + syncColors = DEFAULT_DASHBOARD_OPTIONS.syncColors, + syncCursor = DEFAULT_DASHBOARD_OPTIONS.syncCursor, + syncTooltips = DEFAULT_DASHBOARD_OPTIONS.syncTooltips, + } = JSON.parse(optionsJSON) as DashboardOptions; + return { + hidePanelTitles, + useMargins, + syncColors, + syncCursor, + syncTooltips, + }; +} + +function panelsOut(panelsJSON: string): DashboardAttributes['panels'] { + const panels = JSON.parse(panelsJSON) as SavedDashboardPanel[]; + return panels.map( + ({ embeddableConfig, gridData, id, panelIndex, panelRefName, title, type, version }) => ({ + gridData, + id, + panelConfig: embeddableConfig, + panelIndex, + panelRefName, + title, + type, + version, + }) + ); +} + +export function dashboardAttributesOut( + attributes: DashboardSavedObjectAttributes | Partial +): DashboardAttributes | Partial { + const { + controlGroupInput, + description, + kibanaSavedObjectMeta, + optionsJSON, + panelsJSON, + refreshInterval, + timeFrom, + timeRestore, + timeTo, + title, + version, + } = attributes; + // try to maintain a consistent (alphabetical) order of keys + return { + ...(controlGroupInput && { controlGroupInput: controlGroupInputOut(controlGroupInput) }), + ...(description && { description }), + ...(kibanaSavedObjectMeta && { + kibanaSavedObjectMeta: kibanaSavedObjectMetaOut(kibanaSavedObjectMeta), + }), + ...(optionsJSON && { options: optionsOut(optionsJSON) }), + ...(panelsJSON && { panels: panelsOut(panelsJSON) }), + ...(refreshInterval && { + refreshInterval: { pause: refreshInterval.pause, value: refreshInterval.value }, + }), + ...(timeFrom && { timeFrom }), + timeRestore: timeRestore ?? false, + ...(timeTo && { timeTo }), + title, + ...(version && { version }), + }; +} + +function controlGroupInputIn( + controlGroupInput?: ControlGroupAttributes +): DashboardSavedObjectAttributes['controlGroupInput'] | undefined { + if (!controlGroupInput) { + return; + } + const { controls, ignoreParentSettings, labelPosition, chainingSystem, autoApplySelections } = + controlGroupInput; + const updatedControls = Object.fromEntries( + controls.map(({ controlConfig, id = uuidv4(), ...restOfControl }) => { + return [id, { ...restOfControl, explicitInput: { ...controlConfig, id } }]; + }) + ); + return { + chainingSystem, + controlStyle: labelPosition, + ignoreParentSettingsJSON: JSON.stringify(ignoreParentSettings), + panelsJSON: JSON.stringify(updatedControls), + showApplySelections: !autoApplySelections, + }; +} + +function panelsIn( + panels: DashboardAttributes['panels'] +): DashboardSavedObjectAttributes['panelsJSON'] { + const updatedPanels = panels.map(({ panelIndex, gridData, panelConfig, ...restPanel }) => { + const idx = panelIndex ?? uuidv4(); + return { + ...restPanel, + embeddableConfig: panelConfig, + panelIndex: idx, + gridData: { + ...gridData, + i: idx, + }, + }; + }); + + return JSON.stringify(updatedPanels); +} + +function kibanaSavedObjectMetaIn( + kibanaSavedObjectMeta: DashboardAttributes['kibanaSavedObjectMeta'] +) { + const { searchSource } = kibanaSavedObjectMeta; + return { searchSourceJSON: JSON.stringify(searchSource ?? {}) }; +} + +export const getResultV3ToV2 = (result: DashboardGetOut): DashboardCrudTypesV2['GetOut'] => { + const { meta, item } = result; + const { attributes, ...rest } = item; + const { + controlGroupInput, + description, + kibanaSavedObjectMeta, + options, + panels, + refreshInterval, + timeFrom, + timeRestore, + timeTo, + title, + version, + } = attributes; + + const v2Attributes = { + ...(controlGroupInput && { + controlGroupInput: controlGroupInputIn(controlGroupInput) as ControlGroupAttributesV2, + }), + description, + ...(kibanaSavedObjectMeta && { + kibanaSavedObjectMeta: kibanaSavedObjectMetaIn(kibanaSavedObjectMeta), + }), + ...(options && { optionsJSON: JSON.stringify(options) }), + panelsJSON: panels ? panelsIn(panels) : '[]', + refreshInterval, + ...(timeFrom && { timeFrom }), + timeRestore, + ...(timeTo && { timeTo }), + title, + ...(version && { version }), + }; + return { + meta, + item: { + ...rest, + attributes: v2Attributes, + }, + }; +}; + +export const itemAttrsToSavedObjectAttrs = ( + attributes: DashboardAttributes +): ItemAttrsToSavedObjectAttrsReturn => { + try { + const { controlGroupInput, kibanaSavedObjectMeta, options, panels, ...rest } = attributes; + const soAttributes = { + ...rest, + ...(controlGroupInput && { + controlGroupInput: controlGroupInputIn(controlGroupInput), + }), + ...(options && { + optionsJSON: JSON.stringify(options), + }), + ...(panels && { + panelsJSON: panelsIn(panels), + }), + ...(kibanaSavedObjectMeta && { + kibanaSavedObjectMeta: kibanaSavedObjectMetaIn(kibanaSavedObjectMeta), + }), + }; + return { attributes: soAttributes, error: null }; + } catch (e) { + return { attributes: null, error: e }; + } +}; + +type PartialSavedObject = Omit>, 'references'> & { + references: SavedObjectReference[] | undefined; +}; + +export function savedObjectToItem( + savedObject: SavedObject, + partial: false, + allowedAttributes?: string[] +): SavedObjectToItemReturn; + +export function savedObjectToItem( + savedObject: PartialSavedObject, + partial: true, + allowedAttributes?: string[] +): SavedObjectToItemReturn; + +export function savedObjectToItem( + savedObject: + | SavedObject + | PartialSavedObject, + partial: boolean, + allowedAttributes?: string[] +): SavedObjectToItemReturn { + const { + id, + type, + updated_at: updatedAt, + updated_by: updatedBy, + created_at: createdAt, + created_by: createdBy, + attributes, + error, + namespaces, + references, + version, + managed, + } = savedObject; + + try { + const attributesOut = allowedAttributes + ? pick(dashboardAttributesOut(attributes), allowedAttributes) + : dashboardAttributesOut(attributes); + return { + item: { + id, + type, + updatedAt, + updatedBy, + createdAt, + createdBy, + attributes: attributesOut, + error, + namespaces, + references, + version, + managed, + }, + error: null, + }; + } catch (e) { + return { item: null, error: e }; + } +} diff --git a/src/plugins/dashboard/server/content_management/v3/types.ts b/src/plugins/dashboard/server/content_management/v3/types.ts new file mode 100644 index 0000000000000..36f277ff3b268 --- /dev/null +++ b/src/plugins/dashboard/server/content_management/v3/types.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { TypeOf } from '@kbn/config-schema'; +import { + CreateIn, + GetIn, + SearchIn, + SearchResult, + UpdateIn, +} from '@kbn/content-management-plugin/common'; +import { SavedObjectReference } from '@kbn/core-saved-objects-api-server'; +import { + dashboardItemSchema, + controlGroupInputSchema, + gridDataSchema, + panelSchema, + dashboardAttributesSchema, + dashboardCreateOptionsSchema, + dashboardCreateResultSchema, + dashboardGetResultSchema, + dashboardSearchOptionsSchema, + dashboardSearchResultsSchema, + dashboardUpdateOptionsSchema, + optionsSchema, +} from './cm_services'; +import { CONTENT_ID } from '../../../common/content_management'; +import { DashboardSavedObjectAttributes } from '../../dashboard_saved_object'; + +export type DashboardOptions = TypeOf; + +// Panel config has some defined types but also allows for custom keys added by embeddables +// The schema uses "unknowns: 'allow'" to permit any other keys, but the TypeOf helper does not +// recognize this, so we need to manually extend the type here. +export type DashboardPanel = Omit, 'panelConfig'> & { + panelConfig: TypeOf['panelConfig'] & { [key: string]: any }; +}; +export type DashboardAttributes = Omit, 'panels'> & { + panels: DashboardPanel[]; +}; + +export type DashboardItem = TypeOf; +export type PartialDashboardItem = Omit & { + attributes: Partial; + references: SavedObjectReference[] | undefined; +}; + +export type ControlGroupAttributes = TypeOf; +export type GridData = TypeOf; + +export type DashboardGetIn = GetIn; +export type DashboardGetOut = TypeOf; + +export type DashboardCreateIn = CreateIn; +export type DashboardCreateOut = TypeOf; +export type DashboardCreateOptions = TypeOf; + +export type DashboardUpdateIn = UpdateIn>; +export type DashboardUpdateOut = TypeOf; +export type DashboardUpdateOptions = TypeOf; + +export type DashboardSearchIn = SearchIn; +export type DashboardSearchOptions = TypeOf; +export type DashboardSearchOut = SearchResult>; + +export type SavedObjectToItemReturn = + | { + item: T; + error: null; + } + | { + item: null; + error: Error; + }; + +export type ItemAttrsToSavedObjectAttrsReturn = + | { + attributes: DashboardSavedObjectAttributes; + error: null; + } + | { + attributes: null; + error: Error; + }; diff --git a/src/plugins/dashboard/server/dashboard_saved_object/dashboard_saved_object.ts b/src/plugins/dashboard/server/dashboard_saved_object/dashboard_saved_object.ts index fc551d823377c..3b7f137cc1d96 100644 --- a/src/plugins/dashboard/server/dashboard_saved_object/dashboard_saved_object.ts +++ b/src/plugins/dashboard/server/dashboard_saved_object/dashboard_saved_object.ts @@ -10,19 +10,21 @@ import { ANALYTICS_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; import { SavedObjectsType } from '@kbn/core/server'; -import { dashboardAttributesSchema as dashboardAttributesSchemaV1 } from '../content_management/schema/v1'; -import { dashboardAttributesSchema as dashboardAttributesSchemaV2 } from '../content_management/schema/v2'; +import { dashboardAttributesSchema as dashboardAttributesSchemaV1 } from './schema/v1'; +import { dashboardAttributesSchema as dashboardAttributesSchemaV2 } from './schema/v2'; import { createDashboardSavedObjectTypeMigrations, DashboardSavedObjectTypeMigrationsDeps, } from './migrations/dashboard_saved_object_migrations'; +export const DASHBOARD_SAVED_OBJECT_TYPE = 'dashboard'; + export const createDashboardSavedObjectType = ({ migrationDeps, }: { migrationDeps: DashboardSavedObjectTypeMigrationsDeps; }): SavedObjectsType => ({ - name: 'dashboard', + name: DASHBOARD_SAVED_OBJECT_TYPE, indexPattern: ANALYTICS_SAVED_OBJECT_INDEX, hidden: false, namespaceType: 'multiple-isolated', diff --git a/src/plugins/dashboard/server/dashboard_saved_object/index.ts b/src/plugins/dashboard/server/dashboard_saved_object/index.ts index 912c508f0cedf..23c91f2c6ea35 100644 --- a/src/plugins/dashboard/server/dashboard_saved_object/index.ts +++ b/src/plugins/dashboard/server/dashboard_saved_object/index.ts @@ -7,4 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { createDashboardSavedObjectType } from './dashboard_saved_object'; +export { + createDashboardSavedObjectType, + DASHBOARD_SAVED_OBJECT_TYPE, +} from './dashboard_saved_object'; +export type { DashboardSavedObjectAttributes, GridData, SavedDashboardPanel } from './schema'; diff --git a/src/plugins/dashboard/server/dashboard_saved_object/migrations/dashboard_saved_object_migrations.test.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/dashboard_saved_object_migrations.test.ts index 7abb1523e3611..b5f5b6b20b312 100644 --- a/src/plugins/dashboard/server/dashboard_saved_object/migrations/dashboard_saved_object_migrations.test.ts +++ b/src/plugins/dashboard/server/dashboard_saved_object/migrations/dashboard_saved_object_migrations.test.ts @@ -613,12 +613,11 @@ describe('dashboard', () => { expect(newDoc).toMatchInlineSnapshot(` Object { "attributes": Object { - "description": "", "kibanaSavedObjectMeta": Object { "searchSourceJSON": "{\\"query\\":{\\"language\\":\\"kuery\\",\\"query\\":\\"\\"},\\"filter\\":[{\\"query\\":{\\"match_phrase\\":{\\"machine.os.keyword\\":\\"osx\\"}},\\"$state\\":{\\"store\\":\\"appState\\"},\\"meta\\":{\\"type\\":\\"phrase\\",\\"key\\":\\"machine.os.keyword\\",\\"params\\":{\\"query\\":\\"osx\\"},\\"disabled\\":false,\\"negate\\":false,\\"alias\\":null,\\"indexRefName\\":\\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\\"}}]}", }, - "optionsJSON": "{\\"useMargins\\":true,\\"hidePanelTitles\\":false}", - "panelsJSON": "[{\\"version\\":\\"7.9.3\\",\\"type\\":\\"visualization\\",\\"gridData\\":{\\"x\\":0,\\"y\\":0,\\"w\\":24,\\"h\\":15,\\"i\\":\\"82fa0882-9f9e-476a-bbb9-03555e5ced91\\"},\\"panelIndex\\":\\"82fa0882-9f9e-476a-bbb9-03555e5ced91\\",\\"embeddableConfig\\":{\\"enhancements\\":{\\"dynamicActions\\":{\\"events\\":[]}}},\\"panelRefName\\":\\"panel_82fa0882-9f9e-476a-bbb9-03555e5ced91\\"}]", + "optionsJSON": "{\\"hidePanelTitles\\":false,\\"useMargins\\":true,\\"syncColors\\":true,\\"syncCursor\\":true,\\"syncTooltips\\":true}", + "panelsJSON": "[{\\"version\\":\\"7.9.3\\",\\"type\\":\\"visualization\\",\\"panelRefName\\":\\"panel_82fa0882-9f9e-476a-bbb9-03555e5ced91\\",\\"embeddableConfig\\":{\\"enhancements\\":{\\"dynamicActions\\":{\\"events\\":[]}}},\\"panelIndex\\":\\"82fa0882-9f9e-476a-bbb9-03555e5ced91\\",\\"gridData\\":{\\"x\\":0,\\"y\\":0,\\"w\\":24,\\"h\\":15,\\"i\\":\\"82fa0882-9f9e-476a-bbb9-03555e5ced91\\"}}]", "timeRestore": false, "title": "Dashboard A", "version": 1, @@ -710,7 +709,7 @@ describe('dashboard', () => { contextMock ); expect(migratedDoc.attributes.panelsJSON).toMatchInlineSnapshot( - `"[{\\"version\\":\\"7.9.3\\",\\"gridData\\":{\\"x\\":0,\\"y\\":0,\\"w\\":24,\\"h\\":15,\\"i\\":\\"0\\"},\\"panelIndex\\":\\"0\\",\\"embeddableConfig\\":{}},{\\"version\\":\\"7.13.0\\",\\"gridData\\":{\\"x\\":24,\\"y\\":0,\\"w\\":24,\\"h\\":15,\\"i\\":\\"1\\"},\\"panelIndex\\":\\"1\\",\\"embeddableConfig\\":{\\"attributes\\":{\\"byValueThing\\":\\"ThisIsByValue\\"},\\"superCoolKey\\":\\"ONLY 4 BY VALUE EMBEDDABLES THANK YOU VERY MUCH\\"}}]"` + `"[{\\"version\\":\\"7.9.3\\",\\"gridData\\":{\\"x\\":0,\\"y\\":0,\\"w\\":24,\\"h\\":15,\\"i\\":\\"0\\"},\\"panelIndex\\":\\"0\\",\\"embeddableConfig\\":{}},{\\"gridData\\":{\\"x\\":24,\\"y\\":0,\\"w\\":24,\\"h\\":15,\\"i\\":\\"1\\"},\\"panelIndex\\":\\"1\\",\\"embeddableConfig\\":{\\"attributes\\":{\\"byValueThing\\":\\"ThisIsByValue\\"},\\"superCoolKey\\":\\"ONLY 4 BY VALUE EMBEDDABLES THANK YOU VERY MUCH\\"},\\"version\\":\\"7.13.0\\"}]"` ); }); }); diff --git a/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_by_value_dashboard_panels.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_by_value_dashboard_panels.ts index 1b1d04cdebf77..0e32e2feec300 100644 --- a/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_by_value_dashboard_panels.ts +++ b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_by_value_dashboard_panels.ts @@ -9,8 +9,8 @@ import { CONTROL_GROUP_TYPE } from '@kbn/controls-plugin/common'; import { - controlGroupSerializedStateToSerializableRuntimeState, - serializableRuntimeStateToControlGroupSerializedState, + controlGroupSavedObjectStateToSerializableRuntimeState, + serializableRuntimeStateToControlGroupSavedObjectState, } from '@kbn/controls-plugin/server'; import { Serializable, SerializableRecord } from '@kbn/utility-types'; import { SavedObjectMigrationFn } from '@kbn/core/server'; @@ -20,8 +20,8 @@ import { SavedObjectEmbeddableInput } from '@kbn/embeddable-plugin/common'; import { convertPanelStateToSavedDashboardPanel, convertSavedDashboardPanelToPanelState, -} from '../../../common'; -import { SavedDashboardPanel } from '../../../common/content_management'; +} from './utils'; +import type { SavedDashboardPanel } from '..'; type ValueOrReferenceInput = SavedObjectEmbeddableInput & { attributes?: Serializable; @@ -35,7 +35,7 @@ export const migrateByValueDashboardPanels = const { attributes } = doc; if (attributes?.controlGroupInput) { - const controlGroupState = controlGroupSerializedStateToSerializableRuntimeState( + const controlGroupState = controlGroupSavedObjectStateToSerializableRuntimeState( attributes.controlGroupInput ); const migratedControlGroupInput = migrate({ @@ -43,7 +43,7 @@ export const migrateByValueDashboardPanels = type: CONTROL_GROUP_TYPE, } as SerializableRecord); attributes.controlGroupInput = - serializableRuntimeStateToControlGroupSerializedState(migratedControlGroupInput); + serializableRuntimeStateToControlGroupSavedObjectState(migratedControlGroupInput); } // Skip if panelsJSON is missing otherwise this will cause saved object import to fail when diff --git a/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_extract_panel_references.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_extract_panel_references.ts index 1782a63beda5d..091ef21322671 100644 --- a/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_extract_panel_references.ts +++ b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_extract_panel_references.ts @@ -7,11 +7,12 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { SavedObjectMigrationFn } from '@kbn/core/server'; +import { SavedObject, SavedObjectMigrationFn } from '@kbn/core/server'; import { extractReferences, injectReferences } from '../../../common'; -import { DashboardAttributes } from '../../../common/content_management'; -import { DashboardSavedObjectTypeMigrationsDeps } from './dashboard_saved_object_migrations'; +import type { DashboardSavedObjectTypeMigrationsDeps } from './dashboard_saved_object_migrations'; +import type { DashboardSavedObjectAttributes } from '../schema'; +import { itemAttrsToSavedObjectAttrs, savedObjectToItem } from '../../content_management/latest'; /** * In 7.8.0 we introduced dashboard drilldowns which are stored inside dashboard saved object as part of embeddable state @@ -26,7 +27,7 @@ import { DashboardSavedObjectTypeMigrationsDeps } from './dashboard_saved_object */ export function createExtractPanelReferencesMigration( deps: DashboardSavedObjectTypeMigrationsDeps -): SavedObjectMigrationFn { +): SavedObjectMigrationFn { return (doc) => { const references = doc.references ?? []; @@ -36,19 +37,32 @@ export function createExtractPanelReferencesMigration( */ const oldNonPanelReferences = references.filter((ref) => !ref.name.startsWith('panel_')); + // Use Content Management to convert the saved object to the DashboardAttributes + // expected by injectReferences + const { item, error: itemError } = savedObjectToItem( + doc as unknown as SavedObject, + false + ); + + if (itemError) throw itemError; + + const parsedAttributes = item.attributes; const injectedAttributes = injectReferences( { - attributes: doc.attributes, + attributes: parsedAttributes, references, }, { embeddablePersistableStateService: deps.embeddable } ); - const { attributes, references: newPanelReferences } = extractReferences( + const { attributes: extractedAttributes, references: newPanelReferences } = extractReferences( { attributes: injectedAttributes, references: [] }, { embeddablePersistableStateService: deps.embeddable } ); + const { attributes, error: attributesError } = itemAttrsToSavedObjectAttrs(extractedAttributes); + if (attributesError) throw attributesError; + return { ...doc, references: [...oldNonPanelReferences, ...newPanelReferences], diff --git a/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_hidden_titles.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_hidden_titles.ts index c223ff0bc32a3..77c114315ba1f 100644 --- a/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_hidden_titles.ts +++ b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_hidden_titles.ts @@ -7,14 +7,14 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { SavedObjectMigrationFn } from '@kbn/core/server'; -import { EmbeddableInput } from '@kbn/embeddable-plugin/common'; +import type { SavedObjectMigrationFn } from '@kbn/core/server'; +import type { EmbeddableInput } from '@kbn/embeddable-plugin/common'; +import type { SavedDashboardPanel } from '../schema'; import { convertSavedDashboardPanelToPanelState, convertPanelStateToSavedDashboardPanel, -} from '../../../common'; -import { SavedDashboardPanel } from '../../../common/content_management'; +} from './utils'; /** * Before 7.10, hidden panel titles were stored as a blank string on the title attribute. In 7.10, this was replaced diff --git a/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/migrate_to_730_panels.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/migrate_to_730_panels.ts index ab05f64a2d711..e23ccfd00153d 100644 --- a/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/migrate_to_730_panels.ts +++ b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/migrate_to_730_panels.ts @@ -13,7 +13,7 @@ import semverSatisfies from 'semver/functions/satisfies'; import { i18n } from '@kbn/i18n'; import type { SerializableRecord } from '@kbn/utility-types'; -import { +import type { SavedDashboardPanel620, SavedDashboardPanel630, SavedDashboardPanel610, @@ -25,7 +25,7 @@ import { RawSavedDashboardPanel640To720, RawSavedDashboardPanel730ToLatest, } from './types'; -import { GridData } from '../../../../common/content_management'; +import type { GridData } from '../../../content_management'; const PANEL_HEIGHT_SCALE_FACTOR = 5; const PANEL_HEIGHT_SCALE_FACTOR_WITH_MARGINS = 4; diff --git a/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/migrations_730.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/migrations_730.ts index 6af69882b0774..6da3c1510530c 100644 --- a/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/migrations_730.ts +++ b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/migrations_730.ts @@ -49,7 +49,7 @@ export const migrations730 = (doc: DashboardDoc700To720, { log }: SavedObjectMig } try { - const searchSource = JSON.parse(doc.attributes.kibanaSavedObjectMeta.searchSourceJSON); + const searchSource = JSON.parse(doc.attributes.kibanaSavedObjectMeta.searchSourceJSON!); doc.attributes.kibanaSavedObjectMeta.searchSourceJSON = JSON.stringify( moveFiltersToQuery(searchSource) ); diff --git a/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/types.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/types.ts index 750fd736c9660..585b9c55d5012 100644 --- a/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/types.ts +++ b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/types.ts @@ -10,14 +10,12 @@ import type { Serializable } from '@kbn/utility-types'; import { SavedObjectReference } from '@kbn/core/server'; -import type { - GridData, - DashboardAttributes as CurrentDashboardAttributes, // Dashboard attributes from common are the source of truth for the current version. -} from '../../../../common/content_management'; +import type { GridData } from '../../../content_management'; +import type { DashboardSavedObjectAttributes } from '../../schema'; interface KibanaAttributes { kibanaSavedObjectMeta: { - searchSourceJSON: string; + searchSourceJSON?: string; }; } @@ -45,7 +43,7 @@ interface DashboardAttributesTo720 extends KibanaAttributes { optionsJSON?: string; } -export type DashboardDoc730ToLatest = Doc; +export type DashboardDoc730ToLatest = Doc; export type DashboardDoc700To720 = Doc; diff --git a/src/plugins/dashboard/common/lib/dashboard_panel_converters.test.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/utils.test.ts similarity index 82% rename from src/plugins/dashboard/common/lib/dashboard_panel_converters.test.ts rename to src/plugins/dashboard/server/dashboard_saved_object/migrations/utils.test.ts index f750d95ca2efa..17aca8fef68ce 100644 --- a/src/plugins/dashboard/common/lib/dashboard_panel_converters.test.ts +++ b/src/plugins/dashboard/server/dashboard_saved_object/migrations/utils.test.ts @@ -7,13 +7,14 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import type { EmbeddableInput } from '@kbn/embeddable-plugin/common/types'; +import type { SavedDashboardPanel } from '../schema'; +import type { DashboardPanelState } from '../../../common'; + import { convertSavedDashboardPanelToPanelState, convertPanelStateToSavedDashboardPanel, -} from './dashboard_panel_converters'; -import { SavedDashboardPanel } from '../content_management'; -import { DashboardPanelState } from '../dashboard_container/types'; -import { EmbeddableInput } from '@kbn/embeddable-plugin/common/types'; +} from './utils'; test('convertSavedDashboardPanelToPanelState', () => { const savedDashboardPanel: SavedDashboardPanel = { @@ -148,7 +149,7 @@ test('convertPanelStateToSavedDashboardPanel will not leave title as part of emb expect(converted.title).toBe('title'); }); -test('convertPanelStateToSavedDashboardPanel retains legacy version info when not passed removeLegacyVersion', () => { +test('convertPanelStateToSavedDashboardPanel retains legacy version info', () => { const dashboardPanel: DashboardPanelState = { gridData: { x: 0, @@ -168,24 +169,3 @@ test('convertPanelStateToSavedDashboardPanel retains legacy version info when no const converted = convertPanelStateToSavedDashboardPanel(dashboardPanel); expect(converted.version).toBe('8.10.0'); }); - -test('convertPanelStateToSavedDashboardPanel removes legacy version info when passed removeLegacyVersion', () => { - const dashboardPanel: DashboardPanelState = { - gridData: { - x: 0, - y: 0, - h: 15, - w: 15, - i: '123', - }, - explicitInput: { - id: '123', - title: 'title', - } as EmbeddableInput, - type: 'search', - version: '8.10.0', - }; - - const converted = convertPanelStateToSavedDashboardPanel(dashboardPanel, true); - expect(converted.version).not.toBeDefined(); -}); diff --git a/src/plugins/dashboard/server/dashboard_saved_object/migrations/utils.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/utils.ts new file mode 100644 index 0000000000000..4ed8ec5b8e977 --- /dev/null +++ b/src/plugins/dashboard/server/dashboard_saved_object/migrations/utils.ts @@ -0,0 +1,50 @@ +/* + * Copyright 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 { omit } from 'lodash'; +import type { EmbeddableInput, SavedObjectEmbeddableInput } from '@kbn/embeddable-plugin/common'; +import type { SavedDashboardPanel } from '../schema'; +import type { DashboardPanelState } from '../../../common'; + +export function convertSavedDashboardPanelToPanelState< + TEmbeddableInput extends EmbeddableInput | SavedObjectEmbeddableInput = SavedObjectEmbeddableInput +>(savedDashboardPanel: SavedDashboardPanel): DashboardPanelState { + return { + type: savedDashboardPanel.type, + gridData: savedDashboardPanel.gridData, + panelRefName: savedDashboardPanel.panelRefName, + explicitInput: { + id: savedDashboardPanel.panelIndex, + ...(savedDashboardPanel.id !== undefined && { savedObjectId: savedDashboardPanel.id }), + ...(savedDashboardPanel.title !== undefined && { title: savedDashboardPanel.title }), + ...savedDashboardPanel.embeddableConfig, + } as TEmbeddableInput, + version: savedDashboardPanel.version, + }; +} + +export function convertPanelStateToSavedDashboardPanel( + panelState: DashboardPanelState +): SavedDashboardPanel { + const savedObjectId = (panelState.explicitInput as SavedObjectEmbeddableInput).savedObjectId; + const panelIndex = panelState.explicitInput.id; + return { + type: panelState.type, + gridData: { + ...panelState.gridData, + i: panelIndex, + }, + panelIndex, + embeddableConfig: omit(panelState.explicitInput, ['id', 'savedObjectId', 'title']), + ...(panelState.explicitInput.title !== undefined && { title: panelState.explicitInput.title }), + ...(savedObjectId !== undefined && { id: savedObjectId }), + ...(panelState.panelRefName !== undefined && { panelRefName: panelState.panelRefName }), + ...(panelState.version !== undefined && { version: panelState.version }), + }; +} diff --git a/src/plugins/dashboard/server/dashboard_saved_object/schema/index.ts b/src/plugins/dashboard/server/dashboard_saved_object/schema/index.ts new file mode 100644 index 0000000000000..4c50de472f53e --- /dev/null +++ b/src/plugins/dashboard/server/dashboard_saved_object/schema/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 type { DashboardSavedObjectAttributes, GridData, SavedDashboardPanel } from './latest'; +export { dashboardSavedObjectSchema } from './latest'; diff --git a/src/plugins/dashboard/server/dashboard_saved_object/schema/latest.ts b/src/plugins/dashboard/server/dashboard_saved_object/schema/latest.ts new file mode 100644 index 0000000000000..a40e476abe793 --- /dev/null +++ b/src/plugins/dashboard/server/dashboard_saved_object/schema/latest.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". + */ + +// Latest model version for dashboard saved objects is v2 +export { + dashboardAttributesSchema as dashboardSavedObjectSchema, + type DashboardAttributes as DashboardSavedObjectAttributes, + type GridData, + type SavedDashboardPanel, +} from './v2'; diff --git a/src/plugins/dashboard/server/dashboard_saved_object/schema/v1/index.ts b/src/plugins/dashboard/server/dashboard_saved_object/schema/v1/index.ts new file mode 100644 index 0000000000000..e52a6ca4075ac --- /dev/null +++ b/src/plugins/dashboard/server/dashboard_saved_object/schema/v1/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 type { DashboardAttributes } from './types'; +export { controlGroupInputSchema, dashboardAttributesSchema } from './v1'; diff --git a/src/plugins/dashboard/server/dashboard_saved_object/schema/v1/types.ts b/src/plugins/dashboard/server/dashboard_saved_object/schema/v1/types.ts new file mode 100644 index 0000000000000..8717851845cf7 --- /dev/null +++ b/src/plugins/dashboard/server/dashboard_saved_object/schema/v1/types.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", 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 { TypeOf } from '@kbn/config-schema'; +import { dashboardAttributesSchema } from './v1'; + +export type DashboardAttributes = TypeOf; diff --git a/src/plugins/dashboard/server/dashboard_saved_object/schema/v1/v1.ts b/src/plugins/dashboard/server/dashboard_saved_object/schema/v1/v1.ts new file mode 100644 index 0000000000000..63b4cd3c2c10b --- /dev/null +++ b/src/plugins/dashboard/server/dashboard_saved_object/schema/v1/v1.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", 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'; + +export const controlGroupInputSchema = schema + .object({ + panelsJSON: schema.maybe(schema.string()), + controlStyle: schema.maybe(schema.string()), + chainingSystem: schema.maybe(schema.string()), + ignoreParentSettingsJSON: schema.maybe(schema.string()), + }) + .extends({}, { unknowns: 'ignore' }); + +export const dashboardAttributesSchema = schema.object( + { + // General + title: schema.string(), + description: schema.string({ defaultValue: '' }), + + // Search + kibanaSavedObjectMeta: schema.object({ + searchSourceJSON: schema.maybe(schema.string()), + }), + + // Time + timeRestore: schema.maybe(schema.boolean()), + timeFrom: schema.maybe(schema.string()), + timeTo: schema.maybe(schema.string()), + refreshInterval: schema.maybe( + schema.object({ + pause: schema.boolean(), + value: schema.number(), + display: schema.maybe(schema.string()), + section: schema.maybe(schema.number()), + }) + ), + + // Dashboard Content + controlGroupInput: schema.maybe(controlGroupInputSchema), + panelsJSON: schema.string({ defaultValue: '[]' }), + optionsJSON: schema.maybe(schema.string()), + + // Legacy + hits: schema.maybe(schema.number()), + version: schema.maybe(schema.number()), + }, + { unknowns: 'forbid' } +); diff --git a/src/plugins/dashboard/server/dashboard_saved_object/schema/v2/index.ts b/src/plugins/dashboard/server/dashboard_saved_object/schema/v2/index.ts new file mode 100644 index 0000000000000..2fda02230ed69 --- /dev/null +++ b/src/plugins/dashboard/server/dashboard_saved_object/schema/v2/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 type { DashboardAttributes, GridData, SavedDashboardPanel } from './types'; +export { controlGroupInputSchema, dashboardAttributesSchema } from './v2'; diff --git a/src/plugins/dashboard/server/dashboard_saved_object/schema/v2/types.ts b/src/plugins/dashboard/server/dashboard_saved_object/schema/v2/types.ts new file mode 100644 index 0000000000000..e50a27efe2b3b --- /dev/null +++ b/src/plugins/dashboard/server/dashboard_saved_object/schema/v2/types.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { Serializable } from '@kbn/utility-types'; +import { TypeOf } from '@kbn/config-schema'; +import { dashboardAttributesSchema, gridDataSchema } from './v2'; + +export type DashboardAttributes = TypeOf; +export type GridData = TypeOf; + +/** + * A saved dashboard panel parsed directly from the Dashboard Attributes panels JSON + */ +export interface SavedDashboardPanel { + embeddableConfig: { [key: string]: Serializable }; // parsed into the panel's explicitInput + id?: string; // the saved object id for by reference panels + type: string; // the embeddable type + panelRefName?: string; + gridData: GridData; + panelIndex: string; + title?: string; + + /** + * This version key was used to store Kibana version information from versions 7.3.0 -> 8.11.0. + * As of version 8.11.0, the versioning information is now per-embeddable-type and is stored on the + * embeddable's input. (embeddableConfig in this type). + */ + version?: string; +} diff --git a/src/plugins/dashboard/server/dashboard_saved_object/schema/v2/v2.ts b/src/plugins/dashboard/server/dashboard_saved_object/schema/v2/v2.ts new file mode 100644 index 0000000000000..dc0ed3eb84cbb --- /dev/null +++ b/src/plugins/dashboard/server/dashboard_saved_object/schema/v2/v2.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", 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'; +import { + controlGroupInputSchema as controlGroupInputSchemaV1, + dashboardAttributesSchema as dashboardAttributesSchemaV1, +} from '../v1'; + +export const controlGroupInputSchema = controlGroupInputSchemaV1.extends( + { + showApplySelections: schema.maybe(schema.boolean()), + }, + { unknowns: 'ignore' } +); + +export const dashboardAttributesSchema = dashboardAttributesSchemaV1.extends( + { + controlGroupInput: schema.maybe(controlGroupInputSchema), + }, + { unknowns: 'ignore' } +); + +export const gridDataSchema = schema.object({ + x: schema.number(), + y: schema.number(), + w: schema.number(), + h: schema.number(), + i: schema.string(), +}); diff --git a/src/plugins/dashboard/server/index.ts b/src/plugins/dashboard/server/index.ts index f76a75cb837b3..94e7ed14378c1 100644 --- a/src/plugins/dashboard/server/index.ts +++ b/src/plugins/dashboard/server/index.ts @@ -26,3 +26,7 @@ export async function plugin(initializerContext: PluginInitializerContext) { } export type { DashboardPluginSetup, DashboardPluginStart } from './types'; +export type { DashboardAttributes } from './content_management'; +export type { DashboardSavedObjectAttributes } from './dashboard_saved_object'; + +export { PUBLIC_API_PATH } from './api/constants'; diff --git a/src/plugins/dashboard/server/plugin.ts b/src/plugins/dashboard/server/plugin.ts index 3218ee85ef383..e3d67ca10716b 100644 --- a/src/plugins/dashboard/server/plugin.ts +++ b/src/plugins/dashboard/server/plugin.ts @@ -30,6 +30,7 @@ import { createDashboardSavedObjectType } from './dashboard_saved_object'; import { CONTENT_ID, LATEST_VERSION } from '../common/content_management'; import { registerDashboardUsageCollector } from './usage/register_collector'; import { dashboardPersistableStateServiceFactory } from './dashboard_container/dashboard_container_embeddable_factory'; +import { registerAPIRoutes } from './api'; interface SetupDeps { embeddable: EmbeddableSetup; @@ -111,6 +112,12 @@ export class DashboardPlugin core.uiSettings.register(getUISettings()); + registerAPIRoutes({ + http: core.http, + contentManagement: plugins.contentManagement, + logger: this.logger, + }); + return {}; } diff --git a/src/plugins/dashboard/server/usage/dashboard_telemetry.test.ts b/src/plugins/dashboard/server/usage/dashboard_telemetry.test.ts index 225ac7743d23c..8f4f94d3621e2 100644 --- a/src/plugins/dashboard/server/usage/dashboard_telemetry.test.ts +++ b/src/plugins/dashboard/server/usage/dashboard_telemetry.test.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { SavedDashboardPanel } from '../../common/content_management'; +import { SavedDashboardPanel } from '../dashboard_saved_object'; import { getEmptyDashboardData, collectPanelsByType } from './dashboard_telemetry'; import { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common'; import { createEmbeddablePersistableStateServiceMock } from '@kbn/embeddable-plugin/common/mocks'; diff --git a/src/plugins/dashboard/server/usage/dashboard_telemetry.ts b/src/plugins/dashboard/server/usage/dashboard_telemetry.ts index 0048e081e6f61..f26de753c12e2 100644 --- a/src/plugins/dashboard/server/usage/dashboard_telemetry.ts +++ b/src/plugins/dashboard/server/usage/dashboard_telemetry.ts @@ -17,7 +17,7 @@ import { import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common'; import { TaskManagerStartContract } from '@kbn/task-manager-plugin/server'; -import { DashboardAttributes, SavedDashboardPanel } from '../../common/content_management'; +import { DashboardSavedObjectAttributes, SavedDashboardPanel } from '../dashboard_saved_object'; import { TASK_ID } from './dashboard_telemetry_collection_task'; import { emptyState, type LatestTaskStateSchema } from './task_state'; @@ -95,7 +95,7 @@ export const collectPanelsByType = ( export const controlsCollectorFactory = (embeddableService: EmbeddablePersistableStateService) => - (attributes: DashboardAttributes, collectorData: DashboardCollectorData) => { + (attributes: DashboardSavedObjectAttributes, collectorData: DashboardCollectorData) => { if (!isEmpty(attributes.controlGroupInput)) { collectorData.controls = embeddableService.telemetry( { diff --git a/src/plugins/dashboard/server/usage/dashboard_telemetry_collection_task.ts b/src/plugins/dashboard/server/usage/dashboard_telemetry_collection_task.ts index d660af962db57..7eb4cebc39e49 100644 --- a/src/plugins/dashboard/server/usage/dashboard_telemetry_collection_task.ts +++ b/src/plugins/dashboard/server/usage/dashboard_telemetry_collection_task.ts @@ -23,9 +23,15 @@ import { collectPanelsByType, getEmptyDashboardData, } from './dashboard_telemetry'; -import { injectReferences } from '../../common'; -import { DashboardAttributesAndReferences } from '../../common/types'; -import { DashboardAttributes, SavedDashboardPanel } from '../../common/content_management'; +import type { + DashboardSavedObjectAttributes, + SavedDashboardPanel, +} from '../dashboard_saved_object'; + +interface DashboardSavedObjectAttributesAndReferences { + attributes: DashboardSavedObjectAttributes; + references: SavedObjectReference[]; +} // This task is responsible for running daily and aggregating all the Dashboard telemerty data // into a single document. This is an effort to make sure the load of fetching/parsing all of the @@ -88,17 +94,18 @@ export function dashboardTaskRunner(logger: Logger, core: CoreSetup, embeddable: async run() { let dashboardData = getEmptyDashboardData(); const controlsCollector = controlsCollectorFactory(embeddable); - const processDashboards = (dashboards: DashboardAttributesAndReferences[]) => { + const processDashboards = (dashboards: DashboardSavedObjectAttributesAndReferences[]) => { for (const dashboard of dashboards) { - const attributes = injectReferences(dashboard, { - embeddablePersistableStateService: embeddable, - }); + // TODO is this injecting references really necessary? + // const attributes = injectReferences(dashboard, { + // embeddablePersistableStateService: embeddable, + // }); - dashboardData = controlsCollector(attributes, dashboardData); + dashboardData = controlsCollector(dashboard.attributes, dashboardData); try { const panels = JSON.parse( - attributes.panelsJSON as string + dashboard.attributes.panelsJSON as string ) as unknown as SavedDashboardPanel[]; collectPanelsByType(panels, dashboardData, embeddable); @@ -129,7 +136,7 @@ export function dashboardTaskRunner(logger: Logger, core: CoreSetup, embeddable: const esClient = await getEsClient(); let result = await esClient.search<{ - dashboard: DashboardAttributes; + dashboard: DashboardSavedObjectAttributes; references: SavedObjectReference[]; }>(searchParams); @@ -144,8 +151,8 @@ export function dashboardTaskRunner(logger: Logger, core: CoreSetup, embeddable: } return undefined; }) - .filter( - (s): s is DashboardAttributesAndReferences => s !== undefined + .filter( + (s): s is DashboardSavedObjectAttributesAndReferences => s !== undefined ) ); @@ -163,8 +170,8 @@ export function dashboardTaskRunner(logger: Logger, core: CoreSetup, embeddable: } return undefined; }) - .filter( - (s): s is DashboardAttributesAndReferences => s !== undefined + .filter( + (s): s is DashboardSavedObjectAttributesAndReferences => s !== undefined ) ); } diff --git a/src/plugins/data/kibana.jsonc b/src/plugins/data/kibana.jsonc index c109bde374680..84e692c42648a 100644 --- a/src/plugins/data/kibana.jsonc +++ b/src/plugins/data/kibana.jsonc @@ -5,6 +5,8 @@ "@elastic/kibana-visualizations", "@elastic/kibana-data-discovery" ], + "group": "platform", + "visibility": "shared", "description": "Data services are useful for searching and querying data from Elasticsearch. Helpful utilities include: a re-usable react query bar, KQL autocomplete, async search, Data Views (Index Patterns) and field formatters.", "serviceFolders": [ "search", @@ -13,8 +15,8 @@ ], "plugin": { "id": "data", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "bfetch", "expressions", @@ -38,4 +40,4 @@ "common" ] } -} +} \ No newline at end of file diff --git a/src/plugins/data/server/search/session/session_service.ts b/src/plugins/data/server/search/session/session_service.ts index 4ef741ec78387..48b563c5585ca 100644 --- a/src/plugins/data/server/search/session/session_service.ts +++ b/src/plugins/data/server/search/session/session_service.ts @@ -402,8 +402,8 @@ export class SearchSessionService implements ISearchSessionService { const session = await this.get(deps, user, sessionId); const requestHash = createRequestHash(searchRequest.params); if (!Object.hasOwn(session.attributes.idMapping, requestHash)) { - this.logger.error(`SearchSessionService: getId | ${sessionId} | ${requestHash} not found`); - this.logger.debug( + this.logger.debug(`SearchSessionService: getId | ${sessionId} | ${requestHash} not found`); + this.logger.error( `SearchSessionService: getId not found search with params: ${JSON.stringify( searchRequest.params )}` diff --git a/src/plugins/data_view_editor/kibana.jsonc b/src/plugins/data_view_editor/kibana.jsonc index bdec3b4f4943d..04d543bdd47ec 100644 --- a/src/plugins/data_view_editor/kibana.jsonc +++ b/src/plugins/data_view_editor/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/data-view-editor-plugin", - "owner": "@elastic/kibana-data-discovery", + "owner": [ + "@elastic/kibana-data-discovery" + ], + "group": "platform", + "visibility": "shared", "description": "This plugin provides the ability to create data views via a modal flyout inside Kibana apps", "plugin": { "id": "dataViewEditor", - "server": false, "browser": true, + "server": false, "requiredPlugins": [ "data", "dataViews" @@ -17,4 +21,4 @@ "esUiShared" ] } -} +} \ No newline at end of file diff --git a/src/plugins/data_view_field_editor/kibana.jsonc b/src/plugins/data_view_field_editor/kibana.jsonc index 50a336cfe0c9e..9c3e453d1b796 100644 --- a/src/plugins/data_view_field_editor/kibana.jsonc +++ b/src/plugins/data_view_field_editor/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/data-view-field-editor-plugin", - "owner": "@elastic/kibana-data-discovery", + "owner": [ + "@elastic/kibana-data-discovery" + ], + "group": "platform", + "visibility": "shared", "description": "Reusable data view field editor across Kibana", "plugin": { "id": "dataViewFieldEditor", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "data", "fieldFormats", @@ -20,4 +24,4 @@ "esUiShared" ] } -} +} \ No newline at end of file diff --git a/src/plugins/data_view_management/kibana.jsonc b/src/plugins/data_view_management/kibana.jsonc index 5b827868ee1e8..c679c3b9ad964 100644 --- a/src/plugins/data_view_management/kibana.jsonc +++ b/src/plugins/data_view_management/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/data-view-management-plugin", - "owner": "@elastic/kibana-data-discovery", + "owner": [ + "@elastic/kibana-data-discovery" + ], + "group": "platform", + "visibility": "shared", "description": "Data view management app", "plugin": { "id": "dataViewManagement", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "management", "data", @@ -28,4 +32,4 @@ "kibanaUtils" ] } -} +} \ No newline at end of file diff --git a/src/plugins/data_views/kibana.jsonc b/src/plugins/data_views/kibana.jsonc index 7789383b48ba4..00df1941eaa37 100644 --- a/src/plugins/data_views/kibana.jsonc +++ b/src/plugins/data_views/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/data-views-plugin", - "owner": "@elastic/kibana-data-discovery", + "owner": [ + "@elastic/kibana-data-discovery" + ], + "group": "platform", + "visibility": "shared", "description": "Data services are useful for searching and querying data from Elasticsearch. Helpful utilities include: a re-usable react query bar, KQL autocomplete, async search, Data Views (Index Patterns) and field formatters.", "plugin": { "id": "dataViews", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "fieldFormats", "expressions", @@ -18,9 +22,11 @@ "requiredBundles": [ "kibanaUtils" ], - "runtimePluginDependencies" : ["security"], + "runtimePluginDependencies": [ + "security" + ], "extraPublicDirs": [ "common" ] } -} +} \ No newline at end of file diff --git a/src/plugins/dev_tools/kibana.jsonc b/src/plugins/dev_tools/kibana.jsonc index c269b74918619..45efd4af09fa9 100644 --- a/src/plugins/dev_tools/kibana.jsonc +++ b/src/plugins/dev_tools/kibana.jsonc @@ -1,14 +1,18 @@ { "type": "plugin", "id": "@kbn/dev-tools-plugin", - "owner": "@elastic/kibana-management", + "owner": [ + "@elastic/kibana-management" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "devTools", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "urlForwarding" ], "requiredBundles": [] } -} +} \ No newline at end of file diff --git a/src/plugins/discover/kibana.jsonc b/src/plugins/discover/kibana.jsonc index 1f5e25229df02..87837a38ed834 100644 --- a/src/plugins/discover/kibana.jsonc +++ b/src/plugins/discover/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/discover-plugin", - "owner": "@elastic/kibana-data-discovery", + "owner": [ + "@elastic/kibana-data-discovery" + ], + "group": "platform", + "visibility": "shared", "description": "This plugin contains the Discover application and the saved search embeddable.", "plugin": { "id": "discover", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "charts", "data", @@ -44,7 +48,14 @@ "fieldsMetadata", "logsDataAccess" ], - "requiredBundles": ["kibanaUtils", "kibanaReact", "unifiedSearch", "savedObjects"], - "extraPublicDirs": ["common"] + "requiredBundles": [ + "kibanaUtils", + "kibanaReact", + "unifiedSearch", + "savedObjects" + ], + "extraPublicDirs": [ + "common" + ] } -} +} \ No newline at end of file diff --git a/src/plugins/discover/public/application/context/context_app_route.tsx b/src/plugins/discover/public/application/context/context_app_route.tsx index a272a032bbe35..dad0dd2eb7b93 100644 --- a/src/plugins/discover/public/application/context/context_app_route.tsx +++ b/src/plugins/discover/public/application/context/context_app_route.tsx @@ -16,6 +16,7 @@ import { LoadingIndicator } from '../../components/common/loading_indicator'; import { useDataView } from '../../hooks/use_data_view'; import type { ContextHistoryLocationState } from './services/locator'; import { useDiscoverServices } from '../../hooks/use_discover_services'; +import { useRootProfile } from '../../context_awareness'; export interface ContextUrlParams { dataViewId: string; @@ -47,8 +48,8 @@ export function ContextAppRoute() { const { dataViewId: encodedDataViewId, id } = useParams(); const dataViewId = decodeURIComponent(encodedDataViewId); const anchorId = decodeURIComponent(id); - const { dataView, error } = useDataView({ index: locationState?.dataViewSpec || dataViewId }); + const rootProfileState = useRootProfile(); if (error) { return ( @@ -72,9 +73,13 @@ export function ContextAppRoute() { ); } - if (!dataView) { + if (!dataView || rootProfileState.rootProfileLoading) { return ; } - return ; + return ( + + + + ); } diff --git a/src/plugins/discover/public/application/context/services/anchor.ts b/src/plugins/discover/public/application/context/services/anchor.ts index 350c292772d87..ee5198a8b4100 100644 --- a/src/plugins/discover/public/application/context/services/anchor.ts +++ b/src/plugins/discover/public/application/context/services/anchor.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { firstValueFrom, lastValueFrom } from 'rxjs'; +import { lastValueFrom } from 'rxjs'; import { i18n } from '@kbn/i18n'; import { ISearchSource, EsQuerySortValue } from '@kbn/data-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/public'; @@ -29,11 +29,7 @@ export async function fetchAnchor( anchorRow: DataTableRecord; interceptedWarnings: SearchResponseWarning[]; }> { - const { core, profilesManager } = services; - - const solutionNavId = await firstValueFrom(core.chrome.getActiveSolutionNavId$()); - await profilesManager.resolveRootProfile({ solutionNavId }); - await profilesManager.resolveDataSourceProfile({ + await services.profilesManager.resolveDataSourceProfile({ dataSource: createDataSource({ dataView, query: undefined }), dataView, query: { query: '', language: 'kuery' }, @@ -68,7 +64,7 @@ export async function fetchAnchor( }); return { - anchorRow: profilesManager.resolveDocumentProfile({ + anchorRow: services.profilesManager.resolveDocumentProfile({ record: buildDataTableRecord(doc, dataView, true), }), interceptedWarnings, diff --git a/src/plugins/discover/public/application/doc/components/doc.tsx b/src/plugins/discover/public/application/doc/components/doc.tsx index 432687fdca5e6..8609968f838de 100644 --- a/src/plugins/discover/public/application/doc/components/doc.tsx +++ b/src/plugins/discover/public/application/doc/components/doc.tsx @@ -9,7 +9,6 @@ import React, { useCallback, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { firstValueFrom } from 'rxjs'; import { EuiCallOut, EuiLink, EuiLoadingSpinner, EuiPage, EuiPageBody } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { ElasticRequestState } from '@kbn/unified-doc-viewer'; @@ -31,18 +30,16 @@ export interface DocProps extends EsDocSearchProps { export function Doc(props: DocProps) { const { dataView } = props; const services = useDiscoverServices(); - const { locator, chrome, docLinks, core, profilesManager } = services; + const { locator, chrome, docLinks, profilesManager } = services; const indexExistsLink = docLinks.links.apis.indexExists; const onBeforeFetch = useCallback(async () => { - const solutionNavId = await firstValueFrom(core.chrome.getActiveSolutionNavId$()); - await profilesManager.resolveRootProfile({ solutionNavId }); await profilesManager.resolveDataSourceProfile({ dataSource: dataView?.id ? createDataViewDataSource({ dataViewId: dataView.id }) : undefined, dataView, query: { query: '', language: 'kuery' }, }); - }, [profilesManager, core, dataView]); + }, [profilesManager, dataView]); const onProcessRecord = useCallback( (record: DataTableRecord) => { diff --git a/src/plugins/discover/public/application/doc/single_doc_route.tsx b/src/plugins/discover/public/application/doc/single_doc_route.tsx index 8091e637e8beb..3eedac7be1644 100644 --- a/src/plugins/discover/public/application/doc/single_doc_route.tsx +++ b/src/plugins/discover/public/application/doc/single_doc_route.tsx @@ -19,6 +19,7 @@ import { useDiscoverServices } from '../../hooks/use_discover_services'; import { DiscoverError } from '../../components/common/error_alert'; import { useDataView } from '../../hooks/use_data_view'; import { DocHistoryLocationState } from './locator'; +import { useRootProfile } from '../../context_awareness'; export interface DocUrlParams { dataViewId: string; @@ -53,6 +54,8 @@ export const SingleDocRoute = () => { index: locationState?.dataViewSpec || decodeURIComponent(dataViewId), }); + const rootProfileState = useRootProfile(); + if (error) { return ( { ); } - if (!dataView) { + if (!dataView || rootProfileState.rootProfileLoading) { return ; } @@ -94,5 +97,9 @@ export const SingleDocRoute = () => { ); } - return ; + return ( + + + + ); }; diff --git a/src/plugins/discover/public/application/main/components/top_nav/app_menu_actions/convert_to_top_nav_item.test.ts b/src/plugins/discover/public/application/main/components/top_nav/app_menu_actions/convert_to_top_nav_item.test.ts new file mode 100644 index 0000000000000..2fb65563cddfb --- /dev/null +++ b/src/plugins/discover/public/application/main/components/top_nav/app_menu_actions/convert_to_top_nav_item.test.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", 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 { + AppMenuActionPrimary, + AppMenuActionSecondary, + AppMenuActionSubmenuCustom, + AppMenuActionType, +} from '@kbn/discover-utils'; +import { convertAppMenuItemToTopNavItem } from './convert_to_top_nav_item'; +import { discoverServiceMock } from '../../../../../__mocks__/services'; + +describe('convertAppMenuItemToTopNavItem', () => { + it('should convert a primary AppMenuItem to TopNavMenuData', () => { + const appMenuItem: AppMenuActionPrimary = { + id: 'action-1', + type: AppMenuActionType.primary, + controlProps: { + label: 'Action 1', + testId: 'action-1', + iconType: 'share', + onClick: jest.fn(), + }, + }; + + const topNavItem = convertAppMenuItemToTopNavItem({ + appMenuItem, + services: discoverServiceMock, + }); + + expect(topNavItem).toEqual({ + id: 'action-1', + label: 'Action 1', + description: 'Action 1', + testId: 'action-1', + run: expect.any(Function), + iconType: 'share', + iconOnly: true, + }); + }); + + it('should convert a secondary AppMenuItem to TopNavMenuData', () => { + const appMenuItem: AppMenuActionSecondary = { + id: 'action-2', + type: AppMenuActionType.secondary, + controlProps: { + label: 'Action Secondary', + testId: 'action-secondary', + onClick: jest.fn(), + }, + }; + + const topNavItem = convertAppMenuItemToTopNavItem({ + appMenuItem, + services: discoverServiceMock, + }); + + expect(topNavItem).toEqual({ + id: 'action-2', + label: 'Action Secondary', + description: 'Action Secondary', + testId: 'action-secondary', + run: expect.any(Function), + }); + }); + + it('should convert a custom AppMenuItem to TopNavMenuData', () => { + const appMenuItem: AppMenuActionSubmenuCustom = { + id: 'action-3', + type: AppMenuActionType.custom, + label: 'Action submenu', + testId: 'action-submenu', + actions: [ + { + id: 'action-3-1', + type: AppMenuActionType.custom, + controlProps: { + label: 'Action 3.1', + testId: 'action-3-1', + onClick: jest.fn(), + }, + }, + { + id: 'action-3-2', + type: AppMenuActionType.submenuHorizontalRule, + }, + { + id: 'action-3-3', + type: AppMenuActionType.custom, + controlProps: { + label: 'Action 3.3', + testId: 'action-3-3', + onClick: jest.fn(), + }, + }, + ], + }; + + const topNavItem = convertAppMenuItemToTopNavItem({ + appMenuItem, + services: discoverServiceMock, + }); + + expect(topNavItem).toEqual({ + id: 'action-3', + label: 'Action submenu', + description: 'Action submenu', + testId: 'action-submenu', + run: expect.any(Function), + }); + }); +}); diff --git a/src/plugins/discover/public/application/main/components/top_nav/app_menu_actions/convert_to_top_nav_item.ts b/src/plugins/discover/public/application/main/components/top_nav/app_menu_actions/convert_to_top_nav_item.ts new file mode 100644 index 0000000000000..2ff2d531d77cf --- /dev/null +++ b/src/plugins/discover/public/application/main/components/top_nav/app_menu_actions/convert_to_top_nav_item.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { AppMenuActionType, AppMenuItem } from '@kbn/discover-utils'; +import type { TopNavMenuData } from '@kbn/navigation-plugin/public'; +import { runAppMenuAction, runAppMenuPopoverAction } from './run_app_menu_action'; +import { DiscoverServices } from '../../../../../build_services'; + +export function convertAppMenuItemToTopNavItem({ + appMenuItem, + services, +}: { + appMenuItem: AppMenuItem; + services: DiscoverServices; +}): TopNavMenuData { + if ('actions' in appMenuItem) { + return { + id: appMenuItem.id, + label: appMenuItem.label, + description: appMenuItem.description ?? appMenuItem.label, + testId: appMenuItem.testId, + run: (anchorElement: HTMLElement) => { + runAppMenuPopoverAction({ + appMenuItem, + anchorElement, + services, + }); + }, + }; + } + + return { + id: appMenuItem.id, + label: appMenuItem.controlProps.label, + description: appMenuItem.controlProps.description ?? appMenuItem.controlProps.label, + testId: appMenuItem.controlProps.testId, + run: async (anchorElement: HTMLElement) => { + await runAppMenuAction({ + appMenuItem, + anchorElement, + services, + }); + }, + ...(appMenuItem.type === AppMenuActionType.primary + ? { iconType: appMenuItem.controlProps.iconType, iconOnly: true } + : {}), + }; +} diff --git a/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.test.tsx b/src/plugins/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.test.tsx similarity index 74% rename from src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.test.tsx rename to src/plugins/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.test.tsx index fb9f127b83d86..a658ca750cf28 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.test.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.test.tsx @@ -10,28 +10,40 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { findTestSubject } from '@elastic/eui/lib/test'; -import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; -import { AlertsPopover } from './open_alerts_popover'; -import { discoverServiceMock } from '../../../../__mocks__/services'; -import { dataViewWithTimefieldMock } from '../../../../__mocks__/data_view_with_timefield'; -import { dataViewWithNoTimefieldMock } from '../../../../__mocks__/data_view_no_timefield'; import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; -import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock'; +import { AppMenuActionsMenuPopover } from './run_app_menu_action'; +import { getAlertsAppMenuItem } from './get_alerts'; +import { discoverServiceMock } from '../../../../../__mocks__/services'; +import { dataViewWithTimefieldMock } from '../../../../../__mocks__/data_view_with_timefield'; +import { dataViewWithNoTimefieldMock } from '../../../../../__mocks__/data_view_no_timefield'; +import { getDiscoverStateMock } from '../../../../../__mocks__/discover_state.mock'; const mount = (dataView = dataViewMock, isEsqlMode = false) => { const stateContainer = getDiscoverStateMock({ isTimeBased: true }); stateContainer.actions.setDataView(dataView); + + const discoverParamsMock = { + dataView, + adHocDataViews: [], + isEsqlMode, + onNewSearch: jest.fn(), + onOpenSavedSearch: jest.fn(), + onUpdateAdHocDataViews: jest.fn(), + }; + + const alertsAppMenuItem = getAlertsAppMenuItem({ + discoverParams: discoverParamsMock, + services: discoverServiceMock, + stateContainer, + }); + return mountWithIntl( - - - + ); }; diff --git a/src/plugins/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx b/src/plugins/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx new file mode 100644 index 0000000000000..d6d8bb81bac09 --- /dev/null +++ b/src/plugins/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx @@ -0,0 +1,179 @@ +/* + * Copyright 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, { useCallback, useMemo } from 'react'; +import type { DataView } from '@kbn/data-plugin/common'; +import { i18n } from '@kbn/i18n'; +import { + AppMenuActionId, + AppMenuActionSubmenuSecondary, + AppMenuActionType, +} from '@kbn/discover-utils'; +import { + AlertConsumers, + ES_QUERY_ID, + RuleCreationValidConsumer, + STACK_ALERTS_FEATURE_ID, +} from '@kbn/rule-data-utils'; +import { RuleTypeMetaData } from '@kbn/alerting-plugin/common'; +import { DiscoverStateContainer } from '../../../state_management/discover_state'; +import { AppMenuDiscoverParams } from './types'; +import { DiscoverServices } from '../../../../../build_services'; + +const EsQueryValidConsumer: RuleCreationValidConsumer[] = [ + AlertConsumers.INFRASTRUCTURE, + AlertConsumers.LOGS, + AlertConsumers.OBSERVABILITY, + STACK_ALERTS_FEATURE_ID, +]; + +interface EsQueryAlertMetaData extends RuleTypeMetaData { + isManagementPage?: boolean; + adHocDataViewList: DataView[]; +} + +const CreateAlertFlyout: React.FC<{ + discoverParams: AppMenuDiscoverParams; + services: DiscoverServices; + onFinishAction: () => void; + stateContainer: DiscoverStateContainer; +}> = ({ stateContainer, discoverParams, services, onFinishAction }) => { + const query = stateContainer.appState.getState().query; + + const { dataView, isEsqlMode, adHocDataViews, onUpdateAdHocDataViews } = discoverParams; + const { triggersActionsUi } = services; + const timeField = getTimeField(dataView); + + /** + * Provides the default parameters used to initialize the new rule + */ + const getParams = useCallback(() => { + if (isEsqlMode) { + return { + searchType: 'esqlQuery', + esqlQuery: query, + timeField, + }; + } + const savedQueryId = stateContainer.appState.getState().savedQuery; + return { + searchType: 'searchSource', + searchConfiguration: stateContainer.savedSearchState + .getState() + .searchSource.getSerializedFields(), + savedQueryId, + }; + }, [isEsqlMode, stateContainer.appState, stateContainer.savedSearchState, query, timeField]); + + const discoverMetadata: EsQueryAlertMetaData = useMemo( + () => ({ + isManagementPage: false, + adHocDataViewList: adHocDataViews, + }), + [adHocDataViews] + ); + + return triggersActionsUi?.getAddRuleFlyout({ + metadata: discoverMetadata, + consumer: 'alerts', + onClose: (_, metadata) => { + onUpdateAdHocDataViews(metadata!.adHocDataViewList); + onFinishAction(); + }, + onSave: async (metadata) => { + onUpdateAdHocDataViews(metadata!.adHocDataViewList); + }, + canChangeTrigger: false, + ruleTypeId: ES_QUERY_ID, + initialValues: { params: getParams() }, + validConsumers: EsQueryValidConsumer, + useRuleProducer: true, + // Default to the Logs consumer if it's available. This should fall back to Stack Alerts if it's not. + initialSelectedConsumer: AlertConsumers.LOGS, + }); +}; + +export const getAlertsAppMenuItem = ({ + discoverParams, + services, + stateContainer, +}: { + discoverParams: AppMenuDiscoverParams; + services: DiscoverServices; + stateContainer: DiscoverStateContainer; +}): AppMenuActionSubmenuSecondary => { + const { dataView, isEsqlMode } = discoverParams; + const timeField = getTimeField(dataView); + const hasTimeFieldName = !isEsqlMode ? Boolean(dataView?.timeFieldName) : Boolean(timeField); + + return { + id: AppMenuActionId.alerts, + type: AppMenuActionType.secondary, + label: i18n.translate('discover.localMenu.localMenu.alertsTitle', { + defaultMessage: 'Alerts', + }), + description: i18n.translate('discover.localMenu.alertsDescription', { + defaultMessage: 'Alerts', + }), + testId: 'discoverAlertsButton', + actions: [ + { + id: AppMenuActionId.createRule, + type: AppMenuActionType.secondary, + controlProps: { + label: i18n.translate('discover.alerts.createSearchThreshold', { + defaultMessage: 'Create search threshold rule', + }), + iconType: 'bell', + testId: 'discoverCreateAlertButton', + disableButton: !hasTimeFieldName, + tooltip: hasTimeFieldName + ? undefined + : i18n.translate('discover.alerts.missedTimeFieldToolTip', { + defaultMessage: 'Data view does not have a time field.', + }), + onClick: async (params) => { + return ( + + ); + }, + }, + }, + { + id: 'alertsDivider', + type: AppMenuActionType.submenuHorizontalRule, + }, + { + id: AppMenuActionId.manageRulesAndConnectors, + type: AppMenuActionType.secondary, + controlProps: { + label: i18n.translate('discover.alerts.manageRulesAndConnectors', { + defaultMessage: 'Manage rules and connectors', + }), + iconType: 'tableOfContents', + testId: 'discoverManageAlertsButton', + href: services.application.getUrlForApp( + 'management/insightsAndAlerting/triggersActions/rules' + ), + onClick: undefined, + }, + }, + ], + }; +}; + +function getTimeField(dataView: DataView | undefined) { + const dateFields = dataView?.fields.getByType('date'); + return dataView?.timeFieldName || dateFields?.[0]?.name; +} diff --git a/src/plugins/discover/public/application/main/components/top_nav/app_menu_actions/get_inspect.tsx b/src/plugins/discover/public/application/main/components/top_nav/app_menu_actions/get_inspect.tsx new file mode 100644 index 0000000000000..5943f598c9aef --- /dev/null +++ b/src/plugins/discover/public/application/main/components/top_nav/app_menu_actions/get_inspect.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { AppMenuActionId, AppMenuActionType, AppMenuActionSecondary } from '@kbn/discover-utils'; +import { i18n } from '@kbn/i18n'; + +export const getInspectAppMenuItem = ({ + onOpenInspector, +}: { + onOpenInspector: () => void; +}): AppMenuActionSecondary => { + return { + id: AppMenuActionId.inspect, + type: AppMenuActionType.secondary, + controlProps: { + label: i18n.translate('discover.localMenu.inspectTitle', { + defaultMessage: 'Inspect', + }), + description: i18n.translate('discover.localMenu.openInspectorForSearchDescription', { + defaultMessage: 'Open Inspector for search', + }), + testId: 'openInspectorButton', + onClick: () => { + onOpenInspector(); + }, + }, + }; +}; diff --git a/src/plugins/discover/public/application/main/components/top_nav/app_menu_actions/get_new_search.tsx b/src/plugins/discover/public/application/main/components/top_nav/app_menu_actions/get_new_search.tsx new file mode 100644 index 0000000000000..b67f14f31c56a --- /dev/null +++ b/src/plugins/discover/public/application/main/components/top_nav/app_menu_actions/get_new_search.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { AppMenuActionId, AppMenuActionType, AppMenuActionPrimary } from '@kbn/discover-utils'; +import { i18n } from '@kbn/i18n'; + +export const getNewSearchAppMenuItem = ({ + onNewSearch, +}: { + onNewSearch: () => void; +}): AppMenuActionPrimary => { + return { + id: AppMenuActionId.new, + type: AppMenuActionType.primary, + controlProps: { + label: i18n.translate('discover.localMenu.localMenu.newSearchTitle', { + defaultMessage: 'New', + }), + description: i18n.translate('discover.localMenu.newSearchDescription', { + defaultMessage: 'New Search', + }), + iconType: 'plus', + testId: 'discoverNewButton', + onClick: () => { + onNewSearch(); + }, + }, + }; +}; diff --git a/src/plugins/discover/public/application/main/components/top_nav/app_menu_actions/get_open_search.tsx b/src/plugins/discover/public/application/main/components/top_nav/app_menu_actions/get_open_search.tsx new file mode 100644 index 0000000000000..e8f6c5448d602 --- /dev/null +++ b/src/plugins/discover/public/application/main/components/top_nav/app_menu_actions/get_open_search.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { AppMenuActionId, AppMenuActionType, AppMenuActionPrimary } from '@kbn/discover-utils'; +import { i18n } from '@kbn/i18n'; +import { OpenSearchPanel } from '../open_search_panel'; + +export const getOpenSearchAppMenuItem = ({ + onOpenSavedSearch, +}: { + onOpenSavedSearch: (savedSearchId: string) => void; +}): AppMenuActionPrimary => { + return { + id: AppMenuActionId.open, + type: AppMenuActionType.primary, + controlProps: { + label: i18n.translate('discover.localMenu.openTitle', { + defaultMessage: 'Open', + }), + description: i18n.translate('discover.localMenu.openSavedSearchDescription', { + defaultMessage: 'Open Saved Search', + }), + iconType: 'folderOpen', + testId: 'discoverOpenButton', + onClick: ({ onFinishAction }) => { + return ; + }, + }, + }; +}; diff --git a/src/plugins/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx b/src/plugins/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx new file mode 100644 index 0000000000000..f1a030a40ea0a --- /dev/null +++ b/src/plugins/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx @@ -0,0 +1,135 @@ +/* + * Copyright 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 { AppMenuActionPrimary, AppMenuActionId, AppMenuActionType } from '@kbn/discover-utils'; +import { omit } from 'lodash'; +import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; +import { i18n } from '@kbn/i18n'; +import { DiscoverStateContainer } from '../../../state_management/discover_state'; +import { getSharingData, showPublicUrlSwitch } from '../../../../../utils/get_sharing_data'; +import { DiscoverAppLocatorParams } from '../../../../../../common/app_locator'; +import { AppMenuDiscoverParams } from './types'; +import { DiscoverServices } from '../../../../../build_services'; + +export const getShareAppMenuItem = ({ + discoverParams, + services, + stateContainer, +}: { + discoverParams: AppMenuDiscoverParams; + services: DiscoverServices; + stateContainer: DiscoverStateContainer; +}): AppMenuActionPrimary => { + return { + id: AppMenuActionId.share, + type: AppMenuActionType.primary, + controlProps: { + label: i18n.translate('discover.localMenu.shareTitle', { + defaultMessage: 'Share', + }), + description: i18n.translate('discover.localMenu.shareSearchDescription', { + defaultMessage: 'Share Search', + }), + iconType: 'share', + testId: 'shareTopNavButton', + onClick: async ({ anchorElement }) => { + const { dataView, isEsqlMode } = discoverParams; + + if (!services.share) { + return; + } + + const savedSearch = stateContainer.savedSearchState.getState(); + const searchSourceSharingData = await getSharingData( + savedSearch.searchSource, + stateContainer.appState.getState(), + services, + isEsqlMode + ); + + const { locator, notifications } = services; + const appState = stateContainer.appState.getState(); + const { timefilter } = services.data.query.timefilter; + const timeRange = timefilter.getTime(); + const refreshInterval = timefilter.getRefreshInterval(); + const filters = services.filterManager.getFilters(); + + // Share -> Get links -> Snapshot + const params: DiscoverAppLocatorParams = { + ...omit(appState, 'dataSource'), + ...(savedSearch.id ? { savedSearchId: savedSearch.id } : {}), + ...(dataView?.isPersisted() + ? { dataViewId: dataView?.id } + : { dataViewSpec: dataView?.toMinimalSpec() }), + filters, + timeRange, + refreshInterval, + }; + const relativeUrl = locator.getRedirectUrl(params); + + // This logic is duplicated from `relativeToAbsolute` (for bundle size reasons). Ultimately, this should be + // replaced when https://github.com/elastic/kibana/issues/153323 is implemented. + const link = document.createElement('a'); + link.setAttribute('href', relativeUrl); + const shareableUrl = link.href; + + // Share -> Get links -> Saved object + let shareableUrlForSavedObject = await locator.getUrl( + { savedSearchId: savedSearch.id }, + { absolute: true } + ); + + // UrlPanelContent forces a '_g' parameter in the saved object URL: + // https://github.com/elastic/kibana/blob/a30508153c1467b1968fb94faf1debc5407f61ea/src/plugins/share/public/components/url_panel_content.tsx#L230 + // Since our locator doesn't add the '_g' parameter if it's not needed, UrlPanelContent + // will interpret it as undefined and add '?_g=' to the URL, which is invalid in Discover, + // so instead we add an empty object for the '_g' parameter to the URL. + shareableUrlForSavedObject = setStateToKbnUrl( + '_g', + {}, + undefined, + shareableUrlForSavedObject + ); + + services.share.toggleShareContextMenu({ + anchorElement, + allowEmbed: false, + allowShortUrl: !!services.capabilities.discover.createShortUrl, + shareableUrl, + shareableUrlForSavedObject, + shareableUrlLocatorParams: { locator, params }, + objectId: savedSearch.id, + objectType: 'search', + objectTypeMeta: { + title: i18n.translate('discover.share.shareModal.title', { + defaultMessage: 'Share this search', + }), + }, + sharingData: { + isTextBased: isEsqlMode, + locatorParams: [{ id: locator.id, params }], + ...searchSourceSharingData, + // CSV reports can be generated without a saved search so we provide a fallback title + title: + savedSearch.title || + i18n.translate('discover.localMenu.fallbackReportTitle', { + defaultMessage: 'Untitled discover search', + }), + }, + isDirty: !savedSearch.id || stateContainer.appState.hasChanged(), + showPublicUrlSwitch, + onClose: () => { + anchorElement?.focus(); + }, + toasts: notifications.toasts, + }); + }, + }, + }; +}; diff --git a/src/plugins/discover/public/application/main/components/top_nav/app_menu_actions/index.ts b/src/plugins/discover/public/application/main/components/top_nav/app_menu_actions/index.ts new file mode 100644 index 0000000000000..6a5c2f31946a2 --- /dev/null +++ b/src/plugins/discover/public/application/main/components/top_nav/app_menu_actions/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 { getAlertsAppMenuItem } from './get_alerts'; +export { getNewSearchAppMenuItem } from './get_new_search'; +export { getOpenSearchAppMenuItem } from './get_open_search'; +export { getShareAppMenuItem } from './get_share'; +export { getInspectAppMenuItem } from './get_inspect'; +export { convertAppMenuItemToTopNavItem } from './convert_to_top_nav_item'; +export * from './types'; diff --git a/src/plugins/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.test.tsx b/src/plugins/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.test.tsx new file mode 100644 index 0000000000000..952063317d91c --- /dev/null +++ b/src/plugins/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.test.tsx @@ -0,0 +1,120 @@ +/* + * Copyright 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 { screen } from '@testing-library/react'; +import { AppMenuActionSubmenuCustom, AppMenuActionType, AppMenuItem } from '@kbn/discover-utils'; +import { discoverServiceMock } from '../../../../../__mocks__/services'; +import { runAppMenuAction, runAppMenuPopoverAction } from './run_app_menu_action'; + +describe('run app menu actions', () => { + describe('runAppMenuAction', () => { + it('should call the action correctly', () => { + const appMenuItem: AppMenuItem = { + id: 'action-1', + type: AppMenuActionType.primary, + controlProps: { + label: 'Action 1', + testId: 'action-1', + iconType: 'share', + onClick: jest.fn(), + }, + }; + + const anchorElement = document.createElement('div'); + + runAppMenuAction({ + appMenuItem, + anchorElement, + services: discoverServiceMock, + }); + + expect(appMenuItem.controlProps.onClick).toHaveBeenCalled(); + }); + + it('should call the action and render a custom content', async () => { + const appMenuItem: AppMenuItem = { + id: 'action-1', + type: AppMenuActionType.primary, + controlProps: { + label: 'Action 1', + testId: 'action-1', + iconType: 'share', + onClick: jest.fn(({ onFinishAction }) => ( + + ); + }, + bar: function BarComponent() { + const blueOrRed = useObservable(blueOrRed$, blueOrRed$.getValue()); + + // ...and we can react to the changes + return Look ma, I'm {blueOrRed}!; + }, + }; +}; +``` + +For more advanced use cases, such as when state needs to be shared across extension point implementations, we provide an extension point called `getRenderAppWrapper`. The app wrapper extension point allows consumers to wrap the Discover root in a custom wrapper component, such as a React context provider. With this approach consumers can handle things like integrating with a state management library, accessing custom services from within their extension point implementations, managing shared components such as flyouts, etc. in a React-friendly way and without needing to work around the context awareness framework: + +```tsx +// The app wrapper extension point supports common patterns like React context +const flyoutContext = createContext({ setFlyoutOpen: (open: boolean) => {} }); + +// App wrapper implementations can only exist at the root level, and their lifecycle will match the Discover lifecycle +export const createSecurityRootProfileProvider = (): RootProfileProvider => ({ + profileId: 'security-root-profile', + profile: { + // The app wrapper extension point implementation + getRenderAppWrapper: (PrevWrapper) => + function AppWrapper({ children }) { + // Now we can declare state high up in the React tree + const [flyoutOpen, setFlyoutOpen] = useState(false); + + return ( + // Be sure to render the previous wrapper as well + + // This is our wrapper -- it uses React context to give extension point implementations + access to the shared state + + // Make sure to render `children`, which is the Discover app + {children} + // Now extension point implementations can interact with shared state managed higher + up in the tree + {flyoutOpen && ( + setFlyoutOpen(false)}> + Check it out, I'm a flyout! + + )} + + + ); + }, + // Some other extension point implementation that depends on the shared state + getCellRenderers: (prev) => (params) => ({ + ...prev(params), + foo: function FooComponent() { + // Since the app wrapper implementation wrapped Discover with a React context provider, + // we can now access its values from within our extension point implementations + const { setFlyoutOpen } = useContext(flyoutContext); + + return ; + }, + }), + }, + resolve: (params) => { + if (params.solutionNavId === SolutionType.Security) { + return { + isMatch: true, + context: { solutionType: SolutionType.Security }, + }; + } + + return { isMatch: false }; + }, +}); +``` + +## Custom `context` objects + +By default the `context` object returned from each profile provider's `resolve` method conforms to a standard interface specific to their profile's context level. However, in some situations it may be useful for consumers to extend this object with properties specific to their profile implementation. To support this, profile providers can define a strongly typed `context` interface that extends the default interface, and allows passing properties through to their profile's extension point implementations. One potential use case for this is instantiating state stores or asynchronously initialized services, then accessing them within a `getRenderAppWrapper` implementation to pass to a React context provider: + +```tsx +// The profile provider interfaces accept a custom context object type param +type SecurityRootProfileProvider = RootProfileProvider<{ stateStore: SecurityStateStore }>; + +export const createSecurityRootProfileProvider = ( + services: ProfileProviderServices +): SecurityRootProfileProvider => ({ + profileId: 'security-root-profile', + profile: { + getRenderAppWrapper: + (PrevWrapper, { context }) => + ({ children }) => + ( + + // Custom props can be accessed from the context object available in `accessorParams` + + {children} + + + ), + }, + resolve: async (params) => { + if (params.solutionNavId !== SolutionType.Security) { + return { isMatch: false }; + } + + // Perform async service initialization within the `resolve` method + const stateStore = await initializeSecurityStateStore(services); + + return { + isMatch: true, + context: { + solutionType: SolutionType.Security, + // Include the custom service in the returned context object + stateStore, + }, + }; + }, +}); +``` + +## Overriding defaults + +Discover ships with a set of common contextual profiles, shared across Solutions in Kibana (e.g. the current logs data source profile). The goal of these profiles is to provide Solution agnostic contextual features to help improve the default data exploration experience for various data types. They should be generally useful across user types and not be tailored to specific Solution workflows – for example, viewing logs should be a delightful experience regardless of whether it’s done within the Observability Solution, the Search Solution, or the classic on-prem experience. + +We’re aiming to make these profiles generic enough that they don’t obstruct Solution workflows or create confusion, but there will always be some complexity around juggling the various Discover use cases. For situations where Solution teams are confident some common profile feature will not be helpful to their users or will create confusion, there is an option to override these defaults while keeping the remainder of the functionality for the target profile intact. To do so a Solution team would follow these steps: + +- Create and register a Solution specific root profile provider, e.g. `SecurityRootProfileProvider`. +- Identify the contextual feature you want to override and the common profile provider it belongs to, e.g. the `getDocViewer` implementation in the common `LogsDataSourceProfileProvider`. +- Implement a Solution specific version of the profile provider that extends the common provider as its base (using the `extendProfileProvider` utility), and excludes the extension point implementations you don’t want, e.g. `SecurityLogsDataSourceProfileProvider`. Other than the excluded extension point implementations, the only required change is to update its `resolve` method to first check the `rootContext.solutionType` for the target solution type before executing the base provider’s `resolve` method. This will ensure the override profile only resolves for the specific Solution, and will fall back to the common profile in other Solutions. +- Register the Solution specific version of the profile provider in Discover, ensuring it precedes the common provider in the registration array. The ordering here is important since the Solution specific profile should attempt to resolve first, otherwise the common profile would be resolved instead. + +This is how an example implementation would work in code: + +```tsx +/** + * profile_providers/security/security_root_profile/profile.tsx + */ + +// Create a solution specific root profile provider +export const createSecurityRootProfileProvider = (): RootProfileProvider => ({ + profileId: 'security-root-profile', + profile: {}, + resolve: (params) => { + if (params.solutionNavId === SolutionType.Security) { + return { + isMatch: true, + context: { solutionType: SolutionType.Security }, + }; + } + + return { isMatch: false }; + }, +}); + +/** + * profile_providers/security/security_logs_data_source_profile/profile.tsx + */ + +// Create a solution specific data source profile provider that extends a target base provider +export const createSecurityLogsDataSourceProfileProivder = ( + logsDataSourceProfileProvider: DataSourceProfileProvider +): DataSourceProfileProvider => + // Extend the base profile provider with `extendProfileProvider` + extendProfileProvider(logsDataSourceProfileProvider, { + profileId: 'security-logs-data-source-profile', + profile: { + // Completely remove a specific extension point implementation + getDocViewer: undefined, + // Modify the result of an existing extension point implementation + getCellRenderers: (prev, accessorParams) => (params) => { + // Retrieve and execute the base implementation + const baseImpl = logsDataSourceProfileProvider.profile.getCellRenderers?.( + prev, + accessorParams + ); + const baseRenderers = baseImpl?.(params); + + // Return the modified result + return omit(baseRenderers, 'log.level'); + }, + }, + // Customize the `resolve` implementation + resolve: (params) => { + // Only match this profile when in the target solution context + if (params.rootContext.solutionType !== SolutionType.Security) { + return { isMatch: false }; + } + + // Delegate to the base implementation + return logsDataSourceProfileProvider.resolve(params); + }, + }); + +/** + * profile_providers/register_profile_providers.ts + */ + +// Register root profile providers +const createRootProfileProviders = (providerServices: ProfileProviderServices) => [ + // Register the solution specific root profile provider + createSecurityRootProfileProvider(), +]; + +// Register data source profile providers +const createDataSourceProfileProviders = (providerServices: ProfileProviderServices) => { + // Instantiate the data source profile provider base implementation + const logsDataSourceProfileProvider = createLogsDataSourceProfileProvider(providerServices); + + return [ + // Ensure the solution specific override is registered and resolved first + createSecurityLogsDataSourceProfileProivder(logsDataSourceProfileProvider), + // Then register the base implementation + logsDataSourceProfileProvider, + ]; +}; +``` diff --git a/src/plugins/discover/public/context_awareness/composable_profile.test.ts b/src/plugins/discover/public/context_awareness/composable_profile.test.ts index 34cf44449f20e..bc6a2471c7127 100644 --- a/src/plugins/discover/public/context_awareness/composable_profile.test.ts +++ b/src/plugins/discover/public/context_awareness/composable_profile.test.ts @@ -8,7 +8,7 @@ */ import { DataGridDensity } from '@kbn/unified-data-table'; -import { ComposableProfile, getMergedAccessor } from './composable_profile'; +import { AppliedProfile, getMergedAccessor } from './composable_profile'; import { Profile } from './types'; import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield'; @@ -30,13 +30,13 @@ describe('getMergedAccessor', () => { it('should merge the accessors in the correct order', () => { const baseImpl: Profile['getCellRenderers'] = jest.fn(() => ({ base: jest.fn() })); - const profile1: ComposableProfile = { + const profile1: AppliedProfile = { getCellRenderers: jest.fn((prev) => (params) => ({ ...prev(params), profile1: jest.fn(), })), }; - const profile2: ComposableProfile = { + const profile2: AppliedProfile = { getCellRenderers: jest.fn((prev) => (params) => ({ ...prev(params), profile2: jest.fn(), @@ -57,10 +57,10 @@ describe('getMergedAccessor', () => { it('should allow overwriting previous accessors', () => { const baseImpl: Profile['getCellRenderers'] = jest.fn(() => ({ base: jest.fn() })); - const profile1: ComposableProfile = { + const profile1: AppliedProfile = { getCellRenderers: jest.fn(() => () => ({ profile1: jest.fn() })), }; - const profile2: ComposableProfile = { + const profile2: AppliedProfile = { getCellRenderers: jest.fn((prev) => (params) => ({ ...prev(params), profile2: jest.fn(), diff --git a/src/plugins/discover/public/context_awareness/composable_profile.ts b/src/plugins/discover/public/context_awareness/composable_profile.ts index 84c8b6afca0b3..4779ad724ab2f 100644 --- a/src/plugins/discover/public/context_awareness/composable_profile.ts +++ b/src/plugins/discover/public/context_awareness/composable_profile.ts @@ -14,16 +14,41 @@ import type { Profile } from './types'; */ export type PartialProfile = Partial; +/** + * The parameters passed to a composable accessor, such as the current context object + */ +export interface ComposableAccessorParams { + /** + * The current context object + */ + context: TContext; +} + /** * An accessor function that allows retrieving the extension point result from previous profiles */ -export type ComposableAccessor = (getPrevious: T) => T; +type ComposableAccessor = ( + prev: TPrev, + params: ComposableAccessorParams +) => TPrev; /** * A partial profile implementation that supports composition across multiple profiles */ -export type ComposableProfile = { - [TKey in keyof TProfile]?: ComposableAccessor; +export type ComposableProfile = { + [TKey in keyof TProfile]?: ComposableAccessor; +}; + +/** + * A partially applied accessor function with parameters bound to a specific context + */ +type AppliedAccessor = (prev: TPrev) => TPrev; + +/** + * A partial profile implementation with applied accessors + */ +export type AppliedProfile = { + [TKey in keyof Profile]?: AppliedAccessor; }; /** @@ -34,7 +59,7 @@ export type ComposableProfile = { * @returns The merged extension point accessor function */ export const getMergedAccessor = ( - profiles: ComposableProfile[], + profiles: AppliedProfile[], key: TKey, baseImpl: Profile[TKey] ) => { diff --git a/src/plugins/discover/public/context_awareness/hooks/index.ts b/src/plugins/discover/public/context_awareness/hooks/index.ts index c509fd0119059..28a45be84de76 100644 --- a/src/plugins/discover/public/context_awareness/hooks/index.ts +++ b/src/plugins/discover/public/context_awareness/hooks/index.ts @@ -8,5 +8,5 @@ */ export { useProfileAccessor } from './use_profile_accessor'; -export { useRootProfile } from './use_root_profile'; +export { useRootProfile, BaseAppWrapper } from './use_root_profile'; export { useAdditionalCellActions } from './use_additional_cell_actions'; diff --git a/src/plugins/discover/public/context_awareness/hooks/use_profile_accessor.test.ts b/src/plugins/discover/public/context_awareness/hooks/use_profile_accessor.test.ts index 3fe3c0387149e..65f6f7fb3f30a 100644 --- a/src/plugins/discover/public/context_awareness/hooks/use_profile_accessor.test.ts +++ b/src/plugins/discover/public/context_awareness/hooks/use_profile_accessor.test.ts @@ -8,14 +8,14 @@ */ import { renderHook } from '@testing-library/react-hooks'; -import { ComposableProfile, getMergedAccessor } from '../composable_profile'; +import { AppliedProfile, getMergedAccessor } from '../composable_profile'; import { useProfileAccessor } from './use_profile_accessor'; import { getDataTableRecords } from '../../__fixtures__/real_hits'; import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield'; import { useProfiles } from './use_profiles'; import { DataGridDensity } from '@kbn/unified-data-table'; -let mockProfiles: ComposableProfile[] = []; +let mockProfiles: AppliedProfile[] = []; jest.mock('./use_profiles', () => ({ useProfiles: jest.fn(() => mockProfiles), diff --git a/src/plugins/discover/public/context_awareness/hooks/use_profiles.test.tsx b/src/plugins/discover/public/context_awareness/hooks/use_profiles.test.tsx index 014d29321bf87..b42fc1c4b3c49 100644 --- a/src/plugins/discover/public/context_awareness/hooks/use_profiles.test.tsx +++ b/src/plugins/discover/public/context_awareness/hooks/use_profiles.test.tsx @@ -8,24 +8,43 @@ */ import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; -import { renderHook } from '@testing-library/react-hooks'; +import { act, renderHook } from '@testing-library/react-hooks'; import React from 'react'; import { discoverServiceMock } from '../../__mocks__/services'; -import { GetProfilesOptions } from '../profiles_manager'; +import type { GetProfilesOptions } from '../profiles_manager'; import { createContextAwarenessMocks } from '../__mocks__'; import { useProfiles } from './use_profiles'; +import type { CellRenderersExtensionParams } from '../types'; +import type { AppliedProfile } from '../composable_profile'; +import { SolutionType } from '../profiles'; const { rootProfileProviderMock, dataSourceProfileProviderMock, documentProfileProviderMock, + rootProfileServiceMock, + dataSourceProfileServiceMock, + documentProfileServiceMock, contextRecordMock, contextRecordMock2, profilesManagerMock, -} = createContextAwarenessMocks(); +} = createContextAwarenessMocks({ shouldRegisterProviders: false }); -profilesManagerMock.resolveRootProfile({}); -profilesManagerMock.resolveDataSourceProfile({}); +rootProfileServiceMock.registerProvider({ + profileId: 'other-root-profile', + profile: {}, + resolve: (params) => { + if (params.solutionNavId === 'test') { + return { isMatch: true, context: { solutionType: SolutionType.Default } }; + } + + return { isMatch: false }; + }, +}); + +rootProfileServiceMock.registerProvider(rootProfileProviderMock); +dataSourceProfileServiceMock.registerProvider(dataSourceProfileProviderMock); +documentProfileServiceMock.registerProvider(documentProfileProviderMock); const record = profilesManagerMock.resolveDocumentProfile({ record: contextRecordMock }); const record2 = profilesManagerMock.resolveDocumentProfile({ record: contextRecordMock2 }); @@ -45,22 +64,30 @@ const render = () => { }; describe('useProfiles', () => { - beforeEach(() => { + beforeEach(async () => { jest.clearAllMocks(); + await profilesManagerMock.resolveRootProfile({}); + await profilesManagerMock.resolveDataSourceProfile({}); }); it('should return profiles', () => { const { result } = render(); expect(getProfilesSpy).toHaveBeenCalledTimes(2); expect(getProfiles$Spy).toHaveBeenCalledTimes(1); - expect(result.current).toEqual([ - rootProfileProviderMock.profile, - dataSourceProfileProviderMock.profile, - documentProfileProviderMock.profile, - ]); + expect(result.current).toHaveLength(3); + const [rootProfile, dataSourceProfile, documentProfile] = result.current; + const baseImpl = () => ({}); + rootProfile.getCellRenderers?.(baseImpl)({} as unknown as CellRenderersExtensionParams); + expect(rootProfileProviderMock.profile.getCellRenderers).toHaveBeenCalledTimes(1); + dataSourceProfile.getCellRenderers?.(baseImpl)({} as unknown as CellRenderersExtensionParams); + expect(dataSourceProfileProviderMock.profile.getCellRenderers).toHaveBeenCalledTimes(1); + documentProfile.getCellRenderers?.(baseImpl)({} as unknown as CellRenderersExtensionParams); + expect( + (documentProfileProviderMock.profile as AppliedProfile).getCellRenderers + ).toHaveBeenCalledTimes(1); }); - it('should return the same array reference if profiles do not change', () => { + it('should return the same array reference if profiles and record do not change', () => { const { result, rerender } = render(); expect(getProfilesSpy).toHaveBeenCalledTimes(2); expect(getProfiles$Spy).toHaveBeenCalledTimes(1); @@ -69,13 +96,23 @@ describe('useProfiles', () => { expect(getProfilesSpy).toHaveBeenCalledTimes(2); expect(getProfiles$Spy).toHaveBeenCalledTimes(1); expect(result.current).toBe(prevResult); + }); + + it('should return a different array reference if record changes', () => { + const { result, rerender } = render(); + expect(getProfilesSpy).toHaveBeenCalledTimes(2); + expect(getProfiles$Spy).toHaveBeenCalledTimes(1); + const prevResult = result.current; rerender({ record: record2 }); expect(getProfilesSpy).toHaveBeenCalledTimes(3); expect(getProfiles$Spy).toHaveBeenCalledTimes(2); - expect(result.current).toBe(prevResult); + expect(result.current).not.toBe(prevResult); + expect(result.current[0]).toBe(prevResult[0]); + expect(result.current[1]).toBe(prevResult[1]); + expect(result.current[2]).not.toBe(prevResult[2]); }); - it('should return a different array reference if profiles change', () => { + it('should return a different array reference if profiles change', async () => { const { result, rerender } = render(); expect(getProfilesSpy).toHaveBeenCalledTimes(2); expect(getProfiles$Spy).toHaveBeenCalledTimes(1); @@ -84,9 +121,15 @@ describe('useProfiles', () => { expect(getProfilesSpy).toHaveBeenCalledTimes(2); expect(getProfiles$Spy).toHaveBeenCalledTimes(1); expect(result.current).toBe(prevResult); - rerender({ record: undefined }); + await act(async () => { + await profilesManagerMock.resolveRootProfile({ solutionNavId: 'test' }); + }); + rerender({ record }); expect(getProfilesSpy).toHaveBeenCalledTimes(3); - expect(getProfiles$Spy).toHaveBeenCalledTimes(2); + expect(getProfiles$Spy).toHaveBeenCalledTimes(1); expect(result.current).not.toBe(prevResult); + expect(result.current[0]).not.toBe(prevResult[0]); + expect(result.current[1]).toBe(prevResult[1]); + expect(result.current[2]).not.toBe(prevResult[2]); }); }); diff --git a/src/plugins/discover/public/context_awareness/hooks/use_root_profile.test.tsx b/src/plugins/discover/public/context_awareness/hooks/use_root_profile.test.tsx index 8edbc35ab11a1..26c3aa2df3f15 100644 --- a/src/plugins/discover/public/context_awareness/hooks/use_root_profile.test.tsx +++ b/src/plugins/discover/public/context_awareness/hooks/use_root_profile.test.tsx @@ -8,13 +8,20 @@ */ import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; -import { renderHook } from '@testing-library/react-hooks'; +import { act, renderHook } from '@testing-library/react-hooks'; import React from 'react'; import { discoverServiceMock } from '../../__mocks__/services'; import { useRootProfile } from './use_root_profile'; +import { BehaviorSubject } from 'rxjs'; + +const mockSolutionNavId$ = new BehaviorSubject('solutionNavId'); + +jest + .spyOn(discoverServiceMock.core.chrome, 'getActiveSolutionNavId$') + .mockReturnValue(mockSolutionNavId$); const render = () => { - return renderHook((props) => useRootProfile(props), { + return renderHook(() => useRootProfile(), { initialProps: { solutionNavId: 'solutionNavId' } as React.PropsWithChildren<{ solutionNavId: string; }>, @@ -25,24 +32,36 @@ const render = () => { }; describe('useRootProfile', () => { - it('should return rootProfileLoading as true', () => { - const { result } = render(); + beforeEach(() => { + mockSolutionNavId$.next('solutionNavId'); + }); + + it('should return rootProfileLoading as true', async () => { + const { result, waitForNextUpdate } = render(); expect(result.current.rootProfileLoading).toBe(true); + expect((result.current as Record).AppWrapper).toBeUndefined(); + // avoid act warning + await waitForNextUpdate(); }); it('should return rootProfileLoading as false', async () => { const { result, waitForNextUpdate } = render(); await waitForNextUpdate(); expect(result.current.rootProfileLoading).toBe(false); + expect((result.current as Record).AppWrapper).toBeDefined(); }); it('should return rootProfileLoading as true when solutionNavId changes', async () => { const { result, rerender, waitForNextUpdate } = render(); await waitForNextUpdate(); expect(result.current.rootProfileLoading).toBe(false); - rerender({ solutionNavId: 'newSolutionNavId' }); + expect((result.current as Record).AppWrapper).toBeDefined(); + act(() => mockSolutionNavId$.next('newSolutionNavId')); + rerender(); expect(result.current.rootProfileLoading).toBe(true); + expect((result.current as Record).AppWrapper).toBeUndefined(); await waitForNextUpdate(); expect(result.current.rootProfileLoading).toBe(false); + expect((result.current as Record).AppWrapper).toBeDefined(); }); }); diff --git a/src/plugins/discover/public/context_awareness/hooks/use_root_profile.ts b/src/plugins/discover/public/context_awareness/hooks/use_root_profile.ts deleted file mode 100644 index 2ffccc6d786b2..0000000000000 --- a/src/plugins/discover/public/context_awareness/hooks/use_root_profile.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", 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 { useEffect, useState } from 'react'; -import { useDiscoverServices } from '../../hooks/use_discover_services'; - -/** - * Hook to trigger and wait for root profile resolution - * @param options Options object - * @returns If the root profile is loading - */ -export const useRootProfile = ({ solutionNavId }: { solutionNavId: string | null }) => { - const { profilesManager } = useDiscoverServices(); - const [rootProfileLoading, setRootProfileLoading] = useState(true); - - useEffect(() => { - let aborted = false; - - setRootProfileLoading(true); - - profilesManager.resolveRootProfile({ solutionNavId }).then(() => { - if (!aborted) { - setRootProfileLoading(false); - } - }); - - return () => { - aborted = true; - }; - }, [profilesManager, solutionNavId]); - - return { rootProfileLoading }; -}; diff --git a/src/plugins/discover/public/context_awareness/hooks/use_root_profile.tsx b/src/plugins/discover/public/context_awareness/hooks/use_root_profile.tsx new file mode 100644 index 0000000000000..bf20d6ba58a97 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/hooks/use_root_profile.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", 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 { useEffect, useState } from 'react'; +import { distinctUntilChanged, filter, switchMap, tap } from 'rxjs'; +import React from 'react'; +import { useDiscoverServices } from '../../hooks/use_discover_services'; +import type { Profile } from '../types'; + +/** + * Hook to trigger and wait for root profile resolution + * @param options Options object + * @returns If the root profile is loading + */ +export const useRootProfile = () => { + const { profilesManager, core } = useDiscoverServices(); + const [rootProfileState, setRootProfileState] = useState< + | { rootProfileLoading: true } + | { rootProfileLoading: false; AppWrapper: Profile['getRenderAppWrapper'] } + >({ rootProfileLoading: true }); + + useEffect(() => { + const subscription = core.chrome + .getActiveSolutionNavId$() + .pipe( + distinctUntilChanged(), + filter((id) => id !== undefined), + tap(() => setRootProfileState({ rootProfileLoading: true })), + switchMap((id) => profilesManager.resolveRootProfile({ solutionNavId: id })), + tap(({ getRenderAppWrapper }) => + setRootProfileState({ + rootProfileLoading: false, + AppWrapper: getRenderAppWrapper?.(BaseAppWrapper) ?? BaseAppWrapper, + }) + ) + ) + .subscribe(); + + return () => { + subscription.unsubscribe(); + }; + }, [core.chrome, profilesManager]); + + return rootProfileState; +}; + +export const BaseAppWrapper: Profile['getRenderAppWrapper'] = ({ children }) => <>{children}; diff --git a/src/plugins/discover/public/context_awareness/index.ts b/src/plugins/discover/public/context_awareness/index.ts index fcaec25c0f247..61d829d4e5c5c 100644 --- a/src/plugins/discover/public/context_awareness/index.ts +++ b/src/plugins/discover/public/context_awareness/index.ts @@ -11,4 +11,9 @@ export * from './types'; export * from './profiles'; export { getMergedAccessor } from './composable_profile'; export { ProfilesManager } from './profiles_manager'; -export { useProfileAccessor, useRootProfile, useAdditionalCellActions } from './hooks'; +export { + useProfileAccessor, + useRootProfile, + useAdditionalCellActions, + BaseAppWrapper, +} from './hooks'; diff --git a/src/plugins/discover/public/context_awareness/profile_providers/common/log_document_profile/profile.test.ts b/src/plugins/discover/public/context_awareness/profile_providers/common/log_document_profile/profile.test.ts index 87c5ac137380e..394ccdf71da13 100644 --- a/src/plugins/discover/public/context_awareness/profile_providers/common/log_document_profile/profile.test.ts +++ b/src/plugins/discover/public/context_awareness/profile_providers/common/log_document_profile/profile.test.ts @@ -101,10 +101,13 @@ describe('logDocumentProfileProvider', () => { describe('getDocViewer', () => { it('adds a log overview doc view to the registry', () => { - const getDocViewer = logDocumentProfileProvider.profile.getDocViewer!(() => ({ - title: 'test title', - docViewsRegistry: (registry) => registry, - })); + const getDocViewer = logDocumentProfileProvider.profile.getDocViewer!( + () => ({ + title: 'test title', + docViewsRegistry: (registry) => registry, + }), + { context: { type: DocumentType.Log } } + ); const docViewer = getDocViewer({ record: buildDataTableRecord({}), }); diff --git a/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/profile.test.ts b/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/profile.test.ts index 861ef0b590224..9e8f661a61a33 100644 --- a/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/profile.test.ts +++ b/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/profile.test.ts @@ -117,7 +117,9 @@ describe('logsDataSourceProfileProvider', () => { const row = buildDataTableRecord({ fields: { 'log.level': 'info' } }); const euiTheme = { euiTheme: { colors: {} } } as unknown as EuiThemeComputed; const getRowIndicatorProvider = - logsDataSourceProfileProvider.profile.getRowIndicatorProvider?.(() => undefined); + logsDataSourceProfileProvider.profile.getRowIndicatorProvider?.(() => undefined, { + context: { category: DataSourceCategory.Logs }, + }); const getRowIndicator = getRowIndicatorProvider?.({ dataView: dataViewWithLogLevel, }); @@ -130,7 +132,9 @@ describe('logsDataSourceProfileProvider', () => { const row = buildDataTableRecord({ fields: { other: 'info' } }); const euiTheme = { euiTheme: { colors: {} } } as unknown as EuiThemeComputed; const getRowIndicatorProvider = - logsDataSourceProfileProvider.profile.getRowIndicatorProvider?.(() => undefined); + logsDataSourceProfileProvider.profile.getRowIndicatorProvider?.(() => undefined, { + context: { category: DataSourceCategory.Logs }, + }); const getRowIndicator = getRowIndicatorProvider?.({ dataView: dataViewWithLogLevel, }); @@ -141,7 +145,9 @@ describe('logsDataSourceProfileProvider', () => { it('should not set the color indicator handler if data view does not have log level field', () => { const getRowIndicatorProvider = - logsDataSourceProfileProvider.profile.getRowIndicatorProvider?.(() => undefined); + logsDataSourceProfileProvider.profile.getRowIndicatorProvider?.(() => undefined, { + context: { category: DataSourceCategory.Logs }, + }); const getRowIndicator = getRowIndicatorProvider?.({ dataView: dataViewWithoutLogLevel, }); @@ -152,7 +158,12 @@ describe('logsDataSourceProfileProvider', () => { describe('getCellRenderers', () => { it('should return cell renderers for log level fields', () => { - const getCellRenderers = logsDataSourceProfileProvider.profile.getCellRenderers?.(() => ({})); + const getCellRenderers = logsDataSourceProfileProvider.profile.getCellRenderers?.( + () => ({}), + { + context: { category: DataSourceCategory.Logs }, + } + ); const getCellRenderersParams = { actions: { addFilter: jest.fn() }, dataView: dataViewWithTimefieldMock, @@ -172,7 +183,9 @@ describe('logsDataSourceProfileProvider', () => { describe('getRowAdditionalLeadingControls', () => { it('should return the passed additional controls', () => { const getRowAdditionalLeadingControls = - logsDataSourceProfileProvider.profile.getRowAdditionalLeadingControls?.(() => undefined); + logsDataSourceProfileProvider.profile.getRowAdditionalLeadingControls?.(() => undefined, { + context: { category: DataSourceCategory.Logs }, + }); const rowAdditionalLeadingControls = getRowAdditionalLeadingControls?.({ dataView: dataViewWithLogLevel, }); diff --git a/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/sub_profiles/apache_error_logs.test.ts b/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/sub_profiles/apache_error_logs.test.ts index 9b3e64e520be3..bda23309baec3 100644 --- a/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/sub_profiles/apache_error_logs.test.ts +++ b/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/sub_profiles/apache_error_logs.test.ts @@ -41,7 +41,9 @@ describe('createApacheErrorLogsDataSourceProfileProvider', () => { }); it('should return default app state', () => { - const getDefaultAppState = dataSourceProfileProvider.profile.getDefaultAppState?.(() => ({})); + const getDefaultAppState = dataSourceProfileProvider.profile.getDefaultAppState?.(() => ({}), { + context: { category: DataSourceCategory.Logs }, + }); expect(getDefaultAppState?.({ dataView: dataViewWithTimefieldMock })).toEqual({ columns: [ { name: 'timestamp', width: 212 }, diff --git a/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/sub_profiles/aws_s3access_logs.test.ts b/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/sub_profiles/aws_s3access_logs.test.ts index fc97e67b1bee7..c95cc090195a2 100644 --- a/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/sub_profiles/aws_s3access_logs.test.ts +++ b/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/sub_profiles/aws_s3access_logs.test.ts @@ -41,7 +41,9 @@ describe('createAwsS3accessLogsDataSourceProfileProvider', () => { }); it('should return default app state', () => { - const getDefaultAppState = dataSourceProfileProvider.profile.getDefaultAppState?.(() => ({})); + const getDefaultAppState = dataSourceProfileProvider.profile.getDefaultAppState?.(() => ({}), { + context: { category: DataSourceCategory.Logs }, + }); expect(getDefaultAppState?.({ dataView: dataViewWithTimefieldMock })).toEqual({ columns: [ { name: 'timestamp', width: 212 }, diff --git a/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/sub_profiles/kubernetes_container_logs.test.ts b/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/sub_profiles/kubernetes_container_logs.test.ts index 301ef9ca52a86..3f43aa3b6808a 100644 --- a/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/sub_profiles/kubernetes_container_logs.test.ts +++ b/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/sub_profiles/kubernetes_container_logs.test.ts @@ -41,7 +41,9 @@ describe('createKubernetesContainerLogsDataSourceProfileProvider', () => { }); it('should return default app state', () => { - const getDefaultAppState = dataSourceProfileProvider.profile.getDefaultAppState?.(() => ({})); + const getDefaultAppState = dataSourceProfileProvider.profile.getDefaultAppState?.(() => ({}), { + context: { category: DataSourceCategory.Logs }, + }); expect(getDefaultAppState?.({ dataView: dataViewWithTimefieldMock })).toEqual({ columns: [ { name: 'timestamp', width: 212 }, diff --git a/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/sub_profiles/nginx_access_logs.test.ts b/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/sub_profiles/nginx_access_logs.test.ts index 0265c17152177..3116ebd55d3e7 100644 --- a/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/sub_profiles/nginx_access_logs.test.ts +++ b/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/sub_profiles/nginx_access_logs.test.ts @@ -41,7 +41,9 @@ describe('createNginxAccessLogsDataSourceProfileProvider', () => { }); it('should return default app state', () => { - const getDefaultAppState = dataSourceProfileProvider.profile.getDefaultAppState?.(() => ({})); + const getDefaultAppState = dataSourceProfileProvider.profile.getDefaultAppState?.(() => ({}), { + context: { category: DataSourceCategory.Logs }, + }); expect(getDefaultAppState?.({ dataView: dataViewWithTimefieldMock })).toEqual({ columns: [ { name: 'timestamp', width: 212 }, diff --git a/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/sub_profiles/nginx_error_logs.test.ts b/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/sub_profiles/nginx_error_logs.test.ts index 7ce8e49337a51..c5a980c31d21e 100644 --- a/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/sub_profiles/nginx_error_logs.test.ts +++ b/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/sub_profiles/nginx_error_logs.test.ts @@ -41,7 +41,9 @@ describe('createNginxErrorLogsDataSourceProfileProvider', () => { }); it('should return default app state', () => { - const getDefaultAppState = dataSourceProfileProvider.profile.getDefaultAppState?.(() => ({})); + const getDefaultAppState = dataSourceProfileProvider.profile.getDefaultAppState?.(() => ({}), { + context: { category: DataSourceCategory.Logs }, + }); expect(getDefaultAppState?.({ dataView: dataViewWithTimefieldMock })).toEqual({ columns: [ { name: 'timestamp', width: 212 }, diff --git a/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/sub_profiles/system_logs.test.ts b/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/sub_profiles/system_logs.test.ts index 760546b89bc51..f6105d9115fb4 100644 --- a/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/sub_profiles/system_logs.test.ts +++ b/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/sub_profiles/system_logs.test.ts @@ -41,7 +41,9 @@ describe('createSystemLogsDataSourceProfileProvider', () => { }); it('should return default app state', () => { - const getDefaultAppState = dataSourceProfileProvider.profile.getDefaultAppState?.(() => ({})); + const getDefaultAppState = dataSourceProfileProvider.profile.getDefaultAppState?.(() => ({}), { + context: { category: DataSourceCategory.Logs }, + }); expect(getDefaultAppState?.({ dataView: dataViewWithTimefieldMock })).toEqual({ columns: [ { name: 'timestamp', width: 212 }, diff --git a/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/sub_profiles/windows_logs.test.ts b/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/sub_profiles/windows_logs.test.ts index ce144b9167646..b54cdf3787f5a 100644 --- a/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/sub_profiles/windows_logs.test.ts +++ b/src/plugins/discover/public/context_awareness/profile_providers/common/logs_data_source_profile/sub_profiles/windows_logs.test.ts @@ -41,7 +41,9 @@ describe('createWindowsLogsDataSourceProfileProvider', () => { }); it('should return default app state', () => { - const getDefaultAppState = dataSourceProfileProvider.profile.getDefaultAppState?.(() => ({})); + const getDefaultAppState = dataSourceProfileProvider.profile.getDefaultAppState?.(() => ({}), { + context: { category: DataSourceCategory.Logs }, + }); expect(getDefaultAppState?.({ dataView: dataViewWithTimefieldMock })).toEqual({ columns: [ { name: 'timestamp', width: 212 }, diff --git a/src/plugins/discover/public/context_awareness/profile_providers/example/example_context.ts b/src/plugins/discover/public/context_awareness/profile_providers/example/example_context.ts new file mode 100644 index 0000000000000..e9475d61f1425 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/profile_providers/example/example_context.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { createContext, useContext } from 'react'; + +const exampleContext = createContext<{ + currentMessage: string | undefined; + setCurrentMessage: (message: string | undefined) => void; +}>({ + currentMessage: undefined, + setCurrentMessage: () => {}, +}); + +export const ExampleContextProvider = exampleContext.Provider; + +export const useExampleContext = () => useContext(exampleContext); diff --git a/src/plugins/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx b/src/plugins/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx index c82cf1a893c8d..46ecce387e877 100644 --- a/src/plugins/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx +++ b/src/plugins/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx @@ -7,8 +7,13 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { EuiBadge } from '@elastic/eui'; -import { getFieldValue, RowControlColumn } from '@kbn/discover-utils'; +import { EuiBadge, EuiLink, EuiFlyout, EuiPanel } from '@elastic/eui'; +import { + AppMenuActionId, + AppMenuActionType, + getFieldValue, + RowControlColumn, +} from '@kbn/discover-utils'; import { isOfAggregateQueryType } from '@kbn/es-query'; import { getIndexPatternFromESQLQuery } from '@kbn/esql-utils'; import { euiThemeVars } from '@kbn/ui-theme'; @@ -16,8 +21,11 @@ import { capitalize } from 'lodash'; import React from 'react'; import { DataSourceType, isDataSourceType } from '../../../../../common/data_sources'; import { DataSourceCategory, DataSourceProfileProvider } from '../../../profiles'; +import { useExampleContext } from '../example_context'; -export const createExampleDataSourceProfileProvider = (): DataSourceProfileProvider => ({ +export const createExampleDataSourceProfileProvider = (): DataSourceProfileProvider<{ + formatRecord: (flattenedRecord: Record) => string; +}> => ({ profileId: 'example-data-source-profile', isExperimental: true, profile: { @@ -53,23 +61,135 @@ export const createExampleDataSourceProfileProvider = (): DataSourceProfileProvi ); }, + message: function Message(props) { + const { currentMessage, setCurrentMessage } = useExampleContext(); + const message = getFieldValue(props.row, 'message') as string; + + return ( + setCurrentMessage(message)} + css={{ fontWeight: currentMessage === message ? 'bold' : undefined }} + data-test-subj="exampleDataSourceProfileMessage" + > + {message} + + ); + }, }), - getDocViewer: (prev) => (params) => { - const recordId = params.record.id; + getDocViewer: + (prev, { context }) => + (params) => { + const recordId = params.record.id; + const prevValue = prev(params); + return { + title: `Record #${recordId}`, + docViewsRegistry: (registry) => { + registry.add({ + id: 'doc_view_example', + title: 'Example', + order: 0, + component: () => ( + +
Example Doc View
+
+                    {context.formatRecord(params.record.flattened)}
+                  
+
+ ), + }); + + return prevValue.docViewsRegistry(registry); + }, + }; + }, + /** + * The `getAppMenu` extension point gives access to AppMenuRegistry with methods registerCustomAction and registerCustomActionUnderSubmenu. + * The extension also provides the essential params like current dataView, adHocDataViews etc when defining a custom action implementation. + * And it supports opening custom flyouts and any other modals on the click. + * `getAppMenu` can be configured in both root and data source profiles. + * @param prev + */ + getAppMenu: (prev) => (params) => { const prevValue = prev(params); + + // This is what is available via params: + // const { dataView, services, isEsqlMode, adHocDataViews, onUpdateAdHocDataViews } = params; + return { - title: `Record #${recordId}`, - docViewsRegistry: (registry) => { - registry.add({ - id: 'doc_view_example', - title: 'Example', - order: 0, - component: () => ( -
Example Doc View
- ), + appMenuRegistry: (registry) => { + // Note: Only 2 custom actions are allowed to be rendered in the app menu. The rest will be ignored. + + // Can be a on-click action, link or a submenu with an array of actions and horizontal rules + registry.registerCustomAction({ + id: 'example-custom-action', + type: AppMenuActionType.custom, + controlProps: { + label: 'Custom action', + testId: 'example-custom-action', + onClick: ({ onFinishAction }) => { + alert('Example Custom action clicked'); + onFinishAction(); // This allows to return focus back to the app menu DOM node + }, + }, + // In case of a submenu, you can add actions to it under `actions` + // actions: [ + // { + // id: 'example-custom-action-1-1', + // type: AppMenuActionType.custom, + // controlProps: { + // label: 'Custom action', + // onClick: ({ onFinishAction }) => { + // alert('Example Custom action clicked'); + // onFinishAction(); + // }, + // }, + // }, + // { + // id: 'example-custom-action-1-2', + // type: AppMenuActionType.submenuHorizontalRule + // }, + // ... + // ], + }); + + // This example shows how to add a custom action under the Alerts submenu + registry.registerCustomActionUnderSubmenu(AppMenuActionId.alerts, { + // It's also possible to override the submenu actions by using the same id + // as `AppMenuActionId.createRule` or `AppMenuActionId.manageRulesAndConnectors` + id: 'example-custom-action4', + type: AppMenuActionType.custom, + order: 101, + controlProps: { + label: 'Create SLO (Custom action)', + iconType: 'visGauge', + testId: 'example-custom-action-under-alerts', + onClick: ({ onFinishAction }) => { + // This is an example of a custom action that opens a flyout or any other custom modal. + // To do so, simply return a React element and call onFinishAction when you're done. + return ( + +
Example custom action clicked
+
+ ); + }, + }, }); - return prevValue.docViewsRegistry(registry); + // This submenu was defined in the root profile example_root_pofile/profile.tsx + // And we can still add actions to it from the data source profile here. + registry.registerCustomActionUnderSubmenu('example-custom-root-submenu', { + id: 'example-custom-action5', + type: AppMenuActionType.custom, + controlProps: { + label: 'Custom action (from Data Source profile)', + onClick: ({ onFinishAction }) => { + alert('Example Data source action under root submenu clicked'); + onFinishAction(); + }, + }, + }); + + return prevValue.appMenuRegistry(registry); }, }; }, @@ -156,7 +276,10 @@ export const createExampleDataSourceProfileProvider = (): DataSourceProfileProvi return { isMatch: true, - context: { category: DataSourceCategory.Logs }, + context: { + category: DataSourceCategory.Logs, + formatRecord: (record) => JSON.stringify(record, null, 2), + }, }; }, }); diff --git a/src/plugins/discover/public/context_awareness/profile_providers/example/example_root_pofile/profile.tsx b/src/plugins/discover/public/context_awareness/profile_providers/example/example_root_pofile/profile.tsx deleted file mode 100644 index 125cb609fb849..0000000000000 --- a/src/plugins/discover/public/context_awareness/profile_providers/example/example_root_pofile/profile.tsx +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", 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 { EuiBadge } from '@elastic/eui'; -import { getFieldValue } from '@kbn/discover-utils'; -import React from 'react'; -import { RootProfileProvider, SolutionType } from '../../../profiles'; - -export const createExampleRootProfileProvider = (): RootProfileProvider => ({ - profileId: 'example-root-profile', - isExperimental: true, - profile: { - getCellRenderers: (prev) => (params) => ({ - ...prev(params), - '@timestamp': (props) => { - const timestamp = getFieldValue(props.row, '@timestamp') as string; - - return ( - - {timestamp} - - ); - }, - }), - }, - resolve: (params) => { - if (params.solutionNavId != null) { - return { isMatch: false }; - } - - return { isMatch: true, context: { solutionType: SolutionType.Default } }; - }, -}); diff --git a/src/plugins/dashboard/server/content_management/schema/v2/index.ts b/src/plugins/discover/public/context_awareness/profile_providers/example/example_root_profile/index.ts similarity index 82% rename from src/plugins/dashboard/server/content_management/schema/v2/index.ts rename to src/plugins/discover/public/context_awareness/profile_providers/example/example_root_profile/index.ts index 66beda1385d00..b286a7d8cdce0 100644 --- a/src/plugins/dashboard/server/content_management/schema/v2/index.ts +++ b/src/plugins/discover/public/context_awareness/profile_providers/example/example_root_profile/index.ts @@ -8,7 +8,6 @@ */ export { - serviceDefinition, - dashboardSavedObjectSchema, - dashboardAttributesSchema, -} from './cm_services'; + createExampleRootProfileProvider, + createExampleSolutionViewRootProfileProvider, +} from './profile'; diff --git a/src/plugins/discover/public/context_awareness/profile_providers/example/example_root_profile/profile.tsx b/src/plugins/discover/public/context_awareness/profile_providers/example/example_root_profile/profile.tsx new file mode 100644 index 0000000000000..1b957718c5d6b --- /dev/null +++ b/src/plugins/discover/public/context_awareness/profile_providers/example/example_root_profile/profile.tsx @@ -0,0 +1,153 @@ +/* + * Copyright 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 { + EuiBadge, + EuiCodeBlock, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiTitle, +} from '@elastic/eui'; +import { AppMenuActionType, getFieldValue } from '@kbn/discover-utils'; +import React, { useState } from 'react'; +import { RootProfileProvider, SolutionType } from '../../../profiles'; +import { ExampleContextProvider } from '../example_context'; + +export const createExampleRootProfileProvider = (): RootProfileProvider => ({ + profileId: 'example-root-profile', + isExperimental: true, + profile: { + getRenderAppWrapper, + getCellRenderers: (prev) => (params) => ({ + ...prev(params), + '@timestamp': (props) => { + const timestamp = getFieldValue(props.row, '@timestamp') as string; + + return ( + + {timestamp} + + ); + }, + }), + /** + * The `getAppMenu` extension point gives access to AppMenuRegistry with methods registerCustomAction and registerCustomActionUnderSubmenu. + * The extension also provides the essential params like current dataView, adHocDataViews etc when defining a custom action implementation. + * And it supports opening custom flyouts and any other modals on the click. + * `getAppMenu` can be configured in both root and data source profiles. + * @param prev + */ + getAppMenu: (prev) => (params) => { + const prevValue = prev(params); + + // Check `params` for the available deps + + return { + appMenuRegistry: (registry) => { + // Note: Only 2 custom actions are allowed to be rendered in the app menu. The rest will be ignored. + + // Register a custom submenu action + registry.registerCustomAction({ + id: 'example-custom-root-submenu', + type: AppMenuActionType.custom, + label: 'Custom Submenu', + testId: 'example-custom-root-submenu', + actions: [ + { + id: 'example-custom-root-action11', + type: AppMenuActionType.custom, + controlProps: { + label: 'Custom action 11 (from Root profile)', + testId: 'example-custom-root-action11', + onClick: ({ onFinishAction }) => { + alert('Example Root Custom action 11 clicked'); + onFinishAction(); // This allows to close the popover and return focus back to the app menu DOM node + }, + }, + }, + { + id: 'example-custom-root-action12', + type: AppMenuActionType.custom, + controlProps: { + label: 'Custom action 12 (from Root profile)', + testId: 'example-custom-root-action12', + onClick: ({ onFinishAction }) => { + // This is an example of a custom action that opens a flyout or any other custom modal. + // To do so, simply return a React element and call onFinishAction when you're done. + return ( + +
Example custom action clicked
+
+ ); + }, + }, + }, + ], + }); + + return prevValue.appMenuRegistry(registry); + }, + }; + }, + }, + resolve: (params) => { + if (params.solutionNavId != null) { + return { isMatch: false }; + } + + return { isMatch: true, context: { solutionType: SolutionType.Default } }; + }, +}); + +export const createExampleSolutionViewRootProfileProvider = (): RootProfileProvider => ({ + profileId: 'example-solution-view-root-profile', + isExperimental: true, + profile: { getRenderAppWrapper }, + resolve: (params) => ({ + isMatch: true, + context: { solutionType: params.solutionNavId as SolutionType }, + }), +}); + +const getRenderAppWrapper: RootProfileProvider['profile']['getRenderAppWrapper'] = + (PrevWrapper) => + ({ children }) => { + const [currentMessage, setCurrentMessage] = useState(undefined); + + return ( + + + {children} + {currentMessage && ( + setCurrentMessage(undefined)} + data-test-subj="exampleRootProfileFlyout" + > + + +

Inspect message

+
+
+ + + {currentMessage} + + +
+ )} +
+
+ ); + }; diff --git a/src/plugins/discover/public/context_awareness/profile_providers/extend_profile_provider.ts b/src/plugins/discover/public/context_awareness/profile_providers/extend_profile_provider.ts index 9382101464ec7..9eea569b66217 100644 --- a/src/plugins/discover/public/context_awareness/profile_providers/extend_profile_provider.ts +++ b/src/plugins/discover/public/context_awareness/profile_providers/extend_profile_provider.ts @@ -15,7 +15,7 @@ import type { BaseProfileProvider } from '../profile_service'; * @param extension The extension to apply to the base profile provider * @returns The extended profile provider */ -export const extendProfileProvider = >( +export const extendProfileProvider = >( baseProvider: TProvider, extension: Partial & Pick ): TProvider => ({ diff --git a/src/plugins/discover/public/context_awareness/profile_providers/register_profile_providers.test.ts b/src/plugins/discover/public/context_awareness/profile_providers/register_profile_providers.test.ts index 940eb6b67e591..69edc5d3b7cd3 100644 --- a/src/plugins/discover/public/context_awareness/profile_providers/register_profile_providers.test.ts +++ b/src/plugins/discover/public/context_awareness/profile_providers/register_profile_providers.test.ts @@ -9,20 +9,24 @@ import { createEsqlDataSource } from '../../../common/data_sources'; import { createContextAwarenessMocks } from '../__mocks__'; -import { createExampleRootProfileProvider } from './example/example_root_pofile'; +import { createExampleRootProfileProvider } from './example/example_root_profile'; import { createExampleDataSourceProfileProvider } from './example/example_data_source_profile/profile'; import { createExampleDocumentProfileProvider } from './example/example_document_profile'; - import { registerProfileProviders, registerEnabledProfileProviders, } from './register_profile_providers'; +import type { CellRenderersExtensionParams } from '../types'; const exampleRootProfileProvider = createExampleRootProfileProvider(); const exampleDataSourceProfileProvider = createExampleDataSourceProfileProvider(); const exampleDocumentProfileProvider = createExampleDocumentProfileProvider(); describe('registerEnabledProfileProviders', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should register all profile providers', async () => { const { rootProfileServiceMock, rootProfileProviderMock } = createContextAwarenessMocks({ shouldRegisterProviders: false, @@ -33,37 +37,52 @@ describe('registerEnabledProfileProviders', () => { enabledExperimentalProfileIds: [], }); const context = await rootProfileServiceMock.resolve({ solutionNavId: null }); - expect(rootProfileServiceMock.getProfile(context)).toBe(rootProfileProviderMock.profile); + const profile = rootProfileServiceMock.getProfile({ context }); + const baseImpl = () => ({}); + profile.getCellRenderers?.(baseImpl)({} as unknown as CellRenderersExtensionParams); + expect(rootProfileProviderMock.profile.getCellRenderers).toHaveBeenCalledTimes(1); + expect(rootProfileProviderMock.profile.getCellRenderers).toHaveBeenCalledWith(baseImpl, { + context, + }); }); it('should not register experimental profile providers by default', async () => { + jest.spyOn(exampleRootProfileProvider.profile, 'getCellRenderers'); const { rootProfileServiceMock } = createContextAwarenessMocks({ shouldRegisterProviders: false, }); - registerEnabledProfileProviders({ profileService: rootProfileServiceMock, providers: [exampleRootProfileProvider], enabledExperimentalProfileIds: [], }); const context = await rootProfileServiceMock.resolve({ solutionNavId: null }); - expect(rootProfileServiceMock.getProfile(context)).not.toBe(exampleRootProfileProvider.profile); - expect(rootProfileServiceMock.getProfile(context)).toMatchObject({}); + const profile = rootProfileServiceMock.getProfile({ context }); + const baseImpl = () => ({}); + profile.getCellRenderers?.(baseImpl)({} as unknown as CellRenderersExtensionParams); + expect(exampleRootProfileProvider.profile.getCellRenderers).not.toHaveBeenCalled(); + expect(profile).toMatchObject({}); }); it('should register experimental profile providers when enabled by config', async () => { + jest.spyOn(exampleRootProfileProvider.profile, 'getCellRenderers'); const { rootProfileServiceMock, rootProfileProviderMock } = createContextAwarenessMocks({ shouldRegisterProviders: false, }); - registerEnabledProfileProviders({ profileService: rootProfileServiceMock, providers: [exampleRootProfileProvider], enabledExperimentalProfileIds: [exampleRootProfileProvider.profileId], }); const context = await rootProfileServiceMock.resolve({ solutionNavId: null }); - expect(rootProfileServiceMock.getProfile(context)).toBe(exampleRootProfileProvider.profile); - expect(rootProfileServiceMock.getProfile(context)).not.toBe(rootProfileProviderMock.profile); + const profile = rootProfileServiceMock.getProfile({ context }); + const baseImpl = () => ({}); + profile.getCellRenderers?.(baseImpl)({} as unknown as CellRenderersExtensionParams); + expect(exampleRootProfileProvider.profile.getCellRenderers).toHaveBeenCalledTimes(1); + expect(exampleRootProfileProvider.profile.getCellRenderers).toHaveBeenCalledWith(baseImpl, { + context, + }); + expect(rootProfileProviderMock.profile.getCellRenderers).not.toHaveBeenCalled(); }); }); diff --git a/src/plugins/discover/public/context_awareness/profile_providers/register_profile_providers.ts b/src/plugins/discover/public/context_awareness/profile_providers/register_profile_providers.ts index 58ff63ca35c19..997edac1bae57 100644 --- a/src/plugins/discover/public/context_awareness/profile_providers/register_profile_providers.ts +++ b/src/plugins/discover/public/context_awareness/profile_providers/register_profile_providers.ts @@ -15,7 +15,10 @@ import type { import type { BaseProfileProvider, BaseProfileService } from '../profile_service'; import { createExampleDataSourceProfileProvider } from './example/example_data_source_profile/profile'; import { createExampleDocumentProfileProvider } from './example/example_document_profile'; -import { createExampleRootProfileProvider } from './example/example_root_pofile'; +import { + createExampleSolutionViewRootProfileProvider, + createExampleRootProfileProvider, +} from './example/example_root_profile'; import { createLogsDataSourceProfileProviders } from './common/logs_data_source_profile'; import { createLogDocumentProfileProvider } from './common/log_document_profile'; import { createSecurityRootProfileProvider } from './security/security_root_profile'; @@ -83,8 +86,8 @@ export const registerProfileProviders = async ({ * @param options Register enabled profile providers options */ export const registerEnabledProfileProviders = < - TProvider extends BaseProfileProvider<{}>, - TService extends BaseProfileService + TProvider extends BaseProfileProvider<{}, {}>, + TService extends BaseProfileService >({ profileService, providers: availableProviders, @@ -117,6 +120,7 @@ export const registerEnabledProfileProviders = < */ const createRootProfileProviders = (providerServices: ProfileProviderServices) => [ createExampleRootProfileProvider(), + createExampleSolutionViewRootProfileProvider(), createSecurityRootProfileProvider(providerServices), ]; diff --git a/src/plugins/discover/public/context_awareness/profile_providers/security/security_root_profile/profile.tsx b/src/plugins/discover/public/context_awareness/profile_providers/security/security_root_profile/profile.tsx index 238d29302a910..602879125a331 100644 --- a/src/plugins/discover/public/context_awareness/profile_providers/security/security_root_profile/profile.tsx +++ b/src/plugins/discover/public/context_awareness/profile_providers/security/security_root_profile/profile.tsx @@ -7,8 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React from 'react'; -import { getDiscoverCellRenderer } from '@kbn/security-solution-common'; import { RootProfileProvider, SolutionType } from '../../../profiles'; import { ProfileProviderServices } from '../../profile_provider_services'; import { SecurityProfileProviderFactory } from '../types'; @@ -21,12 +19,6 @@ export const createSecurityRootProfileProvider: SecurityProfileProviderFactory< profile: { getCellRenderers: (prev) => (params) => ({ ...prev(params), - 'host.name': (props) => { - const CellRenderer = getDiscoverCellRenderer({ - fieldName: 'host.name', - }); - return ; - }, }), }, resolve: (params) => { diff --git a/src/plugins/discover/public/context_awareness/profile_service.test.ts b/src/plugins/discover/public/context_awareness/profile_service.test.ts index 22190dcb60da2..20ef2ba4556ae 100644 --- a/src/plugins/discover/public/context_awareness/profile_service.test.ts +++ b/src/plugins/discover/public/context_awareness/profile_service.test.ts @@ -9,8 +9,14 @@ /* eslint-disable max-classes-per-file */ -import { AsyncProfileService, ContextWithProfileId, ProfileService } from './profile_service'; -import { Profile } from './types'; +import { + AsyncProfileProvider, + AsyncProfileService, + ContextWithProfileId, + ProfileProvider, + ProfileService, +} from './profile_service'; +import type { CellRenderersExtensionParams, Profile } from './types'; interface TestParams { myParam: string; @@ -25,7 +31,7 @@ const defaultContext: ContextWithProfileId = { myContext: 'test', }; -class TestProfileService extends ProfileService { +class TestProfileService extends ProfileService> { constructor() { super(defaultContext); } @@ -33,7 +39,9 @@ class TestProfileService extends ProfileService[0]; -class TestAsyncProfileService extends AsyncProfileService { +class TestAsyncProfileService extends AsyncProfileService< + AsyncProfileProvider +> { constructor() { super(defaultContext); } @@ -43,25 +51,27 @@ type TestAsyncProfileProvider = Parameters (params) => prev(params)), + }, resolve: jest.fn(() => ({ isMatch: false })), }; const provider2: TestProfileProvider = { profileId: 'test-profile-2', - profile: { getCellRenderers: jest.fn() }, + profile: { getCellRenderers: jest.fn((prev) => (params) => prev(params)) }, resolve: jest.fn(({ myParam }) => ({ isMatch: true, context: { myContext: myParam } })), }; const provider3: TestProfileProvider = { profileId: 'test-profile-3', - profile: { getCellRenderers: jest.fn() }, + profile: { getCellRenderers: jest.fn((prev) => (params) => prev(params)) }, resolve: jest.fn(({ myParam }) => ({ isMatch: true, context: { myContext: myParam } })), }; const asyncProvider2: TestAsyncProfileProvider = { profileId: 'test-profile-2', - profile: { getCellRenderers: jest.fn() }, + profile: { getCellRenderers: jest.fn((prev) => (params) => prev(params)) }, resolve: jest.fn(async ({ myParam }) => ({ isMatch: true, context: { myContext: myParam } })), }; @@ -80,17 +90,30 @@ describe('ProfileService', () => { it('should allow registering providers and getting profiles', () => { service.registerProvider(provider); service.registerProvider(provider2); - expect(service.getProfile({ profileId: 'test-profile-1', myContext: 'test' })).toBe( - provider.profile - ); - expect(service.getProfile({ profileId: 'test-profile-2', myContext: 'test' })).toBe( - provider2.profile - ); + const params = { + context: { profileId: 'test-profile-1', myContext: 'test' }, + }; + const params2 = { + context: { profileId: 'test-profile-2', myContext: 'test' }, + }; + const profile = service.getProfile(params); + const profile2 = service.getProfile(params2); + const baseImpl = jest.fn(() => ({})); + profile.getCellRenderers?.(baseImpl)({} as unknown as CellRenderersExtensionParams); + expect(provider.profile.getCellRenderers).toHaveBeenCalledTimes(1); + expect(provider.profile.getCellRenderers).toHaveBeenCalledWith(baseImpl, params); + expect(baseImpl).toHaveBeenCalledTimes(1); + profile2.getCellRenderers?.(baseImpl)({} as unknown as CellRenderersExtensionParams); + expect(provider2.profile.getCellRenderers).toHaveBeenCalledTimes(1); + expect(provider2.profile.getCellRenderers).toHaveBeenCalledWith(baseImpl, params2); + expect(baseImpl).toHaveBeenCalledTimes(2); }); it('should return empty profile if no provider is found', () => { service.registerProvider(provider); - expect(service.getProfile({ profileId: 'test-profile-2', myContext: 'test' })).toEqual({}); + expect( + service.getProfile({ context: { profileId: 'test-profile-2', myContext: 'test' } }) + ).toEqual({}); }); it('should resolve to first matching context', () => { diff --git a/src/plugins/discover/public/context_awareness/profile_service.ts b/src/plugins/discover/public/context_awareness/profile_service.ts index aaeb7664aa5cd..e520b42083f1e 100644 --- a/src/plugins/discover/public/context_awareness/profile_service.ts +++ b/src/plugins/discover/public/context_awareness/profile_service.ts @@ -9,13 +9,18 @@ /* eslint-disable max-classes-per-file */ -import type { ComposableProfile, PartialProfile } from './composable_profile'; -import type { Profile } from './types'; +import { isFunction } from 'lodash'; +import type { + AppliedProfile, + ComposableAccessorParams, + ComposableProfile, + PartialProfile, +} from './composable_profile'; /** * The profile provider resolution result */ -export type ResolveProfileResult = +type ResolveProfileResult = | { /** * `true` if the associated profile is a match @@ -33,15 +38,10 @@ export type ResolveProfileResult = isMatch: false; }; -/** - * Context object with an injected profile ID - */ -export type ContextWithProfileId = TContext & { profileId: string }; - /** * The base profile provider interface */ -export interface BaseProfileProvider { +export interface BaseProfileProvider { /** * The unique profile ID */ @@ -49,7 +49,7 @@ export interface BaseProfileProvider { /** * The composable profile implementation */ - profile: ComposableProfile; + profile: ComposableProfile; /** * Set the `isExperimental` flag to `true` for any profile which is under development and should not be enabled by default. * @@ -68,7 +68,7 @@ export interface BaseProfileProvider { * A synchronous profile provider interface */ export interface ProfileProvider - extends BaseProfileProvider { + extends BaseProfileProvider { /** * The method responsible for context resolution and determining if the associated profile is a match * @param params Parameters specific to the provider context level @@ -81,7 +81,7 @@ export interface ProfileProvider - extends BaseProfileProvider { + extends BaseProfileProvider { /** * The method responsible for context resolution and determining if the associated profile is a match * @param params Parameters specific to the provider context level @@ -92,12 +92,36 @@ export interface AsyncProfileProvider ResolveProfileResult | Promise>; } +/** + * Context object with an injected profile ID + */ +export type ContextWithProfileId = TContext & + Pick, 'profileId'>; + +/** + * Used to extract the profile type from a profile provider + */ +type ExtractProfile = TProvider extends BaseProfileProvider + ? TProfile + : never; + +/** + * Used to extract the context type from a profile provider + */ +type ExtractContext = TProvider extends BaseProfileProvider<{}, infer TContext> + ? TContext + : never; + const EMPTY_PROFILE = {}; /** * The base profile service implementation */ -export abstract class BaseProfileService, TContext> { +export abstract class BaseProfileService< + TProvider extends BaseProfileProvider, + TProfile extends PartialProfile = ExtractProfile, + TContext = ExtractContext +> { protected readonly providers: TProvider[] = []; /** @@ -114,31 +138,59 @@ export abstract class BaseProfileService): ComposableProfile { - const provider = this.providers.find((current) => current.profileId === context.profileId); - return provider?.profile ?? EMPTY_PROFILE; + public getProfile( + params: ComposableAccessorParams> + ): AppliedProfile { + const provider = this.providers.find( + (current) => current.profileId === params.context.profileId + ); + + if (!provider?.profile) { + return EMPTY_PROFILE; + } + + return new Proxy(provider.profile, { + get: (target, prop, receiver) => { + const accessor = Reflect.get(target, prop, receiver); + + if (!isFunction(accessor)) { + return accessor; + } + + return (prev: Parameters[0]) => accessor(prev, params); + }, + }) as AppliedProfile; } } +/** + * Used to extract the parameters type from a profile provider + */ +type ExtractParams = TProvider extends ProfileProvider<{}, infer P, {}> + ? P + : TProvider extends AsyncProfileProvider<{}, infer P, {}> + ? P + : never; + /** * A synchronous profile service implementation */ export class ProfileService< - TProfile extends PartialProfile, - TParams, - TContext -> extends BaseProfileService, TContext> { + TProvider extends ProfileProvider<{}, TParams, TContext>, + TParams = ExtractParams, + TContext = ExtractContext +> extends BaseProfileService { /** * Performs context resolution based on the provided context level parameters, * returning the resolved context from the first matching profile provider * @param params Parameters specific to the service context level * @returns The resolved context object with an injected profile ID */ - public resolve(params: TParams) { + public resolve(params: TParams): ContextWithProfileId { for (const provider of this.providers) { const result = provider.resolve(params); @@ -158,17 +210,17 @@ export class ProfileService< * An asynchronous profile service implementation */ export class AsyncProfileService< - TProfile extends PartialProfile, - TParams, - TContext -> extends BaseProfileService, TContext> { + TProvider extends AsyncProfileProvider<{}, TParams, TContext>, + TParams = ExtractParams, + TContext = ExtractContext +> extends BaseProfileService { /** * Performs context resolution based on the provided context level parameters, * returning the resolved context from the first matching profile provider * @param params Parameters specific to the service context level * @returns The resolved context object with an injected profile ID */ - public async resolve(params: TParams) { + public async resolve(params: TParams): Promise> { for (const provider of this.providers) { const result = await provider.resolve(params); diff --git a/src/plugins/discover/public/context_awareness/profiles/data_source_profile.ts b/src/plugins/discover/public/context_awareness/profiles/data_source_profile.ts index 807072d777a93..7f3933de185a6 100644 --- a/src/plugins/discover/public/context_awareness/profiles/data_source_profile.ts +++ b/src/plugins/discover/public/context_awareness/profiles/data_source_profile.ts @@ -25,7 +25,7 @@ export enum DataSourceCategory { /** * The data source profile interface */ -export type DataSourceProfile = Profile; +export type DataSourceProfile = Omit; /** * Parameters for the data source profile provider `resolve` method @@ -59,17 +59,13 @@ export interface DataSourceContext { category: DataSourceCategory; } -export type DataSourceProfileProvider = AsyncProfileProvider< +export type DataSourceProfileProvider = AsyncProfileProvider< DataSourceProfile, DataSourceProfileProviderParams, - DataSourceContext + DataSourceContext & TProviderContext >; -export class DataSourceProfileService extends AsyncProfileService< - DataSourceProfile, - DataSourceProfileProviderParams, - DataSourceContext -> { +export class DataSourceProfileService extends AsyncProfileService { constructor() { super({ profileId: 'default-data-source-profile', diff --git a/src/plugins/discover/public/context_awareness/profiles/document_profile.ts b/src/plugins/discover/public/context_awareness/profiles/document_profile.ts index cd14e9cdec010..f555dfe8c4292 100644 --- a/src/plugins/discover/public/context_awareness/profiles/document_profile.ts +++ b/src/plugins/discover/public/context_awareness/profiles/document_profile.ts @@ -54,17 +54,13 @@ export interface DocumentContext { type: DocumentType; } -export type DocumentProfileProvider = ProfileProvider< +export type DocumentProfileProvider = ProfileProvider< DocumentProfile, DocumentProfileProviderParams, - DocumentContext + DocumentContext & TProviderContext >; -export class DocumentProfileService extends ProfileService< - DocumentProfile, - DocumentProfileProviderParams, - DocumentContext -> { +export class DocumentProfileService extends ProfileService { constructor() { super({ profileId: 'default-document-profile', diff --git a/src/plugins/discover/public/context_awareness/profiles/root_profile.ts b/src/plugins/discover/public/context_awareness/profiles/root_profile.ts index 853c93c05cf64..0a5909538c498 100644 --- a/src/plugins/discover/public/context_awareness/profiles/root_profile.ts +++ b/src/plugins/discover/public/context_awareness/profiles/root_profile.ts @@ -45,17 +45,13 @@ export interface RootContext { solutionType: SolutionType; } -export type RootProfileProvider = AsyncProfileProvider< +export type RootProfileProvider = AsyncProfileProvider< RootProfile, RootProfileProviderParams, - RootContext + RootContext & TProviderContext >; -export class RootProfileService extends AsyncProfileService< - RootProfile, - RootProfileProviderParams, - RootContext -> { +export class RootProfileService extends AsyncProfileService { constructor() { super({ profileId: 'default-root-profile', diff --git a/src/plugins/discover/public/context_awareness/profiles_manager.test.ts b/src/plugins/discover/public/context_awareness/profiles_manager.test.ts index da5ad8b56dcf3..4e1608961f7f5 100644 --- a/src/plugins/discover/public/context_awareness/profiles_manager.test.ts +++ b/src/plugins/discover/public/context_awareness/profiles_manager.test.ts @@ -12,11 +12,15 @@ import { createEsqlDataSource } from '../../common/data_sources'; import { addLog } from '../utils/add_log'; import { SolutionType } from './profiles/root_profile'; import { createContextAwarenessMocks } from './__mocks__'; +import type { ComposableProfile } from './composable_profile'; jest.mock('../utils/add_log'); let mocks = createContextAwarenessMocks(); +const toAppliedProfile = (profile: ComposableProfile<{}, {}>) => + Object.keys(profile).reduce((acc, key) => ({ ...acc, [key]: expect.any(Function) }), {}); + describe('ProfilesManager', () => { beforeEach(() => { jest.clearAllMocks(); @@ -32,13 +36,17 @@ describe('ProfilesManager', () => { it('should resolve root profile', async () => { await mocks.profilesManagerMock.resolveRootProfile({}); const profiles = mocks.profilesManagerMock.getProfiles(); - expect(profiles).toEqual([mocks.rootProfileProviderMock.profile, {}, {}]); + expect(profiles).toEqual([toAppliedProfile(mocks.rootProfileProviderMock.profile), {}, {}]); }); it('should resolve data source profile', async () => { await mocks.profilesManagerMock.resolveDataSourceProfile({}); const profiles = mocks.profilesManagerMock.getProfiles(); - expect(profiles).toEqual([{}, mocks.dataSourceProfileProviderMock.profile, {}]); + expect(profiles).toEqual([ + {}, + toAppliedProfile(mocks.dataSourceProfileProviderMock.profile), + {}, + ]); }); it('should resolve document profile', async () => { @@ -46,7 +54,7 @@ describe('ProfilesManager', () => { record: mocks.contextRecordMock, }); const profiles = mocks.profilesManagerMock.getProfiles({ record }); - expect(profiles).toEqual([{}, {}, mocks.documentProfileProviderMock.profile]); + expect(profiles).toEqual([{}, {}, toAppliedProfile(mocks.documentProfileProviderMock.profile)]); }); it('should resolve multiple profiles', async () => { @@ -57,9 +65,9 @@ describe('ProfilesManager', () => { }); const profiles = mocks.profilesManagerMock.getProfiles({ record }); expect(profiles).toEqual([ - mocks.rootProfileProviderMock.profile, - mocks.dataSourceProfileProviderMock.profile, - mocks.documentProfileProviderMock.profile, + toAppliedProfile(mocks.rootProfileProviderMock.profile), + toAppliedProfile(mocks.dataSourceProfileProviderMock.profile), + toAppliedProfile(mocks.documentProfileProviderMock.profile), ]); expect(mocks.ebtManagerMock.updateProfilesContextWith).toHaveBeenCalledWith([ @@ -77,20 +85,24 @@ describe('ProfilesManager', () => { const next = jest.fn(); profiles$.subscribe(next); expect(getProfilesSpy).toHaveBeenCalledTimes(1); - expect(next).toHaveBeenCalledWith([{}, {}, mocks.documentProfileProviderMock.profile]); + expect(next).toHaveBeenCalledWith([ + {}, + {}, + toAppliedProfile(mocks.documentProfileProviderMock.profile), + ]); await mocks.profilesManagerMock.resolveRootProfile({}); expect(getProfilesSpy).toHaveBeenCalledTimes(2); expect(next).toHaveBeenCalledWith([ - mocks.rootProfileProviderMock.profile, + toAppliedProfile(mocks.rootProfileProviderMock.profile), {}, - mocks.documentProfileProviderMock.profile, + toAppliedProfile(mocks.documentProfileProviderMock.profile), ]); await mocks.profilesManagerMock.resolveDataSourceProfile({}); expect(getProfilesSpy).toHaveBeenCalledTimes(3); expect(next).toHaveBeenCalledWith([ - mocks.rootProfileProviderMock.profile, - mocks.dataSourceProfileProviderMock.profile, - mocks.documentProfileProviderMock.profile, + toAppliedProfile(mocks.rootProfileProviderMock.profile), + toAppliedProfile(mocks.dataSourceProfileProviderMock.profile), + toAppliedProfile(mocks.documentProfileProviderMock.profile), ]); }); @@ -135,7 +147,7 @@ describe('ProfilesManager', () => { it('should log an error and fall back to the default profile if root profile resolution fails', async () => { await mocks.profilesManagerMock.resolveRootProfile({ solutionNavId: 'solutionNavId' }); let profiles = mocks.profilesManagerMock.getProfiles(); - expect(profiles).toEqual([mocks.rootProfileProviderMock.profile, {}, {}]); + expect(profiles).toEqual([toAppliedProfile(mocks.rootProfileProviderMock.profile), {}, {}]); const resolveSpy = jest.spyOn(mocks.rootProfileProviderMock, 'resolve'); resolveSpy.mockRejectedValue(new Error('Failed to resolve')); await mocks.profilesManagerMock.resolveRootProfile({ solutionNavId: 'newSolutionNavId' }); @@ -153,7 +165,11 @@ describe('ProfilesManager', () => { query: { esql: 'from *' }, }); let profiles = mocks.profilesManagerMock.getProfiles(); - expect(profiles).toEqual([{}, mocks.dataSourceProfileProviderMock.profile, {}]); + expect(profiles).toEqual([ + {}, + toAppliedProfile(mocks.dataSourceProfileProviderMock.profile), + {}, + ]); const resolveSpy = jest.spyOn(mocks.dataSourceProfileProviderMock, 'resolve'); resolveSpy.mockRejectedValue(new Error('Failed to resolve')); await mocks.profilesManagerMock.resolveDataSourceProfile({ @@ -173,7 +189,7 @@ describe('ProfilesManager', () => { record: mocks.contextRecordMock, }); let profiles = mocks.profilesManagerMock.getProfiles({ record }); - expect(profiles).toEqual([{}, {}, mocks.documentProfileProviderMock.profile]); + expect(profiles).toEqual([{}, {}, toAppliedProfile(mocks.documentProfileProviderMock.profile)]); const resolveSpy = jest.spyOn(mocks.documentProfileProviderMock, 'resolve'); resolveSpy.mockImplementation(() => { throw new Error('Failed to resolve'); @@ -220,7 +236,7 @@ describe('ProfilesManager', () => { resolvedDeferredResult2$.next(undefined); await promise2; expect(mocks.profilesManagerMock.getProfiles()).toEqual([ - mocks.rootProfileProviderMock.profile, + toAppliedProfile(mocks.rootProfileProviderMock.profile), {}, {}, ]); @@ -266,7 +282,7 @@ describe('ProfilesManager', () => { await promise2; expect(mocks.profilesManagerMock.getProfiles()).toEqual([ {}, - mocks.dataSourceProfileProviderMock.profile, + toAppliedProfile(mocks.dataSourceProfileProviderMock.profile), {}, ]); }); diff --git a/src/plugins/discover/public/context_awareness/profiles_manager.ts b/src/plugins/discover/public/context_awareness/profiles_manager.ts index 6b7bef5e02294..d5714565dacd2 100644 --- a/src/plugins/discover/public/context_awareness/profiles_manager.ts +++ b/src/plugins/discover/public/context_awareness/profiles_manager.ts @@ -10,7 +10,7 @@ import type { DataTableRecord } from '@kbn/discover-utils'; import { isOfAggregateQueryType } from '@kbn/es-query'; import { isEqual } from 'lodash'; -import { BehaviorSubject, combineLatest, map } from 'rxjs'; +import { BehaviorSubject, combineLatest, map, skip } from 'rxjs'; import { DataSourceType, isDataSourceType } from '../../common/data_sources'; import { addLog } from '../utils/add_log'; import type { @@ -25,7 +25,8 @@ import type { DocumentContext, } from './profiles'; import type { ContextWithProfileId } from './profile_service'; -import { DiscoverEBTManager } from '../services/discover_ebt_manager'; +import type { DiscoverEBTManager } from '../services/discover_ebt_manager'; +import type { AppliedProfile } from './composable_profile'; interface SerializedRootProfileParams { solutionNavId: RootProfileProviderParams['solutionNavId']; @@ -53,8 +54,9 @@ export interface GetProfilesOptions { export class ProfilesManager { private readonly rootContext$: BehaviorSubject>; private readonly dataSourceContext$: BehaviorSubject>; - private readonly ebtManager: DiscoverEBTManager; + private rootProfile: AppliedProfile; + private dataSourceProfile: AppliedProfile; private prevRootProfileParams?: SerializedRootProfileParams; private prevDataSourceProfileParams?: SerializedDataSourceProfileParams; private rootProfileAbortController?: AbortController; @@ -64,11 +66,22 @@ export class ProfilesManager { private readonly rootProfileService: RootProfileService, private readonly dataSourceProfileService: DataSourceProfileService, private readonly documentProfileService: DocumentProfileService, - ebtManager: DiscoverEBTManager + private readonly ebtManager: DiscoverEBTManager ) { this.rootContext$ = new BehaviorSubject(rootProfileService.defaultContext); this.dataSourceContext$ = new BehaviorSubject(dataSourceProfileService.defaultContext); - this.ebtManager = ebtManager; + this.rootProfile = rootProfileService.getProfile({ context: this.rootContext$.getValue() }); + this.dataSourceProfile = dataSourceProfileService.getProfile({ + context: this.dataSourceContext$.getValue(), + }); + + this.rootContext$.pipe(skip(1)).subscribe((context) => { + this.rootProfile = rootProfileService.getProfile({ context }); + }); + + this.dataSourceContext$.pipe(skip(1)).subscribe((context) => { + this.dataSourceProfile = dataSourceProfileService.getProfile({ context }); + }); } /** @@ -79,7 +92,7 @@ export class ProfilesManager { const serializedParams = serializeRootProfileParams(params); if (isEqual(this.prevRootProfileParams, serializedParams)) { - return; + return { getRenderAppWrapper: this.rootProfile.getRenderAppWrapper }; } const abortController = new AbortController(); @@ -95,11 +108,13 @@ export class ProfilesManager { } if (abortController.signal.aborted) { - return; + return { getRenderAppWrapper: this.rootProfile.getRenderAppWrapper }; } this.rootContext$.next(context); this.prevRootProfileParams = serializedParams; + + return { getRenderAppWrapper: this.rootProfile.getRenderAppWrapper }; } /** @@ -181,11 +196,13 @@ export class ProfilesManager { */ public getProfiles({ record }: GetProfilesOptions = {}) { return [ - this.rootProfileService.getProfile(this.rootContext$.getValue()), - this.dataSourceProfileService.getProfile(this.dataSourceContext$.getValue()), - this.documentProfileService.getProfile( - recordHasContext(record) ? record.context : this.documentProfileService.defaultContext - ), + this.rootProfile, + this.dataSourceProfile, + this.documentProfileService.getProfile({ + context: recordHasContext(record) + ? record.context + : this.documentProfileService.defaultContext, + }), ]; } diff --git a/src/plugins/discover/public/context_awareness/types.ts b/src/plugins/discover/public/context_awareness/types.ts index 5797a9023f93e..4b75e6473aafd 100644 --- a/src/plugins/discover/public/context_awareness/types.ts +++ b/src/plugins/discover/public/context_awareness/types.ts @@ -14,16 +14,39 @@ import type { UnifiedDataTableProps, } from '@kbn/unified-data-table'; import type { DocViewsRegistry } from '@kbn/unified-doc-viewer'; -import type { DataTableRecord } from '@kbn/discover-utils'; +import type { AppMenuRegistry, DataTableRecord } from '@kbn/discover-utils'; import type { CellAction, CellActionExecutionContext, CellActionsData } from '@kbn/cell-actions'; import type { EuiIconType } from '@elastic/eui/src/components/icon/icon'; import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; import type { OmitIndexSignature } from 'type-fest'; import type { Trigger } from '@kbn/ui-actions-plugin/public'; -import { DocViewFilterFn } from '@kbn/unified-doc-viewer/types'; +import type { PropsWithChildren, ReactElement } from 'react'; +import type { DocViewFilterFn } from '@kbn/unified-doc-viewer/types'; import type { DiscoverDataSource } from '../../common/data_sources'; import type { DiscoverAppState } from '../application/main/state_management/discover_app_state_container'; -import { DiscoverStateContainer } from '../application/main/state_management/discover_state'; +import type { DiscoverStateContainer } from '../application/main/state_management/discover_state'; + +/** + * Supports extending the Discover app menu + */ +export interface AppMenuExtension { + /** + * Supports extending the app menu with additional actions + * @param prevRegistry The app menu registry + * @returns The updated app menu registry + */ + appMenuRegistry: (prevRegistry: AppMenuRegistry) => AppMenuRegistry; +} + +/** + * Parameters passed to the app menu extension + */ +export interface AppMenuExtensionParams { + isEsqlMode: boolean; + dataView: DataView | undefined; + adHocDataViews: DataView[]; + onUpdateAdHocDataViews: (adHocDataViews: DataView[]) => Promise; +} /** * Supports customizing the Discover document viewer flyout @@ -235,6 +258,14 @@ export interface Profile { * Lifecycle */ + /** + * Render a custom wrapper component around the Discover application, + * e.g. to allow using profile specific context providers + * @param props The app wrapper props + * @returns The custom app wrapper component + */ + getRenderAppWrapper: (props: PropsWithChildren<{}>) => ReactElement; + /** * Gets default Discover app state that should be used when the profile is resolved * @param params The default app state extension parameters @@ -288,4 +319,20 @@ export interface Profile { * @returns The doc viewer extension */ getDocViewer: (params: DocViewerExtensionParams) => DocViewerExtension; + + /** + * App Menu (Top Nav actions) + */ + + /** + * Supports extending the app menu with additional actions + * The `getAppMenu` extension point gives access to AppMenuRegistry with methods registerCustomAction and registerCustomActionUnderSubmenu. + * The extension also provides the essential params like current dataView, adHocDataViews etc when defining a custom action implementation. + * And it supports opening custom flyouts and any other modals on the click. + * `getAppMenu` can be configured in both root and data source profiles. + * Note: Only 2 custom actions are allowed to be rendered in the app menu. The rest will be ignored. + * @param params The app menu extension parameters + * @returns The app menu extension + */ + getAppMenu: (params: AppMenuExtensionParams) => AppMenuExtension; } diff --git a/src/plugins/discover/public/customizations/customization_types/top_nav_customization.ts b/src/plugins/discover/public/customizations/customization_types/top_nav_customization.ts index 865c52f211aff..1bbc6adee520f 100644 --- a/src/plugins/discover/public/customizations/customization_types/top_nav_customization.ts +++ b/src/plugins/discover/public/customizations/customization_types/top_nav_customization.ts @@ -7,11 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { TopNavMenuData, TopNavMenuBadgeProps } from '@kbn/navigation-plugin/public'; - export interface TopNavDefaultItem { disabled?: boolean; - order?: number; } export interface TopNavDefaultMenu { @@ -23,24 +20,12 @@ export interface TopNavDefaultMenu { saveItem?: TopNavDefaultItem; } -export interface TopNavMenuItem { - data: TopNavMenuData; - order: number; -} - export interface TopNavDefaultBadges { unsavedChangesBadge?: TopNavDefaultItem; } -export interface TopNavBadge { - data: TopNavMenuBadgeProps; - order: number; -} - export interface TopNavCustomization { id: 'top_nav'; defaultMenu?: TopNavDefaultMenu; - getMenuItems?: () => TopNavMenuItem[]; defaultBadges?: TopNavDefaultBadges; - getBadges?: () => TopNavBadge[]; } diff --git a/src/plugins/discover/public/customizations/defaults.ts b/src/plugins/discover/public/customizations/defaults.ts index 600f1501a1d41..d44b6527b3909 100644 --- a/src/plugins/discover/public/customizations/defaults.ts +++ b/src/plugins/discover/public/customizations/defaults.ts @@ -10,7 +10,6 @@ import { DiscoverCustomizationContext } from './types'; export const defaultCustomizationContext: DiscoverCustomizationContext = { - solutionNavId: null, displayMode: 'standalone', inlineTopNav: { enabled: false, diff --git a/src/plugins/discover/public/customizations/types.ts b/src/plugins/discover/public/customizations/types.ts index e72426b00d8a2..bf71fa80148ec 100644 --- a/src/plugins/discover/public/customizations/types.ts +++ b/src/plugins/discover/public/customizations/types.ts @@ -22,10 +22,6 @@ export type CustomizationCallback = ( export type DiscoverDisplayMode = 'embedded' | 'standalone'; export interface DiscoverCustomizationContext { - /** - * The current solution nav ID - */ - solutionNavId: string | null; /* * Display mode in which discover is running */ diff --git a/src/plugins/discover/public/embeddable/get_search_embeddable_factory.test.tsx b/src/plugins/discover/public/embeddable/get_search_embeddable_factory.test.tsx index 1c8b77982fb24..b1c589f3e1d84 100644 --- a/src/plugins/discover/public/embeddable/get_search_embeddable_factory.test.tsx +++ b/src/plugins/discover/public/embeddable/get_search_embeddable_factory.test.tsx @@ -238,7 +238,7 @@ describe('saved search embeddable', () => { await waitOneTick(); // wait for build to complete expect(resolveRootProfileSpy).toHaveBeenCalledWith({ solutionNavId: 'test' }); - resolveRootProfileSpy.mockReset(); + resolveRootProfileSpy.mockClear(); expect(resolveRootProfileSpy).not.toHaveBeenCalled(); }); 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 549b42c8a6cbe..37213b17c377d 100644 --- a/src/plugins/discover/public/embeddable/get_search_embeddable_factory.tsx +++ b/src/plugins/discover/public/embeddable/get_search_embeddable_factory.tsx @@ -42,6 +42,7 @@ import { SearchEmbeddableSerializedState, } from './types'; import { deserializeState, serializeState } from './utils/serialization_utils'; +import { BaseAppWrapper } from '../context_awareness'; export const getSearchEmbeddableFactory = ({ startServices, @@ -69,7 +70,10 @@ export const getSearchEmbeddableFactory = ({ const solutionNavId = await firstValueFrom( discoverServices.core.chrome.getActiveSolutionNavId$() ); - await discoverServices.profilesManager.resolveRootProfile({ solutionNavId }); + const { getRenderAppWrapper } = await discoverServices.profilesManager.resolveRootProfile({ + solutionNavId, + }); + const AppWrapper = getRenderAppWrapper?.(BaseAppWrapper) ?? BaseAppWrapper; /** Specific by-reference state */ const savedObjectId$ = new BehaviorSubject(initialState?.savedObjectId); @@ -280,30 +284,32 @@ export const getSearchEmbeddableFactory = ({ return ( - {renderAsFieldStatsTable ? ( - - ) : ( - - + {renderAsFieldStatsTable ? ( + - - )} + ) : ( + + + + )} + ); diff --git a/src/plugins/discover/public/plugin.tsx b/src/plugins/discover/public/plugin.tsx index dbbcc90a7d451..0ee80da03a7d1 100644 --- a/src/plugins/discover/public/plugin.tsx +++ b/src/plugins/discover/public/plugin.tsx @@ -213,7 +213,6 @@ export class DiscoverPlugin .pipe( map((solutionNavId) => ({ ...defaultCustomizationContext, - solutionNavId, inlineTopNav: this.inlineTopNav.get(solutionNavId) ?? this.inlineTopNav.get(null) ?? diff --git a/src/plugins/discover/tsconfig.json b/src/plugins/discover/tsconfig.json index 197d323d7d221..72d5594ba40f0 100644 --- a/src/plugins/discover/tsconfig.json +++ b/src/plugins/discover/tsconfig.json @@ -95,7 +95,6 @@ "@kbn/presentation-containers", "@kbn/observability-ai-assistant-plugin", "@kbn/fields-metadata-plugin", - "@kbn/security-solution-common", "@kbn/logs-data-access-plugin", "@kbn/core-lifecycle-browser", "@kbn/discover-contextual-components", diff --git a/src/plugins/embeddable/kibana.jsonc b/src/plugins/embeddable/kibana.jsonc index 0af5411af56dd..b617114f9fa59 100644 --- a/src/plugins/embeddable/kibana.jsonc +++ b/src/plugins/embeddable/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/embeddable-plugin", - "owner": "@elastic/kibana-presentation", + "owner": [ + "@elastic/kibana-presentation" + ], + "group": "platform", + "visibility": "shared", "description": "Adds embeddables service to Kibana", "plugin": { "id": "embeddable", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "data", "inspector", @@ -15,8 +19,17 @@ "savedObjectsManagement", "contentManagement" ], - "optionalPlugins": ["savedObjectsTaggingOss", "usageCollection"], - "requiredBundles": ["savedObjects", "kibanaUtils", "presentationPanel"], - "extraPublicDirs": ["common"] + "optionalPlugins": [ + "savedObjectsTaggingOss", + "usageCollection" + ], + "requiredBundles": [ + "savedObjects", + "kibanaUtils", + "presentationPanel" + ], + "extraPublicDirs": [ + "common" + ] } -} +} \ No newline at end of file diff --git a/src/plugins/es_ui_shared/kibana.jsonc b/src/plugins/es_ui_shared/kibana.jsonc index 3c738268e7034..927939ae4af63 100644 --- a/src/plugins/es_ui_shared/kibana.jsonc +++ b/src/plugins/es_ui_shared/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/es-ui-shared-plugin", - "owner": "@elastic/kibana-management", + "owner": [ + "@elastic/kibana-management" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "esUiShared", - "server": true, "browser": true, + "server": true, "requiredBundles": [ "dataViews" ], @@ -17,4 +21,4 @@ "static/forms/helpers/field_validators/types" ] } -} +} \ No newline at end of file diff --git a/src/plugins/es_ui_shared/static/forms/helpers/field_validators/empty_field.test.ts b/src/plugins/es_ui_shared/static/forms/helpers/field_validators/empty_field.test.ts new file mode 100644 index 0000000000000..e05f7c86b8a60 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/helpers/field_validators/empty_field.test.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { ValidationFuncArg } from '../../hook_form_lib'; +import { emptyField } from './empty_field'; + +describe('emptyField', () => { + const message = 'test error message'; + const code = 'ERR_FIELD_MISSING'; + const path = 'path'; + + const validator = (value: string | any[], trimString?: boolean) => + emptyField(message, trimString)({ value, path } as ValidationFuncArg); + + test('should return Validation function if value is an empty string and trimString is true', () => { + expect(validator('')).toMatchObject({ message, code, path }); + }); + + test('should return Validation function if value is an empty string and trimString is false', () => { + expect(validator('', false)).toMatchObject({ message, code, path }); + }); + + test('should return Validation function if value is a space and trimString is true', () => { + expect(validator(' ')).toMatchObject({ message, code, path }); + }); + + test('should return undefined if value is a space and trimString is false', () => { + expect(validator(' ', false)).toBeUndefined(); + }); + + test('should return undefined if value is a string and is not empty', () => { + expect(validator('not Empty')).toBeUndefined(); + }); + + test('should return undefined if value an array and is not empty', () => { + expect(validator(['not Empty'])).toBeUndefined(); + }); + + test('should return undefined if value an array and is empty', () => { + expect(validator([])).toMatchObject({ message, code, path }); + }); +}); diff --git a/src/plugins/es_ui_shared/static/forms/helpers/field_validators/empty_field.ts b/src/plugins/es_ui_shared/static/forms/helpers/field_validators/empty_field.ts index 3b09e165984d4..9917b273d666c 100644 --- a/src/plugins/es_ui_shared/static/forms/helpers/field_validators/empty_field.ts +++ b/src/plugins/es_ui_shared/static/forms/helpers/field_validators/empty_field.ts @@ -13,12 +13,14 @@ import { isEmptyArray } from '../../../validators/array'; import { ERROR_CODE } from './types'; export const emptyField = - (message: string) => + (message: string, trimString: boolean = true) => (...args: Parameters): ReturnType> => { const [{ value, path }] = args; if (typeof value === 'string') { - return isEmptyString(value) ? { code: 'ERR_FIELD_MISSING', path, message } : undefined; + return isEmptyString(value, trimString) + ? { code: 'ERR_FIELD_MISSING', path, message } + : undefined; } if (Array.isArray(value)) { diff --git a/src/plugins/es_ui_shared/static/validators/string/is_empty.ts b/src/plugins/es_ui_shared/static/validators/string/is_empty.ts index f70cbd36213ed..197d707f5edbf 100644 --- a/src/plugins/es_ui_shared/static/validators/string/is_empty.ts +++ b/src/plugins/es_ui_shared/static/validators/string/is_empty.ts @@ -7,4 +7,5 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export const isEmptyString = (value: string) => value.trim() === ''; +export const isEmptyString = (value: string, trimString: boolean = true) => + (trimString ? value.trim() : value) === ''; diff --git a/src/plugins/esql/kibana.jsonc b/src/plugins/esql/kibana.jsonc index 2bb2b759dc429..6ee732ef79f5a 100644 --- a/src/plugins/esql/kibana.jsonc +++ b/src/plugins/esql/kibana.jsonc @@ -2,6 +2,8 @@ "type": "plugin", "id": "@kbn/esql", "owner": "@elastic/kibana-esql", + "group": "platform", + "visibility": "shared", "plugin": { "id": "esql", "server": true, diff --git a/src/plugins/esql_datagrid/kibana.jsonc b/src/plugins/esql_datagrid/kibana.jsonc index e2596ccb9fc8b..f8f880b2d4313 100644 --- a/src/plugins/esql_datagrid/kibana.jsonc +++ b/src/plugins/esql_datagrid/kibana.jsonc @@ -2,6 +2,8 @@ "type": "plugin", "id": "@kbn/esql-datagrid", "owner": "@elastic/kibana-esql", + "group": "platform", + "visibility": "shared", "plugin": { "id": "esqlDataGrid", "server": false, diff --git a/src/plugins/event_annotation/kibana.jsonc b/src/plugins/event_annotation/kibana.jsonc index 79a2dfb105820..24fb6bfde6223 100644 --- a/src/plugins/event_annotation/kibana.jsonc +++ b/src/plugins/event_annotation/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/event-annotation-plugin", - "owner": "@elastic/kibana-visualizations", + "owner": [ + "@elastic/kibana-visualizations" + ], + "group": "platform", + "visibility": "private", "description": "The Event Annotation service contains expressions for event annotations", "plugin": { "id": "eventAnnotation", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "expressions", "data", @@ -18,15 +22,15 @@ "contentManagement" ], "optionalPlugins": [ - "savedObjectsTagging", + "savedObjectsTagging" ], "requiredBundles": [ "data", "savedObjectsFinder", - "dataViews", + "dataViews" ], "extraPublicDirs": [ "common" ] } -} +} \ No newline at end of file diff --git a/src/plugins/event_annotation_listing/kibana.jsonc b/src/plugins/event_annotation_listing/kibana.jsonc index 1ae3534fc98df..7c6aaf4939d33 100644 --- a/src/plugins/event_annotation_listing/kibana.jsonc +++ b/src/plugins/event_annotation_listing/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/event-annotation-listing-plugin", - "owner": "@elastic/kibana-visualizations", + "owner": [ + "@elastic/kibana-visualizations" + ], + "group": "platform", + "visibility": "private", "description": "The listing page for event annotations.", "plugin": { "id": "eventAnnotationListing", - "server": false, "browser": true, + "server": false, "requiredPlugins": [ "savedObjectsManagement", "eventAnnotation", @@ -16,13 +20,13 @@ "dataViews", "unifiedSearch", "kibanaUtils", - "contentManagement", + "contentManagement" ], "optionalPlugins": [ "savedObjectsTagging", - "lens", + "lens" ], "requiredBundles": [], "extraPublicDirs": [] } -} +} \ No newline at end of file diff --git a/src/plugins/expression_error/kibana.jsonc b/src/plugins/expression_error/kibana.jsonc index 28d389ce5a315..29fb16ff0d744 100644 --- a/src/plugins/expression_error/kibana.jsonc +++ b/src/plugins/expression_error/kibana.jsonc @@ -1,16 +1,20 @@ { "type": "plugin", "id": "@kbn/expression-error-plugin", - "owner": "@elastic/kibana-presentation", + "owner": [ + "@elastic/kibana-presentation" + ], + "group": "platform", + "visibility": "shared", "description": "Adds 'error' renderer to expressions", "plugin": { "id": "expressionError", - "server": false, "browser": true, + "server": false, "requiredPlugins": [ "expressions", "presentationUtil" ], "requiredBundles": [] } -} +} \ No newline at end of file diff --git a/src/plugins/expression_image/kibana.jsonc b/src/plugins/expression_image/kibana.jsonc index b6a05d8b051c5..3aca2b43f0e7f 100644 --- a/src/plugins/expression_image/kibana.jsonc +++ b/src/plugins/expression_image/kibana.jsonc @@ -1,16 +1,20 @@ { "type": "plugin", "id": "@kbn/expression-image-plugin", - "owner": "@elastic/kibana-presentation", + "owner": [ + "@elastic/kibana-presentation" + ], + "group": "platform", + "visibility": "shared", "description": "Adds 'image' function and renderer to expressions", "plugin": { "id": "expressionImage", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "expressions", "presentationUtil" ], "requiredBundles": [] } -} +} \ No newline at end of file diff --git a/src/plugins/expression_metric/kibana.jsonc b/src/plugins/expression_metric/kibana.jsonc index 298e7046bf7f8..19070c800cefc 100644 --- a/src/plugins/expression_metric/kibana.jsonc +++ b/src/plugins/expression_metric/kibana.jsonc @@ -1,16 +1,20 @@ { "type": "plugin", "id": "@kbn/expression-metric-plugin", - "owner": "@elastic/kibana-presentation", + "owner": [ + "@elastic/kibana-presentation" + ], + "group": "platform", + "visibility": "shared", "description": "Adds 'metric' function and renderer to expressions", "plugin": { "id": "expressionMetric", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "expressions", "presentationUtil" ], "requiredBundles": [] } -} +} \ No newline at end of file diff --git a/src/plugins/expression_repeat_image/kibana.jsonc b/src/plugins/expression_repeat_image/kibana.jsonc index 13e88e1970fd8..17936c2203f3d 100644 --- a/src/plugins/expression_repeat_image/kibana.jsonc +++ b/src/plugins/expression_repeat_image/kibana.jsonc @@ -1,16 +1,20 @@ { "type": "plugin", "id": "@kbn/expression-repeat-image-plugin", - "owner": "@elastic/kibana-presentation", + "owner": [ + "@elastic/kibana-presentation" + ], + "group": "platform", + "visibility": "shared", "description": "Adds 'repeatImage' function and renderer to expressions", "plugin": { "id": "expressionRepeatImage", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "expressions", "presentationUtil" ], "requiredBundles": [] } -} +} \ No newline at end of file diff --git a/src/plugins/expression_reveal_image/kibana.jsonc b/src/plugins/expression_reveal_image/kibana.jsonc index 7b13ef28f3088..5d936b2a29a0b 100644 --- a/src/plugins/expression_reveal_image/kibana.jsonc +++ b/src/plugins/expression_reveal_image/kibana.jsonc @@ -1,16 +1,20 @@ { "type": "plugin", "id": "@kbn/expression-reveal-image-plugin", - "owner": "@elastic/kibana-presentation", + "owner": [ + "@elastic/kibana-presentation" + ], + "group": "platform", + "visibility": "shared", "description": "Adds 'revealImage' function and renderer to expressions", "plugin": { "id": "expressionRevealImage", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "expressions", "presentationUtil" ], "requiredBundles": [] } -} +} \ No newline at end of file diff --git a/src/plugins/expression_shape/kibana.jsonc b/src/plugins/expression_shape/kibana.jsonc index 85e6fca310d66..96fa1295b32c0 100644 --- a/src/plugins/expression_shape/kibana.jsonc +++ b/src/plugins/expression_shape/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/expression-shape-plugin", - "owner": "@elastic/kibana-presentation", + "owner": [ + "@elastic/kibana-presentation" + ], + "group": "platform", + "visibility": "shared", "description": "Adds 'shape' function and renderer to expressions", "plugin": { "id": "expressionShape", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "expressions", "presentationUtil" @@ -16,4 +20,4 @@ "common" ] } -} +} \ No newline at end of file diff --git a/src/plugins/expressions/kibana.jsonc b/src/plugins/expressions/kibana.jsonc index 7ed96b7020deb..5c5588fc9e9be 100644 --- a/src/plugins/expressions/kibana.jsonc +++ b/src/plugins/expressions/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/expressions-plugin", - "owner": "@elastic/kibana-visualizations", + "owner": [ + "@elastic/kibana-visualizations" + ], + "group": "platform", + "visibility": "shared", "description": "Adds expression runtime to Kibana", "plugin": { "id": "expressions", - "server": true, "browser": true, + "server": true, "requiredBundles": [ "kibanaUtils", "inspector" @@ -16,4 +20,4 @@ "common/fonts" ] } -} +} \ No newline at end of file diff --git a/src/plugins/field_formats/kibana.jsonc b/src/plugins/field_formats/kibana.jsonc index 49347ac8131b1..4289dd5fe017a 100644 --- a/src/plugins/field_formats/kibana.jsonc +++ b/src/plugins/field_formats/kibana.jsonc @@ -1,14 +1,18 @@ { "type": "plugin", "id": "@kbn/field-formats-plugin", - "owner": "@elastic/kibana-data-discovery", + "owner": [ + "@elastic/kibana-data-discovery" + ], + "group": "platform", + "visibility": "shared", "description": "Index pattern fields and ambiguous values formatters", "plugin": { "id": "fieldFormats", - "server": true, "browser": true, + "server": true, "extraPublicDirs": [ "common" ] } -} +} \ No newline at end of file diff --git a/src/plugins/files/kibana.jsonc b/src/plugins/files/kibana.jsonc index 5f59c5d09b3d7..6a10ac67ebda3 100644 --- a/src/plugins/files/kibana.jsonc +++ b/src/plugins/files/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/files-plugin", - "owner": "@elastic/appex-sharedux", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "shared", "description": "File upload, download, sharing, and serving over HTTP implementation in Kibana.", "plugin": { "id": "files", - "server": true, "browser": true, + "server": true, "optionalPlugins": [ "security", "usageCollection" @@ -18,4 +22,4 @@ "common" ] } -} +} \ No newline at end of file diff --git a/src/plugins/files_management/kibana.jsonc b/src/plugins/files_management/kibana.jsonc index aef8736c6c1f9..5df36d08cd9a9 100644 --- a/src/plugins/files_management/kibana.jsonc +++ b/src/plugins/files_management/kibana.jsonc @@ -1,16 +1,20 @@ { "type": "plugin", "id": "@kbn/files-management-plugin", - "owner": "@elastic/appex-sharedux", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "private", "description": "Simple UI for managing files in Kibana", "plugin": { "id": "filesManagement", - "server": false, "browser": true, + "server": false, "requiredPlugins": [ "files", "management" ], "requiredBundles": [] } -} +} \ No newline at end of file diff --git a/src/plugins/ftr_apis/kibana.jsonc b/src/plugins/ftr_apis/kibana.jsonc index f40ab911a104b..75663274a1f3a 100644 --- a/src/plugins/ftr_apis/kibana.jsonc +++ b/src/plugins/ftr_apis/kibana.jsonc @@ -1,13 +1,17 @@ { "type": "plugin", "id": "@kbn/ftr-apis-plugin", - "owner": "@elastic/kibana-core", + "owner": [ + "@elastic/kibana-core" + ], + "group": "platform", + "visibility": "private", "plugin": { "id": "ftrApis", - "server": true, "browser": false, + "server": true, "configPath": [ "ftr_apis" ] } -} +} \ No newline at end of file diff --git a/src/plugins/ftr_apis/server/routes/kbn_client_so/bulk_delete.ts b/src/plugins/ftr_apis/server/routes/kbn_client_so/bulk_delete.ts index b1667bfed4b99..7ba0a504fa530 100644 --- a/src/plugins/ftr_apis/server/routes/kbn_client_so/bulk_delete.ts +++ b/src/plugins/ftr_apis/server/routes/kbn_client_so/bulk_delete.ts @@ -15,8 +15,10 @@ export const registerBulkDeleteRoute = (router: IRouter) => { router.post( { path: `${KBN_CLIENT_API_PREFIX}/_bulk_delete`, - options: { - tags: ['access:ftrApis'], + security: { + authz: { + requiredPrivileges: ['ftrApis'], + }, }, validate: { body: schema.arrayOf( diff --git a/src/plugins/ftr_apis/server/routes/kbn_client_so/clean.ts b/src/plugins/ftr_apis/server/routes/kbn_client_so/clean.ts index 86be3af46348f..2f2edc66fdc4a 100644 --- a/src/plugins/ftr_apis/server/routes/kbn_client_so/clean.ts +++ b/src/plugins/ftr_apis/server/routes/kbn_client_so/clean.ts @@ -15,8 +15,10 @@ export const registerCleanRoute = (router: IRouter) => { router.post( { path: `${KBN_CLIENT_API_PREFIX}/_clean`, - options: { - tags: ['access:ftrApis'], + security: { + authz: { + requiredPrivileges: ['ftrApis'], + }, }, validate: { body: schema.object({ diff --git a/src/plugins/ftr_apis/server/routes/kbn_client_so/create.ts b/src/plugins/ftr_apis/server/routes/kbn_client_so/create.ts index 528e271de1d4f..fdf93e2d517b8 100644 --- a/src/plugins/ftr_apis/server/routes/kbn_client_so/create.ts +++ b/src/plugins/ftr_apis/server/routes/kbn_client_so/create.ts @@ -15,8 +15,10 @@ export const registerCreateRoute = (router: IRouter) => { router.post( { path: `${KBN_CLIENT_API_PREFIX}/{type}/{id?}`, - options: { - tags: ['access:ftrApis'], + security: { + authz: { + requiredPrivileges: ['ftrApis'], + }, }, validate: { params: schema.object({ diff --git a/src/plugins/ftr_apis/server/routes/kbn_client_so/delete.ts b/src/plugins/ftr_apis/server/routes/kbn_client_so/delete.ts index 77cec6243711c..69bc5f51db118 100644 --- a/src/plugins/ftr_apis/server/routes/kbn_client_so/delete.ts +++ b/src/plugins/ftr_apis/server/routes/kbn_client_so/delete.ts @@ -15,8 +15,10 @@ export const registerDeleteRoute = (router: IRouter) => { router.delete( { path: `${KBN_CLIENT_API_PREFIX}/{type}/{id}`, - options: { - tags: ['access:ftrApis'], + security: { + authz: { + requiredPrivileges: ['ftrApis'], + }, }, validate: { params: schema.object({ diff --git a/src/plugins/ftr_apis/server/routes/kbn_client_so/find.ts b/src/plugins/ftr_apis/server/routes/kbn_client_so/find.ts index 2aefd0f87d334..ecacba6b782cd 100644 --- a/src/plugins/ftr_apis/server/routes/kbn_client_so/find.ts +++ b/src/plugins/ftr_apis/server/routes/kbn_client_so/find.ts @@ -15,8 +15,10 @@ export const registerFindRoute = (router: IRouter) => { router.get( { path: `${KBN_CLIENT_API_PREFIX}/_find`, - options: { - tags: ['access:ftrApis'], + security: { + authz: { + requiredPrivileges: ['ftrApis'], + }, }, validate: { query: schema.object({ diff --git a/src/plugins/ftr_apis/server/routes/kbn_client_so/get.ts b/src/plugins/ftr_apis/server/routes/kbn_client_so/get.ts index bcfcd906ffc4c..88685608aee1a 100644 --- a/src/plugins/ftr_apis/server/routes/kbn_client_so/get.ts +++ b/src/plugins/ftr_apis/server/routes/kbn_client_so/get.ts @@ -15,8 +15,10 @@ export const registerGetRoute = (router: IRouter) => { router.get( { path: `${KBN_CLIENT_API_PREFIX}/{type}/{id}`, - options: { - tags: ['access:ftrApis'], + security: { + authz: { + requiredPrivileges: ['ftrApis'], + }, }, validate: { params: schema.object({ diff --git a/src/plugins/ftr_apis/server/routes/kbn_client_so/update.ts b/src/plugins/ftr_apis/server/routes/kbn_client_so/update.ts index ee5b90e2897e0..e2eef65c0ec26 100644 --- a/src/plugins/ftr_apis/server/routes/kbn_client_so/update.ts +++ b/src/plugins/ftr_apis/server/routes/kbn_client_so/update.ts @@ -15,8 +15,10 @@ export const registerUpdateRoute = (router: IRouter) => { router.put( { path: `${KBN_CLIENT_API_PREFIX}/{type}/{id}`, - options: { - tags: ['access:ftrApis'], + security: { + authz: { + requiredPrivileges: ['ftrApis'], + }, }, validate: { params: schema.object({ diff --git a/src/plugins/guided_onboarding/kibana.jsonc b/src/plugins/guided_onboarding/kibana.jsonc index 1bbdc9d1003c1..5a31cd6986c6b 100644 --- a/src/plugins/guided_onboarding/kibana.jsonc +++ b/src/plugins/guided_onboarding/kibana.jsonc @@ -1,13 +1,20 @@ { "type": "plugin", "id": "@kbn/guided-onboarding-plugin", - "owner": "@elastic/appex-sharedux", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "shared", "description": "Guided onboarding framework", "plugin": { "id": "guidedOnboarding", - "server": true, "browser": true, - "optionalPlugins": ["cloud", "features"], + "server": true, + "optionalPlugins": [ + "cloud", + "features" + ], "requiredBundles": [] } -} +} \ No newline at end of file diff --git a/src/plugins/home/kibana.jsonc b/src/plugins/home/kibana.jsonc index 8c0a7884ce8ee..deef8ba61fd73 100644 --- a/src/plugins/home/kibana.jsonc +++ b/src/plugins/home/kibana.jsonc @@ -1,18 +1,28 @@ { "type": "plugin", "id": "@kbn/home-plugin", - "owner": "@elastic/kibana-core", + "owner": [ + "@elastic/kibana-core" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "home", - "server": true, "browser": true, - "requiredPlugins": ["dataViews", "share", "urlForwarding"], - "requiredBundles": ["kibanaReact"], + "server": true, + "requiredPlugins": [ + "dataViews", + "share", + "urlForwarding" + ], "optionalPlugins": [ "usageCollection", "customIntegrations", "cloud", "guidedOnboarding" + ], + "requiredBundles": [ + "kibanaReact" ] } -} +} \ No newline at end of file diff --git a/src/plugins/image_embeddable/kibana.jsonc b/src/plugins/image_embeddable/kibana.jsonc index 4dbf82a16d962..ec09c71345736 100644 --- a/src/plugins/image_embeddable/kibana.jsonc +++ b/src/plugins/image_embeddable/kibana.jsonc @@ -1,14 +1,27 @@ { "type": "plugin", "id": "@kbn/image-embeddable-plugin", - "owner": "@elastic/appex-sharedux", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "private", "description": "Image embeddable", "plugin": { "id": "imageEmbeddable", - "server": false, "browser": true, - "requiredPlugins": ["embeddable", "files", "uiActions", "kibanaReact"], - "optionalPlugins": ["security", "screenshotMode", "embeddableEnhanced"], + "server": false, + "requiredPlugins": [ + "embeddable", + "files", + "uiActions", + "kibanaReact" + ], + "optionalPlugins": [ + "security", + "screenshotMode", + "embeddableEnhanced" + ], "requiredBundles": [] } -} +} \ No newline at end of file diff --git a/src/plugins/input_control_vis/kibana.jsonc b/src/plugins/input_control_vis/kibana.jsonc index 129396f15ace6..0dce906132726 100644 --- a/src/plugins/input_control_vis/kibana.jsonc +++ b/src/plugins/input_control_vis/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/input-control-vis-plugin", - "owner": "@elastic/kibana-presentation", + "owner": [ + "@elastic/kibana-presentation" + ], + "group": "platform", + "visibility": "private", "description": "Adds Input Control visualization to Kibana", "plugin": { "id": "inputControlVis", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "data", "expressions", @@ -16,7 +20,8 @@ "uiActions" ], "requiredBundles": [ - "kibanaReact", "embeddable" + "kibanaReact", + "embeddable" ] } -} +} \ No newline at end of file diff --git a/src/plugins/inspector/kibana.jsonc b/src/plugins/inspector/kibana.jsonc index 8ff572bc21f67..b77688378a55b 100644 --- a/src/plugins/inspector/kibana.jsonc +++ b/src/plugins/inspector/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/inspector-plugin", - "owner": "@elastic/kibana-presentation", + "owner": [ + "@elastic/kibana-presentation" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "inspector", - "server": false, "browser": true, + "server": false, "requiredPlugins": [ "share" ], @@ -17,4 +21,4 @@ "common/adapters/request" ] } -} +} \ No newline at end of file diff --git a/src/plugins/interactive_setup/kibana.jsonc b/src/plugins/interactive_setup/kibana.jsonc index 5a6c8dace60b9..f313dc6304ac3 100644 --- a/src/plugins/interactive_setup/kibana.jsonc +++ b/src/plugins/interactive_setup/kibana.jsonc @@ -1,15 +1,19 @@ { "type": "plugin", "id": "@kbn/interactive-setup-plugin", - "owner": "@elastic/kibana-security", + "owner": [ + "@elastic/kibana-security" + ], + "group": "platform", + "visibility": "private", "description": "This plugin provides UI and APIs for the interactive setup mode.", "plugin": { "id": "interactiveSetup", - "type": "preboot", - "server": true, "browser": true, + "server": true, + "type": "preboot", "configPath": [ "interactiveSetup" ] } -} +} \ No newline at end of file diff --git a/src/plugins/interactive_setup/server/routes/configure.ts b/src/plugins/interactive_setup/server/routes/configure.ts index 1cdaf588a6cd9..bb5a85800e03b 100644 --- a/src/plugins/interactive_setup/server/routes/configure.ts +++ b/src/plugins/interactive_setup/server/routes/configure.ts @@ -37,6 +37,13 @@ export function defineConfigureRoute({ router.post( { path: '/internal/interactive_setup/configure', + security: { + authz: { + enabled: false, + reason: + 'Interactive setup is strictly a "pre-boot" feature which cannot leverage conventional authorization.', + }, + }, validate: { body: schema.object({ host: schema.uri({ scheme: ['http', 'https'] }), diff --git a/src/plugins/interactive_setup/server/routes/enroll.ts b/src/plugins/interactive_setup/server/routes/enroll.ts index 1cd0362d2790b..7ee97db592ac5 100644 --- a/src/plugins/interactive_setup/server/routes/enroll.ts +++ b/src/plugins/interactive_setup/server/routes/enroll.ts @@ -40,6 +40,13 @@ export function defineEnrollRoutes({ router.post( { path: '/internal/interactive_setup/enroll', + security: { + authz: { + enabled: false, + reason: + 'Interactive setup is strictly a "pre-boot" feature which cannot leverage conventional authorization.', + }, + }, validate: { body: schema.object({ hosts: schema.arrayOf(schema.uri({ scheme: 'https' }), { diff --git a/src/plugins/interactive_setup/server/routes/ping.ts b/src/plugins/interactive_setup/server/routes/ping.ts index 4deaeee675404..4c71d9f05bd1b 100644 --- a/src/plugins/interactive_setup/server/routes/ping.ts +++ b/src/plugins/interactive_setup/server/routes/ping.ts @@ -17,6 +17,13 @@ export function definePingRoute({ router, logger, elasticsearch, preboot }: Rout router.post( { path: '/internal/interactive_setup/ping', + security: { + authz: { + enabled: false, + reason: + 'Interactive setup is strictly a "pre-boot" feature which cannot leverage conventional authorization.', + }, + }, validate: { body: schema.object({ host: schema.uri({ scheme: ['http', 'https'] }), diff --git a/src/plugins/interactive_setup/server/routes/status.ts b/src/plugins/interactive_setup/server/routes/status.ts index 78a97ac862317..14c94411ded53 100644 --- a/src/plugins/interactive_setup/server/routes/status.ts +++ b/src/plugins/interactive_setup/server/routes/status.ts @@ -15,6 +15,13 @@ export function defineStatusRoute({ router, elasticsearch, preboot }: RouteDefin router.get( { path: '/internal/interactive_setup/status', + security: { + authz: { + enabled: false, + reason: + 'Interactive setup is strictly a "pre-boot" feature which cannot leverage conventional authorization.', + }, + }, validate: false, options: { authRequired: false }, }, diff --git a/src/plugins/interactive_setup/server/routes/verify.ts b/src/plugins/interactive_setup/server/routes/verify.ts index a40e35794fb9e..7fb5bb2e70c18 100644 --- a/src/plugins/interactive_setup/server/routes/verify.ts +++ b/src/plugins/interactive_setup/server/routes/verify.ts @@ -15,6 +15,13 @@ export function defineVerifyRoute({ router, verificationCode }: RouteDefinitionP router.post( { path: '/internal/interactive_setup/verify', + security: { + authz: { + enabled: false, + reason: + 'Interactive setup is strictly a "pre-boot" feature which cannot leverage conventional authorization.', + }, + }, validate: { body: schema.object({ code: schema.string(), diff --git a/src/plugins/kibana_overview/kibana.jsonc b/src/plugins/kibana_overview/kibana.jsonc index 3fa85f51fce76..6ea60e6a4007b 100644 --- a/src/plugins/kibana_overview/kibana.jsonc +++ b/src/plugins/kibana_overview/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/kibana-overview-plugin", - "owner": "@elastic/appex-sharedux", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "private", "plugin": { "id": "kibanaOverview", - "server": false, "browser": true, + "server": false, "requiredPlugins": [ "navigation", "dataViews", @@ -22,4 +26,4 @@ "newsfeed" ] } -} +} \ No newline at end of file diff --git a/src/plugins/kibana_react/kibana.jsonc b/src/plugins/kibana_react/kibana.jsonc index 445442e7dc72d..f18c848be2dfe 100644 --- a/src/plugins/kibana_react/kibana.jsonc +++ b/src/plugins/kibana_react/kibana.jsonc @@ -1,13 +1,17 @@ { "type": "plugin", "id": "@kbn/kibana-react-plugin", - "owner": "@elastic/appex-sharedux", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "kibanaReact", - "server": false, "browser": true, + "server": false, "extraPublicDirs": [ "common" ] } -} +} \ No newline at end of file diff --git a/src/plugins/kibana_usage_collection/kibana.jsonc b/src/plugins/kibana_usage_collection/kibana.jsonc index df2eee17b0638..00a92e964cbba 100644 --- a/src/plugins/kibana_usage_collection/kibana.jsonc +++ b/src/plugins/kibana_usage_collection/kibana.jsonc @@ -1,13 +1,17 @@ { "type": "plugin", "id": "@kbn/kibana-usage-collection-plugin", - "owner": "@elastic/kibana-core", + "owner": [ + "@elastic/kibana-core" + ], + "group": "platform", + "visibility": "private", "plugin": { "id": "kibanaUsageCollection", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "usageCollection" ] } -} +} \ No newline at end of file diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/integration_tests/daily_rollups.test.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/integration_tests/daily_rollups.test.ts index 9bd732d81340c..3433af684e92c 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/integration_tests/daily_rollups.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/integration_tests/daily_rollups.test.ts @@ -8,7 +8,12 @@ */ import moment, { type MomentInput } from 'moment'; -import type { Logger, ISavedObjectsRepository, SavedObject } from '@kbn/core/server'; +import type { + Logger, + ISavedObjectsRepository, + SavedObject, + ElasticsearchClient, +} from '@kbn/core/server'; import { type TestElasticsearchUtils, type TestKibanaUtils, @@ -76,6 +81,7 @@ describe(`daily rollups integration test`, () => { let esServer: TestElasticsearchUtils; let root: TestKibanaUtils['root']; let internalRepository: ISavedObjectsRepository; + let esClient: ElasticsearchClient; let logger: Logger; let rawEventLoopDelaysDaily: Array>; let outdatedRawEventLoopDelaysDaily: Array>; @@ -93,6 +99,7 @@ describe(`daily rollups integration test`, () => { const start = await root.start(); logger = root.logger.get('test daily rollups'); internalRepository = start.savedObjects.createInternalRepository([SAVED_OBJECTS_DAILY_TYPE]); + esClient = start.elasticsearch.client.asInternalUser; // Create the docs now const rawDailyDocs = createRawEventLoopDelaysDailyDocs(); @@ -112,6 +119,7 @@ describe(`daily rollups integration test`, () => { it('deletes documents older that 3 days from the saved objects repository', async () => { await rollDailyData(logger, internalRepository); + await esClient.indices.refresh({ index: `.kibana` }); // Make sure that the changes are searchable const { total, saved_objects: savedObjects } = await internalRepository.find({ type: SAVED_OBJECTS_DAILY_TYPE }); expect(total).toBe(rawEventLoopDelaysDaily.length); diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts index 6b3db9460eb7c..000f0bc9ace24 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -578,10 +578,6 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, }, - 'metrics:allowCheckingForFailedShards': { - type: 'boolean', - _meta: { description: 'Non-default value of setting.' }, - }, 'observability:apmDefaultServiceEnvironment': { type: 'keyword', _meta: { description: 'Default value of the setting was changed.' }, diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index 92076ebc302e2..bcbf2d573dec4 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -154,7 +154,6 @@ export interface UsageStats { 'discover:rowHeightOption': number; hideAnnouncements: boolean; isDefaultIndexMigrated: boolean; - 'metrics:allowCheckingForFailedShards': boolean; 'observability:syntheticsThrottlingEnabled': boolean; 'observability:enableLegacyUptimeApp': boolean; 'observability:apmLabsButton': boolean; diff --git a/src/plugins/kibana_utils/kibana.jsonc b/src/plugins/kibana_utils/kibana.jsonc index 7e12fba0e2b86..f58ee6d1a404a 100644 --- a/src/plugins/kibana_utils/kibana.jsonc +++ b/src/plugins/kibana_utils/kibana.jsonc @@ -1,15 +1,19 @@ { "type": "plugin", "id": "@kbn/kibana-utils-plugin", - "owner": "@elastic/appex-sharedux", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "kibanaUtils", - "server": false, "browser": true, + "server": false, "extraPublicDirs": [ "common", "demos/state_containers/todomvc", "common/state_containers" ] } -} +} \ No newline at end of file diff --git a/src/plugins/links/kibana.jsonc b/src/plugins/links/kibana.jsonc index 4aed94ab56751..7eaac4ad878e9 100644 --- a/src/plugins/links/kibana.jsonc +++ b/src/plugins/links/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/links-plugin", - "owner": "@elastic/kibana-presentation", + "owner": [ + "@elastic/kibana-presentation" + ], + "group": "platform", + "visibility": "private", "description": "A dashboard panel for creating links to dashboards or external links.", "plugin": { "id": "links", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "contentManagement", "dashboard", @@ -18,7 +22,12 @@ "uiActionsEnhanced", "visualizations" ], - "optionalPlugins": ["triggersActionsUi", "usageCollection"], - "requiredBundles": ["savedObjects"] + "optionalPlugins": [ + "triggersActionsUi", + "usageCollection" + ], + "requiredBundles": [ + "savedObjects" + ] } -} +} \ No newline at end of file diff --git a/src/plugins/links/public/types.ts b/src/plugins/links/public/types.ts index 97b1f0254f4ea..df3eb7fc2b514 100644 --- a/src/plugins/links/public/types.ts +++ b/src/plugins/links/public/types.ts @@ -22,7 +22,7 @@ import { DynamicActionsSerializedState } from '@kbn/embeddable-enhanced-plugin/p import { HasSerializedChildState, PresentationContainer } from '@kbn/presentation-containers'; import { LocatorPublic } from '@kbn/share-plugin/common'; import { DashboardLocatorParams, DASHBOARD_CONTAINER_TYPE } from '@kbn/dashboard-plugin/public'; -import { DashboardAttributes } from '@kbn/dashboard-plugin/common'; +import type { DashboardAttributes } from '@kbn/dashboard-plugin/server'; import { CONTENT_ID } from '../common'; import { Link, LinksAttributes, LinksLayoutType } from '../common/content_management'; @@ -73,5 +73,5 @@ export type ResolvedLink = Link & { export interface DashboardItem { id: string; - attributes: DashboardAttributes; + attributes: Pick; } diff --git a/src/plugins/management/kibana.jsonc b/src/plugins/management/kibana.jsonc index c6e6b59206306..a70b1cce153a3 100644 --- a/src/plugins/management/kibana.jsonc +++ b/src/plugins/management/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/management-plugin", - "owner": "@elastic/kibana-management", + "owner": [ + "@elastic/kibana-management" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "management", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "share" ], @@ -18,4 +22,4 @@ "kibanaUtils" ] } -} +} \ No newline at end of file diff --git a/src/plugins/maps_ems/kibana.jsonc b/src/plugins/maps_ems/kibana.jsonc index f71542e94ae71..9b86f2f45b0c8 100644 --- a/src/plugins/maps_ems/kibana.jsonc +++ b/src/plugins/maps_ems/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/maps-ems-plugin", - "owner": "@elastic/kibana-gis", + "owner": [ + "@elastic/kibana-presentation" + ], + "group": "platform", + "visibility": "private", "plugin": { "id": "mapsEms", - "server": true, "browser": true, + "server": true, "configPath": [ "map" ], diff --git a/src/plugins/navigation/kibana.jsonc b/src/plugins/navigation/kibana.jsonc index 92cb0d492572d..04328fa2217f8 100644 --- a/src/plugins/navigation/kibana.jsonc +++ b/src/plugins/navigation/kibana.jsonc @@ -1,13 +1,22 @@ { "type": "plugin", "id": "@kbn/navigation-plugin", - "owner": "@elastic/appex-sharedux", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "navigation", - "server": true, "browser": true, - "optionalPlugins": ["cloud", "spaces"], - "requiredPlugins": ["unifiedSearch"], + "server": true, + "requiredPlugins": [ + "unifiedSearch" + ], + "optionalPlugins": [ + "cloud", + "spaces" + ], "requiredBundles": [] } -} +} \ No newline at end of file diff --git a/src/plugins/navigation/public/plugin.test.ts b/src/plugins/navigation/public/plugin.test.ts index d05cf756f7178..3aa9ff8295f41 100644 --- a/src/plugins/navigation/public/plugin.test.ts +++ b/src/plugins/navigation/public/plugin.test.ts @@ -106,7 +106,7 @@ describe('Navigation Plugin', () => { await new Promise((resolve) => setTimeout(resolve)); const definition = { - id: 'es', + id: 'es' as const, title: 'Elasticsearch', navigationTree$: of({ body: [] }), }; diff --git a/src/plugins/navigation/public/plugin.tsx b/src/plugins/navigation/public/plugin.tsx index 9f41405a8438b..e7264efdf51d2 100644 --- a/src/plugins/navigation/public/plugin.tsx +++ b/src/plugins/navigation/public/plugin.tsx @@ -18,7 +18,7 @@ import { } from '@kbn/core/public'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; import type { Space } from '@kbn/spaces-plugin/public'; -import type { SolutionNavigationDefinition } from '@kbn/core-chrome-browser'; +import type { SolutionId, SolutionNavigationDefinition } from '@kbn/core-chrome-browser'; import { InternalChromeStart } from '@kbn/core-chrome-browser-internal'; import type { PanelContentProvider } from '@kbn/shared-ux-chrome-navigation'; import type { @@ -195,7 +195,7 @@ export class NavigationPublicPlugin } } - if (isProjectNav) { + if (isProjectNav && solutionView !== 'classic') { chrome.project.changeActiveSolutionNavigation(solutionView!); } } @@ -210,6 +210,6 @@ function getIsProjectNav(solutionView?: string) { return Boolean(solutionView) && isKnownSolutionView(solutionView); } -function isKnownSolutionView(solution?: string) { +function isKnownSolutionView(solution?: string): solution is SolutionId { return Boolean(solution) && ['oblt', 'es', 'security'].includes(solution!); } diff --git a/src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_menu_item.test.tsx.snap b/src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_menu_item.test.tsx.snap index ab174c6d00102..4edea39255c04 100644 --- a/src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_menu_item.test.tsx.snap +++ b/src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_menu_item.test.tsx.snap @@ -1,5 +1,23 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`TopNavMenu Should render an icon-only item 1`] = ` + + + +`; + exports[`TopNavMenu Should render emphasized item which should be clickable 1`] = ` { const ensureMenuItemDisabled = (data: TopNavMenuData) => { @@ -76,6 +77,23 @@ describe('TopNavMenu', () => { expect(component).toMatchSnapshot(); }); + it('Should render an icon-only item', () => { + const data: TopNavMenuData = { + id: 'test', + label: 'test', + iconType: 'share', + iconOnly: true, + run: jest.fn(), + }; + + const component = shallowWithIntl(); + expect(component).toMatchSnapshot(); + + const event = { currentTarget: { value: 'a' } }; + component.find(EuiButtonIcon).simulate('click', event); + expect(data.run).toHaveBeenCalledTimes(1); + }); + it('Should render disabled item and it shouldnt be clickable', () => { ensureMenuItemDisabled({ id: 'test', diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx index a9ef055d6e1fd..16017fda0ffbc 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx @@ -7,12 +7,19 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { upperFirst, isFunction } from 'lodash'; +import { upperFirst, isFunction, omit } from 'lodash'; import React, { MouseEvent } from 'react'; -import { EuiToolTip, EuiButton, EuiHeaderLink, EuiBetaBadge, EuiButtonColor } from '@elastic/eui'; +import { + EuiToolTip, + EuiButton, + EuiHeaderLink, + EuiBetaBadge, + EuiButtonColor, + EuiButtonIcon, +} from '@elastic/eui'; import { TopNavMenuData } from './top_nav_menu_data'; -export function TopNavMenuItem(props: TopNavMenuData) { +export function TopNavMenuItem(props: TopNavMenuData & { isMobileMenu?: boolean }) { function isDisabled(): boolean { const val = isFunction(props.disableButton) ? props.disableButton() : props.disableButton; return val!; @@ -59,16 +66,28 @@ export function TopNavMenuItem(props: TopNavMenuData) { ? { onClick: undefined, href: props.href, target: props.target } : {}; - // fill is not compatible with EuiHeaderLink - const btn = props.emphasize ? ( - - {getButtonContainer()} - - ) : ( - - {getButtonContainer()} - - ); + const btn = + props.iconOnly && props.iconType && !props.isMobileMenu ? ( + // icon only buttons are not supported by EuiHeaderLink + + + + ) : props.emphasize ? ( + // fill is not compatible with EuiHeaderLink + + {getButtonContainer()} + + ) : ( + + {getButtonContainer()} + + ); const tooltip = getTooltip(); if (tooltip) { diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_items.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_items.tsx index e8d118dadff7d..928749beb4477 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_items.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_items.tsx @@ -7,11 +7,13 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { EuiBreakpointSize, EuiHeaderLinks } from '@elastic/eui'; +import { EuiBreakpointSize, EuiHeaderLinks, useIsWithinBreakpoints } from '@elastic/eui'; import React from 'react'; import type { TopNavMenuData } from './top_nav_menu_data'; import { TopNavMenuItem } from './top_nav_menu_item'; +const POPOVER_BREAKPOINTS: EuiBreakpointSize[] = ['xs', 's']; + interface TopNavMenuItemsProps { config: TopNavMenuData[] | undefined; className?: string; @@ -21,8 +23,10 @@ interface TopNavMenuItemsProps { export const TopNavMenuItems = ({ config, className, - popoverBreakpoints, + popoverBreakpoints = POPOVER_BREAKPOINTS, }: TopNavMenuItemsProps) => { + const isMobileMenu = useIsWithinBreakpoints(popoverBreakpoints); + if (!config || config.length === 0) return null; return ( {config.map((menuItem: TopNavMenuData, i: number) => { - return ; + return ; })} ); diff --git a/src/plugins/newsfeed/kibana.jsonc b/src/plugins/newsfeed/kibana.jsonc index b0c9e21b8fa56..113aa7667e582 100644 --- a/src/plugins/newsfeed/kibana.jsonc +++ b/src/plugins/newsfeed/kibana.jsonc @@ -1,14 +1,18 @@ { "type": "plugin", "id": "@kbn/newsfeed-plugin", - "owner": "@elastic/kibana-core", + "owner": [ + "@elastic/kibana-core" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "newsfeed", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "screenshotMode" ], "requiredBundles": [] } -} +} \ No newline at end of file diff --git a/src/plugins/no_data_page/kibana.jsonc b/src/plugins/no_data_page/kibana.jsonc index 202917173b7a4..0ebe065d5b5ab 100644 --- a/src/plugins/no_data_page/kibana.jsonc +++ b/src/plugins/no_data_page/kibana.jsonc @@ -1,10 +1,14 @@ { "type": "plugin", "id": "@kbn/no-data-page-plugin", - "owner": "@elastic/appex-sharedux", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "private", "plugin": { "id": "noDataPage", - "server": true, - "browser": true + "browser": true, + "server": true } -} +} \ No newline at end of file diff --git a/src/plugins/presentation_panel/kibana.jsonc b/src/plugins/presentation_panel/kibana.jsonc index cbcda3501f40f..dd8a20694ac5d 100644 --- a/src/plugins/presentation_panel/kibana.jsonc +++ b/src/plugins/presentation_panel/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/presentation-panel-plugin", - "owner": "@elastic/kibana-presentation", + "owner": [ + "@elastic/kibana-presentation" + ], + "group": "platform", + "visibility": "private", "description": "Adds a standardized Presentation panel which allows any forward ref component to interface with various Kibana systems.", "plugin": { "id": "presentationPanel", - "server": false, "browser": true, + "server": false, "requiredPlugins": [ "data", "inspector", @@ -16,6 +20,9 @@ "savedObjectsManagement", "savedObjectsTaggingOss" ], - "requiredBundles": ["kibanaReact", "unifiedSearch"] + "requiredBundles": [ + "kibanaReact", + "unifiedSearch" + ] } -} +} \ No newline at end of file diff --git a/src/plugins/presentation_util/kibana.jsonc b/src/plugins/presentation_util/kibana.jsonc index f9b659fa61630..681877d132b7d 100644 --- a/src/plugins/presentation_util/kibana.jsonc +++ b/src/plugins/presentation_util/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/presentation-util-plugin", - "owner": "@elastic/kibana-presentation", + "owner": [ + "@elastic/kibana-presentation" + ], + "group": "platform", + "visibility": "shared", "description": "The Presentation Utility Plugin is a set of common, shared components and toolkits for solutions within the Presentation space, (e.g. Dashboards, Canvas).", "plugin": { "id": "presentationUtil", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "kibanaReact", "contentManagement", @@ -15,7 +19,11 @@ "dataViews", "uiActions" ], - "extraPublicDirs": ["common"], - "requiredBundles": ["savedObjects"], + "requiredBundles": [ + "savedObjects" + ], + "extraPublicDirs": [ + "common" + ] } -} +} \ No newline at end of file diff --git a/src/plugins/saved_objects/kibana.jsonc b/src/plugins/saved_objects/kibana.jsonc index 1f063a7cdfa59..86aa1ab920725 100644 --- a/src/plugins/saved_objects/kibana.jsonc +++ b/src/plugins/saved_objects/kibana.jsonc @@ -1,14 +1,18 @@ { "type": "plugin", "id": "@kbn/saved-objects-plugin", - "owner": "@elastic/kibana-core", + "owner": [ + "@elastic/kibana-core" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "savedObjects", - "server": false, "browser": true, + "server": false, "requiredPlugins": [ "data", "dataViews" ] } -} +} \ No newline at end of file diff --git a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.test.tsx b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.test.tsx index 82e8051b897c7..273a79c307001 100644 --- a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.test.tsx +++ b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.test.tsx @@ -121,4 +121,54 @@ describe('SavedObjectSaveModal', () => { expect(onSave.mock.calls[0][0].newCopyOnSave).toBe(true); }); }); + + describe('handle title duplication logic', () => { + it('should append "[1]" to title if no number is present', async () => { + const onSave = jest.fn(); + + render( + + {}} + title="Saved Object" + objectType="visualization" + showDescription={true} + showCopyOnSave={true} + /> + + ); + + const switchElement = screen.getByTestId('saveAsNewCheckbox'); + await userEvent.click(switchElement); + + await waitFor(() => { + expect(screen.getByTestId('savedObjectTitle')).toHaveValue('Saved Object [1]'); + }); + }); + + it('should increment the number by one when a number is already present', async () => { + const onSave = jest.fn(); + + render( + + {}} + title="Saved Object [1]" + objectType="visualization" + showDescription={true} + showCopyOnSave={true} + /> + + ); + + const switchElement = screen.getByTestId('saveAsNewCheckbox'); + await userEvent.click(switchElement); + + await waitFor(() => { + expect(screen.getByTestId('savedObjectTitle')).toHaveValue('Saved Object [2]'); + }); + }); + }); }); diff --git a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx index aa82feafb60aa..a3e6d1cc22b2a 100644 --- a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx +++ b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx @@ -276,7 +276,28 @@ export class SavedObjectSaveModal extends React.Component }); }; + private handleTitleDuplication = () => { + const regex = /\s*\[(\d+)\]$/; + const match = this.state.title.match(regex); + + if (match) { + const newNumber = Number(match[1]) + 1; + + this.setState({ + title: this.state.title.replace(regex, ` [${newNumber}]`), + }); + } else { + this.setState({ + title: this.state.title + ' [1]', + }); + } + }; + private onCopyOnSaveChange = (event: EuiSwitchEvent) => { + if (this.props.title === this.state.title && event.target.checked) { + this.handleTitleDuplication(); + } + this.setState({ copyOnSave: event.target.checked, }); diff --git a/src/plugins/saved_objects_finder/kibana.jsonc b/src/plugins/saved_objects_finder/kibana.jsonc index ad53ee32ae369..5e2b45f0271bc 100644 --- a/src/plugins/saved_objects_finder/kibana.jsonc +++ b/src/plugins/saved_objects_finder/kibana.jsonc @@ -1,11 +1,17 @@ { "type": "plugin", "id": "@kbn/saved-objects-finder-plugin", - "owner": "@elastic/kibana-data-discovery", + "owner": [ + "@elastic/kibana-data-discovery" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "savedObjectsFinder", - "server": true, "browser": true, - "requiredBundles": ["savedObjectsManagement"] + "server": true, + "requiredBundles": [ + "savedObjectsManagement" + ] } -} +} \ No newline at end of file diff --git a/src/plugins/saved_objects_management/kibana.jsonc b/src/plugins/saved_objects_management/kibana.jsonc index ec6004dca617d..7fab51bcb49ac 100644 --- a/src/plugins/saved_objects_management/kibana.jsonc +++ b/src/plugins/saved_objects_management/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/saved-objects-management-plugin", - "owner": "@elastic/kibana-core", + "owner": [ + "@elastic/kibana-core" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "savedObjectsManagement", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "management", "data", @@ -23,4 +27,4 @@ "public/lib" ] } -} +} \ No newline at end of file diff --git a/src/plugins/saved_objects_tagging_oss/kibana.jsonc b/src/plugins/saved_objects_tagging_oss/kibana.jsonc index 823dd9c074e9e..2d9ebcc42dbb4 100644 --- a/src/plugins/saved_objects_tagging_oss/kibana.jsonc +++ b/src/plugins/saved_objects_tagging_oss/kibana.jsonc @@ -1,13 +1,17 @@ { "type": "plugin", "id": "@kbn/saved-objects-tagging-oss-plugin", - "owner": "@elastic/appex-sharedux", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "savedObjectsTaggingOss", - "server": false, "browser": true, + "server": false, "optionalPlugins": [ "savedObjects" ] } -} +} \ No newline at end of file diff --git a/src/plugins/saved_search/kibana.jsonc b/src/plugins/saved_search/kibana.jsonc index da389103a5f78..820d42662ff1c 100644 --- a/src/plugins/saved_search/kibana.jsonc +++ b/src/plugins/saved_search/kibana.jsonc @@ -1,15 +1,29 @@ { "type": "plugin", "id": "@kbn/saved-search-plugin", - "owner": "@elastic/kibana-data-discovery", + "owner": [ + "@elastic/kibana-data-discovery" + ], + "group": "platform", + "visibility": "shared", "description": "This plugin contains the definition and helper methods around saved searches, used by discover and visualizations.", "plugin": { "id": "savedSearch", - "server": true, "browser": true, - "requiredPlugins": ["data", "contentManagement", "embeddable", "expressions"], - "optionalPlugins": ["spaces", "savedObjectsTaggingOss"], + "server": true, + "requiredPlugins": [ + "data", + "contentManagement", + "embeddable", + "expressions" + ], + "optionalPlugins": [ + "spaces", + "savedObjectsTaggingOss" + ], "requiredBundles": [], - "extraPublicDirs": ["common"] + "extraPublicDirs": [ + "common" + ] } -} +} \ No newline at end of file diff --git a/src/plugins/saved_search/public/services/saved_searches/to_saved_search.test.ts b/src/plugins/saved_search/public/services/saved_searches/to_saved_search.test.ts index 5b5c221ab9b1d..defb0e1a79986 100644 --- a/src/plugins/saved_search/public/services/saved_searches/to_saved_search.test.ts +++ b/src/plugins/saved_search/public/services/saved_searches/to_saved_search.test.ts @@ -10,7 +10,6 @@ import { contentManagementMock } from '@kbn/content-management-plugin/public/mocks'; import { coreMock } from '@kbn/core/public/mocks'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; -import { SEARCH_EMBEDDABLE_TYPE } from '@kbn/discover-utils'; import { AttributeService, type EmbeddableStart } from '@kbn/embeddable-plugin/public'; import { spacesPluginMock } from '@kbn/spaces-plugin/public/mocks'; import { SavedSearchByValueAttributes, byValueToSavedSearch } from '.'; @@ -21,12 +20,7 @@ const mockServices = { spaces: spacesPluginMock.createStartContract(), embeddable: { getAttributeService: jest.fn( - (_, opts) => - new AttributeService( - SEARCH_EMBEDDABLE_TYPE, - coreMock.createStart().notifications.toasts, - opts - ) + (_, opts) => new AttributeService('search', coreMock.createStart().notifications.toasts, opts) ), } as unknown as EmbeddableStart, }; diff --git a/src/plugins/saved_search/tsconfig.json b/src/plugins/saved_search/tsconfig.json index f96d7b385aa2c..803e2b010d952 100644 --- a/src/plugins/saved_search/tsconfig.json +++ b/src/plugins/saved_search/tsconfig.json @@ -27,7 +27,6 @@ "@kbn/core-plugins-server", "@kbn/utility-types", "@kbn/search-types", - "@kbn/discover-utils", "@kbn/unified-data-table", ], "exclude": ["target/**/*"] diff --git a/src/plugins/screenshot_mode/kibana.jsonc b/src/plugins/screenshot_mode/kibana.jsonc index 3b57e37801a15..e00d2f49d6334 100644 --- a/src/plugins/screenshot_mode/kibana.jsonc +++ b/src/plugins/screenshot_mode/kibana.jsonc @@ -1,10 +1,14 @@ { "type": "plugin", "id": "@kbn/screenshot-mode-plugin", - "owner": "@elastic/appex-sharedux", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "screenshotMode", - "server": true, - "browser": true + "browser": true, + "server": true } -} +} \ No newline at end of file diff --git a/src/plugins/share/kibana.jsonc b/src/plugins/share/kibana.jsonc index a705a73709730..d402d595c1a9b 100644 --- a/src/plugins/share/kibana.jsonc +++ b/src/plugins/share/kibana.jsonc @@ -1,14 +1,18 @@ { "type": "plugin", "id": "@kbn/share-plugin", - "owner": "@elastic/appex-sharedux", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "shared", "description": "Adds URL Service and sharing capabilities to Kibana", "plugin": { "id": "share", - "server": true, "browser": true, + "server": true, "requiredBundles": [ "kibanaUtils" ] } -} +} \ No newline at end of file diff --git a/src/plugins/telemetry/kibana.jsonc b/src/plugins/telemetry/kibana.jsonc index a5edcdde85d99..15b87d686c15a 100644 --- a/src/plugins/telemetry/kibana.jsonc +++ b/src/plugins/telemetry/kibana.jsonc @@ -1,12 +1,15 @@ { "type": "plugin", "id": "@kbn/telemetry-plugin", - "owner": "@elastic/kibana-core", + "owner": [ + "@elastic/kibana-core" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "telemetry", - "server": true, "browser": true, - "enabledOnAnonymousPages": true, + "server": true, "requiredPlugins": [ "telemetryCollectionManager", "usageCollection", @@ -19,8 +22,9 @@ "requiredBundles": [ "kibanaUtils" ], + "enabledOnAnonymousPages": true, "extraPublicDirs": [ "common/constants" ] } -} +} \ No newline at end of file diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 4e533249c7346..1d23d73f356e4 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -10728,12 +10728,6 @@ "description": "Non-default value of setting." } }, - "metrics:allowCheckingForFailedShards": { - "type": "boolean", - "_meta": { - "description": "Non-default value of setting." - } - }, "observability:apmDefaultServiceEnvironment": { "type": "keyword", "_meta": { diff --git a/src/plugins/telemetry/server/routes/telemetry_usage_stats.ts b/src/plugins/telemetry/server/routes/telemetry_usage_stats.ts index 843bf67e7863c..f19ec804ac6e9 100644 --- a/src/plugins/telemetry/server/routes/telemetry_usage_stats.ts +++ b/src/plugins/telemetry/server/routes/telemetry_usage_stats.ts @@ -14,6 +14,7 @@ import type { StatsGetterConfig, } from '@kbn/telemetry-collection-manager-plugin/server'; import type { SecurityPluginStart } from '@kbn/security-plugin/server'; +import { ApiOperation } from '@kbn/security-plugin-types-server'; import { RequestHandler } from '@kbn/core-http-server'; import { FetchSnapshotTelemetry } from '../../common/routes'; import { UsageStatsBody, v2 } from '../../common/types'; @@ -50,7 +51,7 @@ export function registerTelemetryUsageStatsRoutes( // security API directly to check privileges for this action. Note that the 'decryptedTelemetry' API privilege string is only // granted to users that have "Global All" or "Global Read" privileges in Kibana. const { checkPrivilegesWithRequest, actions } = security.authz; - const privileges = { kibana: actions.api.get('decryptedTelemetry') }; + const privileges = { kibana: actions.api.get(ApiOperation.Read, 'decryptedTelemetry') }; const { hasAllRequested } = await checkPrivilegesWithRequest(req).globally(privileges); if (!hasAllRequested) { return res.forbidden(); diff --git a/src/plugins/telemetry/tsconfig.json b/src/plugins/telemetry/tsconfig.json index 09d5aa25c914b..a8538b4a0b18a 100644 --- a/src/plugins/telemetry/tsconfig.json +++ b/src/plugins/telemetry/tsconfig.json @@ -36,6 +36,7 @@ "@kbn/analytics-collection-utils", "@kbn/react-kibana-mount", "@kbn/core-node-server", + "@kbn/security-plugin-types-server", ], "exclude": [ "target/**/*", diff --git a/src/plugins/telemetry_collection_manager/kibana.jsonc b/src/plugins/telemetry_collection_manager/kibana.jsonc index cbf63b44c6115..3c96594530acc 100644 --- a/src/plugins/telemetry_collection_manager/kibana.jsonc +++ b/src/plugins/telemetry_collection_manager/kibana.jsonc @@ -1,13 +1,17 @@ { "type": "plugin", "id": "@kbn/telemetry-collection-manager-plugin", - "owner": "@elastic/kibana-core", + "owner": [ + "@elastic/kibana-core" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "telemetryCollectionManager", - "server": true, "browser": false, + "server": true, "requiredPlugins": [ "usageCollection" ] } -} +} \ No newline at end of file diff --git a/src/plugins/telemetry_management_section/kibana.jsonc b/src/plugins/telemetry_management_section/kibana.jsonc index 0cd94f9d23234..e86f9f8901d14 100644 --- a/src/plugins/telemetry_management_section/kibana.jsonc +++ b/src/plugins/telemetry_management_section/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/telemetry-management-section-plugin", - "owner": "@elastic/kibana-core", + "owner": [ + "@elastic/kibana-core" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "telemetryManagementSection", - "server": false, "browser": true, + "server": false, "requiredPlugins": [ "telemetry", "advancedSettings" diff --git a/src/plugins/ui_actions/kibana.jsonc b/src/plugins/ui_actions/kibana.jsonc index e63c80190c074..1af247645ba49 100644 --- a/src/plugins/ui_actions/kibana.jsonc +++ b/src/plugins/ui_actions/kibana.jsonc @@ -1,15 +1,19 @@ { "type": "plugin", "id": "@kbn/ui-actions-plugin", - "owner": "@elastic/appex-sharedux", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "shared", "description": "Adds UI Actions service to Kibana", "plugin": { "id": "uiActions", - "server": false, "browser": true, + "server": false, "requiredPlugins": [], "requiredBundles": [ "kibanaUtils" ] } -} +} \ No newline at end of file diff --git a/src/plugins/ui_actions_enhanced/.eslintrc.json b/src/plugins/ui_actions_enhanced/.eslintrc.json deleted file mode 100644 index 2aab6c2d9093b..0000000000000 --- a/src/plugins/ui_actions_enhanced/.eslintrc.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "rules": { - "@typescript-eslint/consistent-type-definitions": 0 - } -} diff --git a/src/plugins/ui_actions_enhanced/common/types.ts b/src/plugins/ui_actions_enhanced/common/types.ts index 5086d0e541e97..ff60a9370c576 100644 --- a/src/plugins/ui_actions_enhanced/common/types.ts +++ b/src/plugins/ui_actions_enhanced/common/types.ts @@ -11,6 +11,7 @@ import type { SerializableRecord } from '@kbn/utility-types'; export type BaseActionConfig = SerializableRecord; +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions export type SerializedAction = { readonly factoryId: string; readonly name: string; @@ -20,12 +21,14 @@ export type SerializedAction /** * Serialized representation of a triggers-action pair, used to persist in storage. */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions export type SerializedEvent = { eventId: string; triggers: string[]; action: SerializedAction; }; +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions export type DynamicActionsState = { events: SerializedEvent[]; }; diff --git a/src/plugins/ui_actions_enhanced/kibana.jsonc b/src/plugins/ui_actions_enhanced/kibana.jsonc index 595c483a66505..7575e79bf6fe7 100644 --- a/src/plugins/ui_actions_enhanced/kibana.jsonc +++ b/src/plugins/ui_actions_enhanced/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/ui-actions-enhanced-plugin", - "owner": "@elastic/appex-sharedux", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "shared", "description": "Extends UI Actions plugin with more functionality", "plugin": { "id": "uiActionsEnhanced", - "server": true, "browser": true, + "server": true, "configPath": [ "src", "ui_actions_enhanced" @@ -23,4 +27,4 @@ "kibanaReact" ] } -} +} \ No newline at end of file diff --git a/src/plugins/ui_actions_enhanced/public/components/action_wizard/test_data.tsx b/src/plugins/ui_actions_enhanced/public/components/action_wizard/test_data.tsx index 2e4fd27948b8e..cfe7784ec99fd 100644 --- a/src/plugins/ui_actions_enhanced/public/components/action_wizard/test_data.tsx +++ b/src/plugins/ui_actions_enhanced/public/components/action_wizard/test_data.tsx @@ -25,6 +25,7 @@ export const dashboards = [ { id: 'dashboard2', title: 'Dashboard 2' }, ]; +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions type DashboardDrilldownConfig = { dashboardId?: string; useCurrentFilters: boolean; @@ -119,6 +120,7 @@ export const dashboardFactory = new ActionFactory(dashboardDrilldownActionFactor getFeatureUsageStart: () => licensingMock.createStart().featureUsage, }); +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions type UrlDrilldownConfig = { url: string; openInNewTab: boolean; diff --git a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts index 2c49b497e0c75..d357897c32395 100644 --- a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts +++ b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts @@ -9,23 +9,6 @@ import { i18n } from '@kbn/i18n'; -export const txtUrlTemplatePlaceholder = i18n.translate( - 'uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplatePlaceholderText', - { - defaultMessage: 'Example: {exampleUrl}', - values: { - exampleUrl: 'https://www.my-url.com/?{{event.key}}={{event.value}}', - }, - } -); - -export const txtUrlPreviewHelpText = i18n.translate( - 'uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewHelpText', - { - defaultMessage: `Please note that in preview '{{event.*}}' variables are substituted with dummy values.`, - } -); - export const txtUrlTemplateLabel = i18n.translate( 'uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateLabel', { @@ -33,24 +16,43 @@ export const txtUrlTemplateLabel = i18n.translate( } ); -export const txtUrlTemplateSyntaxHelpLinkText = i18n.translate( - 'uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateSyntaxHelpLinkText', +export const txtEmptyErrorMessage = i18n.translate( + 'uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateEmptyErrorMessage', { - defaultMessage: 'Syntax help', + defaultMessage: 'URL template is required.', } ); -export const txtUrlTemplatePreviewLabel = i18n.translate( - 'uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewLabel', +export const txtInvalidFormatErrorMessage = ({ + error, + example, +}: { + error: string; + example: string; +}) => + i18n.translate( + 'uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateInvalidFormatErrorMessage', + { + defaultMessage: '{error} Example: {example}', + values: { + error, + example, + }, + } + ); + +export const txtUrlTemplateSyntaxTestingHelpText = i18n.translate( + 'uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateSyntaxTestingHelpText', { - defaultMessage: 'URL preview:', + defaultMessage: + 'To validate and test the URL template, save the configuration and use this drilldown from the panel.', } ); -export const txtUrlTemplatePreviewLinkText = i18n.translate( - 'uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewLinkText', +export const txtUrlTemplateSyntaxHelpLinkText = i18n.translate( + 'uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateSyntaxHelpLinkText', { - defaultMessage: 'Preview', + defaultMessage: 'Syntax help', } ); diff --git a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx index 60b8cc33c178f..fd9e78c37d981 100644 --- a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx +++ b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx @@ -17,10 +17,14 @@ import { txtUrlTemplateSyntaxHelpLinkText, txtUrlTemplateLabel, txtUrlTemplateAdditionalOptions, + txtEmptyErrorMessage, + txtInvalidFormatErrorMessage, + txtUrlTemplateSyntaxTestingHelpText, } from './i18n'; import { VariablePopover } from '../variable_popover'; import { UrlDrilldownOptionsComponent } from './lazy'; import { DEFAULT_URL_DRILLDOWN_OPTIONS } from '../../constants'; +import { validateUrl } from '../../url_validation'; export interface UrlDrilldownCollectConfigProps { config: UrlDrilldownConfig; @@ -69,7 +73,16 @@ export const UrlDrilldownCollectConfig: React.FC } } const isEmpty = !urlTemplate; - const isInvalid = !isPristine && isEmpty; + + const isValidUrlFormat = validateUrl(urlTemplate); + const isInvalid = !isPristine && (isEmpty || !isValidUrlFormat.isValid); + + const invalidErrorMessage = isInvalid + ? isEmpty + ? txtEmptyErrorMessage + : txtInvalidFormatErrorMessage({ error: isValidUrlFormat.error!, example: exampleUrl }) + : undefined; + const variablesDropdown = ( - {txtUrlTemplateSyntaxHelpLinkText} - - ) + <> + {txtUrlTemplateSyntaxTestingHelpText}{' '} + {syntaxHelpDocsLink ? ( + + {txtUrlTemplateSyntaxHelpLinkText} + + ) : null} + } labelAppend={variablesDropdown} > diff --git a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/types.ts b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/types.ts index 1deafe53db379..973fcb1c8ebbf 100644 --- a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/types.ts +++ b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/types.ts @@ -14,6 +14,7 @@ export type UrlDrilldownConfig = { /** * User-configurable options for URL drilldowns */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions export type UrlDrilldownOptions = { openInNewTab: boolean; encodeUrl: boolean; diff --git a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.ts b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.ts index e95c32df56595..d3c3db4772bec 100644 --- a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.ts +++ b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.ts @@ -14,23 +14,24 @@ import { compile } from './url_template'; const generalFormatError = i18n.translate( 'uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlFormatGeneralErrorMessage', { - defaultMessage: 'Invalid format. Example: {exampleUrl}', - values: { - exampleUrl: 'https://www.my-url.com/?{{event.key}}={{event.value}}', - }, + defaultMessage: 'Invalid URL format.', } ); -const formatError = (message: string) => - i18n.translate('uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlFormatErrorMessage', { - defaultMessage: 'Invalid format: {message}', +const compileError = (message: string) => + i18n.translate('uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlCompileErrorMessage', { + defaultMessage: 'The URL template is not valid in the given context. {message}.', values: { - message, + message: message.replaceAll('[object Object]', 'context'), }, }); const SAFE_URL_PATTERN = /^(?:(?:https?|mailto):|[^&:/?#]*(?:[/?#]|$))/gi; -export function validateUrl(url: string): { isValid: boolean; error?: string } { +export function validateUrl(url: string): { + isValid: boolean; + error?: string; + invalidUrl?: string; +} { if (!url) return { isValid: false, @@ -45,6 +46,7 @@ export function validateUrl(url: string): { isValid: boolean; error?: string } { return { isValid: false, error: generalFormatError, + invalidUrl: url, }; } } @@ -52,20 +54,32 @@ export function validateUrl(url: string): { isValid: boolean; error?: string } { export async function validateUrlTemplate( urlTemplate: UrlDrilldownConfig['url'], scope: UrlDrilldownScope -): Promise<{ isValid: boolean; error?: string }> { +): Promise<{ isValid: boolean; error?: string; invalidUrl?: string }> { if (!urlTemplate.template) return { isValid: false, error: generalFormatError, }; + let compiledUrl: string; + + try { + compiledUrl = await compile(urlTemplate.template, scope); + } catch (e) { + return { + isValid: false, + error: compileError(e.message), + invalidUrl: urlTemplate.template, + }; + } + try { - const compiledUrl = await compile(urlTemplate.template, scope); return validateUrl(compiledUrl); } catch (e) { return { isValid: false, - error: formatError(e.message), + error: generalFormatError + ` ${e.message}.`, + invalidUrl: compiledUrl, }; } } diff --git a/src/plugins/unified_doc_viewer/kibana.jsonc b/src/plugins/unified_doc_viewer/kibana.jsonc index 6bd1b738c0ccb..a741cd93472b3 100644 --- a/src/plugins/unified_doc_viewer/kibana.jsonc +++ b/src/plugins/unified_doc_viewer/kibana.jsonc @@ -1,15 +1,26 @@ { "type": "plugin", "id": "@kbn/unified-doc-viewer-plugin", - "owner": "@elastic/kibana-data-discovery", + "owner": [ + "@elastic/kibana-data-discovery" + ], + "group": "platform", + "visibility": "shared", "description": "This plugin contains services reliant on the plugin lifecycle for the unified doc viewer component (see @kbn/unified-doc-viewer).", "plugin": { "id": "unifiedDocViewer", - "server": false, "browser": true, - "requiredBundles": ["kibanaUtils"], - "requiredPlugins": ["data", "fieldFormats", "share"], - "optionalPlugins": ["fieldsMetadata"] + "server": false, + "requiredPlugins": [ + "data", + "fieldFormats", + "share" + ], + "optionalPlugins": [ + "fieldsMetadata" + ], + "requiredBundles": [ + "kibanaUtils" + ] } -} - \ No newline at end of file +} \ No newline at end of file diff --git a/src/plugins/unified_doc_viewer/public/components/doc_viewer_source/source.test.tsx b/src/plugins/unified_doc_viewer/public/components/doc_viewer_source/source.test.tsx index 5cd4d8b7ba00b..67c4dd65a6634 100644 --- a/src/plugins/unified_doc_viewer/public/components/doc_viewer_source/source.test.tsx +++ b/src/plugins/unified_doc_viewer/public/components/doc_viewer_source/source.test.tsx @@ -103,5 +103,7 @@ describe('Source Viewer component', () => { ); const jsonCodeEditor = comp.find(JsonCodeEditorCommon); expect(jsonCodeEditor).not.toBe(null); + expect(jsonCodeEditor.props().jsonValue).toContain('_source'); + expect(jsonCodeEditor.props().jsonValue).not.toContain('_score'); }); }); diff --git a/src/plugins/unified_doc_viewer/public/components/doc_viewer_source/source.tsx b/src/plugins/unified_doc_viewer/public/components/doc_viewer_source/source.tsx index 0dbaabb4ba55a..5b4ba36cd03f1 100644 --- a/src/plugins/unified_doc_viewer/public/components/doc_viewer_source/source.tsx +++ b/src/plugins/unified_doc_viewer/public/components/doc_viewer_source/source.tsx @@ -18,6 +18,7 @@ import type { DataView } from '@kbn/data-views-plugin/public'; import type { DataTableRecord } from '@kbn/discover-utils/types'; import { ElasticRequestState } from '@kbn/unified-doc-viewer'; import { isLegacyTableEnabled, SEARCH_FIELDS_FROM_SOURCE } from '@kbn/discover-utils'; +import { omit } from 'lodash'; import { getUnifiedDocViewerServices } from '../../plugin'; import { useEsDocSearch } from '../../hooks'; import { getHeight, DEFAULT_MARGIN_BOTTOM } from './get_height'; @@ -70,7 +71,7 @@ export const DocViewerSource = ({ useEffect(() => { if (requestState === ElasticRequestState.Found && hit) { - setJsonValue(JSON.stringify(hit.raw, undefined, 2)); + setJsonValue(JSON.stringify(omit(hit.raw, '_score'), undefined, 2)); } }, [requestState, hit]); diff --git a/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/table.scss b/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/table.scss index 19d556b0b142a..64e700c73fca5 100644 --- a/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/table.scss +++ b/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/table.scss @@ -90,7 +90,7 @@ } & [data-gridcell-column-id='pin_field'] .euiDataGridRowCell__content { - padding: $euiSizeXS / 2 0 0 $euiSizeXS; + padding: calc($euiSizeXS / 2) 0 0 $euiSizeXS; } .kbnDocViewer__fieldsGrid__pinAction { diff --git a/src/plugins/unified_histogram/kibana.jsonc b/src/plugins/unified_histogram/kibana.jsonc index 54e749a89f3e4..4af13ca5d6996 100644 --- a/src/plugins/unified_histogram/kibana.jsonc +++ b/src/plugins/unified_histogram/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/unified-histogram-plugin", - "owner": "@elastic/kibana-data-discovery", + "owner": [ + "@elastic/kibana-data-discovery" + ], + "group": "platform", + "visibility": "shared", "description": "The `unifiedHistogram` plugin provides UI components to create a layout including a resizable histogram and a main display.", "plugin": { "id": "unifiedHistogram", - "server": false, "browser": true, + "server": false, "requiredBundles": [ "data", "dataViews", @@ -15,4 +19,4 @@ "visualizations" ] } -} +} \ No newline at end of file diff --git a/src/plugins/unified_histogram/public/chart/histogram.test.tsx b/src/plugins/unified_histogram/public/chart/histogram.test.tsx index 26bdc0c505234..72b5c0cc0b791 100644 --- a/src/plugins/unified_histogram/public/chart/histogram.test.tsx +++ b/src/plugins/unified_histogram/public/chart/histogram.test.tsx @@ -240,7 +240,7 @@ describe('Histogram', () => { onLoad(false, adapters); }); expect(props.onTotalHitsChange).toHaveBeenLastCalledWith( - UnifiedHistogramFetchStatus.complete, + UnifiedHistogramFetchStatus.error, 100 ); expect(props.onChartLoad).toHaveBeenLastCalledWith({ adapters }); diff --git a/src/plugins/unified_histogram/public/chart/histogram.tsx b/src/plugins/unified_histogram/public/chart/histogram.tsx index faa5ddd2b1fc3..e63cf775158aa 100644 --- a/src/plugins/unified_histogram/public/chart/histogram.tsx +++ b/src/plugins/unified_histogram/public/chart/histogram.tsx @@ -130,9 +130,6 @@ export function Histogram({ | undefined; const response = json?.rawResponse; - // The response can have `response?._shards.failed` but we should still be able to show hits number - // TODO: show shards warnings as a badge next to the total hits number - if (requestFailed) { onTotalHitsChange?.(UnifiedHistogramFetchStatus.error, undefined); onChartLoad?.({ adapters: adapters ?? {} }); @@ -142,10 +139,14 @@ export function Histogram({ const adapterTables = adapters?.tables?.tables; const totalHits = computeTotalHits(hasLensSuggestions, adapterTables, isPlainRecord); - onTotalHitsChange?.( - isLoading ? UnifiedHistogramFetchStatus.loading : UnifiedHistogramFetchStatus.complete, - totalHits ?? hits?.total - ); + if (response?._shards?.failed || response?.timed_out) { + onTotalHitsChange?.(UnifiedHistogramFetchStatus.error, totalHits); + } else { + onTotalHitsChange?.( + isLoading ? UnifiedHistogramFetchStatus.loading : UnifiedHistogramFetchStatus.complete, + totalHits ?? hits?.total + ); + } if (response) { const newBucketInterval = buildBucketInterval({ diff --git a/src/plugins/unified_histogram/public/services/lens_vis_service.ts b/src/plugins/unified_histogram/public/services/lens_vis_service.ts index e48ebc6459071..2367e729b5a70 100644 --- a/src/plugins/unified_histogram/public/services/lens_vis_service.ts +++ b/src/plugins/unified_histogram/public/services/lens_vis_service.ts @@ -27,7 +27,11 @@ import type { import type { AggregateQuery, TimeRange } from '@kbn/es-query'; import { getAggregateQueryMode, isOfAggregateQueryType } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; -import { getLensAttributesFromSuggestion } from '@kbn/visualization-utils'; +import { + getLensAttributesFromSuggestion, + ChartType, + mapVisToChartType, +} from '@kbn/visualization-utils'; import { LegendSize } from '@kbn/visualizations-plugin/public'; import { XYConfiguration } from '@kbn/visualizations-plugin/common'; import type { Datatable, DatatableColumn } from '@kbn/expressions-plugin/common'; @@ -42,6 +46,7 @@ import { isSuggestionShapeAndVisContextCompatible, deriveLensSuggestionFromLensAttributes, type QueryParams, + injectESQLQueryIntoLensLayers, } from '../utils/external_vis_context'; import { computeInterval } from '../utils/compute_interval'; import { fieldSupportsBreakdown } from '../utils/field_supports_breakdown'; @@ -147,7 +152,10 @@ export class LensVisService { externalVisContextStatus: UnifiedHistogramExternalVisContextStatus ) => void; }) => { - const allSuggestions = this.getAllSuggestions({ queryParams }); + const allSuggestions = this.getAllSuggestions({ + queryParams, + preferredVisAttributes: externalVisContext?.attributes, + }); const suggestionState = this.getCurrentSuggestionState({ externalVisContext, @@ -252,6 +260,7 @@ export class LensVisService { const histogramSuggestionForESQL = this.getHistogramSuggestionForESQL({ queryParams, breakdownField, + preferredVisAttributes: externalVisContext?.attributes, }); if (histogramSuggestionForESQL) { // In case if histogram suggestion, we want to empty the array and push the new suggestion @@ -463,9 +472,11 @@ export class LensVisService { private getHistogramSuggestionForESQL = ({ queryParams, breakdownField, + preferredVisAttributes, }: { queryParams: QueryParams; breakdownField?: DataViewField; + preferredVisAttributes?: UnifiedHistogramVisContext['attributes']; }): Suggestion | undefined => { const { dataView, query, timeRange, columns } = queryParams; const breakdownColumn = breakdownField?.name @@ -510,7 +521,22 @@ export class LensVisService { if (breakdownColumn) { context.textBasedColumns.push(breakdownColumn); } - const suggestions = this.lensSuggestionsApi(context, dataView, ['lnsDatatable']) ?? []; + + // here the attributes contain the main query and not the histogram one + const updatedAttributesWithQuery = preferredVisAttributes + ? injectESQLQueryIntoLensLayers(preferredVisAttributes, { + esql: esqlQuery, + }) + : undefined; + + const suggestions = + this.lensSuggestionsApi( + context, + dataView, + ['lnsDatatable'], + ChartType.XY, + updatedAttributesWithQuery + ) ?? []; if (suggestions.length) { const suggestion = suggestions[0]; const suggestionVisualizationState = Object.assign({}, suggestion?.visualizationState); @@ -574,9 +600,25 @@ export class LensVisService { ); }; - private getAllSuggestions = ({ queryParams }: { queryParams: QueryParams }): Suggestion[] => { + private getAllSuggestions = ({ + queryParams, + preferredVisAttributes, + }: { + queryParams: QueryParams; + preferredVisAttributes?: UnifiedHistogramVisContext['attributes']; + }): Suggestion[] => { const { dataView, columns, query, isPlainRecord } = queryParams; + const preferredChartType = preferredVisAttributes + ? mapVisToChartType(preferredVisAttributes.visualizationType) + : undefined; + + let visAttributes = preferredVisAttributes; + + if (query && isOfAggregateQueryType(query) && preferredVisAttributes) { + visAttributes = injectESQLQueryIntoLensLayers(preferredVisAttributes, query); + } + const context = { dataViewSpec: dataView?.toSpec(), fieldName: '', @@ -584,7 +626,13 @@ export class LensVisService { query: query && isOfAggregateQueryType(query) ? query : undefined, }; const allSuggestions = isPlainRecord - ? this.lensSuggestionsApi(context, dataView, ['lnsDatatable']) ?? [] + ? this.lensSuggestionsApi( + context, + dataView, + ['lnsDatatable'], + preferredChartType, + visAttributes + ) ?? [] : []; return allSuggestions; diff --git a/src/plugins/unified_histogram/public/utils/external_vis_context.test.ts b/src/plugins/unified_histogram/public/utils/external_vis_context.test.ts index 2931d3a8410ca..1cbad8b308078 100644 --- a/src/plugins/unified_histogram/public/utils/external_vis_context.test.ts +++ b/src/plugins/unified_histogram/public/utils/external_vis_context.test.ts @@ -13,6 +13,7 @@ import { canImportVisContext, exportVisContext, isSuggestionShapeAndVisContextCompatible, + injectESQLQueryIntoLensLayers, } from './external_vis_context'; import { getLensVisMock } from '../__mocks__/lens_vis'; import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield'; @@ -162,4 +163,63 @@ describe('external_vis_context', () => { ).toBe(true); }); }); + + describe('injectESQLQueryIntoLensLayers', () => { + it('should return the Lens attributes as they are for unknown datasourceId', async () => { + const attributes = { + visualizationType: 'lnsXY', + state: { + visualization: { preferredSeriesType: 'line' }, + datasourceStates: { unknownId: { layers: {} } }, + }, + } as unknown as UnifiedHistogramVisContext['attributes']; + expect(injectESQLQueryIntoLensLayers(attributes, { esql: 'from foo' })).toStrictEqual( + attributes + ); + }); + + it('should return the Lens attributes as they are for DSL config (formbased)', async () => { + const attributes = { + visualizationType: 'lnsXY', + state: { + visualization: { preferredSeriesType: 'line' }, + datasourceStates: { formBased: { layers: {} } }, + }, + } as UnifiedHistogramVisContext['attributes']; + expect(injectESQLQueryIntoLensLayers(attributes, { esql: 'from foo' })).toStrictEqual( + attributes + ); + }); + + it('should inject the query to the Lens attributes for ES|QL config (textbased)', async () => { + const attributes = { + visualizationType: 'lnsXY', + state: { + visualization: { preferredSeriesType: 'line' }, + datasourceStates: { textBased: { layers: { layer1: { query: { esql: 'from foo' } } } } }, + }, + } as unknown as UnifiedHistogramVisContext['attributes']; + + const expectedAttributes = { + ...attributes, + state: { + ...attributes.state, + datasourceStates: { + ...attributes.state.datasourceStates, + textBased: { + ...attributes.state.datasourceStates.textBased, + layers: { + layer1: { + query: { esql: 'from foo | stats count(*)' }, + }, + }, + }, + }, + }, + } as unknown as UnifiedHistogramVisContext['attributes']; + expect( + injectESQLQueryIntoLensLayers(attributes, { esql: 'from foo | stats count(*)' }) + ).toStrictEqual(expectedAttributes); + }); + }); }); diff --git a/src/plugins/unified_histogram/public/utils/external_vis_context.ts b/src/plugins/unified_histogram/public/utils/external_vis_context.ts index fd516dd2c32d8..ef5788b4b25ba 100644 --- a/src/plugins/unified_histogram/public/utils/external_vis_context.ts +++ b/src/plugins/unified_histogram/public/utils/external_vis_context.ts @@ -7,9 +7,10 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { isEqual } from 'lodash'; +import { isEqual, cloneDeep } from 'lodash'; import type { DataView } from '@kbn/data-views-plugin/common'; import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; +import { getDatasourceId } from '@kbn/visualization-utils'; import type { DatatableColumn } from '@kbn/expressions-plugin/common'; import type { PieVisualizationState, Suggestion, XYState } from '@kbn/lens-plugin/public'; import { UnifiedHistogramSuggestionType, UnifiedHistogramVisContext } from '../types'; @@ -103,6 +104,42 @@ export const isSuggestionShapeAndVisContextCompatible = ( ); }; +export const injectESQLQueryIntoLensLayers = ( + visAttributes: UnifiedHistogramVisContext['attributes'], + query: AggregateQuery +) => { + const datasourceId = getDatasourceId(visAttributes.state.datasourceStates); + + // if the datasource is formBased, we should not fix the query + if (!datasourceId || datasourceId === 'formBased') { + return visAttributes; + } + + if (!visAttributes.state.datasourceStates[datasourceId]) { + return visAttributes; + } + + const datasourceState = cloneDeep(visAttributes.state.datasourceStates[datasourceId]); + + if (datasourceState && datasourceState.layers) { + Object.values(datasourceState.layers).forEach((layer) => { + if (!isEqual(layer.query, query)) { + layer.query = query; + } + }); + } + return { + ...visAttributes, + state: { + ...visAttributes.state, + datasourceStates: { + ...visAttributes.state.datasourceStates, + [datasourceId]: datasourceState, + }, + }, + }; +}; + export function deriveLensSuggestionFromLensAttributes({ externalVisContext, queryParams, @@ -122,10 +159,7 @@ export function deriveLensSuggestionFromLensAttributes({ } // it should be one of 'formBased'/'textBased' and have value - const datasourceId: 'formBased' | 'textBased' | undefined = [ - 'formBased' as const, - 'textBased' as const, - ].find((key) => Boolean(externalVisContext.attributes.state.datasourceStates[key])); + const datasourceId = getDatasourceId(externalVisContext.attributes.state.datasourceStates); if (!datasourceId) { return undefined; diff --git a/src/plugins/unified_search/kibana.jsonc b/src/plugins/unified_search/kibana.jsonc index ad7f73a608857..b3e71c12af28a 100644 --- a/src/plugins/unified_search/kibana.jsonc +++ b/src/plugins/unified_search/kibana.jsonc @@ -1,15 +1,19 @@ { "type": "plugin", "id": "@kbn/unified-search-plugin", - "owner": "@elastic/kibana-visualizations", + "owner": [ + "@elastic/kibana-visualizations" + ], + "group": "platform", + "visibility": "shared", "description": "Contains all the key functionality of Kibana's unified search experience.Contains all the key functionality of Kibana's unified search experience.", "serviceFolders": [ "autocomplete" ], "plugin": { "id": "unifiedSearch", - "server": true, "browser": true, + "server": true, "configPath": [ "unifiedSearch" ], @@ -30,4 +34,4 @@ "esql" ] } -} +} \ No newline at end of file diff --git a/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.scss b/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.scss index 7a14dd3a64ef3..85c5b6bb9278f 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.scss +++ b/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.scss @@ -7,7 +7,7 @@ .globalFilterItem { line-height: $euiSize; color: $euiTextColor; - padding-block: $euiSizeM / 2; + padding-block: calc($euiSizeM / 2); white-space: normal; /* 1 */ &:not(.globalFilterItem-isDisabled) { diff --git a/src/plugins/unified_search/public/query_string_input/esql_menu_popover.tsx b/src/plugins/unified_search/public/query_string_input/esql_menu_popover.tsx index 71e54c3376abb..ba978debb73de 100644 --- a/src/plugins/unified_search/public/query_string_input/esql_menu_popover.tsx +++ b/src/plugins/unified_search/public/query_string_input/esql_menu_popover.tsx @@ -145,7 +145,6 @@ export const ESQLMenuPopover: React.FC = ({ }, { id: 1, - initialFocusedItemIndex: 1, title: i18n.translate('unifiedSearch.query.queryBar.esqlMenu.exampleQueries', { defaultMessage: 'Recommended queries', }), diff --git a/src/plugins/url_forwarding/kibana.jsonc b/src/plugins/url_forwarding/kibana.jsonc index 3eede9eb9effc..4089ad10fc7b9 100644 --- a/src/plugins/url_forwarding/kibana.jsonc +++ b/src/plugins/url_forwarding/kibana.jsonc @@ -1,10 +1,14 @@ { "type": "plugin", "id": "@kbn/url-forwarding-plugin", - "owner": "@elastic/kibana-visualizations", + "owner": [ + "@elastic/kibana-visualizations" + ], + "group": "platform", + "visibility": "private", "plugin": { "id": "urlForwarding", - "server": false, - "browser": true + "browser": true, + "server": false } -} +} \ No newline at end of file diff --git a/src/plugins/usage_collection/kibana.jsonc b/src/plugins/usage_collection/kibana.jsonc index 78d54f302a327..3e9ccac82974b 100644 --- a/src/plugins/usage_collection/kibana.jsonc +++ b/src/plugins/usage_collection/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/usage-collection-plugin", - "owner": "@elastic/kibana-core", + "owner": [ + "@elastic/kibana-core" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "usageCollection", - "server": true, "browser": true, + "server": true, "configPath": [ "usageCollection" ], @@ -16,4 +20,4 @@ "kibanaUtils" ] } -} +} \ No newline at end of file diff --git a/src/plugins/vis_default_editor/kibana.jsonc b/src/plugins/vis_default_editor/kibana.jsonc index 15db2338f2ca7..1e096b8be6950 100644 --- a/src/plugins/vis_default_editor/kibana.jsonc +++ b/src/plugins/vis_default_editor/kibana.jsonc @@ -1,15 +1,19 @@ { "type": "plugin", "id": "@kbn/vis-default-editor-plugin", - "owner": "@elastic/kibana-visualizations", + "owner": [ + "@elastic/kibana-visualizations" + ], + "group": "platform", + "visibility": "private", "description": "The default editor used in most aggregation-based visualizations.", "plugin": { "id": "visDefaultEditor", - "server": false, "browser": true, + "server": false, "requiredPlugins": [ "dataViews", - "unifiedSearch", + "unifiedSearch" ], "optionalPlugins": [ "visualizations" @@ -24,4 +28,4 @@ "savedSearch" ] } -} +} \ No newline at end of file diff --git a/src/plugins/vis_type_markdown/kibana.jsonc b/src/plugins/vis_type_markdown/kibana.jsonc index 476dcc0605ad4..49a9f0d00b0bf 100644 --- a/src/plugins/vis_type_markdown/kibana.jsonc +++ b/src/plugins/vis_type_markdown/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/vis-type-markdown-plugin", - "owner": "@elastic/kibana-presentation", + "owner": [ + "@elastic/kibana-presentation" + ], + "group": "platform", + "visibility": "private", "description": "Adds a markdown visualization type", "plugin": { "id": "visTypeMarkdown", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "expressions", "visualizations" @@ -18,4 +22,4 @@ "visualizations" ] } -} +} \ No newline at end of file diff --git a/src/plugins/vis_type_markdown/public/markdown_vis_controller.tsx b/src/plugins/vis_type_markdown/public/markdown_vis_controller.tsx index 1c5253aec6004..6d36703e17446 100644 --- a/src/plugins/vis_type_markdown/public/markdown_vis_controller.tsx +++ b/src/plugins/vis_type_markdown/public/markdown_vis_controller.tsx @@ -31,6 +31,7 @@ const MarkdownVisComponent = ({ data-test-subj="markdownBody" openLinksInNewTab={openLinksInNewTab} markdown={markdown} + tabIndex={0} /> ); diff --git a/src/plugins/vis_types/gauge/kibana.jsonc b/src/plugins/vis_types/gauge/kibana.jsonc index 7897ac6a6a3b1..634fe39685cbc 100644 --- a/src/plugins/vis_types/gauge/kibana.jsonc +++ b/src/plugins/vis_types/gauge/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/vis-type-gauge-plugin", - "owner": "@elastic/kibana-visualizations", + "owner": [ + "@elastic/kibana-visualizations" + ], + "group": "platform", + "visibility": "private", "description": "Contains the gauge chart implementation using the elastic-charts library. The goal is to eventually deprecate the old implementation and keep only this. Until then, the library used is defined by the Legacy charts library advanced setting.", "plugin": { "id": "visTypeGauge", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "charts", "data", @@ -26,4 +30,4 @@ "common" ] } -} +} \ No newline at end of file diff --git a/src/plugins/vis_types/heatmap/kibana.jsonc b/src/plugins/vis_types/heatmap/kibana.jsonc index f658f744526dd..79b426ed2d7bd 100644 --- a/src/plugins/vis_types/heatmap/kibana.jsonc +++ b/src/plugins/vis_types/heatmap/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/vis-type-heatmap-plugin", - "owner": "@elastic/kibana-visualizations", + "owner": [ + "@elastic/kibana-visualizations" + ], + "group": "platform", + "visibility": "private", "description": "Contains the heatmap implementation using the elastic-charts library. The goal is to eventually deprecate the old implementation and keep only this. Until then, the library used is defined by the Legacy heatmap charts library advanced setting.", "plugin": { "id": "visTypeHeatmap", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "charts", "data", @@ -24,4 +28,4 @@ "common" ] } -} +} \ No newline at end of file diff --git a/src/plugins/vis_types/metric/kibana.jsonc b/src/plugins/vis_types/metric/kibana.jsonc index 539e4318a9362..88a3f469e3485 100644 --- a/src/plugins/vis_types/metric/kibana.jsonc +++ b/src/plugins/vis_types/metric/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/vis-type-metric-plugin", - "owner": "@elastic/kibana-visualizations", + "owner": [ + "@elastic/kibana-visualizations" + ], + "group": "platform", + "visibility": "private", "description": "Registers the Metric aggregation-based visualization.", "plugin": { "id": "visTypeMetric", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "data", "visualizations", @@ -19,4 +23,4 @@ "kibanaUtils" ] } -} +} \ No newline at end of file diff --git a/src/plugins/vis_types/pie/kibana.jsonc b/src/plugins/vis_types/pie/kibana.jsonc index 85364316fc19d..79beaa143cbe1 100644 --- a/src/plugins/vis_types/pie/kibana.jsonc +++ b/src/plugins/vis_types/pie/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/vis-type-pie-plugin", - "owner": "@elastic/kibana-visualizations", + "owner": [ + "@elastic/kibana-visualizations" + ], + "group": "platform", + "visibility": "private", "description": "Contains the pie chart implementation using the elastic-charts library. The goal is to eventually deprecate the old implementation and keep only this. Until then, the library used is defined by the Legacy charts library advanced setting.", "plugin": { "id": "visTypePie", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "charts", "data", @@ -24,4 +28,4 @@ "common" ] } -} +} \ No newline at end of file diff --git a/src/plugins/vis_types/table/kibana.jsonc b/src/plugins/vis_types/table/kibana.jsonc index de4712484d07d..5cdc5da34d871 100644 --- a/src/plugins/vis_types/table/kibana.jsonc +++ b/src/plugins/vis_types/table/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/vis-type-table-plugin", - "owner": "@elastic/kibana-visualizations", + "owner": [ + "@elastic/kibana-visualizations" + ], + "group": "platform", + "visibility": "private", "description": "Registers the datatable aggregation-based visualization.", "plugin": { "id": "visTypeTable", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "expressions", "visualizations", @@ -22,4 +26,4 @@ "visDefaultEditor" ] } -} +} \ No newline at end of file diff --git a/src/plugins/vis_types/tagcloud/kibana.jsonc b/src/plugins/vis_types/tagcloud/kibana.jsonc index 8694f3f27c2f5..71d9d91d7becf 100644 --- a/src/plugins/vis_types/tagcloud/kibana.jsonc +++ b/src/plugins/vis_types/tagcloud/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/vis-type-tagcloud-plugin", - "owner": "@elastic/kibana-visualizations", + "owner": [ + "@elastic/kibana-visualizations" + ], + "group": "platform", + "visibility": "private", "description": "Registers the tagcloud visualization. It is based on elastic-charts wordcloud.", "plugin": { "id": "visTypeTagcloud", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "data", "expressions", @@ -20,4 +24,4 @@ "kibanaUtils" ] } -} +} \ No newline at end of file diff --git a/src/plugins/vis_types/timelion/kibana.jsonc b/src/plugins/vis_types/timelion/kibana.jsonc index aa11b92e58874..041d1248cfe1e 100644 --- a/src/plugins/vis_types/timelion/kibana.jsonc +++ b/src/plugins/vis_types/timelion/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/vis-type-timelion-plugin", - "owner": "@elastic/kibana-visualizations", + "owner": [ + "@elastic/kibana-visualizations" + ], + "group": "platform", + "visibility": "private", "description": "Registers the timelion visualization. Also contains the backend for both timelion app and timelion visualization.", "plugin": { "id": "visTypeTimelion", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "visualizations", "data", @@ -24,4 +28,4 @@ "visDefaultEditor" ] } -} +} \ No newline at end of file diff --git a/src/plugins/vis_types/timeseries/common/constants.ts b/src/plugins/vis_types/timeseries/common/constants.ts index e881fb767f0d0..4734d25d191ce 100644 --- a/src/plugins/vis_types/timeseries/common/constants.ts +++ b/src/plugins/vis_types/timeseries/common/constants.ts @@ -10,7 +10,6 @@ export const UI_SETTINGS = { MAX_BUCKETS_SETTING: 'metrics:max_buckets', ALLOW_STRING_INDICES: 'metrics:allowStringIndices', - ALLOW_CHECKING_FOR_FAILED_SHARDS: 'metrics:allowCheckingForFailedShards', }; export const SERIES_SEPARATOR = '╰┄►'; export const INDEXES_SEPARATOR = ','; diff --git a/src/plugins/vis_types/timeseries/kibana.jsonc b/src/plugins/vis_types/timeseries/kibana.jsonc index 9ba099aef6321..bd0e4ac352daa 100644 --- a/src/plugins/vis_types/timeseries/kibana.jsonc +++ b/src/plugins/vis_types/timeseries/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/vis-type-timeseries-plugin", - "owner": "@elastic/kibana-visualizations", + "owner": [ + "@elastic/kibana-visualizations" + ], + "group": "platform", + "visibility": "private", "description": "Registers the TSVB visualization. TSVB has its one editor, works with index patterns and index strings and contains 6 types of charts: timeseries, topN, table. markdown, metric and gauge.", "plugin": { "id": "visTypeTimeseries", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "charts", "data", @@ -28,4 +32,4 @@ "fieldFormats" ] } -} +} \ No newline at end of file diff --git a/src/plugins/vis_types/timeseries/public/metrics_type.ts b/src/plugins/vis_types/timeseries/public/metrics_type.ts index eec28bb6cf47c..65150ab9eabea 100644 --- a/src/plugins/vis_types/timeseries/public/metrics_type.ts +++ b/src/plugins/vis_types/timeseries/public/metrics_type.ts @@ -24,9 +24,9 @@ import { extractIndexPatternValues, isStringTypeIndexPattern, } from '../common/index_patterns_utils'; -import { TSVB_DEFAULT_COLOR, UI_SETTINGS, VIS_TYPE } from '../common/constants'; +import { TSVB_DEFAULT_COLOR, VIS_TYPE } from '../common/constants'; import { toExpressionAst } from './to_ast'; -import { getDataViewsStart, getUISettings } from './services'; +import { getDataViewsStart } from './services'; import type { TimeseriesVisDefaultParams, TimeseriesVisParams } from './types'; import type { IndexPatternValue, Panel } from '../common/types'; @@ -188,6 +188,5 @@ export const metricsVisDefinition: VisTypeDefinition< requests: new RequestAdapter(), }), requiresSearch: true, - suppressWarnings: () => !getUISettings().get(UI_SETTINGS.ALLOW_CHECKING_FOR_FAILED_SHARDS), getUsedIndexPattern: getUsedIndexPatterns, }; diff --git a/src/plugins/vis_types/timeseries/server/ui_settings.ts b/src/plugins/vis_types/timeseries/server/ui_settings.ts index 0d3dcc681110c..9d6ac0f0856d5 100644 --- a/src/plugins/vis_types/timeseries/server/ui_settings.ts +++ b/src/plugins/vis_types/timeseries/server/ui_settings.ts @@ -38,18 +38,4 @@ export const getUiSettings: () => Record = () => ({ }), schema: schema.boolean(), }, - [UI_SETTINGS.ALLOW_CHECKING_FOR_FAILED_SHARDS]: { - name: i18n.translate('visTypeTimeseries.advancedSettings.allowCheckingForFailedShardsTitle', { - defaultMessage: 'Show TSVB request shard failures', - }), - value: true, - description: i18n.translate( - 'visTypeTimeseries.advancedSettings.allowCheckingForFailedShardsText', - { - defaultMessage: - 'Show warning message for partial data in TSVB charts if the request succeeds for some shards but fails for others.', - } - ), - schema: schema.boolean(), - }, }); diff --git a/src/plugins/vis_types/vega/kibana.jsonc b/src/plugins/vis_types/vega/kibana.jsonc index b3a2c28afa698..25c4320825288 100644 --- a/src/plugins/vis_types/vega/kibana.jsonc +++ b/src/plugins/vis_types/vega/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/vis-type-vega-plugin", - "owner": "@elastic/kibana-visualizations", + "owner": [ + "@elastic/kibana-visualizations" + ], + "group": "platform", + "visibility": "private", "description": "Registers the vega visualization. Is the elastic version of vega and vega-lite libraries.", "plugin": { "id": "visTypeVega", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "data", "visualizations", @@ -25,4 +29,4 @@ "visDefaultEditor" ] } -} +} \ No newline at end of file diff --git a/src/plugins/vis_types/vislib/kibana.jsonc b/src/plugins/vis_types/vislib/kibana.jsonc index bb1882e4d860d..8521b198c2b6a 100644 --- a/src/plugins/vis_types/vislib/kibana.jsonc +++ b/src/plugins/vis_types/vislib/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/vis-type-vislib-plugin", - "owner": "@elastic/kibana-visualizations", + "owner": [ + "@elastic/kibana-visualizations" + ], + "group": "platform", + "visibility": "private", "description": "Contains the vislib visualizations. These are the classical area/line/bar, gauge/goal and heatmap charts. We want to replace them with elastic-charts.", "plugin": { "id": "visTypeVislib", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "charts", "data", @@ -23,4 +27,4 @@ "visTypeGauge" ] } -} +} \ No newline at end of file diff --git a/src/plugins/vis_types/xy/kibana.jsonc b/src/plugins/vis_types/xy/kibana.jsonc index bc0f162537fd5..ff8da57388377 100644 --- a/src/plugins/vis_types/xy/kibana.jsonc +++ b/src/plugins/vis_types/xy/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/vis-type-xy-plugin", - "owner": "@elastic/kibana-visualizations", + "owner": [ + "@elastic/kibana-visualizations" + ], + "group": "platform", + "visibility": "private", "description": "Contains the new xy-axis chart using the elastic-charts library, which will eventually replace the vislib xy-axis charts including bar, area, and line.", "plugin": { "id": "visTypeXy", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "charts", "visualizations", @@ -22,4 +26,4 @@ "common" ] } -} +} \ No newline at end of file diff --git a/src/plugins/visualizations/kibana.jsonc b/src/plugins/visualizations/kibana.jsonc index 95a2999611bd4..8e8b492e99a0b 100644 --- a/src/plugins/visualizations/kibana.jsonc +++ b/src/plugins/visualizations/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/visualizations-plugin", - "owner": "@elastic/kibana-visualizations", + "owner": [ + "@elastic/kibana-visualizations" + ], + "group": "platform", + "visibility": "shared", "description": "Contains the shared architecture among all the legacy visualizations, e.g. the visualization type registry or the visualization embeddable.", "plugin": { "id": "visualizations", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "data", "charts", @@ -37,7 +41,12 @@ "noDataPage", "embeddableEnhanced" ], - "requiredBundles": ["kibanaUtils", "kibanaReact", "charts", "savedObjects"], + "requiredBundles": [ + "kibanaUtils", + "kibanaReact", + "charts", + "savedObjects" + ], "extraPublicDirs": [ "common/constants", "common/utils", @@ -45,4 +54,4 @@ "common/convert_to_lens" ] } -} +} \ No newline at end of file diff --git a/src/plugins/visualizations/public/legacy/embeddable/visualize_embeddable.tsx b/src/plugins/visualizations/public/legacy/embeddable/visualize_embeddable.tsx index 4f6bfa344a0a3..196753d73b28c 100644 --- a/src/plugins/visualizations/public/legacy/embeddable/visualize_embeddable.tsx +++ b/src/plugins/visualizations/public/legacy/embeddable/visualize_embeddable.tsx @@ -353,10 +353,6 @@ export class VisualizeEmbeddable ); return true; } - if (this.vis.type.suppressWarnings?.()) { - // if the vis type wishes to supress all warnings, return true so the default logic won't pick it up - return true; - } }); } diff --git a/src/plugins/visualizations/public/vis_types/base_vis_type.ts b/src/plugins/visualizations/public/vis_types/base_vis_type.ts index f3a88245008e3..e7519729eaa03 100644 --- a/src/plugins/visualizations/public/vis_types/base_vis_type.ts +++ b/src/plugins/visualizations/public/vis_types/base_vis_type.ts @@ -43,7 +43,6 @@ export class BaseVisType { public readonly disableCreate; public readonly disableEdit; public readonly requiresSearch; - public readonly suppressWarnings; public readonly hasPartialRows; public readonly hierarchicalData; public readonly setup; @@ -70,7 +69,6 @@ export class BaseVisType { this.icon = opts.icon; this.image = opts.image; this.order = opts.order ?? 0; - this.suppressWarnings = opts.suppressWarnings; this.visConfig = defaultsDeep({}, opts.visConfig, { defaults: {} }); this.editorConfig = defaultsDeep({}, opts.editorConfig, { collections: {} }); this.options = defaultsDeep({}, opts.options, defaultOptions); diff --git a/test/api_integration/apis/dashboards/create_dashboard/index.ts b/test/api_integration/apis/dashboards/create_dashboard/index.ts new file mode 100644 index 0000000000000..c9c2f63dd3b8c --- /dev/null +++ b/test/api_integration/apis/dashboards/create_dashboard/index.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", 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 { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, loadTestFile }: FtrProviderContext) { + const kibanaServer = getService('kibanaServer'); + describe('dashboards - create', () => { + before(async () => { + await kibanaServer.importExport.load( + 'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json' + ); + }); + + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.unload( + 'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json' + ); + }); + loadTestFile(require.resolve('./main')); + loadTestFile(require.resolve('./validation')); + }); +} diff --git a/test/api_integration/apis/dashboards/create_dashboard/main.ts b/test/api_integration/apis/dashboards/create_dashboard/main.ts new file mode 100644 index 0000000000000..3b8b71f827deb --- /dev/null +++ b/test/api_integration/apis/dashboards/create_dashboard/main.ts @@ -0,0 +1,216 @@ +/* + * Copyright 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 expect from '@kbn/expect'; +import { PUBLIC_API_PATH } from '@kbn/dashboard-plugin/server'; +import { DEFAULT_IGNORE_PARENT_SETTINGS } from '@kbn/controls-plugin/common'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + describe('main', () => { + it('sets top level default values', async () => { + const title = `foo-${Date.now()}-${Math.random()}`; + + const response = await supertest + .post(PUBLIC_API_PATH) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send({ + attributes: { + title, + }, + }); + + expect(response.status).to.be(200); + expect(response.body.item.attributes.kibanaSavedObjectMeta.searchSource).to.eql({}); + expect(response.body.item.attributes.panels).to.eql([]); + expect(response.body.item.attributes.timeRestore).to.be(false); + expect(response.body.item.attributes.options).to.eql({ + hidePanelTitles: false, + useMargins: true, + syncColors: true, + syncTooltips: true, + syncCursor: true, + }); + }); + + it('sets panels default values', async () => { + const title = `foo-${Date.now()}-${Math.random()}`; + + const response = await supertest + .post(PUBLIC_API_PATH) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send({ + attributes: { + title, + panels: [ + { + type: 'visualization', + gridData: { + x: 0, + y: 0, + w: 24, + h: 15, + }, + panelConfig: {}, + }, + ], + }, + }); + + expect(response.status).to.be(200); + expect(response.body.item.attributes.panels).to.be.an('array'); + // panel index is a random uuid when not provided + expect(response.body.item.attributes.panels[0].panelIndex).match(/^[0-9a-f-]{36}$/); + expect(response.body.item.attributes.panels[0].panelIndex).to.eql( + response.body.item.attributes.panels[0].gridData.i + ); + }); + + it('sets controls default values', async () => { + const title = `foo-${Date.now()}-${Math.random()}`; + + const response = await supertest + .post(PUBLIC_API_PATH) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send({ + attributes: { + title, + controlGroupInput: { + controls: [ + { + type: 'optionsListControl', + order: 0, + width: 'medium', + grow: true, + controlConfig: { + title: 'Origin City', + fieldName: 'OriginCityName', + dataViewId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + selectedOptions: [], + enhancements: {}, + }, + }, + ], + }, + }, + }); + + expect(response.status).to.be(200); + // generates a random saved object id + expect(response.body.item.id).match(/^[0-9a-f-]{36}$/); + // saved object stores controls panels as an object, but the API should return as an array + expect(response.body.item.attributes.controlGroupInput.controls).to.be.an('array'); + + expect(response.body.item.attributes.controlGroupInput.ignoreParentSettings).to.eql( + DEFAULT_IGNORE_PARENT_SETTINGS + ); + }); + + it('can create a dashboard with a specific id', async () => { + const title = `foo-${Date.now()}-${Math.random()}`; + const id = `bar-${Date.now()}-${Math.random()}`; + + const response = await supertest + .post(`${PUBLIC_API_PATH}/${id}`) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send({ + attributes: { title }, + }); + + expect(response.status).to.be(200); + expect(response.body.item.id).to.be(id); + }); + + it('creates a dashboard with references', async () => { + const title = `foo-${Date.now()}-${Math.random()}`; + + const response = await supertest + .post(PUBLIC_API_PATH) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send({ + attributes: { + title, + panels: [ + { + type: 'visualization', + gridData: { + x: 0, + y: 0, + w: 24, + h: 15, + i: 'bizz', + }, + panelConfig: {}, + panelIndex: 'bizz', + panelRefName: 'panel_bizz', + }, + ], + }, + references: [ + { + name: 'bizz:panel_bizz', + type: 'visualization', + id: 'my-saved-object', + }, + ], + }); + + expect(response.status).to.be(200); + expect(response.body.item.attributes.panels).to.be.an('array'); + }); + + // TODO Maybe move this test to x-pack/test/api_integration/dashboards + it('can create a dashboard in a defined space', async () => { + const title = `foo-${Date.now()}-${Math.random()}`; + + const spaceId = 'space-1'; + + const response = await supertest + .post(`/s/${spaceId}${PUBLIC_API_PATH}`) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send({ + attributes: { + title, + }, + spaces: [spaceId], + }); + + expect(response.status).to.be(200); + expect(response.body.item.namespaces).to.eql([spaceId]); + }); + + it('return error if provided id already exists', async () => { + const title = `foo-${Date.now()}-${Math.random()}`; + // id is a saved object loaded by the kbn_archiver + const id = 'be3733a0-9efe-11e7-acb3-3dab96693fab'; + + const response = await supertest + .post(`${PUBLIC_API_PATH}/${id}`) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send({ + attributes: { + title, + }, + }); + + expect(response.status).to.be(409); + expect(response.body.message).to.be( + 'A dashboard with saved object ID be3733a0-9efe-11e7-acb3-3dab96693fab already exists.' + ); + }); + }); +} diff --git a/test/api_integration/apis/dashboards/create_dashboard/validation.ts b/test/api_integration/apis/dashboards/create_dashboard/validation.ts new file mode 100644 index 0000000000000..c7f0917a7180c --- /dev/null +++ b/test/api_integration/apis/dashboards/create_dashboard/validation.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 expect from '@kbn/expect'; +import { PUBLIC_API_PATH } from '@kbn/dashboard-plugin/server'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + describe('validation', () => { + it('returns error when attributes object is not provided', async () => { + const response = await supertest + .post(PUBLIC_API_PATH) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send({}); + expect(response.status).to.be(400); + expect(response.body.statusCode).to.be(400); + expect(response.body.message).to.be( + '[request body.attributes.title]: expected value of type [string] but got [undefined]' + ); + }); + + it('returns error when title is not provided', async () => { + const response = await supertest + .post(PUBLIC_API_PATH) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send({ + attributes: {}, + }); + expect(response.status).to.be(400); + expect(response.body.statusCode).to.be(400); + expect(response.body.message).to.be( + '[request body.attributes.title]: expected value of type [string] but got [undefined]' + ); + }); + + it('returns error if panels is not an array', async () => { + const response = await supertest + .post(PUBLIC_API_PATH) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send({ + attributes: { + title: 'foo', + panels: {}, + }, + }); + expect(response.status).to.be(400); + expect(response.body.statusCode).to.be(400); + expect(response.body.message).to.be( + '[request body.attributes.panels]: expected value of type [array] but got [Object]' + ); + }); + }); +} diff --git a/test/api_integration/apis/dashboards/delete_dashboard/index.ts b/test/api_integration/apis/dashboards/delete_dashboard/index.ts new file mode 100644 index 0000000000000..41494dfd986d2 --- /dev/null +++ b/test/api_integration/apis/dashboards/delete_dashboard/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, loadTestFile }: FtrProviderContext) { + const kibanaServer = getService('kibanaServer'); + describe('dashboards - delete', () => { + before(async () => { + await kibanaServer.importExport.load( + 'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json' + ); + }); + + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.unload( + 'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json' + ); + }); + loadTestFile(require.resolve('./main')); + }); +} diff --git a/test/api_integration/apis/dashboards/delete_dashboard/main.ts b/test/api_integration/apis/dashboards/delete_dashboard/main.ts new file mode 100644 index 0000000000000..19ed2b2e1c051 --- /dev/null +++ b/test/api_integration/apis/dashboards/delete_dashboard/main.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 expect from '@kbn/expect'; +import { PUBLIC_API_PATH } from '@kbn/dashboard-plugin/server'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + describe('main', () => { + it('should return 404 for a non-existent dashboard', async () => { + const response = await supertest + .delete(`${PUBLIC_API_PATH}/non-existent-dashboard`) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send(); + + expect(response.status).to.be(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'A dashboard with saved object ID non-existent-dashboard was not found.', + }); + }); + + it('should return 200 if the dashboard is deleted', async () => { + const response = await supertest + .delete(`${PUBLIC_API_PATH}/be3733a0-9efe-11e7-acb3-3dab96693fab`) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send(); + + expect(response.status).to.be(200); + }); + }); +} diff --git a/test/api_integration/apis/dashboards/get_dashboard/index.ts b/test/api_integration/apis/dashboards/get_dashboard/index.ts new file mode 100644 index 0000000000000..82ac6f1903cb7 --- /dev/null +++ b/test/api_integration/apis/dashboards/get_dashboard/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, loadTestFile }: FtrProviderContext) { + const kibanaServer = getService('kibanaServer'); + describe('dashboards - get', () => { + before(async () => { + await kibanaServer.importExport.load( + 'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json' + ); + }); + + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.unload( + 'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json' + ); + }); + loadTestFile(require.resolve('./main')); + }); +} diff --git a/test/api_integration/apis/dashboards/get_dashboard/main.ts b/test/api_integration/apis/dashboards/get_dashboard/main.ts new file mode 100644 index 0000000000000..b6585c0c4f48a --- /dev/null +++ b/test/api_integration/apis/dashboards/get_dashboard/main.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 expect from '@kbn/expect'; +import { PUBLIC_API_PATH } from '@kbn/dashboard-plugin/server'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + describe('main', () => { + it('should return 200 with an existing dashboard', async () => { + const response = await supertest + .get(`${PUBLIC_API_PATH}/be3733a0-9efe-11e7-acb3-3dab96693fab`) + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send(); + + expect(response.status).to.be(200); + + expect(response.body.item.id).to.be('be3733a0-9efe-11e7-acb3-3dab96693fab'); + expect(response.body.item.type).to.be('dashboard'); + expect(response.body.item.attributes.title).to.be('Requests'); + + // Does not return unsupported options from the saved object + expect(response.body.item.attributes.options).to.not.have.keys(['darkTheme']); + expect(response.body.item.attributes.refreshInterval).to.not.have.keys(['display']); + }); + }); +} diff --git a/test/api_integration/apis/dashboards/index.ts b/test/api_integration/apis/dashboards/index.ts new file mode 100644 index 0000000000000..f844c02168922 --- /dev/null +++ b/test/api_integration/apis/dashboards/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('dashboards', () => { + loadTestFile(require.resolve('./create_dashboard')); + loadTestFile(require.resolve('./delete_dashboard')); + loadTestFile(require.resolve('./get_dashboard')); + loadTestFile(require.resolve('./update_dashboard')); + loadTestFile(require.resolve('./list_dashboards')); + }); +} diff --git a/test/api_integration/apis/dashboards/list_dashboards/index.ts b/test/api_integration/apis/dashboards/list_dashboards/index.ts new file mode 100644 index 0000000000000..10f77ad3fee5a --- /dev/null +++ b/test/api_integration/apis/dashboards/list_dashboards/index.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, loadTestFile }: FtrProviderContext) { + const kibanaServer = getService('kibanaServer'); + const supertest = getService('supertest'); + describe('dashboards - list', () => { + const createManyDashboards = async (count: number) => { + const fileChunks: string[] = []; + for (let i = 0; i < count; i++) { + const id = `test-dashboard-${i}`; + fileChunks.push( + JSON.stringify({ + type: 'dashboard', + id, + attributes: { + title: `My dashboard (${i})`, + kibanaSavedObjectMeta: { searchSourceJSON: '{}' }, + }, + references: [], + }) + ); + } + + await supertest + .post(`/api/saved_objects/_import`) + .attach('file', Buffer.from(fileChunks.join('\n'), 'utf8'), 'export.ndjson') + .expect(200); + }; + before(async () => { + await createManyDashboards(100); + }); + + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + }); + loadTestFile(require.resolve('./main')); + }); +} diff --git a/test/api_integration/apis/dashboards/list_dashboards/main.ts b/test/api_integration/apis/dashboards/list_dashboards/main.ts new file mode 100644 index 0000000000000..c0ef1059169ef --- /dev/null +++ b/test/api_integration/apis/dashboards/list_dashboards/main.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", 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 expect from '@kbn/expect'; +import { PUBLIC_API_PATH } from '@kbn/dashboard-plugin/server'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + describe('main', () => { + it('should retrieve a paginated list of dashboards', async () => { + const response = await supertest + .get(`${PUBLIC_API_PATH}`) + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send(); + + expect(response.status).to.be(200); + expect(response.body.total).to.be(100); + expect(response.body.items[0].id).to.be('test-dashboard-0'); + expect(response.body.items.length).to.be(20); + }); + + it('should allow users to set a per page limit', async () => { + const response = await supertest + .get(`${PUBLIC_API_PATH}?perPage=10`) + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send(); + + expect(response.status).to.be(200); + expect(response.body.total).to.be(100); + expect(response.body.items.length).to.be(10); + }); + + it('should allow users to paginate through the list of dashboards', async () => { + const response = await supertest + .get(`${PUBLIC_API_PATH}?page=5&perPage=10`) + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send(); + + expect(response.status).to.be(200); + expect(response.body.total).to.be(100); + expect(response.body.items.length).to.be(10); + expect(response.body.items[0].id).to.be('test-dashboard-40'); + }); + }); +} diff --git a/test/api_integration/apis/dashboards/update_dashboard/index.ts b/test/api_integration/apis/dashboards/update_dashboard/index.ts new file mode 100644 index 0000000000000..c2a8d7d16cb27 --- /dev/null +++ b/test/api_integration/apis/dashboards/update_dashboard/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, loadTestFile }: FtrProviderContext) { + const kibanaServer = getService('kibanaServer'); + describe('dashboards - update', () => { + before(async () => { + await kibanaServer.importExport.load( + 'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json' + ); + }); + + after(async () => { + await kibanaServer.importExport.unload( + 'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json' + ); + }); + loadTestFile(require.resolve('./main')); + loadTestFile(require.resolve('./validation')); + }); +} diff --git a/test/api_integration/apis/dashboards/update_dashboard/main.ts b/test/api_integration/apis/dashboards/update_dashboard/main.ts new file mode 100644 index 0000000000000..18a7d5ca2d3fe --- /dev/null +++ b/test/api_integration/apis/dashboards/update_dashboard/main.ts @@ -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 expect from '@kbn/expect'; +import { PUBLIC_API_PATH } from '@kbn/dashboard-plugin/server'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + describe('main', () => { + it('should return 201 with an updated dashboard', async () => { + const response = await supertest + .put(`${PUBLIC_API_PATH}/be3733a0-9efe-11e7-acb3-3dab96693fab`) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send({ + attributes: { + title: 'Refresh Requests (Updated)', + options: { useMargins: false }, + panels: [ + { + type: 'visualization', + gridData: { x: 0, y: 0, w: 48, h: 60, i: '1' }, + panelIndex: '1', + panelRefName: 'panel_1', + version: '7.3.0', + }, + ], + timeFrom: 'Wed Sep 16 2015 22:52:17 GMT-0700', + timeRestore: true, + timeTo: 'Fri Sep 18 2015 12:24:38 GMT-0700', + }, + references: [ + { + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + name: '1:panel_1', + type: 'visualization', + }, + ], + }); + + expect(response.status).to.be(201); + + expect(response.body.item.id).to.be('be3733a0-9efe-11e7-acb3-3dab96693fab'); + expect(response.body.item.type).to.be('dashboard'); + expect(response.body.item.attributes.title).to.be('Refresh Requests (Updated)'); + }); + + it('should return 404 when updating a non-existent dashboard', async () => { + const response = await supertest + .put(`${PUBLIC_API_PATH}/not-an-id`) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send({ + attributes: { + title: 'Some other dashboard (updated)', + }, + }); + + expect(response.status).to.be(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'A dashboard with saved object ID not-an-id was not found.', + }); + }); + }); +} diff --git a/test/api_integration/apis/dashboards/update_dashboard/validation.ts b/test/api_integration/apis/dashboards/update_dashboard/validation.ts new file mode 100644 index 0000000000000..4a7a069e24617 --- /dev/null +++ b/test/api_integration/apis/dashboards/update_dashboard/validation.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 expect from '@kbn/expect'; +import { PUBLIC_API_PATH } from '@kbn/dashboard-plugin/server'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + describe('validation', () => { + it('returns error when attributes object is not provided', async () => { + const response = await supertest + .put(`${PUBLIC_API_PATH}/be3733a0-9efe-11e7-acb3-3dab96693fab`) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send({}); + expect(response.status).to.be(400); + expect(response.body.statusCode).to.be(400); + expect(response.body.message).to.be( + '[request body.attributes.title]: expected value of type [string] but got [undefined]' + ); + }); + + it('returns error when title is not provided', async () => { + const response = await supertest + .put(`${PUBLIC_API_PATH}/be3733a0-9efe-11e7-acb3-3dab96693fab`) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send({ + attributes: {}, + }); + expect(response.status).to.be(400); + expect(response.body.statusCode).to.be(400); + expect(response.body.message).to.be( + '[request body.attributes.title]: expected value of type [string] but got [undefined]' + ); + }); + + it('returns error if panels is not an array', async () => { + const response = await supertest + .put(`${PUBLIC_API_PATH}/be3733a0-9efe-11e7-acb3-3dab96693fab`) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send({ + attributes: { + title: 'foo', + panels: {}, + }, + }); + expect(response.status).to.be(400); + expect(response.body.statusCode).to.be(400); + expect(response.body.message).to.be( + '[request body.attributes.panels]: expected value of type [array] but got [Object]' + ); + }); + }); +} diff --git a/test/api_integration/apis/index.ts b/test/api_integration/apis/index.ts index bbd7c3abf8649..af1cbf2464fa9 100644 --- a/test/api_integration/apis/index.ts +++ b/test/api_integration/apis/index.ts @@ -14,6 +14,7 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./console')); loadTestFile(require.resolve('./core')); loadTestFile(require.resolve('./custom_integration')); + loadTestFile(require.resolve('./dashboards')); loadTestFile(require.resolve('./general')); loadTestFile(require.resolve('./home')); loadTestFile(require.resolve('./data_view_field_editor')); diff --git a/test/common/plugins/otel_metrics/kibana.jsonc b/test/common/plugins/otel_metrics/kibana.jsonc index e64546f446052..dea9f97260c7a 100644 --- a/test/common/plugins/otel_metrics/kibana.jsonc +++ b/test/common/plugins/otel_metrics/kibana.jsonc @@ -2,6 +2,8 @@ "type": "plugin", "id": "@kbn/open-telemetry-instrumented-plugin", "owner": "@elastic/obs-ux-infra_services-team", + "group": "platform", + "visibility": "shared", "plugin": { "id": "openTelemetryInstrumentedPlugin", "server": true, diff --git a/test/examples/discover_customization_examples/customizations.ts b/test/examples/discover_customization_examples/customizations.ts index f9e29611dc0cc..38e8e8ab2a6c5 100644 --- a/test/examples/discover_customization_examples/customizations.ts +++ b/test/examples/discover_customization_examples/customizations.ts @@ -48,15 +48,9 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => { }); it('Top nav', async () => { - await testSubjects.existOrFail('customOptionsButton'); await testSubjects.existOrFail('shareTopNavButton'); - await testSubjects.existOrFail('documentExplorerButton'); await testSubjects.missingOrFail('discoverNewButton'); await testSubjects.missingOrFail('discoverOpenButton'); - await testSubjects.click('customOptionsButton'); - await testSubjects.existOrFail('customOptionsPopover'); - await testSubjects.click('customOptionsButton'); - await testSubjects.missingOrFail('customOptionsPopover'); }); it('Search bar', async () => { diff --git a/test/functional/apps/console/_misc_console_behavior.ts b/test/functional/apps/console/_misc_console_behavior.ts index c55df1072f128..0e56d871109cb 100644 --- a/test/functional/apps/console/_misc_console_behavior.ts +++ b/test/functional/apps/console/_misc_console_behavior.ts @@ -147,8 +147,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.console.toggleKeyboardShortcuts(true); }); - // Failing: See https://github.com/elastic/kibana/issues/193868 - describe.skip('customizable font size', () => { + describe('customizable font size', () => { it('should allow the font size to be customized', async () => { await PageObjects.console.openConfig(); await PageObjects.console.setFontSizeSetting(20); diff --git a/test/functional/apps/dashboard/group1/edit_visualizations.js b/test/functional/apps/dashboard/group1/edit_visualizations.js index f065748f09b00..9e91ed1ea112f 100644 --- a/test/functional/apps/dashboard/group1/edit_visualizations.js +++ b/test/functional/apps/dashboard/group1/edit_visualizations.js @@ -10,7 +10,7 @@ import expect from '@kbn/expect'; export default function ({ getService, getPageObjects }) { - const { dashboard, header, visualize, common, visEditor } = getPageObjects([ + const { dashboard, header, visualize, visEditor } = getPageObjects([ 'dashboard', 'header', 'visualize', @@ -114,7 +114,6 @@ export default function ({ getService, getPageObjects }) { await header.waitUntilLoadingHasFinished(); await appsMenu.clickLink('Visualize Library'); - await common.clickConfirmOnModal(); expect(await testSubjects.exists('visualizationLandingPage')).to.be(true); }); @@ -132,7 +131,6 @@ export default function ({ getService, getPageObjects }) { await header.waitUntilLoadingHasFinished(); await appsMenu.clickLink('Visualize Library'); - await common.clickConfirmOnModal(); expect(await testSubjects.exists('visualizationLandingPage')).to.be(true); }); diff --git a/test/functional/apps/dashboard/group3/dashboard_state.ts b/test/functional/apps/dashboard/group3/dashboard_state.ts index 40022c155f456..9822c2ce361a1 100644 --- a/test/functional/apps/dashboard/group3/dashboard_state.ts +++ b/test/functional/apps/dashboard/group3/dashboard_state.ts @@ -309,12 +309,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { panels: (appState.panels ?? []).map((panel) => { return { ...panel, - embeddableConfig: { - ...(panel.embeddableConfig ?? {}), + panelConfig: { + ...(panel.panelConfig ?? {}), vis: { - ...((panel.embeddableConfig?.vis as object) ?? {}), + ...((panel.panelConfig?.vis as object) ?? {}), colors: { - ...((panel.embeddableConfig?.vis as { colors: object })?.colors ?? {}), + ...((panel.panelConfig?.vis as { colors: object })?.colors ?? {}), ['80000']: 'FFFFFF', }, }, @@ -353,10 +353,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { panels: (appState.panels ?? []).map((panel) => { return { ...panel, - embeddableConfig: { - ...(panel.embeddableConfig ?? {}), + panelConfig: { + ...(panel.panelConfig ?? {}), vis: { - ...((panel.embeddableConfig?.vis as object) ?? {}), + ...((panel.panelConfig?.vis as object) ?? {}), colors: {}, }, }, diff --git a/test/functional/apps/discover/ccs_compatibility/_search_errors.ts b/test/functional/apps/discover/ccs_compatibility/_search_errors.ts index 7045e0e7d1a3b..96db6e2f7a347 100644 --- a/test/functional/apps/discover/ccs_compatibility/_search_errors.ts +++ b/test/functional/apps/discover/ccs_compatibility/_search_errors.ts @@ -65,7 +65,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // Ensure documents are still returned for the successful shards await retry.try(async function tryingForTime() { - const hitCount = await discover.getHitCount(); + const hitCount = await discover.getHitCount({ isPartial: true }); expect(hitCount).to.be('9,247'); }); diff --git a/test/functional/apps/discover/context_awareness/_data_source_profile.ts b/test/functional/apps/discover/context_awareness/_data_source_profile.ts index 35e3552afa655..eeffafa38cd4e 100644 --- a/test/functional/apps/discover/context_awareness/_data_source_profile.ts +++ b/test/functional/apps/discover/context_awareness/_data_source_profile.ts @@ -20,6 +20,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const dataViews = getService('dataViews'); const dataGrid = getService('dataGrid'); + const retry = getService('retry'); describe('data source profile', () => { describe('ES|QL mode', () => { @@ -98,6 +99,41 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await testSubjects.getVisibleText('docViewerRowDetailsTitle')).to.be('Record #0'); }); }); + + describe('custom context', () => { + it('should render formatted record in doc viewer using formatter from custom context', async () => { + const state = kbnRison.encode({ + dataSource: { type: 'esql' }, + query: { esql: 'from my-example-logs | sort @timestamp desc' }, + }); + await common.navigateToActualUrl('discover', `?_a=${state}`, { + ensureCurrentUrl: false, + }); + await discover.waitUntilSearchingHasFinished(); + await dataGrid.clickRowToggle({ rowIndex: 0, defaultTabId: 'doc_view_example' }); + await retry.try(async () => { + const formattedRecord = await testSubjects.find( + 'exampleDataSourceProfileDocViewRecord' + ); + expect(await formattedRecord.getVisibleText()).to.be( + JSON.stringify( + { + '@timestamp': '2024-06-10T16:00:00.000Z', + 'agent.name': 'java', + 'agent.name.text': 'java', + 'data_stream.type': 'logs', + 'log.level': 'debug', + message: 'This is a debug log', + 'service.name': 'product', + 'service.name.text': 'product', + }, + null, + 2 + ) + ); + }); + }); + }); }); describe('data view mode', () => { @@ -166,6 +202,41 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); }); }); + + describe('custom context', () => { + it('should render formatted record in doc viewer using formatter from custom context', async () => { + await common.navigateToActualUrl('discover', undefined, { + ensureCurrentUrl: false, + }); + await dataViews.switchTo('my-example-logs'); + await discover.waitUntilSearchingHasFinished(); + await dataGrid.clickRowToggle({ rowIndex: 0, defaultTabId: 'doc_view_example' }); + await retry.try(async () => { + const formattedRecord = await testSubjects.find( + 'exampleDataSourceProfileDocViewRecord' + ); + expect(await formattedRecord.getVisibleText()).to.be( + JSON.stringify( + { + '@timestamp': ['2024-06-10T16:00:00.000Z'], + 'agent.name': ['java'], + 'agent.name.text': ['java'], + 'data_stream.type': ['logs'], + 'log.level': ['debug'], + message: ['This is a debug log'], + 'service.name': ['product'], + 'service.name.text': ['product'], + _id: 'XdQFDpABfGznVC1bCHLo', + _index: 'my-example-logs', + _score: null, + }, + null, + 2 + ) + ); + }); + }); + }); }); }); } diff --git a/test/functional/apps/discover/context_awareness/config.ts b/test/functional/apps/discover/context_awareness/config.ts index 9261cef450adb..ded4755a61f92 100644 --- a/test/functional/apps/discover/context_awareness/config.ts +++ b/test/functional/apps/discover/context_awareness/config.ts @@ -25,7 +25,12 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...baseConfig.kbnTestServer, serverArgs: [ ...baseConfig.kbnTestServer.serverArgs, - '--discover.experimental.enabledProfiles=["example-root-profile","example-data-source-profile","example-document-profile"]', + `--discover.experimental.enabledProfiles=${JSON.stringify([ + 'example-root-profile', + 'example-solution-view-root-profile', + 'example-data-source-profile', + 'example-document-profile', + ])}`, `--plugin-path=${path.resolve( __dirname, '../../../../analytics/plugins/analytics_ftr_helpers' diff --git a/test/functional/apps/discover/context_awareness/extensions/_get_app_menu.ts b/test/functional/apps/discover/context_awareness/extensions/_get_app_menu.ts new file mode 100644 index 0000000000000..9b019a67d6507 --- /dev/null +++ b/test/functional/apps/discover/context_awareness/extensions/_get_app_menu.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", 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 kbnRison from '@kbn/rison'; +import type { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const { common, discover, header } = getPageObjects([ + 'common', + 'timePicker', + 'discover', + 'header', + ]); + const esArchiver = getService('esArchiver'); + const testSubjects = getService('testSubjects'); + + describe('extension getAppMenu', () => { + before(async () => { + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); + }); + + after(async () => { + await esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional'); + }); + + it('should render the main actions and the action from root profile', async () => { + const state = kbnRison.encode({ + dataSource: { type: 'esql' }, + query: { esql: 'from logstash* | sort @timestamp desc' }, + }); + await common.navigateToActualUrl('discover', `?_a=${state}`, { + ensureCurrentUrl: false, + }); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + await testSubjects.existOrFail('discoverNewButton'); + await testSubjects.existOrFail('discoverAlertsButton'); + await testSubjects.existOrFail('example-custom-root-submenu'); + }); + + it('should render custom actions', async () => { + const state = kbnRison.encode({ + dataSource: { type: 'esql' }, + query: { esql: 'from my-example-logs | sort @timestamp desc' }, + }); + await common.navigateToActualUrl('discover', `?_a=${state}`, { + ensureCurrentUrl: false, + }); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + await testSubjects.existOrFail('discoverNewButton'); + await testSubjects.existOrFail('discoverAlertsButton'); + await testSubjects.existOrFail('example-custom-root-submenu'); + await testSubjects.existOrFail('example-custom-action'); + + await testSubjects.click('example-custom-root-submenu'); + await testSubjects.existOrFail('example-custom-root-action12'); + + await testSubjects.click('example-custom-root-action12'); + await testSubjects.existOrFail('example-custom-root-action12-flyout'); + await testSubjects.click('euiFlyoutCloseButton'); + + await testSubjects.click('discoverAlertsButton'); + await testSubjects.existOrFail('example-custom-action-under-alerts'); + }); + }); +} diff --git a/test/functional/apps/discover/context_awareness/extensions/_get_render_app_wrapper.ts b/test/functional/apps/discover/context_awareness/extensions/_get_render_app_wrapper.ts new file mode 100644 index 0000000000000..b30d16c215044 --- /dev/null +++ b/test/functional/apps/discover/context_awareness/extensions/_get_render_app_wrapper.ts @@ -0,0 +1,171 @@ +/* + * Copyright 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 kbnRison from '@kbn/rison'; +import expect from '@kbn/expect'; +import type { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const { common, discover, header, unifiedFieldList, dashboard } = getPageObjects([ + 'common', + 'discover', + 'header', + 'unifiedFieldList', + 'dashboard', + ]); + const testSubjects = getService('testSubjects'); + const dataViews = getService('dataViews'); + const dataGrid = getService('dataGrid'); + const browser = getService('browser'); + const retry = getService('retry'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const kibanaServer = getService('kibanaServer'); + + describe('extension getRenderAppWrapper', () => { + after(async () => { + await kibanaServer.savedObjects.clean({ types: ['search'] }); + }); + + describe('ES|QL mode', () => { + it('should allow clicking message cells to inspect the message', async () => { + const state = kbnRison.encode({ + dataSource: { type: 'esql' }, + query: { esql: 'from my-example-logs | sort @timestamp desc' }, + }); + await common.navigateToActualUrl('discover', `?_a=${state}`, { + ensureCurrentUrl: false, + }); + await discover.waitUntilSearchingHasFinished(); + await unifiedFieldList.clickFieldListItemAdd('message'); + let messageCell = await dataGrid.getCellElementExcludingControlColumns(0, 2); + await retry.try(async () => { + await (await messageCell.findByTestSubject('exampleDataSourceProfileMessage')).click(); + await testSubjects.existOrFail('exampleRootProfileFlyout'); + }); + let message = await testSubjects.find('exampleRootProfileCurrentMessage'); + expect(await message.getVisibleText()).to.be('This is a debug log'); + messageCell = await dataGrid.getCellElementExcludingControlColumns(1, 2); + await retry.try(async () => { + await (await messageCell.findByTestSubject('exampleDataSourceProfileMessage')).click(); + await testSubjects.existOrFail('exampleRootProfileFlyout'); + message = await testSubjects.find('exampleRootProfileCurrentMessage'); + expect(await message.getVisibleText()).to.be('This is an error log'); + }); + await testSubjects.click('euiFlyoutCloseButton'); + await testSubjects.missingOrFail('exampleRootProfileFlyout'); + + // check Dashboard page + await discover.saveSearch('ES|QL app wrapper test'); + await dashboard.navigateToApp(); + await dashboard.gotoDashboardLandingPage(); + await dashboard.clickNewDashboard(); + await dashboardAddPanel.addSavedSearch('ES|QL app wrapper test'); + await header.waitUntilLoadingHasFinished(); + await dashboard.waitForRenderComplete(); + + messageCell = await dataGrid.getCellElementExcludingControlColumns(0, 2); + await retry.try(async () => { + await (await messageCell.findByTestSubject('exampleDataSourceProfileMessage')).click(); + await testSubjects.existOrFail('exampleRootProfileFlyout'); + }); + message = await testSubjects.find('exampleRootProfileCurrentMessage'); + expect(await message.getVisibleText()).to.be('This is a debug log'); + messageCell = await dataGrid.getCellElementExcludingControlColumns(1, 2); + await retry.try(async () => { + await (await messageCell.findByTestSubject('exampleDataSourceProfileMessage')).click(); + await testSubjects.existOrFail('exampleRootProfileFlyout'); + message = await testSubjects.find('exampleRootProfileCurrentMessage'); + expect(await message.getVisibleText()).to.be('This is an error log'); + }); + await testSubjects.click('euiFlyoutCloseButton'); + await testSubjects.missingOrFail('exampleRootProfileFlyout'); + }); + }); + + describe('data view mode', () => { + it('should allow clicking message cells to inspect the message', async () => { + await common.navigateToActualUrl('discover', undefined, { + ensureCurrentUrl: false, + }); + await dataViews.switchTo('my-example-logs'); + await discover.waitUntilSearchingHasFinished(); + await unifiedFieldList.clickFieldListItemAdd('message'); + let messageCell = await dataGrid.getCellElementExcludingControlColumns(0, 2); + await retry.try(async () => { + await (await messageCell.findByTestSubject('exampleDataSourceProfileMessage')).click(); + await testSubjects.existOrFail('exampleRootProfileFlyout'); + }); + let message = await testSubjects.find('exampleRootProfileCurrentMessage'); + expect(await message.getVisibleText()).to.be('This is a debug log'); + messageCell = await dataGrid.getCellElementExcludingControlColumns(1, 2); + await retry.try(async () => { + await (await messageCell.findByTestSubject('exampleDataSourceProfileMessage')).click(); + await testSubjects.existOrFail('exampleRootProfileFlyout'); + message = await testSubjects.find('exampleRootProfileCurrentMessage'); + expect(await message.getVisibleText()).to.be('This is an error log'); + }); + await testSubjects.click('euiFlyoutCloseButton'); + await testSubjects.missingOrFail('exampleRootProfileFlyout'); + + // check Surrounding docs page + await dataGrid.clickRowToggle(); + const [, surroundingActionEl] = await dataGrid.getRowActions(); + await surroundingActionEl.click(); + await header.waitUntilLoadingHasFinished(); + await browser.refresh(); + await header.waitUntilLoadingHasFinished(); + + messageCell = await dataGrid.getCellElementExcludingControlColumns(0, 2); + await retry.try(async () => { + await (await messageCell.findByTestSubject('exampleDataSourceProfileMessage')).click(); + await testSubjects.existOrFail('exampleRootProfileFlyout'); + }); + message = await testSubjects.find('exampleRootProfileCurrentMessage'); + expect(await message.getVisibleText()).to.be('This is a debug log'); + messageCell = await dataGrid.getCellElementExcludingControlColumns(1, 2); + await retry.try(async () => { + await (await messageCell.findByTestSubject('exampleDataSourceProfileMessage')).click(); + await testSubjects.existOrFail('exampleRootProfileFlyout'); + message = await testSubjects.find('exampleRootProfileCurrentMessage'); + expect(await message.getVisibleText()).to.be('This is an error log'); + }); + await testSubjects.click('euiFlyoutCloseButton'); + await testSubjects.missingOrFail('exampleRootProfileFlyout'); + await browser.goBack(); + await discover.waitUntilSearchingHasFinished(); + + // check Dashboard page + await discover.saveSearch('Data view app wrapper test'); + await dashboard.navigateToApp(); + await dashboard.gotoDashboardLandingPage(); + await dashboard.clickNewDashboard(); + await dashboardAddPanel.addSavedSearch('Data view app wrapper test'); + await header.waitUntilLoadingHasFinished(); + await dashboard.waitForRenderComplete(); + + messageCell = await dataGrid.getCellElementExcludingControlColumns(0, 2); + await retry.try(async () => { + await (await messageCell.findByTestSubject('exampleDataSourceProfileMessage')).click(); + await testSubjects.existOrFail('exampleRootProfileFlyout'); + }); + message = await testSubjects.find('exampleRootProfileCurrentMessage'); + expect(await message.getVisibleText()).to.be('This is a debug log'); + messageCell = await dataGrid.getCellElementExcludingControlColumns(1, 2); + await retry.try(async () => { + await (await messageCell.findByTestSubject('exampleDataSourceProfileMessage')).click(); + await testSubjects.existOrFail('exampleRootProfileFlyout'); + message = await testSubjects.find('exampleRootProfileCurrentMessage'); + expect(await message.getVisibleText()).to.be('This is an error log'); + }); + await testSubjects.click('euiFlyoutCloseButton'); + await testSubjects.missingOrFail('exampleRootProfileFlyout'); + }); + }); + }); +} diff --git a/test/functional/apps/discover/context_awareness/index.ts b/test/functional/apps/discover/context_awareness/index.ts index f937f38c741f9..0edf18b7e9027 100644 --- a/test/functional/apps/discover/context_awareness/index.ts +++ b/test/functional/apps/discover/context_awareness/index.ts @@ -45,5 +45,7 @@ export default function ({ getService, getPageObjects, loadTestFile }: FtrProvid loadTestFile(require.resolve('./extensions/_get_cell_renderers')); loadTestFile(require.resolve('./extensions/_get_default_app_state')); loadTestFile(require.resolve('./extensions/_get_additional_cell_actions')); + loadTestFile(require.resolve('./extensions/_get_app_menu')); + loadTestFile(require.resolve('./extensions/_get_render_app_wrapper')); }); } diff --git a/test/functional/apps/discover/esql/_esql_view.ts b/test/functional/apps/discover/esql/_esql_view.ts index 31e89ac42f3ea..272de320d2051 100644 --- a/test/functional/apps/discover/esql/_esql_view.ts +++ b/test/functional/apps/discover/esql/_esql_view.ts @@ -25,14 +25,17 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const find = getService('find'); const esql = getService('esql'); const dashboardAddPanel = getService('dashboardAddPanel'); - const { common, discover, dashboard, header, timePicker, unifiedFieldList } = getPageObjects([ - 'common', - 'discover', - 'dashboard', - 'header', - 'timePicker', - 'unifiedFieldList', - ]); + const dataViews = getService('dataViews'); + const { common, discover, dashboard, header, timePicker, unifiedFieldList, unifiedSearch } = + getPageObjects([ + 'common', + 'discover', + 'dashboard', + 'header', + 'timePicker', + 'unifiedFieldList', + 'unifiedSearch', + ]); const defaultSettings = { defaultIndex: 'logstash-*', @@ -305,6 +308,24 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.existOrFail('discover-esql-to-dataview-modal'); }); }); + + it('should show available data views after switching to classic mode', async () => { + await discover.selectTextBaseLang(); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + + await browser.refresh(); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + await unifiedSearch.switchToDataViewMode(); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + const availableDataViews = await unifiedSearch.getDataViewList( + 'discover-dataView-switch-link' + ); + expect(availableDataViews).to.eql(['kibana_sample_data_flights', 'logstash-*']); + await dataViews.switchToAndValidate('kibana_sample_data_flights'); + }); }); describe('inspector', () => { @@ -676,6 +697,94 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { `from logstash-* | sort @timestamp desc | limit 10000 | stats countB = count(bytes) by geo.dest | sort countB | where countB > 0\nAND \`geo.dest\`=="BT"` ); }); + + it('should append a where clause by clicking the table without changing the chart type', async () => { + await discover.selectTextBaseLang(); + const testQuery = `from logstash-* | sort @timestamp desc | limit 10000 | stats countB = count(bytes) by geo.dest | sort countB`; + await monacoEditor.setCodeEditorValue(testQuery); + + await testSubjects.click('querySubmitButton'); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + await unifiedFieldList.waitUntilSidebarHasLoaded(); + + // change the type to line + await testSubjects.click('unifiedHistogramEditFlyoutVisualization'); + await header.waitUntilLoadingHasFinished(); + await testSubjects.click('lnsChartSwitchPopover'); + await testSubjects.click('lnsChartSwitchPopover_line'); + await header.waitUntilLoadingHasFinished(); + await testSubjects.click('applyFlyoutButton'); + + await dataGrid.clickCellFilterForButtonExcludingControlColumns(0, 1); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + await unifiedFieldList.waitUntilSidebarHasLoaded(); + + const editorValue = await monacoEditor.getCodeEditorValue(); + expect(editorValue).to.eql( + `from logstash-* | sort @timestamp desc | limit 10000 | stats countB = count(bytes) by geo.dest | sort countB\n| WHERE \`geo.dest\`=="BT"` + ); + + // check that the type is still line + await testSubjects.click('unifiedHistogramEditFlyoutVisualization'); + await header.waitUntilLoadingHasFinished(); + const chartSwitcher = await testSubjects.find('lnsChartSwitchPopover'); + const type = await chartSwitcher.getVisibleText(); + expect(type).to.be('Line'); + }); + + it('should append a where clause by clicking the table without changing the chart type nor the visualization state', async () => { + await discover.selectTextBaseLang(); + const testQuery = `from logstash-* | sort @timestamp desc | limit 10000 | stats countB = count(bytes) by geo.dest | sort countB`; + await monacoEditor.setCodeEditorValue(testQuery); + + await testSubjects.click('querySubmitButton'); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + await unifiedFieldList.waitUntilSidebarHasLoaded(); + + // change the type to line + await testSubjects.click('unifiedHistogramEditFlyoutVisualization'); + await header.waitUntilLoadingHasFinished(); + await testSubjects.click('lnsChartSwitchPopover'); + await testSubjects.click('lnsChartSwitchPopover_line'); + + // change the color to red + await testSubjects.click('lnsXY_yDimensionPanel'); + const colorPickerInput = await testSubjects.find('~indexPattern-dimension-colorPicker'); + await colorPickerInput.clearValueWithKeyboard(); + await colorPickerInput.type('#ff0000'); + await common.sleep(1000); // give time for debounced components to rerender + + await header.waitUntilLoadingHasFinished(); + await testSubjects.click('lns-indexPattern-dimensionContainerClose'); + await testSubjects.click('applyFlyoutButton'); + + await dataGrid.clickCellFilterForButtonExcludingControlColumns(0, 1); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + await unifiedFieldList.waitUntilSidebarHasLoaded(); + + const editorValue = await monacoEditor.getCodeEditorValue(); + expect(editorValue).to.eql( + `from logstash-* | sort @timestamp desc | limit 10000 | stats countB = count(bytes) by geo.dest | sort countB\n| WHERE \`geo.dest\`=="BT"` + ); + + // check that the type is still line + await testSubjects.click('unifiedHistogramEditFlyoutVisualization'); + await header.waitUntilLoadingHasFinished(); + const chartSwitcher = await testSubjects.find('lnsChartSwitchPopover'); + const type = await chartSwitcher.getVisibleText(); + expect(type).to.be('Line'); + + // check that the color is still red + await testSubjects.click('lnsXY_yDimensionPanel'); + const colorPickerInputAfterFilter = await testSubjects.find( + '~indexPattern-dimension-colorPicker' + ); + expect(await colorPickerInputAfterFilter.getAttribute('value')).to.be('#FF0000'); + }); }); describe('histogram breakdown', () => { diff --git a/test/functional/apps/discover/group2_data_grid3/_data_grid_column_widths.ts b/test/functional/apps/discover/group2_data_grid3/_data_grid_column_widths.ts index ba6f64d929fb1..a56248d2174c3 100644 --- a/test/functional/apps/discover/group2_data_grid3/_data_grid_column_widths.ts +++ b/test/functional/apps/discover/group2_data_grid3/_data_grid_column_widths.ts @@ -56,11 +56,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should not show reset width button for auto width column', async () => { await unifiedFieldList.clickFieldListItemAdd('@message'); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); expect(await dataGrid.resetColumnWidthExists('@message')).to.be(false); }); it('should show reset width button for absolute width column, and allow resetting to auto width', async () => { await unifiedFieldList.clickFieldListItemAdd('@message'); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); await testResizeColumn('@message'); }); diff --git a/test/functional/apps/discover/group3/_lens_vis.ts b/test/functional/apps/discover/group3/_lens_vis.ts index 5e13c8bbb243c..03641ee5bcb41 100644 --- a/test/functional/apps/discover/group3/_lens_vis.ts +++ b/test/functional/apps/discover/group3/_lens_vis.ts @@ -110,7 +110,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { return seriesType; } - describe('discover lens vis', function () { + // FLAKY: https://github.com/elastic/kibana/issues/184600 + describe.skip('discover lens vis', function () { before(async () => { await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader']); await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); @@ -288,7 +289,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await header.waitUntilLoadingHasFinished(); await discover.waitUntilSearchingHasFinished(); - expect(await getCurrentVisTitle()).to.be('Bar'); + // Line has been retained although the query changed! + expect(await getCurrentVisTitle()).to.be('Line'); await checkESQLHistogramVis(defaultTimespanESQL, '100'); @@ -567,15 +569,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.existOrFail('partitionVisChart'); expect(await discover.getVisContextSuggestionType()).to.be('lensSuggestion'); - await monacoEditor.setCodeEditorValue( - 'from logstash-* | stats averageB = avg(bytes) by extension.raw' - ); + // reset to histogram + await monacoEditor.setCodeEditorValue('from logstash-*'); await testSubjects.click('querySubmitButton'); await header.waitUntilLoadingHasFinished(); await discover.waitUntilSearchingHasFinished(); expect(await getCurrentVisTitle()).to.be('Bar'); - expect(await discover.getVisContextSuggestionType()).to.be('lensSuggestion'); + expect(await discover.getVisContextSuggestionType()).to.be('histogramForESQL'); await testSubjects.existOrFail('unsavedChangesBadge'); diff --git a/test/functional/apps/discover/group6/_sidebar_field_stats.ts b/test/functional/apps/discover/group6/_sidebar_field_stats.ts index 3cfa2c1da20af..d9fb2798c0f2c 100644 --- a/test/functional/apps/discover/group6/_sidebar_field_stats.ts +++ b/test/functional/apps/discover/group6/_sidebar_field_stats.ts @@ -155,77 +155,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await unifiedFieldList.waitUntilSidebarHasLoaded(); }); - it('should show top values popover for numeric field', async () => { + it('should not show top values popover for numeric field', async () => { await unifiedFieldList.clickFieldListItem('bytes'); - await testSubjects.existOrFail('dscFieldStats-topValues'); - expect(await testSubjects.getVisibleText('dscFieldStats-title')).to.be('Top values'); - const topValuesRows = await testSubjects.findAll('dscFieldStats-topValues-bucket'); - expect(topValuesRows.length).to.eql(10); - expect(await testSubjects.getVisibleText('dscFieldStats-statsFooter')).to.contain( - '42 sample values' - ); - - await unifiedFieldList.clickFieldListPlusFilter('bytes', '0'); - const editorValue = await monacoEditor.getCodeEditorValue(); - expect(editorValue).to.eql( - `from logstash-* [METADATA _index, _id] | sort @timestamp desc | limit 500\n| WHERE \`bytes\`==0` - ); + await testSubjects.missingOrFail('dscFieldStats-statsFooter'); await unifiedFieldList.closeFieldPopover(); }); - it('should show a top values popover for a keyword field', async () => { + it('should not show a top values popover for a keyword field', async () => { await unifiedFieldList.clickFieldListItem('extension.raw'); - await testSubjects.existOrFail('dscFieldStats-topValues'); - expect(await testSubjects.getVisibleText('dscFieldStats-title')).to.be('Top values'); - const topValuesRows = await testSubjects.findAll('dscFieldStats-topValues-bucket'); - expect(topValuesRows.length).to.eql(5); - await testSubjects.missingOrFail('unifiedFieldStats-buttonGroup'); - await testSubjects.missingOrFail('unifiedFieldStats-histogram'); - expect(await testSubjects.getVisibleText('dscFieldStats-statsFooter')).to.contain( - '500 sample values' - ); - - await unifiedFieldList.clickFieldListPlusFilter('extension.raw', 'css'); - const editorValue = await monacoEditor.getCodeEditorValue(); - expect(editorValue).to.eql( - `from logstash-* [METADATA _index, _id] | sort @timestamp desc | limit 500\n| WHERE \`extension.raw\`=="css"` - ); - - await unifiedFieldList.closeFieldPopover(); - }); - - it('should show a top values popover for an ip field', async () => { - await unifiedFieldList.clickFieldListItem('clientip'); - await testSubjects.existOrFail('dscFieldStats-topValues'); - expect(await testSubjects.getVisibleText('dscFieldStats-title')).to.be('Top values'); - const topValuesRows = await testSubjects.findAll('dscFieldStats-topValues-bucket'); - expect(topValuesRows.length).to.eql(10); - await testSubjects.missingOrFail('unifiedFieldStats-buttonGroup'); - await testSubjects.missingOrFail('unifiedFieldStats-histogram'); - expect(await testSubjects.getVisibleText('dscFieldStats-statsFooter')).to.contain( - '32 sample values' - ); - - await unifiedFieldList.clickFieldListPlusFilter('clientip', '216.126.255.31'); - const editorValue = await monacoEditor.getCodeEditorValue(); - expect(editorValue).to.eql( - `from logstash-* [METADATA _index, _id] | sort @timestamp desc | limit 500\n| WHERE \`clientip\`::string=="216.126.255.31"` - ); - - await unifiedFieldList.closeFieldPopover(); - }); - - it('should show a top values popover for _index field', async () => { - await unifiedFieldList.clickFieldListItem('_index'); - await testSubjects.existOrFail('dscFieldStats-topValues'); - expect(await testSubjects.getVisibleText('dscFieldStats-title')).to.be('Top values'); - const topValuesRows = await testSubjects.findAll('dscFieldStats-topValues-bucket'); - expect(topValuesRows.length).to.eql(1); - await testSubjects.missingOrFail('unifiedFieldStats-buttonGroup'); - await testSubjects.missingOrFail('unifiedFieldStats-histogram'); - expect(await testSubjects.getVisibleText('dscFieldStats-statsFooter')).to.contain( - '500 sample values' - ); + await testSubjects.missingOrFail('dscFieldStats-statsFooter'); await unifiedFieldList.closeFieldPopover(); }); @@ -240,102 +178,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await unifiedFieldList.closeFieldPopover(); }); - it('should show examples for geo points field', async () => { - await unifiedFieldList.clickFieldListItem('geo.coordinates'); - await testSubjects.existOrFail('dscFieldStats-topValues'); - expect(await testSubjects.getVisibleText('dscFieldStats-title')).to.be('Examples'); - const topValuesRows = await testSubjects.findAll('dscFieldStats-topValues-bucket'); - expect(topValuesRows.length).to.eql(11); - await testSubjects.missingOrFail('unifiedFieldStats-buttonGroup'); - await testSubjects.missingOrFail('unifiedFieldStats-histogram'); - expect(await testSubjects.getVisibleText('dscFieldStats-statsFooter')).to.contain( - '100 sample records' - ); - await unifiedFieldList.closeFieldPopover(); - }); - - it('should show examples for text field', async () => { + it('should not show examples for text field', async () => { await unifiedFieldList.clickFieldListItem('extension'); - await testSubjects.existOrFail('dscFieldStats-topValues'); - expect(await testSubjects.getVisibleText('dscFieldStats-title')).to.be('Examples'); - const topValuesRows = await testSubjects.findAll('dscFieldStats-topValues-bucket'); - expect(topValuesRows.length).to.eql(5); - await testSubjects.missingOrFail('unifiedFieldStats-buttonGroup'); - await testSubjects.missingOrFail('unifiedFieldStats-histogram'); - expect(await testSubjects.getVisibleText('dscFieldStats-statsFooter')).to.contain( - '100 sample records' - ); - - await unifiedFieldList.clickFieldListPlusFilter('extension', 'css'); - const editorValue = await monacoEditor.getCodeEditorValue(); - expect(editorValue).to.eql( - `from logstash-* [METADATA _index, _id] | sort @timestamp desc | limit 500\n| WHERE \`extension\`=="css"` - ); - - await unifiedFieldList.closeFieldPopover(); - }); - - it('should show examples for _id field', async () => { - await unifiedFieldList.clickFieldListItem('_id'); - await testSubjects.existOrFail('dscFieldStats-topValues'); - expect(await testSubjects.getVisibleText('dscFieldStats-title')).to.be('Examples'); - const topValuesRows = await testSubjects.findAll('dscFieldStats-topValues-bucket'); - expect(topValuesRows.length).to.eql(11); - await testSubjects.missingOrFail('unifiedFieldStats-buttonGroup'); - await testSubjects.missingOrFail('unifiedFieldStats-histogram'); - expect(await testSubjects.getVisibleText('dscFieldStats-statsFooter')).to.contain( - '100 sample records' - ); - await unifiedFieldList.closeFieldPopover(); - }); - - it('should show a top values popover for a more complex query', async () => { - const testQuery = `from logstash-* | sort @timestamp desc | limit 50 | stats avg(bytes) by geo.dest | limit 3`; - await monacoEditor.setCodeEditorValue(testQuery); - await testSubjects.click('querySubmitButton'); - await header.waitUntilLoadingHasFinished(); - await unifiedFieldList.waitUntilSidebarHasLoaded(); - - await unifiedFieldList.clickFieldListItem('avg(bytes)'); - await testSubjects.existOrFail('dscFieldStats-topValues'); - expect(await testSubjects.getVisibleText('dscFieldStats-title')).to.be('Top values'); - const topValuesRows = await testSubjects.findAll('dscFieldStats-topValues-bucket'); - expect(topValuesRows.length).to.eql(3); - expect(await testSubjects.getVisibleText('dscFieldStats-statsFooter')).to.contain( - '3 sample values' - ); - - await unifiedFieldList.clickFieldListPlusFilter('avg(bytes)', '5453'); - const editorValue = await monacoEditor.getCodeEditorValue(); - expect(editorValue).to.eql( - `from logstash-* | sort @timestamp desc | limit 50 | stats avg(bytes) by geo.dest | limit 3\n| WHERE \`avg(bytes)\`==5453` - ); - - await unifiedFieldList.closeFieldPopover(); - }); - - it('should show a top values popover for a boolean field', async () => { - const testQuery = `row enabled = true`; - await monacoEditor.setCodeEditorValue(testQuery); - await testSubjects.click('querySubmitButton'); - await header.waitUntilLoadingHasFinished(); - await unifiedFieldList.waitUntilSidebarHasLoaded(); - - await unifiedFieldList.clickFieldListItem('enabled'); - await testSubjects.existOrFail('dscFieldStats-topValues'); - expect(await testSubjects.getVisibleText('dscFieldStats-title')).to.be('Top values'); - const topValuesRows = await testSubjects.findAll('dscFieldStats-topValues-bucket'); - expect(topValuesRows.length).to.eql(1); - expect(await unifiedFieldList.getFieldStatsTopValueBucketsVisibleText()).to.be( - 'true\n100%' - ); - expect(await testSubjects.getVisibleText('dscFieldStats-statsFooter')).to.contain( - '1 sample value' - ); - - await unifiedFieldList.clickFieldListMinusFilter('enabled', 'true'); - const editorValue = await monacoEditor.getCodeEditorValue(); - expect(editorValue).to.eql(`row enabled = true\n| WHERE \`enabled\`!=true`); + await testSubjects.missingOrFail('dscFieldStats-statsFooter'); await unifiedFieldList.closeFieldPopover(); }); }); diff --git a/test/functional/page_objects/console_page.ts b/test/functional/page_objects/console_page.ts index 29b88787e7ec2..71a4d05aecdb0 100644 --- a/test/functional/page_objects/console_page.ts +++ b/test/functional/page_objects/console_page.ts @@ -9,6 +9,7 @@ import { Key } from 'selenium-webdriver'; import { asyncForEach } from '@kbn/std'; +import expect from '@kbn/expect'; import { FtrService } from '../ftr_provider_context'; export class ConsolePageObject extends FtrService { @@ -368,10 +369,12 @@ export class ConsolePageObject extends FtrService { public async setFontSizeSetting(newSize: number) { // while the settings form opens/loads this may fail, so retry for a while await this.retry.try(async () => { + const newSizeString = String(newSize); const fontSizeInput = await this.testSubjects.find('setting-font-size-input'); await fontSizeInput.clearValue({ withJS: true }); await fontSizeInput.click(); - await fontSizeInput.type(String(newSize)); + await fontSizeInput.type(newSizeString); + expect(await fontSizeInput.getAttribute('value')).to.be(newSizeString); }); } diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts index ab6356075fd81..e8a0de7fbc340 100644 --- a/test/functional/page_objects/discover_page.ts +++ b/test/functional/page_objects/discover_page.ts @@ -164,6 +164,7 @@ export class DiscoverPageObject extends FtrService { public async clickNewSearchButton() { await this.testSubjects.click('discoverNewButton'); + await this.testSubjects.moveMouseTo('unifiedFieldListSidebar__toggle-collapse'); // cancel tooltips await this.header.waitUntilLoadingHasFinished(); } @@ -338,9 +339,11 @@ export class DiscoverPageObject extends FtrService { return await this.header.waitUntilLoadingHasFinished(); } - public async getHitCount() { + public async getHitCount({ isPartial }: { isPartial?: boolean } = {}) { await this.header.waitUntilLoadingHasFinished(); - return await this.testSubjects.getVisibleText('discoverQueryHits'); + return await this.testSubjects.getVisibleText( + isPartial ? 'discoverQueryHitsPartial' : 'discoverQueryHits' + ); } public async getHitCountInt() { diff --git a/test/functional/page_objects/home_page.ts b/test/functional/page_objects/home_page.ts index 31a162a8800d6..4bdf99a9b7b35 100644 --- a/test/functional/page_objects/home_page.ts +++ b/test/functional/page_objects/home_page.ts @@ -91,40 +91,46 @@ export class HomePageObject extends FtrService { async addSampleDataSet(id: string) { await this.openSampleDataAccordion(); - const isInstalled = await this.isSampleDataSetInstalled(id); - if (!isInstalled) { + await this.retry.waitFor('sample data to be installed', async () => { + // count for the edge case where some how installation completes just before the retry occurs + if (await this.isSampleDataSetInstalled(id)) { + return true; + } + this.log.debug(`Attempting to add sample data: ${id}`); - await this.retry.waitFor('sample data to be installed', async () => { - // Echoing the adjustments made to 'removeSampleDataSet', as we are seeing flaky test cases here as well - // https://github.com/elastic/kibana/issues/52714 - await this.testSubjects.waitForEnabled(`addSampleDataSet${id}`); - await this.common.sleep(1010); - await this.testSubjects.click(`addSampleDataSet${id}`); - await this.common.sleep(1010); - await this._waitForSampleDataLoadingAction(id); - return await this.isSampleDataSetInstalled(id); - }); - } + + // Echoing the adjustments made to 'removeSampleDataSet', as we are seeing flaky test cases here as well + // https://github.com/elastic/kibana/issues/52714 + await this.testSubjects.waitForEnabled(`addSampleDataSet${id}`); + await this.common.sleep(1010); + await this.testSubjects.click(`addSampleDataSet${id}`); + await this.common.sleep(1010); + await this._waitForSampleDataLoadingAction(id); + return await this.isSampleDataSetInstalled(id); + }); } async removeSampleDataSet(id: string) { await this.openSampleDataAccordion(); - const isInstalled = await this.isSampleDataSetInstalled(id); - if (isInstalled) { + await this.retry.waitFor('sample data to be removed', async () => { + // account for the edge case where some how data is uninstalled just before the retry occurs + if (!(await this.isSampleDataSetInstalled(id))) { + return true; + } + this.log.debug(`Attempting to remove sample data: ${id}`); - await this.retry.waitFor('sample data to be removed', async () => { - // looks like overkill but we're hitting flaky cases where we click but it doesn't remove - await this.testSubjects.waitForEnabled(`removeSampleDataSet${id}`); - // https://github.com/elastic/kibana/issues/65949 - // Even after waiting for the "Remove" button to be enabled we still have failures - // where it appears the click just didn't work. - await this.common.sleep(1010); - await this.testSubjects.click(`removeSampleDataSet${id}`); - await this.common.sleep(1010); - await this._waitForSampleDataLoadingAction(id); - return !(await this.isSampleDataSetInstalled(id)); - }); - } + + // looks like overkill but we're hitting flaky cases where we click but it doesn't remove + await this.testSubjects.waitForEnabled(`removeSampleDataSet${id}`); + // https://github.com/elastic/kibana/issues/65949 + // Even after waiting for the "Remove" button to be enabled we still have failures + // where it appears the click just didn't work. + await this.common.sleep(1010); + await this.testSubjects.click(`removeSampleDataSet${id}`); + await this.common.sleep(1010); + await this._waitForSampleDataLoadingAction(id); + return !(await this.isSampleDataSetInstalled(id)); + }); } // loading action is either uninstall and install diff --git a/test/functional/page_objects/unified_search_page.ts b/test/functional/page_objects/unified_search_page.ts index 0ccfb388f1f15..4c9c9e5f4976b 100644 --- a/test/functional/page_objects/unified_search_page.ts +++ b/test/functional/page_objects/unified_search_page.ts @@ -27,6 +27,28 @@ export class UnifiedSearchPageObject extends FtrService { ); } + public async getDataViewList(switchButtonSelector: string) { + await this.testSubjects.click(switchButtonSelector); + + await this.retry.waitFor( + 'wait for popover', + async () => await this.testSubjects.exists('indexPattern-switcher') + ); + + const indexPatternSwitcher = await this.testSubjects.find('indexPattern-switcher', 500); + const availableDataViews = await Promise.all( + ( + await indexPatternSwitcher.findAllByCssSelector('.euiSelectableListItem') + ).map(async (item) => { + return await item.getAttribute('title'); + }) + ); + + await this.testSubjects.click(switchButtonSelector); + + return availableDataViews; + } + public async getSelectedDataView(switchButtonSelector: string) { let visibleText = ''; @@ -46,6 +68,12 @@ export class UnifiedSearchPageObject extends FtrService { public async switchToDataViewMode() { await this.testSubjects.click('switch-to-dataviews'); + await this.retry.waitFor('the modal to open', async () => { + return await this.testSubjects.exists('discover-esql-to-dataview-modal'); + }); await this.testSubjects.click('discover-esql-to-dataview-no-save-btn'); + await this.retry.waitFor('the modal to close', async () => { + return !(await this.testSubjects.exists('discover-esql-to-dataview-modal')); + }); } } diff --git a/test/functional/services/data_grid.ts b/test/functional/services/data_grid.ts index 07a723857088c..f56b58cfa88f1 100644 --- a/test/functional/services/data_grid.ts +++ b/test/functional/services/data_grid.ts @@ -102,9 +102,38 @@ export class DataGridService extends FtrService { public async resizeColumn(field: string, delta: number) { const header = await this.getHeaderElement(field); const originalWidth = (await header.getSize()).width; - const resizer = await header.findByCssSelector( - this.testSubjects.getCssSelector('dataGridColumnResizer') - ); + + let resizer: WebElementWrapper | undefined; + + if (await this.testSubjects.exists('euiDataGridHeaderDroppable')) { + // if drag & drop is enabled for data grid columns + const headerDraggableColumns = await this.find.allByCssSelector( + '[data-test-subj="euiDataGridHeaderDroppable"] > div' + ); + // searching for a common parent of the field column header and its resizer + const fieldHeader: WebElementWrapper | null | undefined = ( + await Promise.all( + headerDraggableColumns.map(async (column) => { + const hasFieldColumn = + (await column.findAllByCssSelector(`[data-gridcell-column-id="${field}"]`)).length > + 0; + return hasFieldColumn ? column : null; + }) + ) + ).find(Boolean); + + resizer = await fieldHeader?.findByTestSubject('dataGridColumnResizer'); + } else { + // if drag & drop is not enabled for data grid columns + resizer = await header.findByCssSelector( + this.testSubjects.getCssSelector('dataGridColumnResizer') + ); + } + + if (!resizer) { + throw new Error(`Unable to find column resizer for field ${field}`); + } + await this.browser.dragAndDrop({ location: resizer }, { location: { x: delta, y: 0 } }); return { originalWidth, newWidth: (await header.getSize()).width }; } diff --git a/test/plugin_functional/test_suites/core_plugins/rendering.ts b/test/plugin_functional/test_suites/core_plugins/rendering.ts index 6a863a78cff15..83ef8629a6efc 100644 --- a/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -362,6 +362,9 @@ export default function ({ getService }: PluginFunctionalProviderContext) { 'xpack.observability_onboarding.ui.enabled (boolean?)', 'xpack.observabilityLogsExplorer.navigation.showAppLink (boolean?|never)', 'xpack.observabilityAIAssistant.scope (observability?|search?)', + 'xpack.observabilityAiAssistantManagement.logSourcesEnabled (boolean?)', + 'xpack.observabilityAiAssistantManagement.spacesEnabled (boolean?)', + 'xpack.observabilityAiAssistantManagement.visibilityEnabled (boolean?)', 'share.new_version.enabled (boolean?)', 'aiAssistantManagementSelection.preferredAIAssistantType (default?|never?|observability?)', /** diff --git a/tsconfig.base.json b/tsconfig.base.json index 2da0c007278e8..a04f19c8af2a7 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1044,6 +1044,8 @@ "@kbn/index-patterns-test-plugin/*": ["test/plugin_functional/plugins/index_patterns/*"], "@kbn/inference_integration_flyout": ["x-pack/packages/ml/inference_integration_flyout"], "@kbn/inference_integration_flyout/*": ["x-pack/packages/ml/inference_integration_flyout/*"], + "@kbn/inference-common": ["x-pack/packages/ai-infra/inference-common"], + "@kbn/inference-common/*": ["x-pack/packages/ai-infra/inference-common/*"], "@kbn/inference-plugin": ["x-pack/plugins/inference"], "@kbn/inference-plugin/*": ["x-pack/plugins/inference/*"], "@kbn/infra-forge": ["x-pack/packages/kbn-infra-forge"], @@ -1576,8 +1578,6 @@ "@kbn/security-plugin-types-server/*": ["x-pack/packages/security/plugin_types_server/*"], "@kbn/security-role-management-model": ["x-pack/packages/security/role_management_model"], "@kbn/security-role-management-model/*": ["x-pack/packages/security/role_management_model/*"], - "@kbn/security-solution-common": ["x-pack/packages/security-solution/common"], - "@kbn/security-solution-common/*": ["x-pack/packages/security-solution/common/*"], "@kbn/security-solution-distribution-bar": ["x-pack/packages/security-solution/distribution_bar"], "@kbn/security-solution-distribution-bar/*": ["x-pack/packages/security-solution/distribution_bar/*"], "@kbn/security-solution-ess": ["x-pack/plugins/security_solution_ess"], diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 7afbc9dc704c4..e1e8478aa0517 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -3,6 +3,7 @@ "paths": { "xpack.actions": "plugins/actions", "xpack.aiops": [ + "packages/ml/aiops_common", "packages/ml/aiops_components", "packages/ml/aiops_log_pattern_analysis", "packages/ml/aiops_log_rate_analysis", diff --git a/x-pack/examples/exploratory_view_example/kibana.jsonc b/x-pack/examples/exploratory_view_example/kibana.jsonc index 6cf8fa64983ac..cf077336b0f90 100644 --- a/x-pack/examples/exploratory_view_example/kibana.jsonc +++ b/x-pack/examples/exploratory_view_example/kibana.jsonc @@ -2,6 +2,8 @@ "type": "plugin", "id": "@kbn/exploratory-view-example-plugin", "owner": "@elastic/obs-ux-infra_services-team", + "group": "observability", + "visibility": "private", "plugin": { "id": "exploratoryViewExample", "server": false, diff --git a/x-pack/examples/screenshotting_example/kibana.jsonc b/x-pack/examples/screenshotting_example/kibana.jsonc index 127706ad42e3d..3519bc91caa66 100644 --- a/x-pack/examples/screenshotting_example/kibana.jsonc +++ b/x-pack/examples/screenshotting_example/kibana.jsonc @@ -2,6 +2,10 @@ "type": "plugin", "id": "@kbn/screenshotting-example-plugin", "owner": "@elastic/appex-sharedux", + // This plugin is not meant to be referenced or imported + "visibility": "private", + // If cloned / used as an inspiration, please bear in mind that your plugin might belong to a specific solution group + "group": "platform", "description": "An example integration with the screenshotting plugin.", "plugin": { "id": "screenshottingExample", diff --git a/x-pack/examples/testing_embedded_lens/public/controls.tsx b/x-pack/examples/testing_embedded_lens/public/controls.tsx index 6cfcd414ed66d..6f7658e981e4d 100644 --- a/x-pack/examples/testing_embedded_lens/public/controls.tsx +++ b/x-pack/examples/testing_embedded_lens/public/controls.tsx @@ -150,7 +150,7 @@ export function OverrideSwitch({ } helpText={helpText} - display="columnCompressedSwitch" + display="columnCompressed" hasChildLabel={false} > ) : null} {isPieChart(currentAttributes) ? ( - + ) : null} {isHeatmapChart(currentAttributes) ? ( - + ) : null} {isGaugeChart(currentAttributes) ? ( - + } - display="columnCompressedSwitch" + display="columnCompressed" helpText="Pass a consumer defined action to show in the panel context menu." > /x-pack/packages/security-solution/common'], + roots: ['/x-pack/packages/ai-infra/inference-common'], }; diff --git a/x-pack/packages/ai-infra/inference-common/kibana.jsonc b/x-pack/packages/ai-infra/inference-common/kibana.jsonc new file mode 100644 index 0000000000000..568755d303c3b --- /dev/null +++ b/x-pack/packages/ai-infra/inference-common/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "id": "@kbn/inference-common", + "owner": "@elastic/appex-ai-infra" +} diff --git a/x-pack/packages/ai-infra/inference-common/package.json b/x-pack/packages/ai-infra/inference-common/package.json new file mode 100644 index 0000000000000..0c67ca7815f16 --- /dev/null +++ b/x-pack/packages/ai-infra/inference-common/package.json @@ -0,0 +1,7 @@ +{ + "name": "@kbn/inference-common", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0", + "sideEffects": false +} diff --git a/x-pack/packages/ai-infra/inference-common/src/chat_complete/api.ts b/x-pack/packages/ai-infra/inference-common/src/chat_complete/api.ts new file mode 100644 index 0000000000000..cb91f4e53e8ae --- /dev/null +++ b/x-pack/packages/ai-infra/inference-common/src/chat_complete/api.ts @@ -0,0 +1,142 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Observable } from 'rxjs'; +import type { ToolCallsOf, ToolOptions } from './tools'; +import type { Message } from './messages'; +import type { ChatCompletionEvent, ChatCompletionTokenCount } from './events'; + +/** + * Request a completion from the LLM based on a prompt or conversation. + * + * By default, The complete LLM response will be returned as a promise. + * + * @example using the API in default mode to get promise of the LLM response. + * ```ts + * const response = await chatComplete({ + * connectorId: 'my-connector', + * system: "You are a helpful assistant", + * messages: [ + * { role: MessageRole.User, content: "Some question?"}, + * ] + * }); + * + * const { content, tokens, toolCalls } = response; + * ``` + * + * Use `stream: true` to return an observable returning the full set + * of events in real time. + * + * @example using the API in stream mode to get an event observable. + * ```ts + * const events$ = chatComplete({ + * stream: true, + * connectorId: 'my-connector', + * system: "You are a helpful assistant", + * messages: [ + * { role: MessageRole.User, content: "First question?"}, + * { role: MessageRole.Assistant, content: "Some answer"}, + * { role: MessageRole.User, content: "Another question?"}, + * ] + * }); + * + * // using the observable + * events$.pipe(withoutTokenCountEvents()).subscribe((event) => { + * if (isChatCompletionChunkEvent(event)) { + * // do something with the chunk event + * } else { + * // do something with the message event + * } + * }); + * ``` + */ +export type ChatCompleteAPI = < + TToolOptions extends ToolOptions = ToolOptions, + TStream extends boolean = false +>( + options: ChatCompleteOptions +) => ChatCompleteCompositeResponse; + +/** + * Options used to call the {@link ChatCompleteAPI} + */ +export type ChatCompleteOptions< + TToolOptions extends ToolOptions = ToolOptions, + TStream extends boolean = false +> = { + /** + * The ID of the connector to use. + * Must be an inference connector, or an error will be thrown. + */ + connectorId: string; + /** + * Set to true to enable streaming, which will change the API response type from + * a single {@link ChatCompleteResponse} promise + * to a {@link ChatCompleteStreamResponse} event observable. + * + * Defaults to false. + */ + stream?: TStream; + /** + * Optional system message for the LLM. + */ + system?: string; + /** + * The list of messages for the current conversation + */ + messages: Message[]; + /** + * Function calling mode, defaults to "native". + */ + functionCalling?: FunctionCallingMode; +} & TToolOptions; + +/** + * Composite response type from the {@link ChatCompleteAPI}, + * which can be either an observable or a promise depending on + * whether API was called with stream mode enabled or not. + */ +export type ChatCompleteCompositeResponse< + TToolOptions extends ToolOptions = ToolOptions, + TStream extends boolean = false +> = TStream extends true + ? ChatCompleteStreamResponse + : Promise>; + +/** + * Response from the {@link ChatCompleteAPI} when streaming is enabled. + * + * Observable of {@link ChatCompletionEvent} + */ +export type ChatCompleteStreamResponse = Observable< + ChatCompletionEvent +>; + +/** + * Response from the {@link ChatCompleteAPI} when streaming is not enabled. + */ +export interface ChatCompleteResponse { + /** + * The text content of the LLM response. + */ + content: string; + /** + * The eventual tool calls performed by the LLM. + */ + toolCalls: ToolCallsOf['toolCalls']; + /** + * Token counts + */ + tokens?: ChatCompletionTokenCount; +} + +/** + * Define the function calling mode when using inference APIs. + * - native will use the LLM's native function calling (requires the LLM to have native support) + * - simulated: will emulate function calling with function calling instructions + */ +export type FunctionCallingMode = 'native' | 'simulated'; diff --git a/x-pack/plugins/inference/common/chat_complete/errors.ts b/x-pack/packages/ai-infra/inference-common/src/chat_complete/errors.ts similarity index 61% rename from x-pack/plugins/inference/common/chat_complete/errors.ts rename to x-pack/packages/ai-infra/inference-common/src/chat_complete/errors.ts index 8497350d7b49b..b9d859a666761 100644 --- a/x-pack/plugins/inference/common/chat_complete/errors.ts +++ b/x-pack/packages/ai-infra/inference-common/src/chat_complete/errors.ts @@ -5,16 +5,22 @@ * 2.0. */ -import { i18n } from '@kbn/i18n'; import { InferenceTaskError } from '../errors'; import type { UnvalidatedToolCall } from './tools'; +/** + * List of code of error that are specific to the {@link ChatCompleteAPI} + */ export enum ChatCompletionErrorCode { TokenLimitReachedError = 'tokenLimitReachedError', ToolNotFoundError = 'toolNotFoundError', ToolValidationError = 'toolValidationError', } +/** + * Error thrown if the completion call fails because of a token limit + * error, e.g. when the context window is higher than the limit + */ export type ChatCompletionTokenLimitReachedError = InferenceTaskError< ChatCompletionErrorCode.TokenLimitReachedError, { @@ -23,13 +29,24 @@ export type ChatCompletionTokenLimitReachedError = InferenceTaskError< } >; +/** + * Error thrown if the LLM called a tool that was not provided + * in the list of available tools. + */ export type ChatCompletionToolNotFoundError = InferenceTaskError< ChatCompletionErrorCode.ToolNotFoundError, { + /** The name of the tool that got called */ name: string; } >; +/** + * Error thrown when the LLM called a tool with parameters that + * don't match the tool's schema. + * + * The level of details on the error vary depending on the underlying LLM. + */ export type ChatCompletionToolValidationError = InferenceTaskError< ChatCompletionErrorCode.ToolValidationError, { @@ -40,42 +57,9 @@ export type ChatCompletionToolValidationError = InferenceTaskError< } >; -export function createTokenLimitReachedError( - tokenLimit?: number, - tokenCount?: number -): ChatCompletionTokenLimitReachedError { - return new InferenceTaskError( - ChatCompletionErrorCode.TokenLimitReachedError, - i18n.translate('xpack.inference.chatCompletionError.tokenLimitReachedError', { - defaultMessage: `Token limit reached. Token limit is {tokenLimit}, but the current conversation has {tokenCount} tokens.`, - values: { tokenLimit, tokenCount }, - }), - { tokenLimit, tokenCount } - ); -} - -export function createToolNotFoundError(name: string): ChatCompletionToolNotFoundError { - return new InferenceTaskError( - ChatCompletionErrorCode.ToolNotFoundError, - `Tool ${name} called but was not available`, - { - name, - } - ); -} - -export function createToolValidationError( - message: string, - meta: { - name?: string; - arguments?: string; - errorsText?: string; - toolCalls?: UnvalidatedToolCall[]; - } -): ChatCompletionToolValidationError { - return new InferenceTaskError(ChatCompletionErrorCode.ToolValidationError, message, meta); -} - +/** + * Check if an error is a {@link ChatCompletionToolValidationError} + */ export function isToolValidationError(error?: Error): error is ChatCompletionToolValidationError { return ( error instanceof InferenceTaskError && @@ -83,6 +67,9 @@ export function isToolValidationError(error?: Error): error is ChatCompletionToo ); } +/** + * Check if an error is a {@link ChatCompletionTokenLimitReachedError} + */ export function isTokenLimitReachedError( error: Error ): error is ChatCompletionTokenLimitReachedError { @@ -92,6 +79,9 @@ export function isTokenLimitReachedError( ); } +/** + * Check if an error is a {@link ChatCompletionToolNotFoundError} + */ export function isToolNotFoundError(error: Error): error is ChatCompletionToolNotFoundError { return ( error instanceof InferenceTaskError && error.code === ChatCompletionErrorCode.ToolNotFoundError diff --git a/x-pack/packages/ai-infra/inference-common/src/chat_complete/event_utils.ts b/x-pack/packages/ai-infra/inference-common/src/chat_complete/event_utils.ts new file mode 100644 index 0000000000000..4749673264aff --- /dev/null +++ b/x-pack/packages/ai-infra/inference-common/src/chat_complete/event_utils.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 { filter, OperatorFunction } from 'rxjs'; +import { InferenceTaskEvent } from '../inference_task'; +import { + ChatCompletionEventType, + ChatCompletionEvent, + ChatCompletionChunkEvent, + ChatCompletionMessageEvent, + ChatCompletionTokenCountEvent, +} from './events'; +import type { ToolOptions } from './tools'; + +/** + * Check if the provided {@link ChatCompletionEvent} is a {@link ChatCompletionChunkEvent} + */ +export function isChatCompletionChunkEvent( + event: ChatCompletionEvent +): event is ChatCompletionChunkEvent { + return event.type === ChatCompletionEventType.ChatCompletionChunk; +} + +/** + * Check if the provided {@link ChatCompletionEvent} is a {@link ChatCompletionMessageEvent} + */ +export function isChatCompletionMessageEvent( + event: ChatCompletionEvent +): event is ChatCompletionMessageEvent { + return event.type === ChatCompletionEventType.ChatCompletionMessage; +} + +/** + * Check if the provided {@link ChatCompletionEvent} is a {@link ChatCompletionMessageEvent} + */ +export function isChatCompletionTokenCountEvent( + event: ChatCompletionEvent +): event is ChatCompletionTokenCountEvent { + return event.type === ChatCompletionEventType.ChatCompletionTokenCount; +} + +/** + * Check if the provided {@link InferenceTaskEvent} is a {@link ChatCompletionEvent} + */ +export function isChatCompletionEvent(event: InferenceTaskEvent): event is ChatCompletionEvent { + return ( + event.type === ChatCompletionEventType.ChatCompletionChunk || + event.type === ChatCompletionEventType.ChatCompletionMessage || + event.type === ChatCompletionEventType.ChatCompletionTokenCount + ); +} + +/** + * Operator filtering out the chunk events from the provided observable. + */ +export function withoutChunkEvents(): OperatorFunction< + T, + Exclude +> { + return filter( + (event): event is Exclude => + event.type !== ChatCompletionEventType.ChatCompletionChunk + ); +} + +/** + * Operator filtering out the token count events from the provided observable. + */ +export function withoutTokenCountEvents(): OperatorFunction< + T, + Exclude +> { + return filter( + (event): event is Exclude => + event.type !== ChatCompletionEventType.ChatCompletionTokenCount + ); +} diff --git a/x-pack/packages/ai-infra/inference-common/src/chat_complete/events.ts b/x-pack/packages/ai-infra/inference-common/src/chat_complete/events.ts new file mode 100644 index 0000000000000..73396b3e2b905 --- /dev/null +++ b/x-pack/packages/ai-infra/inference-common/src/chat_complete/events.ts @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { InferenceTaskEventBase } from '../inference_task'; +import type { ToolCallsOf, ToolOptions } from './tools'; + +/** + * List possible values of {@link ChatCompletionEvent} types. + */ +export enum ChatCompletionEventType { + ChatCompletionChunk = 'chatCompletionChunk', + ChatCompletionTokenCount = 'chatCompletionTokenCount', + ChatCompletionMessage = 'chatCompletionMessage', +} + +/** + * Message event, sent only once, after all the chunks were emitted, and containing + * the whole text content and potential tool calls of the response. + */ +export type ChatCompletionMessageEvent = + InferenceTaskEventBase & { + /** + * The text content of the LLM response. + */ + content: string; + /** + * The eventual tool calls performed by the LLM. + */ + toolCalls: ToolCallsOf['toolCalls']; + }; + +/** + * Represent a partial tool call present in a chunk event. + * + * Note that all properties of the structure, except from the index, + * are partial and must be aggregated. + */ +export interface ChatCompletionChunkToolCall { + /** + * The tool call index (position in the tool call array). + */ + index: number; + /** + * chunk of tool call id. + */ + toolCallId: string; + function: { + /** + * chunk of tool name. + */ + name: string; + /** + * chunk of tool call arguments. + */ + arguments: string; + }; +} + +/** + * Chunk event, containing a fragment of the total content, + * and potentially chunks of tool calls. + */ +export type ChatCompletionChunkEvent = + InferenceTaskEventBase & { + /** + * The content chunk + */ + content: string; + /** + * The tool call chunks + */ + tool_calls: ChatCompletionChunkToolCall[]; + }; + +/** + * Token count structure for the chatComplete API. + */ +export interface ChatCompletionTokenCount { + /** + * Input token count + */ + prompt: number; + /** + * Output token count + */ + completion: number; + /** + * Total token count + */ + total: number; +} + +/** + * Token count event, send only once, usually (but not necessarily) + * before the message event + */ +export type ChatCompletionTokenCountEvent = + InferenceTaskEventBase & { + /** + * The token count structure + */ + tokens: ChatCompletionTokenCount; + }; + +/** + * Events emitted from the {@link ChatCompleteResponse} observable + * returned from the {@link ChatCompleteAPI}. + * + * The chatComplete API returns 3 type of events: + * - {@link ChatCompletionChunkEvent}: message chunk events + * - {@link ChatCompletionTokenCountEvent}: token count event + * - {@link ChatCompletionMessageEvent}: message event + * + * Note that chunk events can be emitted any amount of times, but token count will be emitted + * at most once (could not be emitted depending on the underlying connector), and message + * event will be emitted ex + * + */ +export type ChatCompletionEvent = + | ChatCompletionChunkEvent + | ChatCompletionTokenCountEvent + | ChatCompletionMessageEvent; diff --git a/x-pack/packages/ai-infra/inference-common/src/chat_complete/index.ts b/x-pack/packages/ai-infra/inference-common/src/chat_complete/index.ts new file mode 100644 index 0000000000000..ca69f39b273e5 --- /dev/null +++ b/x-pack/packages/ai-infra/inference-common/src/chat_complete/index.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { + ChatCompleteCompositeResponse, + ChatCompleteAPI, + ChatCompleteOptions, + FunctionCallingMode, + ChatCompleteStreamResponse, + ChatCompleteResponse, +} from './api'; +export { + ChatCompletionEventType, + type ChatCompletionMessageEvent, + type ChatCompletionChunkEvent, + type ChatCompletionEvent, + type ChatCompletionChunkToolCall, + type ChatCompletionTokenCountEvent, + type ChatCompletionTokenCount, +} from './events'; +export { + MessageRole, + type Message, + type AssistantMessage, + type UserMessage, + type ToolMessage, +} from './messages'; +export { type ToolSchema, type ToolSchemaType, type FromToolSchema } from './tool_schema'; +export { + ToolChoiceType, + type ToolOptions, + type ToolDefinition, + type ToolCall, + type ToolCallsOf, + type UnvalidatedToolCall, + type ToolChoice, +} from './tools'; +export { + isChatCompletionChunkEvent, + isChatCompletionEvent, + isChatCompletionMessageEvent, + isChatCompletionTokenCountEvent, + withoutChunkEvents, + withoutTokenCountEvents, +} from './event_utils'; +export { + ChatCompletionErrorCode, + type ChatCompletionToolNotFoundError, + type ChatCompletionToolValidationError, + type ChatCompletionTokenLimitReachedError, + isToolValidationError, + isTokenLimitReachedError, + isToolNotFoundError, +} from './errors'; diff --git a/x-pack/packages/ai-infra/inference-common/src/chat_complete/messages.ts b/x-pack/packages/ai-infra/inference-common/src/chat_complete/messages.ts new file mode 100644 index 0000000000000..ca74b094e0a3b --- /dev/null +++ b/x-pack/packages/ai-infra/inference-common/src/chat_complete/messages.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { ToolCall } from './tools'; + +/** + * Enum for all possible {@link Message} roles. + */ +export enum MessageRole { + User = 'user', + Assistant = 'assistant', + Tool = 'tool', +} + +/** + * Base type for all subtypes of {@link Message}. + */ +interface MessageBase { + role: TRole; +} + +/** + * Represents a message from the user. + */ +export type UserMessage = MessageBase & { + /** + * The text content of the user message + */ + content: string; +}; + +/** + * Represents a message from the LLM. + */ +export type AssistantMessage = MessageBase & { + /** + * The text content of the message. + * Can be null if the LLM called a tool. + */ + content: string | null; + /** + * A potential list of {@ToolCall} the LLM asked to execute. + * Note that LLM with parallel tool invocation can potentially call multiple tools at the same time. + */ + toolCalls?: ToolCall[]; +}; + +/** + * Represents a tool invocation result, following a request from the LLM to execute a tool. + */ +export type ToolMessage | unknown> = + MessageBase & { + /** + * The call id matching the {@link ToolCall} this tool message is for. + */ + toolCallId: string; + /** + * The response from the tool invocation. + */ + response: TToolResponse; + }; + +/** + * Mixin composed of all the possible types of messages in a chatComplete discussion. + * + * Message can be of three types: + * - {@link UserMessage} + * - {@link AssistantMessage} + * - {@link ToolMessage} + */ +export type Message = UserMessage | AssistantMessage | ToolMessage; diff --git a/x-pack/plugins/inference/common/chat_complete/tool_schema.ts b/x-pack/packages/ai-infra/inference-common/src/chat_complete/tool_schema.ts similarity index 89% rename from x-pack/plugins/inference/common/chat_complete/tool_schema.ts rename to x-pack/packages/ai-infra/inference-common/src/chat_complete/tool_schema.ts index 2a2c61f8e9b70..fd935785f74f5 100644 --- a/x-pack/plugins/inference/common/chat_complete/tool_schema.ts +++ b/x-pack/packages/ai-infra/inference-common/src/chat_complete/tool_schema.ts @@ -11,9 +11,9 @@ interface ToolSchemaFragmentBase { description?: string; } -interface ToolSchemaTypeObject extends ToolSchemaFragmentBase { +export interface ToolSchemaTypeObject extends ToolSchemaFragmentBase { type: 'object'; - properties?: Record; + properties: Record; required?: string[] | readonly string[]; } @@ -40,6 +40,9 @@ interface ToolSchemaTypeArray extends ToolSchemaFragmentBase { items: Exclude; } +/** + * A tool schema property's possible types. + */ export type ToolSchemaType = | ToolSchemaTypeObject | ToolSchemaTypeString @@ -72,8 +75,14 @@ type FromToolSchemaString = ? ValuesType : string; +/** + * Defines the schema for a {@link ToolDefinition} + */ export type ToolSchema = ToolSchemaTypeObject; +/** + * Utility type to infer the shape of a tool call from its schema. + */ export type FromToolSchema = TToolSchema extends ToolSchemaTypeObject ? FromToolSchemaObject diff --git a/x-pack/plugins/inference/common/chat_complete/tools.ts b/x-pack/packages/ai-infra/inference-common/src/chat_complete/tools.ts similarity index 53% rename from x-pack/plugins/inference/common/chat_complete/tools.ts rename to x-pack/packages/ai-infra/inference-common/src/chat_complete/tools.ts index a5db86c7c996d..0c7d5c6755f31 100644 --- a/x-pack/plugins/inference/common/chat_complete/tools.ts +++ b/x-pack/packages/ai-infra/inference-common/src/chat_complete/tools.ts @@ -4,15 +4,12 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import type { ValuesType } from 'utility-types'; import { FromToolSchema, ToolSchema } from './tool_schema'; type Assert = TValue extends TType ? TValue & TType : never; -interface CustomToolChoice { - function: TName; -} - type ToolsOfChoice = TToolOptions['toolChoice'] extends { function: infer TToolName; } @@ -21,6 +18,9 @@ type ToolsOfChoice = TToolOptions['toolChoice' : TToolOptions['tools'] : TToolOptions['tools']; +/** + * Utility type to infer the tool calls response shape. + */ type ToolResponsesOf | undefined> = TTools extends Record ? Array< @@ -30,18 +30,64 @@ type ToolResponsesOf | undefined> > : never[]; +/** + * Utility type to infer the tool call response shape. + */ type ToolResponseOf = ToolCall< TName, TToolDefinition extends { schema: ToolSchema } ? FromToolSchema : {} >; +/** + * Tool invocation choice type. + * + * Refer to {@link ToolChoice} for more details. + */ +export enum ToolChoiceType { + none = 'none', + auto = 'auto', + required = 'required', +} + +/** + * Represent a tool choice where the LLM is forced to call a specific tool. + * + * Refer to {@link ToolChoice} for more details. + */ +interface CustomToolChoice { + function: TName; +} + +/** + * Defines the tool invocation for {@link ToolOptions}, either a {@link ToolChoiceType} or {@link CustomToolChoice}. + * - {@link ToolChoiceType.none}: the LLM will never call a tool + * - {@link ToolChoiceType.auto}: the LLM will decide if it should call a tool or provide a text response + * - {@link ToolChoiceType.required}: the LLM will always call a tool, but will decide with one to call + * - {@link CustomToolChoice}: the LLM will always call the specified tool + */ export type ToolChoice = ToolChoiceType | CustomToolChoice; +/** + * The definition of a tool that will be provided to the LLM for it to eventually call. + */ export interface ToolDefinition { + /** + * A description of what the tool does. Note that this will be exposed to the LLM, + * so the description should be explicit about what the tool does and when to call it. + */ description: string; + /** + * The input schema for the tool, representing the shape of the tool's parameters + * + * Even if optional, it is highly recommended to define a schema for all tool definitions, unless + * the tool is supposed to be called without parameters. + */ schema?: ToolSchema; } +/** + * Utility type to infer the toolCall type of {@link ChatCompletionMessageEvent}. + */ export type ToolCallsOf = TToolOptions extends { tools?: Record; } @@ -52,12 +98,11 @@ export type ToolCallsOf = TToolOptions extends } : { toolCalls: never }; -export enum ToolChoiceType { - none = 'none', - auto = 'auto', - required = 'required', -} - +/** + * Represents a tool call from the LLM before correctly converted to the schema type. + * + * Only publicly exposed because referenced by {@link ChatCompletionToolValidationError} + */ export interface UnvalidatedToolCall { toolCallId: string; function: { @@ -66,17 +111,39 @@ export interface UnvalidatedToolCall { }; } +/** + * Represents a tool call performed by the LLM. + */ export interface ToolCall< TName extends string = string, TArguments extends Record | undefined = Record | undefined > { + /** + * The id of the tool call, that must be re-used when providing the tool call response + */ toolCallId: string; function: { + /** + * The name of the tool that was called + */ name: TName; } & (TArguments extends Record ? { arguments: TArguments } : {}); } +/** + * Tool-related parameters of {@link ChatCompleteAPI} + */ export interface ToolOptions { + /** + * The choice of tool execution. + * + * Refer to {@link ToolChoice} + */ toolChoice?: ToolChoice; + /** + * The list of tool definitions that will be exposed to the LLM. + * + * Refer to {@link ToolDefinition}. + */ tools?: Record; } diff --git a/x-pack/plugins/inference/common/errors.ts b/x-pack/packages/ai-infra/inference-common/src/errors.ts similarity index 93% rename from x-pack/plugins/inference/common/errors.ts rename to x-pack/packages/ai-infra/inference-common/src/errors.ts index e8bcd4cf60aaf..5a99adc4321d9 100644 --- a/x-pack/plugins/inference/common/errors.ts +++ b/x-pack/packages/ai-infra/inference-common/src/errors.ts @@ -4,14 +4,20 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { i18n } from '@kbn/i18n'; + import { InferenceTaskEventBase, InferenceTaskEventType } from './inference_task'; +/** + * Enum for generic inference error codes. + */ export enum InferenceTaskErrorCode { internalError = 'internalError', requestError = 'requestError', } +/** + * Base class for all inference API errors. + */ export class InferenceTaskError< TCode extends string, TMeta extends Record | undefined @@ -51,9 +57,7 @@ export type InferenceTaskRequestError = InferenceTaskError< >; export function createInferenceInternalError( - message: string = i18n.translate('xpack.inference.internalError', { - defaultMessage: 'An internal error occurred', - }), + message = 'An internal error occurred', meta?: Record ): InferenceTaskInternalError { return new InferenceTaskError(InferenceTaskErrorCode.internalError, message, meta ?? {}); diff --git a/x-pack/plugins/inference/common/inference_task.ts b/x-pack/packages/ai-infra/inference-common/src/inference_task.ts similarity index 81% rename from x-pack/plugins/inference/common/inference_task.ts rename to x-pack/packages/ai-infra/inference-common/src/inference_task.ts index 7b8f65b7af2c9..15449e1275a5b 100644 --- a/x-pack/plugins/inference/common/inference_task.ts +++ b/x-pack/packages/ai-infra/inference-common/src/inference_task.ts @@ -5,7 +5,13 @@ * 2.0. */ +/** + * Base interface for all inference events. + */ export interface InferenceTaskEventBase { + /** + * Unique identifier of the event type. + */ type: TEventType; } diff --git a/x-pack/packages/ai-infra/inference-common/src/output/api.ts b/x-pack/packages/ai-infra/inference-common/src/output/api.ts new file mode 100644 index 0000000000000..3355042910a61 --- /dev/null +++ b/x-pack/packages/ai-infra/inference-common/src/output/api.ts @@ -0,0 +1,147 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { Observable } from 'rxjs'; +import { Message, FunctionCallingMode, FromToolSchema, ToolSchema } from '../chat_complete'; +import { Output, OutputEvent } from './events'; + +/** + * Generate a response with the LLM for a prompt, optionally based on a schema. + * + * @example + * ```ts + * // schema must be defined as full const or using the `satisfies ToolSchema` modifier for TS type inference to work + * const mySchema = { + * type: 'object', + * properties: { + * animals: { + * description: 'the list of animals that are mentioned in the provided article', + * type: 'array', + * items: { + * type: 'string', + * }, + * }, + * }, + * } as const; + * + * const response = outputApi({ + * id: 'extract_from_article', + * connectorId: 'my-connector connector', + * schema: mySchema, + * input: ` + * Please find all the animals that are mentioned in the following document: + * ## Document¬ + * ${theDoc} + * `, + * }); + * + * // output is properly typed from the provided schema + * const { animals } = response.output; + * ``` + */ +export type OutputAPI = < + TId extends string = string, + TOutputSchema extends ToolSchema | undefined = ToolSchema | undefined, + TStream extends boolean = false +>( + options: OutputOptions +) => OutputCompositeResponse; + +/** + * Options for the {@link OutputAPI} + */ +export interface OutputOptions< + TId extends string = string, + TOutputSchema extends ToolSchema | undefined = ToolSchema | undefined, + TStream extends boolean = false +> { + /** + * The id of the operation. + */ + id: TId; + /** + * The ID of the connector to use. + * Must be an inference connector, or an error will be thrown. + */ + connectorId: string; + /** + * Optional system message for the LLM. + */ + system?: string; + /** + * The prompt for the LLM. + */ + input: string; + /** + * The schema the response from the LLM should adhere to. + */ + schema?: TOutputSchema; + /** + * Previous messages in the conversation. + * If provided, will be passed to the LLM in addition to `input`. + */ + previousMessages?: Message[]; + /** + * Function calling mode, defaults to "native". + */ + functionCalling?: FunctionCallingMode; + /** + * Set to true to enable streaming, which will change the API response type from + * a single promise to an event observable. + * + * Defaults to false. + */ + stream?: TStream; +} + +/** + * Composite response type from the {@link OutputAPI}, + * which can be either an observable or a promise depending on + * whether API was called with stream mode enabled or not. + */ +export type OutputCompositeResponse< + TId extends string = string, + TOutputSchema extends ToolSchema | undefined = ToolSchema | undefined, + TStream extends boolean = false +> = TStream extends true + ? OutputStreamResponse + : Promise< + OutputResponse< + TId, + TOutputSchema extends ToolSchema ? FromToolSchema : undefined + > + >; + +/** + * Response from the {@link OutputAPI} when streaming is not enabled. + */ +export interface OutputResponse { + /** + * The id of the operation, as specified when calling the API. + */ + id: TId; + /** + * The task output, following the schema specified as input. + */ + output: TOutput; + /** + * Potential text content provided by the LLM, if it was provided in addition to the tool call. + */ + content: string; +} + +/** + * Response from the {@link OutputAPI} in streaming mode. + * + * @returns Observable of {@link OutputEvent} + */ +export type OutputStreamResponse< + TId extends string = string, + TOutputSchema extends ToolSchema | undefined = ToolSchema | undefined +> = Observable< + OutputEvent : undefined> +>; diff --git a/x-pack/packages/ai-infra/inference-common/src/output/event_utils.ts b/x-pack/packages/ai-infra/inference-common/src/output/event_utils.ts new file mode 100644 index 0000000000000..1139bac92c610 --- /dev/null +++ b/x-pack/packages/ai-infra/inference-common/src/output/event_utils.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { filter, OperatorFunction } from 'rxjs'; +import { OutputCompleteEvent, OutputEvent, OutputEventType, OutputUpdateEvent } from '.'; +import type { InferenceTaskEvent } from '../inference_task'; + +/** + * Check if the provided {@link ChatCompletionEvent} is a {@link ChatCompletionChunkEvent} + */ +export function isOutputCompleteEvent( + event: TOutputEvent +): event is Extract { + return event.type === OutputEventType.OutputComplete; +} + +/** + * Check if the provided {@link InferenceTaskEvent} is a {@link OutputEvent} + */ +export function isOutputEvent(event: InferenceTaskEvent): event is OutputEvent { + return ( + event.type === OutputEventType.OutputComplete || event.type === OutputEventType.OutputUpdate + ); +} + +/** + * Check if the provided {@link OutputEvent} is a {@link OutputUpdateEvent} + */ +export function isOutputUpdateEvent( + event: OutputEvent +): event is OutputUpdateEvent { + return event.type === OutputEventType.OutputComplete; +} + +/** + * Operator filtering out the update events from the provided observable. + */ +export function withoutOutputUpdateEvents(): OperatorFunction< + T, + Exclude +> { + return filter( + (event): event is Exclude => event.type !== OutputEventType.OutputUpdate + ); +} diff --git a/x-pack/packages/ai-infra/inference-common/src/output/events.ts b/x-pack/packages/ai-infra/inference-common/src/output/events.ts new file mode 100644 index 0000000000000..794f58bd7db79 --- /dev/null +++ b/x-pack/packages/ai-infra/inference-common/src/output/events.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { InferenceTaskEventBase } from '../inference_task'; + +/** + * List possible values of {@link OutputEvent} types. + */ +export enum OutputEventType { + OutputUpdate = 'output', + OutputComplete = 'complete', +} + +/** + * Task output of a {@link OutputCompleteEvent} + */ +export type Output = Record | undefined | unknown; + +/** + * Update (chunk) event for the {@link OutputAPI} + */ +export type OutputUpdateEvent = + InferenceTaskEventBase & { + /** + * The id of the operation, as provided as input + */ + id: TId; + /** + * The text content of the chunk + */ + content: string; + }; + +/** + * Completion (complete message) event for the {@link OutputAPI} + */ +export type OutputCompleteEvent< + TId extends string = string, + TOutput extends Output = Output +> = InferenceTaskEventBase & { + /** + * The id of the operation, as provided as input + */ + id: TId; + /** + * The task output, following the schema specified as input + */ + output: TOutput; + /** + * Potential text content provided by the LLM, + * if it was provided in addition to the tool call + */ + content: string; +}; + +/** + * Events emitted from the {@link OutputEvent}. + */ +export type OutputEvent = + | OutputUpdateEvent + | OutputCompleteEvent; diff --git a/x-pack/packages/ai-infra/inference-common/src/output/index.ts b/x-pack/packages/ai-infra/inference-common/src/output/index.ts new file mode 100644 index 0000000000000..a3039005b2f7c --- /dev/null +++ b/x-pack/packages/ai-infra/inference-common/src/output/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { + OutputAPI, + OutputOptions, + OutputCompositeResponse, + OutputResponse, + OutputStreamResponse, +} from './api'; +export { + OutputEventType, + type OutputCompleteEvent, + type OutputUpdateEvent, + type Output, + type OutputEvent, +} from './events'; +export { + isOutputCompleteEvent, + isOutputUpdateEvent, + isOutputEvent, + withoutOutputUpdateEvents, +} from './event_utils'; diff --git a/x-pack/packages/ai-infra/inference-common/tsconfig.json b/x-pack/packages/ai-infra/inference-common/tsconfig.json new file mode 100644 index 0000000000000..86d57b8d692f7 --- /dev/null +++ b/x-pack/packages/ai-infra/inference-common/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node", + "react" + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + ] +} diff --git a/x-pack/packages/kbn-ai-assistant/kibana.jsonc b/x-pack/packages/kbn-ai-assistant/kibana.jsonc index 4cddd90431e39..625dedc6c99f4 100644 --- a/x-pack/packages/kbn-ai-assistant/kibana.jsonc +++ b/x-pack/packages/kbn-ai-assistant/kibana.jsonc @@ -1,5 +1,7 @@ { "id": "@kbn/ai-assistant", "owner": "@elastic/search-kibana", - "type": "shared-browser" + "type": "shared-browser", + "group": "platform", + "visibility": "shared" } diff --git a/x-pack/packages/kbn-ai-assistant/src/buttons/new_chat_button.tsx b/x-pack/packages/kbn-ai-assistant/src/buttons/new_chat_button.tsx index 3e515e87c2197..60e37d85b92d9 100644 --- a/x-pack/packages/kbn-ai-assistant/src/buttons/new_chat_button.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/buttons/new_chat_button.tsx @@ -19,7 +19,7 @@ export function NewChatButton( {...nextProps} > {i18n.translate('xpack.aiAssistant.newChatButton', { - defaultMessage: 'New chat', + defaultMessage: 'New conversation', })} ) : ( diff --git a/x-pack/packages/kbn-ai-assistant/src/chat/chat_actions_menu.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/chat_actions_menu.tsx index ac25fe6c3703a..4a19272e8938b 100644 --- a/x-pack/packages/kbn-ai-assistant/src/chat/chat_actions_menu.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/chat_actions_menu.tsx @@ -18,6 +18,7 @@ import { import { ConnectorSelectorBase } from '@kbn/observability-ai-assistant-plugin/public'; import type { UseGenAIConnectorsResult } from '../hooks/use_genai_connectors'; import { useKibana } from '../hooks/use_kibana'; +import { useKnowledgeBase } from '../hooks'; export function ChatActionsMenu({ connectors, @@ -31,6 +32,7 @@ export function ChatActionsMenu({ onCopyConversationClick: () => void; }) { const { application, http } = useKibana().services; + const knowledgeBase = useKnowledgeBase(); const [isOpen, setIsOpen] = useState(false); const handleNavigateToConnectors = () => { @@ -91,15 +93,19 @@ export function ChatActionsMenu({ defaultMessage: 'Actions', }), items: [ - { - name: i18n.translate('xpack.aiAssistant.chatHeader.actions.knowledgeBase', { - defaultMessage: 'Manage knowledge base', - }), - onClick: () => { - toggleActionsMenu(); - handleNavigateToSettingsKnowledgeBase(); - }, - }, + ...(knowledgeBase?.status.value?.enabled + ? [ + { + name: i18n.translate('xpack.aiAssistant.chatHeader.actions.knowledgeBase', { + defaultMessage: 'Manage knowledge base', + }), + onClick: () => { + toggleActionsMenu(); + handleNavigateToSettingsKnowledgeBase(); + }, + }, + ] + : []), { name: i18n.translate('xpack.aiAssistant.chatHeader.actions.settings', { defaultMessage: 'AI Assistant Settings', diff --git a/x-pack/packages/kbn-ai-assistant/src/chat/chat_body.stories.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/chat_body.stories.tsx index 182cb046cba70..3809e97f059b6 100644 --- a/x-pack/packages/kbn-ai-assistant/src/chat/chat_body.stories.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/chat_body.stories.tsx @@ -37,6 +37,7 @@ const defaultProps: ComponentStoryObj = { loading: false, value: { ready: true, + enabled: true, }, refresh: () => {}, }, diff --git a/x-pack/packages/kbn-ai-assistant/src/chat/chat_body.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/chat_body.tsx index 5b80a34e0bf7b..12cb747d148c4 100644 --- a/x-pack/packages/kbn-ai-assistant/src/chat/chat_body.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/chat_body.tsx @@ -123,7 +123,7 @@ export function ChatBody({ showLinkToConversationsApp: boolean; onConversationUpdate: (conversation: { conversation: Conversation['conversation'] }) => void; onToggleFlyoutPositionMode?: (flyoutPositionMode: FlyoutPositionMode) => void; - navigateToConversation: (conversationId?: string) => void; + navigateToConversation?: (conversationId?: string) => void; }) { const license = useLicense(); const hasCorrectLicense = license?.hasAtLeast('enterprise'); diff --git a/x-pack/packages/kbn-ai-assistant/src/chat/chat_flyout.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/chat_flyout.tsx index 8d636374ac768..f7d8b3b20c433 100644 --- a/x-pack/packages/kbn-ai-assistant/src/chat/chat_flyout.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/chat_flyout.tsx @@ -47,13 +47,15 @@ export function ChatFlyout({ isOpen, onClose, navigateToConversation, + hideConversationList, }: { initialTitle: string; initialMessages: Message[]; initialFlyoutPositionMode?: FlyoutPositionMode; isOpen: boolean; onClose: () => void; - navigateToConversation(conversationId?: string): void; + navigateToConversation?: (conversationId?: string) => void; + hideConversationList?: boolean; }) { const { euiTheme } = useEuiTheme(); const breakpoint = useCurrentEuiBreakpoint(); @@ -174,84 +176,86 @@ export function ChatFlyout({ }} > - - - setConversationsExpanded(!conversationsExpanded)} - /> - - } - /> - - {conversationsExpanded ? ( - { - conversationList.deleteConversation(deletedConversationId).then(() => { - if (deletedConversationId === conversationId) { - setConversationId(undefined); - } - }); - }} - onConversationSelect={(nextConversationId) => { - setConversationId(nextConversationId); - }} - /> - ) : ( + {!hideConversationList ? ( + - { - setConversationId(undefined); - }} + className={expandButtonClassName} + color="text" + data-test-subj="observabilityAiAssistantChatFlyoutButton" + iconType={conversationsExpanded ? 'transitionLeftIn' : 'transitionLeftOut'} + onClick={() => setConversationsExpanded(!conversationsExpanded)} /> } - className={newChatButtonClassName} /> - )} - + + {conversationsExpanded ? ( + { + conversationList.deleteConversation(deletedConversationId).then(() => { + if (deletedConversationId === conversationId) { + setConversationId(undefined); + } + }); + }} + onConversationSelect={(nextConversationId) => { + setConversationId(nextConversationId); + }} + /> + ) : ( + + { + setConversationId(undefined); + }} + /> + + } + className={newChatButtonClassName} + /> + )} + + ) : null} { - if (onClose) onClose(); - navigateToConversation(newConversationId); - }} + navigateToConversation={ + navigateToConversation + ? (newConversationId?: string) => { + if (onClose) onClose(); + navigateToConversation(newConversationId); + } + : undefined + } /> diff --git a/x-pack/packages/kbn-ai-assistant/src/chat/chat_header.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/chat_header.tsx index c9f0588a1c90f..e55daf640082e 100644 --- a/x-pack/packages/kbn-ai-assistant/src/chat/chat_header.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/chat_header.tsx @@ -60,7 +60,7 @@ export function ChatHeader({ onCopyConversation: () => void; onSaveTitle: (title: string) => void; onToggleFlyoutPositionMode?: (newFlyoutPositionMode: FlyoutPositionMode) => void; - navigateToConversation: (nextConversationId?: string) => void; + navigateToConversation?: (nextConversationId?: string) => void; }) { const theme = useEuiTheme(); const breakpoint = useCurrentEuiBreakpoint(); @@ -142,11 +142,11 @@ export function ChatHeader({ flyoutPositionMode === 'overlay' ? i18n.translate( 'xpack.aiAssistant.chatHeader.euiToolTip.flyoutModeLabel.dock', - { defaultMessage: 'Dock chat' } + { defaultMessage: 'Dock conversation' } ) : i18n.translate( 'xpack.aiAssistant.chatHeader.euiToolTip.flyoutModeLabel.undock', - { defaultMessage: 'Undock chat' } + { defaultMessage: 'Undock conversation' } ) } display="block" @@ -164,31 +164,32 @@ export function ChatHeader({ } /> - - - - + navigateToConversation(conversationId)} - /> - - } - /> - + display="block" + > + navigateToConversation(conversationId)} + /> + + } + /> + + ) : null} ) : null} diff --git a/x-pack/packages/kbn-ai-assistant/src/chat/chat_timeline.stories.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/chat_timeline.stories.tsx index 0afb0c7e79fc0..7c04c3ad0bae7 100644 --- a/x-pack/packages/kbn-ai-assistant/src/chat/chat_timeline.stories.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/chat_timeline.stories.tsx @@ -58,6 +58,7 @@ const defaultProps: ComponentProps = { loading: false, value: { ready: true, + enabled: true, }, refresh: () => {}, }, diff --git a/x-pack/packages/kbn-ai-assistant/src/chat/disclaimer.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/disclaimer.tsx index 8f9c3abca0e71..54c09e841d81b 100644 --- a/x-pack/packages/kbn-ai-assistant/src/chat/disclaimer.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/disclaimer.tsx @@ -19,7 +19,7 @@ export function Disclaimer() { > {i18n.translate('xpack.aiAssistant.disclaimer.disclaimerLabel', { defaultMessage: - "This chat is powered by an integration with your LLM provider. LLMs are known to sometimes present incorrect information as if it's correct. Elastic supports configuration and connection to the LLM provider and your knowledge base, but is not responsible for the LLM's responses.", + "This conversation is powered by an integration with your LLM provider. LLMs are known to sometimes present incorrect information as if it's correct. Elastic supports configuration and connection to the LLM provider and your knowledge base, but is not responsible for the LLM's responses.", })}
); diff --git a/x-pack/packages/kbn-ai-assistant/src/chat/knowledge_base_callout.stories.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/knowledge_base_callout.stories.tsx index e87aa161d80c3..84c730129348e 100644 --- a/x-pack/packages/kbn-ai-assistant/src/chat/knowledge_base_callout.stories.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/knowledge_base_callout.stories.tsx @@ -24,6 +24,7 @@ const defaultProps: ComponentStoryObj = { loading: false, value: { ready: false, + enabled: true, }, refresh: () => {}, }, @@ -43,12 +44,15 @@ export const Loading: ComponentStoryObj = merge({}, defaultPro }); export const NotInstalled: ComponentStoryObj = merge({}, defaultProps, { - args: { knowledgeBase: { status: { loading: false, value: { ready: false } } } }, + args: { knowledgeBase: { status: { loading: false, value: { ready: false, enabled: true } } } }, }); export const Installing: ComponentStoryObj = merge({}, defaultProps, { args: { - knowledgeBase: { status: { loading: false, value: { ready: false } }, isInstalling: true }, + knowledgeBase: { + status: { loading: false, value: { ready: false, enabled: true } }, + isInstalling: true, + }, }, }); @@ -57,7 +61,7 @@ export const InstallError: ComponentStoryObj = merge({}, defau knowledgeBase: { status: { loading: false, - value: { ready: false }, + value: { ready: false, enabled: true }, }, isInstalling: false, installError: new Error(), @@ -66,5 +70,5 @@ export const InstallError: ComponentStoryObj = merge({}, defau }); export const Installed: ComponentStoryObj = merge({}, defaultProps, { - args: { knowledgeBase: { status: { loading: false, value: { ready: true } } } }, + args: { knowledgeBase: { status: { loading: false, value: { ready: true, enabled: true } } } }, }); diff --git a/x-pack/packages/kbn-ai-assistant/src/chat/welcome_message.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/welcome_message.tsx index a449235ba44e6..2ce11d16905af 100644 --- a/x-pack/packages/kbn-ai-assistant/src/chat/welcome_message.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/welcome_message.tsx @@ -85,8 +85,9 @@ export function WelcomeMessage({ connectors={connectors} onSetupConnectorClick={handleConnectorClick} /> - - + {knowledgeBase.status.value?.enabled ? ( + + ) : null} diff --git a/x-pack/packages/kbn-ai-assistant/src/conversation/conversation_view.tsx b/x-pack/packages/kbn-ai-assistant/src/conversation/conversation_view.tsx index fe71a9585dd1e..fb74ff7647a21 100644 --- a/x-pack/packages/kbn-ai-assistant/src/conversation/conversation_view.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/conversation/conversation_view.tsx @@ -25,7 +25,7 @@ const SECOND_SLOT_CONTAINER_WIDTH = 400; interface ConversationViewProps { conversationId?: string; - navigateToConversation: (nextConversationId?: string) => void; + navigateToConversation?: (nextConversationId?: string) => void; getConversationHref?: (conversationId: string) => string; newConversationHref?: string; scopes?: AssistantScope[]; @@ -81,7 +81,9 @@ export const ConversationView: React.FC = ({ const handleConversationUpdate = (conversation: { conversation: { id: string } }) => { if (!conversationId) { updateConversationIdInPlace(conversation.conversation.id); - navigateToConversation(conversation.conversation.id); + if (navigateToConversation) { + navigateToConversation(conversation.conversation.id); + } } handleRefreshConversations(); }; @@ -143,7 +145,7 @@ export const ConversationView: React.FC = ({ isLoading={conversationList.isLoading} onConversationDeleteClick={(deletedConversationId) => { conversationList.deleteConversation(deletedConversationId).then(() => { - if (deletedConversationId === conversationId) { + if (deletedConversationId === conversationId && navigateToConversation) { navigateToConversation(undefined); } }); diff --git a/x-pack/packages/kbn-ai-assistant/src/hooks/__storybook_mocks__/use_knowledge_base.ts b/x-pack/packages/kbn-ai-assistant/src/hooks/__storybook_mocks__/use_knowledge_base.ts index bcb1725d35109..8859cc716cc52 100644 --- a/x-pack/packages/kbn-ai-assistant/src/hooks/__storybook_mocks__/use_knowledge_base.ts +++ b/x-pack/packages/kbn-ai-assistant/src/hooks/__storybook_mocks__/use_knowledge_base.ts @@ -17,6 +17,7 @@ export function useKnowledgeBase(): UseKnowledgeBaseResult { error: undefined, value: { ready: true, + enabled: true, }, }, }; diff --git a/x-pack/packages/kbn-ai-assistant/src/hooks/use_knowledge_base.tsx b/x-pack/packages/kbn-ai-assistant/src/hooks/use_knowledge_base.tsx index 0b949fcdbff0e..72d4fa0acf737 100644 --- a/x-pack/packages/kbn-ai-assistant/src/hooks/use_knowledge_base.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/hooks/use_knowledge_base.tsx @@ -20,6 +20,7 @@ import { useAIAssistantAppService } from './use_ai_assistant_app_service'; export interface UseKnowledgeBaseResult { status: AbortableAsyncState<{ ready: boolean; + enabled: boolean; error?: any; deployment_state?: MlDeploymentState; allocation_state?: MlDeploymentAllocationState; diff --git a/x-pack/packages/kbn-ai-assistant/src/prompt_editor/prompt_editor.tsx b/x-pack/packages/kbn-ai-assistant/src/prompt_editor/prompt_editor.tsx index cc2fe761d6176..c848935c17a3a 100644 --- a/x-pack/packages/kbn-ai-assistant/src/prompt_editor/prompt_editor.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/prompt_editor/prompt_editor.tsx @@ -18,6 +18,7 @@ import { useLastUsedPrompts } from '../hooks/use_last_used_prompts'; import { FunctionListPopover } from '../chat/function_list_popover'; import { PromptEditorFunction } from './prompt_editor_function'; import { PromptEditorNaturalLanguage } from './prompt_editor_natural_language'; +import { useScopes } from '../hooks/use_scopes'; export interface PromptEditorProps { disabled: boolean; @@ -42,6 +43,7 @@ export function PromptEditor({ onSendTelemetry, onSubmit, }: PromptEditorProps) { + const scopes = useScopes(); const containerRef = useRef(null); const [mode, setMode] = useState<'prompt' | 'function'>( @@ -121,16 +123,15 @@ export function PromptEditor({ setInnerMessage(undefined); setMode('prompt'); - onSendTelemetry({ type: ObservabilityAIAssistantTelemetryEventType.UserSentPromptInChat, - payload: message, + payload: { ...message, scopes }, }); } catch (_) { setInnerMessage(oldMessage); setMode(oldMessage.function_call?.name ? 'function' : 'prompt'); } - }, [addLastUsedPrompt, innerMessage, loading, onSendTelemetry, onSubmit]); + }, [addLastUsedPrompt, innerMessage, loading, onSendTelemetry, onSubmit, scopes]); // Submit on Enter useEffect(() => { diff --git a/x-pack/packages/kbn-ai-assistant/src/prompt_editor/prompt_editor_natural_language.tsx b/x-pack/packages/kbn-ai-assistant/src/prompt_editor/prompt_editor_natural_language.tsx index 0b84b504d9507..bdef8c5e3a079 100644 --- a/x-pack/packages/kbn-ai-assistant/src/prompt_editor/prompt_editor_natural_language.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/prompt_editor/prompt_editor_natural_language.tsx @@ -109,6 +109,15 @@ export function PromptEditorNaturalLanguage({ } }, [handleResizeTextArea, prompt]); + useEffect(() => { + // Attach the event listener to the window to catch mouseup outside the browser window + window.addEventListener('mouseup', handleResizeTextArea); + + return () => { + window.removeEventListener('mouseup', handleResizeTextArea); + }; + }, [handleResizeTextArea]); + return ( ; -function Providers({ children }: { children: React.ReactElement }) { +function Providers({ children }: { children: React.ReactNode }) { return ( { export const useNavigateVulnerabilities = () => useNavigate(findingsNavigation.vulnerabilities.path); + +export const useNavigateNativeVulnerabilities = () => { + const navToVulnerabilities = useNavigateVulnerabilities(); + + return useCallback( + (filterParams: NavFilter = {}, groupBy?: string[]) => { + navToVulnerabilities( + { ...filterParams, 'data_stream.dataset': 'cloud_security_posture.vulnerabilities' }, + groupBy + ); + }, + [navToVulnerabilities] + ); +}; diff --git a/x-pack/packages/kbn-elastic-assistant-common/constants.ts b/x-pack/packages/kbn-elastic-assistant-common/constants.ts index d84d9d4cd6825..49db6c295a51a 100755 --- a/x-pack/packages/kbn-elastic-assistant-common/constants.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/constants.ts @@ -50,6 +50,8 @@ export const ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL_FIND = `${ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL}/_find` as const; export const ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL_BULK_ACTION = `${ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL}/_bulk_action` as const; +export const ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_INDICES_URL = + `${ELASTIC_AI_ASSISTANT_INTERNAL_URL}/knowledge_base/_indices` as const; export const ELASTIC_AI_ASSISTANT_EVALUATE_URL = `${ELASTIC_AI_ASSISTANT_INTERNAL_URL}/evaluate` as const; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/capabilities/index.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/capabilities/index.ts index c1c101fd74cd8..54c24f6ce7b8f 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/capabilities/index.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/capabilities/index.ts @@ -19,6 +19,6 @@ export type AssistantFeatureKey = keyof AssistantFeatures; * Default features available to the elastic assistant */ export const defaultAssistantFeatures = Object.freeze({ - assistantKnowledgeBaseByDefault: false, + assistantKnowledgeBaseByDefault: true, assistantModelEvaluation: false, }); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/index.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/index.ts index 6304bfa4786cf..9233791a870c3 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/index.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/index.ts @@ -49,6 +49,7 @@ export * from './actions_connector/post_actions_connector_execute_route.gen'; // Knowledge Base Schemas export * from './knowledge_base/crud_kb_route.gen'; +export * from './knowledge_base/get_knowledge_base_indices_route.gen'; export * from './knowledge_base/entries/bulk_crud_knowledge_base_entries_route.gen'; export * from './knowledge_base/entries/common_attributes.gen'; export * from './knowledge_base/entries/crud_knowledge_base_entries_route.gen'; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/crud_kb_route.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/crud_kb_route.gen.ts index fd599f5798cdc..4f03dbe0b1343 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/crud_kb_route.gen.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/crud_kb_route.gen.ts @@ -15,6 +15,7 @@ */ import { z } from '@kbn/zod'; +import { BooleanFromString } from '@kbn/zod-helpers'; /** * AI assistant KnowledgeBase. @@ -33,6 +34,10 @@ export const CreateKnowledgeBaseRequestQuery = z.object({ * Optional ELSER modelId to use when setting up the Knowledge Base */ modelId: z.string().optional(), + /** + * Indicates whether we should or should not install Security Labs docs when setting up the Knowledge Base + */ + ignoreSecurityLabs: BooleanFromString.optional().default(false), }); export type CreateKnowledgeBaseRequestQueryInput = z.input; @@ -81,4 +86,5 @@ export const ReadKnowledgeBaseResponse = z.object({ is_setup_in_progress: z.boolean().optional(), pipeline_exists: z.boolean().optional(), security_labs_exists: z.boolean().optional(), + user_data_exists: z.boolean().optional(), }); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/crud_kb_route.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/crud_kb_route.schema.yaml index a61e98602ab40..b4c16189e2387 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/crud_kb_route.schema.yaml +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/crud_kb_route.schema.yaml @@ -24,6 +24,13 @@ paths: required: false schema: type: string + - name: ignoreSecurityLabs + in: query + description: Indicates whether we should or should not install Security Labs docs when setting up the Knowledge Base + required: false + schema: + type: boolean + default: false responses: 200: description: Indicates a successful call. @@ -78,6 +85,8 @@ paths: type: boolean security_labs_exists: type: boolean + user_data_exists: + type: boolean 400: description: Generic Error content: diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/get_knowledge_base_indices_route.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/get_knowledge_base_indices_route.gen.ts new file mode 100644 index 0000000000000..0e1df8bce089f --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/get_knowledge_base_indices_route.gen.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + * + * info: + * title: Get Knowledge Base Indices API endpoints + * version: 1 + */ + +import { z } from '@kbn/zod'; + +export type GetKnowledgeBaseIndicesResponse = z.infer; +export const GetKnowledgeBaseIndicesResponse = z.object({ + /** + * List of indices with at least one field of a `sematic_text` type. + */ + indices: z.array(z.string()), +}); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/get_knowledge_base_indices_route.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/get_knowledge_base_indices_route.schema.yaml new file mode 100644 index 0000000000000..f9dba830f9556 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/get_knowledge_base_indices_route.schema.yaml @@ -0,0 +1,42 @@ +openapi: 3.0.0 +info: + title: Get Knowledge Base Indices API endpoints + version: '1' +paths: + /internal/elastic_assistant/knowledge_base/_indices: + get: + x-codegen-enabled: true + x-labels: [ess, serverless] + operationId: GetKnowledgeBaseIndices + description: Gets Knowledge Base indices that have fields of a `sematic_text` type. + summary: Gets Knowledge Base indices that have fields of a `sematic_text` type. + tags: + - KnowledgeBase API + responses: + 200: + description: Indicates a successful call. + content: + application/json: + schema: + type: object + properties: + indices: + type: array + description: List of indices with at least one field of a `sematic_text` type. + items: + type: string + required: + - indices + 400: + description: Generic Error + content: + application/json: + schema: + type: object + properties: + statusCode: + type: number + error: + type: string + message: + type: string diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/api.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/api.test.tsx index 22ccd2bc0ecdf..5509f43037444 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/api.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/api.test.tsx @@ -7,7 +7,12 @@ import { HttpSetup } from '@kbn/core-http-browser'; -import { deleteKnowledgeBase, getKnowledgeBaseStatus, postKnowledgeBase } from './api'; +import { + deleteKnowledgeBase, + getKnowledgeBaseIndices, + getKnowledgeBaseStatus, + postKnowledgeBase, +} from './api'; jest.mock('@kbn/core-http-browser'); @@ -95,4 +100,29 @@ describe('API tests', () => { await expect(deleteKnowledgeBase(knowledgeBaseArgs)).resolves.toThrowError('simulated error'); }); }); + + describe('getKnowledgeBaseIndices', () => { + it('calls the knowledge base API when correct resource path', async () => { + await getKnowledgeBaseIndices({ http: mockHttp }); + + expect(mockHttp.fetch).toHaveBeenCalledWith( + '/internal/elastic_assistant/knowledge_base/_indices', + { + method: 'GET', + signal: undefined, + version: '1', + } + ); + }); + it('returns error when error is an error', async () => { + const error = 'simulated error'; + (mockHttp.fetch as jest.Mock).mockImplementation(() => { + throw new Error(error); + }); + + await expect(getKnowledgeBaseIndices({ http: mockHttp })).resolves.toThrowError( + 'simulated error' + ); + }); + }); }); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/api.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/api.tsx index 4dd03a1cb2931..4db8c0787a1e1 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/api.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/api.tsx @@ -11,7 +11,9 @@ import { CreateKnowledgeBaseResponse, DeleteKnowledgeBaseRequestParams, DeleteKnowledgeBaseResponse, + ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_INDICES_URL, ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_URL, + GetKnowledgeBaseIndicesResponse, ReadKnowledgeBaseRequestParams, ReadKnowledgeBaseResponse, } from '@kbn/elastic-assistant-common'; @@ -108,3 +110,32 @@ export const deleteKnowledgeBase = async ({ return error as IHttpFetchError; } }; + +/** + * API call for getting indices that have fields of `semantic_text` type. + * + * @param {Object} options - The options object. + * @param {HttpSetup} options.http - HttpSetup + * @param {AbortSignal} [options.signal] - AbortSignal + * + * @returns {Promise} + */ +export const getKnowledgeBaseIndices = async ({ + http, + signal, +}: { + http: HttpSetup; + signal?: AbortSignal | undefined; +}): Promise => { + try { + const response = await http.fetch(ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_INDICES_URL, { + method: 'GET', + signal, + version: API_VERSIONS.internal.v1, + }); + + return response as GetKnowledgeBaseIndicesResponse; + } catch (error) { + return error as IHttpFetchError; + } +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_knowledge_base_entries.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_knowledge_base_entries.ts index b41119779b21d..0775ed2d27a36 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_knowledge_base_entries.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_knowledge_base_entries.ts @@ -24,6 +24,7 @@ export interface UseKnowledgeBaseEntriesParams { signal?: AbortSignal | undefined; toasts?: IToasts; enabled?: boolean; // For disabling if FF is off + isRefetching?: boolean; // For enabling polling } const defaultQuery: FindKnowledgeBaseEntriesRequestQuery = { @@ -56,6 +57,7 @@ export const useKnowledgeBaseEntries = ({ signal, toasts, enabled = false, + isRefetching = false, }: UseKnowledgeBaseEntriesParams) => useQuery( KNOWLEDGE_BASE_ENTRY_QUERY_KEY, @@ -73,6 +75,7 @@ export const useKnowledgeBaseEntries = ({ enabled, keepPreviousData: true, initialData: { page: 1, perPage: 100, total: 0, data: [] }, + refetchInterval: isRefetching ? 30000 : false, onError: (error: IHttpFetchError) => { if (error.name !== 'AbortError') { toasts?.addError(error, { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/use_knowledge_base_indices.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/use_knowledge_base_indices.test.tsx new file mode 100644 index 0000000000000..4f258aa3c1964 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/use_knowledge_base_indices.test.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { + useKnowledgeBaseIndices, + UseKnowledgeBaseIndicesParams, +} from './use_knowledge_base_indices'; +import { getKnowledgeBaseIndices as _getKnowledgeBaseIndices } from './api'; + +const getKnowledgeBaseIndicesMock = _getKnowledgeBaseIndices as jest.Mock; + +jest.mock('./api', () => { + const actual = jest.requireActual('./api'); + return { + ...actual, + getKnowledgeBaseIndices: jest.fn((...args) => actual.getKnowledgeBaseIndices(...args)), + }; +}); + +jest.mock('@tanstack/react-query', () => ({ + useQuery: jest.fn().mockImplementation(async (queryKey, fn, opts) => { + try { + const res = await fn({}); + return Promise.resolve(res); + } catch (e) { + opts.onError(e); + } + }), +})); + +const indicesResponse = ['index-1', 'index-2', 'index-3']; + +const http = { + fetch: jest.fn().mockResolvedValue(indicesResponse), +}; +const toasts = { + addError: jest.fn(), +}; +const defaultProps = { http, toasts } as unknown as UseKnowledgeBaseIndicesParams; +describe('useKnowledgeBaseIndices', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call api to get knowledge base indices', async () => { + await act(async () => { + const { waitForNextUpdate } = renderHook(() => useKnowledgeBaseIndices(defaultProps)); + await waitForNextUpdate(); + + expect(defaultProps.http.fetch).toHaveBeenCalledWith( + '/internal/elastic_assistant/knowledge_base/_indices', + { + method: 'GET', + signal: undefined, + version: '1', + } + ); + expect(toasts.addError).not.toHaveBeenCalled(); + }); + }); + + it('should return indices response', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => useKnowledgeBaseIndices(defaultProps)); + await waitForNextUpdate(); + + await expect(result.current).resolves.toStrictEqual(indicesResponse); + }); + }); + + it('should display error toast when api throws error', async () => { + getKnowledgeBaseIndicesMock.mockRejectedValue(new Error('this is an error')); + await act(async () => { + const { waitForNextUpdate } = renderHook(() => useKnowledgeBaseIndices(defaultProps)); + await waitForNextUpdate(); + + expect(toasts.addError).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/use_knowledge_base_indices.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/use_knowledge_base_indices.tsx new file mode 100644 index 0000000000000..2b245c70754b5 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/use_knowledge_base_indices.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { UseQueryResult } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; +import type { HttpSetup, IHttpFetchError, ResponseErrorBody } from '@kbn/core-http-browser'; +import type { IToasts } from '@kbn/core-notifications-browser'; +import { i18n } from '@kbn/i18n'; +import { GetKnowledgeBaseIndicesResponse } from '@kbn/elastic-assistant-common'; +import { getKnowledgeBaseIndices } from './api'; + +const KNOWLEDGE_BASE_INDICES_QUERY_KEY = ['elastic-assistant', 'knowledge-base-indices']; + +export interface UseKnowledgeBaseIndicesParams { + http: HttpSetup; + toasts?: IToasts; +} + +/** + * Hook for getting indices that have fields of `semantic_text` type. + * + * @param {Object} options - The options object. + * @param {HttpSetup} options.http - HttpSetup + * @param {IToasts} [options.toasts] - IToasts + * + * @returns {useQuery} hook for getting indices that have fields of `semantic_text` type + */ +export const useKnowledgeBaseIndices = ({ + http, + toasts, +}: UseKnowledgeBaseIndicesParams): UseQueryResult< + GetKnowledgeBaseIndicesResponse, + IHttpFetchError +> => { + return useQuery( + KNOWLEDGE_BASE_INDICES_QUERY_KEY, + async ({ signal }) => { + return getKnowledgeBaseIndices({ http, signal }); + }, + { + onError: (error: IHttpFetchError) => { + if (error.name !== 'AbortError') { + toasts?.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { + title: i18n.translate('xpack.elasticAssistant.knowledgeBase.indicesError', { + defaultMessage: 'Error fetching Knowledge Base Indices', + }), + } + ); + } + }, + } + ); +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/use_knowledge_base_status.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/use_knowledge_base_status.test.tsx index 80ce3d27d8dcb..83073b5770ba0 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/use_knowledge_base_status.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/use_knowledge_base_status.test.tsx @@ -34,6 +34,7 @@ const statusResponse = { elser_exists: true, index_exists: true, pipeline_exists: true, + security_labs_exists: true, }; const http = { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/use_knowledge_base_status.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/use_knowledge_base_status.tsx index 75e78f2a06948..3ae89edc2a912 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/use_knowledge_base_status.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/use_knowledge_base_status.tsx @@ -20,6 +20,7 @@ export interface UseKnowledgeBaseStatusParams { http: HttpSetup; resource?: string; toasts?: IToasts; + enabled: boolean; } /** @@ -36,6 +37,7 @@ export const useKnowledgeBaseStatus = ({ http, resource, toasts, + enabled, }: UseKnowledgeBaseStatusParams): UseQueryResult => { return useQuery( KNOWLEDGE_BASE_STATUS_QUERY_KEY, @@ -43,8 +45,11 @@ export const useKnowledgeBaseStatus = ({ return getKnowledgeBaseStatus({ http, resource, signal }); }, { + enabled, retry: false, keepPreviousData: true, + // Polling interval for Knowledge Base setup in progress + refetchInterval: (data) => (data?.is_setup_in_progress ? 30000 : false), // Deprecated, hoist to `queryCache` w/in `QueryClient. See: https://stackoverflow.com/a/76961109 onError: (error: IHttpFetchError) => { if (error.name !== 'AbortError') { @@ -86,12 +91,12 @@ export const useInvalidateKnowledgeBaseStatus = () => { * * @param kbStatus ReadKnowledgeBaseResponse */ -export const isKnowledgeBaseSetup = (kbStatus: ReadKnowledgeBaseResponse | undefined): boolean => { - return ( - (kbStatus?.elser_exists && - kbStatus?.security_labs_exists && - kbStatus?.index_exists && - kbStatus?.pipeline_exists) ?? - false - ); -}; +export const isKnowledgeBaseSetup = (kbStatus: ReadKnowledgeBaseResponse | undefined): boolean => + (kbStatus?.elser_exists && + kbStatus?.index_exists && + kbStatus?.pipeline_exists && + // Allows to use UI while importing Security Labs docs + (kbStatus?.security_labs_exists || + kbStatus?.is_setup_in_progress || + kbStatus?.user_data_exists)) ?? + false; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/use_chat_send.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/use_chat_send.test.tsx index 0de7adc484fc1..f72f85892d379 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/use_chat_send.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/use_chat_send.test.tsx @@ -57,6 +57,9 @@ describe('use chat send', () => { assistantTelemetry: { reportAssistantMessageSent, }, + assistantAvailability: { + isAssistantEnabled: true, + }, }); }); it('handleOnChatCleared clears the conversation', async () => { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/use_chat_send.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/use_chat_send.tsx index 4ea376518b5a7..c240d5ac6b60b 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/use_chat_send.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/chat_send/use_chat_send.tsx @@ -52,12 +52,16 @@ export const useChatSend = ({ setSelectedPromptContexts, setCurrentConversation, }: UseChatSendProps): UseChatSend => { - const { assistantTelemetry, toasts } = useAssistantContext(); + const { + assistantTelemetry, + toasts, + assistantAvailability: { isAssistantEnabled }, + } = useAssistantContext(); const [userPrompt, setUserPrompt] = useState(null); const { isLoading, sendMessage, abortStream } = useSendMessage(); const { clearConversation, removeLastMessage } = useConversation(); - const { data: kbStatus } = useKnowledgeBaseStatus({ http }); + const { data: kbStatus } = useKnowledgeBaseStatus({ http, enabled: isAssistantEnabled }); const isSetupComplete = kbStatus?.elser_exists && kbStatus?.index_exists && diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.test.tsx index 1ef2db7b26c03..368477455c941 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.test.tsx @@ -24,7 +24,6 @@ import { Conversation } from '../assistant_context/types'; import * as all from './chat_send/use_chat_send'; import { useConversation } from './use_conversation'; import { AIConnector } from '../connectorland/connector_selector'; -import { omit } from 'lodash'; jest.mock('../connectorland/use_load_connectors'); jest.mock('../connectorland/connector_setup'); @@ -142,84 +141,6 @@ describe('Assistant', () => { }); describe('persistent storage', () => { - it('should refetchCurrentUserConversations after settings save button click', async () => { - const chatSendSpy = jest.spyOn(all, 'useChatSend'); - await renderAssistant(); - - fireEvent.click(screen.getByTestId('settings')); - - jest.mocked(useFetchCurrentUserConversations).mockReturnValue({ - data: { - ...mockData, - welcome_id: { - ...mockData.welcome_id, - apiConfig: { newProp: true }, - }, - }, - isLoading: false, - refetch: jest.fn().mockResolvedValue({ - isLoading: false, - data: { - ...mockData, - welcome_id: { - ...mockData.welcome_id, - apiConfig: { newProp: true }, - }, - }, - }), - isFetched: true, - } as unknown as DefinedUseQueryResult, unknown>); - - await act(async () => { - fireEvent.click(screen.getByTestId('save-button')); - }); - - expect(chatSendSpy).toHaveBeenLastCalledWith( - expect.objectContaining({ - currentConversation: { - apiConfig: { newProp: true }, - category: 'assistant', - id: mockData.welcome_id.id, - messages: [], - title: 'Welcome', - replacements: {}, - }, - }) - ); - }); - - it('should refetchCurrentUserConversations after settings save button click, but do not update convos when refetch returns bad results', async () => { - jest.mocked(useFetchCurrentUserConversations).mockReturnValue({ - data: mockData, - isLoading: false, - refetch: jest.fn().mockResolvedValue({ - isLoading: false, - data: omit(mockData, 'welcome_id'), - }), - isFetched: true, - } as unknown as DefinedUseQueryResult, unknown>); - const chatSendSpy = jest.spyOn(all, 'useChatSend'); - await renderAssistant(); - - fireEvent.click(screen.getByTestId('settings')); - await act(async () => { - fireEvent.click(screen.getByTestId('save-button')); - }); - - expect(chatSendSpy).toHaveBeenLastCalledWith( - expect.objectContaining({ - currentConversation: { - apiConfig: { connectorId: '123' }, - replacements: {}, - category: 'assistant', - id: mockData.welcome_id.id, - messages: [], - title: 'Welcome', - }, - }) - ); - }); - it('should delete conversation when delete button is clicked', async () => { await renderAssistant(); const deleteButton = screen.getAllByTestId('delete-option')[0]; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings.tsx index 57ad1312a271e..4aa3611f523c3 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings.tsx @@ -28,7 +28,7 @@ const AlertsSettingsComponent = ({ knowledgeBase, setUpdatedKnowledgeBaseSetting return ( <> { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings.tsx index a46ba652574f6..49830d337e7cb 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings.tsx @@ -44,8 +44,16 @@ interface Props { */ export const KnowledgeBaseSettings: React.FC = React.memo( ({ knowledgeBase, setUpdatedKnowledgeBaseSettings, modalMode = false }) => { - const { http, toasts } = useAssistantContext(); - const { data: kbStatus, isLoading, isFetching } = useKnowledgeBaseStatus({ http }); + const { + http, + toasts, + assistantAvailability: { isAssistantEnabled }, + } = useAssistantContext(); + const { + data: kbStatus, + isLoading, + isFetching, + } = useKnowledgeBaseStatus({ http, enabled: isAssistantEnabled }); const { mutate: setupKB, isLoading: isSettingUpKB } = useSetupKnowledgeBase({ http, toasts }); // Resource enabled state @@ -53,9 +61,9 @@ export const KnowledgeBaseSettings: React.FC = React.memo( const isSecurityLabsEnabled = kbStatus?.security_labs_exists ?? false; const isKnowledgeBaseSetup = (isElserEnabled && - isSecurityLabsEnabled && kbStatus?.index_exists && - kbStatus?.pipeline_exists) ?? + kbStatus?.pipeline_exists && + (isSecurityLabsEnabled || kbStatus?.user_data_exists)) ?? false; const isSetupInProgress = kbStatus?.is_setup_in_progress ?? false; const isSetupAvailable = kbStatus?.is_setup_available ?? false; @@ -141,7 +149,7 @@ export const KnowledgeBaseSettings: React.FC = React.memo( { }, isFetched: true, }); + (useKnowledgeBaseIndices as jest.Mock).mockReturnValue({ + data: { indices: ['index-1', 'index-2'] }, + }); (useKnowledgeBaseEntries as jest.Mock).mockReturnValue({ data: { data: mockData }, isFetching: false, @@ -421,6 +425,63 @@ describe('KnowledgeBaseSettingsManagement', () => { expect(mockCreateEntry).toHaveBeenCalledWith({ ...mockData[3], users: undefined }); }); + it('does not show duplicate entry modal on new document entry creation', async () => { + // Covers the BUG: https://github.com/elastic/kibana/issues/198892 + const closeFlyoutMock = jest.fn(); + (useFlyoutModalVisibility as jest.Mock).mockReturnValue({ + isFlyoutOpen: true, + openFlyout: jest.fn(), + closeFlyout: closeFlyoutMock, + }); + render(, { + wrapper, + }); + + await waitFor(() => { + fireEvent.click(screen.getAllByTestId('edit-button')[3]); + }); + expect(screen.getByTestId('flyout')).toBeVisible(); + + await waitFor(() => { + expect(screen.getByText('Edit document entry')).toBeInTheDocument(); + }); + + await waitFor(() => { + fireEvent.click(screen.getByTestId('sharing-select')); + fireEvent.click(screen.getByTestId('sharing-private-option')); + fireEvent.click(screen.getByTestId('save-button')); + }); + + expect(screen.getByTestId('create-duplicate-entry-modal')).toBeInTheDocument(); + await waitFor(() => { + fireEvent.click(screen.getByTestId('confirmModalConfirmButton')); + }); + expect(screen.queryByTestId('create-duplicate-entry-modal')).not.toBeInTheDocument(); + await waitFor(() => { + expect(mockCreateEntry).toHaveBeenCalledTimes(1); + }); + + // Create a new document entry + await waitFor(() => { + fireEvent.click(screen.getByTestId('addEntry')); + }); + await waitFor(() => { + fireEvent.click(screen.getByTestId('addDocument')); + }); + + expect(screen.getByTestId('flyout')).toBeVisible(); + + await userEvent.type(screen.getByTestId('entryNameInput'), 'hi'); + await userEvent.type(screen.getByTestId('entryMarkdownInput'), 'hi'); + + await waitFor(() => { + fireEvent.click(screen.getByTestId('save-button')); + }); + + expect(screen.queryByTestId('create-duplicate-entry-modal')).not.toBeInTheDocument(); + expect(closeFlyoutMock).toHaveBeenCalled(); + }); + it('shows warning icon for index entries with missing indices', async () => { render(, { wrapper, diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.tsx index 54ea159ff0589..86b3594daa3cd 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.tsx @@ -74,12 +74,15 @@ interface Params { export const KnowledgeBaseSettingsManagement: React.FC = React.memo(({ dataViews }) => { const { assistantFeatures: { assistantKnowledgeBaseByDefault: enableKnowledgeBaseByDefault }, - assistantAvailability: { hasManageGlobalKnowledgeBase }, + assistantAvailability: { hasManageGlobalKnowledgeBase, isAssistantEnabled }, http, toasts, } = useAssistantContext(); const [hasPendingChanges, setHasPendingChanges] = useState(false); - const { data: kbStatus, isFetched } = useKnowledgeBaseStatus({ http }); + const { data: kbStatus, isFetched } = useKnowledgeBaseStatus({ + http, + enabled: isAssistantEnabled, + }); const isKbSetup = isKnowledgeBaseSetup(kbStatus); const [deleteKBItem, setDeleteKBItem] = useState(null); @@ -159,29 +162,35 @@ export const KnowledgeBaseSettingsManagement: React.FC = React.memo(({ d } = useKnowledgeBaseEntries({ http, toasts, - enabled: enableKnowledgeBaseByDefault, + enabled: enableKnowledgeBaseByDefault && isAssistantEnabled, + isRefetching: kbStatus?.is_setup_in_progress, }); + const resetStateAndCloseFlyout = useCallback(() => { + setOriginalEntry(undefined); + setSelectedEntry(undefined); + setDuplicateKBItem(null); + closeFlyout(); + }, [closeFlyout]); + // Flyout Save/Cancel Actions const onSaveConfirmed = useCallback(async () => { if (isKnowledgeBaseEntryResponse(selectedEntry)) { await updateEntries([selectedEntry]); - closeFlyout(); + resetStateAndCloseFlyout(); } else if (isKnowledgeBaseEntryCreateProps(selectedEntry)) { if (originalEntry) { setDuplicateKBItem(selectedEntry); return; } await createEntry(selectedEntry); - closeFlyout(); + resetStateAndCloseFlyout(); } - }, [selectedEntry, originalEntry, updateEntries, closeFlyout, createEntry]); + }, [selectedEntry, updateEntries, resetStateAndCloseFlyout, originalEntry, createEntry]); const onSaveCancelled = useCallback(() => { - setOriginalEntry(undefined); - setSelectedEntry(undefined); - closeFlyout(); - }, [closeFlyout]); + resetStateAndCloseFlyout(); + }, [resetStateAndCloseFlyout]); const { value: existingIndices } = useAsync(() => { const indices: string[] = []; @@ -190,13 +199,15 @@ export const KnowledgeBaseSettingsManagement: React.FC = React.memo(({ d indices.push(entry.index); } }); - return dataViews.getExistingIndices(indices); + + return indices.length ? dataViews.getExistingIndices(indices) : Promise.resolve([]); }, [entries.data]); const { getColumns } = useKnowledgeBaseTable(); const columns = useMemo( () => getColumns({ + isKbSetupInProgress: kbStatus?.is_setup_in_progress ?? false, existingIndices, isDeleteEnabled: (entry: KnowledgeBaseEntryResponse) => { return ( @@ -219,7 +230,14 @@ export const KnowledgeBaseSettingsManagement: React.FC = React.memo(({ d openFlyout(); }, }), - [entries.data, existingIndices, getColumns, hasManageGlobalKnowledgeBase, openFlyout] + [ + entries.data, + existingIndices, + getColumns, + hasManageGlobalKnowledgeBase, + kbStatus?.is_setup_in_progress, + openFlyout, + ] ); // Refresh button @@ -310,10 +328,9 @@ export const KnowledgeBaseSettingsManagement: React.FC = React.memo(({ d const handleDuplicateEntry = useCallback(async () => { if (duplicateKBItem) { await createEntry(duplicateKBItem); - closeFlyout(); - setDuplicateKBItem(null); + resetStateAndCloseFlyout(); } - }, [closeFlyout, createEntry, duplicateKBItem]); + }, [createEntry, duplicateKBItem, resetStateAndCloseFlyout]); if (!enableKnowledgeBaseByDefault) { return ( @@ -421,6 +438,7 @@ export const KnowledgeBaseSettingsManagement: React.FC = React.memo(({ d /> ) : ( { const mockSetEntry = jest.fn(); const mockDataViews = { - getIndices: jest.fn().mockResolvedValue([{ name: 'index-1' }, { name: 'index-2' }]), getFieldsForWildcard: jest.fn().mockResolvedValue([ { name: 'field-1', esTypes: ['semantic_text'] }, { name: 'field-2', esTypes: ['text'] }, @@ -24,6 +27,9 @@ describe('IndexEntryEditor', () => { ]), getExistingIndices: jest.fn().mockResolvedValue(['index-1']), } as unknown as DataViewsContract; + const http = { + get: jest.fn(), + } as unknown as HttpSetup; const defaultProps = { dataViews: mockDataViews, @@ -37,10 +43,14 @@ describe('IndexEntryEditor', () => { queryDescription: 'Test Query Description', users: [], } as unknown as IndexEntry, + http, }; beforeEach(() => { jest.clearAllMocks(); + (useKnowledgeBaseIndices as jest.Mock).mockReturnValue({ + data: { indices: ['index-1', 'index-2'] }, + }); }); it('renders the form fields with initial values', async () => { @@ -102,7 +112,6 @@ describe('IndexEntryEditor', () => { const { getAllByTestId, getByTestId } = render(); await waitFor(() => { - expect(mockDataViews.getIndices).toHaveBeenCalled(); fireEvent.click(getByTestId('index-combobox')); fireEvent.click(getAllByTestId('comboBoxToggleListButton')[0]); fireEvent.click(getByTestId('index-2')); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index_entry_editor.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index_entry_editor.tsx index ff61c61ed7423..b55fb4b1b8270 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index_entry_editor.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index_entry_editor.tsx @@ -20,10 +20,13 @@ import useAsync from 'react-use/lib/useAsync'; import React, { useCallback, useMemo } from 'react'; import { IndexEntry } from '@kbn/elastic-assistant-common'; import { DataViewsContract } from '@kbn/data-views-plugin/public'; +import { HttpSetup } from '@kbn/core-http-browser'; import * as i18n from './translations'; import { isGlobalEntry } from './helpers'; +import { useKnowledgeBaseIndices } from '../../assistant/api/knowledge_base/use_knowledge_base_indices'; interface Props { + http: HttpSetup; dataViews: DataViewsContract; entry?: IndexEntry; originalEntry?: IndexEntry; @@ -32,7 +35,7 @@ interface Props { } export const IndexEntryEditor: React.FC = React.memo( - ({ dataViews, entry, setEntry, hasManageGlobalKnowledgeBase, originalEntry }) => { + ({ http, dataViews, entry, setEntry, hasManageGlobalKnowledgeBase, originalEntry }) => { const privateUsers = useMemo(() => { const originalUsers = originalEntry?.users; if (originalEntry && !isGlobalEntry(originalEntry)) { @@ -93,18 +96,16 @@ export const IndexEntryEditor: React.FC = React.memo( entry?.users?.length === 0 ? sharingOptions[1].value : sharingOptions[0].value; // Index - const indexOptions = useAsync(async () => { - const indices = await dataViews.getIndices({ - pattern: '*', - isRollupIndex: () => false, - }); - - return indices.map((index) => ({ - 'data-test-subj': index.name, - label: index.name, - value: index.name, + const { data: kbIndices } = useKnowledgeBaseIndices({ + http, + }); + const indexOptions = useMemo(() => { + return kbIndices?.indices.map((index) => ({ + 'data-test-subj': index, + label: index, + value: index, })); - }, [dataViews]); + }, [kbIndices?.indices]); const { value: isMissingIndex } = useAsync(async () => { if (!entry?.index?.length) return false; @@ -117,7 +118,7 @@ export const IndexEntryEditor: React.FC = React.memo( dataViews.getFieldsForWildcard({ pattern: entry?.index ?? '', }), - [] + [entry?.index] ); const fieldOptions = useMemo( @@ -272,6 +273,7 @@ export const IndexEntryEditor: React.FC = React.memo( fullWidth isInvalid={isMissingIndex} error={isMissingIndex && <>{i18n.MISSING_INDEX_ERROR}} + helpText={i18n.ENTRY_INDEX_NAME_INPUT_DESCRIPTION} > = React.memo( singleSelection={{ asPlainText: true }} onCreateOption={onCreateIndexOption} fullWidth - options={indexOptions.value ?? []} + options={indexOptions ?? []} selectedOptions={ entry?.index ? [ diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/translations.ts index b311f373c214b..24784586edcdf 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/translations.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/translations.ts @@ -234,6 +234,14 @@ export const ENTRY_INDEX_NAME_INPUT_LABEL = i18n.translate( } ); +export const ENTRY_INDEX_NAME_INPUT_DESCRIPTION = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryIndexNameInputDescription', + { + defaultMessage: + 'Indices will only be available to select from this drop down list if they contain a semantic_text field. Please refer to the documentation for more information on configuring an index for use as a custom knowledge source.', + } +); + export const ENTRY_FIELD_INPUT_LABEL = i18n.translate( 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryFieldInputLabel', { @@ -372,3 +380,10 @@ export const MISSING_INDEX_TOOLTIP_CONTENT = i18n.translate( 'The index assigned to this knowledge base entry is unavailable. Check the permissions on the configured index, or that the index has not been deleted. You can update the index to be used for this knowledge entry, or delete the entry entirely.', } ); + +export const SECURITY_LABS_NOT_FULLY_LOADED = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.securityLabsNotFullyLoadedTooltipContent', + { + defaultMessage: 'Security Labs content is not fully loaded. Click to reload.', + } +); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/use_knowledge_base_table.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/use_knowledge_base_table.tsx index 7180be139c286..cbdf97f116f7b 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/use_knowledge_base_table.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/use_knowledge_base_table.tsx @@ -11,6 +11,7 @@ import { EuiBasicTableColumn, EuiIcon, EuiText, + EuiLoadingSpinner, EuiToolTip, } from '@elastic/eui'; import { css } from '@emotion/react'; @@ -29,11 +30,16 @@ import * as i18n from './translations'; import { BadgesColumn } from '../../assistant/common/components/assistant_settings_management/badges'; import { useInlineActions } from '../../assistant/common/components/assistant_settings_management/inline_actions'; import { isSystemEntry } from './helpers'; +import { SetupKnowledgeBaseButton } from '../setup_knowledge_base_button'; const AuthorColumn = ({ entry }: { entry: KnowledgeBaseEntryResponse }) => { const { userProfileService } = useAssistantContext(); const userProfile = useAsync(async () => { + if (isSystemEntry(entry) || entry.createdBy === 'unknown') { + return; + } + const profile = await userProfileService?.bulkGet<{ avatar: UserProfileAvatarData }>({ uids: new Set([entry.createdBy]), dataPath: 'avatar', @@ -45,7 +51,7 @@ const AuthorColumn = ({ entry }: { entry: KnowledgeBaseEntryResponse }) => { () => userProfile?.value?.username ?? 'Unknown', [userProfile?.value?.username] ); - const userAvatar = userProfile.value?.avatar; + const userAvatar = userProfile?.value?.avatar; const badgeItem = isSystemEntry(entry) ? 'Elastic' : userName; const userImage = isSystemEntry(entry) ? ( { isEditEnabled, onDeleteActionClicked, onEditActionClicked, + isKbSetupInProgress, }: { existingIndices?: string[]; isDeleteEnabled: (entry: KnowledgeBaseEntryResponse) => boolean; isEditEnabled: (entry: KnowledgeBaseEntryResponse) => boolean; onDeleteActionClicked: (entry: KnowledgeBaseEntryResponse) => void; onEditActionClicked: (entry: KnowledgeBaseEntryResponse) => void; + isKbSetupInProgress: boolean; }): Array> => { return [ { @@ -180,11 +188,27 @@ export const useKnowledgeBaseTable = () => { { name: i18n.COLUMN_ENTRIES, render: (entry: KnowledgeBaseEntryResponse) => { - return isSystemEntry(entry) - ? entry.text - : entry.type === DocumentEntryType.value - ? '1' - : '-'; + return isSystemEntry(entry) ? ( + <> + {`${entry.text}`} + {isKbSetupInProgress ? ( + + ) : ( + + + + )} + + ) : entry.type === DocumentEntryType.value ? ( + '1' + ) : ( + '-' + ); }, }, { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/setup_knowledge_base_button.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/setup_knowledge_base_button.tsx index d697fc7120d01..41656c968d38e 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/setup_knowledge_base_button.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/setup_knowledge_base_button.tsx @@ -6,15 +6,16 @@ */ import React, { useCallback } from 'react'; -import { EuiButton, EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; +import { EuiButton, EuiButtonIcon, EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/react'; import { useAssistantContext } from '../..'; import { useSetupKnowledgeBase } from '../assistant/api/knowledge_base/use_setup_knowledge_base'; import { useKnowledgeBaseStatus } from '../assistant/api/knowledge_base/use_knowledge_base_status'; interface Props { - display?: 'mini'; + display?: 'mini' | 'refresh'; } /** @@ -22,9 +23,13 @@ interface Props { * */ export const SetupKnowledgeBaseButton: React.FC = React.memo(({ display }: Props) => { - const { http, toasts } = useAssistantContext(); + const { + http, + toasts, + assistantAvailability: { isAssistantEnabled }, + } = useAssistantContext(); - const { data: kbStatus } = useKnowledgeBaseStatus({ http }); + const { data: kbStatus } = useKnowledgeBaseStatus({ http, enabled: isAssistantEnabled }); const { mutate: setupKB, isLoading: isSettingUpKB } = useSetupKnowledgeBase({ http, toasts }); const isSetupInProgress = kbStatus?.is_setup_in_progress || isSettingUpKB; @@ -48,6 +53,23 @@ export const SetupKnowledgeBaseButton: React.FC = React.memo(({ display } }) : undefined; + if (display === 'refresh') { + return ( + + ); + } + return ( {display === 'mini' ? ( diff --git a/x-pack/packages/kbn-langchain/server/language_models/mocks/index.ts b/x-pack/packages/kbn-langchain/server/language_models/mocks/index.ts index f40bafca1a469..838f93ca308af 100644 --- a/x-pack/packages/kbn-langchain/server/language_models/mocks/index.ts +++ b/x-pack/packages/kbn-langchain/server/language_models/mocks/index.ts @@ -20,6 +20,7 @@ export const mockChatCompletion: OpenAI.ChatCompletion = { message: { role: 'assistant', content: 'Yes, your name is Andrew. How can I assist you further, Andrew?', + refusal: null, }, finish_reason: 'stop', logprobs: null, diff --git a/x-pack/packages/kbn-langchain/server/language_models/types.ts b/x-pack/packages/kbn-langchain/server/language_models/types.ts index 43dcad34fda3c..35415e8eaf118 100644 --- a/x-pack/packages/kbn-langchain/server/language_models/types.ts +++ b/x-pack/packages/kbn-langchain/server/language_models/types.ts @@ -11,7 +11,10 @@ import type OpenAI from 'openai'; export interface InvokeAIActionParamsSchema { messages: Array<{ role: string; - content: string | OpenAI.ChatCompletionContentPart[]; + content: + | string + | OpenAI.ChatCompletionContentPart[] + | Array; name?: string; function_call?: { arguments: string; diff --git a/x-pack/packages/kbn-slo-schema/src/schema/slo.ts b/x-pack/packages/kbn-slo-schema/src/schema/slo.ts index dcf18d0e3a82e..0576f1cf328eb 100644 --- a/x-pack/packages/kbn-slo-schema/src/schema/slo.ts +++ b/x-pack/packages/kbn-slo-schema/src/schema/slo.ts @@ -6,6 +6,7 @@ */ import * as t from 'io-ts'; +import { Either } from 'fp-ts/Either'; import { allOrAnyStringOrArray, dateType } from './common'; import { durationType } from './duration'; import { indicatorSchema } from './indicators'; @@ -36,7 +37,35 @@ const groupBySchema = allOrAnyStringOrArray; const optionalSettingsSchema = t.partial({ ...settingsSchema.props }); const tagsSchema = t.array(t.string); -const sloIdSchema = t.string; +// id cannot contain special characters and spaces +const sloIdSchema = new t.Type( + 'sloIdSchema', + t.string.is, + (input, context): Either => { + if (typeof input === 'string') { + const valid = isValidId(input); + if (!valid) { + return t.failure( + input, + context, + 'Invalid slo id, must be between 8 and 48 characters and contain only letters, numbers, hyphens, and underscores' + ); + } + + return t.success(input); + } else { + return t.failure(input, context); + } + }, + t.identity +); + +function isValidId(id: string): boolean { + const MIN_ID_LENGTH = 8; + const MAX_ID_LENGTH = 48; + const validLength = MIN_ID_LENGTH <= id.length && id.length <= MAX_ID_LENGTH; + return validLength && /^[a-z0-9-_]+$/.test(id); +} const sloDefinitionSchema = t.type({ id: sloIdSchema, diff --git a/x-pack/packages/kbn-slo-schema/tsconfig.json b/x-pack/packages/kbn-slo-schema/tsconfig.json index bc9fd2fdede8a..cd411fff0db4a 100644 --- a/x-pack/packages/kbn-slo-schema/tsconfig.json +++ b/x-pack/packages/kbn-slo-schema/tsconfig.json @@ -12,7 +12,7 @@ ], "kbn_references": [ "@kbn/std", - "@kbn/io-ts-utils" + "@kbn/io-ts-utils", ], "exclude": [ "target/**/*", diff --git a/x-pack/packages/ml/aiops_common/constants.ts b/x-pack/packages/ml/aiops_common/constants.ts index 39a0fdc5842c8..1a75e929c147a 100644 --- a/x-pack/packages/ml/aiops_common/constants.ts +++ b/x-pack/packages/ml/aiops_common/constants.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; + /** * AIOPS_PLUGIN_ID is used as a unique identifier for the aiops plugin */ @@ -28,3 +30,14 @@ export const AIOPS_EMBEDDABLE_ORIGIN = { DISCOVER: 'discover', ML_AIOPS_LABS: 'ml_aiops_labs', } as const; + +export const AIOPS_EMBEDDABLE_GROUPING = [ + { + id: 'logs-aiops', + getDisplayName: () => + i18n.translate('xpack.aiops.embedabble.groupingDisplayName', { + defaultMessage: 'Logs AIOps', + }), + getIconType: () => 'machineLearningApp', + }, +]; diff --git a/x-pack/packages/ml/aiops_common/tsconfig.json b/x-pack/packages/ml/aiops_common/tsconfig.json index 806b5b07e847e..ffd8c074a421d 100644 --- a/x-pack/packages/ml/aiops_common/tsconfig.json +++ b/x-pack/packages/ml/aiops_common/tsconfig.json @@ -15,6 +15,7 @@ ], "kbn_references": [ "@kbn/ml-is-populated-object", + "@kbn/i18n", ], "exclude": [ "target/**/*", diff --git a/x-pack/packages/ml/aiops_components/src/document_count_chart/document_count_chart.tsx b/x-pack/packages/ml/aiops_components/src/document_count_chart/document_count_chart.tsx index 39291762b8fca..d9f68fe7ef890 100644 --- a/x-pack/packages/ml/aiops_components/src/document_count_chart/document_count_chart.tsx +++ b/x-pack/packages/ml/aiops_components/src/document_count_chart/document_count_chart.tsx @@ -426,7 +426,12 @@ export const DocumentCountChart: FC = (props) => { <> {isBrushVisible && (
-
+ {/** + * We need position:relative on this parent container of the BrushBadges, + * because of the absolute positioning of the BrushBadges. Without it, the + * BrushBadges would not be positioned correctly when used in embedded panels. + */} +
= (props) => { const d3BrushContainer = useRef(null); const brushes = useRef([]); + // id to prefix html ids for the brushes since this component can be used + // multiple times within dashboard and embedded charts. + const htmlId = useMemo(() => htmlIdGenerator()(), []); + // We need to pass props to refs here because the d3-brush code doesn't consider // native React prop changes. The brush code does its own check whether these props changed then. // The initialized brushes might otherwise act on stale data. @@ -135,10 +141,10 @@ export const DualBrush: FC = (props) => { const xMax = x(maxRef.current) ?? 0; const minExtentPx = Math.round((xMax - xMin) / 100); - const baselineBrush = d3.select('#aiops-brush-baseline'); + const baselineBrush = d3.select(`#aiops-brush-baseline-${htmlId}`); const baselineSelection = d3.brushSelection(baselineBrush.node() as SVGGElement); - const deviationBrush = d3.select('#aiops-brush-deviation'); + const deviationBrush = d3.select(`#aiops-brush-deviation-${htmlId}`); const deviationSelection = d3.brushSelection(deviationBrush.node() as SVGGElement); if (!isBrushXSelection(deviationSelection) || !isBrushXSelection(baselineSelection)) { @@ -260,7 +266,7 @@ export const DualBrush: FC = (props) => { .insert('g', '.brush') .attr('class', 'brush') .attr('id', (b: DualBrush) => { - return 'aiops-brush-' + b.id; + return `aiops-brush-${b.id}-${htmlId}`; }) .attr('data-test-subj', (b: DualBrush) => { // Uppercase the first character of the `id` so we get aiopsBrushBaseline/aiopsBrushDeviation. @@ -339,6 +345,7 @@ export const DualBrush: FC = (props) => { drawBrushes(); } }, [ + htmlId, min, max, width, diff --git a/x-pack/packages/ml/aiops_components/src/progress_controls/progress_controls.tsx b/x-pack/packages/ml/aiops_components/src/progress_controls/progress_controls.tsx index 098f7038f82c8..173f33e08f0b4 100644 --- a/x-pack/packages/ml/aiops_components/src/progress_controls/progress_controls.tsx +++ b/x-pack/packages/ml/aiops_components/src/progress_controls/progress_controls.tsx @@ -70,7 +70,6 @@ export const ProgressControls: FC> = (pr const { euiTheme } = useEuiTheme(); const runningProgressBarStyles = useAnimatedProgressBarBackground(euiTheme.colors.success); - const analysisCompleteStyle = { display: 'none' }; return ( @@ -144,32 +143,30 @@ export const ProgressControls: FC> = (pr ) : null} - - - - + + + + + + + - - - - - - + + + ) : null} {children} diff --git a/x-pack/packages/ml/aiops_log_rate_analysis/constants.ts b/x-pack/packages/ml/aiops_log_rate_analysis/constants.ts index 054bb876a4f7a..a9812a7507441 100644 --- a/x-pack/packages/ml/aiops_log_rate_analysis/constants.ts +++ b/x-pack/packages/ml/aiops_log_rate_analysis/constants.ts @@ -33,3 +33,9 @@ export const RANDOM_SAMPLER_SEED = 3867412; /** Highlighting color for charts */ export const LOG_RATE_ANALYSIS_HIGHLIGHT_COLOR = 'orange'; + +/** */ +export const EMBEDDABLE_LOG_RATE_ANALYSIS_TYPE = 'aiopsLogRateAnalysisEmbeddable' as const; + +/** */ +export const LOG_RATE_ANALYSIS_DATA_VIEW_REF_NAME = 'aiopsLogRateAnalysisEmbeddableDataViewId'; diff --git a/x-pack/packages/ml/aiops_log_rate_analysis/state/hooks.ts b/x-pack/packages/ml/aiops_log_rate_analysis/state/hooks.ts index 4652d604c5d61..d02a3bea22bf3 100644 --- a/x-pack/packages/ml/aiops_log_rate_analysis/state/hooks.ts +++ b/x-pack/packages/ml/aiops_log_rate_analysis/state/hooks.ts @@ -6,10 +6,9 @@ */ import type { TypedUseSelectorHook } from 'react-redux'; -import { useDispatch, useSelector, useStore } from 'react-redux'; -import type { AppDispatch, AppStore, RootState } from './store'; +import { useDispatch, useSelector } from 'react-redux'; +import type { AppDispatch, RootState } from './store'; // Improves TypeScript support compared to plain `useDispatch` and `useSelector` export const useAppDispatch: () => AppDispatch = useDispatch; export const useAppSelector: TypedUseSelectorHook = useSelector; -export const useAppStore: () => AppStore = useStore; diff --git a/x-pack/packages/ml/aiops_log_rate_analysis/state/index.ts b/x-pack/packages/ml/aiops_log_rate_analysis/state/index.ts index 785bb02c24f31..7f7710ec23f3b 100644 --- a/x-pack/packages/ml/aiops_log_rate_analysis/state/index.ts +++ b/x-pack/packages/ml/aiops_log_rate_analysis/state/index.ts @@ -11,6 +11,7 @@ export { setAnalysisType, setAutoRunAnalysis, setDocumentCountChartData, + setGroupResults, setInitialAnalysisStart, setIsBrushCleared, setStickyHistogram, @@ -23,9 +24,10 @@ export { setPinnedSignificantItem, setSelectedGroup, setSelectedSignificantItem, -} from './log_rate_analysis_table_row_slice'; + setSkippedColumns, +} from './log_rate_analysis_table_slice'; export { LogRateAnalysisReduxProvider } from './store'; -export { useAppDispatch, useAppSelector, useAppStore } from './hooks'; +export { useAppDispatch, useAppSelector } from './hooks'; export { useCurrentSelectedGroup } from './use_current_selected_group'; export { useCurrentSelectedSignificantItem } from './use_current_selected_significant_item'; export type { GroupTableItem, GroupTableItemGroup, TableItemAction } from './types'; diff --git a/x-pack/packages/ml/aiops_log_rate_analysis/state/log_rate_analysis_field_candidates_slice.test.ts b/x-pack/packages/ml/aiops_log_rate_analysis/state/log_rate_analysis_field_candidates_slice.test.ts index 4f829b0e0bf5a..5b4946dc2eb2b 100644 --- a/x-pack/packages/ml/aiops_log_rate_analysis/state/log_rate_analysis_field_candidates_slice.test.ts +++ b/x-pack/packages/ml/aiops_log_rate_analysis/state/log_rate_analysis_field_candidates_slice.test.ts @@ -9,14 +9,16 @@ import { httpServiceMock } from '@kbn/core/public/mocks'; import type { FetchFieldCandidatesResponse } from '../queries/fetch_field_candidates'; -import { fetchFieldCandidates } from './log_rate_analysis_field_candidates_slice'; +import { fetchFieldCandidates, getDefaultState } from './log_rate_analysis_field_candidates_slice'; const mockHttp = httpServiceMock.createStartContract(); describe('fetchFieldCandidates', () => { it('dispatches field candidates', async () => { const mockDispatch = jest.fn(); - const mockGetState = jest.fn(); + const mockGetState = jest.fn().mockReturnValue({ + logRateAnalysisFieldCandidates: getDefaultState(), + }); const mockResponse: FetchFieldCandidatesResponse = { isECS: false, @@ -60,7 +62,12 @@ describe('fetchFieldCandidates', () => { payload: { fieldSelectionMessage: '2 out of 5 fields were preselected for the analysis. Use the "Fields" dropdown to adjust the selection.', - fieldFilterSkippedItems: [ + initialFieldFilterSkippedItems: [ + 'another-keyword-field', + 'another-text-field', + 'yet-another-text-field', + ], + currentFieldFilterSkippedItems: [ 'another-keyword-field', 'another-text-field', 'yet-another-text-field', diff --git a/x-pack/packages/ml/aiops_log_rate_analysis/state/log_rate_analysis_field_candidates_slice.ts b/x-pack/packages/ml/aiops_log_rate_analysis/state/log_rate_analysis_field_candidates_slice.ts index aa5cb969e5401..07b1cd6fee402 100644 --- a/x-pack/packages/ml/aiops_log_rate_analysis/state/log_rate_analysis_field_candidates_slice.ts +++ b/x-pack/packages/ml/aiops_log_rate_analysis/state/log_rate_analysis_field_candidates_slice.ts @@ -90,10 +90,14 @@ export const fetchFieldCandidates = createAsyncThunk( ...selectedKeywordFieldCandidates, ...selectedTextFieldCandidates, ]; - const fieldFilterSkippedItems = fieldFilterUniqueItems.filter( + const initialFieldFilterSkippedItems = fieldFilterUniqueItems.filter( (d) => !fieldFilterUniqueSelectedItems.includes(d) ); + const currentFieldFilterSkippedItems = ( + thunkApi.getState() as { logRateAnalysisFieldCandidates: FieldCandidatesState } + ).logRateAnalysisFieldCandidates.currentFieldFilterSkippedItems; + thunkApi.dispatch( setAllFieldCandidates({ fieldSelectionMessage: getFieldSelectionMessage( @@ -102,7 +106,13 @@ export const fetchFieldCandidates = createAsyncThunk( fieldFilterUniqueSelectedItems.length ), fieldFilterUniqueItems, - fieldFilterSkippedItems, + initialFieldFilterSkippedItems, + // If the currentFieldFilterSkippedItems is null, we're on the first load, + // only then we set the current skipped fields to the initial skipped fields. + currentFieldFilterSkippedItems: + currentFieldFilterSkippedItems === null + ? initialFieldFilterSkippedItems + : currentFieldFilterSkippedItems, keywordFieldCandidates, textFieldCandidates, selectedKeywordFieldCandidates, @@ -116,18 +126,20 @@ export interface FieldCandidatesState { isLoading: boolean; fieldSelectionMessage?: string; fieldFilterUniqueItems: string[]; - fieldFilterSkippedItems: string[]; + initialFieldFilterSkippedItems: string[]; + currentFieldFilterSkippedItems: string[] | null; keywordFieldCandidates: string[]; textFieldCandidates: string[]; selectedKeywordFieldCandidates: string[]; selectedTextFieldCandidates: string[]; } -function getDefaultState(): FieldCandidatesState { +export function getDefaultState(): FieldCandidatesState { return { isLoading: false, fieldFilterUniqueItems: [], - fieldFilterSkippedItems: [], + initialFieldFilterSkippedItems: [], + currentFieldFilterSkippedItems: null, keywordFieldCandidates: [], textFieldCandidates: [], selectedKeywordFieldCandidates: [], @@ -145,6 +157,12 @@ export const logRateAnalysisFieldCandidatesSlice = createSlice({ ) => { return { ...state, ...action.payload }; }, + setCurrentFieldFilterSkippedItems: ( + state: FieldCandidatesState, + action: PayloadAction + ) => { + return { ...state, currentFieldFilterSkippedItems: action.payload }; + }, }, extraReducers: (builder) => { builder.addCase(fetchFieldCandidates.pending, (state) => { @@ -157,4 +175,5 @@ export const logRateAnalysisFieldCandidatesSlice = createSlice({ }); // Action creators are generated for each case reducer function -export const { setAllFieldCandidates } = logRateAnalysisFieldCandidatesSlice.actions; +export const { setAllFieldCandidates, setCurrentFieldFilterSkippedItems } = + logRateAnalysisFieldCandidatesSlice.actions; diff --git a/x-pack/packages/ml/aiops_log_rate_analysis/state/log_rate_analysis_slice.ts b/x-pack/packages/ml/aiops_log_rate_analysis/state/log_rate_analysis_slice.ts index 251f0d3263800..8399e896900c6 100644 --- a/x-pack/packages/ml/aiops_log_rate_analysis/state/log_rate_analysis_slice.ts +++ b/x-pack/packages/ml/aiops_log_rate_analysis/state/log_rate_analysis_slice.ts @@ -34,6 +34,7 @@ export interface LogRateAnalysisState { autoRunAnalysis: boolean; initialAnalysisStart: InitialAnalysisStart; isBrushCleared: boolean; + groupResults: boolean; stickyHistogram: boolean; chartWindowParameters?: WindowParameters; earliest?: number; @@ -48,6 +49,7 @@ function getDefaultState(): LogRateAnalysisState { autoRunAnalysis: true, initialAnalysisStart: undefined, isBrushCleared: true, + groupResults: false, documentStats: { sampleProbability: 1, totalCount: 0, @@ -98,6 +100,9 @@ export const logRateAnalysisSlice = createSlice({ state.intervalMs = action.payload.intervalMs; state.documentStats = action.payload.documentStats; }, + setGroupResults: (state: LogRateAnalysisState, action: PayloadAction) => { + state.groupResults = action.payload; + }, setInitialAnalysisStart: ( state: LogRateAnalysisState, action: PayloadAction @@ -127,6 +132,7 @@ export const { setAnalysisType, setAutoRunAnalysis, setDocumentCountChartData, + setGroupResults, setInitialAnalysisStart, setIsBrushCleared, setStickyHistogram, diff --git a/x-pack/packages/ml/aiops_log_rate_analysis/state/log_rate_analysis_table_row_slice.ts b/x-pack/packages/ml/aiops_log_rate_analysis/state/log_rate_analysis_table_row_slice.ts deleted file mode 100644 index 3da98e4cc80ff..0000000000000 --- a/x-pack/packages/ml/aiops_log_rate_analysis/state/log_rate_analysis_table_row_slice.ts +++ /dev/null @@ -1,72 +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 { PayloadAction } from '@reduxjs/toolkit'; -import { createSlice } from '@reduxjs/toolkit'; - -import type { SignificantItem } from '@kbn/ml-agg-utils'; - -import type { GroupTableItem } from './types'; - -type SignificantItemOrNull = SignificantItem | null; -type GroupOrNull = GroupTableItem | null; - -export interface LogRateAnalysisTableRowState { - pinnedGroup: GroupOrNull; - pinnedSignificantItem: SignificantItemOrNull; - selectedGroup: GroupOrNull; - selectedSignificantItem: SignificantItemOrNull; -} - -function getDefaultState(): LogRateAnalysisTableRowState { - return { - pinnedGroup: null, - pinnedSignificantItem: null, - selectedGroup: null, - selectedSignificantItem: null, - }; -} - -export const logRateAnalysisTableRowSlice = createSlice({ - name: 'logRateAnalysisTableRow', - initialState: getDefaultState(), - reducers: { - clearAllRowState: (state: LogRateAnalysisTableRowState) => { - state.pinnedGroup = null; - state.pinnedSignificantItem = null; - state.selectedGroup = null; - state.selectedSignificantItem = null; - }, - setPinnedGroup: (state: LogRateAnalysisTableRowState, action: PayloadAction) => { - state.pinnedGroup = action.payload; - }, - setPinnedSignificantItem: ( - state: LogRateAnalysisTableRowState, - action: PayloadAction - ) => { - state.pinnedSignificantItem = action.payload; - }, - setSelectedGroup: (state: LogRateAnalysisTableRowState, action: PayloadAction) => { - state.selectedGroup = action.payload; - }, - setSelectedSignificantItem: ( - state: LogRateAnalysisTableRowState, - action: PayloadAction - ) => { - state.selectedSignificantItem = action.payload; - }, - }, -}); - -// Action creators are generated for each case reducer function -export const { - clearAllRowState, - setPinnedGroup, - setPinnedSignificantItem, - setSelectedGroup, - setSelectedSignificantItem, -} = logRateAnalysisTableRowSlice.actions; diff --git a/x-pack/packages/ml/aiops_log_rate_analysis/state/log_rate_analysis_table_slice.test.ts b/x-pack/packages/ml/aiops_log_rate_analysis/state/log_rate_analysis_table_slice.test.ts new file mode 100644 index 0000000000000..498ada00654f0 --- /dev/null +++ b/x-pack/packages/ml/aiops_log_rate_analysis/state/log_rate_analysis_table_slice.test.ts @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { configureStore } from '@reduxjs/toolkit'; +import { + logRateAnalysisTableSlice, + localStorageListenerMiddleware, + setSkippedColumns, + getPreloadedState, + AIOPS_LOG_RATE_ANALYSIS_RESULT_COLUMNS, + type LogRateAnalysisResultsTableColumnName, +} from './log_rate_analysis_table_slice'; + +describe('getPreloadedState', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('should return default state when localStorage is empty', () => { + const state = getPreloadedState(); + expect(state).toEqual({ + skippedColumns: ['p-value', 'Baseline rate', 'Deviation rate'], + pinnedGroup: null, + pinnedSignificantItem: null, + selectedGroup: null, + selectedSignificantItem: null, + }); + }); + + it('should return state with skippedColumns from localStorage', () => { + const skippedColumns = ['Log rate', 'Doc count']; + localStorage.setItem(AIOPS_LOG_RATE_ANALYSIS_RESULT_COLUMNS, JSON.stringify(skippedColumns)); + + const state = getPreloadedState(); + expect(state.skippedColumns).toEqual(skippedColumns); + }); + + it('should return default state when localStorage contains invalid JSON', () => { + localStorage.setItem(AIOPS_LOG_RATE_ANALYSIS_RESULT_COLUMNS, 'invalid-json'); + + const state = getPreloadedState(); + expect(state).toEqual({ + skippedColumns: ['p-value', 'Baseline rate', 'Deviation rate'], + pinnedGroup: null, + pinnedSignificantItem: null, + selectedGroup: null, + selectedSignificantItem: null, + }); + }); + + it('should return default state when localStorage does not contain skippedColumns', () => { + localStorage.setItem('someOtherKey', JSON.stringify(['someValue'])); + + const state = getPreloadedState(); + expect(state).toEqual({ + skippedColumns: ['p-value', 'Baseline rate', 'Deviation rate'], + pinnedGroup: null, + pinnedSignificantItem: null, + selectedGroup: null, + selectedSignificantItem: null, + }); + }); +}); + +type Store = ReturnType; + +describe('localStorageListenerMiddleware', () => { + let store: Store; + + beforeEach(() => { + localStorage.clear(); + store = configureStore({ + reducer: { + logRateAnalysisTable: logRateAnalysisTableSlice.reducer, + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().prepend(localStorageListenerMiddleware.middleware), + }) as Store; + }); + + it('should save skippedColumns to localStorage when setSkippedColumns is dispatched', () => { + const skippedColumns: LogRateAnalysisResultsTableColumnName[] = ['Log rate', 'Doc count']; + store.dispatch(setSkippedColumns(skippedColumns)); + + const storedSkippedColumns = localStorage.getItem(AIOPS_LOG_RATE_ANALYSIS_RESULT_COLUMNS); + expect(storedSkippedColumns).toEqual(JSON.stringify(skippedColumns)); + }); + + it('should handle invalid JSON in localStorage gracefully', () => { + localStorage.setItem(AIOPS_LOG_RATE_ANALYSIS_RESULT_COLUMNS, 'invalid-json'); + const skippedColumns: LogRateAnalysisResultsTableColumnName[] = ['Log rate', 'Doc count']; + store.dispatch(setSkippedColumns(skippedColumns)); + + const storedSkippedColumns = localStorage.getItem(AIOPS_LOG_RATE_ANALYSIS_RESULT_COLUMNS); + expect(storedSkippedColumns).toEqual(JSON.stringify(skippedColumns)); + }); + + it('should not overwrite other localStorage keys', () => { + const otherKey = 'someOtherKey'; + const otherValue = ['someValue']; + localStorage.setItem(otherKey, JSON.stringify(otherValue)); + + const skippedColumns: LogRateAnalysisResultsTableColumnName[] = ['Log rate', 'Doc count']; + store.dispatch(setSkippedColumns(skippedColumns)); + + const storedOtherValue = localStorage.getItem(otherKey); + expect(storedOtherValue).toEqual(JSON.stringify(otherValue)); + }); + + it('should update localStorage when skippedColumns are updated multiple times', () => { + const initialSkippedColumns: LogRateAnalysisResultsTableColumnName[] = ['Log rate']; + store.dispatch(setSkippedColumns(initialSkippedColumns)); + + let storedSkippedColumns = localStorage.getItem(AIOPS_LOG_RATE_ANALYSIS_RESULT_COLUMNS); + expect(storedSkippedColumns).toEqual(JSON.stringify(initialSkippedColumns)); + + const updatedSkippedColumns: LogRateAnalysisResultsTableColumnName[] = [ + 'Log rate', + 'Doc count', + ]; + store.dispatch(setSkippedColumns(updatedSkippedColumns)); + + storedSkippedColumns = localStorage.getItem(AIOPS_LOG_RATE_ANALYSIS_RESULT_COLUMNS); + expect(storedSkippedColumns).toEqual(JSON.stringify(updatedSkippedColumns)); + }); +}); diff --git a/x-pack/packages/ml/aiops_log_rate_analysis/state/log_rate_analysis_table_slice.ts b/x-pack/packages/ml/aiops_log_rate_analysis/state/log_rate_analysis_table_slice.ts new file mode 100644 index 0000000000000..1d9c83dea98a6 --- /dev/null +++ b/x-pack/packages/ml/aiops_log_rate_analysis/state/log_rate_analysis_table_slice.ts @@ -0,0 +1,172 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { PayloadAction } from '@reduxjs/toolkit'; +import { createSlice, createListenerMiddleware } from '@reduxjs/toolkit'; + +import { i18n } from '@kbn/i18n'; +import type { SignificantItem } from '@kbn/ml-agg-utils'; + +import type { GroupTableItem } from './types'; + +export const AIOPS_LOG_RATE_ANALYSIS_RESULT_COLUMNS = 'aiops.logRateAnalysisResultColumns'; + +export const commonColumns = { + ['Log rate']: i18n.translate('xpack.aiops.logRateAnalysis.resultsTable.logRateColumnTitle', { + defaultMessage: 'Log rate', + }), + ['Doc count']: i18n.translate('xpack.aiops.logRateAnalysis.resultsTable.docCountColumnTitle', { + defaultMessage: 'Doc count', + }), + ['p-value']: i18n.translate('xpack.aiops.logRateAnalysis.resultsTable.pValueColumnTitle', { + defaultMessage: 'p-value', + }), + ['Impact']: i18n.translate('xpack.aiops.logRateAnalysis.resultsTable.impactColumnTitle', { + defaultMessage: 'Impact', + }), + ['Baseline rate']: i18n.translate( + 'xpack.aiops.logRateAnalysis.resultsTable.baselineRateColumnTitle', + { + defaultMessage: 'Baseline rate', + } + ), + ['Deviation rate']: i18n.translate( + 'xpack.aiops.logRateAnalysis.resultsTable.deviationRateColumnTitle', + { + defaultMessage: 'Deviation rate', + } + ), + ['Log rate change']: i18n.translate( + 'xpack.aiops.logRateAnalysis.resultsTable.logRateChangeColumnTitle', + { + defaultMessage: 'Log rate change', + } + ), + ['Actions']: i18n.translate('xpack.aiops.logRateAnalysis.resultsTable.actionsColumnTitle', { + defaultMessage: 'Actions', + }), +}; + +export const significantItemColumns = { + ['Field name']: i18n.translate('xpack.aiops.logRateAnalysis.resultsTable.fieldNameColumnTitle', { + defaultMessage: 'Field name', + }), + ['Field value']: i18n.translate( + 'xpack.aiops.logRateAnalysis.resultsTable.fieldValueColumnTitle', + { + defaultMessage: 'Field value', + } + ), + ...commonColumns, +} as const; + +export type LogRateAnalysisResultsTableColumnName = keyof typeof significantItemColumns | 'unique'; + +type SignificantItemOrNull = SignificantItem | null; +type GroupOrNull = GroupTableItem | null; + +export interface LogRateAnalysisTableState { + skippedColumns: LogRateAnalysisResultsTableColumnName[]; + pinnedGroup: GroupOrNull; + pinnedSignificantItem: SignificantItemOrNull; + selectedGroup: GroupOrNull; + selectedSignificantItem: SignificantItemOrNull; +} + +function getDefaultState(): LogRateAnalysisTableState { + return { + skippedColumns: ['p-value', 'Baseline rate', 'Deviation rate'], + pinnedGroup: null, + pinnedSignificantItem: null, + selectedGroup: null, + selectedSignificantItem: null, + }; +} + +export function getPreloadedState(): LogRateAnalysisTableState { + const defaultState = getDefaultState(); + + const localStorageSkippedColumns = localStorage.getItem(AIOPS_LOG_RATE_ANALYSIS_RESULT_COLUMNS); + + if (localStorageSkippedColumns === null) { + return defaultState; + } + + try { + defaultState.skippedColumns = JSON.parse(localStorageSkippedColumns); + } catch (err) { + // eslint-disable-next-line no-console + console.warn('Failed to parse skipped columns from local storage:', err); + } + + return defaultState; +} + +export const logRateAnalysisTableSlice = createSlice({ + name: 'logRateAnalysisTable', + initialState: getDefaultState(), + reducers: { + clearAllRowState: (state: LogRateAnalysisTableState) => { + state.pinnedGroup = null; + state.pinnedSignificantItem = null; + state.selectedGroup = null; + state.selectedSignificantItem = null; + }, + setPinnedGroup: (state: LogRateAnalysisTableState, action: PayloadAction) => { + state.pinnedGroup = action.payload; + }, + setPinnedSignificantItem: ( + state: LogRateAnalysisTableState, + action: PayloadAction + ) => { + state.pinnedSignificantItem = action.payload; + }, + setSelectedGroup: (state: LogRateAnalysisTableState, action: PayloadAction) => { + state.selectedGroup = action.payload; + }, + setSelectedSignificantItem: ( + state: LogRateAnalysisTableState, + action: PayloadAction + ) => { + state.selectedSignificantItem = action.payload; + }, + setSkippedColumns: ( + state: LogRateAnalysisTableState, + action: PayloadAction + ) => { + state.skippedColumns = action.payload; + }, + }, +}); + +// Action creators are generated for each case reducer function +export const { + clearAllRowState, + setPinnedGroup, + setPinnedSignificantItem, + setSelectedGroup, + setSelectedSignificantItem, + setSkippedColumns, +} = logRateAnalysisTableSlice.actions; + +// Create listener middleware +export const localStorageListenerMiddleware = createListenerMiddleware(); + +// Add a listener to save skippedColumns to localStorage whenever it changes +localStorageListenerMiddleware.startListening({ + actionCreator: setSkippedColumns, + effect: (action, listenerApi) => { + const state = listenerApi.getState() as { logRateAnalysisTable: LogRateAnalysisTableState }; + try { + const serializedState = JSON.stringify(state.logRateAnalysisTable.skippedColumns); + localStorage.setItem(AIOPS_LOG_RATE_ANALYSIS_RESULT_COLUMNS, serializedState); + } catch (err) { + // eslint-disable-next-line no-console + console.warn('Failed to save state to localStorage:', err); + } + }, +}); diff --git a/x-pack/packages/ml/aiops_log_rate_analysis/state/store.tsx b/x-pack/packages/ml/aiops_log_rate_analysis/state/store.tsx index 1589b27348d89..9fd8e8240dde3 100644 --- a/x-pack/packages/ml/aiops_log_rate_analysis/state/store.tsx +++ b/x-pack/packages/ml/aiops_log_rate_analysis/state/store.tsx @@ -15,12 +15,19 @@ import { streamSlice } from '@kbn/ml-response-stream/client'; import { logRateAnalysisResultsSlice } from '../api/stream_reducer'; import { logRateAnalysisSlice } from './log_rate_analysis_slice'; -import { logRateAnalysisTableRowSlice } from './log_rate_analysis_table_row_slice'; +import { + logRateAnalysisTableSlice, + getPreloadedState, + localStorageListenerMiddleware, +} from './log_rate_analysis_table_slice'; import { logRateAnalysisFieldCandidatesSlice } from './log_rate_analysis_field_candidates_slice'; import type { InitialAnalysisStart } from './log_rate_analysis_slice'; const getReduxStore = () => configureStore({ + preloadedState: { + logRateAnalysisTable: getPreloadedState(), + }, reducer: { // General page state logRateAnalysis: logRateAnalysisSlice.reducer, @@ -28,11 +35,13 @@ const getReduxStore = () => logRateAnalysisFieldCandidates: logRateAnalysisFieldCandidatesSlice.reducer, // Analysis results logRateAnalysisResults: logRateAnalysisResultsSlice.reducer, - // Handles running the analysis - logRateAnalysisStream: streamSlice.reducer, - // Handles hovering and pinning table rows - logRateAnalysisTableRow: logRateAnalysisTableRowSlice.reducer, + // Handles running the analysis, needs to be "stream" for the async thunk to work properly. + stream: streamSlice.reducer, + // Handles hovering and pinning table rows and column selection + logRateAnalysisTable: logRateAnalysisTableSlice.reducer, }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().prepend(localStorageListenerMiddleware.middleware), }); interface LogRateAnalysisReduxProviderProps { @@ -54,6 +63,6 @@ export const LogRateAnalysisReduxProvider: FC< }; // Infer the `RootState` and `AppDispatch` types from the store itself -export type AppStore = ReturnType; +type AppStore = ReturnType; export type RootState = ReturnType; export type AppDispatch = AppStore['dispatch']; diff --git a/x-pack/packages/ml/aiops_log_rate_analysis/state/use_current_selected_group.ts b/x-pack/packages/ml/aiops_log_rate_analysis/state/use_current_selected_group.ts index 9653691d3efd4..a19bd3e18a735 100644 --- a/x-pack/packages/ml/aiops_log_rate_analysis/state/use_current_selected_group.ts +++ b/x-pack/packages/ml/aiops_log_rate_analysis/state/use_current_selected_group.ts @@ -10,8 +10,8 @@ import { createSelector } from '@reduxjs/toolkit'; import type { RootState } from './store'; import { useAppSelector } from './hooks'; -const selectSelectedGroup = (s: RootState) => s.logRateAnalysisTableRow.selectedGroup; -const selectPinnedGroup = (s: RootState) => s.logRateAnalysisTableRow.pinnedGroup; +const selectSelectedGroup = (s: RootState) => s.logRateAnalysisTable.selectedGroup; +const selectPinnedGroup = (s: RootState) => s.logRateAnalysisTable.pinnedGroup; const selectCurrentSelectedGroup = createSelector( selectSelectedGroup, selectPinnedGroup, diff --git a/x-pack/packages/ml/aiops_log_rate_analysis/state/use_current_selected_significant_item.ts b/x-pack/packages/ml/aiops_log_rate_analysis/state/use_current_selected_significant_item.ts index d189d16fc2fa0..f7327d3033df0 100644 --- a/x-pack/packages/ml/aiops_log_rate_analysis/state/use_current_selected_significant_item.ts +++ b/x-pack/packages/ml/aiops_log_rate_analysis/state/use_current_selected_significant_item.ts @@ -11,9 +11,8 @@ import type { RootState } from './store'; import { useAppSelector } from './hooks'; const selectSelectedSignificantItem = (s: RootState) => - s.logRateAnalysisTableRow.selectedSignificantItem; -const selectPinnedSignificantItem = (s: RootState) => - s.logRateAnalysisTableRow.pinnedSignificantItem; + s.logRateAnalysisTable.selectedSignificantItem; +const selectPinnedSignificantItem = (s: RootState) => s.logRateAnalysisTable.pinnedSignificantItem; const selectCurrentSelectedSignificantItem = createSelector( selectSelectedSignificantItem, selectPinnedSignificantItem, diff --git a/x-pack/packages/ml/field_stats_flyout/field_stats_flyout_provider.tsx b/x-pack/packages/ml/field_stats_flyout/field_stats_flyout_provider.tsx index 678dec7d36f42..4e7a501140b01 100644 --- a/x-pack/packages/ml/field_stats_flyout/field_stats_flyout_provider.tsx +++ b/x-pack/packages/ml/field_stats_flyout/field_stats_flyout_provider.tsx @@ -7,9 +7,8 @@ import type { PropsWithChildren, FC } from 'react'; import React, { useCallback, useState } from 'react'; -import type { CoreStart } from '@kbn/core/public'; +import type { ThemeServiceStart } from '@kbn/core-theme-browser'; import type { DataView } from '@kbn/data-plugin/common'; -import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { FieldStatsServices } from '@kbn/unified-field-list/src/components/field_stats'; import type { TimeRange as TimeRangeMs } from '@kbn/ml-date-picker'; import type { FieldStatsProps } from '@kbn/unified-field-list/src/components/field_stats'; @@ -18,32 +17,18 @@ import { getProcessedFields } from '@kbn/ml-data-grid'; import { stringHash } from '@kbn/ml-string-hash'; import { lastValueFrom } from 'rxjs'; import { useRef } from 'react'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; import { getMergedSampleDocsForPopulatedFieldsQuery } from './populated_fields/get_merged_populated_fields_query'; import { FieldStatsFlyout } from './field_stats_flyout'; import { MLFieldStatsFlyoutContext } from './use_field_stats_flyout_context'; import { PopulatedFieldsCacheManager } from './populated_fields/populated_fields_cache_manager'; -type Services = CoreStart & { - data: DataPublicPluginStart; -}; - -function useDataSearch() { - const { data } = useKibana().services; - - if (!data) { - throw new Error('Kibana data service not available.'); - } - - return data.search; -} - /** * Props for the FieldStatsFlyoutProvider component. * * @typedef {Object} FieldStatsFlyoutProviderProps * @property dataView - The data view object. * @property fieldStatsServices - Services required for field statistics. + * @property theme - The EUI theme service. * @property [timeRangeMs] - Optional time range in milliseconds. * @property [dslQuery] - Optional DSL query for filtering field statistics. * @property [disablePopulatedFields] - Optional flag to disable populated fields. @@ -51,6 +36,7 @@ function useDataSearch() { export type FieldStatsFlyoutProviderProps = PropsWithChildren<{ dataView: DataView; fieldStatsServices: FieldStatsServices; + theme: ThemeServiceStart; timeRangeMs?: TimeRangeMs; dslQuery?: FieldStatsProps['dslQuery']; disablePopulatedFields?: boolean; @@ -79,12 +65,13 @@ export const FieldStatsFlyoutProvider: FC = (prop const { dataView, fieldStatsServices, + theme, timeRangeMs, dslQuery, disablePopulatedFields = false, children, } = props; - const search = useDataSearch(); + const { search } = fieldStatsServices.data; const [isFieldStatsFlyoutVisible, setFieldStatsIsFlyoutVisible] = useState(false); const [fieldName, setFieldName] = useState(); const [fieldValue, setFieldValue] = useState(); @@ -187,6 +174,7 @@ export const FieldStatsFlyoutProvider: FC = (prop fieldValue, timeRangeMs, populatedFields, + theme, }} > = (props) => { const { field, label, onButtonClick, disabled, isEmpty, hideTrigger } = props; - const themeVars = useThemeVars(); + const theme = useFieldStatsFlyoutThemeVars(); + const themeVars = useCurrentEuiThemeVars(theme); + const emptyFieldMessage = isEmpty ? ' ' + i18n.translate('xpack.ml.newJob.wizard.fieldContextPopover.emptyFieldInSampleDocsMsg', { diff --git a/x-pack/packages/ml/field_stats_flyout/tsconfig.json b/x-pack/packages/ml/field_stats_flyout/tsconfig.json index 0010d79432e34..df70aa27788b8 100644 --- a/x-pack/packages/ml/field_stats_flyout/tsconfig.json +++ b/x-pack/packages/ml/field_stats_flyout/tsconfig.json @@ -23,9 +23,7 @@ "@kbn/i18n", "@kbn/react-field", "@kbn/ml-anomaly-utils", - "@kbn/kibana-react-plugin", "@kbn/ml-kibana-theme", - "@kbn/core", "@kbn/ml-data-grid", "@kbn/ml-string-hash", "@kbn/ml-is-populated-object", @@ -33,5 +31,6 @@ "@kbn/ml-is-defined", "@kbn/field-types", "@kbn/ui-theme", + "@kbn/core-theme-browser", ] } diff --git a/x-pack/packages/ml/field_stats_flyout/use_field_stats_flyout_context.ts b/x-pack/packages/ml/field_stats_flyout/use_field_stats_flyout_context.ts index ec6c28873011c..121426352e6e4 100644 --- a/x-pack/packages/ml/field_stats_flyout/use_field_stats_flyout_context.ts +++ b/x-pack/packages/ml/field_stats_flyout/use_field_stats_flyout_context.ts @@ -7,6 +7,7 @@ import { createContext, useContext } from 'react'; import type { TimeRange as TimeRangeMs } from '@kbn/ml-date-picker'; +import type { ThemeServiceStart } from '@kbn/core-theme-browser'; /** * Represents the properties for the MLJobWizardFieldStatsFlyout component. @@ -21,6 +22,7 @@ interface MLJobWizardFieldStatsFlyoutProps { fieldValue?: string | number; timeRangeMs?: TimeRangeMs; populatedFields?: Set; + theme?: ThemeServiceStart; } /** @@ -34,6 +36,7 @@ export const MLFieldStatsFlyoutContext = createContext {}, timeRangeMs: undefined, populatedFields: undefined, + theme: undefined, }); /** @@ -41,5 +44,25 @@ export const MLFieldStatsFlyoutContext = createContext() { populatedFields, }; } + +export type UseFieldStatsTrigger = typeof useFieldStatsTrigger; diff --git a/x-pack/packages/observability/alert_details/src/hooks/use_alerts_history.ts b/x-pack/packages/observability/alert_details/src/hooks/use_alerts_history.ts index 7519fea5f99f8..193acb63f845b 100644 --- a/x-pack/packages/observability/alert_details/src/hooks/use_alerts_history.ts +++ b/x-pack/packages/observability/alert_details/src/hooks/use_alerts_history.ts @@ -55,6 +55,7 @@ export function useAlertsHistory({ http, instanceId, }: Props): UseAlertsHistory { + const enabled = !!featureIds.length; const { isInitialLoading, isLoading, isError, isSuccess, isRefetching, data } = useQuery({ queryKey: ['useAlertsHistory'], queryFn: async ({ signal }) => { @@ -71,10 +72,11 @@ export function useAlertsHistory({ }); }, refetchOnWindowFocus: false, + enabled, }); return { data: isInitialLoading ? EMPTY_ALERTS_HISTORY : data ?? EMPTY_ALERTS_HISTORY, - isLoading: isInitialLoading || isLoading || isRefetching, + isLoading: enabled && (isInitialLoading || isLoading || isRefetching), isSuccess, isError, }; diff --git a/x-pack/packages/observability/alerting_test_data/kibana.jsonc b/x-pack/packages/observability/alerting_test_data/kibana.jsonc index 6bbf701e73c75..7cf9294af9b73 100644 --- a/x-pack/packages/observability/alerting_test_data/kibana.jsonc +++ b/x-pack/packages/observability/alerting_test_data/kibana.jsonc @@ -1,5 +1,7 @@ { "type": "shared-common", "id": "@kbn/observability-alerting-test-data", - "owner": "@elastic/obs-ux-management-team" + "owner": "@elastic/obs-ux-management-team", + "group": "observability", + "visibility": "private" } diff --git a/x-pack/packages/observability/logs_overview/src/services/categorize_logs_service/queries.ts b/x-pack/packages/observability/logs_overview/src/services/categorize_logs_service/queries.ts index aef12da303bcc..3328bd5f8585a 100644 --- a/x-pack/packages/observability/logs_overview/src/services/categorize_logs_service/queries.ts +++ b/x-pack/packages/observability/logs_overview/src/services/categorize_logs_service/queries.ts @@ -5,7 +5,10 @@ * 2.0. */ -import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import { + AggregationsCategorizeTextAnalyzer, + QueryDslQueryContainer, +} from '@elastic/elasticsearch/lib/api/types'; import { calculateAuto } from '@kbn/calculate-auto'; import { RandomSamplerWrapper } from '@kbn/ml-random-sampler-utils'; import moment from 'moment'; @@ -109,9 +112,7 @@ export const createCategorizationRequestParams = ({ categorize_text: { field: messageField, size: maxCategoriesCount, - categorization_analyzer: { - tokenizer: 'standard', - }, + categorization_analyzer: categorizationAnalyzerConfig, ...(minDocsPerCategory > 0 ? { min_doc_count: minDocsPerCategory } : {}), }, aggs: { @@ -149,3 +150,38 @@ export const createCategoryQuery = }, }, }); + +// This emulates the behavior of the `ml_standard` tokenizer in the ML plugin in +// regard to the hexadecimal and numeric tokens. The other parts pertaining to +// infix punctuation and file paths are not easily emulated this way. +// https://github.com/elastic/elasticsearch/blob/becd08da24df2af93eee28053d32929298cdccbd/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/job/categorization/MlStandardTokenizer.java#L35-L146 +// We don't use the `ml_standard` tokenizer directly because it produces tokens +// that are different from the ones produced by the `standard` tokenizer upon +// indexing. +const categorizationAnalyzerConfig: AggregationsCategorizeTextAnalyzer = { + tokenizer: 'standard', + char_filter: [ + 'first_line_with_letters', + // This ignores tokens that are hexadecimal numbers + // @ts-expect-error the official types don't support inline char filters + { + type: 'pattern_replace', + pattern: '\\b[a-fA-F][a-fA-F0-9]+\\b', + replacement: '', + }, + // This ignore tokens that start with a digit + // @ts-expect-error the official types don't support inline char filters + { + type: 'pattern_replace', + pattern: '\\b\\d\\w*\\b', + replacement: '', + }, + ], + filter: [ + // @ts-expect-error the official types don't support inline token filters + { + type: 'limit', + max_token_count: '100', + }, + ], +}; diff --git a/x-pack/packages/observability/observability_utils/es/utils/esql_result_to_plain_objects.test.ts b/x-pack/packages/observability/observability_utils/es/utils/esql_result_to_plain_objects.test.ts new file mode 100644 index 0000000000000..4557d0ba0bdd5 --- /dev/null +++ b/x-pack/packages/observability/observability_utils/es/utils/esql_result_to_plain_objects.test.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ESQLSearchResponse } from '@kbn/es-types'; +import { esqlResultToPlainObjects } from './esql_result_to_plain_objects'; + +describe('esqlResultToPlainObjects', () => { + it('should return an empty array for an empty result', () => { + const result: ESQLSearchResponse = { + columns: [], + values: [], + }; + const output = esqlResultToPlainObjects(result); + expect(output).toEqual([]); + }); + + it('should return plain objects', () => { + const result: ESQLSearchResponse = { + columns: [{ name: 'name', type: 'keyword' }], + values: [['Foo Bar']], + }; + const output = esqlResultToPlainObjects(result); + expect(output).toEqual([{ name: 'Foo Bar' }]); + }); + + it('should return columns without "text" or "keyword" in their names', () => { + const result: ESQLSearchResponse = { + columns: [ + { name: 'name.text', type: 'text' }, + { name: 'age', type: 'keyword' }, + ], + values: [ + ['Foo Bar', 30], + ['Foo Qux', 25], + ], + }; + const output = esqlResultToPlainObjects(result); + expect(output).toEqual([ + { name: 'Foo Bar', age: 30 }, + { name: 'Foo Qux', age: 25 }, + ]); + }); + + it('should handle mixed columns correctly', () => { + const result: ESQLSearchResponse = { + columns: [ + { name: 'name', type: 'text' }, + { name: 'name.text', type: 'text' }, + { name: 'age', type: 'keyword' }, + ], + values: [ + ['Foo Bar', 'Foo Bar', 30], + ['Foo Qux', 'Foo Qux', 25], + ], + }; + const output = esqlResultToPlainObjects(result); + expect(output).toEqual([ + { name: 'Foo Bar', age: 30 }, + { name: 'Foo Qux', age: 25 }, + ]); + }); +}); diff --git a/x-pack/packages/observability/observability_utils/es/utils/esql_result_to_plain_objects.ts b/x-pack/packages/observability/observability_utils/es/utils/esql_result_to_plain_objects.ts index ad48bcb311b25..96049f75ef156 100644 --- a/x-pack/packages/observability/observability_utils/es/utils/esql_result_to_plain_objects.ts +++ b/x-pack/packages/observability/observability_utils/es/utils/esql_result_to_plain_objects.ts @@ -13,7 +13,17 @@ export function esqlResultToPlainObjects>( return result.values.map((row) => { return row.reduce>((acc, value, index) => { const column = result.columns[index]; - acc[column.name] = value; + + 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[]; diff --git a/x-pack/packages/security-solution/common/kibana.jsonc b/x-pack/packages/security-solution/common/kibana.jsonc deleted file mode 100644 index 708feab435425..0000000000000 --- a/x-pack/packages/security-solution/common/kibana.jsonc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "type": "shared-browser", - "id": "@kbn/security-solution-common", - "owner": "@elastic/security-threat-hunting-investigations" -} diff --git a/x-pack/packages/security-solution/common/package.json b/x-pack/packages/security-solution/common/package.json deleted file mode 100644 index ce355e927c3df..0000000000000 --- a/x-pack/packages/security-solution/common/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@kbn/security-solution-common", - "version": "1.0.0", - "description": "security solution common components which can be used in multiple plugins such as custom discover and timeline", - "license": "Elastic License 2.0", - "private": true, - "sideEffects": false -} \ No newline at end of file diff --git a/x-pack/packages/security-solution/common/src/cells/renderers/discover.ts b/x-pack/packages/security-solution/common/src/cells/renderers/discover.ts deleted file mode 100644 index 8d0393a3c6f2b..0000000000000 --- a/x-pack/packages/security-solution/common/src/cells/renderers/discover.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { DataGridCellValueElementProps } from '@kbn/unified-data-table'; -import { ComponentType, PropsWithChildren } from 'react'; -import { HostCellWithFlyoutRenderer } from './host'; - -export type DiscoverCellRenderer = ComponentType>; - -const RENDERERS: Record = { - 'host.name': HostCellWithFlyoutRenderer, -}; - -interface GetRendererArgs { - fieldName: string; -} - -export const getDiscoverCellRenderer = ({ fieldName }: GetRendererArgs) => { - return RENDERERS[fieldName]; -}; diff --git a/x-pack/packages/security-solution/common/src/cells/renderers/host/button.test.tsx b/x-pack/packages/security-solution/common/src/cells/renderers/host/button.test.tsx deleted file mode 100644 index d49af3ed50518..0000000000000 --- a/x-pack/packages/security-solution/common/src/cells/renderers/host/button.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { HostDetailsButton } from './button'; -import { render, screen } from '@testing-library/react'; - -const onClickMock = jest.fn(); -const TestComponent = () => { - return {'Test'}; -}; - -describe('Host Button', () => { - it('should render as button with link formatting', () => { - render(); - expect(screen.getByTestId('host-details-button')).toBeVisible(); - expect(screen.getByTestId('host-details-button')).toHaveAttribute('type', 'button'); - expect(screen.getByTestId('host-details-button')).toHaveClass('euiLink'); - }); - - it('should perform onClick Correctly', () => { - render(); - screen.getByTestId('host-details-button').click(); - expect(onClickMock).toHaveBeenCalled(); - }); -}); diff --git a/x-pack/packages/security-solution/common/src/cells/renderers/host/button.tsx b/x-pack/packages/security-solution/common/src/cells/renderers/host/button.tsx deleted file mode 100644 index b478ee17bf9d4..0000000000000 --- a/x-pack/packages/security-solution/common/src/cells/renderers/host/button.tsx +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { SyntheticEvent } from 'react'; -import { EuiLink } from '@elastic/eui'; - -interface HostDetailsButtonProps { - children?: React.ReactNode; - onClick?: (e: SyntheticEvent) => void; - title?: string; -} - -export const HostDetailsButton: React.FC = ({ - children, - onClick, - title, -}) => { - return ( - - {children} - - ); -}; diff --git a/x-pack/packages/security-solution/common/src/cells/renderers/host/with_expandable_flyout.test.tsx b/x-pack/packages/security-solution/common/src/cells/renderers/host/with_expandable_flyout.test.tsx deleted file mode 100644 index 0568c656cdbe5..0000000000000 --- a/x-pack/packages/security-solution/common/src/cells/renderers/host/with_expandable_flyout.test.tsx +++ /dev/null @@ -1,54 +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 { - HostCellWithFlyoutRenderer, - HostCellWithFlyoutRendererProps, -} from './with_expandable_flyout'; -import React from 'react'; -import { render, screen } from '@testing-library/react'; - -const renderTestComponents = (props?: Partial) => { - const finalProps: HostCellWithFlyoutRendererProps = { - rowIndex: 0, - columnId: 'test', - setCellProps: jest.fn(), - isExpandable: false, - isExpanded: true, - isDetails: false, - colIndex: 0, - fieldFormats: {} as HostCellWithFlyoutRendererProps['fieldFormats'], - dataView: {} as HostCellWithFlyoutRendererProps['dataView'], - closePopover: jest.fn(), - row: { - id: '1', - raw: { - _source: { - host: { - name: 'test-host-name', - }, - }, - }, - flattened: { - 'host.name': 'test-host-name', - }, - }, - ...props, - }; - return render(); -}; - -describe('With Expandable Flyout', () => { - it('should open Expandable Flyout on Click', () => { - renderTestComponents(); - - expect(screen.getByTestId('host-details-button')).toBeVisible(); - screen.getByTestId('host-details-button').click(); - expect(screen.getByTestId('host-name-flyout')).toBeVisible(); - expect(screen.getByText('Host Flyout Header - test-host-name')).toBeVisible(); - }); -}); diff --git a/x-pack/packages/security-solution/common/src/cells/renderers/host/with_expandable_flyout.tsx b/x-pack/packages/security-solution/common/src/cells/renderers/host/with_expandable_flyout.tsx deleted file mode 100644 index 5e48d85b0384d..0000000000000 --- a/x-pack/packages/security-solution/common/src/cells/renderers/host/with_expandable_flyout.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useCallback, useMemo } from 'react'; -import { getFieldValue } from '@kbn/discover-utils'; -import type { PropsWithChildren } from 'react'; -import type { DataGridCellValueElementProps } from '@kbn/unified-data-table'; -import { - ExpandableFlyout, - type ExpandableFlyoutProps, - useExpandableFlyoutApi, - withExpandableFlyoutProvider, -} from '@kbn/expandable-flyout'; -import { HostRightPanel, HostRightPanelProps } from '../../../flyout/panels'; -import { HostDetailsButton } from './button'; - -export type HostCellWithFlyoutRendererProps = PropsWithChildren; - -const HostCellWithFlyoutRendererComp = React.memo(function HostCellWithFlyoutRendererComp( - props: HostCellWithFlyoutRendererProps -) { - const hostName = getFieldValue(props.row, 'host.name') as string; - - const { openFlyout } = useExpandableFlyoutApi(); - - const onClick = useCallback(() => { - openFlyout({ - right: { - id: `host-panel-${hostName}-${props.rowIndex}`, - params: { - hostName, - }, - } as HostRightPanelProps, - }); - }, [openFlyout, hostName, props.rowIndex]); - - const panels: ExpandableFlyoutProps['registeredPanels'] = useMemo(() => { - return [ - { - key: `host-panel-${hostName}-${props.rowIndex}`, - component: (panelProps) => { - return ; - }, - }, - ]; - }, [hostName, props.rowIndex]); - - return ( - <> - - {hostName} - - ); -}); - -export const HostCellWithFlyoutRenderer = withExpandableFlyoutProvider( - HostCellWithFlyoutRendererComp -); diff --git a/x-pack/packages/security-solution/common/src/flyout/common/components/index.ts b/x-pack/packages/security-solution/common/src/flyout/common/components/index.ts deleted file mode 100644 index 4624ae2f70c55..0000000000000 --- a/x-pack/packages/security-solution/common/src/flyout/common/components/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export { FlyoutFooter } from './flyout_footer'; -export { FlyoutError } from './flyout_error'; -export { FlyoutLoading } from './flyout_loading'; -export { FlyoutNavigation } from './flyout_navigation'; -export { FlyoutTitle } from './flyout_title'; -export { FlyoutBody } from './flyout_body'; -export { FlyoutHeader } from './flyout_header'; -export { FlyoutHeaderTabs } from './flyout_header_tabs'; -export { ExpandablePanel } from './expandable_panel'; diff --git a/x-pack/packages/security-solution/common/src/flyout/panels/host/right/index.tsx b/x-pack/packages/security-solution/common/src/flyout/panels/host/right/index.tsx deleted file mode 100644 index d877695f5b170..0000000000000 --- a/x-pack/packages/security-solution/common/src/flyout/panels/host/right/index.tsx +++ /dev/null @@ -1,37 +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 { FlyoutPanelProps } from '@kbn/expandable-flyout'; -import React from 'react'; -import { - FlyoutBody, - FlyoutFooter, - FlyoutHeader, - FlyoutNavigation, -} from '../../../common/components'; -// import { getEntityTableColumns } from './columns'; -// import type { BasicEntityData, EntityTableRows } from './types'; - -export interface HostRightPanelParamProps extends Record { - hostName: string; -} - -export interface HostRightPanelProps extends FlyoutPanelProps { - key: 'host'; - params: HostRightPanelParamProps; -} - -export const HostRightPanel = (props: HostRightPanelParamProps) => { - return ( - <> - - {`Host Flyout Header - ${props.hostName}`} - {'Host Flyout'} - {'Host Flyout Footer'} - - ); -}; diff --git a/x-pack/packages/security-solution/common/tsconfig.json b/x-pack/packages/security-solution/common/tsconfig.json deleted file mode 100644 index fb0d3709961d8..0000000000000 --- a/x-pack/packages/security-solution/common/tsconfig.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "extends": "../../../../tsconfig.base.json", - "compilerOptions": { - "outDir": "target/types", - "types": [ - "jest", - "node", - "react", - "@testing-library/jest-dom", - "@testing-library/react", - "@emotion/react/types/css-prop" - ] - }, - "include": [ - "**/*.ts", - "**/*.tsx" - ], - "kbn_references": [ - "@kbn/unified-data-table", - "@kbn/discover-utils", - "@kbn/expandable-flyout", - "@kbn/i18n", - "@kbn/i18n-react" - ], - "exclude": [ - "target/**/*" - ] -} diff --git a/x-pack/packages/security-solution/data_table/kibana.jsonc b/x-pack/packages/security-solution/data_table/kibana.jsonc index 5298db752359f..9695411a65301 100644 --- a/x-pack/packages/security-solution/data_table/kibana.jsonc +++ b/x-pack/packages/security-solution/data_table/kibana.jsonc @@ -1,5 +1,7 @@ { "type": "shared-common", "id": "@kbn/securitysolution-data-table", - "owner": "@elastic/security-threat-hunting-investigations" + "owner": "@elastic/security-threat-hunting-investigations", + "group": "security", + "visibility": "private" } diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_context/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_context/index.test.tsx index fb8eccd4b7f8a..a82bf7d6c432b 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_context/index.test.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_context/index.test.tsx @@ -65,6 +65,8 @@ const ContextWrapper: FC> = ({ children }) => ( }, ]} setSelectedIlmPhaseOptions={jest.fn()} + defaultStartTime="now-7d" + defaultEndTime="now" > {children} diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_context/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_context/index.tsx index 762efef424a10..876ff528e75ff 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_context/index.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_context/index.tsx @@ -41,6 +41,8 @@ export interface DataQualityProviderProps { ilmPhases: string[]; selectedIlmPhaseOptions: EuiComboBoxOptionOption[]; setSelectedIlmPhaseOptions: (options: EuiComboBoxOptionOption[]) => void; + defaultStartTime: string; + defaultEndTime: string; } const DataQualityContext = React.createContext(undefined); @@ -67,6 +69,8 @@ export const DataQualityProvider: React.FC { const value = useMemo( () => ({ @@ -90,6 +94,8 @@ export const DataQualityProvider: React.FC { describe('tour', () => { test('it renders the tour wrapping view history button of first row of first non-empty pattern', async () => { const wrapper = await screen.findByTestId('historicalResultsTour'); - const button = within(wrapper).getByRole('button', { name: 'View history' }); - expect(button).toBeInTheDocument(); + const button = within(wrapper).getByTestId( + 'viewHistoryAction-.internal.alerts-security.alerts-default-000001' + ); expect(button).toHaveAttribute('data-tour-element', patterns[1]); - expect( - screen.getByRole('dialog', { name: 'Introducing data quality history' }) - ).toBeInTheDocument(); + expect(screen.getByTestId('historicalResultsTourPanel')).toHaveTextContent( + 'Introducing data quality history' + ); }); describe('when the tour is dismissed', () => { test('it hides the tour and persists in localStorage', async () => { - const wrapper = await screen.findByRole('dialog', { - name: 'Introducing data quality history', - }); - - const button = within(wrapper).getByRole('button', { name: 'Close' }); - + const wrapper = screen.getByTestId('historicalResultsTourPanel'); + const button = within(wrapper).getByText('Close'); await userEvent.click(button); await waitFor(() => expect(screen.queryByTestId('historicalResultsTour')).toBeNull()); @@ -127,24 +124,22 @@ describe('IndicesDetails', () => { const firstNonEmptyPatternAccordionWrapper = await screen.findByTestId( `${patterns[1]}PatternPanel` ); - const accordionToggle = within(firstNonEmptyPatternAccordionWrapper).getByRole('button', { - name: /Pass/, - }); + const accordionToggle = within(firstNonEmptyPatternAccordionWrapper).getByTestId( + 'indexResultBadge' + ); await userEvent.click(accordionToggle); const secondPatternAccordionWrapper = screen.getByTestId(`${patterns[2]}PatternPanel`); const historicalResultsWrapper = await within(secondPatternAccordionWrapper).findByTestId( 'historicalResultsTour' ); - const button = within(historicalResultsWrapper).getByRole('button', { - name: 'View history', - }); + const button = within(historicalResultsWrapper).getByTestId( + `viewHistoryAction-${patternIndexNames[patterns[2]][0]}` + ); expect(button).toHaveAttribute('data-tour-element', patterns[2]); - expect( - screen.getByRole('dialog', { name: 'Introducing data quality history' }) - ).toBeInTheDocument(); - }, 10000); + expect(screen.getByTestId('historicalResultsTourPanel')).toBeInTheDocument(); + }); }); }); }); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/historical_results_tour/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/historical_results_tour/index.tsx index 5e63379d17375..c35dce5da868e 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/historical_results_tour/index.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/historical_results_tour/index.tsx @@ -67,6 +67,9 @@ export const HistoricalResultsTour: FC = ({ repositionOnScroll anchor={anchorElement} zIndex={zIndex} + panelProps={{ + 'data-test-subj': 'historicalResultsTourPanel', + }} footerAction={[ {CLOSE} diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/hooks/use_ilm_explain/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/hooks/use_ilm_explain/index.test.tsx index bfc66c3711dbb..93309af1bc0e3 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/hooks/use_ilm_explain/index.test.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/hooks/use_ilm_explain/index.test.tsx @@ -71,6 +71,8 @@ const ContextWrapper: React.FC<{ children: React.ReactNode; isILMAvailable: bool }, ]} setSelectedIlmPhaseOptions={jest.fn()} + defaultStartTime={'now-7d'} + defaultEndTime={'now'} > {children} @@ -159,6 +161,8 @@ describe('useIlmExplain', () => { }, ]} setSelectedIlmPhaseOptions={jest.fn()} + defaultStartTime={'now-7d'} + defaultEndTime={'now'} > {children} diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/hooks/use_stats/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/hooks/use_stats/index.test.tsx index 061bbb5aa6824..ae4ee9a7bd2c4 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/hooks/use_stats/index.test.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/hooks/use_stats/index.test.tsx @@ -69,6 +69,8 @@ const ContextWrapper: FC> = ({ children }) => ( }, ]} setSelectedIlmPhaseOptions={jest.fn()} + defaultStartTime={'now-7d'} + defaultEndTime={'now'} > {children} @@ -119,6 +121,8 @@ const ContextWrapperILMNotAvailable: FC> = ({ childre }, ]} setSelectedIlmPhaseOptions={jest.fn()} + defaultStartTime={'now-7d'} + defaultEndTime={'now'} > {children} diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index.test.tsx index eb6116c3276f9..8f8ed7d702d2f 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index.test.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index.test.tsx @@ -23,6 +23,7 @@ import { ERROR_LOADING_METADATA_TITLE, LOADING_STATS } from './translations'; import { useHistoricalResults } from './hooks/use_historical_results'; import { getHistoricalResultStub } from '../../../stub/get_historical_result_stub'; import userEvent from '@testing-library/user-event'; +import { HISTORY_TAB_ID, LATEST_CHECK_TAB_ID } from './constants'; const pattern = 'auditbeat-*'; @@ -94,11 +95,10 @@ describe('pattern', () => { ); - const accordionToggle = screen.getByRole('button', { - name: 'Fail auditbeat-* hot (1) unmanaged (2) Incompatible fields 4 Indices checked 3 Indices 3 Size 17.9MB Docs 19,127', - }); - - expect(accordionToggle).toBeInTheDocument(); + const accordionToggle = screen.getByTestId('patternAccordionButton-auditbeat-*'); + expect(accordionToggle).toHaveTextContent( + 'Failauditbeat-*hot (1)unmanaged (2)Incompatible fields4Indices checked3Indices3Size17.9MBDocs19,127' + ); expect(accordionToggle).toHaveAttribute('aria-expanded', 'true'); expect(screen.getByTestId('summaryTable')).toBeInTheDocument(); }); @@ -139,9 +139,10 @@ describe('pattern', () => { ); - const accordionToggle = await screen.findByRole('button', { - name: 'auditbeat-* Incompatible fields 0 Indices checked 0 Indices 0 Size 0B Docs 0', - }); + const accordionToggle = screen.getByTestId('patternAccordionButton-auditbeat-*'); + expect(accordionToggle).toHaveTextContent( + 'auditbeat-*Incompatible fields0Indices checked0Indices0Size0BDocs0' + ); expect(onAccordionToggle).toHaveBeenCalledTimes(1); @@ -186,10 +187,7 @@ describe('pattern', () => { ); - const accordionToggle = screen.getByRole('button', { - name: 'Fail auditbeat-* hot (1) unmanaged (2) Incompatible fields 4 Indices checked 3 Indices 3 Size 17.9MB Docs 19,127', - }); - + const accordionToggle = screen.getByTestId('patternAccordionButton-auditbeat-*'); expect(onAccordionToggle).toHaveBeenCalledTimes(1); await userEvent.click(accordionToggle); @@ -234,9 +232,7 @@ describe('pattern', () => { ); - const accordionToggle = screen.getByRole('button', { - name: 'Fail auditbeat-* hot (1) unmanaged (2) Incompatible fields 4 Indices checked 3 Indices 3 Size 17.9MB Docs 19,127', - }); + const accordionToggle = screen.getByTestId('patternAccordionButton-auditbeat-*'); expect(onAccordionToggle).toHaveBeenCalledTimes(1); expect(onAccordionToggle).toHaveBeenCalledWith(pattern, true, false); @@ -484,14 +480,11 @@ describe('pattern', () => { ); - const rows = screen.getAllByRole('row'); - const firstBodyRow = within(rows[1]); - expect(screen.queryByTestId('indexCheckFlyout')).not.toBeInTheDocument(); - const checkNowButton = firstBodyRow.getByRole('button', { - name: 'Check now', - }); + const checkNowButton = screen.getByTestId( + 'checkNowAction-.ds-auditbeat-8.6.1-2023.02.07-000001' + ); await userEvent.click(checkNowButton); @@ -505,12 +498,11 @@ describe('pattern', () => { indexName, pattern, }); - expect(screen.getByTestId('indexCheckFlyout')).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: 'Latest Check' })).toHaveAttribute( + expect(screen.getByTestId(`indexCheckFlyoutTab-${LATEST_CHECK_TAB_ID}`)).toHaveAttribute( 'aria-selected', 'true' ); - expect(screen.getByRole('tab', { name: 'History' })).toHaveAttribute( + expect(screen.getByTestId(`indexCheckFlyoutTab-${HISTORY_TAB_ID}`)).toHaveAttribute( 'aria-selected', 'false' ); @@ -566,15 +558,11 @@ describe('pattern', () => { ); - const rows = screen.getAllByRole('row'); - const firstBodyRow = within(rows[1]); - expect(screen.queryByTestId('indexCheckFlyout')).not.toBeInTheDocument(); - const viewHistoryButton = firstBodyRow.getByRole('button', { - name: 'View history', - }); - + const viewHistoryButton = screen.getByTestId( + 'viewHistoryAction-.ds-auditbeat-8.6.1-2023.02.07-000001' + ); await userEvent.click(viewHistoryButton); // assert @@ -583,12 +571,11 @@ describe('pattern', () => { abortController: expect.any(AbortController), indexName, }); - expect(screen.getByTestId('indexCheckFlyout')).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: 'Latest Check' })).toHaveAttribute( + expect(screen.getByTestId(`indexCheckFlyoutTab-${LATEST_CHECK_TAB_ID}`)).toHaveAttribute( 'aria-selected', 'false' ); - expect(screen.getByRole('tab', { name: 'History' })).toHaveAttribute( + expect(screen.getByTestId(`indexCheckFlyoutTab-${HISTORY_TAB_ID}`)).toHaveAttribute( 'aria-selected', 'true' ); @@ -644,24 +631,21 @@ describe('pattern', () => { ); - const rows = screen.getAllByRole('row'); - const firstBodyRow = within(rows[1]); - + // assert expect(screen.queryByTestId('indexCheckFlyout')).not.toBeInTheDocument(); - const viewHistoryButton = firstBodyRow.getByRole('button', { - name: 'View history', - }); + const viewHistoryButton = screen.getByTestId( + 'viewHistoryAction-.ds-auditbeat-8.6.1-2023.02.07-000001' + ); + // act await userEvent.click(viewHistoryButton); - - const closeButton = screen.getByRole('button', { name: 'Close this dialog' }); - + const closeButton = screen.getByTestId('euiFlyoutCloseButton'); await userEvent.click(closeButton); // assert expect(screen.queryByTestId('indexCheckFlyout')).not.toBeInTheDocument(); - }, 15000); + }); }); describe('when chartSelectedIndex is set', () => { @@ -718,15 +702,15 @@ describe('pattern', () => { indexName, pattern, }); - expect(screen.getByTestId('indexCheckFlyout')).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: 'Latest Check' })).toHaveAttribute( + expect(screen.getByTestId(`indexCheckFlyoutTab-${LATEST_CHECK_TAB_ID}`)).toHaveAttribute( 'aria-selected', 'true' ); - expect(screen.getByRole('tab', { name: 'History' })).toHaveAttribute( + expect(screen.getByTestId(`indexCheckFlyoutTab-${HISTORY_TAB_ID}`)).toHaveAttribute( 'aria-selected', 'false' ); + expect(screen.getByTestId('latestResults')).toBeInTheDocument(); expect(screen.queryByTestId('historicalResults')).not.toBeInTheDocument(); }); @@ -766,19 +750,13 @@ describe('pattern', () => { ); - const rows = screen.getAllByRole('row'); - // skipping the first row which is the header - const firstBodyRow = within(rows[1]); - - const tourWrapper = await firstBodyRow.findByTestId('historicalResultsTour'); + const tourWrapper = await screen.findByTestId('historicalResultsTour'); expect( - within(tourWrapper).getByRole('button', { name: 'View history' }) + within(tourWrapper).getByTestId('viewHistoryAction-.ds-auditbeat-8.6.1-2023.02.07-000001') ).toBeInTheDocument(); - expect( - screen.getByRole('dialog', { name: 'Introducing data quality history' }) - ).toBeInTheDocument(); + expect(screen.getByTestId('historicalResultsTourPanel')).toBeInTheDocument(); }); describe('when accordion is collapsed', () => { @@ -815,14 +793,11 @@ describe('pattern', () => { expect(await screen.findByTestId('historicalResultsTour')).toBeInTheDocument(); - const accordionToggle = screen.getByRole('button', { - name: 'Fail auditbeat-* hot (1) unmanaged (2) Incompatible fields 4 Indices checked 3 Indices 3 Size 17.9MB Docs 19,127', - }); - + const accordionToggle = screen.getByTestId('patternAccordionButton-auditbeat-*'); await userEvent.click(accordionToggle); expect(screen.queryByTestId('historicalResultsTour')).not.toBeInTheDocument(); - }, 10000); + }); }); describe('when the tour close button is clicked', () => { @@ -859,11 +834,8 @@ describe('pattern', () => { ); - const tourDialog = await screen.findByRole('dialog', { - name: 'Introducing data quality history', - }); - - const closeButton = within(tourDialog).getByRole('button', { name: 'Close' }); + const tourDialog = await screen.findByTestId('historicalResultsTourPanel'); + const closeButton = within(tourDialog).getByText('Close'); await userEvent.click(closeButton); @@ -905,28 +877,24 @@ describe('pattern', () => { ); - const tourDialog = await screen.findByRole('dialog', { - name: 'Introducing data quality history', - }); - - const tryItButton = within(tourDialog).getByRole('button', { name: 'Try it' }); + const tourDialog = await screen.findByTestId('historicalResultsTourPanel'); + const tryItButton = within(tourDialog).getByText('Try it'); await userEvent.click(tryItButton); expect(onDismissTour).toHaveBeenCalledTimes(1); - expect(screen.getByTestId('indexCheckFlyout')).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: 'Latest Check' })).toHaveAttribute( + expect(screen.getByTestId(`indexCheckFlyoutTab-${LATEST_CHECK_TAB_ID}`)).toHaveAttribute( 'aria-selected', 'false' ); - expect(screen.getByRole('tab', { name: 'History' })).toHaveAttribute( + expect(screen.getByTestId(`indexCheckFlyoutTab-${HISTORY_TAB_ID}`)).toHaveAttribute( 'aria-selected', 'true' ); }); }); - describe('when latest latest check flyout tab is opened', () => { + describe('when latest check flyout tab is opened', () => { it('hides the tour in listview and shows in flyout', async () => { (useIlmExplain as jest.Mock).mockReturnValue({ error: null, @@ -960,41 +928,41 @@ describe('pattern', () => { ); - const rows = screen.getAllByRole('row'); - // skipping the first row which is the header - const firstBodyRow = within(rows[1]); + const summaryTableWrapper = within(screen.getByTestId('summaryTable')); - expect(await firstBodyRow.findByTestId('historicalResultsTour')).toBeInTheDocument(); expect( - screen.getByRole('dialog', { name: 'Introducing data quality history' }) + await summaryTableWrapper.findByTestId('historicalResultsTour') ).toBeInTheDocument(); + expect(screen.queryByTestId('historicalResultsTourPanel')).toBeInTheDocument(); - const checkNowButton = firstBodyRow.getByRole('button', { - name: 'Check now', - }); + const checkNowButton = summaryTableWrapper.getByTestId( + 'checkNowAction-.ds-auditbeat-8.6.1-2023.02.07-000001' + ); await userEvent.click(checkNowButton); - expect(screen.getByTestId('indexCheckFlyout')).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: 'Latest Check' })).toHaveAttribute( + expect(screen.getByTestId(`indexCheckFlyoutTab-${LATEST_CHECK_TAB_ID}`)).toHaveAttribute( 'aria-selected', 'true' ); - expect(screen.getByRole('tab', { name: 'History' })).toHaveAttribute( + expect(screen.getByTestId(`indexCheckFlyoutTab-${HISTORY_TAB_ID}`)).toHaveAttribute( 'aria-selected', 'false' ); - expect(firstBodyRow.queryByTestId('historicalResultsTour')).not.toBeInTheDocument(); + expect( + summaryTableWrapper.queryByTestId('historicalResultsTour') + ).not.toBeInTheDocument(); - const tabWrapper = await screen.findByRole('tab', { name: 'History' }); - await waitFor(() => + const tabWrapper = await screen.findByTestId(`indexCheckFlyoutTab-${HISTORY_TAB_ID}`); + await waitFor(() => { expect( tabWrapper.closest('[data-test-subj="historicalResultsTour"]') - ).toBeInTheDocument() - ); + ).toBeInTheDocument(); + expect(screen.queryByTestId('historicalResultsTourPanel')).toBeInTheDocument(); + }); expect(onDismissTour).not.toHaveBeenCalled(); - }, 10000); + }); }); }); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index.tsx index a51f521eca169..fd0100bc1192e 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index.tsx @@ -322,7 +322,9 @@ const PatternComponent: React.FC = ({ id={patternComponentAccordionId} forceState={isAccordionOpen ? 'open' : 'closed'} onToggle={handleAccordionToggle} - buttonElement="div" + buttonProps={{ + 'data-test-subj': `patternAccordionButton-${pattern}`, + }} buttonContent={ { - return TOGGLE_HISTORICAL_RESULT_CHECKED_AT(getFormattedCheckTime(checkedAt)); -}; - describe('HistoricalResultsList', () => { it('should render individual historical result accordions with result outcome text, formatted check time and amount of incompatible fields', () => { const indexName = 'test'; @@ -65,13 +60,13 @@ describe('HistoricalResultsList', () => { ); expect( - screen.getByLabelText(getAccordionToggleLabel(historicalResultFail.checkedAt)) + screen.getByTestId(`historicalResultAccordionButton-${historicalResultFail.checkedAt}`) ).toHaveTextContent( `Fail${getFormattedCheckTime(historicalResultFail.checkedAt)}1 Incompatible field` ); expect( - screen.getByLabelText(getAccordionToggleLabel(historicalResultPass.checkedAt)) + screen.getByTestId(`historicalResultAccordionButton-${historicalResultPass.checkedAt}`) ).toHaveTextContent( `Pass${getFormattedCheckTime(historicalResultPass.checkedAt)}0 Incompatible fields` ); @@ -97,9 +92,9 @@ describe('HistoricalResultsList', () => { ); - const accordionToggleButton = screen.getByRole('button', { - name: TOGGLE_HISTORICAL_RESULT_CHECKED_AT(getFormattedCheckTime(historicalResult.checkedAt)), - }); + const accordionToggleButton = screen.getByTestId( + `historicalResultAccordionButton-${historicalResult.checkedAt}` + ); expect(accordionToggleButton).toBeInTheDocument(); @@ -127,11 +122,9 @@ describe('HistoricalResultsList', () => { ); - const accordionToggleButton = screen.getByRole('button', { - name: TOGGLE_HISTORICAL_RESULT_CHECKED_AT( - getFormattedCheckTime(historicalResult.checkedAt) - ), - }); + const accordionToggleButton = screen.getByTestId( + `historicalResultAccordionButton-${historicalResult.checkedAt}` + ); expect(accordionToggleButton).toBeInTheDocument(); @@ -139,15 +132,11 @@ describe('HistoricalResultsList', () => { expect(accordionToggleButton).toHaveAttribute('aria-expanded', 'true'); - const accordionToggleDiv = screen.getByLabelText( - getAccordionToggleLabel(historicalResult.checkedAt) - ); - - expect(accordionToggleDiv).toHaveTextContent( + expect(accordionToggleButton).toHaveTextContent( `Fail${getFormattedCheckTime(historicalResult.checkedAt)}` ); - expect(accordionToggleDiv).not.toHaveTextContent('1 Incompatible field'); + expect(accordionToggleButton).not.toHaveTextContent('1 Incompatible field'); }); }); @@ -198,9 +187,9 @@ describe('HistoricalResultsList', () => { ); for (const result of results) { - const accordionToggleButton = screen.getByRole('button', { - name: TOGGLE_HISTORICAL_RESULT_CHECKED_AT(getFormattedCheckTime(result.checkedAt)), - }); + const accordionToggleButton = screen.getByTestId( + `historicalResultAccordionButton-${result.checkedAt}` + ); expect(accordionToggleButton).toBeInTheDocument(); @@ -209,9 +198,7 @@ describe('HistoricalResultsList', () => { await act(async () => userEvent.click(accordionToggleButton)); } - const allAccordionToggles = screen.getAllByRole('button', { - name: /Toggle historical result checked at/, - }); + const allAccordionToggles = screen.getAllByTestId(/historicalResultAccordionButton-.*/); for (const accordionToggleButton of allAccordionToggles) { expect(accordionToggleButton).toHaveAttribute('aria-expanded', 'true'); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/historical_results/historical_results_list/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/historical_results/historical_results_list/index.tsx index cabe0b26f8bac..4032f72389d58 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/historical_results/historical_results_list/index.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/historical_results/historical_results_list/index.tsx @@ -53,11 +53,11 @@ export const HistoricalResultsListComponent: FC = ({ indexName }) => { { setAccordionState((prevState) => ({ diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/historical_results/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/historical_results/index.test.tsx index 7c0b13f094030..9a74d5ffaa3f7 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/historical_results/index.test.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/historical_results/index.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { HistoricalResults } from '.'; -import { screen, render, within, act } from '@testing-library/react'; +import { screen, render, within, act, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { @@ -16,11 +16,7 @@ import { TestHistoricalResultsProvider, } from '../../../../../mock/test_providers/test_providers'; import { getHistoricalResultStub } from '../../../../../stub/get_historical_result_stub'; -import { - ERROR_LOADING_HISTORICAL_RESULTS, - FILTER_RESULTS_BY_OUTCOME, - LOADING_HISTORICAL_RESULTS, -} from './translations'; +import { ERROR_LOADING_HISTORICAL_RESULTS, LOADING_HISTORICAL_RESULTS } from './translations'; import { generateHistoricalResultsStub } from '../../../../../stub/generate_historical_results_stub'; describe('HistoricalResults', () => { @@ -44,7 +40,7 @@ describe('HistoricalResults', () => { ); - expect(screen.getByRole('status', { name: '2 checks' })).toBeInTheDocument(); + expect(screen.getByTestId('historicalResultsTotalChecks')).toBeInTheDocument(); expect(screen.getByTestId('historicalResultsList')).toBeInTheDocument(); }); @@ -69,10 +65,8 @@ describe('HistoricalResults', () => { ); - expect( - screen.getByRole('radiogroup', { name: FILTER_RESULTS_BY_OUTCOME }) - ).toBeInTheDocument(); - const outcomeFilterAll = screen.getByRole('radio', { name: 'All' }); + expect(screen.getByTestId('historicalResultsOutcomeFilterGroup')).toBeInTheDocument(); + const outcomeFilterAll = screen.getByTestId('historicalResultsOutcomeFilterAll'); expect(outcomeFilterAll).toBeInTheDocument(); expect(outcomeFilterAll).toHaveAttribute('aria-checked', 'true'); @@ -102,7 +96,7 @@ describe('HistoricalResults', () => { ); - const outcomeFilter = screen.getByRole('radio', { name: outcome }); + const outcomeFilter = screen.getByTestId(`historicalResultsOutcomeFilter${outcome}`); await act(async () => outcomeFilter.click()); const fetchQueryOpts = { @@ -145,14 +139,15 @@ describe('HistoricalResults', () => { ); const superDatePicker = screen.getByTestId('historicalResultsDatePicker'); - expect(superDatePicker).toBeInTheDocument(); expect( - within(superDatePicker).getByRole('button', { name: 'Date quick select' }) + within(superDatePicker).getByTestId('superDatePickerToggleQuickMenuButton') ).toBeInTheDocument(); expect( - within(superDatePicker).getByRole('button', { name: 'Last 30 days' }) + within(superDatePicker).getByTestId('superDatePickerShowDatesButton') + ).toHaveTextContent('Last 30 days'); + expect( + within(superDatePicker).getByTestId('superDatePickerApplyTimeButton') ).toBeInTheDocument(); - expect(within(superDatePicker).getByRole('button', { name: 'Refresh' })).toBeInTheDocument(); }); describe('when new date is selected', () => { @@ -181,14 +176,14 @@ describe('HistoricalResults', () => { const superDatePicker = screen.getByTestId('historicalResultsDatePicker'); await act(async () => { - const dateQuickSelect = within(superDatePicker).getByRole('button', { - name: 'Date quick select', - }); + const dateQuickSelect = within(superDatePicker).getByTestId( + 'superDatePickerToggleQuickMenuButton' + ); await userEvent.click(dateQuickSelect); }); await act(async () => { - const monthToDateButton = screen.getByRole('button', { name: 'Month to date' }); + const monthToDateButton = screen.getByTestId('superDatePickerCommonlyUsed_Month_to date'); await userEvent.click(monthToDateButton); }); @@ -215,7 +210,7 @@ describe('HistoricalResults', () => { describe('by default', () => { it('should show rows per page: 10 by default', () => { const indexName = 'test'; - const results = generateHistoricalResultsStub(indexName, 20); + const results = generateHistoricalResultsStub(indexName, 11); render( @@ -235,14 +230,16 @@ describe('HistoricalResults', () => { const wrapper = screen.getByTestId('historicalResultsPagination'); - expect(within(wrapper).getByText('Rows per page: 10')).toBeInTheDocument(); + expect(within(wrapper).getByTestId('tablePaginationPopoverButton')).toHaveTextContent( + 'Rows per page: 10' + ); }); }); describe('when rows per page are clicked', () => { it('should show 10, 25, 50 rows per page options', async () => { const indexName = 'test'; - const results = generateHistoricalResultsStub(indexName, 20); + const results = generateHistoricalResultsStub(indexName, 11); render( @@ -262,18 +259,20 @@ describe('HistoricalResults', () => { const wrapper = screen.getByTestId('historicalResultsPagination'); - await act(async () => userEvent.click(within(wrapper).getByText('Rows per page: 10'))); + await act(async () => + userEvent.click(within(wrapper).getByTestId('tablePaginationPopoverButton')) + ); - expect(screen.getByText('10 rows')).toBeInTheDocument(); - expect(screen.getByText('25 rows')).toBeInTheDocument(); - expect(screen.getByText('50 rows')).toBeInTheDocument(); + expect(screen.getByTestId('tablePagination-10-rows')).toBeInTheDocument(); + expect(screen.getByTestId('tablePagination-25-rows')).toBeInTheDocument(); + expect(screen.getByTestId('tablePagination-50-rows')).toBeInTheDocument(); }); }); describe('when total results are more than or equal 1 page', () => { it('should render pagination', () => { const indexName = 'test'; - const results = generateHistoricalResultsStub(indexName, 20); + const results = generateHistoricalResultsStub(indexName, 11); render( @@ -292,14 +291,12 @@ describe('HistoricalResults', () => { ); const wrapper = screen.getByTestId('historicalResultsPagination'); - - expect(within(wrapper).getByText('Rows per page: 10')).toBeInTheDocument(); - expect(within(wrapper).getByRole('list')).toBeInTheDocument(); + expect(within(wrapper).getByTestId('historicalResultsTablePagination')).toBeInTheDocument(); }); }); describe('when total results are less than 1 page', () => { - it('should not render pagination', () => { + it('should not render pagination', async () => { const indexName = 'test'; const results = generateHistoricalResultsStub(indexName, 9); render( @@ -319,14 +316,16 @@ describe('HistoricalResults', () => { ); - expect(screen.queryByTestId('historicalResultsPagination')).not.toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByTestId('historicalResultsPagination')).not.toBeInTheDocument(); + }); }); }); describe('when new page is clicked', () => { it('should invoke fetchHistoricalResults with new from and remaining fetch query opts', async () => { const indexName = 'test'; - const results = generateHistoricalResultsStub(indexName, 20); + const results = generateHistoricalResultsStub(indexName, 11); const fetchHistoricalResults = jest.fn(); render( @@ -346,9 +345,9 @@ describe('HistoricalResults', () => { ); - const nextPageButton = screen.getByLabelText('Page 2 of 2'); - expect(nextPageButton).toHaveRole('button'); - await act(async () => nextPageButton.click()); + const wrapper = screen.getByTestId('historicalResultsPagination'); + + await act(() => userEvent.click(within(wrapper).getByTestId('pagination-button-1'))); const fetchQueryOpts = { abortController: expect.any(AbortController), @@ -370,7 +369,7 @@ describe('HistoricalResults', () => { describe('when items per page is changed', () => { it('should invoke fetchHistoricalResults with new size, from: 0 and remaining fetch query opts', async () => { const indexName = 'test'; - const results = generateHistoricalResultsStub(indexName, 20); + const results = generateHistoricalResultsStub(indexName, 11); const fetchHistoricalResults = jest.fn(); render( @@ -392,9 +391,11 @@ describe('HistoricalResults', () => { const wrapper = screen.getByTestId('historicalResultsPagination'); - await act(async () => userEvent.click(within(wrapper).getByText('Rows per page: 10'))); + await act(() => + userEvent.click(within(wrapper).getByTestId('tablePaginationPopoverButton')) + ); - await act(async () => userEvent.click(screen.getByText('25 rows'))); + await act(() => userEvent.click(screen.getByTestId('tablePagination-25-rows'))); const fetchQueryOpts = { abortController: expect.any(AbortController), diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/historical_results/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/historical_results/index.tsx index 66fc6b100de13..3e12768efe39d 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/historical_results/index.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/historical_results/index.tsx @@ -112,10 +112,15 @@ export const HistoricalResultsComponent: FC = ({ indexName }) => {
- + @@ -124,6 +129,7 @@ export const HistoricalResultsComponent: FC = ({ indexName }) => { @@ -132,6 +138,7 @@ export const HistoricalResultsComponent: FC = ({ indexName }) => { @@ -156,6 +163,7 @@ export const HistoricalResultsComponent: FC = ({ indexName }) => { aria-live="polite" // because it's not inferred in accessibility tree aria-label={totalChecksText} + data-test-subj="historicalResultsTotalChecks" aria-describedby={historicalResultsListId} > {totalChecksText} diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/index.test.tsx index e73fd4c2d610d..a41e7d67e7682 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/index.test.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/index.test.tsx @@ -20,6 +20,7 @@ import { auditbeatWithAllResults } from '../../../../mock/pattern_rollup/mock_au import { mockStats } from '../../../../mock/stats/mock_stats'; import { mockHistoricalResult } from '../../../../mock/historical_results/mock_historical_results_response'; import { getFormattedCheckTime } from './utils/get_formatted_check_time'; +import { HISTORY_TAB_ID, LATEST_CHECK_TAB_ID } from '../constants'; describe('IndexCheckFlyout', () => { beforeEach(() => { @@ -55,7 +56,7 @@ describe('IndexCheckFlyout', () => { }); it('should render heading section correctly with formatted latest check time', () => { - expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent( + expect(screen.getByTestId('indexCheckFlyoutHeading')).toHaveTextContent( 'auditbeat-custom-index-1' ); expect(screen.getByTestId('latestCheckedAt')).toHaveTextContent( @@ -66,12 +67,12 @@ describe('IndexCheckFlyout', () => { }); it('should render tabs correctly, with latest check preselected', () => { - expect(screen.getByRole('tab', { name: 'Latest Check' })).toHaveAttribute( + expect(screen.getByTestId(`indexCheckFlyoutTab-${LATEST_CHECK_TAB_ID}`)).toHaveAttribute( 'aria-selected', 'true' ); - expect(screen.getByRole('tab', { name: 'Latest Check' })).not.toBeDisabled(); - expect(screen.getByRole('tab', { name: 'History' })).not.toBeDisabled(); + expect(screen.getByTestId(`indexCheckFlyoutTab-${LATEST_CHECK_TAB_ID}`)).not.toBeDisabled(); + expect(screen.getByTestId(`indexCheckFlyoutTab-${HISTORY_TAB_ID}`)).not.toBeDisabled(); }); it('should render the correct index properties panel', () => { @@ -80,7 +81,7 @@ describe('IndexCheckFlyout', () => { }); it('should render footer with check now button', () => { - expect(screen.getByRole('button', { name: 'Check now' })).toBeInTheDocument(); + expect(screen.getByTestId('indexCheckFlyoutCheckNowButton')).toBeInTheDocument(); }); }); @@ -107,7 +108,7 @@ describe('IndexCheckFlyout', () => { ); - const closeButton = screen.getByRole('button', { name: 'Close this dialog' }); + const closeButton = screen.getByTestId('euiFlyoutCloseButton'); await userEvent.click(closeButton); expect(onClose).toHaveBeenCalled(); @@ -141,7 +142,7 @@ describe('IndexCheckFlyout', () => { ); - const checkNowButton = screen.getByRole('button', { name: 'Check now' }); + const checkNowButton = screen.getByTestId('indexCheckFlyoutCheckNowButton'); await userEvent.click(checkNowButton); expect(checkIndex).toHaveBeenCalledWith({ @@ -189,16 +190,12 @@ describe('IndexCheckFlyout', () => { ); - expect(screen.getByRole('tab', { name: 'Latest Check' })).toHaveAttribute( - 'aria-selected', - 'true' - ); - expect(screen.getByRole('tab', { name: 'History' })).not.toHaveAttribute( - 'aria-selected', - 'true' - ); + const latestCheckTab = screen.getByTestId(`indexCheckFlyoutTab-${LATEST_CHECK_TAB_ID}`); + expect(latestCheckTab).toHaveAttribute('aria-selected', 'true'); + + const historyTab = screen.getByTestId(`indexCheckFlyoutTab-${HISTORY_TAB_ID}`); + expect(historyTab).toHaveAttribute('aria-selected', 'false'); - const historyTab = screen.getByRole('tab', { name: 'History' }); await userEvent.click(historyTab); expect(fetchHistoricalResults).toHaveBeenCalledWith({ @@ -206,11 +203,8 @@ describe('IndexCheckFlyout', () => { abortController: expect.any(AbortController), }); - expect(screen.getByRole('tab', { name: 'History' })).toHaveAttribute('aria-selected', 'true'); - expect(screen.getByRole('tab', { name: 'Latest Check' })).not.toHaveAttribute( - 'aria-selected', - 'true' - ); + expect(historyTab).toHaveAttribute('aria-selected', 'true'); + expect(latestCheckTab).toHaveAttribute('aria-selected', 'false'); expect(screen.getByTestId('historicalResults')).toBeInTheDocument(); }); @@ -240,17 +234,15 @@ describe('IndexCheckFlyout', () => { ); - const historyTab = screen.getByRole('tab', { name: 'History' }); - const latestCheckTab = screen.getByRole('tab', { name: 'Latest Check' }); + const historyTab = screen.getByTestId(`indexCheckFlyoutTab-${HISTORY_TAB_ID}`); + const latestCheckTab = screen.getByTestId(`indexCheckFlyoutTab-${LATEST_CHECK_TAB_ID}`); expect(historyTab).toHaveAttribute('data-tour-element', `${pattern}-history-tab`); expect(latestCheckTab).not.toHaveAttribute('data-tour-element', `${pattern}-history-tab`); await waitFor(() => expect(historyTab.closest('[data-test-subj="historicalResultsTour"]')).toBeInTheDocument() ); - expect( - screen.getByRole('dialog', { name: 'Introducing data quality history' }) - ).toBeInTheDocument(); + expect(screen.getByTestId('historicalResultsTourPanel')).toBeInTheDocument(); }); describe('when the tour close button is clicked', () => { @@ -276,11 +268,8 @@ describe('IndexCheckFlyout', () => { ); - const dialogWrapper = await screen.findByRole('dialog', { - name: 'Introducing data quality history', - }); - - const closeButton = within(dialogWrapper).getByRole('button', { name: 'Close' }); + const dialogWrapper = await screen.findByTestId('historicalResultsTourPanel'); + const closeButton = within(dialogWrapper).getByText('Close'); await userEvent.click(closeButton); expect(onDismissTour).toHaveBeenCalled(); @@ -310,15 +299,13 @@ describe('IndexCheckFlyout', () => { ); - const dialogWrapper = await screen.findByRole('dialog', { - name: 'Introducing data quality history', - }); + const dialogWrapper = await screen.findByTestId('historicalResultsTourPanel'); - const tryItButton = within(dialogWrapper).getByRole('button', { name: 'Try it' }); + const tryItButton = within(dialogWrapper).getByText('Try it'); await userEvent.click(tryItButton); expect(onDismissTour).toHaveBeenCalled(); - expect(screen.getByRole('tab', { name: 'History' })).toHaveAttribute( + expect(screen.getByTestId(`indexCheckFlyoutTab-${HISTORY_TAB_ID}`)).toHaveAttribute( 'aria-selected', 'true' ); @@ -350,7 +337,7 @@ describe('IndexCheckFlyout', () => { ); - const historyTab = screen.getByRole('tab', { name: 'History' }); + const historyTab = screen.getByTestId(`indexCheckFlyoutTab-${HISTORY_TAB_ID}`); await userEvent.click(historyTab); expect(onDismissTour).toHaveBeenCalled(); @@ -384,9 +371,7 @@ describe('IndexCheckFlyout', () => { expect(screen.queryByTestId('historicalResultsTour')).not.toBeInTheDocument() ); - expect( - screen.queryByRole('dialog', { name: 'Introducing data quality history' }) - ).not.toBeInTheDocument(); + expect(screen.queryByTestId('historicalResultsTourPanel')).not.toBeInTheDocument(); }); }); }); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/index.tsx index b6dcf850d15b0..3bb20323ca1c4 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/index.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/index.tsx @@ -171,6 +171,7 @@ export const IndexCheckFlyoutComponent: React.FC = ({ tabs.map((tab, index) => { return ( handleTabClick(tab.id)} isSelected={tab.id === selectedTabId} key={index} @@ -199,7 +200,9 @@ export const IndexCheckFlyoutComponent: React.FC = ({ {partitionedFieldMetadata?.incompatible != null && ( )} -

{indexName}

+

+ {indexName} +

{indexResult != null && indexResult.checkedAt != null && ( @@ -236,7 +239,13 @@ export const IndexCheckFlyoutComponent: React.FC = ({ - + {CHECK_NOW} diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/summary_table/utils/columns.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/summary_table/utils/columns.tsx index 832ba71d26af8..4e9bcf641ac16 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/summary_table/utils/columns.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/summary_table/utils/columns.tsx @@ -126,6 +126,7 @@ export const getSummaryTableColumns = ({ onCheckNowAction(item.indexName)} /> @@ -141,6 +142,7 @@ export const getSummaryTableColumns = ({ onViewHistoryAction(item.indexName)} {...(isFirstIndexName && { [HISTORICAL_RESULTS_TOUR_SELECTOR_KEY]: pattern, diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_summary/summary_actions/check_all/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_summary/summary_actions/check_all/index.test.tsx index f303d614bce00..cd9ddec6dffb7 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_summary/summary_actions/check_all/index.test.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_summary/summary_actions/check_all/index.test.tsx @@ -403,10 +403,9 @@ describe('CheckAll', () => { // simulate the wall clock advancing for (let i = 0; i < totalIndexNames + 1; i++) { - act(() => { + await act(async () => { jest.advanceTimersByTime(1000 * 10); }); - await waitFor(() => {}); } }); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/hooks/use_results_rollup/hooks/use_stored_pattern_results/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/hooks/use_results_rollup/hooks/use_stored_pattern_results/index.test.tsx new file mode 100644 index 0000000000000..5f90890eea693 --- /dev/null +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/hooks/use_results_rollup/hooks/use_stored_pattern_results/index.test.tsx @@ -0,0 +1,186 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks'; + +import { getHistoricalResultStub } from '../../../../stub/get_historical_result_stub'; +import { useStoredPatternResults } from '.'; + +const startTime = 'now-7d'; +const endTime = 'now'; +const isILMAvailable = true; + +describe('useStoredPatternResults', () => { + const httpFetch = jest.fn(); + const mockToasts = notificationServiceMock.createStartContract().toasts; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('when patterns are empty', () => { + it('should return an empty array and not call getStorageResults', () => { + const { result } = renderHook(() => + useStoredPatternResults({ + patterns: [], + toasts: mockToasts, + httpFetch, + isILMAvailable, + startTime, + endTime, + }) + ); + + expect(result.current).toEqual([]); + expect(httpFetch).not.toHaveBeenCalled(); + }); + }); + + describe('when patterns are provided', () => { + it('should fetch and return stored pattern results correctly', async () => { + const patterns = ['pattern1-*', 'pattern2-*']; + + httpFetch.mockImplementation((path: string) => { + if (path === '/internal/ecs_data_quality_dashboard/results_latest/pattern1-*') { + return Promise.resolve([getHistoricalResultStub('pattern1-index1')]); + } + + if (path === '/internal/ecs_data_quality_dashboard/results_latest/pattern2-*') { + return Promise.resolve([getHistoricalResultStub('pattern2-index1')]); + } + + return Promise.reject(new Error('Invalid path')); + }); + + const { result, waitFor } = renderHook(() => + useStoredPatternResults({ + patterns, + toasts: mockToasts, + httpFetch, + isILMAvailable, + startTime, + endTime, + }) + ); + + await waitFor(() => result.current.length > 0); + + expect(httpFetch).toHaveBeenCalledTimes(2); + + expect(httpFetch).toHaveBeenCalledWith( + '/internal/ecs_data_quality_dashboard/results_latest/pattern1-*', + { + method: 'GET', + signal: expect.any(AbortSignal), + version: '1', + } + ); + expect(httpFetch).toHaveBeenCalledWith( + '/internal/ecs_data_quality_dashboard/results_latest/pattern2-*', + { + method: 'GET', + signal: expect.any(AbortSignal), + version: '1', + } + ); + + expect(result.current).toEqual([ + { + pattern: 'pattern1-*', + results: { + 'pattern1-index1': { + docsCount: expect.any(Number), + error: null, + ilmPhase: expect.any(String), + incompatible: expect.any(Number), + indexName: 'pattern1-index1', + pattern: 'pattern1-*', + markdownComments: expect.any(Array), + sameFamily: expect.any(Number), + checkedAt: expect.any(Number), + }, + }, + }, + { + pattern: 'pattern2-*', + results: { + 'pattern2-index1': { + docsCount: expect.any(Number), + error: null, + ilmPhase: expect.any(String), + incompatible: expect.any(Number), + indexName: 'pattern2-index1', + pattern: 'pattern2-*', + markdownComments: expect.any(Array), + sameFamily: expect.any(Number), + checkedAt: expect.any(Number), + }, + }, + }, + ]); + }); + + describe('when isILMAvailable is false', () => { + it('should call getStorageResults with startDate and endDate', async () => { + const patterns = ['pattern1-*', 'pattern2-*']; + + httpFetch.mockImplementation((path: string) => { + if (path === '/internal/ecs_data_quality_dashboard/results_latest/pattern1-*') { + return Promise.resolve([getHistoricalResultStub('pattern1-index1')]); + } + + if (path === '/internal/ecs_data_quality_dashboard/results_latest/pattern2-*') { + return Promise.resolve([getHistoricalResultStub('pattern2-index1')]); + } + + return Promise.reject(new Error('Invalid path')); + }); + + const { result, waitFor } = renderHook(() => + useStoredPatternResults({ + patterns, + toasts: mockToasts, + httpFetch, + isILMAvailable: false, + startTime, + endTime, + }) + ); + + await waitFor(() => result.current.length > 0); + + expect(httpFetch).toHaveBeenCalledTimes(2); + + expect(httpFetch).toHaveBeenCalledWith( + '/internal/ecs_data_quality_dashboard/results_latest/pattern1-*', + { + method: 'GET', + signal: expect.any(AbortSignal), + version: '1', + query: { + startDate: startTime, + endDate: endTime, + }, + } + ); + expect(httpFetch).toHaveBeenCalledWith( + '/internal/ecs_data_quality_dashboard/results_latest/pattern2-*', + { + method: 'GET', + signal: expect.any(AbortSignal), + version: '1', + query: { + startDate: startTime, + endDate: endTime, + }, + } + ); + }); + }); + }); +}); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/hooks/use_results_rollup/hooks/use_stored_pattern_results/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/hooks/use_results_rollup/hooks/use_stored_pattern_results/index.tsx new file mode 100644 index 0000000000000..b92b36218c07a --- /dev/null +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/hooks/use_results_rollup/hooks/use_stored_pattern_results/index.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useEffect, useState } from 'react'; +import { IToasts } from '@kbn/core-notifications-browser'; +import { HttpHandler } from '@kbn/core-http-browser'; +import { isEmpty } from 'lodash/fp'; + +import { DataQualityCheckResult } from '../../../../types'; +import { + GetStorageResultsOpts, + formatResultFromStorage, + getStorageResults, +} from '../../utils/storage'; + +export interface UseStoredPatternResultsOpts { + patterns: string[]; + toasts: IToasts; + httpFetch: HttpHandler; + isILMAvailable: boolean; + startTime: string; + endTime: string; +} + +export type UseStoredPatternResultsReturnValue = Array<{ + pattern: string; + results: Record; +}>; + +export const useStoredPatternResults = ({ + patterns, + toasts, + httpFetch, + isILMAvailable, + startTime, + endTime, +}: UseStoredPatternResultsOpts): UseStoredPatternResultsReturnValue => { + const [storedPatternResults, setStoredPatternResults] = useState< + Array<{ pattern: string; results: Record }> + >([]); + + useEffect(() => { + if (isEmpty(patterns)) { + return; + } + + const abortController = new AbortController(); + const fetchStoredPatternResults = async () => { + const requests = patterns.map(async (pattern) => { + const getStorageResultsOpts: GetStorageResultsOpts = { + pattern, + httpFetch, + abortController, + toasts, + }; + + if (!isILMAvailable) { + getStorageResultsOpts.startTime = startTime; + getStorageResultsOpts.endTime = endTime; + } + + return getStorageResults(getStorageResultsOpts).then((results) => ({ + pattern, + results: Object.fromEntries( + results.map((storageResult) => [ + storageResult.indexName, + formatResultFromStorage({ storageResult, pattern }), + ]) + ), + })); + }); + + const patternResults = await Promise.all(requests); + if (patternResults?.length) { + setStoredPatternResults(patternResults); + } + }; + + fetchStoredPatternResults(); + }, [endTime, httpFetch, isILMAvailable, patterns, startTime, toasts]); + + return storedPatternResults; +}; diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/hooks/use_results_rollup/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/hooks/use_results_rollup/index.test.tsx new file mode 100644 index 0000000000000..7dc74731d66dd --- /dev/null +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/hooks/use_results_rollup/index.test.tsx @@ -0,0 +1,712 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// fixing timezone for Date +// so when tests are run in different timezones, the results are consistent +process.env.TZ = 'UTC'; + +import { renderHook, act } from '@testing-library/react-hooks'; +import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks'; + +import type { TelemetryEvents } from '../../types'; +import { useStoredPatternResults } from './hooks/use_stored_pattern_results'; +import { mockPartitionedFieldMetadata } from '../../mock/partitioned_field_metadata/mock_partitioned_field_metadata'; +import { useResultsRollup } from '.'; +import { getPatternRollupStub } from '../../stub/get_pattern_rollup_stub'; +import { formatBytes, formatNumber } from '../../mock/test_providers/utils/format'; + +jest.mock('./hooks/use_stored_pattern_results', () => ({ + ...jest.requireActual('./hooks/use_stored_pattern_results'), + useStoredPatternResults: jest.fn().mockReturnValue([]), +})); + +describe('useResultsRollup', () => { + const httpFetch = jest.fn(); + const toasts = notificationServiceMock.createStartContract().toasts; + + const mockTelemetryEvents: TelemetryEvents = { + reportDataQualityIndexChecked: jest.fn(), + reportDataQualityCheckAllCompleted: jest.fn(), + }; + + const patterns = ['auditbeat-*', 'packetbeat-*']; + const isILMAvailable = true; + const startTime = 'now-7d'; + const endTime = 'now'; + + const useStoredPatternResultsMock = useStoredPatternResults as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + useStoredPatternResultsMock.mockReturnValue([]); + }); + + describe('initialization', () => { + it('should initialize with default values', () => { + const { result } = renderHook(() => + useResultsRollup({ + httpFetch, + toasts, + patterns, + isILMAvailable, + telemetryEvents: mockTelemetryEvents, + startTime, + endTime, + }) + ); + + expect(result.current.patternIndexNames).toEqual({}); + expect(result.current.patternRollups).toEqual({}); + expect(result.current.totalDocsCount).toBe(0); + expect(result.current.totalIncompatible).toBeUndefined(); + expect(result.current.totalIndices).toBe(0); + expect(result.current.totalIndicesChecked).toBe(0); + expect(result.current.totalSameFamily).toBeUndefined(); + expect(result.current.totalSizeInBytes).toBe(0); + }); + + it('should fetch stored pattern results and update patternRollups from it', () => { + const mockStoredResults = [ + { + pattern: 'auditbeat-*', + results: { + 'auditbeat-7.11.0-2021.01.01': { + indexName: 'auditbeat-7.11.0-2021.01.01', + pattern: 'auditbeat-*', + docsCount: 500, + incompatible: 0, + error: null, + ilmPhase: 'hot', + sameFamily: 0, + markdownComments: [], + checkedAt: Date.now(), + }, + }, + }, + ]; + + useStoredPatternResultsMock.mockReturnValue(mockStoredResults); + + const { result } = renderHook(() => + useResultsRollup({ + httpFetch, + toasts, + patterns: ['auditbeat-*'], + isILMAvailable, + telemetryEvents: mockTelemetryEvents, + startTime, + endTime, + }) + ); + + expect(useStoredPatternResultsMock).toHaveBeenCalledWith({ + patterns: ['auditbeat-*'], + toasts, + httpFetch, + isILMAvailable, + startTime, + endTime, + }); + + expect(result.current.patternRollups).toEqual({ + 'auditbeat-*': { + pattern: 'auditbeat-*', + results: { + 'auditbeat-7.11.0-2021.01.01': expect.any(Object), + }, + }, + }); + }); + }); + + describe('updatePatternIndexNames', () => { + it('should update pattern index names', () => { + const { result } = renderHook(() => + useResultsRollup({ + httpFetch, + toasts, + patterns, + isILMAvailable, + telemetryEvents: mockTelemetryEvents, + startTime, + endTime, + }) + ); + + act(() => { + result.current.updatePatternIndexNames({ + pattern: 'packetbeat-*', + indexNames: ['packetbeat-7.10.0-2021.01.01'], + }); + }); + + expect(result.current.patternIndexNames).toEqual({ + 'packetbeat-*': ['packetbeat-7.10.0-2021.01.01'], + }); + }); + }); + + describe('updatePatternRollup', () => { + it('should update pattern rollup when called', () => { + const { result } = renderHook(() => + useResultsRollup({ + httpFetch, + toasts, + patterns, + isILMAvailable, + telemetryEvents: mockTelemetryEvents, + startTime, + endTime, + }) + ); + + const patternRollup = getPatternRollupStub('packetbeat-*', 1); + + expect(result.current.patternRollups).toEqual({}); + + act(() => { + result.current.updatePatternRollup(patternRollup); + }); + + expect(result.current.patternRollups).toEqual({ + 'packetbeat-*': patternRollup, + }); + }); + }); + + describe('onCheckCompleted', () => { + describe('when invoked with successful check data', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2021-10-07T00:00:00Z').getTime()); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should update patternRollup with said data, report to telemetry and persist it in storage', () => { + const { result } = renderHook(() => + useResultsRollup({ + httpFetch, + toasts, + patterns, + isILMAvailable, + telemetryEvents: mockTelemetryEvents, + startTime, + endTime, + }) + ); + + const patternRollup = getPatternRollupStub('packetbeat-*', 1); + + act(() => { + result.current.updatePatternRollup(patternRollup); + }); + + expect(result.current.patternRollups['packetbeat-*'].results?.['.ds-packetbeat-1']).toEqual( + { + checkedAt: new Date('2021-10-07T00:00:00Z').getTime(), + docsCount: 1000000, + error: null, + ilmPhase: 'hot', + incompatible: 0, + indexName: '.ds-packetbeat-1', + markdownComments: ['foo', 'bar', 'baz'], + pattern: 'packetbeat-*', + sameFamily: 0, + } + ); + + jest.advanceTimersByTime(1000); + + const mockOnCheckCompletedOpts = { + batchId: 'test-batch', + checkAllStartTime: Date.now(), + error: null, + formatBytes, + formatNumber, + indexName: '.ds-packetbeat-1', + partitionedFieldMetadata: mockPartitionedFieldMetadata, + pattern: 'packetbeat-*', + requestTime: 1500, + isLastCheck: true, + isCheckAll: true, + }; + + jest.advanceTimersByTime(1000); + + act(() => { + result.current.onCheckCompleted(mockOnCheckCompletedOpts); + }); + + expect(result.current.patternRollups['packetbeat-*'].results?.['.ds-packetbeat-1']).toEqual( + { + checkedAt: new Date('2021-10-07T00:00:02Z').getTime(), + docsCount: 1000000, + error: null, + ilmPhase: 'hot', + incompatible: 3, + indexName: '.ds-packetbeat-1', + markdownComments: expect.any(Array), + pattern: 'packetbeat-*', + sameFamily: 0, + } + ); + + expect(mockTelemetryEvents.reportDataQualityIndexChecked).toHaveBeenCalledWith({ + batchId: 'test-batch', + ecsVersion: '8.11.0', + errorCount: 0, + ilmPhase: 'hot', + indexId: 'uuid-1', + indexName: '.ds-packetbeat-1', + isCheckAll: true, + numberOfCustomFields: 4, + numberOfDocuments: 1000000, + numberOfEcsFields: 2, + numberOfFields: 9, + numberOfIncompatibleFields: 3, + numberOfIndices: 1, + numberOfIndicesChecked: 1, + numberOfSameFamily: 0, + sameFamilyFields: [], + sizeInBytes: 500000000, + timeConsumedMs: 1500, + unallowedMappingFields: ['host.name', 'source.ip'], + unallowedValueFields: ['event.category'], + }); + expect(mockTelemetryEvents.reportDataQualityCheckAllCompleted).toHaveBeenCalledWith({ + batchId: 'test-batch', + ecsVersion: '8.11.0', + isCheckAll: true, + numberOfDocuments: 1000000, + numberOfIncompatibleFields: 3, + numberOfIndices: 1, + numberOfIndicesChecked: 1, + numberOfSameFamily: 0, + sizeInBytes: 500000000, + timeConsumedMs: 1000, + }); + + expect(httpFetch).toHaveBeenCalledWith('/internal/ecs_data_quality_dashboard/results', { + method: 'POST', + version: '1', + signal: expect.any(AbortSignal), + body: expect.any(String), + }); + + const body = JSON.parse(httpFetch.mock.calls[0][1].body); + + expect(body).toEqual({ + batchId: 'test-batch', + indexName: '.ds-packetbeat-1', + indexPattern: 'packetbeat-*', + isCheckAll: true, + checkedAt: new Date('2021-10-07T00:00:02Z').getTime(), + docsCount: 1000000, + totalFieldCount: 9, + ecsFieldCount: 2, + customFieldCount: 4, + incompatibleFieldCount: 3, + incompatibleFieldMappingItems: [ + { + fieldName: 'host.name', + expectedValue: 'keyword', + actualValue: 'text', + description: + 'Name of the host.\nIt can contain what `hostname` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use.', + }, + { + fieldName: 'source.ip', + expectedValue: 'ip', + actualValue: 'text', + description: 'IP address of the source (IPv4 or IPv6).', + }, + ], + incompatibleFieldValueItems: [ + { + fieldName: 'event.category', + expectedValues: [ + 'authentication', + 'configuration', + 'database', + 'driver', + 'email', + 'file', + 'host', + 'iam', + 'intrusion_detection', + 'malware', + 'network', + 'package', + 'process', + 'registry', + 'session', + 'threat', + 'vulnerability', + 'web', + ], + actualValues: [ + { name: 'an_invalid_category', count: 2 }, + { name: 'theory', count: 1 }, + ], + description: + 'This is one of four ECS Categorization Fields, and indicates the second level in the ECS category hierarchy.\n`event.category` represents the "big buckets" of ECS categories. For example, filtering on `event.category:process` yields all events relating to process activity. This field is closely related to `event.type`, which is used as a subcategory.\nThis field is an array. This will allow proper categorization of some events that fall in multiple categories.', + }, + ], + sameFamilyFieldCount: 0, + sameFamilyFields: [], + sameFamilyFieldItems: [], + unallowedMappingFields: ['host.name', 'source.ip'], + unallowedValueFields: ['event.category'], + sizeInBytes: 500000000, + ilmPhase: 'hot', + markdownComments: [ + '### .ds-packetbeat-1\n', + '| Result | Index | Docs | Incompatible fields | ILM Phase | Size |\n|--------|-------|------|---------------------|-----------|------|\n| ❌ | .ds-packetbeat-1 | 1,000,000 (100.0%) | 3 | `hot` | 476.8MB |\n\n', + '### **Incompatible fields** `3` **Same family** `0` **Custom fields** `4` **ECS compliant fields** `2` **All fields** `9`\n', + "#### 3 incompatible fields\n\nFields are incompatible with ECS when index mappings, or the values of the fields in the index, don't conform to the Elastic Common Schema (ECS), version 8.11.0.\n\n❌ Detection engine rules referencing these fields may not match them correctly\n❌ Pages may not display some events or fields due to unexpected field mappings or values\n❌ Mappings or field values that don't comply with ECS are not supported\n", + '\n#### Incompatible field mappings - .ds-packetbeat-1\n\n\n| Field | ECS mapping type (expected) | Index mapping type (actual) | \n|-------|-----------------------------|-----------------------------|\n| host.name | `keyword` | `text` |\n| source.ip | `ip` | `text` |\n\n#### Incompatible field values - .ds-packetbeat-1\n\n\n| Field | ECS values (expected) | Document values (actual) | \n|-------|-----------------------|--------------------------|\n| event.category | `authentication`, `configuration`, `database`, `driver`, `email`, `file`, `host`, `iam`, `intrusion_detection`, `malware`, `network`, `package`, `process`, `registry`, `session`, `threat`, `vulnerability`, `web` | `an_invalid_category` (2), `theory` (1) |\n\n', + ], + ecsVersion: '8.11.0', + indexId: 'uuid-1', + error: null, + }); + }); + + describe('when isILMAvailable is false', () => { + it('should omit ilmPhase and nullify sizeInBytes when storing payload', () => { + const { result } = renderHook(() => + useResultsRollup({ + httpFetch, + toasts, + patterns, + isILMAvailable: false, + telemetryEvents: mockTelemetryEvents, + startTime, + endTime, + }) + ); + + const patternRollup = getPatternRollupStub('packetbeat-*', 1, false); + + act(() => { + result.current.updatePatternRollup(patternRollup); + }); + + jest.advanceTimersByTime(1000); + + const mockOnCheckCompletedOpts = { + batchId: 'test-batch', + checkAllStartTime: Date.now(), + error: null, + formatBytes, + formatNumber, + indexName: '.ds-packetbeat-1', + partitionedFieldMetadata: mockPartitionedFieldMetadata, + pattern: 'packetbeat-*', + requestTime: 1500, + isLastCheck: true, + isCheckAll: true, + }; + + jest.advanceTimersByTime(1000); + + act(() => { + result.current.onCheckCompleted(mockOnCheckCompletedOpts); + }); + + expect(mockTelemetryEvents.reportDataQualityIndexChecked).toHaveBeenCalledWith({ + batchId: 'test-batch', + ecsVersion: '8.11.0', + errorCount: 0, + ilmPhase: undefined, + indexId: 'uuid-1', + indexName: '.ds-packetbeat-1', + isCheckAll: true, + numberOfCustomFields: 4, + numberOfDocuments: 1000000, + numberOfEcsFields: 2, + numberOfFields: 9, + numberOfIncompatibleFields: 3, + numberOfIndices: 1, + numberOfIndicesChecked: 1, + numberOfSameFamily: 0, + sameFamilyFields: [], + sizeInBytes: undefined, + timeConsumedMs: 1500, + unallowedMappingFields: ['host.name', 'source.ip'], + unallowedValueFields: ['event.category'], + }); + expect(mockTelemetryEvents.reportDataQualityCheckAllCompleted).toHaveBeenCalledWith({ + batchId: 'test-batch', + ecsVersion: '8.11.0', + isCheckAll: true, + numberOfDocuments: 1000000, + numberOfIncompatibleFields: 3, + numberOfIndices: 1, + numberOfIndicesChecked: 1, + numberOfSameFamily: 0, + sizeInBytes: undefined, + timeConsumedMs: 1000, + }); + + expect(httpFetch).toHaveBeenCalledWith('/internal/ecs_data_quality_dashboard/results', { + method: 'POST', + version: '1', + signal: expect.any(AbortSignal), + body: expect.any(String), + }); + + const body = JSON.parse(httpFetch.mock.calls[0][1].body); + + expect(body).toEqual({ + batchId: 'test-batch', + indexName: '.ds-packetbeat-1', + indexPattern: 'packetbeat-*', + isCheckAll: true, + checkedAt: new Date('2021-10-07T00:00:02Z').getTime(), + docsCount: 1000000, + totalFieldCount: 9, + ecsFieldCount: 2, + customFieldCount: 4, + incompatibleFieldCount: 3, + incompatibleFieldMappingItems: [ + { + fieldName: 'host.name', + expectedValue: 'keyword', + actualValue: 'text', + description: + 'Name of the host.\nIt can contain what `hostname` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use.', + }, + { + fieldName: 'source.ip', + expectedValue: 'ip', + actualValue: 'text', + description: 'IP address of the source (IPv4 or IPv6).', + }, + ], + incompatibleFieldValueItems: [ + { + fieldName: 'event.category', + expectedValues: [ + 'authentication', + 'configuration', + 'database', + 'driver', + 'email', + 'file', + 'host', + 'iam', + 'intrusion_detection', + 'malware', + 'network', + 'package', + 'process', + 'registry', + 'session', + 'threat', + 'vulnerability', + 'web', + ], + actualValues: [ + { name: 'an_invalid_category', count: 2 }, + { name: 'theory', count: 1 }, + ], + description: + 'This is one of four ECS Categorization Fields, and indicates the second level in the ECS category hierarchy.\n`event.category` represents the "big buckets" of ECS categories. For example, filtering on `event.category:process` yields all events relating to process activity. This field is closely related to `event.type`, which is used as a subcategory.\nThis field is an array. This will allow proper categorization of some events that fall in multiple categories.', + }, + ], + sameFamilyFieldCount: 0, + sameFamilyFields: [], + sameFamilyFieldItems: [], + unallowedMappingFields: ['host.name', 'source.ip'], + unallowedValueFields: ['event.category'], + ilmPhase: undefined, + sizeInBytes: 0, + markdownComments: [ + '### .ds-packetbeat-1\n', + '| Result | Index | Docs | Incompatible fields |\n|--------|-------|------|---------------------|\n| ❌ | .ds-packetbeat-1 | 1,000,000 (100.0%) | 3 |\n\n', + '### **Incompatible fields** `3` **Same family** `0` **Custom fields** `4` **ECS compliant fields** `2` **All fields** `9`\n', + "#### 3 incompatible fields\n\nFields are incompatible with ECS when index mappings, or the values of the fields in the index, don't conform to the Elastic Common Schema (ECS), version 8.11.0.\n\n❌ Detection engine rules referencing these fields may not match them correctly\n❌ Pages may not display some events or fields due to unexpected field mappings or values\n❌ Mappings or field values that don't comply with ECS are not supported\n", + '\n#### Incompatible field mappings - .ds-packetbeat-1\n\n\n| Field | ECS mapping type (expected) | Index mapping type (actual) | \n|-------|-----------------------------|-----------------------------|\n| host.name | `keyword` | `text` |\n| source.ip | `ip` | `text` |\n\n#### Incompatible field values - .ds-packetbeat-1\n\n\n| Field | ECS values (expected) | Document values (actual) | \n|-------|-----------------------|--------------------------|\n| event.category | `authentication`, `configuration`, `database`, `driver`, `email`, `file`, `host`, `iam`, `intrusion_detection`, `malware`, `network`, `package`, `process`, `registry`, `session`, `threat`, `vulnerability`, `web` | `an_invalid_category` (2), `theory` (1) |\n\n', + ], + ecsVersion: '8.11.0', + indexId: 'uuid-1', + error: null, + }); + }); + }); + }); + + describe('when check fails with error message and no partitionedFieldMetadata', () => { + it('should update patternRollup with error message, reset state without persisting in storage', () => { + const { result } = renderHook(() => + useResultsRollup({ + httpFetch, + toasts, + patterns, + isILMAvailable, + telemetryEvents: mockTelemetryEvents, + startTime, + endTime, + }) + ); + + const patternRollup = getPatternRollupStub('packetbeat-*', 1); + + act(() => { + result.current.updatePatternRollup(patternRollup); + }); + + const mockOnCheckCompletedOpts = { + batchId: 'test-batch', + checkAllStartTime: Date.now(), + error: 'Something went wrong', + formatBytes, + formatNumber, + indexName: '.ds-packetbeat-1', + partitionedFieldMetadata: null, + pattern: 'packetbeat-*', + requestTime: 1500, + isLastCheck: true, + isCheckAll: true, + }; + + act(() => { + result.current.onCheckCompleted(mockOnCheckCompletedOpts); + }); + + expect(result.current.patternRollups['packetbeat-*'].results?.['.ds-packetbeat-1']).toEqual( + { + checkedAt: undefined, + docsCount: 1000000, + error: 'Something went wrong', + ilmPhase: 'hot', + incompatible: undefined, + indexName: '.ds-packetbeat-1', + markdownComments: expect.any(Array), + pattern: 'packetbeat-*', + sameFamily: undefined, + } + ); + + expect(mockTelemetryEvents.reportDataQualityIndexChecked).not.toHaveBeenCalled(); + + expect(httpFetch).not.toHaveBeenCalledWith( + '/internal/ecs_data_quality_dashboard/results', + expect.any(Object) + ); + }); + }); + + describe('edge cases', () => { + describe('given no error nor partitionedFieldMetadata', () => { + it('should reset result state accordingly and not invoke telemetry report nor persist in storage', () => { + const { result } = renderHook(() => + useResultsRollup({ + httpFetch, + toasts, + patterns, + isILMAvailable, + telemetryEvents: mockTelemetryEvents, + startTime, + endTime, + }) + ); + + const patternRollup = getPatternRollupStub('packetbeat-*', 1); + + act(() => { + result.current.updatePatternRollup(patternRollup); + }); + + const mockOnCheckCompletedOpts = { + batchId: 'test-batch', + checkAllStartTime: Date.now(), + error: null, + formatBytes, + formatNumber, + indexName: '.ds-packetbeat-1', + partitionedFieldMetadata: null, + pattern: 'packetbeat-*', + requestTime: 1500, + isLastCheck: true, + isCheckAll: true, + }; + + act(() => { + result.current.onCheckCompleted(mockOnCheckCompletedOpts); + }); + + expect( + result.current.patternRollups['packetbeat-*'].results?.['.ds-packetbeat-1'] + ).toEqual({ + checkedAt: undefined, + docsCount: 1000000, + error: null, + ilmPhase: 'hot', + incompatible: undefined, + indexName: '.ds-packetbeat-1', + markdownComments: expect.any(Array), + pattern: 'packetbeat-*', + sameFamily: undefined, + }); + + expect(mockTelemetryEvents.reportDataQualityIndexChecked).not.toHaveBeenCalled(); + + expect(httpFetch).not.toHaveBeenCalledWith( + '/internal/ecs_data_quality_dashboard/results', + expect.any(Object) + ); + }); + }); + }); + }); + + describe('calculating totals', () => { + describe('when patternRollups change', () => { + it('should update totals', () => { + const { result } = renderHook(() => + useResultsRollup({ + httpFetch, + toasts, + patterns: ['packetbeat-*', 'auditbeat-*'], + isILMAvailable, + telemetryEvents: mockTelemetryEvents, + startTime, + endTime, + }) + ); + + const patternRollup1 = getPatternRollupStub('packetbeat-*', 1); + const patternRollup2 = getPatternRollupStub('auditbeat-*', 1); + + expect(result.current.totalIndices).toBe(0); + expect(result.current.totalDocsCount).toBe(0); + expect(result.current.totalSizeInBytes).toBe(0); + + act(() => { + result.current.updatePatternRollup(patternRollup1); + }); + + expect(result.current.totalIndices).toEqual(1); + expect(result.current.totalDocsCount).toEqual(1000000); + expect(result.current.totalSizeInBytes).toEqual(500000000); + + act(() => { + result.current.updatePatternRollup(patternRollup2); + }); + + expect(result.current.totalIndices).toEqual(2); + expect(result.current.totalDocsCount).toEqual(2000000); + expect(result.current.totalSizeInBytes).toEqual(1000000000); + }); + }); + }); +}); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/hooks/use_results_rollup/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/hooks/use_results_rollup/index.tsx index 28b36765a245b..bfed849e373d3 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/hooks/use_results_rollup/index.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/hooks/use_results_rollup/index.tsx @@ -21,91 +21,48 @@ import { getTotalPatternSameFamily, getIndexId, } from './utils/stats'; -import { - getStorageResults, - postStorageResult, - formatStorageResult, - formatResultFromStorage, -} from './utils/storage'; +import { postStorageResult, formatStorageResult } from './utils/storage'; import { getPatternRollupsWithLatestCheckResult } from './utils/get_pattern_rollups_with_latest_check_result'; -import type { - DataQualityCheckResult, - OnCheckCompleted, - PatternRollup, - TelemetryEvents, -} from '../../types'; +import type { OnCheckCompleted, PatternRollup, TelemetryEvents } from '../../types'; import { getEscapedIncompatibleMappingsFields, getEscapedIncompatibleValuesFields, getEscapedSameFamilyFields, } from './utils/metadata'; import { UseResultsRollupReturnValue } from './types'; -import { useIsMountedRef } from '../use_is_mounted_ref'; import { getDocsCount, getIndexIncompatible, getSizeInBytes } from '../../utils/stats'; import { getIlmPhase } from '../../utils/get_ilm_phase'; +import { useStoredPatternResults } from './hooks/use_stored_pattern_results'; interface Props { - ilmPhases: string[]; patterns: string[]; toasts: IToasts; httpFetch: HttpHandler; telemetryEvents: TelemetryEvents; isILMAvailable: boolean; + startTime: string; + endTime: string; } -const useStoredPatternResults = (patterns: string[], toasts: IToasts, httpFetch: HttpHandler) => { - const { isMountedRef } = useIsMountedRef(); - const [storedPatternResults, setStoredPatternResults] = useState< - Array<{ pattern: string; results: Record }> - >([]); - - useEffect(() => { - if (isEmpty(patterns)) { - return; - } - - let ignore = false; - const abortController = new AbortController(); - const fetchStoredPatternResults = async () => { - const requests = patterns.map((pattern) => - getStorageResults({ pattern, httpFetch, abortController, toasts }).then((results = []) => ({ - pattern, - results: Object.fromEntries( - results.map((storageResult) => [ - storageResult.indexName, - formatResultFromStorage({ storageResult, pattern }), - ]) - ), - })) - ); - const patternResults = await Promise.all(requests); - if (patternResults?.length && !ignore) { - if (isMountedRef.current) { - setStoredPatternResults(patternResults); - } - } - }; - - fetchStoredPatternResults(); - return () => { - ignore = true; - }; - }, [httpFetch, isMountedRef, patterns, toasts]); - - return storedPatternResults; -}; - export const useResultsRollup = ({ httpFetch, toasts, - ilmPhases, patterns, isILMAvailable, telemetryEvents, + startTime, + endTime, }: Props): UseResultsRollupReturnValue => { const [patternIndexNames, setPatternIndexNames] = useState>({}); const [patternRollups, setPatternRollups] = useState>({}); - const storedPatternsResults = useStoredPatternResults(patterns, toasts, httpFetch); + const storedPatternsResults = useStoredPatternResults({ + httpFetch, + patterns, + toasts, + isILMAvailable, + startTime, + endTime, + }); useEffect(() => { if (!isEmpty(storedPatternsResults)) { @@ -247,12 +204,6 @@ export const useResultsRollup = ({ [httpFetch, isILMAvailable, telemetryEvents, toasts] ); - useEffect(() => { - // reset all state - setPatternRollups({}); - setPatternIndexNames({}); - }, [ilmPhases, patterns]); - const useResultsRollupReturnValue = useMemo( () => ({ onCheckCompleted, diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/hooks/use_results_rollup/utils/storage.test.ts b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/hooks/use_results_rollup/utils/storage.test.ts index 9f315d65c01d5..b43954e73f6fd 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/hooks/use_results_rollup/utils/storage.test.ts +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/hooks/use_results_rollup/utils/storage.test.ts @@ -200,4 +200,26 @@ describe('getStorageResults', () => { expect(toasts.addError).toHaveBeenCalledWith('test-error', { title: expect.any(String) }); expect(results).toEqual([]); }); + + it('should provide stad and end date', async () => { + await getStorageResults({ + httpFetch: fetch, + abortController: new AbortController(), + pattern: 'auditbeat-*', + toasts, + startTime: 'now-7d', + endTime: 'now', + }); + + expect(fetch).toHaveBeenCalledWith( + '/internal/ecs_data_quality_dashboard/results_latest/auditbeat-*', + expect.objectContaining({ + method: 'GET', + query: { + startDate: 'now-7d', + endDate: 'now', + }, + }) + ); + }); }); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/hooks/use_results_rollup/utils/storage.ts b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/hooks/use_results_rollup/utils/storage.ts index e4a5c43d5b4a5..7fc339c085bea 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/hooks/use_results_rollup/utils/storage.ts +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/hooks/use_results_rollup/utils/storage.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { HttpHandler } from '@kbn/core-http-browser'; +import { HttpFetchQuery, HttpHandler } from '@kbn/core-http-browser'; import { IToasts } from '@kbn/core-notifications-browser'; import { @@ -131,23 +131,40 @@ export async function postStorageResult({ } } +export interface GetStorageResultsOpts { + pattern: string; + httpFetch: HttpHandler; + toasts: IToasts; + abortController: AbortController; + startTime?: string; + endTime?: string; +} + export async function getStorageResults({ pattern, httpFetch, toasts, abortController, -}: { - pattern: string; - httpFetch: HttpHandler; - toasts: IToasts; - abortController: AbortController; -}): Promise { + startTime, + endTime, +}: GetStorageResultsOpts): Promise { try { const route = GET_INDEX_RESULTS_LATEST.replace('{pattern}', pattern); + + const query: HttpFetchQuery = {}; + + if (startTime) { + query.startDate = startTime; + } + if (endTime) { + query.endDate = endTime; + } + const results = await httpFetch(route, { method: 'GET', signal: abortController.signal, version: INTERNAL_API_VERSION, + ...(Object.keys(query).length > 0 ? { query } : {}), }); return results; } catch (err) { diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/index.test.tsx index 90e5dba08d4dc..f925a67ea3d32 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/index.test.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/index.test.tsx @@ -67,6 +67,8 @@ describe('DataQualityPanel', () => { setLastChecked={jest.fn()} baseTheme={DARK_THEME} toasts={toasts} + defaultStartTime={'now-7d'} + defaultEndTime={'now'} /> ); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/index.tsx index 7d1a106d83570..9b9cbdefb6670 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/index.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/index.tsx @@ -46,6 +46,8 @@ interface Props { setLastChecked: (lastChecked: string) => void; startDate?: string | null; theme?: PartialTheme; + defaultStartTime: string; + defaultEndTime: string; } const defaultSelectedIlmPhaseOptions: EuiComboBoxOptionOption[] = ilmPhaseOptionsStatic.filter( @@ -71,6 +73,8 @@ const DataQualityPanelComponent: React.FC = ({ setLastChecked, startDate, theme, + defaultStartTime, + defaultEndTime, }) => { const [selectedIlmPhaseOptions, setSelectedIlmPhaseOptions] = useState( defaultSelectedIlmPhaseOptions @@ -104,12 +108,13 @@ const DataQualityPanelComponent: React.FC = ({ ); const resultsRollupHookReturnValue = useResultsRollup({ - ilmPhases, patterns, httpFetch, toasts, isILMAvailable, telemetryEvents, + startTime: defaultStartTime, + endTime: defaultEndTime, }); const indicesCheckHookReturnValue = useIndicesCheck({ @@ -138,6 +143,8 @@ const DataQualityPanelComponent: React.FC = ({ ilmPhases={ilmPhases} selectedIlmPhaseOptions={selectedIlmPhaseOptions} setSelectedIlmPhaseOptions={setSelectedIlmPhaseOptions} + defaultStartTime={defaultStartTime} + defaultEndTime={defaultEndTime} > diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/mock/test_providers/test_providers.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/mock/test_providers/test_providers.tsx index 17b73f1e6dcd0..e0220d26e8690 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/mock/test_providers/test_providers.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/mock/test_providers/test_providers.tsx @@ -135,6 +135,8 @@ const TestDataQualityProvidersComponent: React.FC ilmPhases, selectedIlmPhaseOptions, setSelectedIlmPhaseOptions, + defaultStartTime, + defaultEndTime, } = getMergedDataQualityContextProps(dataQualityContextProps); const mergedResultsRollupContextProps = @@ -162,6 +164,8 @@ const TestDataQualityProvidersComponent: React.FC ilmPhases={ilmPhases} selectedIlmPhaseOptions={selectedIlmPhaseOptions} setSelectedIlmPhaseOptions={setSelectedIlmPhaseOptions} + defaultStartTime={defaultStartTime} + defaultEndTime={defaultEndTime} > + value != null ? numeral(value).format(defaultBytesFormat) : EMPTY_STAT; + +const defaultNumberFormat = '0,0.[000]'; +export const formatNumber = (value: number | undefined) => + value != null ? numeral(value).format(defaultNumberFormat) : EMPTY_STAT; diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/mock/test_providers/utils/get_merged_data_quality_context_props.ts b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/mock/test_providers/utils/get_merged_data_quality_context_props.ts index 264198e510b5e..be12434fc6c85 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/mock/test_providers/utils/get_merged_data_quality_context_props.ts +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/mock/test_providers/utils/get_merged_data_quality_context_props.ts @@ -5,10 +5,9 @@ * 2.0. */ -import numeral from '@elastic/numeral'; - import { DataQualityProviderProps } from '../../../data_quality_context'; -import { EMPTY_STAT } from '../../../constants'; + +import { formatBytes as formatBytesMock, formatNumber as formatNumberMock } from './format'; export const getMergedDataQualityContextProps = ( dataQualityContextProps?: Partial @@ -31,15 +30,15 @@ export const getMergedDataQualityContextProps = ( ilmPhases, selectedIlmPhaseOptions, setSelectedIlmPhaseOptions, + defaultStartTime, + defaultEndTime, } = { isILMAvailable: true, addSuccessToast: jest.fn(), canUserCreateAndReadCases: jest.fn(() => true), endDate: null, - formatBytes: (value: number | undefined) => - value != null ? numeral(value).format('0,0.[0]b') : EMPTY_STAT, - formatNumber: (value: number | undefined) => - value != null ? numeral(value).format('0,0.[000]') : EMPTY_STAT, + formatBytes: formatBytesMock, + formatNumber: formatNumberMock, isAssistantEnabled: true, lastChecked: '2023-03-28T22:27:28.159Z', openCreateCaseFlyout: jest.fn(), @@ -72,6 +71,8 @@ export const getMergedDataQualityContextProps = ( }, ], setSelectedIlmPhaseOptions: jest.fn(), + defaultStartTime: 'now-7d/d', + defaultEndTime: 'now/d', ...dataQualityContextProps, }; @@ -93,5 +94,7 @@ export const getMergedDataQualityContextProps = ( ilmPhases, selectedIlmPhaseOptions, setSelectedIlmPhaseOptions, + defaultStartTime, + defaultEndTime, }; }; diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/stub/get_pattern_rollup_stub/index.ts b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/stub/get_pattern_rollup_stub/index.ts new file mode 100644 index 0000000000000..38aa129a6ec9a --- /dev/null +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/stub/get_pattern_rollup_stub/index.ts @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PatternRollup } from '../../types'; + +const phases = ['hot', 'warm', 'cold', 'frozen'] as const; + +/** + * + * This function derives ilmExplain, results, stats and ilmExplainPhaseCounts + * from the provided pattern and indicesCount for the purpose of simplifying + * stubbing of resultsRollup in tests. + * + * @param pattern - The index pattern to simulate. Defaults to `'packetbeat-*'`. + * @param indicesCount - The number of indices to generate. Defaults to `2`. + * @param isILMAvailable - Whether ILM is available. Defaults to `true`. + * @returns An object containing stubbed pattern rollup data + */ +export const getPatternRollupStub = ( + pattern = 'packetbeat-*', + indicesCount = 2, + isILMAvailable = true +): PatternRollup => { + // Derive ilmExplain from isILMAvailable, pattern and indicesCount + const ilmExplain = isILMAvailable + ? Object.fromEntries( + Array.from({ length: indicesCount }).map((_, i) => { + const indexName = pattern.replace('*', `${i + 1}`); + const dsIndexName = `.ds-${indexName}`; + // Cycle through phases + const phase = phases[i % phases.length]; + return [ + dsIndexName, + { + index: dsIndexName, + managed: true, + policy: pattern, + phase, + }, + ]; + }) + ) + : null; + + // Derive ilmExplainPhaseCounts from ilmExplain + const ilmExplainPhaseCounts = ilmExplain + ? phases.reduce( + (counts, phase) => ({ + ...counts, + [phase]: Object.values(ilmExplain).filter((explain) => explain.phase === phase).length, + }), + { hot: 0, warm: 0, cold: 0, frozen: 0, unmanaged: 0 } + ) + : undefined; + + // Derive results from pattern and indicesCount + const results = Object.fromEntries( + Array.from({ length: indicesCount }, (_, i) => { + const indexName = pattern.replace('*', `${i + 1}`); + const dsIndexName = `.ds-${indexName}`; + return [ + dsIndexName, + { + docsCount: 1000000 + i * 100000, // Example doc count + error: null, + ilmPhase: ilmExplain?.[dsIndexName].phase, + incompatible: i, + indexName: dsIndexName, + markdownComments: ['foo', 'bar', 'baz'], + pattern, + sameFamily: i, + checkedAt: Date.now(), + }, + ]; + }) + ); + + // Derive stats from isILMAvailable, pattern and indicesCount + const stats = Object.fromEntries( + Array.from({ length: indicesCount }, (_, i) => { + const indexName = pattern.replace('*', `${i + 1}`); + const dsIndexName = `.ds-${indexName}`; + return [ + dsIndexName, + { + uuid: `uuid-${i + 1}`, + size_in_bytes: isILMAvailable ? 500000000 + i * 10000000 : null, + name: dsIndexName, + num_docs: results[dsIndexName].docsCount, + }, + ]; + }) + ); + + // Derive total docsCount and sizeInBytes from stats + const totalDocsCount = Object.values(stats).reduce((sum, stat) => sum + stat.num_docs, 0); + const totalSizeInBytes = isILMAvailable + ? Object.values(stats).reduce((sum, stat) => sum + (stat.size_in_bytes ?? 0), 0) + : undefined; + + return { + docsCount: totalDocsCount, + error: null, + pattern, + ilmExplain, + ilmExplainPhaseCounts, + indices: indicesCount, + results, + sizeInBytes: totalSizeInBytes, + stats, + }; +}; diff --git a/x-pack/packages/security/authorization_core/src/actions/api.ts b/x-pack/packages/security/authorization_core/src/actions/api.ts index fec6296d8f63f..d91bc1bd89669 100644 --- a/x-pack/packages/security/authorization_core/src/actions/api.ts +++ b/x-pack/packages/security/authorization_core/src/actions/api.ts @@ -8,6 +8,7 @@ import { isString } from 'lodash'; import type { ApiActions as ApiActionsType } from '@kbn/security-plugin-types-server'; +import { ApiOperation } from '@kbn/security-plugin-types-server'; export class ApiActions implements ApiActionsType { private readonly prefix: string; @@ -16,11 +17,33 @@ export class ApiActions implements ApiActionsType { this.prefix = `api:`; } - public get(operation: string) { - if (!operation || !isString(operation)) { - throw new Error('operation is required and must be a string'); + private isValidOperation(operation: string): operation is ApiOperation { + return Object.values(ApiOperation).includes(operation as ApiOperation); + } + public actionFromRouteTag(routeTag: string) { + const [operation, subject] = routeTag.split('_'); + if (!this.isValidOperation(operation)) { + throw new Error('operation is required and must be a valid ApiOperation'); + } + return this.get(operation, subject); + } + + public get(operation: string | ApiOperation, subject?: string) { + if (arguments.length === 1) { + if (!isString(operation) || !operation) { + throw new Error('operation is required and must be a string'); + } + return `${this.prefix}${operation}`; + } + + if (!isString(subject) || !subject) { + throw new Error('subject is required and must be a string'); + } + + if (!this.isValidOperation(operation)) { + throw new Error('operation is required and must be a valid ApiOperation'); } - return `${this.prefix}${operation}`; + return `${this.prefix}${operation}_${subject}`; } } diff --git a/x-pack/packages/security/authorization_core/src/privileges/privileges.test.ts b/x-pack/packages/security/authorization_core/src/privileges/privileges.test.ts index f9d490bfcb09b..6af21d5357a72 100644 --- a/x-pack/packages/security/authorization_core/src/privileges/privileges.test.ts +++ b/x-pack/packages/security/authorization_core/src/privileges/privileges.test.ts @@ -7,6 +7,7 @@ import { KibanaFeature } from '@kbn/features-plugin/server'; import { featuresPluginMock } from '@kbn/features-plugin/server/mocks'; +import { ApiOperation } from '@kbn/security-plugin-types-server'; import { getReplacedByForPrivilege, privilegesFactory } from './privileges'; import { licenseMock } from '../__fixtures__/licensing.mock'; @@ -793,10 +794,12 @@ describe('features', () => { const actual = privileges.get(); expect(actual).toHaveProperty(`${group}.all`, [ actions.login, - ...(expectDecryptedTelemetry ? [actions.api.get('decryptedTelemetry')] : []), - ...(expectGetFeatures ? [actions.api.get('features')] : []), - ...(expectGetFeatures ? [actions.api.get('taskManager')] : []), - ...(expectGetFeatures ? [actions.api.get('manageSpaces')] : []), + ...(expectDecryptedTelemetry + ? [actions.api.get(ApiOperation.Read, 'decryptedTelemetry')] + : []), + ...(expectGetFeatures ? [actions.api.get(ApiOperation.Read, 'features')] : []), + ...(expectGetFeatures ? [actions.api.get(ApiOperation.Manage, 'taskManager')] : []), + ...(expectGetFeatures ? [actions.api.get(ApiOperation.Manage, 'spaces')] : []), ...(expectManageSpaces ? [ actions.space.manage, @@ -965,10 +968,12 @@ describe('features', () => { const expectedActions = [ actions.login, - ...(expectDecryptedTelemetry ? [actions.api.get('decryptedTelemetry')] : []), - ...(expectGetFeatures ? [actions.api.get('features')] : []), - ...(expectGetFeatures ? [actions.api.get('taskManager')] : []), - ...(expectGetFeatures ? [actions.api.get('manageSpaces')] : []), + ...(expectDecryptedTelemetry + ? [actions.api.get(ApiOperation.Read, 'decryptedTelemetry')] + : []), + ...(expectGetFeatures ? [actions.api.get(ApiOperation.Read, 'features')] : []), + ...(expectGetFeatures ? [actions.api.get(ApiOperation.Manage, 'taskManager')] : []), + ...(expectGetFeatures ? [actions.api.get(ApiOperation.Manage, 'spaces')] : []), ...(expectManageSpaces ? [ actions.space.manage, @@ -1124,7 +1129,9 @@ describe('features', () => { const actual = privileges.get(); expect(actual).toHaveProperty(`${group}.read`, [ actions.login, - ...(expectDecryptedTelemetry ? [actions.api.get('decryptedTelemetry')] : []), + ...(expectDecryptedTelemetry + ? [actions.api.get(ApiOperation.Read, 'decryptedTelemetry')] + : []), ...(expectGlobalSettings ? [actions.ui.get('globalSettings', 'show')] : []), actions.ui.get('catalogue', 'read-catalogue-1'), actions.ui.get('catalogue', 'read-catalogue-2'), @@ -1243,7 +1250,9 @@ describe('features', () => { const expectedActions = [ actions.login, - ...(expectDecryptedTelemetry ? [actions.api.get('decryptedTelemetry')] : []), + ...(expectDecryptedTelemetry + ? [actions.api.get(ApiOperation.Read, 'decryptedTelemetry')] + : []), ...(expectGlobalSettings ? [actions.ui.get('globalSettings', 'show')] : []), actions.ui.get('catalogue', 'read-catalogue-2'), actions.ui.get('management', 'read-management', 'read-management-2'), @@ -1341,10 +1350,12 @@ describe('features', () => { const actual = privileges.get(); expect(actual).toHaveProperty(`${group}.all`, [ actions.login, - ...(expectDecryptedTelemetry ? [actions.api.get('decryptedTelemetry')] : []), - ...(expectGetFeatures ? [actions.api.get('features')] : []), - ...(expectGetFeatures ? [actions.api.get('taskManager')] : []), - ...(expectGetFeatures ? [actions.api.get('manageSpaces')] : []), + ...(expectDecryptedTelemetry + ? [actions.api.get(ApiOperation.Read, 'decryptedTelemetry')] + : []), + ...(expectGetFeatures ? [actions.api.get(ApiOperation.Read, 'features')] : []), + ...(expectGetFeatures ? [actions.api.get(ApiOperation.Manage, 'taskManager')] : []), + ...(expectGetFeatures ? [actions.api.get(ApiOperation.Manage, 'spaces')] : []), ...(expectManageSpaces ? [ actions.space.manage, @@ -1359,7 +1370,9 @@ describe('features', () => { ]); expect(actual).toHaveProperty(`${group}.read`, [ actions.login, - ...(expectDecryptedTelemetry ? [actions.api.get('decryptedTelemetry')] : []), + ...(expectDecryptedTelemetry + ? [actions.api.get(ApiOperation.Read, 'decryptedTelemetry')] + : []), ...(expectGlobalSettings ? [actions.ui.get('globalSettings', 'show')] : []), ]); }); @@ -1410,10 +1423,12 @@ describe('features', () => { const actual = privileges.get(); expect(actual).toHaveProperty(`${group}.all`, [ actions.login, - ...(expectDecryptedTelemetry ? [actions.api.get('decryptedTelemetry')] : []), - ...(expectGetFeatures ? [actions.api.get('features')] : []), - ...(expectGetFeatures ? [actions.api.get('taskManager')] : []), - ...(expectGetFeatures ? [actions.api.get('manageSpaces')] : []), + ...(expectDecryptedTelemetry + ? [actions.api.get(ApiOperation.Read, 'decryptedTelemetry')] + : []), + ...(expectGetFeatures ? [actions.api.get(ApiOperation.Read, 'features')] : []), + ...(expectGetFeatures ? [actions.api.get(ApiOperation.Manage, 'taskManager')] : []), + ...(expectGetFeatures ? [actions.api.get(ApiOperation.Manage, 'spaces')] : []), ...(expectManageSpaces ? [ actions.space.manage, @@ -1428,7 +1443,9 @@ describe('features', () => { ]); expect(actual).toHaveProperty(`${group}.read`, [ actions.login, - ...(expectDecryptedTelemetry ? [actions.api.get('decryptedTelemetry')] : []), + ...(expectDecryptedTelemetry + ? [actions.api.get(ApiOperation.Read, 'decryptedTelemetry')] + : []), ...(expectGlobalSettings ? [actions.ui.get('globalSettings', 'show')] : []), ]); }); @@ -1508,10 +1525,12 @@ describe('features', () => { const actual = privileges.get(); expect(actual).toHaveProperty(`${group}.all`, [ actions.login, - ...(expectDecryptedTelemetry ? [actions.api.get('decryptedTelemetry')] : []), - ...(expectGetFeatures ? [actions.api.get('features')] : []), - ...(expectGetFeatures ? [actions.api.get('taskManager')] : []), - ...(expectGetFeatures ? [actions.api.get('manageSpaces')] : []), + ...(expectDecryptedTelemetry + ? [actions.api.get(ApiOperation.Read, 'decryptedTelemetry')] + : []), + ...(expectGetFeatures ? [actions.api.get(ApiOperation.Read, 'features')] : []), + ...(expectGetFeatures ? [actions.api.get(ApiOperation.Manage, 'taskManager')] : []), + ...(expectGetFeatures ? [actions.api.get(ApiOperation.Manage, 'spaces')] : []), ...(expectManageSpaces ? [ actions.space.manage, @@ -1526,7 +1545,9 @@ describe('features', () => { ]); expect(actual).toHaveProperty(`${group}.read`, [ actions.login, - ...(expectDecryptedTelemetry ? [actions.api.get('decryptedTelemetry')] : []), + ...(expectDecryptedTelemetry + ? [actions.api.get(ApiOperation.Read, 'decryptedTelemetry')] + : []), ...(expectGlobalSettings ? [actions.ui.get('globalSettings', 'show')] : []), ]); }); @@ -1578,10 +1599,12 @@ describe('features', () => { const actual = privileges.get(); expect(actual).toHaveProperty(`${group}.all`, [ actions.login, - ...(expectDecryptedTelemetry ? [actions.api.get('decryptedTelemetry')] : []), - ...(expectGetFeatures ? [actions.api.get('features')] : []), - ...(expectGetFeatures ? [actions.api.get('taskManager')] : []), - ...(expectGetFeatures ? [actions.api.get('manageSpaces')] : []), + ...(expectDecryptedTelemetry + ? [actions.api.get(ApiOperation.Read, 'decryptedTelemetry')] + : []), + ...(expectGetFeatures ? [actions.api.get(ApiOperation.Read, 'features')] : []), + ...(expectGetFeatures ? [actions.api.get(ApiOperation.Manage, 'taskManager')] : []), + ...(expectGetFeatures ? [actions.api.get(ApiOperation.Manage, 'spaces')] : []), ...(expectManageSpaces ? [ actions.space.manage, @@ -1596,7 +1619,9 @@ describe('features', () => { ]); expect(actual).toHaveProperty(`${group}.read`, [ actions.login, - ...(expectDecryptedTelemetry ? [actions.api.get('decryptedTelemetry')] : []), + ...(expectDecryptedTelemetry + ? [actions.api.get(ApiOperation.Read, 'decryptedTelemetry')] + : []), ...(expectGlobalSettings ? [actions.ui.get('globalSettings', 'show')] : []), ]); }); @@ -1677,10 +1702,12 @@ describe('features', () => { const actual = privileges.get(); expect(actual).toHaveProperty(`${group}.all`, [ actions.login, - ...(expectDecryptedTelemetry ? [actions.api.get('decryptedTelemetry')] : []), - ...(expectGetFeatures ? [actions.api.get('features')] : []), - ...(expectGetFeatures ? [actions.api.get('taskManager')] : []), - ...(expectGetFeatures ? [actions.api.get('manageSpaces')] : []), + ...(expectDecryptedTelemetry + ? [actions.api.get(ApiOperation.Read, 'decryptedTelemetry')] + : []), + ...(expectGetFeatures ? [actions.api.get(ApiOperation.Read, 'features')] : []), + ...(expectGetFeatures ? [actions.api.get(ApiOperation.Manage, 'taskManager')] : []), + ...(expectGetFeatures ? [actions.api.get(ApiOperation.Manage, 'spaces')] : []), ...(expectManageSpaces ? [ actions.space.manage, @@ -1695,7 +1722,9 @@ describe('features', () => { ]); expect(actual).toHaveProperty(`${group}.read`, [ actions.login, - ...(expectDecryptedTelemetry ? [actions.api.get('decryptedTelemetry')] : []), + ...(expectDecryptedTelemetry + ? [actions.api.get(ApiOperation.Read, 'decryptedTelemetry')] + : []), ...(expectGlobalSettings ? [actions.ui.get('globalSettings', 'show')] : []), ]); }); @@ -1945,10 +1974,10 @@ describe('subFeatures', () => { expect(actual).toHaveProperty('global.all', [ actions.login, - actions.api.get('decryptedTelemetry'), - actions.api.get('features'), - actions.api.get('taskManager'), - actions.api.get('manageSpaces'), + actions.api.get(ApiOperation.Read, 'decryptedTelemetry'), + actions.api.get(ApiOperation.Read, 'features'), + actions.api.get(ApiOperation.Manage, 'taskManager'), + actions.api.get(ApiOperation.Manage, 'spaces'), actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), @@ -1960,7 +1989,7 @@ describe('subFeatures', () => { ]); expect(actual).toHaveProperty('global.read', [ actions.login, - actions.api.get('decryptedTelemetry'), + actions.api.get(ApiOperation.Read, 'decryptedTelemetry'), actions.ui.get('globalSettings', 'show'), actions.ui.get('foo', 'foo'), ]); @@ -2104,10 +2133,10 @@ describe('subFeatures', () => { expect(actual).toHaveProperty('global.all', [ actions.login, - actions.api.get('decryptedTelemetry'), - actions.api.get('features'), - actions.api.get('taskManager'), - actions.api.get('manageSpaces'), + actions.api.get(ApiOperation.Read, 'decryptedTelemetry'), + actions.api.get(ApiOperation.Read, 'features'), + actions.api.get(ApiOperation.Manage, 'taskManager'), + actions.api.get(ApiOperation.Manage, 'spaces'), actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), @@ -2137,7 +2166,7 @@ describe('subFeatures', () => { ]); expect(actual).toHaveProperty('global.read', [ actions.login, - actions.api.get('decryptedTelemetry'), + actions.api.get(ApiOperation.Read, 'decryptedTelemetry'), actions.ui.get('globalSettings', 'show'), actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), @@ -2340,10 +2369,10 @@ describe('subFeatures', () => { expect(actual).toHaveProperty('global.all', [ actions.login, - actions.api.get('decryptedTelemetry'), - actions.api.get('features'), - actions.api.get('taskManager'), - actions.api.get('manageSpaces'), + actions.api.get(ApiOperation.Read, 'decryptedTelemetry'), + actions.api.get(ApiOperation.Read, 'features'), + actions.api.get(ApiOperation.Manage, 'taskManager'), + actions.api.get(ApiOperation.Manage, 'spaces'), actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), @@ -2354,7 +2383,7 @@ describe('subFeatures', () => { ]); expect(actual).toHaveProperty('global.read', [ actions.login, - actions.api.get('decryptedTelemetry'), + actions.api.get(ApiOperation.Read, 'decryptedTelemetry'), actions.ui.get('globalSettings', 'show'), ]); @@ -2479,10 +2508,10 @@ describe('subFeatures', () => { expect(actual).toHaveProperty('global.all', [ actions.login, - actions.api.get('decryptedTelemetry'), - actions.api.get('features'), - actions.api.get('taskManager'), - actions.api.get('manageSpaces'), + actions.api.get(ApiOperation.Read, 'decryptedTelemetry'), + actions.api.get(ApiOperation.Read, 'features'), + actions.api.get(ApiOperation.Manage, 'taskManager'), + actions.api.get(ApiOperation.Manage, 'spaces'), actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), @@ -2512,7 +2541,7 @@ describe('subFeatures', () => { ]); expect(actual).toHaveProperty('global.read', [ actions.login, - actions.api.get('decryptedTelemetry'), + actions.api.get(ApiOperation.Read, 'decryptedTelemetry'), actions.ui.get('globalSettings', 'show'), actions.ui.get('foo', 'foo'), ]); @@ -2658,10 +2687,10 @@ describe('subFeatures', () => { expect(actual).toHaveProperty('global.all', [ actions.login, - actions.api.get('decryptedTelemetry'), - actions.api.get('features'), - actions.api.get('taskManager'), - actions.api.get('manageSpaces'), + actions.api.get(ApiOperation.Read, 'decryptedTelemetry'), + actions.api.get(ApiOperation.Read, 'features'), + actions.api.get(ApiOperation.Manage, 'taskManager'), + actions.api.get(ApiOperation.Manage, 'spaces'), actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), @@ -2672,7 +2701,7 @@ describe('subFeatures', () => { ]); expect(actual).toHaveProperty('global.read', [ actions.login, - actions.api.get('decryptedTelemetry'), + actions.api.get(ApiOperation.Read, 'decryptedTelemetry'), actions.ui.get('globalSettings', 'show'), ]); @@ -2795,10 +2824,10 @@ describe('subFeatures', () => { expect(actual).toHaveProperty('global.all', [ actions.login, - actions.api.get('decryptedTelemetry'), - actions.api.get('features'), - actions.api.get('taskManager'), - actions.api.get('manageSpaces'), + actions.api.get(ApiOperation.Read, 'decryptedTelemetry'), + actions.api.get(ApiOperation.Read, 'features'), + actions.api.get(ApiOperation.Manage, 'taskManager'), + actions.api.get(ApiOperation.Manage, 'spaces'), actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), @@ -2828,7 +2857,7 @@ describe('subFeatures', () => { ]); expect(actual).toHaveProperty('global.read', [ actions.login, - actions.api.get('decryptedTelemetry'), + actions.api.get(ApiOperation.Read, 'decryptedTelemetry'), actions.ui.get('globalSettings', 'show'), actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), @@ -3010,10 +3039,10 @@ describe('subFeatures', () => { expect(actual).toHaveProperty('global.all', [ actions.login, - actions.api.get('decryptedTelemetry'), - actions.api.get('features'), - actions.api.get('taskManager'), - actions.api.get('manageSpaces'), + actions.api.get(ApiOperation.Read, 'decryptedTelemetry'), + actions.api.get(ApiOperation.Read, 'features'), + actions.api.get(ApiOperation.Manage, 'taskManager'), + actions.api.get(ApiOperation.Manage, 'spaces'), actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), @@ -3043,7 +3072,7 @@ describe('subFeatures', () => { ]); expect(actual).toHaveProperty('global.read', [ actions.login, - actions.api.get('decryptedTelemetry'), + actions.api.get(ApiOperation.Read, 'decryptedTelemetry'), actions.ui.get('globalSettings', 'show'), actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), @@ -3244,10 +3273,10 @@ describe('subFeatures', () => { expect(actual).toHaveProperty('global.all', [ actions.login, - actions.api.get('decryptedTelemetry'), - actions.api.get('features'), - actions.api.get('taskManager'), - actions.api.get('manageSpaces'), + actions.api.get(ApiOperation.Read, 'decryptedTelemetry'), + actions.api.get(ApiOperation.Read, 'features'), + actions.api.get(ApiOperation.Manage, 'taskManager'), + actions.api.get(ApiOperation.Manage, 'spaces'), actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), @@ -3277,7 +3306,7 @@ describe('subFeatures', () => { ]); expect(actual).toHaveProperty('global.read', [ actions.login, - actions.api.get('decryptedTelemetry'), + actions.api.get(ApiOperation.Read, 'decryptedTelemetry'), actions.ui.get('globalSettings', 'show'), actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), @@ -3514,10 +3543,10 @@ describe('subFeatures', () => { expect(actual).toHaveProperty('global.all', [ actions.login, - actions.api.get('decryptedTelemetry'), - actions.api.get('features'), - actions.api.get('taskManager'), - actions.api.get('manageSpaces'), + actions.api.get(ApiOperation.Read, 'decryptedTelemetry'), + actions.api.get(ApiOperation.Read, 'features'), + actions.api.get(ApiOperation.Manage, 'taskManager'), + actions.api.get(ApiOperation.Manage, 'spaces'), actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), @@ -3565,7 +3594,7 @@ describe('subFeatures', () => { ]); expect(actual).toHaveProperty('global.read', [ actions.login, - actions.api.get('decryptedTelemetry'), + actions.api.get(ApiOperation.Read, 'decryptedTelemetry'), actions.ui.get('globalSettings', 'show'), actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), diff --git a/x-pack/packages/security/authorization_core/src/privileges/privileges.ts b/x-pack/packages/security/authorization_core/src/privileges/privileges.ts index 7f388e80defd2..b81eaba5fa54d 100644 --- a/x-pack/packages/security/authorization_core/src/privileges/privileges.ts +++ b/x-pack/packages/security/authorization_core/src/privileges/privileges.ts @@ -17,6 +17,7 @@ import { isMinimalPrivilegeId, } from '@kbn/security-authorization-core-common'; import type { RawKibanaPrivileges, SecurityLicense } from '@kbn/security-plugin-types-common'; +import { ApiOperation } from '@kbn/security-plugin-types-server'; import { featurePrivilegeBuilderFactory } from './feature_privilege_builder'; import type { Actions } from '../actions'; @@ -210,10 +211,10 @@ export function privilegesFactory( global: { all: [ actions.login, - actions.api.get('decryptedTelemetry'), - actions.api.get('features'), - actions.api.get('taskManager'), - actions.api.get('manageSpaces'), + actions.api.get(ApiOperation.Read, 'decryptedTelemetry'), + actions.api.get(ApiOperation.Read, 'features'), + actions.api.get(ApiOperation.Manage, 'taskManager'), + actions.api.get(ApiOperation.Manage, 'spaces'), actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), @@ -225,7 +226,7 @@ export function privilegesFactory( ], read: [ actions.login, - actions.api.get('decryptedTelemetry'), + actions.api.get(ApiOperation.Read, 'decryptedTelemetry'), actions.ui.get('globalSettings', 'show'), ...readActions, ], diff --git a/x-pack/packages/security/plugin_types_server/index.ts b/x-pack/packages/security/plugin_types_server/index.ts index 21ab0eb2b39af..2b46fa0146a2a 100644 --- a/x-pack/packages/security/plugin_types_server/index.ts +++ b/x-pack/packages/security/plugin_types_server/index.ts @@ -88,3 +88,4 @@ export { getRestApiKeyWithKibanaPrivilegesSchema, } from './src/authentication'; export { getKibanaRoleSchema, elasticsearchRoleSchema, GLOBAL_RESOURCE } from './src/authorization'; +export { ApiOperation } from './src/authorization'; diff --git a/x-pack/packages/security/plugin_types_server/src/authorization/actions/api.ts b/x-pack/packages/security/plugin_types_server/src/authorization/actions/api.ts index 30a1328ce5639..01fa535a1a0d5 100644 --- a/x-pack/packages/security/plugin_types_server/src/authorization/actions/api.ts +++ b/x-pack/packages/security/plugin_types_server/src/authorization/actions/api.ts @@ -6,5 +6,19 @@ */ export interface ApiActions { - get(operation: string): string; + get(operation: ApiOperation, subject: string): string; + + /** + * @deprecated use `get(operation: ApiOperation, subject: string)` instead + */ + get(subject: string): string; + actionFromRouteTag(routeTag: string): string; +} + +export enum ApiOperation { + Read = 'read', + Create = 'create', + Update = 'update', + Delete = 'delete', + Manage = 'manage', } diff --git a/x-pack/packages/security/plugin_types_server/src/authorization/actions/index.ts b/x-pack/packages/security/plugin_types_server/src/authorization/actions/index.ts index 6b3993423015f..baed1cde4457e 100644 --- a/x-pack/packages/security/plugin_types_server/src/authorization/actions/index.ts +++ b/x-pack/packages/security/plugin_types_server/src/authorization/actions/index.ts @@ -8,6 +8,7 @@ export type { Actions } from './actions'; export type { AlertingActions } from './alerting'; export type { ApiActions } from './api'; +export { ApiOperation } from './api'; export type { AppActions } from './app'; export type { CasesActions } from './cases'; export type { SavedObjectActions } from './saved_object'; diff --git a/x-pack/packages/security/plugin_types_server/src/authorization/index.ts b/x-pack/packages/security/plugin_types_server/src/authorization/index.ts index baeeeddc1fa74..c48e797dc1d1b 100644 --- a/x-pack/packages/security/plugin_types_server/src/authorization/index.ts +++ b/x-pack/packages/security/plugin_types_server/src/authorization/index.ts @@ -15,6 +15,7 @@ export type { SpaceActions, UIActions, } from './actions'; +export { ApiOperation } from './actions'; export type { AuthorizationServiceSetup } from './authorization_service'; export type { CheckPrivilegesOptions, diff --git a/x-pack/packages/security/ui_components/src/constants.ts b/x-pack/packages/security/ui_components/src/constants.ts index a47a9bff9842d..d30c61bf02d6d 100644 --- a/x-pack/packages/security/ui_components/src/constants.ts +++ b/x-pack/packages/security/ui_components/src/constants.ts @@ -5,5 +5,5 @@ * 2.0. */ -export const NO_PRIVILEGE_VALUE: string = 'none'; +export const NO_PRIVILEGE_VALUE = 'none' as const; export const CUSTOM_PRIVILEGE_VALUE: string = 'custom'; diff --git a/x-pack/packages/security/ui_components/src/kibana_privilege_table/change_all_privileges.tsx b/x-pack/packages/security/ui_components/src/kibana_privilege_table/change_all_privileges.tsx index 4793f86a7a2a5..e475a5da7a106 100644 --- a/x-pack/packages/security/ui_components/src/kibana_privilege_table/change_all_privileges.tsx +++ b/x-pack/packages/security/ui_components/src/kibana_privilege_table/change_all_privileges.tsx @@ -15,9 +15,9 @@ import { EuiPopover, EuiText, } from '@elastic/eui'; -import _ from 'lodash'; import React, { Component } from 'react'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import type { KibanaPrivilege } from '@kbn/security-role-management-model'; @@ -38,6 +38,43 @@ export class ChangeAllPrivilegesControl extends Component { isPopoverOpen: false, }; + private getPrivilegeCopy = (privilege: string): { label?: string; icon?: string } => { + switch (privilege) { + case 'all': + return { + icon: 'documentEdit', + label: i18n.translate( + 'xpack.security.management.editRole.changeAllPrivileges.allSelectionLabel', + { + defaultMessage: 'Grant full access for all', + } + ), + }; + case 'read': + return { + icon: 'glasses', + label: i18n.translate( + 'xpack.security.management.editRole.changeAllPrivileges.readSelectionLabel', + { + defaultMessage: 'Grant read access for all', + } + ), + }; + case 'none': + return { + icon: 'trash', + label: i18n.translate( + 'xpack.security.management.editRole.changeAllPrivileges.noneSelectionLabel', + { + defaultMessage: 'Revoke access to all', + } + ), + }; + default: + return {}; + } + }; + public render() { const button = ( { ); const items = this.props.privileges.map((privilege) => { + const { icon, label } = this.getPrivilegeCopy(privilege.id); return ( { @@ -65,21 +104,24 @@ export class ChangeAllPrivilegesControl extends Component { }} disabled={this.props.disabled} > - {_.upperFirst(privilege.id)} + {label} ); }); items.push( { this.onSelectPrivilege(NO_PRIVILEGE_VALUE); }} disabled={this.props.disabled} + // @ts-expect-error leaving this here so that when https://github.com/elastic/eui/issues/8123 is fixed we remove this comment + css={({ euiTheme }) => ({ color: euiTheme.colors.danger })} > - {_.upperFirst(NO_PRIVILEGE_VALUE)} + {this.getPrivilegeCopy(NO_PRIVILEGE_VALUE).label} ); 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 2c858e7bb6ff6..2ed172a49ad8b 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 @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiAccordion, EuiIconTip } from '@elastic/eui'; +import { EuiAccordion, EuiIconTip, EuiThemeProvider } from '@elastic/eui'; import React from 'react'; import type { KibanaFeature, SubFeatureConfig } from '@kbn/features-plugin/public'; @@ -47,16 +47,18 @@ const setup = (config: TestConfig) => { const onChange = jest.fn(); const onChangeAll = jest.fn(); const wrapper = mountWithIntl( - + + + ); const displayedPrivileges = config.calculateDisplayedPrivileges diff --git a/x-pack/plugins/actions/kibana.jsonc b/x-pack/plugins/actions/kibana.jsonc index 78f66742c2a03..882c832245951 100644 --- a/x-pack/plugins/actions/kibana.jsonc +++ b/x-pack/plugins/actions/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/actions-plugin", - "owner": "@elastic/response-ops", + "owner": [ + "@elastic/response-ops" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "actions", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "actions" @@ -22,10 +26,11 @@ "spaces", "security", "monitoringCollection", - "serverless" + "serverless", + "cloud" ], "extraPublicDirs": [ "common" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/actions/server/actions_client/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client/actions_client.test.ts index 7f15dd6287d6b..f95210fd20dbf 100644 --- a/x-pack/plugins/actions/server/actions_client/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client/actions_client.test.ts @@ -57,6 +57,7 @@ import { OAuthParams } from '../routes/get_oauth_access_token'; import { eventLogClientMock } from '@kbn/event-log-plugin/server/event_log_client.mock'; import { GetGlobalExecutionKPIParams, GetGlobalExecutionLogParams } from '../../common'; import { estypes } from '@elastic/elasticsearch'; +import { DEFAULT_USAGE_API_URL } from '../config'; jest.mock('@kbn/core-saved-objects-utils-server', () => { const actual = jest.requireActual('@kbn/core-saved-objects-utils-server'); @@ -613,6 +614,9 @@ describe('create()', () => { microsoftGraphApiUrl: DEFAULT_MICROSOFT_GRAPH_API_URL, microsoftGraphApiScope: DEFAULT_MICROSOFT_GRAPH_API_SCOPE, microsoftExchangeUrl: DEFAULT_MICROSOFT_EXCHANGE_URL, + usage: { + url: DEFAULT_USAGE_API_URL, + }, }); const localActionTypeRegistryParams = { diff --git a/x-pack/plugins/actions/server/actions_config.test.ts b/x-pack/plugins/actions/server/actions_config.test.ts index a6966e0e85c40..2b5c4efc283b6 100644 --- a/x-pack/plugins/actions/server/actions_config.test.ts +++ b/x-pack/plugins/actions/server/actions_config.test.ts @@ -6,7 +6,7 @@ */ import { ByteSizeValue } from '@kbn/config-schema'; -import { ActionsConfig } from './config'; +import { ActionsConfig, DEFAULT_USAGE_API_URL } from './config'; import { DEFAULT_MICROSOFT_EXCHANGE_URL, DEFAULT_MICROSOFT_GRAPH_API_SCOPE, @@ -42,6 +42,9 @@ const defaultActionsConfig: ActionsConfig = { microsoftGraphApiUrl: DEFAULT_MICROSOFT_GRAPH_API_URL, microsoftGraphApiScope: DEFAULT_MICROSOFT_GRAPH_API_SCOPE, microsoftExchangeUrl: DEFAULT_MICROSOFT_EXCHANGE_URL, + usage: { + url: DEFAULT_USAGE_API_URL, + }, }; describe('ensureUriAllowed', () => { diff --git a/x-pack/plugins/actions/server/config.test.ts b/x-pack/plugins/actions/server/config.test.ts index 5adc9c18b07a7..4034fc5cb50b5 100644 --- a/x-pack/plugins/actions/server/config.test.ts +++ b/x-pack/plugins/actions/server/config.test.ts @@ -38,6 +38,9 @@ describe('config validation', () => { "proxyRejectUnauthorizedCertificates": true, "rejectUnauthorized": true, "responseTimeout": "PT1M", + "usage": Object { + "url": "https://usage-api.usage-api/api/v1/usage", + }, } `); }); @@ -85,6 +88,9 @@ describe('config validation', () => { "proxyRejectUnauthorizedCertificates": false, "rejectUnauthorized": false, "responseTimeout": "PT1M", + "usage": Object { + "url": "https://usage-api.usage-api/api/v1/usage", + }, } `); }); @@ -225,6 +231,9 @@ describe('config validation', () => { "proxyVerificationMode": "none", "verificationMode": "none", }, + "usage": Object { + "url": "https://usage-api.usage-api/api/v1/usage", + }, } `); }); diff --git a/x-pack/plugins/actions/server/config.ts b/x-pack/plugins/actions/server/config.ts index d806bde1fa227..f475c05424df4 100644 --- a/x-pack/plugins/actions/server/config.ts +++ b/x-pack/plugins/actions/server/config.ts @@ -72,6 +72,8 @@ const connectorTypeSchema = schema.object({ maxAttempts: schema.maybe(schema.number({ min: MIN_MAX_ATTEMPTS, max: MAX_MAX_ATTEMPTS })), }); +export const DEFAULT_USAGE_API_URL = 'https://usage-api.usage-api/api/v1/usage'; + // We leverage enabledActionTypes list by allowing the other plugins to overwrite it by using "setEnabledConnectorTypes" in the plugin setup. // The list can be overwritten only if it's not already been set in the config. const enabledConnectorTypesSchema = schema.arrayOf( @@ -145,15 +147,14 @@ export const configSchema = schema.object({ max: schema.maybe(schema.number({ min: MIN_QUEUED_MAX, defaultValue: DEFAULT_QUEUED_MAX })), }) ), - usage: schema.maybe( - schema.object({ - ca: schema.maybe( - schema.object({ - path: schema.string(), - }) - ), - }) - ), + usage: schema.object({ + url: schema.string({ defaultValue: DEFAULT_USAGE_API_URL }), + ca: schema.maybe( + schema.object({ + path: schema.string(), + }) + ), + }), }); export type ActionsConfig = TypeOf; diff --git a/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap b/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap index d778849347d18..fad093938de40 100644 --- a/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap +++ b/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap @@ -2251,6 +2251,78 @@ Object { ], "type": "string", }, + "getIncidentJson": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "getIncidentMethod": Object { + "flags": Object { + "default": "get", + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "allow": Array [ + "get", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + Object { + "schema": Object { + "allow": Array [ + "post", + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, "getIncidentResponseExternalTitleKey": Object { "flags": Object { "error": [Function], diff --git a/x-pack/plugins/actions/server/integration_tests/axios_utils_connection.test.ts b/x-pack/plugins/actions/server/integration_tests/axios_utils_connection.test.ts index 200656d339ac3..3a4101bb9f152 100644 --- a/x-pack/plugins/actions/server/integration_tests/axios_utils_connection.test.ts +++ b/x-pack/plugins/actions/server/integration_tests/axios_utils_connection.test.ts @@ -20,7 +20,7 @@ import { ByteSizeValue } from '@kbn/config-schema'; import { Logger } from '@kbn/core/server'; import { loggingSystemMock } from '@kbn/core/server/mocks'; import { createReadySignal } from '@kbn/event-log-plugin/server/lib/ready_signal'; -import { ActionsConfig } from '../config'; +import { ActionsConfig, DEFAULT_USAGE_API_URL } from '../config'; import { ActionsConfigurationUtilities, getActionsConfigurationUtilities } from '../actions_config'; import { resolveCustomHosts } from '../lib/custom_host_settings'; import { @@ -691,6 +691,9 @@ const BaseActionsConfig: ActionsConfig = { microsoftGraphApiUrl: DEFAULT_MICROSOFT_GRAPH_API_URL, microsoftGraphApiScope: DEFAULT_MICROSOFT_GRAPH_API_SCOPE, microsoftExchangeUrl: DEFAULT_MICROSOFT_EXCHANGE_URL, + usage: { + url: DEFAULT_USAGE_API_URL, + }, }; function getACUfromConfig(config: Partial = {}): ActionsConfigurationUtilities { diff --git a/x-pack/plugins/actions/server/integration_tests/axios_utils_proxy.test.ts b/x-pack/plugins/actions/server/integration_tests/axios_utils_proxy.test.ts index f29b2a9855186..1c1d411111253 100644 --- a/x-pack/plugins/actions/server/integration_tests/axios_utils_proxy.test.ts +++ b/x-pack/plugins/actions/server/integration_tests/axios_utils_proxy.test.ts @@ -20,7 +20,7 @@ import { ByteSizeValue } from '@kbn/config-schema'; import { Logger } from '@kbn/core/server'; import { loggingSystemMock } from '@kbn/core/server/mocks'; import { createReadySignal } from '@kbn/event-log-plugin/server/lib/ready_signal'; -import { ActionsConfig } from '../config'; +import { ActionsConfig, DEFAULT_USAGE_API_URL } from '../config'; import { ActionsConfigurationUtilities, getActionsConfigurationUtilities } from '../actions_config'; import { resolveCustomHosts } from '../lib/custom_host_settings'; import { @@ -597,6 +597,9 @@ const BaseActionsConfig: ActionsConfig = { microsoftGraphApiUrl: DEFAULT_MICROSOFT_GRAPH_API_URL, microsoftGraphApiScope: DEFAULT_MICROSOFT_GRAPH_API_SCOPE, microsoftExchangeUrl: DEFAULT_MICROSOFT_EXCHANGE_URL, + usage: { + url: DEFAULT_USAGE_API_URL, + }, }; function getACUfromConfig(config: Partial = {}): ActionsConfigurationUtilities { diff --git a/x-pack/plugins/actions/server/lib/axios_utils.test.ts b/x-pack/plugins/actions/server/lib/axios_utils.test.ts index bee09a90ed27b..b7bb7548b9052 100644 --- a/x-pack/plugins/actions/server/lib/axios_utils.test.ts +++ b/x-pack/plugins/actions/server/lib/axios_utils.test.ts @@ -577,4 +577,12 @@ describe('throwIfResponseIsNotValid', () => { }) ).not.toThrow(); }); + + test('it does NOT throw if HTTP status code is 204 even if the content type is not supported', () => { + expect(() => + throwIfResponseIsNotValid({ + res: { ...res, status: 204, headers: { ['content-type']: 'text/html' } }, + }) + ).not.toThrow(); + }); }); diff --git a/x-pack/plugins/actions/server/lib/axios_utils.ts b/x-pack/plugins/actions/server/lib/axios_utils.ts index 254ad1a36f6e2..78abebf48022f 100644 --- a/x-pack/plugins/actions/server/lib/axios_utils.ts +++ b/x-pack/plugins/actions/server/lib/axios_utils.ts @@ -137,6 +137,16 @@ export const throwIfResponseIsNotValid = ({ const requiredContentType = 'application/json'; const contentType = res.headers['content-type'] ?? 'undefined'; const data = res.data; + const statusCode = res.status; + + /** + * Some external services may return a 204 + * status code but with unsupported content type like text/html. + * To avoid throwing on valid requests we return. + */ + if (statusCode === 204) { + return; + } /** * Check that the content-type of the response is application/json. diff --git a/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts b/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts index 818d7fb9bcd0a..0a9d9c6df31e7 100644 --- a/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts +++ b/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts @@ -10,7 +10,7 @@ import { resolve as pathResolve, join as pathJoin } from 'path'; import { ByteSizeValue } from '@kbn/config-schema'; import moment from 'moment'; -import { ActionsConfig } from '../config'; +import { ActionsConfig, DEFAULT_USAGE_API_URL } from '../config'; import { Logger } from '@kbn/core/server'; import { loggingSystemMock } from '@kbn/core/server/mocks'; @@ -82,6 +82,9 @@ describe('custom_host_settings', () => { microsoftGraphApiUrl: DEFAULT_MICROSOFT_GRAPH_API_URL, microsoftGraphApiScope: DEFAULT_MICROSOFT_GRAPH_API_SCOPE, microsoftExchangeUrl: DEFAULT_MICROSOFT_EXCHANGE_URL, + usage: { + url: DEFAULT_USAGE_API_URL, + }, }; test('ensure it copies over the config parts that it does not touch', () => { diff --git a/x-pack/plugins/actions/server/plugin.test.ts b/x-pack/plugins/actions/server/plugin.test.ts index 89efb80867fd7..4ff87aa0459ef 100644 --- a/x-pack/plugins/actions/server/plugin.test.ts +++ b/x-pack/plugins/actions/server/plugin.test.ts @@ -30,6 +30,7 @@ import { DEFAULT_MICROSOFT_GRAPH_API_SCOPE, DEFAULT_MICROSOFT_GRAPH_API_URL, } from '../common'; +import { cloudMock } from '@kbn/cloud-plugin/server/mocks'; const executor: ExecutorType<{}, {}, {}, void> = async (options) => { return { status: 'ok', actionId: options.actionId }; @@ -59,6 +60,9 @@ function getConfig(overrides = {}) { microsoftGraphApiUrl: DEFAULT_MICROSOFT_GRAPH_API_URL, microsoftGraphApiScope: DEFAULT_MICROSOFT_GRAPH_API_SCOPE, microsoftExchangeUrl: DEFAULT_MICROSOFT_EXCHANGE_URL, + usage: { + url: 'ca.path', + }, ...overrides, }; } @@ -84,6 +88,9 @@ describe('Actions Plugin', () => { microsoftGraphApiUrl: DEFAULT_MICROSOFT_GRAPH_API_URL, microsoftGraphApiScope: DEFAULT_MICROSOFT_GRAPH_API_SCOPE, microsoftExchangeUrl: DEFAULT_MICROSOFT_EXCHANGE_URL, + usage: { + url: 'ca.path', + }, }); plugin = new ActionsPlugin(context); coreSetup = coreMock.createSetup(); @@ -95,6 +102,7 @@ describe('Actions Plugin', () => { eventLog: eventLogMock.createSetup(), usageCollection: usageCollectionPluginMock.createSetupContract(), features: featuresPluginMock.createSetup(), + cloud: cloudMock.createSetup(), }; coreSetup.getStartServices.mockResolvedValue([ coreMock.createStart(), @@ -347,6 +355,7 @@ describe('Actions Plugin', () => { eventLog: eventLogMock.createSetup(), usageCollection: usageCollectionPluginMock.createSetupContract(), features: featuresPluginMock.createSetup(), + cloud: cloudMock.createSetup(), }; } @@ -374,6 +383,7 @@ describe('Actions Plugin', () => { usageCollection: usageCollectionPluginMock.createSetupContract(), features: featuresPluginMock.createSetup(), serverless: serverlessPluginMock.createSetupContract(), + cloud: cloudMock.createSetup(), }; } @@ -585,6 +595,9 @@ describe('Actions Plugin', () => { microsoftGraphApiUrl: DEFAULT_MICROSOFT_GRAPH_API_URL, microsoftGraphApiScope: DEFAULT_MICROSOFT_GRAPH_API_SCOPE, microsoftExchangeUrl: DEFAULT_MICROSOFT_EXCHANGE_URL, + usage: { + url: 'ca.path', + }, }); plugin = new ActionsPlugin(context); coreSetup = coreMock.createSetup(); @@ -596,6 +609,7 @@ describe('Actions Plugin', () => { eventLog: eventLogMock.createSetup(), usageCollection: usageCollectionPluginMock.createSetupContract(), features: featuresPluginMock.createSetup(), + cloud: cloudMock.createSetup(), }; pluginsStart = { licensing: licensingMock.createStart(), @@ -680,6 +694,7 @@ describe('Actions Plugin', () => { eventLog: eventLogMock.createSetup(), usageCollection: usageCollectionPluginMock.createSetupContract(), features: featuresPluginMock.createSetup(), + cloud: cloudMock.createSetup(), }; pluginsStart = { licensing: licensingMock.createStart(), diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 00dc17c2f92d7..6731d2bd1f805 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -42,6 +42,7 @@ import { import { MonitoringCollectionSetup } from '@kbn/monitoring-collection-plugin/server'; import { ServerlessPluginSetup, ServerlessPluginStart } from '@kbn/serverless/server'; +import type { CloudSetup } from '@kbn/cloud-plugin/server'; import { ActionsConfig, AllowedHosts, EnabledConnectorTypes, getValidatedConfig } from './config'; import { resolveCustomHosts } from './lib/custom_host_settings'; import { events } from './lib/event_based_telemetry'; @@ -112,6 +113,7 @@ import type { IUnsecuredActionsClient } from './unsecured_actions_client/unsecur import { UnsecuredActionsClient } from './unsecured_actions_client/unsecured_actions_client'; import { createBulkUnsecuredExecutionEnqueuerFunction } from './create_unsecured_execute_function'; import { createSystemConnectors } from './create_system_actions'; +import { ConnectorUsageReportingTask } from './usage/connector_usage_reporting_task'; export interface PluginSetupContract { registerType< @@ -184,6 +186,7 @@ export interface ActionsPluginsSetup { spaces?: SpacesPluginSetup; monitoringCollection?: MonitoringCollectionSetup; serverless?: ServerlessPluginSetup; + cloud: CloudSetup; } export interface ActionsPluginsStart { @@ -218,6 +221,7 @@ export class ActionsPlugin implements Plugin {}); + return { isActionTypeEnabled: (id, options = { notifyUsage: false }) => { return this.actionTypeRegistry!.isActionTypeEnabled(id, options); diff --git a/x-pack/plugins/actions/server/routes/legacy/create.test.ts b/x-pack/plugins/actions/server/routes/legacy/create.test.ts index 05993e44746f9..57ac772785265 100644 --- a/x-pack/plugins/actions/server/routes/legacy/create.test.ts +++ b/x-pack/plugins/actions/server/routes/legacy/create.test.ts @@ -40,6 +40,16 @@ describe('createActionRoute', () => { const [config, handler] = router.post.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/actions/action"`); + expect(config.options?.deprecated).toEqual({ + documentationUrl: + 'https://www.elastic.co/docs/api/doc/kibana/v8/operation/operation-legacycreateconnector', + severity: 'warning', + reason: { + type: 'migrate', + newApiPath: `/api/actions/connector/{id?}`, + newApiMethod: 'POST', + }, + }); const createResult = { id: '1', diff --git a/x-pack/plugins/actions/server/routes/legacy/create.ts b/x-pack/plugins/actions/server/routes/legacy/create.ts index f667a9e003a77..53290141649d8 100644 --- a/x-pack/plugins/actions/server/routes/legacy/create.ts +++ b/x-pack/plugins/actions/server/routes/legacy/create.ts @@ -38,8 +38,16 @@ export const createActionRoute = ( access: 'public', summary: `Create a connector`, tags: ['oas-tag:connectors'], - // @ts-expect-error TODO(https://github.com/elastic/kibana/issues/196095): Replace {RouteDeprecationInfo} - deprecated: true, + deprecated: { + documentationUrl: + 'https://www.elastic.co/docs/api/doc/kibana/v8/operation/operation-legacycreateconnector', + severity: 'warning', + reason: { + type: 'migrate', + newApiPath: `${BASE_ACTION_API_PATH}/connector/{id?}`, + newApiMethod: 'POST', + }, + }, }, validate: { request: { diff --git a/x-pack/plugins/actions/server/routes/legacy/delete.test.ts b/x-pack/plugins/actions/server/routes/legacy/delete.test.ts index 2bfb5c7810e46..7af920f565db6 100644 --- a/x-pack/plugins/actions/server/routes/legacy/delete.test.ts +++ b/x-pack/plugins/actions/server/routes/legacy/delete.test.ts @@ -39,6 +39,16 @@ describe('deleteActionRoute', () => { const [config, handler] = router.delete.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/actions/action/{id}"`); + expect(config.options?.deprecated).toEqual({ + documentationUrl: + 'https://www.elastic.co/docs/api/doc/kibana/v8/operation/operation-legacydeleteconnector', + severity: 'warning', + reason: { + type: 'migrate', + newApiPath: `/api/actions/connector/{id}`, + newApiMethod: 'DELETE', + }, + }); const actionsClient = actionsClientMock.create(); actionsClient.delete.mockResolvedValueOnce({}); diff --git a/x-pack/plugins/actions/server/routes/legacy/delete.ts b/x-pack/plugins/actions/server/routes/legacy/delete.ts index c7e1e985cc6f0..4ab073050eefd 100644 --- a/x-pack/plugins/actions/server/routes/legacy/delete.ts +++ b/x-pack/plugins/actions/server/routes/legacy/delete.ts @@ -32,8 +32,16 @@ export const deleteActionRoute = ( summary: `Delete a connector`, description: 'WARNING: When you delete a connector, it cannot be recovered.', tags: ['oas-tag:connectors'], - // @ts-expect-error TODO(https://github.com/elastic/kibana/issues/196095): Replace {RouteDeprecationInfo} - deprecated: true, + deprecated: { + documentationUrl: + 'https://www.elastic.co/docs/api/doc/kibana/v8/operation/operation-legacydeleteconnector', + severity: 'warning', + reason: { + type: 'migrate', + newApiPath: `${BASE_ACTION_API_PATH}/connector/{id}`, + newApiMethod: 'DELETE', + }, + }, }, validate: { request: { diff --git a/x-pack/plugins/actions/server/routes/legacy/execute.test.ts b/x-pack/plugins/actions/server/routes/legacy/execute.test.ts index c989731407650..efdb1bdcc1f58 100644 --- a/x-pack/plugins/actions/server/routes/legacy/execute.test.ts +++ b/x-pack/plugins/actions/server/routes/legacy/execute.test.ts @@ -63,6 +63,16 @@ describe('executeActionRoute', () => { const [config, handler] = router.post.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/actions/action/{id}/_execute"`); + expect(config.options?.deprecated).toEqual({ + documentationUrl: + 'https://www.elastic.co/docs/api/doc/kibana/v8/operation/operation-legacyrunconnector', + severity: 'warning', + reason: { + type: 'migrate', + newApiPath: `/api/actions/connector/{id}/_execute`, + newApiMethod: 'POST', + }, + }); expect(await handler(context, req, res)).toEqual({ body: executeResult }); diff --git a/x-pack/plugins/actions/server/routes/legacy/execute.ts b/x-pack/plugins/actions/server/routes/legacy/execute.ts index 71b04262075d4..d5d8da2b69269 100644 --- a/x-pack/plugins/actions/server/routes/legacy/execute.ts +++ b/x-pack/plugins/actions/server/routes/legacy/execute.ts @@ -37,8 +37,16 @@ export const executeActionRoute = ( options: { access: 'public', summary: `Run a connector`, - // @ts-expect-error TODO(https://github.com/elastic/kibana/issues/196095): Replace {RouteDeprecationInfo} - deprecated: true, + deprecated: { + documentationUrl: + 'https://www.elastic.co/docs/api/doc/kibana/v8/operation/operation-legacyrunconnector', + severity: 'warning', + reason: { + type: 'migrate', + newApiPath: `${BASE_ACTION_API_PATH}/connector/{id}/_execute`, + newApiMethod: 'POST', + }, + }, tags: ['oas-tag:connectors'], }, validate: { diff --git a/x-pack/plugins/actions/server/routes/legacy/get.test.ts b/x-pack/plugins/actions/server/routes/legacy/get.test.ts index 732c964fb8284..229927218aeed 100644 --- a/x-pack/plugins/actions/server/routes/legacy/get.test.ts +++ b/x-pack/plugins/actions/server/routes/legacy/get.test.ts @@ -39,6 +39,16 @@ describe('getActionRoute', () => { const [config, handler] = router.get.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/actions/action/{id}"`); + expect(config.options?.deprecated).toEqual({ + documentationUrl: + 'https://www.elastic.co/docs/api/doc/kibana/v8/operation/operation-legacygetconnector', + severity: 'warning', + reason: { + type: 'migrate', + newApiPath: `/api/actions/connector/{id}`, + newApiMethod: 'GET', + }, + }); const getResult = { id: '1', diff --git a/x-pack/plugins/actions/server/routes/legacy/get.ts b/x-pack/plugins/actions/server/routes/legacy/get.ts index 571849ccaf478..844e68f5edf33 100644 --- a/x-pack/plugins/actions/server/routes/legacy/get.ts +++ b/x-pack/plugins/actions/server/routes/legacy/get.ts @@ -31,8 +31,16 @@ export const getActionRoute = ( options: { access: 'public', summary: `Get connector information`, - // @ts-expect-error TODO(https://github.com/elastic/kibana/issues/196095): Replace {RouteDeprecationInfo} - deprecated: true, + deprecated: { + documentationUrl: + 'https://www.elastic.co/docs/api/doc/kibana/v8/operation/operation-legacygetconnector', + severity: 'warning', + reason: { + type: 'migrate', + newApiPath: `${BASE_ACTION_API_PATH}/connector/{id}`, + newApiMethod: 'GET', + }, + }, tags: ['oas-tag:connectors'], }, validate: { diff --git a/x-pack/plugins/actions/server/routes/legacy/get_all.test.ts b/x-pack/plugins/actions/server/routes/legacy/get_all.test.ts index e8657e56259e1..21aa51daa1618 100644 --- a/x-pack/plugins/actions/server/routes/legacy/get_all.test.ts +++ b/x-pack/plugins/actions/server/routes/legacy/get_all.test.ts @@ -39,6 +39,16 @@ describe('getAllActionRoute', () => { const [config, handler] = router.get.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/actions"`); + expect(config.options?.deprecated).toEqual({ + documentationUrl: + 'https://www.elastic.co/docs/api/doc/kibana/v8/operation/operation-legacygetconnectors', + severity: 'warning', + reason: { + type: 'migrate', + newApiPath: `/api/actions/connectors`, + newApiMethod: 'GET', + }, + }); const actionsClient = actionsClientMock.create(); actionsClient.getAll.mockResolvedValueOnce([]); diff --git a/x-pack/plugins/actions/server/routes/legacy/get_all.ts b/x-pack/plugins/actions/server/routes/legacy/get_all.ts index f0a17acb96691..0ce41efa7d732 100644 --- a/x-pack/plugins/actions/server/routes/legacy/get_all.ts +++ b/x-pack/plugins/actions/server/routes/legacy/get_all.ts @@ -23,8 +23,16 @@ export const getAllActionRoute = ( options: { access: 'public', summary: `Get all connectors`, - // @ts-expect-error TODO(https://github.com/elastic/kibana/issues/196095): Replace {RouteDeprecationInfo} - deprecated: true, + deprecated: { + documentationUrl: + 'https://www.elastic.co/docs/api/doc/kibana/v8/operation/operation-legacygetconnectors', + severity: 'warning', + reason: { + type: 'migrate', + newApiPath: `${BASE_ACTION_API_PATH}/connectors`, + newApiMethod: 'GET', + }, + }, tags: ['oas-tag:connectors'], }, validate: {}, diff --git a/x-pack/plugins/actions/server/routes/legacy/list_action_types.test.ts b/x-pack/plugins/actions/server/routes/legacy/list_action_types.test.ts index ec57c4b9a99a9..8b2ba7346b050 100644 --- a/x-pack/plugins/actions/server/routes/legacy/list_action_types.test.ts +++ b/x-pack/plugins/actions/server/routes/legacy/list_action_types.test.ts @@ -40,6 +40,16 @@ describe('listActionTypesRoute', () => { const [config, handler] = router.get.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/actions/list_action_types"`); + expect(config.options?.deprecated).toEqual({ + documentationUrl: + 'https://www.elastic.co/docs/api/doc/kibana/v8/operation/operation-legacygetconnectortypes', + severity: 'warning', + reason: { + type: 'migrate', + newApiPath: `/api/actions/connector_types`, + newApiMethod: 'GET', + }, + }); const listTypes = [ { diff --git a/x-pack/plugins/actions/server/routes/legacy/list_action_types.ts b/x-pack/plugins/actions/server/routes/legacy/list_action_types.ts index cc3e9c23f240d..300069b85f13f 100644 --- a/x-pack/plugins/actions/server/routes/legacy/list_action_types.ts +++ b/x-pack/plugins/actions/server/routes/legacy/list_action_types.ts @@ -27,8 +27,16 @@ export const listActionTypesRoute = ( options: { access: 'public', summary: `Get connector types`, - // @ts-expect-error TODO(https://github.com/elastic/kibana/issues/196095): Replace {RouteDeprecationInfo} - deprecated: true, + deprecated: { + documentationUrl: + 'https://www.elastic.co/docs/api/doc/kibana/v8/operation/operation-legacygetconnectortypes', + severity: 'warning', + reason: { + type: 'migrate', + newApiPath: `${BASE_ACTION_API_PATH}/connector_types`, + newApiMethod: 'GET', + }, + }, tags: ['oas-tag:connectors'], }, validate: {}, diff --git a/x-pack/plugins/actions/server/routes/legacy/update.test.ts b/x-pack/plugins/actions/server/routes/legacy/update.test.ts index 493d1c873690e..cc1bc74bd516d 100644 --- a/x-pack/plugins/actions/server/routes/legacy/update.test.ts +++ b/x-pack/plugins/actions/server/routes/legacy/update.test.ts @@ -39,6 +39,16 @@ describe('updateActionRoute', () => { const [config, handler] = router.put.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/actions/action/{id}"`); + expect(config.options?.deprecated).toEqual({ + documentationUrl: + 'https://www.elastic.co/docs/api/doc/kibana/v8/operation/operation-legacyupdateconnector', + severity: 'warning', + reason: { + type: 'migrate', + newApiPath: `/api/actions/connector/{id}`, + newApiMethod: 'PUT', + }, + }); const updateResult = { id: '1', diff --git a/x-pack/plugins/actions/server/routes/legacy/update.ts b/x-pack/plugins/actions/server/routes/legacy/update.ts index 0bf1ec7ece55d..add1bdb298472 100644 --- a/x-pack/plugins/actions/server/routes/legacy/update.ts +++ b/x-pack/plugins/actions/server/routes/legacy/update.ts @@ -37,8 +37,16 @@ export const updateActionRoute = ( options: { access: 'public', summary: `Update a connector`, - // @ts-expect-error TODO(https://github.com/elastic/kibana/issues/196095): Replace {RouteDeprecationInfo} - deprecated: true, + deprecated: { + documentationUrl: + 'https://www.elastic.co/docs/api/doc/kibana/v8/operation/operation-legacyupdateconnector', + severity: 'warning', + reason: { + type: 'migrate', + newApiPath: `${BASE_ACTION_API_PATH}/connector/{id}`, + newApiMethod: 'PUT', + }, + }, tags: ['oas-tag:connectors'], }, validate: { diff --git a/x-pack/plugins/actions/server/usage/connector_usage_reporting_task.test.ts b/x-pack/plugins/actions/server/usage/connector_usage_reporting_task.test.ts new file mode 100644 index 0000000000000..77dec7f15e156 --- /dev/null +++ b/x-pack/plugins/actions/server/usage/connector_usage_reporting_task.test.ts @@ -0,0 +1,394 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import fs from 'fs'; +import axios from 'axios'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { coreMock } from '@kbn/core/server/mocks'; +import { + TaskManagerSetupContract, + TaskManagerStartContract, + TaskStatus, +} from '@kbn/task-manager-plugin/server'; +import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; +import { + CONNECTOR_USAGE_REPORTING_SOURCE_ID, + CONNECTOR_USAGE_REPORTING_TASK_ID, + CONNECTOR_USAGE_REPORTING_TASK_SCHEDULE, + CONNECTOR_USAGE_REPORTING_TASK_TYPE, + ConnectorUsageReportingTask, +} from './connector_usage_reporting_task'; +import type { CoreSetup, ElasticsearchClient } from '@kbn/core/server'; +import { ActionsPluginsStart } from '../plugin'; +import { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; + +jest.mock('axios'); +const mockedAxiosPost = jest.spyOn(axios, 'post'); + +const nowStr = '2024-01-01T12:00:00.000Z'; +const nowDate = new Date(nowStr); + +jest.useFakeTimers(); +jest.setSystemTime(nowDate.getTime()); +const readFileSpy = jest.spyOn(fs, 'readFileSync'); + +describe('ConnectorUsageReportingTask', () => { + const logger = loggingSystemMock.createLogger(); + const { createSetup } = coreMock; + const { createSetup: taskManagerSetupMock, createStart: taskManagerStartMock } = taskManagerMock; + let mockEsClient: jest.Mocked; + let mockCore: CoreSetup; + let mockTaskManagerSetup: jest.Mocked; + let mockTaskManagerStart: jest.Mocked; + + beforeEach(async () => { + mockTaskManagerSetup = taskManagerSetupMock(); + mockTaskManagerStart = taskManagerStartMock(); + mockCore = createSetup(); + mockEsClient = (await mockCore.getStartServices())[0].elasticsearch.client + .asInternalUser as jest.Mocked; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + const createTaskRunner = async ({ + lastReportedUsageDate, + projectId, + attempts = 0, + }: { + lastReportedUsageDate: Date; + projectId?: string; + attempts?: number; + }) => { + const timestamp = new Date(new Date().setMinutes(-15)); + const task = new ConnectorUsageReportingTask({ + eventLogIndex: 'test-index', + projectId, + logger, + core: mockCore, + taskManager: mockTaskManagerSetup, + config: { + url: 'usage-api', + ca: { + path: './ca.crt', + }, + }, + }); + + await task.start(mockTaskManagerStart); + + const createTaskRunnerFunction = + mockTaskManagerSetup.registerTaskDefinitions.mock.calls[0][0][ + CONNECTOR_USAGE_REPORTING_TASK_TYPE + ].createTaskRunner; + + return createTaskRunnerFunction({ + taskInstance: { + id: CONNECTOR_USAGE_REPORTING_TASK_ID, + runAt: timestamp, + attempts: 0, + ownerId: '', + status: TaskStatus.Running, + startedAt: timestamp, + scheduledAt: timestamp, + retryAt: null, + params: {}, + state: { + lastReportedUsageDate, + attempts, + }, + taskType: CONNECTOR_USAGE_REPORTING_TASK_TYPE, + }, + }); + }; + + it('registers the task', async () => { + readFileSpy.mockImplementationOnce(() => '---CA CERTIFICATE---'); + new ConnectorUsageReportingTask({ + eventLogIndex: 'test-index', + projectId: 'test-projecr', + logger, + core: createSetup(), + taskManager: mockTaskManagerSetup, + config: { + url: 'usage-api', + ca: { + path: './ca.crt', + }, + }, + }); + + expect(mockTaskManagerSetup.registerTaskDefinitions).toBeCalledTimes(1); + expect(mockTaskManagerSetup.registerTaskDefinitions).toHaveBeenCalledWith({ + [CONNECTOR_USAGE_REPORTING_TASK_TYPE]: { + title: 'Connector usage reporting task', + timeout: '1m', + createTaskRunner: expect.any(Function), + }, + }); + }); + + it('schedules the task', async () => { + readFileSpy.mockImplementationOnce(() => '---CA CERTIFICATE---'); + const core = createSetup(); + const taskManagerStart = taskManagerStartMock(); + + const task = new ConnectorUsageReportingTask({ + eventLogIndex: 'test-index', + projectId: 'test-projecr', + logger, + core, + taskManager: mockTaskManagerSetup, + config: { + url: 'usage-api', + ca: { + path: './ca.crt', + }, + }, + }); + + await task.start(taskManagerStart); + + expect(taskManagerStart.ensureScheduled).toBeCalledTimes(1); + expect(taskManagerStart.ensureScheduled).toHaveBeenCalledWith({ + id: CONNECTOR_USAGE_REPORTING_TASK_ID, + taskType: CONNECTOR_USAGE_REPORTING_TASK_TYPE, + schedule: { + ...CONNECTOR_USAGE_REPORTING_TASK_SCHEDULE, + }, + state: {}, + params: {}, + }); + }); + + it('logs error if task manager is not ready', async () => { + readFileSpy.mockImplementationOnce(() => '---CA CERTIFICATE---'); + const core = createSetup(); + const taskManagerStart = taskManagerStartMock(); + + const task = new ConnectorUsageReportingTask({ + eventLogIndex: 'test-index', + projectId: 'test-projecr', + logger, + core, + taskManager: mockTaskManagerSetup, + config: { + url: 'usage-api', + ca: { + path: './ca.crt', + }, + }, + }); + + await task.start(); + + expect(taskManagerStart.ensureScheduled).not.toBeCalled(); + expect(logger.error).toHaveBeenCalledWith( + `Missing required task manager service during start of ${CONNECTOR_USAGE_REPORTING_TASK_TYPE}` + ); + }); + + it('logs error if scheduling task fails', async () => { + readFileSpy.mockImplementationOnce(() => '---CA CERTIFICATE---'); + const core = createSetup(); + const taskManagerStart = taskManagerStartMock(); + taskManagerStart.ensureScheduled.mockRejectedValue(new Error('test')); + + const task = new ConnectorUsageReportingTask({ + eventLogIndex: 'test-index', + projectId: 'test-projecr', + logger, + core, + taskManager: mockTaskManagerSetup, + config: { + url: 'usage-api', + ca: { + path: './ca.crt', + }, + }, + }); + + await task.start(taskManagerStart); + + expect(logger.error).toHaveBeenCalledWith( + 'Error scheduling task actions:connector_usage_reporting, received test' + ); + }); + + it('returns the existing state and logs a warning when project id is missing', async () => { + const lastReportedUsageDateStr = '2024-01-01T00:00:00.000Z'; + const lastReportedUsageDate = new Date(lastReportedUsageDateStr); + + const taskRunner = await createTaskRunner({ lastReportedUsageDate }); + + const response = await taskRunner.run(); + + expect(logger.warn).toHaveBeenCalledWith( + 'Missing required project id while running actions:connector_usage_reporting' + ); + + expect(response).toEqual({ + state: { + attempts: 0, + lastReportedUsageDate, + }, + }); + }); + + it('returns the existing state and logs an error when the CA Certificate is missing', async () => { + const lastReportedUsageDateStr = '2024-01-01T00:00:00.000Z'; + const lastReportedUsageDate = new Date(lastReportedUsageDateStr); + readFileSpy.mockImplementationOnce((func) => { + throw new Error('Mock file read error.'); + }); + + const taskRunner = await createTaskRunner({ lastReportedUsageDate, projectId: 'test-id' }); + + const response = await taskRunner.run(); + + expect(logger.error).toHaveBeenCalledTimes(2); + + expect(logger.error).toHaveBeenNthCalledWith( + 1, + `CA Certificate for the project "test-id" couldn't be loaded, Error: Mock file read error.` + ); + + expect(logger.error).toHaveBeenNthCalledWith( + 2, + 'Missing required CA Certificate while running actions:connector_usage_reporting' + ); + + expect(response).toEqual({ + state: { + attempts: 0, + lastReportedUsageDate, + }, + }); + }); + + it('runs the task', async () => { + readFileSpy.mockImplementationOnce(() => '---CA CERTIFICATE---'); + mockEsClient.search.mockResolvedValueOnce({ + aggregations: { total_usage: 215 }, + } as SearchResponse); + + mockedAxiosPost.mockResolvedValueOnce(200); + + const lastReportedUsageDateStr = '2024-01-01T00:00:00.000Z'; + const lastReportedUsageDate = new Date(lastReportedUsageDateStr); + + const taskRunner = await createTaskRunner({ lastReportedUsageDate, projectId: 'test-project' }); + + const response = await taskRunner.run(); + + const report = [ + { + creation_timestamp: nowStr, + id: 'connector-request-body-bytes-test-project-2024-01-01T12:00:00.000Z', + source: { + id: CONNECTOR_USAGE_REPORTING_SOURCE_ID, + instance_group_id: 'test-project', + }, + usage: { + period_seconds: 43200, + quantity: 0, + type: 'connector_request_body_bytes', + }, + usage_timestamp: nowStr, + }, + ]; + + expect(mockedAxiosPost).toHaveBeenCalledWith('usage-api', report, { + headers: { 'Content-Type': 'application/json' }, + timeout: 30000, + httpsAgent: expect.any(Object), + }); + + expect(response).toEqual({ + state: { + attempts: 0, + lastReportedUsageDate: expect.any(Date), + }, + }); + }); + + it('re-runs the task when search for records fails', async () => { + readFileSpy.mockImplementationOnce(() => '---CA CERTIFICATE---'); + mockEsClient.search.mockRejectedValue(new Error('500')); + + mockedAxiosPost.mockResolvedValueOnce(200); + + const lastReportedUsageDate = new Date('2024-01-01T00:00:00.000Z'); + + const taskRunner = await createTaskRunner({ lastReportedUsageDate, projectId: 'test-project' }); + + const response = await taskRunner.run(); + + expect(response).toEqual({ + state: { + lastReportedUsageDate, + attempts: 0, + }, + runAt: nowDate, + }); + }); + + it('re-runs the task when it fails to push the usage record', async () => { + readFileSpy.mockImplementationOnce(() => '---CA CERTIFICATE---'); + mockEsClient.search.mockResolvedValueOnce({ + aggregations: { total_usage: 215 }, + } as SearchResponse); + + mockedAxiosPost.mockRejectedValueOnce(500); + + const lastReportedUsageDate = new Date('2024-01-01T00:00:00.000Z'); + + const taskRunner = await createTaskRunner({ lastReportedUsageDate, projectId: 'test-project' }); + + const response = await taskRunner.run(); + + expect(response).toEqual({ + state: { + lastReportedUsageDate, + attempts: 1, + }, + runAt: new Date(nowDate.getTime() + 60000), // After a min + }); + }); + + it('stops retrying after 5 attempts', async () => { + readFileSpy.mockImplementationOnce(() => '---CA CERTIFICATE---'); + mockEsClient.search.mockResolvedValueOnce({ + aggregations: { total_usage: 215 }, + } as SearchResponse); + + mockedAxiosPost.mockRejectedValueOnce(new Error('test-error')); + + const lastReportedUsageDate = new Date('2024-01-01T00:00:00.000Z'); + + const taskRunner = await createTaskRunner({ + lastReportedUsageDate, + projectId: 'test-project', + attempts: 4, + }); + + const response = await taskRunner.run(); + + expect(response).toEqual({ + state: { + lastReportedUsageDate, + attempts: 0, + }, + }); + + expect(logger.error).toHaveBeenCalledWith( + 'Usage data could not be pushed to usage-api. Stopped retrying after 5 attempts. Error:test-error' + ); + }); +}); diff --git a/x-pack/plugins/actions/server/usage/connector_usage_reporting_task.ts b/x-pack/plugins/actions/server/usage/connector_usage_reporting_task.ts new file mode 100644 index 0000000000000..ce44718749006 --- /dev/null +++ b/x-pack/plugins/actions/server/usage/connector_usage_reporting_task.ts @@ -0,0 +1,309 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import fs from 'fs'; +import { Logger, CoreSetup, type ElasticsearchClient } from '@kbn/core/server'; +import { + IntervalSchedule, + type ConcreteTaskInstance, + TaskManagerStartContract, + TaskManagerSetupContract, +} from '@kbn/task-manager-plugin/server'; +import { AggregationsSumAggregate } from '@elastic/elasticsearch/lib/api/types'; +import axios from 'axios'; +import https from 'https'; +import { ActionsConfig } from '../config'; +import { ConnectorUsageReport } from './types'; +import { ActionsPluginsStart } from '../plugin'; + +export const CONNECTOR_USAGE_REPORTING_TASK_SCHEDULE: IntervalSchedule = { interval: '1h' }; +export const CONNECTOR_USAGE_REPORTING_TASK_ID = 'connector_usage_reporting'; +export const CONNECTOR_USAGE_REPORTING_TASK_TYPE = `actions:${CONNECTOR_USAGE_REPORTING_TASK_ID}`; +export const CONNECTOR_USAGE_REPORTING_TASK_TIMEOUT = 30000; +export const CONNECTOR_USAGE_TYPE = `connector_request_body_bytes`; +export const CONNECTOR_USAGE_REPORTING_SOURCE_ID = `task-connector-usage-report`; +export const MAX_PUSH_ATTEMPTS = 5; + +export class ConnectorUsageReportingTask { + private readonly logger: Logger; + private readonly eventLogIndex: string; + private readonly projectId: string | undefined; + private readonly caCertificate: string | undefined; + private readonly usageApiUrl: string; + + constructor({ + logger, + eventLogIndex, + core, + taskManager, + projectId, + config, + }: { + logger: Logger; + eventLogIndex: string; + core: CoreSetup; + taskManager: TaskManagerSetupContract; + projectId: string | undefined; + config: ActionsConfig['usage']; + }) { + this.logger = logger; + this.projectId = projectId; + this.eventLogIndex = eventLogIndex; + this.usageApiUrl = config.url; + const caCertificatePath = config.ca?.path; + + if (caCertificatePath && caCertificatePath.length > 0) { + try { + this.caCertificate = fs.readFileSync(caCertificatePath, 'utf8'); + } catch (e) { + this.caCertificate = undefined; + this.logger.error( + `CA Certificate for the project "${projectId}" couldn't be loaded, Error: ${e.message}` + ); + } + } + + taskManager.registerTaskDefinitions({ + [CONNECTOR_USAGE_REPORTING_TASK_TYPE]: { + title: 'Connector usage reporting task', + timeout: '1m', + createTaskRunner: ({ taskInstance }: { taskInstance: ConcreteTaskInstance }) => { + return { + run: async () => this.runTask(taskInstance, core), + cancel: async () => {}, + }; + }, + }, + }); + } + + public start = async (taskManager?: TaskManagerStartContract) => { + if (!taskManager) { + this.logger.error( + `Missing required task manager service during start of ${CONNECTOR_USAGE_REPORTING_TASK_TYPE}` + ); + return; + } + + try { + await taskManager.ensureScheduled({ + id: CONNECTOR_USAGE_REPORTING_TASK_ID, + taskType: CONNECTOR_USAGE_REPORTING_TASK_TYPE, + schedule: { + ...CONNECTOR_USAGE_REPORTING_TASK_SCHEDULE, + }, + state: {}, + params: {}, + }); + } catch (e) { + this.logger.error( + `Error scheduling task ${CONNECTOR_USAGE_REPORTING_TASK_TYPE}, received ${e.message}` + ); + } + }; + + private runTask = async (taskInstance: ConcreteTaskInstance, core: CoreSetup) => { + const { state } = taskInstance; + + if (!this.projectId) { + this.logger.warn( + `Missing required project id while running ${CONNECTOR_USAGE_REPORTING_TASK_TYPE}` + ); + return { + state, + }; + } + + if (!this.caCertificate) { + this.logger.error( + `Missing required CA Certificate while running ${CONNECTOR_USAGE_REPORTING_TASK_TYPE}` + ); + return { + state, + }; + } + + const [{ elasticsearch }] = await core.getStartServices(); + const esClient = elasticsearch.client.asInternalUser; + + const now = new Date(); + const oneDayAgo = new Date(new Date().getTime() - 24 * 60 * 60 * 1000); + const lastReportedUsageDate: Date = !!state.lastReportedUsageDate + ? new Date(state.lastReportedUsageDate) + : oneDayAgo; + + let attempts: number = state.attempts || 0; + + const fromDate = lastReportedUsageDate; + const toDate = now; + + let totalUsage = 0; + try { + totalUsage = await this.getTotalUsage({ + esClient, + fromDate, + toDate, + }); + } catch (e) { + this.logger.error(`Usage data could not be fetched. It will be retried. Error:${e.message}`); + return { + state: { + lastReportedUsageDate, + attempts, + }, + runAt: now, + }; + } + + const record: ConnectorUsageReport = this.createUsageRecord({ + totalUsage, + fromDate, + toDate, + projectId: this.projectId, + }); + + this.logger.debug(`Record: ${JSON.stringify(record)}`); + + try { + attempts = attempts + 1; + await this.pushUsageRecord(record); + this.logger.info( + `Connector usage record has been successfully reported, ${record.creation_timestamp}, usage: ${record.usage.quantity}, period:${record.usage.period_seconds}` + ); + } catch (e) { + if (attempts < MAX_PUSH_ATTEMPTS) { + this.logger.error( + `Usage data could not be pushed to usage-api. It will be retried (${attempts}). Error:${e.message}` + ); + + return { + state: { + lastReportedUsageDate, + attempts, + }, + runAt: this.getDelayedRetryDate({ attempts, now }), + }; + } + this.logger.error( + `Usage data could not be pushed to usage-api. Stopped retrying after ${attempts} attempts. Error:${e.message}` + ); + return { + state: { + lastReportedUsageDate, + attempts: 0, + }, + }; + } + + return { + state: { lastReportedUsageDate: toDate, attempts: 0 }, + }; + }; + + private getTotalUsage = async ({ + esClient, + fromDate, + toDate, + }: { + esClient: ElasticsearchClient; + fromDate: Date; + toDate: Date; + }): Promise => { + const usageResult = await esClient.search({ + index: this.eventLogIndex, + sort: '@timestamp', + size: 0, + query: { + bool: { + filter: { + bool: { + must: [ + { + term: { 'event.action': 'execute' }, + }, + { + term: { 'event.provider': 'actions' }, + }, + { + exists: { + field: 'kibana.action.execution.usage.request_body_bytes', + }, + }, + { + range: { + '@timestamp': { + gt: fromDate, + lte: toDate, + }, + }, + }, + ], + }, + }, + }, + }, + aggs: { + total_usage: { sum: { field: 'kibana.action.execution.usage.request_body_bytes' } }, + }, + }); + + return (usageResult.aggregations?.total_usage as AggregationsSumAggregate)?.value ?? 0; + }; + + private createUsageRecord = ({ + totalUsage, + fromDate, + toDate, + projectId, + }: { + totalUsage: number; + fromDate: Date; + toDate: Date; + projectId: string; + }): ConnectorUsageReport => { + const period = Math.round((toDate.getTime() - fromDate.getTime()) / 1000); + const toStr = toDate.toISOString(); + const timestamp = new Date(toStr); + timestamp.setMinutes(0); + timestamp.setSeconds(0); + timestamp.setMilliseconds(0); + + return { + id: `connector-request-body-bytes-${projectId}-${timestamp.toISOString()}`, + usage_timestamp: toStr, + creation_timestamp: toStr, + usage: { + type: CONNECTOR_USAGE_TYPE, + period_seconds: period, + quantity: totalUsage, + }, + source: { + id: CONNECTOR_USAGE_REPORTING_SOURCE_ID, + instance_group_id: projectId, + }, + }; + }; + + private pushUsageRecord = async (record: ConnectorUsageReport) => { + return axios.post(this.usageApiUrl, [record], { + headers: { 'Content-Type': 'application/json' }, + timeout: CONNECTOR_USAGE_REPORTING_TASK_TIMEOUT, + httpsAgent: new https.Agent({ + ca: this.caCertificate, + }), + }); + }; + + private getDelayedRetryDate = ({ attempts, now }: { attempts: number; now: Date }) => { + const baseDelay = 60 * 1000; + const delayByAttempts = baseDelay * attempts; + + const delayedTime = now.getTime() + delayByAttempts; + + return new Date(delayedTime); + }; +} diff --git a/x-pack/plugins/actions/server/usage/types.ts b/x-pack/plugins/actions/server/usage/types.ts index 6bdfe316c76e2..d57de6f4dad33 100644 --- a/x-pack/plugins/actions/server/usage/types.ts +++ b/x-pack/plugins/actions/server/usage/types.ts @@ -65,3 +65,18 @@ export const byServiceProviderTypeSchema: MakeSchemaFrom['count_ac other: { type: 'long' }, ses: { type: 'long' }, }; + +export interface ConnectorUsageReport { + id: string; + usage_timestamp: string; + creation_timestamp: string; + usage: { + type: string; + period_seconds: number; + quantity: number | string | undefined; + }; + source: { + id: string | undefined; + instance_group_id: string; + }; +} diff --git a/x-pack/plugins/actions/tsconfig.json b/x-pack/plugins/actions/tsconfig.json index d060287d24143..384aba6a6b014 100644 --- a/x-pack/plugins/actions/tsconfig.json +++ b/x-pack/plugins/actions/tsconfig.json @@ -47,7 +47,8 @@ "@kbn/core-http-server", "@kbn/core-test-helpers-kbn-server", "@kbn/security-plugin-types-server", - "@kbn/core-application-common" + "@kbn/core-application-common", + "@kbn/cloud-plugin" ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/aiops/kibana.jsonc b/x-pack/plugins/aiops/kibana.jsonc index 65ecf52c76258..d327a131aec4d 100644 --- a/x-pack/plugins/aiops/kibana.jsonc +++ b/x-pack/plugins/aiops/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/aiops-plugin", - "owner": "@elastic/ml-ui", + "owner": [ + "@elastic/ml-ui" + ], + "group": "platform", + "visibility": "shared", "description": "AIOps plugin maintained by ML team.", "plugin": { "id": "aiops", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "charts", "data", @@ -31,7 +35,7 @@ "kibanaReact", "kibanaUtils", "embeddable", - "cases", + "cases" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/fields_config.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/fields_config.tsx index 38b5620465a0d..f967fffd45647 100644 --- a/x-pack/plugins/aiops/public/components/change_point_detection/fields_config.tsx +++ b/x-pack/plugins/aiops/public/components/change_point_detection/fields_config.tsx @@ -261,7 +261,7 @@ const FieldPanel: FC = ({ disabled: removeDisabled, }, ], - 'data=test-subj': 'aiopsChangePointDetectionContextMenuPanel', + 'data-test-subj': 'aiopsChangePointDetectionContextMenuPanel', }, { id: 'attachMainPanel', @@ -638,7 +638,7 @@ export const FieldsControls: FC> = ({ }) => { const { splitFieldsOptions, combinedQuery } = useChangePointDetectionContext(); const { dataView } = useDataSource(); - const { data, uiSettings, fieldFormats, charts, fieldStats } = useAiopsAppContext(); + const { data, uiSettings, fieldFormats, charts, fieldStats, theme } = useAiopsAppContext(); const timefilter = useTimefilter(); // required in order to trigger state updates useTimeRangeUpdates(); @@ -677,6 +677,7 @@ export const FieldsControls: FC> = ({ } : undefined } + theme={theme} > diff --git a/x-pack/plugins/aiops/public/components/document_count_content/document_count_content/document_count_content.tsx b/x-pack/plugins/aiops/public/components/document_count_content/document_count_content/document_count_content.tsx index 23caf21c39ee3..4dbf021e3b10b 100644 --- a/x-pack/plugins/aiops/public/components/document_count_content/document_count_content/document_count_content.tsx +++ b/x-pack/plugins/aiops/public/components/document_count_content/document_count_content/document_count_content.tsx @@ -15,6 +15,7 @@ import type { import { useAppSelector } from '@kbn/aiops-log-rate-analysis/state'; import { DocumentCountChartRedux } from '@kbn/aiops-components'; +import { AIOPS_EMBEDDABLE_ORIGIN } from '@kbn/aiops-common/constants'; import { useAiopsAppContext } from '../../../hooks/use_aiops_app_context'; @@ -37,17 +38,29 @@ export const DocumentCountContent: FC = ({ barHighlightColorOverride, ...docCountChartProps }) => { - const { data, uiSettings, fieldFormats, charts } = useAiopsAppContext(); + const { data, uiSettings, fieldFormats, charts, embeddingOrigin } = useAiopsAppContext(); const { documentStats } = useAppSelector((s) => s.logRateAnalysis); const { sampleProbability, totalCount, documentCountStats } = documentStats; if (documentCountStats === undefined) { - return totalCount !== undefined ? ( + return totalCount !== undefined && embeddingOrigin !== AIOPS_EMBEDDABLE_ORIGIN.DASHBOARD ? ( ) : null; } + if (embeddingOrigin === AIOPS_EMBEDDABLE_ORIGIN.DASHBOARD) { + return ( + + ); + } + return ( diff --git a/x-pack/plugins/aiops/public/components/log_categorization/categorize_field_actions.ts b/x-pack/plugins/aiops/public/components/log_categorization/categorize_field_actions.ts index 10c6311d065db..e5e6ede863558 100644 --- a/x-pack/plugins/aiops/public/components/log_categorization/categorize_field_actions.ts +++ b/x-pack/plugins/aiops/public/components/log_categorization/categorize_field_actions.ts @@ -10,7 +10,6 @@ import { createAction } from '@kbn/ui-actions-plugin/public'; import type { CoreStart } from '@kbn/core/public'; import { ACTION_CATEGORIZE_FIELD, type CategorizeFieldContext } from '@kbn/ml-ui-actions'; import type { AiopsPluginStartDeps } from '../../types'; -import { showCategorizeFlyout } from './show_flyout'; export const createCategorizeFieldAction = (coreStart: CoreStart, plugins: AiopsPluginStartDeps) => createAction({ @@ -25,6 +24,7 @@ export const createCategorizeFieldAction = (coreStart: CoreStart, plugins: Aiops }, execute: async (context: CategorizeFieldContext) => { const { field, dataView, originatingApp, additionalFilter } = context; + const { showCategorizeFlyout } = await import('./show_flyout'); showCategorizeFlyout(field, dataView, coreStart, plugins, originatingApp, additionalFilter); }, }); diff --git a/x-pack/plugins/aiops/public/components/log_categorization/index.ts b/x-pack/plugins/aiops/public/components/log_categorization/index.ts index ace01d4f03389..748a0f8486420 100644 --- a/x-pack/plugins/aiops/public/components/log_categorization/index.ts +++ b/x-pack/plugins/aiops/public/components/log_categorization/index.ts @@ -7,7 +7,6 @@ export type { LogCategorizationAppStateProps } from './log_categorization_app_state'; import { LogCategorizationAppState } from './log_categorization_app_state'; -export { createCategorizeFieldAction } from './categorize_field_actions'; // required for dynamic import using React.lazy() // eslint-disable-next-line import/no-default-export diff --git a/x-pack/plugins/aiops/public/components/log_categorization/show_flyout.tsx b/x-pack/plugins/aiops/public/components/log_categorization/show_flyout.tsx index a97f4c7f7fe79..6dae21c36222d 100644 --- a/x-pack/plugins/aiops/public/components/log_categorization/show_flyout.tsx +++ b/x-pack/plugins/aiops/public/components/log_categorization/show_flyout.tsx @@ -36,7 +36,7 @@ export async function showCategorizeFlyout( ): Promise { const { overlays, application, i18n } = coreStart; - return new Promise(async (resolve, reject) => { + return new Promise((resolve, reject) => { try { const onFlyoutClose = () => { flyoutSession.close(); diff --git a/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_content/log_rate_analysis_content.tsx b/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_content/log_rate_analysis_content.tsx index 7bf43037f45c0..2821b59353b52 100644 --- a/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_content/log_rate_analysis_content.tsx +++ b/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_content/log_rate_analysis_content.tsx @@ -7,7 +7,7 @@ import { isEqual } from 'lodash'; import React, { useCallback, useEffect, useMemo, useRef, type FC } from 'react'; -import { EuiButton, EuiEmptyPrompt, EuiHorizontalRule, EuiPanel } from '@elastic/eui'; +import { EuiButton, EuiEmptyPrompt, EuiSpacer, EuiPanel } from '@elastic/eui'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { BarStyleAccessor } from '@elastic/charts/dist/chart_types/xy_chart/utils/specs'; @@ -28,13 +28,16 @@ import { setInitialAnalysisStart, useAppDispatch, useAppSelector, + setGroupResults, } from '@kbn/aiops-log-rate-analysis/state'; +import { AIOPS_EMBEDDABLE_ORIGIN } from '@kbn/aiops-common/constants'; import { DocumentCountContent } from '../../document_count_content/document_count_content'; import { LogRateAnalysisResults, type LogRateAnalysisResultsData, } from '../log_rate_analysis_results'; +import { useAiopsAppContext } from '../../../hooks/use_aiops_app_context'; export const DEFAULT_SEARCH_QUERY: estypes.QueryDslQueryContainer = { match_all: {} }; const DEFAULT_SEARCH_BAR_QUERY: estypes.QueryDslQueryContainer = { @@ -69,9 +72,11 @@ export const LogRateAnalysisContent: FC = ({ onAnalysisCompleted, onWindowParametersChange, }) => { + const { embeddingOrigin } = useAiopsAppContext(); + const dispatch = useAppDispatch(); - const isRunning = useAppSelector((s) => s.logRateAnalysisStream.isRunning); + const isRunning = useAppSelector((s) => s.stream.isRunning); const significantItems = useAppSelector((s) => s.logRateAnalysisResults.significantItems); const significantItemsGroups = useAppSelector( (s) => s.logRateAnalysisResults.significantItemsGroups @@ -116,6 +121,7 @@ export const LogRateAnalysisContent: FC = ({ const { documentCountStats } = documentStats; function clearSelectionHandler() { + dispatch(setGroupResults(false)); dispatch(clearSelection()); dispatch(clearAllRowState()); } @@ -200,7 +206,11 @@ export const LogRateAnalysisContent: FC = ({ const changePointType = documentCountStats?.changePoint?.type; return ( - + {showDocumentCountContent && ( = ({ barStyleAccessor={barStyleAccessor} /> )} - + {showLogRateAnalysisResults && ( = ({ + timeRange, +}) => { + const { uiSettings } = useAiopsAppContext(); + const { dataView } = useDataSource(); + const { filters, query } = useFilterQueryUpdates(); + const appState = getDefaultLogRateAnalysisAppState({ + searchQuery: buildEsQuery( + dataView, + query ?? [], + filters ?? [], + uiSettings ? getEsQueryConfig(uiSettings) : undefined + ), + filters, + }); + const { searchQuery } = useSearch({ dataView, savedSearch: null }, appState, true); + + const timeRangeParsed = useMemo(() => { + if (timeRange) { + const min = datemath.parse(timeRange.from); + const max = datemath.parse(timeRange.to); + if (min && max) { + return { min, max }; + } + } + }, [timeRange]); + + return ( + <> + + + + ); +}; diff --git a/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_options.tsx b/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_options.tsx new file mode 100644 index 0000000000000..0aba8e2d763d4 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_options.tsx @@ -0,0 +1,190 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { FC } from 'react'; +import React from 'react'; + +import { EuiButtonGroup, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import { + clearAllRowState, + setGroupResults, + useAppDispatch, + useAppSelector, +} from '@kbn/aiops-log-rate-analysis/state'; +import { + commonColumns, + significantItemColumns, + setSkippedColumns, + type LogRateAnalysisResultsTableColumnName, +} from '@kbn/aiops-log-rate-analysis/state/log_rate_analysis_table_slice'; +import { setCurrentFieldFilterSkippedItems } from '@kbn/aiops-log-rate-analysis/state/log_rate_analysis_field_candidates_slice'; + +import { ItemFilterPopover as FieldFilterPopover } from './item_filter_popover'; +import { ItemFilterPopover as ColumnFilterPopover } from './item_filter_popover'; + +const groupResultsMessage = i18n.translate( + 'xpack.aiops.logRateAnalysis.resultsTable.groupedSwitchLabel.groupResults', + { + defaultMessage: 'Smart grouping', + } +); +const fieldFilterHelpText = i18n.translate('xpack.aiops.logRateAnalysis.page.fieldFilterHelpText', { + defaultMessage: + 'Deselect non-relevant fields to remove them from the analysis and click the Apply button to rerun the analysis. Use the search bar to filter the list, then select/deselect multiple fields with the actions below.', +}); +const columnsFilterHelpText = i18n.translate( + 'xpack.aiops.logRateAnalysis.page.columnsFilterHelpText', + { + defaultMessage: 'Configure visible columns.', + } +); +const disabledFieldFilterApplyButtonTooltipContent = i18n.translate( + 'xpack.aiops.analysis.fieldSelectorNotEnoughFieldsSelected', + { + defaultMessage: 'Grouping requires at least 2 fields to be selected.', + } +); +const disabledColumnFilterApplyButtonTooltipContent = i18n.translate( + 'xpack.aiops.analysis.columnSelectorNotEnoughColumnsSelected', + { + defaultMessage: 'At least one column must be selected.', + } +); +const columnSearchAriaLabel = i18n.translate('xpack.aiops.analysis.columnSelectorAriaLabel', { + defaultMessage: 'Filter columns', +}); +const columnsButton = i18n.translate('xpack.aiops.logRateAnalysis.page.columnsFilterButtonLabel', { + defaultMessage: 'Columns', +}); +const fieldsButton = i18n.translate('xpack.aiops.analysis.fieldsButtonLabel', { + defaultMessage: 'Fields', +}); +const groupResultsOffMessage = i18n.translate( + 'xpack.aiops.logRateAnalysis.resultsTable.groupedSwitchLabel.groupResultsOff', + { + defaultMessage: 'Off', + } +); +const groupResultsOnMessage = i18n.translate( + 'xpack.aiops.logRateAnalysis.resultsTable.groupedSwitchLabel.groupResultsOn', + { + defaultMessage: 'On', + } +); +const resultsGroupedOffId = 'aiopsLogRateAnalysisGroupingOff'; +const resultsGroupedOnId = 'aiopsLogRateAnalysisGroupingOn'; + +export interface LogRateAnalysisOptionsProps { + foundGroups: boolean; + growFirstItem?: boolean; +} + +export const LogRateAnalysisOptions: FC = ({ + foundGroups, + growFirstItem = false, +}) => { + const dispatch = useAppDispatch(); + + const { groupResults } = useAppSelector((s) => s.logRateAnalysis); + const { isRunning } = useAppSelector((s) => s.stream); + const fieldCandidates = useAppSelector((s) => s.logRateAnalysisFieldCandidates); + const { skippedColumns } = useAppSelector((s) => s.logRateAnalysisTable); + const { fieldFilterUniqueItems, initialFieldFilterSkippedItems } = fieldCandidates; + const fieldFilterButtonDisabled = + isRunning || fieldCandidates.isLoading || fieldFilterUniqueItems.length === 0; + const toggleIdSelected = groupResults ? resultsGroupedOnId : resultsGroupedOffId; + + const onGroupResultsToggle = (optionId: string) => { + dispatch(setGroupResults(optionId === resultsGroupedOnId)); + // When toggling the group switch, clear all row selections + dispatch(clearAllRowState()); + }; + + const onVisibleColumnsChange = (columns: LogRateAnalysisResultsTableColumnName[]) => { + dispatch(setSkippedColumns(columns)); + }; + + const onFieldsFilterChange = (skippedFieldsUpdate: string[]) => { + dispatch(setCurrentFieldFilterSkippedItems(skippedFieldsUpdate)); + }; + + // Disable the grouping switch toggle only if no groups were found, + // the toggle wasn't enabled already and no fields were selected to be skipped. + const disabledGroupResultsSwitch = !foundGroups && !groupResults; + + const toggleButtons = [ + { + id: resultsGroupedOffId, + label: groupResultsOffMessage, + 'data-test-subj': 'aiopsLogRateAnalysisGroupSwitchOff', + }, + { + id: resultsGroupedOnId, + label: groupResultsOnMessage, + 'data-test-subj': 'aiopsLogRateAnalysisGroupSwitchOn', + }, + ]; + + return ( + <> + + + + {groupResultsMessage} + + + + + + + + + + + void} + /> + + + ); +}; diff --git a/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_results.tsx b/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_results.tsx index b97019c4b4d29..1eb4f8fd0af0d 100644 --- a/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_results.tsx +++ b/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_results.tsx @@ -11,12 +11,13 @@ import { isEqual } from 'lodash'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { + EuiButtonIcon, EuiButton, - EuiButtonGroup, EuiCallOut, EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, + EuiToolTip, EuiSpacer, EuiText, } from '@elastic/eui'; @@ -26,6 +27,7 @@ import { ProgressControls } from '@kbn/aiops-components'; import { cancelStream, startStream } from '@kbn/ml-response-stream/client'; import { clearAllRowState, + setGroupResults, useAppDispatch, useAppSelector, } from '@kbn/aiops-log-rate-analysis/state'; @@ -37,8 +39,7 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import type { SignificantItem, SignificantItemGroup } from '@kbn/ml-agg-utils'; -import { useStorage } from '@kbn/ml-local-storage'; -import { AIOPS_ANALYSIS_RUN_ORIGIN } from '@kbn/aiops-common/constants'; +import { AIOPS_ANALYSIS_RUN_ORIGIN, AIOPS_EMBEDDABLE_ORIGIN } from '@kbn/aiops-common/constants'; import type { AiopsLogRateAnalysisSchema } from '@kbn/aiops-log-rate-analysis/api/schema'; import type { AiopsLogRateAnalysisSchemaSignificantItem } from '@kbn/aiops-log-rate-analysis/api/schema_v3'; import { @@ -50,15 +51,6 @@ import { fetchFieldCandidates } from '@kbn/aiops-log-rate-analysis/state/log_rat import { useAiopsAppContext } from '../../hooks/use_aiops_app_context'; import { useDataSource } from '../../hooks/use_data_source'; -import { - commonColumns, - significantItemColumns, -} from '../log_rate_analysis_results_table/use_columns'; -import { - AIOPS_LOG_RATE_ANALYSIS_RESULT_COLUMNS, - type AiOpsKey, - type AiOpsStorageMapped, -} from '../../types/storage'; import { getGroupTableItems, @@ -66,68 +58,15 @@ import { LogRateAnalysisResultsGroupsTable, } from '../log_rate_analysis_results_table'; -import { ItemFilterPopover as FieldFilterPopover } from './item_filter_popover'; -import { ItemFilterPopover as ColumnFilterPopover } from './item_filter_popover'; import { LogRateAnalysisInfoPopover } from './log_rate_analysis_info_popover'; -import type { ColumnNames } from '../log_rate_analysis_results_table'; +import { LogRateAnalysisOptions } from './log_rate_analysis_options'; -const groupResultsMessage = i18n.translate( - 'xpack.aiops.logRateAnalysis.resultsTable.groupedSwitchLabel.groupResults', - { - defaultMessage: 'Smart grouping', - } -); const groupResultsHelpMessage = i18n.translate( 'xpack.aiops.logRateAnalysis.resultsTable.groupedSwitchLabel.groupResultsHelpMessage', { defaultMessage: 'Items which are unique to a group are marked by an asterisk (*).', } ); -const groupResultsOffMessage = i18n.translate( - 'xpack.aiops.logRateAnalysis.resultsTable.groupedSwitchLabel.groupResultsOff', - { - defaultMessage: 'Off', - } -); -const groupResultsOnMessage = i18n.translate( - 'xpack.aiops.logRateAnalysis.resultsTable.groupedSwitchLabel.groupResultsOn', - { - defaultMessage: 'On', - } -); -const resultsGroupedOffId = 'aiopsLogRateAnalysisGroupingOff'; -const resultsGroupedOnId = 'aiopsLogRateAnalysisGroupingOn'; -const fieldFilterHelpText = i18n.translate('xpack.aiops.logRateAnalysis.page.fieldFilterHelpText', { - defaultMessage: - 'Deselect non-relevant fields to remove them from the analysis and click the Apply button to rerun the analysis. Use the search bar to filter the list, then select/deselect multiple fields with the actions below.', -}); -const columnsFilterHelpText = i18n.translate( - 'xpack.aiops.logRateAnalysis.page.columnsFilterHelpText', - { - defaultMessage: 'Configure visible columns.', - } -); -const disabledFieldFilterApplyButtonTooltipContent = i18n.translate( - 'xpack.aiops.analysis.fieldSelectorNotEnoughFieldsSelected', - { - defaultMessage: 'Grouping requires at least 2 fields to be selected.', - } -); -const disabledColumnFilterApplyButtonTooltipContent = i18n.translate( - 'xpack.aiops.analysis.columnSelectorNotEnoughColumnsSelected', - { - defaultMessage: 'At least one column must be selected.', - } -); -const columnSearchAriaLabel = i18n.translate('xpack.aiops.analysis.columnSelectorAriaLabel', { - defaultMessage: 'Filter columns', -}); -const columnsButton = i18n.translate('xpack.aiops.logRateAnalysis.page.columnsFilterButtonLabel', { - defaultMessage: 'Columns', -}); -const fieldsButton = i18n.translate('xpack.aiops.analysis.fieldsButtonLabel', { - defaultMessage: 'Fields', -}); /** * Interface for log rate analysis results data. @@ -173,72 +112,51 @@ export const LogRateAnalysisResults: FC = ({ documentStats: { sampleProbability }, stickyHistogram, isBrushCleared, + groupResults, } = useAppSelector((s) => s.logRateAnalysis); - const { isRunning, errors: streamErrors } = useAppSelector((s) => s.logRateAnalysisStream); + const { isRunning, errors: streamErrors } = useAppSelector((s) => s.stream); const data = useAppSelector((s) => s.logRateAnalysisResults); const fieldCandidates = useAppSelector((s) => s.logRateAnalysisFieldCandidates); + const { skippedColumns } = useAppSelector((s) => s.logRateAnalysisTable); const { currentAnalysisWindowParameters } = data; // Store the performance metric's start time using a ref // to be able to track it across rerenders. const analysisStartTime = useRef(window.performance.now()); const abortCtrl = useRef(new AbortController()); + const previousSearchQuery = useRef(searchQuery); - const [groupResults, setGroupResults] = useState(false); const [overrides, setOverrides] = useState( undefined ); const [shouldStart, setShouldStart] = useState(false); - const [toggleIdSelected, setToggleIdSelected] = useState(resultsGroupedOffId); - const [skippedColumns, setSkippedColumns] = useStorage< - AiOpsKey, - AiOpsStorageMapped - >(AIOPS_LOG_RATE_ANALYSIS_RESULT_COLUMNS, ['p-value', 'Baseline rate', 'Deviation rate']); - // null is used as the uninitialized state to identify the first load. - const [skippedFields, setSkippedFields] = useState(null); - - const onGroupResultsToggle = (optionId: string) => { - setToggleIdSelected(optionId); - setGroupResults(optionId === resultsGroupedOnId); - - // When toggling the group switch, clear all row selections - dispatch(clearAllRowState()); + const [embeddableOptionsVisible, setEmbeddableOptionsVisible] = useState(false); + + const onEmbeddableOptionsClickHandler = () => { + setEmbeddableOptionsVisible((s) => !s); }; - const { - fieldFilterUniqueItems, - fieldFilterSkippedItems, - keywordFieldCandidates, - textFieldCandidates, - } = fieldCandidates; - const fieldFilterButtonDisabled = - isRunning || fieldCandidates.isLoading || fieldFilterUniqueItems.length === 0; - - // Set skipped fields only on first load, otherwise we'd overwrite the user's selection. + const { currentFieldFilterSkippedItems, keywordFieldCandidates, textFieldCandidates } = + fieldCandidates; + useEffect(() => { - if (skippedFields === null && fieldFilterSkippedItems.length > 0) - setSkippedFields(fieldFilterSkippedItems); - }, [fieldFilterSkippedItems, skippedFields]); + if (currentFieldFilterSkippedItems === null) return; - const onFieldsFilterChange = (skippedFieldsUpdate: string[]) => { dispatch(resetResults()); - setSkippedFields(skippedFieldsUpdate); setOverrides({ loaded: 0, remainingKeywordFieldCandidates: keywordFieldCandidates.filter( - (d) => !skippedFieldsUpdate.includes(d) + (d) => !currentFieldFilterSkippedItems.includes(d) ), remainingTextFieldCandidates: textFieldCandidates.filter( - (d) => !skippedFieldsUpdate.includes(d) + (d) => !currentFieldFilterSkippedItems.includes(d) ), regroupOnly: false, }); startHandler(true, false); - }; - - const onVisibleColumnsChange = (columns: ColumnNames[]) => { - setSkippedColumns(columns); - }; + // custom check to trigger on currentFieldFilterSkippedItems change + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentFieldFilterSkippedItems]); function cancelHandler() { abortCtrl.current.abort(); @@ -298,18 +216,20 @@ export const LogRateAnalysisResults: FC = ({ dispatch(resetResults()); setOverrides({ remainingKeywordFieldCandidates: keywordFieldCandidates.filter( - (d) => skippedFields === null || !skippedFields.includes(d) + (d) => + currentFieldFilterSkippedItems === null || !currentFieldFilterSkippedItems.includes(d) ), remainingTextFieldCandidates: textFieldCandidates.filter( - (d) => skippedFields === null || !skippedFields.includes(d) + (d) => + currentFieldFilterSkippedItems === null || !currentFieldFilterSkippedItems.includes(d) ), }); } // Reset grouping to false and clear all row selections when restarting the analysis. if (resetGroupButton) { - setGroupResults(false); - setToggleIdSelected(resultsGroupedOffId); + dispatch(setGroupResults(false)); + // When toggling the group switch, clear all row selections dispatch(clearAllRowState()); } @@ -371,12 +291,13 @@ export const LogRateAnalysisResults: FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [shouldStart]); + // On mount, fetch field candidates first. Once they are populated, + // the actual analysis will be triggered. useEffect(() => { if (startParams) { dispatch(fetchFieldCandidates(startParams)); dispatch(setCurrentAnalysisType(analysisType)); dispatch(setCurrentAnalysisWindowParameters(chartWindowParameters)); - dispatch(startStream(startParams)); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -386,6 +307,19 @@ export const LogRateAnalysisResults: FC = ({ [data.significantItemsGroups] ); + const searchQueryUpdated = useMemo(() => { + let searchQueryChanged = false; + if ( + !isRunning && + previousSearchQuery.current !== undefined && + !isEqual(previousSearchQuery.current, searchQuery) + ) { + searchQueryChanged = true; + } + previousSearchQuery.current = searchQuery; + return searchQueryChanged; + }, [searchQuery, isRunning]); + const shouldRerunAnalysis = useMemo( () => currentAnalysisWindowParameters !== undefined && @@ -399,23 +333,6 @@ export const LogRateAnalysisResults: FC = ({ }, 0); const foundGroups = groupTableItems.length > 0 && groupItemCount > 0; - // Disable the grouping switch toggle only if no groups were found, - // the toggle wasn't enabled already and no fields were selected to be skipped. - const disabledGroupResultsSwitch = !foundGroups && !groupResults; - - const toggleButtons = [ - { - id: resultsGroupedOffId, - label: groupResultsOffMessage, - 'data-test-subj': 'aiopsLogRateAnalysisGroupSwitchOff', - }, - { - id: resultsGroupedOnId, - label: groupResultsOnMessage, - 'data-test-subj': 'aiopsLogRateAnalysisGroupSwitchOn', - }, - ]; - return (
= ({ onRefresh={() => startHandler(false)} onCancel={cancelHandler} onReset={onReset} - shouldRerunAnalysis={shouldRerunAnalysis} + shouldRerunAnalysis={shouldRerunAnalysis || searchQueryUpdated} analysisInfo={} > - - - - {groupResultsMessage} - + <> + {embeddingOrigin !== AIOPS_EMBEDDABLE_ORIGIN.DASHBOARD && ( + + )} + {embeddingOrigin === AIOPS_EMBEDDABLE_ORIGIN.DASHBOARD && ( - + + + - - - - - - - void} - /> - + )} + + {embeddingOrigin === AIOPS_EMBEDDABLE_ORIGIN.DASHBOARD && embeddableOptionsVisible && ( + <> + + + + + + )} + {errors.length > 0 ? ( <> diff --git a/x-pack/plugins/aiops/public/components/log_rate_analysis_results_table/index.ts b/x-pack/plugins/aiops/public/components/log_rate_analysis_results_table/index.ts index 6813e71704918..c5112723e2784 100644 --- a/x-pack/plugins/aiops/public/components/log_rate_analysis_results_table/index.ts +++ b/x-pack/plugins/aiops/public/components/log_rate_analysis_results_table/index.ts @@ -8,4 +8,3 @@ export { getGroupTableItems } from './get_group_table_items'; export { LogRateAnalysisResultsTable } from './log_rate_analysis_results_table'; export { LogRateAnalysisResultsGroupsTable } from './log_rate_analysis_results_table_groups'; -export type { ColumnNames } from './use_columns'; diff --git a/x-pack/plugins/aiops/public/components/log_rate_analysis_results_table/log_rate_analysis_results_table.tsx b/x-pack/plugins/aiops/public/components/log_rate_analysis_results_table/log_rate_analysis_results_table.tsx index 83be306e93f50..e9072c2929f14 100644 --- a/x-pack/plugins/aiops/public/components/log_rate_analysis_results_table/log_rate_analysis_results_table.tsx +++ b/x-pack/plugins/aiops/public/components/log_rate_analysis_results_table/log_rate_analysis_results_table.tsx @@ -83,13 +83,11 @@ export const LogRateAnalysisResultsTable: FC = }, [allSignificantItems, groupFilter]); const zeroDocsFallback = useAppSelector((s) => s.logRateAnalysisResults.zeroDocsFallback); - const pinnedGroup = useAppSelector((s) => s.logRateAnalysisTableRow.pinnedGroup); - const selectedGroup = useAppSelector((s) => s.logRateAnalysisTableRow.selectedGroup); - const pinnedSignificantItem = useAppSelector( - (s) => s.logRateAnalysisTableRow.pinnedSignificantItem - ); + const pinnedGroup = useAppSelector((s) => s.logRateAnalysisTable.pinnedGroup); + const selectedGroup = useAppSelector((s) => s.logRateAnalysisTable.selectedGroup); + const pinnedSignificantItem = useAppSelector((s) => s.logRateAnalysisTable.pinnedSignificantItem); const selectedSignificantItem = useAppSelector( - (s) => s.logRateAnalysisTableRow.selectedSignificantItem + (s) => s.logRateAnalysisTable.selectedSignificantItem ); const dispatch = useAppDispatch(); diff --git a/x-pack/plugins/aiops/public/components/log_rate_analysis_results_table/log_rate_analysis_results_table_groups.tsx b/x-pack/plugins/aiops/public/components/log_rate_analysis_results_table/log_rate_analysis_results_table_groups.tsx index d69a0fec7200f..6bd0a5e4ce213 100644 --- a/x-pack/plugins/aiops/public/components/log_rate_analysis_results_table/log_rate_analysis_results_table_groups.tsx +++ b/x-pack/plugins/aiops/public/components/log_rate_analysis_results_table/log_rate_analysis_results_table_groups.tsx @@ -91,8 +91,8 @@ export const LogRateAnalysisResultsGroupsTable: FC s.logRateAnalysisTableRow.pinnedGroup); - const selectedGroup = useAppSelector((s) => s.logRateAnalysisTableRow.selectedGroup); + const pinnedGroup = useAppSelector((s) => s.logRateAnalysisTable.pinnedGroup); + const selectedGroup = useAppSelector((s) => s.logRateAnalysisTable.selectedGroup); const dispatch = useAppDispatch(); const isMounted = useMountedState(); diff --git a/x-pack/plugins/aiops/public/components/log_rate_analysis_results_table/use_columns.tsx b/x-pack/plugins/aiops/public/components/log_rate_analysis_results_table/use_columns.tsx index f3b8195767101..c5b7a83e33641 100644 --- a/x-pack/plugins/aiops/public/components/log_rate_analysis_results_table/use_columns.tsx +++ b/x-pack/plugins/aiops/public/components/log_rate_analysis_results_table/use_columns.tsx @@ -21,6 +21,11 @@ import { type SignificantItem, SIGNIFICANT_ITEM_TYPE } from '@kbn/ml-agg-utils'; import { getCategoryQuery } from '@kbn/aiops-log-pattern-analysis/get_category_query'; import type { FieldStatsServices } from '@kbn/unified-field-list/src/components/field_stats'; import { useAppSelector } from '@kbn/aiops-log-rate-analysis/state'; +import { + commonColumns, + significantItemColumns, + type LogRateAnalysisResultsTableColumnName, +} from '@kbn/aiops-log-rate-analysis/state/log_rate_analysis_table_slice'; import { getBaselineAndDeviationRates, getLogRateChange, @@ -40,55 +45,6 @@ const TRUNCATE_TEXT_LINES = 3; const UNIQUE_COLUMN_WIDTH = '40px'; const NOT_AVAILABLE = '--'; -export const commonColumns = { - ['Log rate']: i18n.translate('xpack.aiops.logRateAnalysis.resultsTable.logRateColumnTitle', { - defaultMessage: 'Log rate', - }), - ['Doc count']: i18n.translate('xpack.aiops.logRateAnalysis.resultsTable.docCountColumnTitle', { - defaultMessage: 'Doc count', - }), - ['p-value']: i18n.translate('xpack.aiops.logRateAnalysis.resultsTable.pValueColumnTitle', { - defaultMessage: 'p-value', - }), - ['Impact']: i18n.translate('xpack.aiops.logRateAnalysis.resultsTable.impactColumnTitle', { - defaultMessage: 'Impact', - }), - ['Baseline rate']: i18n.translate( - 'xpack.aiops.logRateAnalysis.resultsTable.baselineRateColumnTitle', - { - defaultMessage: 'Baseline rate', - } - ), - ['Deviation rate']: i18n.translate( - 'xpack.aiops.logRateAnalysis.resultsTable.deviationRateColumnTitle', - { - defaultMessage: 'Deviation rate', - } - ), - ['Log rate change']: i18n.translate( - 'xpack.aiops.logRateAnalysis.resultsTable.logRateChangeColumnTitle', - { - defaultMessage: 'Log rate change', - } - ), - ['Actions']: i18n.translate('xpack.aiops.logRateAnalysis.resultsTable.actionsColumnTitle', { - defaultMessage: 'Actions', - }), -}; - -export const significantItemColumns = { - ['Field name']: i18n.translate('xpack.aiops.logRateAnalysis.resultsTable.fieldNameColumnTitle', { - defaultMessage: 'Field name', - }), - ['Field value']: i18n.translate( - 'xpack.aiops.logRateAnalysis.resultsTable.fieldValueColumnTitle', - { - defaultMessage: 'Field value', - } - ), - ...commonColumns, -} as const; - export const LOG_RATE_ANALYSIS_RESULTS_TABLE_TYPE = { GROUPS: 'groups', SIGNIFICANT_ITEMS: 'significantItems', @@ -96,8 +52,6 @@ export const LOG_RATE_ANALYSIS_RESULTS_TABLE_TYPE = { export type LogRateAnalysisResultsTableType = (typeof LOG_RATE_ANALYSIS_RESULTS_TABLE_TYPE)[keyof typeof LOG_RATE_ANALYSIS_RESULTS_TABLE_TYPE]; -export type ColumnNames = keyof typeof significantItemColumns | 'unique'; - const logRateHelpMessage = i18n.translate( 'xpack.aiops.logRateAnalysis.resultsTable.logRateColumnTooltip', { @@ -213,7 +167,7 @@ export const useColumns = ( const { earliest, latest } = useAppSelector((s) => s.logRateAnalysis); const timeRangeMs = { from: earliest ?? 0, to: latest ?? 0 }; - const loading = useAppSelector((s) => s.logRateAnalysisStream.isRunning); + const loading = useAppSelector((s) => s.stream.isRunning); const zeroDocsFallback = useAppSelector((s) => s.logRateAnalysisResults.zeroDocsFallback); const { documentStats: { documentCountStats }, @@ -271,7 +225,10 @@ export const useColumns = ( [currentAnalysisType, buckets] ); - const columnsMap: Record> = useMemo( + const columnsMap: Record< + LogRateAnalysisResultsTableColumnName, + EuiBasicTableColumn + > = useMemo( () => ({ ['Field name']: { 'data-test-subj': 'aiopsLogRateAnalysisResultsTableColumnFieldName', @@ -615,20 +572,21 @@ export const useColumns = ( ); const columns = useMemo(() => { - const columnNamesToReturn: Partial> = isGroupsTable - ? commonColumns - : significantItemColumns; + const columnNamesToReturn: Partial> = + isGroupsTable ? commonColumns : significantItemColumns; const columnsToReturn = []; for (const columnName in columnNamesToReturn) { if ( Object.hasOwn(columnNamesToReturn, columnName) === false || - skippedColumns.includes(columnNamesToReturn[columnName as ColumnNames] as string) || + skippedColumns.includes( + columnNamesToReturn[columnName as LogRateAnalysisResultsTableColumnName] as string + ) || ((columnName === 'p-value' || columnName === 'Impact') && zeroDocsFallback) ) continue; - columnsToReturn.push(columnsMap[columnName as ColumnNames]); + columnsToReturn.push(columnsMap[columnName as LogRateAnalysisResultsTableColumnName]); } if (isExpandedRow === true) { diff --git a/x-pack/plugins/aiops/public/components/log_rate_analysis_results_table/use_view_in_discover_action.tsx b/x-pack/plugins/aiops/public/components/log_rate_analysis_results_table/use_view_in_discover_action.tsx index ec4284d6452e5..765f1435a93ad 100644 --- a/x-pack/plugins/aiops/public/components/log_rate_analysis_results_table/use_view_in_discover_action.tsx +++ b/x-pack/plugins/aiops/public/components/log_rate_analysis_results_table/use_view_in_discover_action.tsx @@ -28,8 +28,8 @@ export const useViewInDiscoverAction = (dataViewId?: string): TableItemAction => const { application, share, data } = useAiopsAppContext(); const discoverLocator = useMemo( - () => share.url.locators.get('DISCOVER_APP_LOCATOR'), - [share.url.locators] + () => share?.url.locators.get('DISCOVER_APP_LOCATOR'), + [share?.url.locators] ); const discoverUrlError = useMemo(() => { diff --git a/x-pack/plugins/aiops/public/components/log_rate_analysis_results_table/use_view_in_log_pattern_analysis_action.tsx b/x-pack/plugins/aiops/public/components/log_rate_analysis_results_table/use_view_in_log_pattern_analysis_action.tsx index dbac6fbe8c9f3..ec1f6774b6b46 100644 --- a/x-pack/plugins/aiops/public/components/log_rate_analysis_results_table/use_view_in_log_pattern_analysis_action.tsx +++ b/x-pack/plugins/aiops/public/components/log_rate_analysis_results_table/use_view_in_log_pattern_analysis_action.tsx @@ -32,7 +32,7 @@ const viewInLogPatternAnalysisMessage = i18n.translate( export const useViewInLogPatternAnalysisAction = (dataViewId?: string): TableItemAction => { const { application, share, data } = useAiopsAppContext(); - const mlLocator = useMemo(() => share.url.locators.get('ML_APP_LOCATOR'), [share.url.locators]); + const mlLocator = useMemo(() => share?.url.locators.get('ML_APP_LOCATOR'), [share?.url.locators]); const generateLogPatternAnalysisUrl = async ( groupTableItem: GroupTableItem | SignificantItem diff --git a/x-pack/plugins/aiops/public/components/page_header/page_header.tsx b/x-pack/plugins/aiops/public/components/page_header/page_header.tsx index 9895fe082fcc1..a01e715e86272 100644 --- a/x-pack/plugins/aiops/public/components/page_header/page_header.tsx +++ b/x-pack/plugins/aiops/public/components/page_header/page_header.tsx @@ -9,7 +9,7 @@ import { css } from '@emotion/react'; import type { FC } from 'react'; import React, { useCallback, useMemo } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiPageHeader } from '@elastic/eui'; +import { EuiPageHeader } from '@elastic/eui'; import { useUrlState } from '@kbn/ml-url-state'; import { useStorage } from '@kbn/ml-local-storage'; @@ -71,29 +71,29 @@ export const PageHeader: FC = () => { return ( {dataView.getName()}
} + rightSideGroupProps={{ + gutterSize: 's', + 'data-test-subj': 'aiopsTimeRangeSelectorSection', + }} rightSideItems={[ - - {hasValidTimeField ? ( - - - - ) : null} - , + hasValidTimeField && ( + - , - ]} + ), + ].filter(Boolean)} /> ); }; diff --git a/x-pack/plugins/aiops/public/components/time_field_warning.tsx b/x-pack/plugins/aiops/public/components/time_field_warning.tsx new file mode 100644 index 0000000000000..beb64917af538 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/time_field_warning.tsx @@ -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 { EuiSpacer, EuiCallOut } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +export const TimeFieldWarning = () => { + return ( + <> + +

+ {i18n.translate('xpack.aiops.embeddableMenu.timeFieldWarning.title.description', { + defaultMessage: 'The analysis can only be run on data views with a time field.', + })} +

+
+ + + ); +}; diff --git a/x-pack/plugins/aiops/public/embeddables/change_point_chart/change_point_chart_initializer.tsx b/x-pack/plugins/aiops/public/embeddables/change_point_chart/change_point_chart_initializer.tsx index 08cf6ffd7dcb1..e69511fe45f92 100644 --- a/x-pack/plugins/aiops/public/embeddables/change_point_chart/change_point_chart_initializer.tsx +++ b/x-pack/plugins/aiops/public/embeddables/change_point_chart/change_point_chart_initializer.tsx @@ -18,16 +18,23 @@ import { EuiHorizontalRule, EuiTitle, } from '@elastic/eui'; +import { DatePickerContextProvider, type DatePickerDependencies } from '@kbn/ml-date-picker'; +import { UI_SETTINGS } from '@kbn/data-plugin/common'; +import type { FieldStatsServices } from '@kbn/unified-field-list/src/components/field_stats'; import { ES_FIELD_TYPES } from '@kbn/field-types'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { isPopulatedObject } from '@kbn/ml-is-populated-object'; +import { FieldStatsFlyoutProvider } from '@kbn/ml-field-stats-flyout'; +import { useTimefilter } from '@kbn/ml-date-picker'; import { pick } from 'lodash'; import type { FC } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import usePrevious from 'react-use/lib/usePrevious'; import { + ChangePointDetectionContextProvider, ChangePointDetectionControlsContextProvider, + useChangePointDetectionContext, useChangePointDetectionControlsContext, } from '../../components/change_point_detection/change_point_detection_context'; import { DEFAULT_AGG_FUNCTION } from '../../components/change_point_detection/constants'; @@ -38,7 +45,8 @@ import { PartitionsSelector } from '../../components/change_point_detection/part import { SplitFieldSelector } from '../../components/change_point_detection/split_field_selector'; import { ViewTypeSelector } from '../../components/change_point_detection/view_type_selector'; import { useAiopsAppContext } from '../../hooks/use_aiops_app_context'; -import { DataSourceContextProvider } from '../../hooks/use_data_source'; +import { useDataSource, DataSourceContextProvider } from '../../hooks/use_data_source'; +import { FilterQueryContextProvider } from '../../hooks/use_filters_query'; import { DEFAULT_SERIES } from './const'; import type { ChangePointEmbeddableRuntimeState } from './types'; @@ -53,11 +61,18 @@ export const ChangePointChartInitializer: FC = ({ onCreate, onCancel, }) => { + const appContextValue = useAiopsAppContext(); const { + data: { dataViews }, unifiedSearch: { ui: { IndexPatternSelect }, }, - } = useAiopsAppContext(); + } = appContextValue; + + const datePickerDeps: DatePickerDependencies = { + ...pick(appContextValue, ['data', 'http', 'notifications', 'theme', 'uiSettings', 'i18n']), + uiSettingsKeys: UI_SETTINGS, + }; const [dataViewId, setDataViewId] = useState(initialInput?.dataViewId ?? ''); const [viewType, setViewType] = useState(initialInput?.viewType ?? 'charts'); @@ -135,15 +150,21 @@ export const ChangePointChartInitializer: FC = ({ }} /> - - - - - + + + + + + + + + + + @@ -190,7 +211,13 @@ export const FormControls: FC<{ onChange: (update: FormControlsProps) => void; onValidationChange: (isValid: boolean) => void; }> = ({ formInput, onChange, onValidationChange }) => { + const { charts, data, fieldFormats, theme, uiSettings } = useAiopsAppContext(); + const { dataView } = useDataSource(); + const { combinedQuery } = useChangePointDetectionContext(); const { metricFieldOptions, splitFieldsOptions } = useChangePointDetectionControlsContext(); + const timefilter = useTimefilter(); + const timefilterActiveBounds = timefilter.getActiveBounds(); + const prevMetricFieldOptions = usePrevious(metricFieldOptions); const enableSearch = useMemo(() => { @@ -238,10 +265,33 @@ export const FormControls: FC<{ [formInput, onChange] ); + const fieldStatsServices: FieldStatsServices = useMemo(() => { + return { + uiSettings, + dataViews: data.dataViews, + data, + fieldFormats, + charts, + }; + }, [uiSettings, data, fieldFormats, charts]); + if (!isPopulatedObject(formInput)) return null; return ( - <> + updateCallback({ maxSeriesToPlot: v })} onValidationChange={(result) => onValidationChange(result === null)} /> - + ); }; diff --git a/x-pack/plugins/aiops/public/embeddables/change_point_chart/embeddable_change_point_chart_factory.tsx b/x-pack/plugins/aiops/public/embeddables/change_point_chart/embeddable_change_point_chart_factory.tsx index 7cf39eb1cf4ae..5f7ff6ff67f76 100644 --- a/x-pack/plugins/aiops/public/embeddables/change_point_chart/embeddable_change_point_chart_factory.tsx +++ b/x-pack/plugins/aiops/public/embeddables/change_point_chart/embeddable_change_point_chart_factory.tsx @@ -11,18 +11,10 @@ import { } from '@kbn/aiops-change-point-detection/constants'; import type { Reference } from '@kbn/content-management-utils'; import type { StartServicesAccessor } from '@kbn/core-lifecycle-browser'; -import { type DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/common'; import { DATA_VIEW_SAVED_OBJECT_TYPE } from '@kbn/data-views-plugin/common'; import type { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public'; import { i18n } from '@kbn/i18n'; -import { - apiHasExecutionContext, - fetch$, - initializeTimeRange, - initializeTitles, - useBatchedPublishingSubjects, -} from '@kbn/presentation-publishing'; import fastIsEqual from 'fast-deep-equal'; import { cloneDeep } from 'lodash'; @@ -38,32 +30,8 @@ import type { ChangePointEmbeddableState, } from './types'; -export interface EmbeddableChangePointChartStartServices { - data: DataPublicPluginStart; -} - export type EmbeddableChangePointChartType = typeof EMBEDDABLE_CHANGE_POINT_CHART_TYPE; -export const getDependencies = async ( - getStartServices: StartServicesAccessor -) => { - const [ - { http, uiSettings, notifications, ...startServices }, - { lens, data, usageCollection, fieldFormats }, - ] = await getStartServices(); - - return { - http, - uiSettings, - data, - notifications, - lens, - usageCollection, - fieldFormats, - ...startServices, - }; -}; - export const getChangePointChartEmbeddableFactory = ( getStartServices: StartServicesAccessor ) => { @@ -86,21 +54,15 @@ export const getChangePointChartEmbeddableFactory = ( return serializedState; }, buildEmbeddable: async (state, buildApi, uuid, parentApi) => { - const [coreStart, pluginStart] = await getStartServices(); - - const { http, uiSettings, notifications, ...startServices } = coreStart; - const { lens, data, usageCollection, fieldFormats } = pluginStart; + const { + apiHasExecutionContext, + fetch$, + initializeTimeRange, + initializeTitles, + useBatchedPublishingSubjects, + } = await import('@kbn/presentation-publishing'); - const deps = { - http, - uiSettings, - data, - notifications, - lens, - usageCollection, - fieldFormats, - ...startServices, - }; + const [coreStart, pluginStart] = await getStartServices(); const { api: timeRangeApi, @@ -120,7 +82,7 @@ export const getChangePointChartEmbeddableFactory = ( const blockingError = new BehaviorSubject(undefined); const dataViews$ = new BehaviorSubject([ - await deps.data.dataViews.get(state.dataViewId), + await pluginStart.data.dataViews.get(state.dataViewId), ]); const api = buildApi( diff --git a/x-pack/plugins/aiops/public/embeddables/change_point_chart/types.ts b/x-pack/plugins/aiops/public/embeddables/change_point_chart/types.ts index 4a39020a299c9..f86270bdaf19d 100644 --- a/x-pack/plugins/aiops/public/embeddables/change_point_chart/types.ts +++ b/x-pack/plugins/aiops/public/embeddables/change_point_chart/types.ts @@ -15,14 +15,6 @@ import type { SerializedTimeRange, SerializedTitles, } from '@kbn/presentation-publishing'; -import type { FC } from 'react'; -import type { SelectedChangePoint } from '../../components/change_point_detection/change_point_detection_context'; - -export type ViewComponent = FC<{ - changePoints: SelectedChangePoint[]; - interval: string; - onRenderComplete?: () => void; -}>; export interface ChangePointComponentApi { viewType: PublishingSubject; diff --git a/x-pack/plugins/aiops/public/embeddables/index.ts b/x-pack/plugins/aiops/public/embeddables/index.ts index b7d9ad25951fb..dae1f0eb3eeec 100644 --- a/x-pack/plugins/aiops/public/embeddables/index.ts +++ b/x-pack/plugins/aiops/public/embeddables/index.ts @@ -9,6 +9,7 @@ import type { CoreSetup } from '@kbn/core-lifecycle-browser'; import type { EmbeddableSetup } from '@kbn/embeddable-plugin/public'; import { EMBEDDABLE_CHANGE_POINT_CHART_TYPE } from '@kbn/aiops-change-point-detection/constants'; import { EMBEDDABLE_PATTERN_ANALYSIS_TYPE } from '@kbn/aiops-log-pattern-analysis/constants'; +import { EMBEDDABLE_LOG_RATE_ANALYSIS_TYPE } from '@kbn/aiops-log-rate-analysis/constants'; import type { AiopsPluginStart, AiopsPluginStartDeps } from '../types'; export const registerEmbeddables = ( @@ -23,4 +24,8 @@ export const registerEmbeddables = ( const { getPatternAnalysisEmbeddableFactory } = await import('./pattern_analysis'); return getPatternAnalysisEmbeddableFactory(core.getStartServices); }); + embeddable.registerReactEmbeddableFactory(EMBEDDABLE_LOG_RATE_ANALYSIS_TYPE, async () => { + const { getLogRateAnalysisEmbeddableFactory } = await import('./log_rate_analysis'); + return getLogRateAnalysisEmbeddableFactory(core.getStartServices); + }); }; diff --git a/x-pack/plugins/aiops/public/embeddables/log_rate_analysis/embeddable_log_rate_analysis_factory.tsx b/x-pack/plugins/aiops/public/embeddables/log_rate_analysis/embeddable_log_rate_analysis_factory.tsx new file mode 100644 index 0000000000000..592ec32cef120 --- /dev/null +++ b/x-pack/plugins/aiops/public/embeddables/log_rate_analysis/embeddable_log_rate_analysis_factory.tsx @@ -0,0 +1,211 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EMBEDDABLE_LOG_RATE_ANALYSIS_TYPE, + LOG_RATE_ANALYSIS_DATA_VIEW_REF_NAME, +} from '@kbn/aiops-log-rate-analysis/constants'; +import type { Reference } from '@kbn/content-management-utils'; +import type { StartServicesAccessor } from '@kbn/core-lifecycle-browser'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import { DATA_VIEW_SAVED_OBJECT_TYPE } from '@kbn/data-views-plugin/common'; +import type { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public'; +import { i18n } from '@kbn/i18n'; +import { + apiHasExecutionContext, + fetch$, + initializeTimeRange, + initializeTitles, + useBatchedPublishingSubjects, +} from '@kbn/presentation-publishing'; + +import fastIsEqual from 'fast-deep-equal'; +import { cloneDeep } from 'lodash'; +import React, { useMemo } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { BehaviorSubject, distinctUntilChanged, map, skipWhile } from 'rxjs'; +import { getLogRateAnalysisEmbeddableWrapperComponent } from '../../shared_components'; +import type { AiopsPluginStart, AiopsPluginStartDeps } from '../../types'; +import { initializeLogRateAnalysisControls } from './initialize_log_rate_analysis_analysis_controls'; +import type { + LogRateAnalysisEmbeddableApi, + LogRateAnalysisEmbeddableRuntimeState, + LogRateAnalysisEmbeddableState, +} from './types'; + +export const getLogRateAnalysisEmbeddableFactory = ( + getStartServices: StartServicesAccessor +) => { + const factory: ReactEmbeddableFactory< + LogRateAnalysisEmbeddableState, + LogRateAnalysisEmbeddableRuntimeState, + LogRateAnalysisEmbeddableApi + > = { + type: EMBEDDABLE_LOG_RATE_ANALYSIS_TYPE, + deserializeState: (state) => { + const serializedState = cloneDeep(state.rawState); + // inject the reference + const dataViewIdRef = state.references?.find( + (ref) => ref.name === LOG_RATE_ANALYSIS_DATA_VIEW_REF_NAME + ); + // if the serializedState already contains a dataViewId, we don't want to overwrite it. (Unsaved state can cause this) + if (dataViewIdRef && serializedState && !serializedState.dataViewId) { + serializedState.dataViewId = dataViewIdRef?.id; + } + return serializedState; + }, + buildEmbeddable: async (state, buildApi, uuid, parentApi) => { + const [coreStart, pluginStart] = await getStartServices(); + + const { + api: timeRangeApi, + comparators: timeRangeComparators, + serialize: serializeTimeRange, + } = initializeTimeRange(state); + + const { titlesApi, titleComparators, serializeTitles } = initializeTitles(state); + + const { + logRateAnalysisControlsApi, + serializeLogRateAnalysisChartState, + logRateAnalysisControlsComparators, + } = initializeLogRateAnalysisControls(state); + + const dataLoading = new BehaviorSubject(true); + const blockingError = new BehaviorSubject(undefined); + + const dataViews$ = new BehaviorSubject([ + await pluginStart.data.dataViews.get( + state.dataViewId ?? (await pluginStart.data.dataViews.getDefaultId()) + ), + ]); + + const api = buildApi( + { + ...timeRangeApi, + ...titlesApi, + ...logRateAnalysisControlsApi, + getTypeDisplayName: () => + i18n.translate('xpack.aiops.logRateAnalysis.typeDisplayName', { + defaultMessage: 'log rate analysis', + }), + isEditingEnabled: () => true, + onEdit: async () => { + try { + const { resolveEmbeddableLogRateAnalysisUserInput } = await import( + './resolve_log_rate_analysis_config_input' + ); + + const result = await resolveEmbeddableLogRateAnalysisUserInput( + coreStart, + pluginStart, + parentApi, + uuid, + false, + logRateAnalysisControlsApi, + undefined, + serializeLogRateAnalysisChartState() + ); + + logRateAnalysisControlsApi.updateUserInput(result); + } catch (e) { + return Promise.reject(); + } + }, + dataLoading, + blockingError, + dataViews: dataViews$, + serializeState: () => { + const dataViewId = logRateAnalysisControlsApi.dataViewId.getValue(); + const references: Reference[] = dataViewId + ? [ + { + type: DATA_VIEW_SAVED_OBJECT_TYPE, + name: LOG_RATE_ANALYSIS_DATA_VIEW_REF_NAME, + id: dataViewId, + }, + ] + : []; + return { + rawState: { + timeRange: undefined, + ...serializeTitles(), + ...serializeTimeRange(), + ...serializeLogRateAnalysisChartState(), + }, + references, + }; + }, + }, + { + ...timeRangeComparators, + ...titleComparators, + ...logRateAnalysisControlsComparators, + } + ); + + const LogRateAnalysisEmbeddableWrapper = getLogRateAnalysisEmbeddableWrapperComponent( + coreStart, + pluginStart + ); + + const onLoading = (v: boolean) => dataLoading.next(v); + const onRenderComplete = () => dataLoading.next(false); + const onError = (error: Error) => blockingError.next(error); + + return { + api, + Component: () => { + if (!apiHasExecutionContext(parentApi)) { + throw new Error('Parent API does not have execution context'); + } + + const [dataViewId] = useBatchedPublishingSubjects(api.dataViewId); + + const reload$ = useMemo( + () => + fetch$(api).pipe( + skipWhile((fetchContext) => !fetchContext.isReload), + map((fetchContext) => Date.now()) + ), + [] + ); + + const timeRange$ = useMemo( + () => + fetch$(api).pipe( + map((fetchContext) => fetchContext.timeRange), + distinctUntilChanged(fastIsEqual) + ), + [] + ); + + const lastReloadRequestTime = useObservable(reload$, Date.now()); + const timeRange = useObservable(timeRange$, undefined); + + const embeddingOrigin = apiHasExecutionContext(parentApi) + ? parentApi.executionContext.type + : undefined; + + return ( + + ); + }, + }; + }, + }; + + return factory; +}; diff --git a/x-pack/plugins/aiops/public/embeddables/log_rate_analysis/index.ts b/x-pack/plugins/aiops/public/embeddables/log_rate_analysis/index.ts new file mode 100644 index 0000000000000..2203d4c64bc8b --- /dev/null +++ b/x-pack/plugins/aiops/public/embeddables/log_rate_analysis/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { getLogRateAnalysisEmbeddableFactory } from './embeddable_log_rate_analysis_factory'; diff --git a/x-pack/plugins/aiops/public/embeddables/log_rate_analysis/initialize_log_rate_analysis_analysis_controls.ts b/x-pack/plugins/aiops/public/embeddables/log_rate_analysis/initialize_log_rate_analysis_analysis_controls.ts new file mode 100644 index 0000000000000..9d8a49b8f0e9c --- /dev/null +++ b/x-pack/plugins/aiops/public/embeddables/log_rate_analysis/initialize_log_rate_analysis_analysis_controls.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { StateComparators } from '@kbn/presentation-publishing'; +import { BehaviorSubject } from 'rxjs'; +import type { LogRateAnalysisComponentApi, LogRateAnalysisEmbeddableState } from './types'; + +type LogRateAnalysisEmbeddableCustomState = Omit< + LogRateAnalysisEmbeddableState, + 'timeRange' | 'title' | 'description' | 'hidePanelTitles' +>; + +export const initializeLogRateAnalysisControls = (rawState: LogRateAnalysisEmbeddableState) => { + const dataViewId = new BehaviorSubject(rawState.dataViewId); + + const updateUserInput = (update: LogRateAnalysisEmbeddableCustomState) => { + dataViewId.next(update.dataViewId); + }; + + const serializeLogRateAnalysisChartState = (): LogRateAnalysisEmbeddableCustomState => { + return { + dataViewId: dataViewId.getValue(), + }; + }; + + const logRateAnalysisControlsComparators: StateComparators = + { + dataViewId: [dataViewId, (arg) => dataViewId.next(arg)], + }; + + return { + logRateAnalysisControlsApi: { + dataViewId, + updateUserInput, + } as unknown as LogRateAnalysisComponentApi, + serializeLogRateAnalysisChartState, + logRateAnalysisControlsComparators, + }; +}; diff --git a/x-pack/plugins/aiops/public/embeddables/log_rate_analysis/log_rate_analysis_embeddable_initializer.tsx b/x-pack/plugins/aiops/public/embeddables/log_rate_analysis/log_rate_analysis_embeddable_initializer.tsx new file mode 100644 index 0000000000000..bf53f07677739 --- /dev/null +++ b/x-pack/plugins/aiops/public/embeddables/log_rate_analysis/log_rate_analysis_embeddable_initializer.tsx @@ -0,0 +1,230 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { FC } from 'react'; +import React, { useEffect, useMemo, useState, useCallback } from 'react'; +import { pick } from 'lodash'; +import useMountedState from 'react-use/lib/useMountedState'; + +import { + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, + EuiForm, + EuiFormRow, + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFlyoutFooter, + EuiSpacer, +} from '@elastic/eui'; + +import type { IndexPatternSelectProps } from '@kbn/unified-search-plugin/public'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { isPopulatedObject } from '@kbn/ml-is-populated-object'; +import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; + +import { TimeFieldWarning } from '../../components/time_field_warning'; + +import type { LogRateAnalysisEmbeddableRuntimeState } from './types'; + +export interface LogRateAnalysisEmbeddableInitializerProps { + dataViews: DataViewsPublicPluginStart; + IndexPatternSelect: React.ComponentType; + initialInput?: Partial; + onCreate: (props: LogRateAnalysisEmbeddableRuntimeState) => void; + onCancel: () => void; + onPreview: (update: LogRateAnalysisEmbeddableRuntimeState) => Promise; + isNewPanel: boolean; +} + +export const LogRateAnalysisEmbeddableInitializer: FC< + LogRateAnalysisEmbeddableInitializerProps +> = ({ + dataViews, + IndexPatternSelect, + initialInput, + onCreate, + onCancel, + onPreview, + isNewPanel, +}) => { + const isMounted = useMountedState(); + + const [formInput, setFormInput] = useState( + pick(initialInput ?? {}, ['dataViewId']) as LogRateAnalysisEmbeddableRuntimeState + ); + + // State to track if the selected data view is time based, undefined is used + // to track that the check is in progress. + const [isDataViewTimeBased, setIsDataViewTimeBased] = useState(); + + const isFormValid = useMemo( + () => + isPopulatedObject(formInput, ['dataViewId']) && + formInput.dataViewId !== '' && + isDataViewTimeBased === true, + [formInput, isDataViewTimeBased] + ); + + const updatedProps = useMemo(() => { + return { + ...formInput, + title: isPopulatedObject(formInput) + ? i18n.translate('xpack.aiops.embeddableLogRateAnalysis.attachmentTitle', { + defaultMessage: 'Log rate analysis', + }) + : '', + }; + }, [formInput]); + + useEffect( + function previewChanges() { + if (isFormValid) { + onPreview(updatedProps); + } + }, + [isFormValid, onPreview, updatedProps, isDataViewTimeBased] + ); + + const setDataViewId = useCallback( + (dataViewId: string | undefined) => { + setFormInput({ + ...formInput, + dataViewId: dataViewId ?? '', + }); + setIsDataViewTimeBased(undefined); + }, + [formInput] + ); + + useEffect( + function checkIsDataViewTimeBased() { + setIsDataViewTimeBased(undefined); + + const { dataViewId } = formInput; + + if (!dataViewId) { + return; + } + + dataViews + .get(dataViewId) + .then((dataView) => { + if (!isMounted()) { + return; + } + setIsDataViewTimeBased(dataView.isTimeBased()); + }) + .catch(() => { + setIsDataViewTimeBased(undefined); + }); + }, + [dataViews, formInput, isMounted] + ); + + return ( + <> + + +

+ {isNewPanel + ? i18n.translate('xpack.aiops.embeddableLogRateAnalysis.config.title.new', { + defaultMessage: 'Create log rate analysis', + }) + : i18n.translate('xpack.aiops.embeddableLogRateAnalysis.config.title.edit', { + defaultMessage: 'Edit log rate analysis', + })} +

+
+
+ + + + + <> + { + setDataViewId(newId ?? ''); + }} + data-test-subj="aiopsLogRateAnalysisEmbeddableDataViewSelector" + /> + {isDataViewTimeBased === false && ( + <> + + + + )} + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/aiops/public/embeddables/log_rate_analysis/resolve_log_rate_analysis_config_input.tsx b/x-pack/plugins/aiops/public/embeddables/log_rate_analysis/resolve_log_rate_analysis_config_input.tsx new file mode 100644 index 0000000000000..a066b5bc722f2 --- /dev/null +++ b/x-pack/plugins/aiops/public/embeddables/log_rate_analysis/resolve_log_rate_analysis_config_input.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { CoreStart } from '@kbn/core/public'; +import { tracksOverlays } from '@kbn/presentation-containers'; +import { toMountPoint } from '@kbn/react-kibana-mount'; +import React from 'react'; +import type { AiopsPluginStartDeps } from '../../types'; +import { LogRateAnalysisEmbeddableInitializer } from './log_rate_analysis_embeddable_initializer'; +import type { LogRateAnalysisComponentApi, LogRateAnalysisEmbeddableState } from './types'; + +export async function resolveEmbeddableLogRateAnalysisUserInput( + coreStart: CoreStart, + pluginStart: AiopsPluginStartDeps, + parentApi: unknown, + focusedPanelId: string, + isNewPanel: boolean, + logRateAnalysisControlsApi: LogRateAnalysisComponentApi, + deletePanel?: () => void, + initialState?: LogRateAnalysisEmbeddableState +): Promise { + const { overlays } = coreStart; + + const overlayTracker = tracksOverlays(parentApi) ? parentApi : undefined; + + let hasChanged = false; + return new Promise(async (resolve, reject) => { + try { + const cancelChanges = () => { + if (isNewPanel && deletePanel) { + deletePanel(); + } else if (hasChanged && logRateAnalysisControlsApi && initialState) { + // Reset to initialState in case user has changed the preview state + logRateAnalysisControlsApi.updateUserInput(initialState); + } + + flyoutSession.close(); + overlayTracker?.clearOverlays(); + }; + + const update = async (nextUpdate: LogRateAnalysisEmbeddableState) => { + resolve(nextUpdate); + flyoutSession.close(); + overlayTracker?.clearOverlays(); + }; + + const preview = async (nextUpdate: LogRateAnalysisEmbeddableState) => { + if (logRateAnalysisControlsApi) { + logRateAnalysisControlsApi.updateUserInput(nextUpdate); + hasChanged = true; + } + }; + + const flyoutSession = overlays.openFlyout( + toMountPoint( + , + coreStart + ), + { + ownFocus: true, + size: 's', + type: 'push', + paddingSize: 'm', + hideCloseButton: true, + 'data-test-subj': 'aiopsLogRateAnalysisEmbeddableInitializer', + 'aria-labelledby': 'logRateAnalysisConfig', + onClose: () => { + reject(); + flyoutSession.close(); + overlayTracker?.clearOverlays(); + }, + } + ); + + if (tracksOverlays(parentApi)) { + parentApi.openOverlay(flyoutSession, { focusedPanelId }); + } + } catch (error) { + reject(error); + } + }); +} diff --git a/x-pack/plugins/aiops/public/embeddables/log_rate_analysis/types.ts b/x-pack/plugins/aiops/public/embeddables/log_rate_analysis/types.ts new file mode 100644 index 0000000000000..d2255e6dacb87 --- /dev/null +++ b/x-pack/plugins/aiops/public/embeddables/log_rate_analysis/types.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public'; +import type { + HasEditCapabilities, + PublishesDataViews, + PublishesTimeRange, + PublishingSubject, + SerializedTimeRange, + SerializedTitles, +} from '@kbn/presentation-publishing'; + +export interface LogRateAnalysisComponentApi { + dataViewId: PublishingSubject; + updateUserInput: (update: LogRateAnalysisEmbeddableState) => void; +} + +export type LogRateAnalysisEmbeddableApi = DefaultEmbeddableApi & + HasEditCapabilities & + PublishesDataViews & + PublishesTimeRange & + LogRateAnalysisComponentApi; + +export interface LogRateAnalysisEmbeddableState extends SerializedTitles, SerializedTimeRange { + dataViewId: string; +} + +export interface LogRateAnalysisEmbeddableInitialState + extends SerializedTitles, + SerializedTimeRange { + dataViewId?: string; +} + +export type LogRateAnalysisEmbeddableRuntimeState = LogRateAnalysisEmbeddableState; diff --git a/x-pack/plugins/aiops/public/embeddables/pattern_analysis/embeddable_pattern_analysis_factory.tsx b/x-pack/plugins/aiops/public/embeddables/pattern_analysis/embeddable_pattern_analysis_factory.tsx index e7b1d6da3be61..e0017668b338c 100644 --- a/x-pack/plugins/aiops/public/embeddables/pattern_analysis/embeddable_pattern_analysis_factory.tsx +++ b/x-pack/plugins/aiops/public/embeddables/pattern_analysis/embeddable_pattern_analysis_factory.tsx @@ -11,18 +11,10 @@ import { } from '@kbn/aiops-log-pattern-analysis/constants'; import type { Reference } from '@kbn/content-management-utils'; import type { StartServicesAccessor } from '@kbn/core-lifecycle-browser'; -import { type DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/common'; import { DATA_VIEW_SAVED_OBJECT_TYPE } from '@kbn/data-views-plugin/common'; import type { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public'; import { i18n } from '@kbn/i18n'; -import { - apiHasExecutionContext, - fetch$, - initializeTimeRange, - initializeTitles, - useBatchedPublishingSubjects, -} from '@kbn/presentation-publishing'; import fastIsEqual from 'fast-deep-equal'; import { cloneDeep } from 'lodash'; import React, { useMemo } from 'react'; @@ -37,32 +29,6 @@ import type { PatternAnalysisEmbeddableState, } from './types'; -export interface EmbeddablePatternAnalysisStartServices { - data: DataPublicPluginStart; -} - -export type EmbeddablePatternAnalysisType = typeof EMBEDDABLE_PATTERN_ANALYSIS_TYPE; - -export const getDependencies = async ( - getStartServices: StartServicesAccessor -) => { - const [ - { http, uiSettings, notifications, ...startServices }, - { lens, data, usageCollection, fieldFormats }, - ] = await getStartServices(); - - return { - http, - uiSettings, - data, - notifications, - lens, - usageCollection, - fieldFormats, - ...startServices, - }; -}; - export const getPatternAnalysisEmbeddableFactory = ( getStartServices: StartServicesAccessor ) => { @@ -85,21 +51,15 @@ export const getPatternAnalysisEmbeddableFactory = ( return serializedState; }, buildEmbeddable: async (state, buildApi, uuid, parentApi) => { - const [coreStart, pluginStart] = await getStartServices(); - - const { http, uiSettings, notifications, ...startServices } = coreStart; - const { lens, data, usageCollection, fieldFormats } = pluginStart; + const { + apiHasExecutionContext, + fetch$, + initializeTimeRange, + initializeTitles, + useBatchedPublishingSubjects, + } = await import('@kbn/presentation-publishing'); - const deps = { - http, - uiSettings, - data, - notifications, - lens, - usageCollection, - fieldFormats, - ...startServices, - }; + const [coreStart, pluginStart] = await getStartServices(); const { api: timeRangeApi, @@ -119,8 +79,8 @@ export const getPatternAnalysisEmbeddableFactory = ( const blockingError = new BehaviorSubject(undefined); const dataViews$ = new BehaviorSubject([ - await deps.data.dataViews.get( - state.dataViewId ?? (await deps.data.dataViews.getDefaultId()) + await pluginStart.data.dataViews.get( + state.dataViewId ?? (await pluginStart.data.dataViews.getDefaultId()) ), ]); diff --git a/x-pack/plugins/aiops/public/embeddables/pattern_analysis/pattern_analysys_component_wrapper.tsx b/x-pack/plugins/aiops/public/embeddables/pattern_analysis/pattern_analysis_component_wrapper.tsx similarity index 100% rename from x-pack/plugins/aiops/public/embeddables/pattern_analysis/pattern_analysys_component_wrapper.tsx rename to x-pack/plugins/aiops/public/embeddables/pattern_analysis/pattern_analysis_component_wrapper.tsx diff --git a/x-pack/plugins/aiops/public/embeddables/pattern_analysis/pattern_analysis_initializer.tsx b/x-pack/plugins/aiops/public/embeddables/pattern_analysis/pattern_analysis_initializer.tsx index f44fff343fb50..ef185518638b8 100644 --- a/x-pack/plugins/aiops/public/embeddables/pattern_analysis/pattern_analysis_initializer.tsx +++ b/x-pack/plugins/aiops/public/embeddables/pattern_analysis/pattern_analysis_initializer.tsx @@ -33,6 +33,7 @@ import { useAiopsAppContext } from '../../hooks/use_aiops_app_context'; import { DataSourceContextProvider } from '../../hooks/use_data_source'; import type { PatternAnalysisEmbeddableRuntimeState } from './types'; import { PatternAnalysisSettings } from '../../components/log_categorization/log_categorization_for_embeddable/embeddable_menu'; +import { TimeFieldWarning } from '../../components/time_field_warning'; import { RandomSampler } from '../../components/log_categorization/sampling_menu'; import { DEFAULT_PROBABILITY, @@ -62,6 +63,7 @@ export const PatternAnalysisEmbeddableInitializer: FC { const { + data: { dataViews }, unifiedSearch: { ui: { IndexPatternSelect }, }, @@ -166,7 +168,7 @@ export const PatternAnalysisEmbeddableInitializer: FC - + { ); }; - -const TimeFieldWarning = () => { - return ( - <> - -

- {i18n.translate( - 'xpack.aiops.logCategorization.embeddableMenu.timeFieldWarning.title.description', - { - defaultMessage: 'Pattern analysis can only be run on data views with a time field.', - } - )} -

-
- - - ); -}; diff --git a/x-pack/plugins/aiops/public/embeddables/pattern_analysis/types.ts b/x-pack/plugins/aiops/public/embeddables/pattern_analysis/types.ts index f78934b9075f1..710e18823a2bb 100644 --- a/x-pack/plugins/aiops/public/embeddables/pattern_analysis/types.ts +++ b/x-pack/plugins/aiops/public/embeddables/pattern_analysis/types.ts @@ -14,18 +14,12 @@ import type { SerializedTimeRange, SerializedTitles, } from '@kbn/presentation-publishing'; -import type { FC } from 'react'; import type { MinimumTimeRangeOption } from '../../components/log_categorization/log_categorization_for_embeddable/minimum_time_range'; import type { RandomSamplerOption, RandomSamplerProbability, } from '../../components/log_categorization/sampling_menu/random_sampler'; -export type ViewComponent = FC<{ - interval: string; - onRenderComplete?: () => void; -}>; - export interface PatternAnalysisComponentApi { dataViewId: PublishingSubject; fieldName: PublishingSubject; diff --git a/x-pack/plugins/aiops/public/hooks/use_aiops_app_context.ts b/x-pack/plugins/aiops/public/hooks/use_aiops_app_context.ts index 03762a7ba70ba..c240ec90bc1df 100644 --- a/x-pack/plugins/aiops/public/hooks/use_aiops_app_context.ts +++ b/x-pack/plugins/aiops/public/hooks/use_aiops_app_context.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { createContext, type FC, type PropsWithChildren, useContext } from 'react'; +import { createContext, type FC, useContext } from 'react'; import type { ObservabilityAIAssistantPublicStart } from '@kbn/observability-ai-assistant-plugin/public'; import type { IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; @@ -24,17 +24,12 @@ import type { ThemeServiceStart, } from '@kbn/core/public'; import type { LensPublicStart } from '@kbn/lens-plugin/public'; -import { type EuiComboBoxProps } from '@elastic/eui/src/components/combo_box/combo_box'; -import { type DataView } from '@kbn/data-views-plugin/common'; -import type { - FieldStatsProps, - FieldStatsServices, -} from '@kbn/unified-field-list/src/components/field_stats'; -import type { TimeRange as TimeRangeMs } from '@kbn/ml-date-picker'; import type { EmbeddableStart } from '@kbn/embeddable-plugin/public'; import type { CasesPublicStart } from '@kbn/cases-plugin/public'; import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; import type { UiActionsStart } from '@kbn/ui-actions-plugin/public'; +import type { FieldStatsFlyoutProviderProps } from '@kbn/ml-field-stats-flyout/field_stats_flyout_provider'; +import type { UseFieldStatsTrigger } from '@kbn/ml-field-stats-flyout/use_field_stats_trigger'; /** * AIOps app context value to be provided via React context. @@ -98,7 +93,7 @@ export interface AiopsAppContextValue { /** * Used to create deep links to other plugins. */ - share: SharePluginStart; + share?: SharePluginStart; /** * Used to create lens embeddables. */ @@ -115,18 +110,8 @@ export interface AiopsAppContextValue { * Deps for unified fields stats. */ fieldStats?: { - useFieldStatsTrigger: () => { - renderOption: EuiComboBoxProps['renderOption']; - closeFlyout: () => void; - }; - FieldStatsFlyoutProvider: FC< - PropsWithChildren<{ - dataView: DataView; - fieldStatsServices: FieldStatsServices; - timeRangeMs?: TimeRangeMs; - dslQuery?: FieldStatsProps['dslQuery']; - }> - >; + useFieldStatsTrigger: UseFieldStatsTrigger; + FieldStatsFlyoutProvider: FC; }; embeddable?: EmbeddableStart; cases?: CasesPublicStart; diff --git a/x-pack/plugins/aiops/public/hooks/use_data_source.tsx b/x-pack/plugins/aiops/public/hooks/use_data_source.tsx index 081e10a34de65..ef574a348b928 100644 --- a/x-pack/plugins/aiops/public/hooks/use_data_source.tsx +++ b/x-pack/plugins/aiops/public/hooks/use_data_source.tsx @@ -8,10 +8,10 @@ import type { FC, PropsWithChildren } from 'react'; import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'; import type { DataView } from '@kbn/data-views-plugin/common'; +import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import type { SavedSearch } from '@kbn/saved-search-plugin/public'; import { EuiEmptyPrompt } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { useAiopsAppContext } from './use_aiops_app_context'; export const DataSourceContext = createContext({ get dataView(): never { @@ -30,6 +30,7 @@ export interface DataViewAndSavedSearch { } export interface DataSourceContextProviderProps { + dataViews: DataViewsPublicPluginStart; dataViewId?: string; savedSearchId?: string; /** Output resolves data view objects */ @@ -43,20 +44,14 @@ export interface DataSourceContextProviderProps { * @constructor */ export const DataSourceContextProvider: FC> = ({ + dataViews, dataViewId, - savedSearchId, children, onChange, }) => { const [value, setValue] = useState(); const [error, setError] = useState(); - const { - data: { dataViews }, - // uiSettings, - // savedSearch: savedSearchService, - } = useAiopsAppContext(); - /** * Resolve data view or saved search if exists. */ diff --git a/x-pack/plugins/aiops/public/plugin.tsx b/x-pack/plugins/aiops/public/plugin.tsx index ceb378b0f29bf..5863ea03b3072 100755 --- a/x-pack/plugins/aiops/public/plugin.tsx +++ b/x-pack/plugins/aiops/public/plugin.tsx @@ -8,15 +8,18 @@ import type { CoreStart, Plugin } from '@kbn/core/public'; import { type CoreSetup } from '@kbn/core/public'; import { firstValueFrom } from 'rxjs'; -import { dynamic } from '@kbn/shared-ux-utility'; import { getChangePointDetectionComponent } from './shared_components'; +import { LogCategorizationForDiscover as PatternAnalysisComponent } from './shared_lazy_components'; import type { AiopsPluginSetup, AiopsPluginSetupDeps, AiopsPluginStart, AiopsPluginStartDeps, } from './types'; +import { registerEmbeddables } from './embeddables'; +import { registerAiopsUiActions } from './ui_actions'; +import { registerChangePointChartsAttachment } from './cases/register_change_point_charts_attachment'; export type AiopsCoreSetup = CoreSetup; @@ -27,20 +30,8 @@ export class AiopsPlugin core: AiopsCoreSetup, { embeddable, cases, licensing, uiActions }: AiopsPluginSetupDeps ) { - Promise.all([ - firstValueFrom(licensing.license$), - import('./embeddables'), - import('./ui_actions'), - import('./cases/register_change_point_charts_attachment'), - core.getStartServices(), - ]).then( - ([ - license, - { registerEmbeddables }, - { registerAiopsUiActions }, - { registerChangePointChartsAttachment }, - [coreStart, pluginStart], - ]) => { + Promise.all([firstValueFrom(licensing.license$), core.getStartServices()]).then( + ([license, [coreStart, pluginStart]]) => { const { canUseAiops } = coreStart.application.capabilities.ml; if (license.hasAtLeast('platinum') && canUseAiops) { @@ -69,12 +60,7 @@ export class AiopsPlugin ); return getPatternAnalysisAvailable(plugins.licensing); }, - PatternAnalysisComponent: dynamic( - async () => - import( - './components/log_categorization/log_categorization_for_embeddable/log_categorization_for_discover_wrapper' - ) - ), + PatternAnalysisComponent, }; } diff --git a/x-pack/plugins/aiops/public/shared_components/change_point_detection.tsx b/x-pack/plugins/aiops/public/shared_components/change_point_detection.tsx index 9afbd9e1c4c8d..53997219fd639 100644 --- a/x-pack/plugins/aiops/public/shared_components/change_point_detection.tsx +++ b/x-pack/plugins/aiops/public/shared_components/change_point_detection.tsx @@ -11,7 +11,6 @@ import type { CoreStart } from '@kbn/core-lifecycle-browser'; import { UI_SETTINGS } from '@kbn/data-service'; import type { TimeRange } from '@kbn/es-query'; import { DatePickerContextProvider } from '@kbn/ml-date-picker'; -import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import { pick } from 'lodash'; import React, { useEffect, useMemo, useState, type FC } from 'react'; import type { Observable } from 'rxjs'; @@ -141,33 +140,34 @@ const ChangePointDetectionWrapper: FC = ({ width: 100%; `} > - - - - - - - - - - - - - - - + + + + + + + + + + + + +
); }; diff --git a/x-pack/plugins/aiops/public/shared_components/index.tsx b/x-pack/plugins/aiops/public/shared_components/index.tsx index 1c5b85c4b79b6..b347d3ee24cac 100644 --- a/x-pack/plugins/aiops/public/shared_components/index.tsx +++ b/x-pack/plugins/aiops/public/shared_components/index.tsx @@ -11,6 +11,7 @@ import type { CoreStart } from '@kbn/core-lifecycle-browser'; import type { AiopsPluginStartDeps } from '../types'; import type { ChangePointDetectionSharedComponent } from './change_point_detection'; import type { PatternAnalysisSharedComponent } from './pattern_analysis'; +import type { LogRateAnalysisEmbeddableWrapper } from './log_rate_analysis_embeddable_wrapper'; const ChangePointDetectionLazy = dynamic(async () => import('./change_point_detection')); @@ -37,3 +38,24 @@ export const getPatternAnalysisComponent = ( }; export type { PatternAnalysisSharedComponent } from './pattern_analysis'; + +const LogRateAnalysisEmbeddableWrapperLazy = dynamic( + async () => import('./log_rate_analysis_embeddable_wrapper') +); + +export const getLogRateAnalysisEmbeddableWrapperComponent = ( + coreStart: CoreStart, + pluginStart: AiopsPluginStartDeps +): LogRateAnalysisEmbeddableWrapper => { + return React.memo((props) => { + return ( + + ); + }); +}; + +export type { LogRateAnalysisEmbeddableWrapper } from './log_rate_analysis_embeddable_wrapper'; diff --git a/x-pack/plugins/aiops/public/shared_components/log_rate_analysis_embeddable_wrapper.tsx b/x-pack/plugins/aiops/public/shared_components/log_rate_analysis_embeddable_wrapper.tsx new file mode 100644 index 0000000000000..9f2a88e73461c --- /dev/null +++ b/x-pack/plugins/aiops/public/shared_components/log_rate_analysis_embeddable_wrapper.tsx @@ -0,0 +1,179 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { pick } from 'lodash'; +import React, { useEffect, useMemo, useState, type FC } from 'react'; +import usePrevious from 'react-use/lib/usePrevious'; +import type { Observable } from 'rxjs'; +import { BehaviorSubject, combineLatest, distinctUntilChanged, map } from 'rxjs'; +import { createBrowserHistory } from 'history'; + +import { UrlStateProvider } from '@kbn/ml-url-state'; +import { Router } from '@kbn/shared-ux-router'; +import { AIOPS_EMBEDDABLE_ORIGIN } from '@kbn/aiops-common/constants'; +import type { CoreStart } from '@kbn/core-lifecycle-browser'; +import { UI_SETTINGS } from '@kbn/data-service'; +import { LogRateAnalysisReduxProvider } from '@kbn/aiops-log-rate-analysis/state'; +import type { TimeRange } from '@kbn/es-query'; +import { DatePickerContextProvider } from '@kbn/ml-date-picker'; +import type { SignificantItem } from '@kbn/ml-agg-utils'; + +import { AiopsAppContext, type AiopsAppContextValue } from '../hooks/use_aiops_app_context'; +import { DataSourceContextProvider } from '../hooks/use_data_source'; +import { ReloadContextProvider } from '../hooks/use_reload'; +import { FilterQueryContextProvider } from '../hooks/use_filters_query'; +import type { AiopsPluginStartDeps } from '../types'; + +import { LogRateAnalysisForEmbeddable } from '../components/log_rate_analysis/log_rate_analysis_for_embeddable'; + +/** + * Only used to initialize internally + */ +export type LogRateAnalysisPropsWithDeps = LogRateAnalysisEmbeddableWrapperProps & { + coreStart: CoreStart; + pluginStart: AiopsPluginStartDeps; +}; + +export type LogRateAnalysisEmbeddableWrapper = FC; + +export interface LogRateAnalysisEmbeddableWrapperProps { + dataViewId: string; + timeRange: TimeRange; + /** + * Component to render if there are no significant items found + */ + emptyState?: React.ReactElement; + /** + * Outputs the most recent significant items + */ + onChange?: (significantItems: SignificantItem[]) => void; + /** + * Last reload request time, can be used for manual reload + */ + lastReloadRequestTime?: number; + /** Origin of the embeddable instance */ + embeddingOrigin?: string; + onLoading: (isLoading: boolean) => void; + onRenderComplete: () => void; + onError: (error: Error) => void; +} + +const LogRateAnalysisEmbeddableWrapperWithDeps: FC = ({ + // Component dependencies + coreStart, + pluginStart, + // Component props + dataViewId, + timeRange, + embeddingOrigin, + lastReloadRequestTime, +}) => { + const deps = useMemo(() => { + const { lens, data, usageCollection, fieldFormats, charts, share, storage, unifiedSearch } = + pluginStart; + + return { + data, + lens, + usageCollection, + fieldFormats, + charts, + share, + storage, + unifiedSearch, + ...coreStart, + }; + }, [coreStart, pluginStart]); + + const datePickerDeps = { + ...pick(deps, ['data', 'http', 'notifications', 'theme', 'uiSettings', 'i18n']), + uiSettingsKeys: UI_SETTINGS, + }; + + const aiopsAppContextValue = useMemo(() => { + return { + embeddingOrigin: embeddingOrigin ?? AIOPS_EMBEDDABLE_ORIGIN.DEFAULT, + ...deps, + }; + }, [deps, embeddingOrigin]); + + const [manualReload$] = useState>( + new BehaviorSubject(lastReloadRequestTime ?? Date.now()) + ); + + useEffect( + function updateManualReloadSubject() { + if (!lastReloadRequestTime) return; + manualReload$.next(lastReloadRequestTime); + }, + [lastReloadRequestTime, manualReload$] + ); + + const resultObservable$ = useMemo>(() => { + return combineLatest([manualReload$]).pipe( + map(([manualReload]) => Math.max(manualReload)), + distinctUntilChanged() + ); + }, [manualReload$]); + + const history = createBrowserHistory(); + + // We use the following pattern to track changes of dataViewId, and if there's + // a change, we unmount and remount the complete inner component. This makes + // sure the component is reinitialized correctly when the options of the + // dashboard panel are used to change the data view. This is a bit of a + // workaround since originally log rate analysis was developed as a standalone + // page with the expectation that the data view is set once and never changes. + const prevDataViewId = usePrevious(dataViewId); + const [_, setRerenderFlag] = useState(false); + useEffect(() => { + if (prevDataViewId && prevDataViewId !== dataViewId) { + setRerenderFlag((prev) => !prev); + } + }, [dataViewId, prevDataViewId]); + const showComponent = prevDataViewId === undefined || prevDataViewId === dataViewId; + + // TODO: Remove data-shared-item as part of https://github.com/elastic/kibana/issues/179376> + return ( +
+ {showComponent && ( + + + + + + + + + + + + + + + + + + )} +
+ ); +}; + +// eslint-disable-next-line import/no-default-export +export default LogRateAnalysisEmbeddableWrapperWithDeps; diff --git a/x-pack/plugins/aiops/public/shared_components/pattern_analysis.tsx b/x-pack/plugins/aiops/public/shared_components/pattern_analysis.tsx index 78261cd1f62f0..f601474a5707f 100644 --- a/x-pack/plugins/aiops/public/shared_components/pattern_analysis.tsx +++ b/x-pack/plugins/aiops/public/shared_components/pattern_analysis.tsx @@ -10,7 +10,6 @@ import type { CoreStart } from '@kbn/core-lifecycle-browser'; import { UI_SETTINGS } from '@kbn/data-service'; import type { TimeRange } from '@kbn/es-query'; import { DatePickerContextProvider } from '@kbn/ml-date-picker'; -import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import { pick } from 'lodash'; import React, { useEffect, useMemo, useState, type FC } from 'react'; import type { Observable } from 'rxjs'; @@ -20,7 +19,7 @@ import type { RandomSamplerOption, RandomSamplerProbability, } from '../components/log_categorization/sampling_menu/random_sampler'; -import { PatternAnalysisEmbeddableWrapper } from '../embeddables/pattern_analysis/pattern_analysys_component_wrapper'; +import { PatternAnalysisEmbeddableWrapper } from '../embeddables/pattern_analysis/pattern_analysis_component_wrapper'; import { AiopsAppContext, type AiopsAppContextValue } from '../hooks/use_aiops_app_context'; import { DataSourceContextProvider } from '../hooks/use_data_source'; import { FilterQueryContextProvider } from '../hooks/use_filters_query'; @@ -139,31 +138,32 @@ const PatternAnalysisWrapper: FC = ({ padding: '10px', }} > - - - - - - - - - - - - - + + + + + + + + + + +
); }; diff --git a/x-pack/plugins/aiops/public/shared_lazy_components.tsx b/x-pack/plugins/aiops/public/shared_lazy_components.tsx index fe9e31f146590..b34efdd6bff04 100644 --- a/x-pack/plugins/aiops/public/shared_lazy_components.tsx +++ b/x-pack/plugins/aiops/public/shared_lazy_components.tsx @@ -12,6 +12,7 @@ import { EuiErrorBoundary, EuiSkeletonText } from '@elastic/eui'; import type { LogRateAnalysisAppStateProps } from './components/log_rate_analysis'; import type { LogRateAnalysisContentWrapperProps } from './components/log_rate_analysis/log_rate_analysis_content/log_rate_analysis_content_wrapper'; import type { LogCategorizationAppStateProps } from './components/log_categorization'; +import type { LogCategorizationEmbeddableWrapperProps } from './components/log_categorization/log_categorization_for_embeddable/log_categorization_for_discover_wrapper'; import type { ChangePointDetectionAppStateProps } from './components/change_point_detection'; const LogRateAnalysisAppStateLazy = React.lazy(() => import('./components/log_rate_analysis')); @@ -58,6 +59,25 @@ export const LogCategorization: FC = (props) => ); +const LogCategorizationForDiscoverLazy = React.lazy( + () => + import( + './components/log_categorization/log_categorization_for_embeddable/log_categorization_for_discover_wrapper' + ) +); + +/** + * Lazy-wrapped LogCategorizationForDiscover React component + * @param {LogCategorizationEmbeddableWrapperProps} props - properties specifying the data on which to run the analysis. + */ +export const LogCategorizationForDiscover: FC = ( + props +) => ( + + + +); + const ChangePointDetectionLazy = React.lazy(() => import('./components/change_point_detection')); /** * Lazy-wrapped ChangePointDetectionAppStateProps React component diff --git a/x-pack/plugins/aiops/public/types/storage.ts b/x-pack/plugins/aiops/public/types/storage.ts index a4a29dda2f2c3..ea6fde6b06552 100644 --- a/x-pack/plugins/aiops/public/types/storage.ts +++ b/x-pack/plugins/aiops/public/types/storage.ts @@ -19,14 +19,12 @@ export const AIOPS_RANDOM_SAMPLING_PROBABILITY_PREFERENCE = 'aiops.randomSamplingProbabilityPreference'; export const AIOPS_PATTERN_ANALYSIS_MINIMUM_TIME_RANGE_PREFERENCE = 'aiops.patternAnalysisMinimumTimeRangePreference'; -export const AIOPS_LOG_RATE_ANALYSIS_RESULT_COLUMNS = 'aiops.logRateAnalysisResultColumns'; export type AiOps = Partial<{ [AIOPS_FROZEN_TIER_PREFERENCE]: FrozenTierPreference; [AIOPS_RANDOM_SAMPLING_MODE_PREFERENCE]: RandomSamplerOption; [AIOPS_RANDOM_SAMPLING_PROBABILITY_PREFERENCE]: number; [AIOPS_PATTERN_ANALYSIS_MINIMUM_TIME_RANGE_PREFERENCE]: MinimumTimeRangeOption; - [AIOPS_LOG_RATE_ANALYSIS_RESULT_COLUMNS]: string[]; }> | null; export type AiOpsKey = keyof Exclude; @@ -39,8 +37,6 @@ export type AiOpsStorageMapped = T extends typeof AIOPS_FROZ ? RandomSamplerProbability : T extends typeof AIOPS_PATTERN_ANALYSIS_MINIMUM_TIME_RANGE_PREFERENCE ? MinimumTimeRangeOption - : T extends typeof AIOPS_LOG_RATE_ANALYSIS_RESULT_COLUMNS - ? string[] : null; export const AIOPS_STORAGE_KEYS = [ @@ -48,5 +44,4 @@ export const AIOPS_STORAGE_KEYS = [ AIOPS_RANDOM_SAMPLING_MODE_PREFERENCE, AIOPS_RANDOM_SAMPLING_PROBABILITY_PREFERENCE, AIOPS_PATTERN_ANALYSIS_MINIMUM_TIME_RANGE_PREFERENCE, - AIOPS_LOG_RATE_ANALYSIS_RESULT_COLUMNS, ] as const; diff --git a/x-pack/plugins/aiops/public/ui_actions/change_point_action_context.ts b/x-pack/plugins/aiops/public/ui_actions/change_point_action_context.ts index a4307b69d3fac..3bf2eee922560 100644 --- a/x-pack/plugins/aiops/public/ui_actions/change_point_action_context.ts +++ b/x-pack/plugins/aiops/public/ui_actions/change_point_action_context.ts @@ -6,7 +6,8 @@ */ import { isPopulatedObject } from '@kbn/ml-is-populated-object'; -import { apiIsOfType, type EmbeddableApiContext } from '@kbn/presentation-publishing'; +import type { EmbeddableApiContext } from '@kbn/presentation-publishing'; +import { apiIsOfType } from '@kbn/presentation-publishing/interfaces/has_type'; import { EMBEDDABLE_CHANGE_POINT_CHART_TYPE } from '@kbn/aiops-change-point-detection/constants'; import type { ChangePointEmbeddableApi } from '../embeddables/change_point_chart/types'; diff --git a/x-pack/plugins/aiops/public/ui_actions/create_change_point_chart.tsx b/x-pack/plugins/aiops/public/ui_actions/create_change_point_chart.tsx index f9078f575818a..4d7ed26e295f5 100644 --- a/x-pack/plugins/aiops/public/ui_actions/create_change_point_chart.tsx +++ b/x-pack/plugins/aiops/public/ui_actions/create_change_point_chart.tsx @@ -12,7 +12,9 @@ import type { UiActionsActionDefinition } from '@kbn/ui-actions-plugin/public'; import { IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; import { EMBEDDABLE_CHANGE_POINT_CHART_TYPE } from '@kbn/aiops-change-point-detection/constants'; import type { CoreStart } from '@kbn/core-lifecycle-browser'; + import type { AiopsPluginStartDeps } from '../types'; + import type { ChangePointChartActionContext } from './change_point_action_context'; const parentApiIsCompatible = async ( diff --git a/x-pack/plugins/aiops/public/ui_actions/create_log_rate_analysis_actions.tsx b/x-pack/plugins/aiops/public/ui_actions/create_log_rate_analysis_actions.tsx new file mode 100644 index 0000000000000..588903e96aa16 --- /dev/null +++ b/x-pack/plugins/aiops/public/ui_actions/create_log_rate_analysis_actions.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import type { PresentationContainer } from '@kbn/presentation-containers'; +import type { EmbeddableApiContext } from '@kbn/presentation-publishing'; +import type { UiActionsActionDefinition } from '@kbn/ui-actions-plugin/public'; +import { IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; +import type { CoreStart } from '@kbn/core-lifecycle-browser'; +import { EMBEDDABLE_LOG_RATE_ANALYSIS_TYPE } from '@kbn/aiops-log-rate-analysis/constants'; +import { AIOPS_EMBEDDABLE_GROUPING } from '@kbn/aiops-common/constants'; + +import type { + LogRateAnalysisEmbeddableApi, + LogRateAnalysisEmbeddableInitialState, +} from '../embeddables/log_rate_analysis/types'; +import type { AiopsPluginStartDeps } from '../types'; + +import type { LogRateAnalysisActionContext } from './log_rate_analysis_action_context'; + +const parentApiIsCompatible = async ( + parentApi: unknown +): Promise => { + const { apiIsPresentationContainer } = await import('@kbn/presentation-containers'); + // we cannot have an async type check, so return the casted parentApi rather than a boolean + return apiIsPresentationContainer(parentApi) ? (parentApi as PresentationContainer) : undefined; +}; + +export function createAddLogRateAnalysisEmbeddableAction( + coreStart: CoreStart, + pluginStart: AiopsPluginStartDeps +): UiActionsActionDefinition { + return { + id: 'create-log-rate-analysis-embeddable', + grouping: AIOPS_EMBEDDABLE_GROUPING, + getIconType: () => 'logRateAnalysis', + getDisplayName: () => + i18n.translate('xpack.aiops.embeddableLogRateAnalysisDisplayName', { + defaultMessage: 'Log rate analysis', + }), + async isCompatible(context: EmbeddableApiContext) { + return Boolean(await parentApiIsCompatible(context.embeddable)); + }, + async execute(context) { + const presentationContainerParent = await parentApiIsCompatible(context.embeddable); + if (!presentationContainerParent) throw new IncompatibleActionError(); + + try { + const { resolveEmbeddableLogRateAnalysisUserInput } = await import( + '../embeddables/log_rate_analysis/resolve_log_rate_analysis_config_input' + ); + + const initialState: LogRateAnalysisEmbeddableInitialState = { + dataViewId: undefined, + }; + + const embeddable = await presentationContainerParent.addNewPanel< + object, + LogRateAnalysisEmbeddableApi + >({ + panelType: EMBEDDABLE_LOG_RATE_ANALYSIS_TYPE, + initialState, + }); + + if (!embeddable) { + return; + } + + const deletePanel = () => { + presentationContainerParent.removePanel(embeddable.uuid); + }; + + resolveEmbeddableLogRateAnalysisUserInput( + coreStart, + pluginStart, + context.embeddable, + embeddable.uuid, + true, + embeddable, + deletePanel + ); + } catch (e) { + return Promise.reject(); + } + }, + }; +} diff --git a/x-pack/plugins/aiops/public/ui_actions/create_pattern_analysis_action.tsx b/x-pack/plugins/aiops/public/ui_actions/create_pattern_analysis_action.tsx index 81127559e4e3d..f840e896abac4 100644 --- a/x-pack/plugins/aiops/public/ui_actions/create_pattern_analysis_action.tsx +++ b/x-pack/plugins/aiops/public/ui_actions/create_pattern_analysis_action.tsx @@ -12,13 +12,16 @@ import type { UiActionsActionDefinition } from '@kbn/ui-actions-plugin/public'; import { IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; import type { CoreStart } from '@kbn/core-lifecycle-browser'; import { EMBEDDABLE_PATTERN_ANALYSIS_TYPE } from '@kbn/aiops-log-pattern-analysis/constants'; +import { AIOPS_EMBEDDABLE_GROUPING } from '@kbn/aiops-common/constants'; + import type { AiopsPluginStartDeps } from '../types'; -import type { PatternAnalysisActionContext } from './pattern_analysis_action_context'; import type { PatternAnalysisEmbeddableApi, PatternAnalysisEmbeddableInitialState, } from '../embeddables/pattern_analysis/types'; +import type { PatternAnalysisActionContext } from './pattern_analysis_action_context'; + const parentApiIsCompatible = async ( parentApi: unknown ): Promise => { @@ -33,16 +36,7 @@ export function createAddPatternAnalysisEmbeddableAction( ): UiActionsActionDefinition { return { id: 'create-pattern-analysis-embeddable', - grouping: [ - { - id: 'ml', - getDisplayName: () => - i18n.translate('xpack.aiops.navMenu.mlAppNameText', { - defaultMessage: 'Machine Learning and Analytics', - }), - getIconType: () => 'logPatternAnalysis', - }, - ], + grouping: AIOPS_EMBEDDABLE_GROUPING, getIconType: () => 'logPatternAnalysis', getDisplayName: () => i18n.translate('xpack.aiops.embeddablePatternAnalysisDisplayName', { diff --git a/x-pack/plugins/aiops/public/ui_actions/index.ts b/x-pack/plugins/aiops/public/ui_actions/index.ts index d14856fd28733..b0b39083aabd4 100644 --- a/x-pack/plugins/aiops/public/ui_actions/index.ts +++ b/x-pack/plugins/aiops/public/ui_actions/index.ts @@ -16,8 +16,9 @@ import type { CoreStart } from '@kbn/core/public'; import { createAddChangePointChartAction } from './create_change_point_chart'; import { createOpenChangePointInMlAppAction } from './open_change_point_ml'; import type { AiopsPluginStartDeps } from '../types'; -import { createCategorizeFieldAction } from '../components/log_categorization'; +import { createCategorizeFieldAction } from '../components/log_categorization/categorize_field_actions'; import { createAddPatternAnalysisEmbeddableAction } from './create_pattern_analysis_action'; +import { createAddLogRateAnalysisEmbeddableAction } from './create_log_rate_analysis_actions'; export function registerAiopsUiActions( uiActions: UiActionsSetup, @@ -27,6 +28,7 @@ export function registerAiopsUiActions( const openChangePointInMlAppAction = createOpenChangePointInMlAppAction(coreStart, pluginStart); const addChangePointChartAction = createAddChangePointChartAction(coreStart, pluginStart); const addPatternAnalysisAction = createAddPatternAnalysisEmbeddableAction(coreStart, pluginStart); + const addLogRateAnalysisAction = createAddLogRateAnalysisEmbeddableAction(coreStart, pluginStart); uiActions.addTriggerAction(ADD_PANEL_TRIGGER, addPatternAnalysisAction); uiActions.addTriggerAction(ADD_PANEL_TRIGGER, addChangePointChartAction); @@ -39,4 +41,6 @@ export function registerAiopsUiActions( ); uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, openChangePointInMlAppAction); + + uiActions.addTriggerAction(ADD_PANEL_TRIGGER, addLogRateAnalysisAction); } diff --git a/x-pack/plugins/aiops/public/ui_actions/log_rate_analysis_action_context.ts b/x-pack/plugins/aiops/public/ui_actions/log_rate_analysis_action_context.ts new file mode 100644 index 0000000000000..d0c1ef715b84c --- /dev/null +++ b/x-pack/plugins/aiops/public/ui_actions/log_rate_analysis_action_context.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isPopulatedObject } from '@kbn/ml-is-populated-object'; +import { apiIsOfType, type EmbeddableApiContext } from '@kbn/presentation-publishing'; +import { EMBEDDABLE_LOG_RATE_ANALYSIS_TYPE } from '@kbn/aiops-log-rate-analysis/constants'; +import type { LogRateAnalysisEmbeddableApi } from '../embeddables/log_rate_analysis/types'; + +export interface LogRateAnalysisActionContext extends EmbeddableApiContext { + embeddable: LogRateAnalysisEmbeddableApi; +} + +export function isLogRateAnalysisEmbeddableContext( + arg: unknown +): arg is LogRateAnalysisActionContext { + return ( + isPopulatedObject(arg, ['embeddable']) && + apiIsOfType(arg.embeddable, EMBEDDABLE_LOG_RATE_ANALYSIS_TYPE) + ); +} diff --git a/x-pack/plugins/aiops/public/ui_actions/open_change_point_ml.tsx b/x-pack/plugins/aiops/public/ui_actions/open_change_point_ml.tsx index 8d2e4f1bbd089..3d52d34a72b85 100644 --- a/x-pack/plugins/aiops/public/ui_actions/open_change_point_ml.tsx +++ b/x-pack/plugins/aiops/public/ui_actions/open_change_point_ml.tsx @@ -10,17 +10,17 @@ import { IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; import { i18n } from '@kbn/i18n'; import type { CoreStart } from '@kbn/core/public'; import type { TimeRange } from '@kbn/es-query'; -import { apiHasParentApi, apiPublishesTimeRange } from '@kbn/presentation-publishing'; import type { ChangePointEmbeddableApi } from '../embeddables/change_point_chart/types'; import type { AiopsPluginStartDeps } from '../types'; import type { ChangePointChartActionContext } from './change_point_action_context'; -import { isChangePointChartEmbeddableContext } from './change_point_action_context'; export const OPEN_CHANGE_POINT_IN_ML_APP_ACTION = 'openChangePointInMlAppAction'; -export const getEmbeddableTimeRange = ( +const getEmbeddableTimeRange = async ( embeddable: ChangePointEmbeddableApi -): TimeRange | undefined => { +): Promise => { + const { apiHasParentApi, apiPublishesTimeRange } = await import('@kbn/presentation-publishing'); + let timeRange = embeddable.timeRange$?.getValue(); if (!timeRange && apiHasParentApi(embeddable) && apiPublishesTimeRange(embeddable.parentApi)) { @@ -45,6 +45,7 @@ export function createOpenChangePointInMlAppAction( defaultMessage: 'Open in AIOps Labs', }), async getHref(context): Promise { + const { isChangePointChartEmbeddableContext } = await import('./change_point_action_context'); if (!isChangePointChartEmbeddableContext(context)) { throw new IncompatibleActionError(); } @@ -57,7 +58,7 @@ export function createOpenChangePointInMlAppAction( page: 'aiops/change_point_detection', pageState: { index: dataViewId.getValue(), - timeRange: getEmbeddableTimeRange(context.embeddable), + timeRange: await getEmbeddableTimeRange(context.embeddable), fieldConfigs: [ { fn: fn.getValue(), @@ -69,6 +70,7 @@ export function createOpenChangePointInMlAppAction( }); }, async execute(context) { + const { isChangePointChartEmbeddableContext } = await import('./change_point_action_context'); if (!isChangePointChartEmbeddableContext(context)) { throw new IncompatibleActionError(); } @@ -78,6 +80,7 @@ export function createOpenChangePointInMlAppAction( } }, async isCompatible(context) { + const { isChangePointChartEmbeddableContext } = await import('./change_point_action_context'); return isChangePointChartEmbeddableContext(context); }, }; diff --git a/x-pack/plugins/aiops/server/routes/categorization_field_validation/define_route.ts b/x-pack/plugins/aiops/server/routes/categorization_field_validation/define_route.ts index ecc4d9c94bde1..5d166d1493b33 100644 --- a/x-pack/plugins/aiops/server/routes/categorization_field_validation/define_route.ts +++ b/x-pack/plugins/aiops/server/routes/categorization_field_validation/define_route.ts @@ -27,6 +27,13 @@ export const defineRoute = ( .addVersion( { version: '1', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because permissions will be checked by elasticsearch', + }, + }, validate: { request: { body: categorizationFieldValidationSchema, diff --git a/x-pack/plugins/aiops/server/routes/log_rate_analysis/define_route.ts b/x-pack/plugins/aiops/server/routes/log_rate_analysis/define_route.ts index 5c092c1a3be58..b69a66e4e69c5 100644 --- a/x-pack/plugins/aiops/server/routes/log_rate_analysis/define_route.ts +++ b/x-pack/plugins/aiops/server/routes/log_rate_analysis/define_route.ts @@ -38,6 +38,13 @@ export const defineRoute = ( .addVersion( { version: '2', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because permissions will be checked by elasticsearch', + }, + }, validate: { request: { body: aiopsLogRateAnalysisSchemaV2, @@ -49,6 +56,13 @@ export const defineRoute = ( .addVersion( { version: '3', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because permissions will be checked by elasticsearch', + }, + }, validate: { request: { body: aiopsLogRateAnalysisSchemaV3, diff --git a/x-pack/plugins/aiops/server/routes/log_rate_analysis_field_candidates/define_route.ts b/x-pack/plugins/aiops/server/routes/log_rate_analysis_field_candidates/define_route.ts index 132ecfee7b212..fc7ed7c808975 100644 --- a/x-pack/plugins/aiops/server/routes/log_rate_analysis_field_candidates/define_route.ts +++ b/x-pack/plugins/aiops/server/routes/log_rate_analysis_field_candidates/define_route.ts @@ -35,6 +35,13 @@ export const defineRoute = ( .addVersion( { version: '1', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because permissions will be checked by elasticsearch', + }, + }, validate: { request: { body: aiopsLogRateAnalysisSchemaV3, diff --git a/x-pack/plugins/aiops/tsconfig.json b/x-pack/plugins/aiops/tsconfig.json index 188f8b275fbac..e8b4f4f3ed972 100644 --- a/x-pack/plugins/aiops/tsconfig.json +++ b/x-pack/plugins/aiops/tsconfig.json @@ -63,7 +63,6 @@ "@kbn/presentation-containers", "@kbn/presentation-publishing", "@kbn/presentation-util-plugin", - "@kbn/react-kibana-context-render", "@kbn/react-kibana-context-theme", "@kbn/react-kibana-mount", "@kbn/rison", @@ -79,6 +78,8 @@ "@kbn/observability-ai-assistant-plugin", "@kbn/ui-theme", "@kbn/apm-utils", + "@kbn/ml-field-stats-flyout", + "@kbn/shared-ux-router", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/alerting/common/routes/maintenance_window/apis/find/index.ts b/x-pack/plugins/alerting/common/routes/maintenance_window/apis/find/index.ts index c3e30072e7348..8715e176f9935 100644 --- a/x-pack/plugins/alerting/common/routes/maintenance_window/apis/find/index.ts +++ b/x-pack/plugins/alerting/common/routes/maintenance_window/apis/find/index.ts @@ -5,6 +5,20 @@ * 2.0. */ -export type { FindMaintenanceWindowsResponse } from './types/latest'; +export { + findMaintenanceWindowsRequestQuerySchema, + findMaintenanceWindowsResponseBodySchema, +} from './schemas/latest'; +export type { + FindMaintenanceWindowsRequestQuery, + FindMaintenanceWindowsResponse, +} from './types/latest'; -export type { FindMaintenanceWindowsResponse as FindMaintenanceWindowsResponseV1 } from './types/v1'; +export { + findMaintenanceWindowsRequestQuerySchema as findMaintenanceWindowsRequestQuerySchemaV1, + findMaintenanceWindowsResponseBodySchema as findMaintenanceWindowsResponseBodySchemaV1, +} from './schemas/v1'; +export type { + FindMaintenanceWindowsRequestQuery as FindMaintenanceWindowsRequestQueryV1, + FindMaintenanceWindowsResponse as FindMaintenanceWindowsResponseV1, +} from './types/v1'; diff --git a/x-pack/packages/security-solution/common/src/cells/renderers/index.ts b/x-pack/plugins/alerting/common/routes/maintenance_window/apis/find/schemas/latest.ts similarity index 91% rename from x-pack/packages/security-solution/common/src/cells/renderers/index.ts rename to x-pack/plugins/alerting/common/routes/maintenance_window/apis/find/schemas/latest.ts index e42333a710c04..25300c97a6d2e 100644 --- a/x-pack/packages/security-solution/common/src/cells/renderers/index.ts +++ b/x-pack/plugins/alerting/common/routes/maintenance_window/apis/find/schemas/latest.ts @@ -5,4 +5,4 @@ * 2.0. */ -export * from './get'; +export * from './v1'; diff --git a/x-pack/plugins/alerting/common/routes/maintenance_window/apis/find/schemas/v1.ts b/x-pack/plugins/alerting/common/routes/maintenance_window/apis/find/schemas/v1.ts new file mode 100644 index 0000000000000..7c4dffdd1d94c --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/maintenance_window/apis/find/schemas/v1.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { maintenanceWindowResponseSchemaV1 } from '../../../response'; + +const MAX_DOCS = 10000; + +export const findMaintenanceWindowsRequestQuerySchema = schema.object( + { + // we do not need to use schema.maybe here, because if we do not pass property page, defaultValue will be used + page: schema.number({ + defaultValue: 1, + min: 1, + max: MAX_DOCS, + meta: { + description: 'The page number to return.', + }, + }), + // we do not need to use schema.maybe here, because if we do not pass property per_page, defaultValue will be used + per_page: schema.number({ + defaultValue: 1000, + min: 0, + max: 100, + meta: { + description: 'The number of maintenance windows to return per page.', + }, + }), + }, + { + validate: (params) => { + const pageAsNumber = params.page ?? 0; + const perPageAsNumber = params.per_page ?? 0; + + if (Math.max(pageAsNumber, pageAsNumber * perPageAsNumber) > MAX_DOCS) { + return `The number of documents is too high. Paginating through more than ${MAX_DOCS} documents is not possible.`; + } + }, + } +); + +export const findMaintenanceWindowsResponseBodySchema = schema.object({ + page: schema.number(), + per_page: schema.number(), + total: schema.number(), + data: schema.arrayOf(maintenanceWindowResponseSchemaV1), +}); diff --git a/x-pack/plugins/alerting/common/routes/maintenance_window/apis/find/types/latest.ts b/x-pack/plugins/alerting/common/routes/maintenance_window/apis/find/types/latest.ts index 4741df5c6c6c1..25300c97a6d2e 100644 --- a/x-pack/plugins/alerting/common/routes/maintenance_window/apis/find/types/latest.ts +++ b/x-pack/plugins/alerting/common/routes/maintenance_window/apis/find/types/latest.ts @@ -5,4 +5,4 @@ * 2.0. */ -export type { FindMaintenanceWindowsResponse } from './v1'; +export * from './v1'; diff --git a/x-pack/plugins/alerting/common/routes/maintenance_window/apis/find/types/v1.ts b/x-pack/plugins/alerting/common/routes/maintenance_window/apis/find/types/v1.ts index 0bdff90d3419f..0176d2e6689f8 100644 --- a/x-pack/plugins/alerting/common/routes/maintenance_window/apis/find/types/v1.ts +++ b/x-pack/plugins/alerting/common/routes/maintenance_window/apis/find/types/v1.ts @@ -5,11 +5,15 @@ * 2.0. */ -import { MaintenanceWindowResponseV1 } from '../../../response'; +import { TypeOf } from '@kbn/config-schema'; +import { + findMaintenanceWindowsResponseBodySchema, + findMaintenanceWindowsRequestQuerySchema, +} from '..'; -export interface FindMaintenanceWindowsResponse { - body: { - data: MaintenanceWindowResponseV1[]; - total: number; - }; -} +export type FindMaintenanceWindowsResponse = TypeOf< + typeof findMaintenanceWindowsResponseBodySchema +>; +export type FindMaintenanceWindowsRequestQuery = TypeOf< + typeof findMaintenanceWindowsRequestQuerySchema +>; diff --git a/x-pack/plugins/alerting/common/routes/maintenance_window/response/schemas/v1.ts b/x-pack/plugins/alerting/common/routes/maintenance_window/response/schemas/v1.ts index 648b2b806978f..a9237e6be4ecc 100644 --- a/x-pack/plugins/alerting/common/routes/maintenance_window/response/schemas/v1.ts +++ b/x-pack/plugins/alerting/common/routes/maintenance_window/response/schemas/v1.ts @@ -6,7 +6,7 @@ */ import { schema } from '@kbn/config-schema'; -import { maintenanceWindowStatusV1 } from '..'; +import { maintenanceWindowStatus as maintenanceWindowStatusV1 } from '../constants/v1'; import { maintenanceWindowCategoryIdsSchemaV1 } from '../../shared'; import { rRuleResponseSchemaV1 } from '../../../r_rule'; import { alertsFilterQuerySchemaV1 } from '../../../alerts_filter_query'; diff --git a/x-pack/plugins/alerting/kibana.jsonc b/x-pack/plugins/alerting/kibana.jsonc index 9b6e523e27ebf..0b5f930dbb34a 100644 --- a/x-pack/plugins/alerting/kibana.jsonc +++ b/x-pack/plugins/alerting/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/alerting-plugin", - "owner": "@elastic/response-ops", + "owner": [ + "@elastic/response-ops" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "alerting", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "alerting" @@ -32,11 +36,11 @@ "security", "monitoringCollection", "spaces", - "serverless", + "serverless" ], "extraPublicDirs": [ "common", "common/parse_duration" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/alerting/public/services/maintenance_windows_api/find.ts b/x-pack/plugins/alerting/public/services/maintenance_windows_api/find.ts index c63e491198ce9..822fb6e2bae1f 100644 --- a/x-pack/plugins/alerting/public/services/maintenance_windows_api/find.ts +++ b/x-pack/plugins/alerting/public/services/maintenance_windows_api/find.ts @@ -16,7 +16,7 @@ export async function findMaintenanceWindows({ }: { http: HttpSetup; }): Promise { - const res = await http.get( + const res = await http.get( `${INTERNAL_BASE_ALERTING_API_PATH}/rules/maintenance_window/_find` ); return res.data.map((mw) => transformMaintenanceWindowResponse(mw)); diff --git a/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/find_maintenance_windows.test.ts b/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/find_maintenance_windows.test.ts index 8d06b335892b9..35c15ebb57c61 100644 --- a/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/find_maintenance_windows.test.ts +++ b/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/find_maintenance_windows.test.ts @@ -17,6 +17,7 @@ import { MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, } from '../../../../../common'; import { getMockMaintenanceWindow } from '../../../../data/maintenance_window/test_helpers'; +import { findMaintenanceWindowsParamsSchema } from './schemas'; const savedObjectsClient = savedObjectsClientMock.create(); const uiSettings = uiSettingsServiceMock.createClient(); @@ -37,8 +38,47 @@ describe('MaintenanceWindowClient - find', () => { jest.useRealTimers(); }); + it('throws an error if page is string', async () => { + savedObjectsClient.find.mockResolvedValueOnce({ + saved_objects: [ + { + attributes: getMockMaintenanceWindow({ expirationDate: new Date().toISOString() }), + id: 'test-1', + }, + { + attributes: getMockMaintenanceWindow({ expirationDate: new Date().toISOString() }), + id: 'test-2', + }, + ], + page: 1, + per_page: 5, + } as unknown as SavedObjectsFindResponse); + + await expect( + // @ts-expect-error: testing validation of strings + findMaintenanceWindows(mockContext, { page: 'dfsd', perPage: 10 }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + '"Error validating find maintenance windows data - [page]: expected value of type [number] but got [string]"' + ); + }); + + it('throws an error if savedObjectsClient.find will throw an error', async () => { + jest.useFakeTimers().setSystemTime(new Date('2023-02-26T00:00:00.000Z')); + + savedObjectsClient.find.mockImplementation(() => { + throw new Error('something went wrong!'); + }); + + await expect( + findMaintenanceWindows(mockContext, { page: 1, perPage: 10 }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + '"Failed to find maintenance window, Error: Error: something went wrong!: something went wrong!"' + ); + }); + it('should find maintenance windows', async () => { jest.useFakeTimers().setSystemTime(new Date('2023-02-26T00:00:00.000Z')); + const spy = jest.spyOn(findMaintenanceWindowsParamsSchema, 'validate'); savedObjectsClient.find.mockResolvedValueOnce({ saved_objects: [ @@ -51,10 +91,13 @@ describe('MaintenanceWindowClient - find', () => { id: 'test-2', }, ], + page: 1, + per_page: 5, } as unknown as SavedObjectsFindResponse); - const result = await findMaintenanceWindows(mockContext); + const result = await findMaintenanceWindows(mockContext, {}); + expect(spy).toHaveBeenCalledWith({}); expect(savedObjectsClient.find).toHaveBeenLastCalledWith({ type: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, }); @@ -62,5 +105,7 @@ describe('MaintenanceWindowClient - find', () => { expect(result.data.length).toEqual(2); expect(result.data[0].id).toEqual('test-1'); expect(result.data[1].id).toEqual('test-2'); + expect(result.page).toEqual(1); + expect(result.perPage).toEqual(5); }); }); diff --git a/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/find_maintenance_windows.ts b/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/find_maintenance_windows.ts index fe0f279ea4073..5cb1e01c1f1a0 100644 --- a/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/find_maintenance_windows.ts +++ b/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/find_maintenance_windows.ts @@ -9,17 +9,35 @@ import Boom from '@hapi/boom'; import { MaintenanceWindowClientContext } from '../../../../../common'; import { transformMaintenanceWindowAttributesToMaintenanceWindow } from '../../transforms'; import { findMaintenanceWindowSo } from '../../../../data/maintenance_window'; -import type { FindMaintenanceWindowsResult } from './types'; +import type { FindMaintenanceWindowsResult, FindMaintenanceWindowsParams } from './types'; +import { findMaintenanceWindowsParamsSchema } from './schemas'; export async function findMaintenanceWindows( - context: MaintenanceWindowClientContext + context: MaintenanceWindowClientContext, + params?: FindMaintenanceWindowsParams ): Promise { const { savedObjectsClient, logger } = context; try { - const result = await findMaintenanceWindowSo({ savedObjectsClient }); + if (params) { + findMaintenanceWindowsParamsSchema.validate(params); + } + } catch (error) { + throw Boom.badRequest(`Error validating find maintenance windows data - ${error.message}`); + } + + try { + const result = await findMaintenanceWindowSo({ + savedObjectsClient, + ...(params + ? { savedObjectsFindOptions: { page: params.page, perPage: params.perPage } } + : {}), + }); return { + page: result.page, + perPage: result.per_page, + total: result.total, data: result.saved_objects.map((so) => transformMaintenanceWindowAttributesToMaintenanceWindow({ attributes: so.attributes, diff --git a/x-pack/packages/security-solution/common/index.ts b/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/schemas/find_maintenance_window_params_schema.ts similarity index 56% rename from x-pack/packages/security-solution/common/index.ts rename to x-pack/plugins/alerting/server/application/maintenance_window/methods/find/schemas/find_maintenance_window_params_schema.ts index ba5d797648f13..e874882450c26 100644 --- a/x-pack/packages/security-solution/common/index.ts +++ b/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/schemas/find_maintenance_window_params_schema.ts @@ -5,7 +5,9 @@ * 2.0. */ -export { HostDetailsButton } from './src/cells/renderers/host'; +import { schema } from '@kbn/config-schema'; -export * from './src/flyout'; -export * from './src/cells/renderers'; +export const findMaintenanceWindowsParamsSchema = schema.object({ + perPage: schema.maybe(schema.number()), + page: schema.maybe(schema.number()), +}); diff --git a/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/schemas/find_maintenance_windows_result_schema.ts b/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/schemas/find_maintenance_windows_result_schema.ts index 1bdc2f00219ae..49b03325faa33 100644 --- a/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/schemas/find_maintenance_windows_result_schema.ts +++ b/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/schemas/find_maintenance_windows_result_schema.ts @@ -9,5 +9,8 @@ import { schema } from '@kbn/config-schema'; import { maintenanceWindowSchema } from '../../../schemas'; export const findMaintenanceWindowsResultSchema = schema.object({ + page: schema.number(), + perPage: schema.number(), data: schema.arrayOf(maintenanceWindowSchema), + total: schema.number(), }); diff --git a/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/schemas/index.ts b/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/schemas/index.ts index 4b2f087c95505..4e6c55b08955f 100644 --- a/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/schemas/index.ts +++ b/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/schemas/index.ts @@ -6,3 +6,4 @@ */ export { findMaintenanceWindowsResultSchema } from './find_maintenance_windows_result_schema'; +export { findMaintenanceWindowsParamsSchema } from './find_maintenance_window_params_schema'; diff --git a/x-pack/plugins/observability_solution/inventory/.storybook/jest_setup.js b/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/types/find_maintenance_window_params.ts similarity index 55% rename from x-pack/plugins/observability_solution/inventory/.storybook/jest_setup.js rename to x-pack/plugins/alerting/server/application/maintenance_window/methods/find/types/find_maintenance_window_params.ts index 32071b8aa3f62..878d5168c7e55 100644 --- a/x-pack/plugins/observability_solution/inventory/.storybook/jest_setup.js +++ b/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/types/find_maintenance_window_params.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { setGlobalConfig } from '@storybook/testing-react'; -import * as globalStorybookConfig from './preview'; +import { TypeOf } from '@kbn/config-schema'; +import { findMaintenanceWindowsParamsSchema } from '../schemas'; -setGlobalConfig(globalStorybookConfig); +export type FindMaintenanceWindowsParams = TypeOf; diff --git a/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/types/index.ts b/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/types/index.ts index a5f00973bb82e..97472fc231ab6 100644 --- a/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/types/index.ts +++ b/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/types/index.ts @@ -6,3 +6,4 @@ */ export type { FindMaintenanceWindowsResult } from './find_maintenance_window_result'; +export type { FindMaintenanceWindowsParams } from './find_maintenance_window_params'; diff --git a/x-pack/plugins/alerting/server/data/maintenance_window/methods/find_maintenance_window_so.ts b/x-pack/plugins/alerting/server/data/maintenance_window/methods/find_maintenance_window_so.ts index baaed546c88cb..d08a3c360cbb0 100644 --- a/x-pack/plugins/alerting/server/data/maintenance_window/methods/find_maintenance_window_so.ts +++ b/x-pack/plugins/alerting/server/data/maintenance_window/methods/find_maintenance_window_so.ts @@ -24,7 +24,7 @@ export const findMaintenanceWindowSo = ({ - ...savedObjectsFindOptions, + ...(savedObjectsFindOptions ? savedObjectsFindOptions : {}), type: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, }); }; diff --git a/x-pack/plugins/alerting/server/integration_tests/__snapshots__/alert_as_data_fields.test.ts.snap b/x-pack/plugins/alerting/server/integration_tests/__snapshots__/alert_as_data_fields.test.ts.snap index 8c65843f2d844..0513842a6126b 100644 --- a/x-pack/plugins/alerting/server/integration_tests/__snapshots__/alert_as_data_fields.test.ts.snap +++ b/x-pack/plugins/alerting/server/integration_tests/__snapshots__/alert_as_data_fields.test.ts.snap @@ -9851,6 +9851,10 @@ Object { "required": false, "type": "text", }, + "error.stack_trace": Object { + "required": false, + "type": "wildcard", + }, "host.name": Object { "required": false, "type": "keyword", @@ -9991,6 +9995,10 @@ Object { "required": false, "type": "text", }, + "error.stack_trace": Object { + "required": false, + "type": "wildcard", + }, "host.name": Object { "required": false, "type": "keyword", @@ -10131,6 +10139,10 @@ Object { "required": false, "type": "text", }, + "error.stack_trace": Object { + "required": false, + "type": "wildcard", + }, "host.name": Object { "required": false, "type": "keyword", @@ -10271,6 +10283,10 @@ Object { "required": false, "type": "text", }, + "error.stack_trace": Object { + "required": false, + "type": "wildcard", + }, "host.name": Object { "required": false, "type": "keyword", @@ -10417,6 +10433,10 @@ Object { "required": false, "type": "text", }, + "error.stack_trace": Object { + "required": false, + "type": "wildcard", + }, "host.name": Object { "required": false, "type": "keyword", diff --git a/x-pack/plugins/alerting/server/maintenance_window_client/maintenance_window_client.ts b/x-pack/plugins/alerting/server/maintenance_window_client/maintenance_window_client.ts index 2c5e6f417ff3d..dba7ae0800ede 100644 --- a/x-pack/plugins/alerting/server/maintenance_window_client/maintenance_window_client.ts +++ b/x-pack/plugins/alerting/server/maintenance_window_client/maintenance_window_client.ts @@ -13,7 +13,10 @@ import type { GetMaintenanceWindowParams } from '../application/maintenance_wind import { updateMaintenanceWindow } from '../application/maintenance_window/methods/update/update_maintenance_window'; import type { UpdateMaintenanceWindowParams } from '../application/maintenance_window/methods/update/types'; import { findMaintenanceWindows } from '../application/maintenance_window/methods/find/find_maintenance_windows'; -import type { FindMaintenanceWindowsResult } from '../application/maintenance_window/methods/find/types'; +import type { + FindMaintenanceWindowsResult, + FindMaintenanceWindowsParams, +} from '../application/maintenance_window/methods/find/types'; import { deleteMaintenanceWindow } from '../application/maintenance_window/methods/delete/delete_maintenance_window'; import type { DeleteMaintenanceWindowParams } from '../application/maintenance_window/methods/delete/types'; import { archiveMaintenanceWindow } from '../application/maintenance_window/methods/archive/archive_maintenance_window'; @@ -75,7 +78,8 @@ export class MaintenanceWindowClient { getMaintenanceWindow(this.context, params); public update = (params: UpdateMaintenanceWindowParams): Promise => updateMaintenanceWindow(this.context, params); - public find = (): Promise => findMaintenanceWindows(this.context); + public find = (params?: FindMaintenanceWindowsParams): Promise => + findMaintenanceWindows(this.context, params); public delete = (params: DeleteMaintenanceWindowParams): Promise<{}> => deleteMaintenanceWindow(this.context, params); public archive = (params: ArchiveMaintenanceWindowParams): Promise => diff --git a/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/find_maintenance_windows_route.test.ts b/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/find_maintenance_windows_route.test.ts index 0a51513f4a08a..99c1cf7b23f6a 100644 --- a/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/find_maintenance_windows_route.test.ts +++ b/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/find_maintenance_windows_route.test.ts @@ -22,6 +22,9 @@ jest.mock('../../../../lib/license_api_access', () => ({ })); const mockMaintenanceWindows = { + page: 1, + perPage: 3, + total: 2, data: [ { ...getMockMaintenanceWindow(), @@ -67,11 +70,54 @@ describe('findMaintenanceWindowsRoute', () => { await handler(context, req, res); - expect(maintenanceWindowClient.find).toHaveBeenCalled(); + expect(maintenanceWindowClient.find).toHaveBeenCalledWith({}); expect(res.ok).toHaveBeenLastCalledWith({ body: { data: mockMaintenanceWindows.data.map((data) => rewriteMaintenanceWindowRes(data)), total: 2, + page: 1, + per_page: 3, + }, + }); + }); + + test('should find the maintenance windows with query', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + findMaintenanceWindowsRoute(router, licenseState); + + maintenanceWindowClient.find.mockResolvedValueOnce(mockMaintenanceWindows); + const [config, handler] = router.get.mock.calls[0]; + const [context, req, res] = mockHandlerArguments( + { maintenanceWindowClient }, + { + query: { + page: 1, + per_page: 3, + }, + } + ); + + expect(config.path).toEqual('/internal/alerting/rules/maintenance_window/_find'); + expect(config.options).toMatchInlineSnapshot(` + Object { + "access": "internal", + "tags": Array [ + "access:read-maintenance-window", + ], + } + `); + + await handler(context, req, res); + + expect(maintenanceWindowClient.find).toHaveBeenCalledWith({ page: 1, perPage: 3 }); + expect(res.ok).toHaveBeenLastCalledWith({ + body: { + data: mockMaintenanceWindows.data.map((data) => rewriteMaintenanceWindowRes(data)), + total: 2, + page: 1, + per_page: 3, }, }); }); diff --git a/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/find_maintenance_windows_route.ts b/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/find_maintenance_windows_route.ts index 7a7fb13160252..1aa4653e3d8d3 100644 --- a/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/find_maintenance_windows_route.ts +++ b/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/find_maintenance_windows_route.ts @@ -15,7 +15,15 @@ import { import { MAINTENANCE_WINDOW_API_PRIVILEGES } from '../../../../../common'; import type { FindMaintenanceWindowsResult } from '../../../../application/maintenance_window/methods/find/types'; import type { FindMaintenanceWindowsResponseV1 } from '../../../../../common/routes/maintenance_window/apis/find'; -import { transformMaintenanceWindowToResponseV1 } from '../../transforms'; +import { + findMaintenanceWindowsRequestQuerySchemaV1, + findMaintenanceWindowsResponseBodySchemaV1, + type FindMaintenanceWindowsRequestQueryV1, +} from '../../../../../common/routes/maintenance_window/apis/find'; +import { + transformFindMaintenanceWindowParamsV1, + transformFindMaintenanceWindowResponseV1, +} from './transforms'; export const findMaintenanceWindowsRoute = ( router: IRouter, @@ -24,7 +32,23 @@ export const findMaintenanceWindowsRoute = ( router.get( { path: `${INTERNAL_ALERTING_API_MAINTENANCE_WINDOW_PATH}/_find`, - validate: {}, + validate: { + request: { + query: findMaintenanceWindowsRequestQuerySchemaV1, + }, + response: { + 200: { + body: () => findMaintenanceWindowsResponseBodySchemaV1, + description: 'Indicates a successful call.', + }, + 400: { + description: 'Indicates an invalid schema or parameters.', + }, + 403: { + description: 'Indicates that this call is forbidden.', + }, + }, + }, options: { access: 'internal', tags: [`access:${MAINTENANCE_WINDOW_API_PRIVILEGES.READ_MAINTENANCE_WINDOW}`], @@ -34,20 +58,17 @@ export const findMaintenanceWindowsRoute = ( verifyAccessAndContext(licenseState, async function (context, req, res) { licenseState.ensureLicenseForMaintenanceWindow(); + const query: FindMaintenanceWindowsRequestQueryV1 = req.query || {}; const maintenanceWindowClient = (await context.alerting).getMaintenanceWindowClient(); - const result: FindMaintenanceWindowsResult = await maintenanceWindowClient.find(); - - const response: FindMaintenanceWindowsResponseV1 = { - body: { - data: result.data.map((maintenanceWindow) => - transformMaintenanceWindowToResponseV1(maintenanceWindow) - ), - total: result.data.length, - }, - }; + const options = transformFindMaintenanceWindowParamsV1(query); + const findResult: FindMaintenanceWindowsResult = await maintenanceWindowClient.find( + options + ); + const responseBody: FindMaintenanceWindowsResponseV1 = + transformFindMaintenanceWindowResponseV1(findResult); - return res.ok(response); + return res.ok({ body: responseBody }); }) ) ); diff --git a/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/transforms/index.ts b/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/transforms/index.ts new file mode 100644 index 0000000000000..43d428f9dd47a --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/transforms/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { transformFindMaintenanceWindowParams } from './transform_find_maintenance_window_params/latest'; +export { transformFindMaintenanceWindowResponse } from './transform_find_maintenance_window_to_response/latest'; + +export { transformFindMaintenanceWindowParams as transformFindMaintenanceWindowParamsV1 } from './transform_find_maintenance_window_params/v1'; +export { transformFindMaintenanceWindowResponse as transformFindMaintenanceWindowResponseV1 } from './transform_find_maintenance_window_to_response/v1'; diff --git a/x-pack/test_serverless/functional/test_suites/security/cypress/screens/index.ts b/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/transforms/transform_find_maintenance_window_params/latest.ts similarity index 88% rename from x-pack/test_serverless/functional/test_suites/security/cypress/screens/index.ts rename to x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/transforms/transform_find_maintenance_window_params/latest.ts index 194bf6301191a..25300c97a6d2e 100644 --- a/x-pack/test_serverless/functional/test_suites/security/cypress/screens/index.ts +++ b/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/transforms/transform_find_maintenance_window_params/latest.ts @@ -5,4 +5,4 @@ * 2.0. */ -export * from './landing_page'; +export * from './v1'; diff --git a/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/transforms/transform_find_maintenance_window_params/v1.ts b/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/transforms/transform_find_maintenance_window_params/v1.ts new file mode 100644 index 0000000000000..c59f5d189716e --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/transforms/transform_find_maintenance_window_params/v1.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 { FindMaintenanceWindowsRequestQuery } from '../../../../../../../common/routes/maintenance_window/apis/find'; +import { FindMaintenanceWindowsParams } from '../../../../../../application/maintenance_window/methods/find/types'; + +export const transformFindMaintenanceWindowParams = ( + params: FindMaintenanceWindowsRequestQuery +): FindMaintenanceWindowsParams => ({ + ...(params.page ? { page: params.page } : {}), + ...(params.per_page ? { perPage: params.per_page } : {}), +}); diff --git a/x-pack/packages/security-solution/common/src/flyout/panels/keys.ts b/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/transforms/transform_find_maintenance_window_to_response/latest.ts similarity index 86% rename from x-pack/packages/security-solution/common/src/flyout/panels/keys.ts rename to x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/transforms/transform_find_maintenance_window_to_response/latest.ts index fe06cf652d016..25300c97a6d2e 100644 --- a/x-pack/packages/security-solution/common/src/flyout/panels/keys.ts +++ b/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/transforms/transform_find_maintenance_window_to_response/latest.ts @@ -5,4 +5,4 @@ * 2.0. */ -export const HOST_PANEL = 'host-panel'; +export * from './v1'; diff --git a/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/transforms/transform_find_maintenance_window_to_response/v1.ts b/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/transforms/transform_find_maintenance_window_to_response/v1.ts new file mode 100644 index 0000000000000..9110914a998ca --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/transforms/transform_find_maintenance_window_to_response/v1.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { transformMaintenanceWindowToResponseV1 } from '../../../../transforms'; +import type { FindMaintenanceWindowsResponseV1 } from '../../../../../../../common/routes/maintenance_window/apis/find'; +import type { MaintenanceWindow } from '../../../../../../application/maintenance_window/types'; +import type { FindMaintenanceWindowsResult } from '../../../../../../application/maintenance_window/methods/find/types'; + +export const transformFindMaintenanceWindowResponse = ( + result: FindMaintenanceWindowsResult +): FindMaintenanceWindowsResponseV1 => { + return { + page: result.page, + per_page: result.perPage, + total: result.total, + data: result.data.map((maintenanceWindow: MaintenanceWindow) => + transformMaintenanceWindowToResponseV1(maintenanceWindow) + ), + }; +}; diff --git a/x-pack/plugins/alerting/server/saved_objects/index.ts b/x-pack/plugins/alerting/server/saved_objects/index.ts index 8e76f28ff7fb8..8f11020ee6285 100644 --- a/x-pack/plugins/alerting/server/saved_objects/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/index.ts @@ -217,6 +217,11 @@ export function setupSavedObjects( // Encrypted attributes encryptedSavedObjects.registerType({ type: RULE_SAVED_OBJECT_TYPE, + /** + * We disable enforcing random SO IDs for the rule SO + * to allow users creating rules with a predefined ID. + */ + enforceRandomId: false, attributesToEncrypt: new Set(RuleAttributesToEncrypt), attributesToIncludeInAAD: new Set(RuleAttributesIncludedInAAD), }); diff --git a/x-pack/plugins/banners/kibana.jsonc b/x-pack/plugins/banners/kibana.jsonc index 75d275a6bde4a..67a5f3b1b79d0 100644 --- a/x-pack/plugins/banners/kibana.jsonc +++ b/x-pack/plugins/banners/kibana.jsonc @@ -1,22 +1,26 @@ { "type": "plugin", "id": "@kbn/banners-plugin", - "owner": "@elastic/appex-sharedux", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "private", "plugin": { "id": "banners", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "banners" ], - "enabledOnAnonymousPages": true, "requiredPlugins": [ "licensing" ], "optionalPlugins": [ "screenshotMode" ], - "requiredBundles": [] + "requiredBundles": [], + "enabledOnAnonymousPages": true } -} +} \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/partition_labels/extended_template.tsx b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/partition_labels/extended_template.tsx index 8884533fb7ce3..1be1d50824cfd 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/partition_labels/extended_template.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/partition_labels/extended_template.tsx @@ -116,7 +116,7 @@ export const ExtendedTemplate: FunctionComponent = ({ onValueChange, argV /> - + ({ - repositionedElements, -})); +export const setMultiplePositions = createAction( + 'setMultiplePositions', + (repositionedElements) => ({ + repositionedElements, + }) +); export const flushContext = createAction('flushContext'); export const flushContextAfterIndex = createAction('flushContextAfterIndex'); diff --git a/x-pack/plugins/cases/common/schema/index.test.ts b/x-pack/plugins/cases/common/schema/index.test.ts index ae1146b594dbb..64eb2ad393fcb 100644 --- a/x-pack/plugins/cases/common/schema/index.test.ts +++ b/x-pack/plugins/cases/common/schema/index.test.ts @@ -13,6 +13,7 @@ import { limitedStringSchema, NonEmptyString, paginationSchema, + limitedNumberAsIntegerSchema, } from '.'; import { MAX_DOCS_PER_PAGE } from '../constants'; @@ -319,4 +320,69 @@ describe('schema', () => { `); }); }); + + describe('limitedNumberAsIntegerSchema', () => { + it('works correctly the number is safe integer', () => { + expect(PathReporter.report(limitedNumberAsIntegerSchema({ fieldName: 'foo' }).decode(1))) + .toMatchInlineSnapshot(` + Array [ + "No errors!", + ] + `); + }); + + it('fails when given a number that is lower than the minimum', () => { + expect( + PathReporter.report( + limitedNumberAsIntegerSchema({ fieldName: 'foo' }).decode(Number.MIN_SAFE_INTEGER - 1) + ) + ).toMatchInlineSnapshot(` + Array [ + "The foo field should be an integer between -(2^53 - 1) and 2^53 - 1, inclusive.", + ] + `); + }); + + it('fails when given a number that is higher than the maximum', () => { + expect( + PathReporter.report( + limitedNumberAsIntegerSchema({ fieldName: 'foo' }).decode(Number.MAX_SAFE_INTEGER + 1) + ) + ).toMatchInlineSnapshot(` + Array [ + "The foo field should be an integer between -(2^53 - 1) and 2^53 - 1, inclusive.", + ] + `); + }); + + it('fails when given a null instead of a number', () => { + expect(PathReporter.report(limitedNumberAsIntegerSchema({ fieldName: 'foo' }).decode(null))) + .toMatchInlineSnapshot(` + Array [ + "Invalid value null supplied to : LimitedNumberAsInteger", + ] + `); + }); + + it('fails when given a string instead of a number', () => { + expect( + PathReporter.report( + limitedNumberAsIntegerSchema({ fieldName: 'foo' }).decode('some string') + ) + ).toMatchInlineSnapshot(` + Array [ + "Invalid value \\"some string\\" supplied to : LimitedNumberAsInteger", + ] + `); + }); + + it('fails when given a float number instead of an safe integer number', () => { + expect(PathReporter.report(limitedNumberAsIntegerSchema({ fieldName: 'foo' }).decode(1.2))) + .toMatchInlineSnapshot(` + Array [ + "The foo field should be an integer between -(2^53 - 1) and 2^53 - 1, inclusive.", + ] + `); + }); + }); }); diff --git a/x-pack/plugins/cases/common/schema/index.ts b/x-pack/plugins/cases/common/schema/index.ts index b38d499c8c04c..0bcbdcfb2c480 100644 --- a/x-pack/plugins/cases/common/schema/index.ts +++ b/x-pack/plugins/cases/common/schema/index.ts @@ -154,6 +154,24 @@ export const limitedNumberSchema = ({ fieldName, min, max }: LimitedSchemaType) rt.identity ); +export const limitedNumberAsIntegerSchema = ({ fieldName }: { fieldName: string }) => + new rt.Type( + 'LimitedNumberAsInteger', + rt.number.is, + (input, context) => + either.chain(rt.number.validate(input, context), (s) => { + if (!Number.isSafeInteger(s)) { + return rt.failure( + input, + context, + `The ${fieldName} field should be an integer between -(2^53 - 1) and 2^53 - 1, inclusive.` + ); + } + return rt.success(s); + }), + rt.identity + ); + export interface RegexStringSchemaType { codec: rt.Type; pattern: string; diff --git a/x-pack/plugins/cases/common/types/api/case/v1.test.ts b/x-pack/plugins/cases/common/types/api/case/v1.test.ts index a509bdee36525..baf9626d3562e 100644 --- a/x-pack/plugins/cases/common/types/api/case/v1.test.ts +++ b/x-pack/plugins/cases/common/types/api/case/v1.test.ts @@ -114,10 +114,15 @@ const basicCase: Case = { value: true, }, { - key: 'second_custom_field_key', + key: 'third_custom_field_key', type: CustomFieldTypes.TEXT, value: 'www.example.com', }, + { + key: 'fourth_custom_field_key', + type: CustomFieldTypes.NUMBER, + value: 3, + }, ], }; @@ -149,6 +154,11 @@ describe('CasePostRequestRt', () => { type: CustomFieldTypes.TOGGLE, value: true, }, + { + key: 'third_custom_field_key', + type: CustomFieldTypes.NUMBER, + value: 3, + }, ], }; @@ -322,6 +332,44 @@ describe('CasePostRequestRt', () => { ); }); + it(`throws an error when a number customFields is more than ${Number.MAX_SAFE_INTEGER}`, () => { + expect( + PathReporter.report( + CasePostRequestRt.decode({ + ...defaultRequest, + customFields: [ + { + key: 'first_custom_field_key', + type: CustomFieldTypes.NUMBER, + value: Number.MAX_SAFE_INTEGER + 1, + }, + ], + }) + ) + ).toContain( + `The value field should be an integer between -(2^53 - 1) and 2^53 - 1, inclusive.` + ); + }); + + it(`throws an error when a number customFields is less than ${Number.MIN_SAFE_INTEGER}`, () => { + expect( + PathReporter.report( + CasePostRequestRt.decode({ + ...defaultRequest, + customFields: [ + { + key: 'first_custom_field_key', + type: CustomFieldTypes.NUMBER, + value: Number.MIN_SAFE_INTEGER - 1, + }, + ], + }) + ) + ).toContain( + `The value field should be an integer between -(2^53 - 1) and 2^53 - 1, inclusive.` + ); + }); + it('throws an error when a text customField is an empty string', () => { expect( PathReporter.report( @@ -665,6 +713,11 @@ describe('CasePatchRequestRt', () => { type: 'toggle', value: true, }, + { + key: 'third_custom_field_key', + type: 'number', + value: 123, + }, ], }; diff --git a/x-pack/plugins/cases/common/types/api/case/v1.ts b/x-pack/plugins/cases/common/types/api/case/v1.ts index 7a45f92fa4668..f66df68169e5b 100644 --- a/x-pack/plugins/cases/common/types/api/case/v1.ts +++ b/x-pack/plugins/cases/common/types/api/case/v1.ts @@ -29,7 +29,11 @@ import { NonEmptyString, paginationSchema, } from '../../../schema'; -import { CaseCustomFieldToggleRt, CustomFieldTextTypeRt } from '../../domain'; +import { + CaseCustomFieldToggleRt, + CustomFieldTextTypeRt, + CustomFieldNumberTypeRt, +} from '../../domain'; import { CaseRt, CaseSettingsRt, @@ -41,7 +45,10 @@ import { import { CaseConnectorRt } from '../../domain/connector/v1'; import { CaseUserProfileRt, UserRt } from '../../domain/user/v1'; import { CasesStatusResponseRt } from '../stats/v1'; -import { CaseCustomFieldTextWithValidationValueRt } from '../custom_field/v1'; +import { + CaseCustomFieldTextWithValidationValueRt, + CaseCustomFieldNumberWithValidationValueRt, +} from '../custom_field/v1'; const CaseCustomFieldTextWithValidationRt = rt.strict({ key: rt.string, @@ -49,7 +56,17 @@ const CaseCustomFieldTextWithValidationRt = rt.strict({ value: rt.union([CaseCustomFieldTextWithValidationValueRt('value'), rt.null]), }); -const CustomFieldRt = rt.union([CaseCustomFieldTextWithValidationRt, CaseCustomFieldToggleRt]); +const CaseCustomFieldNumberWithValidationRt = rt.strict({ + key: rt.string, + type: CustomFieldNumberTypeRt, + value: rt.union([CaseCustomFieldNumberWithValidationValueRt({ fieldName: 'value' }), rt.null]), +}); + +const CustomFieldRt = rt.union([ + CaseCustomFieldTextWithValidationRt, + CaseCustomFieldToggleRt, + CaseCustomFieldNumberWithValidationRt, +]); export const CaseRequestCustomFieldsRt = limitedArraySchema({ codec: CustomFieldRt, diff --git a/x-pack/plugins/cases/common/types/api/configure/v1.test.ts b/x-pack/plugins/cases/common/types/api/configure/v1.test.ts index c16dfbc60eaf7..64baf7b2e46f4 100644 --- a/x-pack/plugins/cases/common/types/api/configure/v1.test.ts +++ b/x-pack/plugins/cases/common/types/api/configure/v1.test.ts @@ -36,6 +36,7 @@ import { CustomFieldConfigurationWithoutTypeRt, TextCustomFieldConfigurationRt, ToggleCustomFieldConfigurationRt, + NumberCustomFieldConfigurationRt, TemplateConfigurationRt, } from './v1'; @@ -79,6 +80,12 @@ describe('configure', () => { type: CustomFieldTypes.TOGGLE, required: false, }, + { + key: 'number_custom_field', + label: 'Number custom field', + type: CustomFieldTypes.NUMBER, + required: false, + }, ], }; const query = ConfigurationRequestRt.decode(request); @@ -512,6 +519,93 @@ describe('configure', () => { }); }); + describe('NumberCustomFieldConfigurationRt', () => { + const defaultRequest = { + key: 'my_number_custom_field', + label: 'Number Custom Field', + type: CustomFieldTypes.NUMBER, + required: true, + }; + + it('has expected attributes in request', () => { + const query = NumberCustomFieldConfigurationRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('has expected attributes in request with defaultValue', () => { + const query = NumberCustomFieldConfigurationRt.decode({ + ...defaultRequest, + defaultValue: 1, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, defaultValue: 1 }, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = NumberCustomFieldConfigurationRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('defaultValue fails if the type is string', () => { + expect( + PathReporter.report( + NumberCustomFieldConfigurationRt.decode({ + ...defaultRequest, + defaultValue: 'string', + }) + )[0] + ).toContain('Invalid value "string" supplied'); + }); + + it('defaultValue fails if the type is boolean', () => { + expect( + PathReporter.report( + NumberCustomFieldConfigurationRt.decode({ + ...defaultRequest, + defaultValue: false, + }) + )[0] + ).toContain('Invalid value false supplied'); + }); + + it(`throws an error if the default value is more than ${Number.MAX_SAFE_INTEGER}`, () => { + expect( + PathReporter.report( + NumberCustomFieldConfigurationRt.decode({ + ...defaultRequest, + defaultValue: Number.MAX_SAFE_INTEGER + 1, + }) + )[0] + ).toContain( + 'The defaultValue field should be an integer between -(2^53 - 1) and 2^53 - 1, inclusive.' + ); + }); + + it(`throws an error if the default value is less than ${Number.MIN_SAFE_INTEGER}`, () => { + expect( + PathReporter.report( + NumberCustomFieldConfigurationRt.decode({ + ...defaultRequest, + defaultValue: Number.MIN_SAFE_INTEGER - 1, + }) + )[0] + ).toContain( + 'The defaultValue field should be an integer between -(2^53 - 1) and 2^53 - 1, inclusive.' + ); + }); + }); + describe('TemplateConfigurationRt', () => { const defaultRequest = { key: 'template_key_1', diff --git a/x-pack/plugins/cases/common/types/api/configure/v1.ts b/x-pack/plugins/cases/common/types/api/configure/v1.ts index bd2e1f5c11af0..52843da1ac1ad 100644 --- a/x-pack/plugins/cases/common/types/api/configure/v1.ts +++ b/x-pack/plugins/cases/common/types/api/configure/v1.ts @@ -18,12 +18,19 @@ import { MAX_TEMPLATE_TAG_LENGTH, } from '../../../constants'; import { limitedArraySchema, limitedStringSchema, regexStringRt } from '../../../schema'; -import { CustomFieldTextTypeRt, CustomFieldToggleTypeRt } from '../../domain'; +import { + CustomFieldTextTypeRt, + CustomFieldToggleTypeRt, + CustomFieldNumberTypeRt, +} from '../../domain'; import type { Configurations, Configuration } from '../../domain/configure/v1'; import { ConfigurationBasicWithoutOwnerRt, ClosureTypeRt } from '../../domain/configure/v1'; import { CaseConnectorRt } from '../../domain/connector/v1'; import { CaseBaseOptionalFieldsRequestRt } from '../case/v1'; -import { CaseCustomFieldTextWithValidationValueRt } from '../custom_field/v1'; +import { + CaseCustomFieldTextWithValidationValueRt, + CaseCustomFieldNumberWithValidationValueRt, +} from '../custom_field/v1'; export const CustomFieldConfigurationWithoutTypeRt = rt.strict({ /** @@ -64,8 +71,25 @@ export const ToggleCustomFieldConfigurationRt = rt.intersection([ ), ]); +export const NumberCustomFieldConfigurationRt = rt.intersection([ + rt.strict({ type: CustomFieldNumberTypeRt }), + CustomFieldConfigurationWithoutTypeRt, + rt.exact( + rt.partial({ + defaultValue: rt.union([ + CaseCustomFieldNumberWithValidationValueRt({ fieldName: 'defaultValue' }), + rt.null, + ]), + }) + ), +]); + export const CustomFieldsConfigurationRt = limitedArraySchema({ - codec: rt.union([TextCustomFieldConfigurationRt, ToggleCustomFieldConfigurationRt]), + codec: rt.union([ + TextCustomFieldConfigurationRt, + ToggleCustomFieldConfigurationRt, + NumberCustomFieldConfigurationRt, + ]), min: 0, max: MAX_CUSTOM_FIELDS_PER_CASE, fieldName: 'customFields', diff --git a/x-pack/plugins/cases/common/types/api/custom_field/v1.test.ts b/x-pack/plugins/cases/common/types/api/custom_field/v1.test.ts index 83d9a437c998d..d17c936ff4463 100644 --- a/x-pack/plugins/cases/common/types/api/custom_field/v1.test.ts +++ b/x-pack/plugins/cases/common/types/api/custom_field/v1.test.ts @@ -7,7 +7,11 @@ import { PathReporter } from 'io-ts/lib/PathReporter'; import { MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH } from '../../../constants'; -import { CaseCustomFieldTextWithValidationValueRt, CustomFieldPutRequestRt } from './v1'; +import { + CaseCustomFieldTextWithValidationValueRt, + CustomFieldPutRequestRt, + CaseCustomFieldNumberWithValidationValueRt, +} from './v1'; describe('Custom Fields', () => { describe('CaseCustomFieldTextWithValidationValueRt', () => { @@ -100,4 +104,34 @@ describe('Custom Fields', () => { ).toContain('The value field cannot be an empty string.'); }); }); + + describe('CaseCustomFieldNumberWithValidationValueRt', () => { + const numberCustomFieldValueType = CaseCustomFieldNumberWithValidationValueRt({ + fieldName: 'value', + }); + it('should decode number correctly', () => { + const query = numberCustomFieldValueType.decode(123); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: 123, + }); + }); + + it('should not be more than Number.MAX_SAFE_INTEGER', () => { + expect( + PathReporter.report(numberCustomFieldValueType.decode(Number.MAX_SAFE_INTEGER + 1))[0] + ).toContain( + 'The value field should be an integer between -(2^53 - 1) and 2^53 - 1, inclusive.' + ); + }); + + it('should not be less than Number.MIN_SAFE_INTEGER', () => { + expect( + PathReporter.report(numberCustomFieldValueType.decode(Number.MIN_SAFE_INTEGER - 1))[0] + ).toContain( + 'The value field should be an integer between -(2^53 - 1) and 2^53 - 1, inclusive.' + ); + }); + }); }); diff --git a/x-pack/plugins/cases/common/types/api/custom_field/v1.ts b/x-pack/plugins/cases/common/types/api/custom_field/v1.ts index fb59f187991b3..c3e618278adbe 100644 --- a/x-pack/plugins/cases/common/types/api/custom_field/v1.ts +++ b/x-pack/plugins/cases/common/types/api/custom_field/v1.ts @@ -7,7 +7,7 @@ import * as rt from 'io-ts'; import { MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH } from '../../../constants'; -import { limitedStringSchema } from '../../../schema'; +import { limitedStringSchema, limitedNumberAsIntegerSchema } from '../../../schema'; export const CaseCustomFieldTextWithValidationValueRt = (fieldName: string) => limitedStringSchema({ @@ -16,12 +16,22 @@ export const CaseCustomFieldTextWithValidationValueRt = (fieldName: string) => max: MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH, }); +export const CaseCustomFieldNumberWithValidationValueRt = ({ fieldName }: { fieldName: string }) => + limitedNumberAsIntegerSchema({ + fieldName, + }); + /** * Update custom_field */ export const CustomFieldPutRequestRt = rt.strict({ - value: rt.union([rt.boolean, rt.null, CaseCustomFieldTextWithValidationValueRt('value')]), + value: rt.union([ + rt.boolean, + rt.null, + CaseCustomFieldTextWithValidationValueRt('value'), + CaseCustomFieldNumberWithValidationValueRt({ fieldName: 'value' }), + ]), caseVersion: rt.string, }); diff --git a/x-pack/plugins/cases/common/types/domain/case/v1.test.ts b/x-pack/plugins/cases/common/types/domain/case/v1.test.ts index 267e08d205f15..b0a6f96bcacd0 100644 --- a/x-pack/plugins/cases/common/types/domain/case/v1.test.ts +++ b/x-pack/plugins/cases/common/types/domain/case/v1.test.ts @@ -85,6 +85,11 @@ const basicCase = { type: 'toggle', value: true, }, + { + key: 'third_custom_field_key', + type: 'number', + value: 0, + }, ], }; @@ -193,6 +198,11 @@ describe('CaseAttributesRt', () => { type: 'toggle', value: true, }, + { + key: 'third_custom_field_key', + type: 'number', + value: 0, + }, ], }; diff --git a/x-pack/plugins/cases/common/types/domain/configure/v1.test.ts b/x-pack/plugins/cases/common/types/domain/configure/v1.test.ts index 13637fb4d8c68..59682de1e7c7a 100644 --- a/x-pack/plugins/cases/common/types/domain/configure/v1.test.ts +++ b/x-pack/plugins/cases/common/types/domain/configure/v1.test.ts @@ -16,6 +16,7 @@ import { TemplateConfigurationRt, TextCustomFieldConfigurationRt, ToggleCustomFieldConfigurationRt, + NumberCustomFieldConfigurationRt, } from './v1'; describe('configure', () => { @@ -47,6 +48,13 @@ describe('configure', () => { required: false, }; + const numberCustomField = { + key: 'number_custom_field', + label: 'Number custom field', + type: CustomFieldTypes.NUMBER, + required: false, + }; + const templateWithAllCaseFields = { key: 'template_sample_1', name: 'Sample template 1', @@ -98,7 +106,7 @@ describe('configure', () => { const defaultRequest = { connector: resilient, closure_type: 'close-by-user', - customFields: [textCustomField, toggleCustomField], + customFields: [textCustomField, toggleCustomField, numberCustomField], templates: [], owner: 'cases', created_at: '2020-02-19T23:06:33.798Z', @@ -122,7 +130,7 @@ describe('configure', () => { _tag: 'Right', right: { ...defaultRequest, - customFields: [textCustomField, toggleCustomField], + customFields: [textCustomField, toggleCustomField, numberCustomField], }, }); }); @@ -134,7 +142,7 @@ describe('configure', () => { _tag: 'Right', right: { ...defaultRequest, - customFields: [textCustomField, toggleCustomField], + customFields: [textCustomField, toggleCustomField, numberCustomField], }, }); }); @@ -142,14 +150,14 @@ describe('configure', () => { it('removes foo:bar attributes from custom fields', () => { const query = ConfigurationAttributesRt.decode({ ...defaultRequest, - customFields: [{ ...textCustomField, foo: 'bar' }, toggleCustomField], + customFields: [{ ...textCustomField, foo: 'bar' }, toggleCustomField, numberCustomField], }); expect(query).toStrictEqual({ _tag: 'Right', right: { ...defaultRequest, - customFields: [textCustomField, toggleCustomField], + customFields: [textCustomField, toggleCustomField, numberCustomField], }, }); }); @@ -351,6 +359,62 @@ describe('configure', () => { }); }); + describe('NumberCustomFieldConfigurationRt', () => { + const defaultRequest = { + key: 'my_number_custom_field', + label: 'Number Custom Field', + type: CustomFieldTypes.NUMBER, + required: false, + }; + + it('has expected attributes in request with required: false', () => { + const query = NumberCustomFieldConfigurationRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('has expected attributes in request with defaultValue and required: true', () => { + const query = NumberCustomFieldConfigurationRt.decode({ + ...defaultRequest, + required: true, + defaultValue: 0, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { + ...defaultRequest, + required: true, + defaultValue: 0, + }, + }); + }); + + it('defaultValue fails if the type is not number', () => { + expect( + PathReporter.report( + NumberCustomFieldConfigurationRt.decode({ + ...defaultRequest, + required: true, + defaultValue: 'foobar', + }) + )[0] + ).toContain('Invalid value "foobar" supplied'); + }); + + it('removes foo:bar attributes from request', () => { + const query = NumberCustomFieldConfigurationRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + }); + describe('TemplateConfigurationRt', () => { const defaultRequest = templateWithAllCaseFields; diff --git a/x-pack/plugins/cases/common/types/domain/configure/v1.ts b/x-pack/plugins/cases/common/types/domain/configure/v1.ts index 1e4e30c95e381..17760922d2cda 100644 --- a/x-pack/plugins/cases/common/types/domain/configure/v1.ts +++ b/x-pack/plugins/cases/common/types/domain/configure/v1.ts @@ -8,7 +8,11 @@ import * as rt from 'io-ts'; import { CaseConnectorRt, ConnectorMappingsRt } from '../connector/v1'; import { UserRt } from '../user/v1'; -import { CustomFieldTextTypeRt, CustomFieldToggleTypeRt } from '../custom_field/v1'; +import { + CustomFieldTextTypeRt, + CustomFieldToggleTypeRt, + CustomFieldNumberTypeRt, +} from '../custom_field/v1'; import { CaseBaseOptionalFieldsRt } from '../case/v1'; export const ClosureTypeRt = rt.union([ @@ -51,9 +55,20 @@ export const ToggleCustomFieldConfigurationRt = rt.intersection([ ), ]); +export const NumberCustomFieldConfigurationRt = rt.intersection([ + rt.strict({ type: CustomFieldNumberTypeRt }), + CustomFieldConfigurationWithoutTypeRt, + rt.exact( + rt.partial({ + defaultValue: rt.union([rt.number, rt.null]), + }) + ), +]); + export const CustomFieldConfigurationRt = rt.union([ TextCustomFieldConfigurationRt, ToggleCustomFieldConfigurationRt, + NumberCustomFieldConfigurationRt, ]); export const CustomFieldsConfigurationRt = rt.array(CustomFieldConfigurationRt); diff --git a/x-pack/plugins/cases/common/types/domain/custom_field/v1.test.ts b/x-pack/plugins/cases/common/types/domain/custom_field/v1.test.ts index ea57d3e3201c1..5513325d30fb0 100644 --- a/x-pack/plugins/cases/common/types/domain/custom_field/v1.test.ts +++ b/x-pack/plugins/cases/common/types/domain/custom_field/v1.test.ts @@ -42,6 +42,22 @@ describe('CaseCustomFieldRt', () => { value: null, }, ], + [ + 'type number value number', + { + key: 'number_custom_field_1', + type: 'number', + value: 1, + }, + ], + [ + 'type number value null', + { + key: 'number_custom_field_2', + type: 'number', + value: null, + }, + ], ])(`has expected attributes for customField with %s`, (_, customField) => { const query = CaseCustomFieldRt.decode(customField); @@ -70,4 +86,14 @@ describe('CaseCustomFieldRt', () => { expect(PathReporter.report(query)[0]).toContain('Invalid value "hello" supplied'); }); + + it('fails if number type but value is a string', () => { + const query = CaseCustomFieldRt.decode({ + key: 'list_custom_field_1', + type: 'number', + value: 'hi', + }); + + expect(PathReporter.report(query)[0]).toContain('Invalid value "hi" supplied'); + }); }); diff --git a/x-pack/plugins/cases/common/types/domain/custom_field/v1.ts b/x-pack/plugins/cases/common/types/domain/custom_field/v1.ts index 4878fea326b04..d0f9404f8f113 100644 --- a/x-pack/plugins/cases/common/types/domain/custom_field/v1.ts +++ b/x-pack/plugins/cases/common/types/domain/custom_field/v1.ts @@ -9,10 +9,12 @@ import * as rt from 'io-ts'; export enum CustomFieldTypes { TEXT = 'text', TOGGLE = 'toggle', + NUMBER = 'number', } export const CustomFieldTextTypeRt = rt.literal(CustomFieldTypes.TEXT); export const CustomFieldToggleTypeRt = rt.literal(CustomFieldTypes.TOGGLE); +export const CustomFieldNumberTypeRt = rt.literal(CustomFieldTypes.NUMBER); const CaseCustomFieldTextRt = rt.strict({ key: rt.string, @@ -26,10 +28,21 @@ export const CaseCustomFieldToggleRt = rt.strict({ value: rt.union([rt.boolean, rt.null]), }); -export const CaseCustomFieldRt = rt.union([CaseCustomFieldTextRt, CaseCustomFieldToggleRt]); +export const CaseCustomFieldNumberRt = rt.strict({ + key: rt.string, + type: CustomFieldNumberTypeRt, + value: rt.union([rt.number, rt.null]), +}); + +export const CaseCustomFieldRt = rt.union([ + CaseCustomFieldTextRt, + CaseCustomFieldToggleRt, + CaseCustomFieldNumberRt, +]); export const CaseCustomFieldsRt = rt.array(CaseCustomFieldRt); export type CaseCustomFields = rt.TypeOf; export type CaseCustomField = rt.TypeOf; export type CaseCustomFieldToggle = rt.TypeOf; export type CaseCustomFieldText = rt.TypeOf; +export type CaseCustomFieldNumber = rt.TypeOf; diff --git a/x-pack/plugins/cases/kibana.jsonc b/x-pack/plugins/cases/kibana.jsonc index 300b1ee4c2c12..e5dafa52a3a11 100644 --- a/x-pack/plugins/cases/kibana.jsonc +++ b/x-pack/plugins/cases/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/cases-plugin", - "owner": "@elastic/response-ops", + "owner": [ + "@elastic/response-ops" + ], + "group": "platform", + "visibility": "shared", "description": "The Case management system in Kibana", "plugin": { "id": "cases", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "cases" @@ -27,7 +31,7 @@ "ruleRegistry", "files", "contentManagement", - "uiActions", + "uiActions" ], "optionalPlugins": [ "cloud", @@ -35,16 +39,16 @@ "taskManager", "usageCollection", "spaces", - "serverless", + "serverless" ], "requiredBundles": [ "esUiShared", "kibanaReact", "kibanaUtils", - "savedObjectsFinder", + "savedObjectsFinder" ], "extraPublicDirs": [ "common" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/cases/public/common/translations.ts b/x-pack/plugins/cases/public/common/translations.ts index 2e11c3a64caae..7fa5b54db00ec 100644 --- a/x-pack/plugins/cases/public/common/translations.ts +++ b/x-pack/plugins/cases/public/common/translations.ts @@ -300,6 +300,12 @@ export const MAX_LENGTH_ERROR = (field: string, length: number) => 'The length of the {field} is too long. The maximum length is {length} characters.', }); +export const SAFE_INTEGER_NUMBER_ERROR = (field: string) => + i18n.translate('xpack.cases.customFields.safeIntegerNumberError', { + values: { field }, + defaultMessage: `The value of the {field} should be an integer between -(2^53 - 1) and 2^53 - 1, inclusive.`, + }); + export const MAX_TAGS_ERROR = (length: number) => i18n.translate('xpack.cases.createCase.maxTagsError', { values: { length }, diff --git a/x-pack/plugins/cases/public/components/add_comment/index.test.tsx b/x-pack/plugins/cases/public/components/add_comment/index.test.tsx index 68cf0c8a1e2b5..5664151aa6df0 100644 --- a/x-pack/plugins/cases/public/components/add_comment/index.test.tsx +++ b/x-pack/plugins/cases/public/components/add_comment/index.test.tsx @@ -21,12 +21,13 @@ import { CasesTimelineIntegrationProvider } from '../timeline_context'; import { timelineIntegrationMock } from '../__mock__/timeline'; import type { CaseAttachmentWithoutOwner } from '../../types'; import type { AppMockRenderer } from '../../common/mock'; +import { useCreateAttachments } from '../../containers/use_create_attachments'; -jest.mock('../../containers/api', () => ({ - createAttachments: jest.fn(), -})); +jest.mock('../../containers/use_create_attachments'); -const createAttachmentsMock = createAttachments as jest.Mock; +const useCreateAttachmentsMock = useCreateAttachments as jest.Mock; + +const createAttachmentsMock = jest.fn().mockImplementation(() => defaultResponse); const onCommentSaving = jest.fn(); const onCommentPosted = jest.fn(); @@ -58,7 +59,10 @@ describe('AddComment ', () => { beforeEach(() => { jest.clearAllMocks(); appMockRender = createAppMockRenderer(); - createAttachmentsMock.mockImplementation(() => defaultResponse); + useCreateAttachmentsMock.mockReturnValue({ + isLoading: false, + mutate: createAttachmentsMock, + }); }); afterEach(() => { @@ -72,6 +76,11 @@ describe('AddComment ', () => { }); it('should render spinner and disable submit when loading', async () => { + useCreateAttachmentsMock.mockReturnValue({ + isLoading: true, + mutateAsync: createAttachmentsMock, + }); + appMockRender.render(); fireEvent.change(screen.getByLabelText('caseComment'), { @@ -109,16 +118,19 @@ describe('AddComment ', () => { await waitFor(() => expect(onCommentSaving).toBeCalled()); await waitFor(() => - expect(createAttachmentsMock).toBeCalledWith({ - caseId: addCommentProps.caseId, - attachments: [ - { - comment: sampleData.comment, - owner: SECURITY_SOLUTION_OWNER, - type: AttachmentType.user, - }, - ], - }) + expect(createAttachmentsMock).toBeCalledWith( + { + caseId: addCommentProps.caseId, + attachments: [ + { + comment: sampleData.comment, + type: AttachmentType.user, + }, + ], + caseOwner: SECURITY_SOLUTION_OWNER, + }, + { onSuccess: expect.any(Function) } + ) ); await waitFor(() => { expect(screen.getByTestId('euiMarkdownEditorTextArea')).toHaveTextContent(''); @@ -258,16 +270,19 @@ describe('draft comment ', () => { await waitFor(() => { expect(onCommentSaving).toBeCalled(); - expect(createAttachmentsMock).toBeCalledWith({ - caseId: addCommentProps.caseId, - attachments: [ - { - comment: sampleData.comment, - owner: SECURITY_SOLUTION_OWNER, - type: AttachmentType.user, - }, - ], - }); + expect(createAttachmentsMock).toBeCalledWith( + { + caseId: addCommentProps.caseId, + attachments: [ + { + comment: sampleData.comment, + type: AttachmentType.user, + }, + ], + caseOwner: SECURITY_SOLUTION_OWNER, + }, + { onSuccess: expect.any(Function) } + ); }); await waitFor(() => { diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx index b0c57d0cc3dd5..bc540040cce57 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx @@ -92,7 +92,8 @@ const mockKibana = () => { } as unknown as ReturnType); }; -describe('AllCasesListGeneric', () => { +// FLAKY: https://github.com/elastic/kibana/issues/192739 +describe.skip('AllCasesListGeneric', () => { const onRowClick = jest.fn(); const updateCaseProperty = jest.fn(); diff --git a/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.test.tsx b/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.test.tsx index f11e5826ca91c..9a96b0a342771 100644 --- a/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.test.tsx @@ -78,7 +78,7 @@ describe.skip('CustomFields', () => { ); - expect(await screen.findAllByTestId('form-optional-field-label')).toHaveLength(2); + expect(await screen.findAllByTestId('form-optional-field-label')).toHaveLength(4); }); it('should not set default value when in edit mode', async () => { @@ -115,12 +115,14 @@ describe.skip('CustomFields', () => { const customFields = customFieldsWrapper.querySelectorAll('.euiFormRow'); - expect(customFields).toHaveLength(4); + expect(customFields).toHaveLength(6); expect(customFields[0]).toHaveTextContent('My test label 1'); expect(customFields[1]).toHaveTextContent('My test label 2'); expect(customFields[2]).toHaveTextContent('My test label 3'); expect(customFields[3]).toHaveTextContent('My test label 4'); + expect(customFields[4]).toHaveTextContent('My test label 5'); + expect(customFields[5]).toHaveTextContent('My test label 6'); }); it('should update the custom fields', async () => { @@ -132,6 +134,7 @@ describe.skip('CustomFields', () => { const textField = customFieldsConfigurationMock[2]; const toggleField = customFieldsConfigurationMock[3]; + const numberField = customFieldsConfigurationMock[5]; await userEvent.type( await screen.findByTestId(`${textField.key}-${textField.type}-create-custom-field`), @@ -140,6 +143,10 @@ describe.skip('CustomFields', () => { await userEvent.click( await screen.findByTestId(`${toggleField.key}-${toggleField.type}-create-custom-field`) ); + await userEvent.type( + await screen.findByTestId(`${numberField.key}-${numberField.type}-create-custom-field`), + '4' + ); await userEvent.click(await screen.findByText('Submit')); @@ -152,6 +159,8 @@ describe.skip('CustomFields', () => { [customFieldsConfigurationMock[1].key]: customFieldsConfigurationMock[1].defaultValue, [textField.key]: 'hello', [toggleField.key]: true, + [customFieldsConfigurationMock[4].key]: customFieldsConfigurationMock[4].defaultValue, + [numberField.key]: '4', }, }, true diff --git a/x-pack/plugins/cases/public/components/case_form_fields/index.test.tsx b/x-pack/plugins/cases/public/components/case_form_fields/index.test.tsx index ac162e41a47e4..438b0a24841e9 100644 --- a/x-pack/plugins/cases/public/components/case_form_fields/index.test.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/index.test.tsx @@ -206,6 +206,7 @@ describe('CaseFormFields', () => { const textField = customFieldsConfigurationMock[0]; const toggleField = customFieldsConfigurationMock[1]; + const numberField = customFieldsConfigurationMock[4]; const textCustomField = await screen.findByTestId( `${textField.key}-${textField.type}-create-custom-field` @@ -219,6 +220,13 @@ describe('CaseFormFields', () => { await screen.findByTestId(`${toggleField.key}-${toggleField.type}-create-custom-field`) ); + const numberCustomField = await screen.findByTestId( + `${numberField.key}-${numberField.type}-create-custom-field` + ); + + await user.clear(numberCustomField); + await user.paste('4321'); + await user.click(await screen.findByText('Submit')); await waitFor(() => { @@ -230,6 +238,7 @@ describe('CaseFormFields', () => { test_key_1: 'My text test value 1', test_key_2: false, test_key_4: false, + test_key_5: '4321', }, }, true @@ -268,6 +277,7 @@ describe('CaseFormFields', () => { test_key_1: 'Test custom filed value', test_key_2: true, test_key_4: false, + test_key_5: 123, }, }, true diff --git a/x-pack/plugins/cases/public/components/case_view/components/custom_fields.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/custom_fields.test.tsx index 2afa4231396a7..315010b1a39ca 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/custom_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/custom_fields.test.tsx @@ -90,7 +90,7 @@ describe.skip('Case View Page files tab', () => { exact: false, }); - expect(customFields.length).toBe(4); + expect(customFields.length).toBe(6); expect(await within(customFields[0]).findByRole('heading')).toHaveTextContent( 'My test label 1' @@ -104,6 +104,12 @@ describe.skip('Case View Page files tab', () => { expect(await within(customFields[3]).findByRole('heading')).toHaveTextContent( 'My test label 4' ); + expect(await within(customFields[4]).findByRole('heading')).toHaveTextContent( + 'My test label 5' + ); + expect(await within(customFields[5]).findByRole('heading')).toHaveTextContent( + 'My test label 6' + ); }); it('pass the permissions to custom fields correctly', async () => { diff --git a/x-pack/plugins/cases/public/components/configure_cases/flyout.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/flyout.test.tsx index b3c782f83fb50..8b42dd7df6f0d 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/flyout.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/flyout.test.tsx @@ -612,6 +612,16 @@ describe('CommonFlyout ', () => { type: 'toggle', value: false, }, + { + key: 'test_key_5', + type: 'number', + value: 123, + }, + { + key: 'test_key_6', + type: 'number', + value: null, + }, ], }, }); diff --git a/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx index e058d982e7367..7a29c959d2525 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx @@ -715,6 +715,8 @@ describe('ConfigureCases', () => { { ...customFieldsConfigurationMock[1] }, { ...customFieldsConfigurationMock[2] }, { ...customFieldsConfigurationMock[3] }, + { ...customFieldsConfigurationMock[4] }, + { ...customFieldsConfigurationMock[5] }, ], templates: [], id: '', @@ -774,6 +776,8 @@ describe('ConfigureCases', () => { { ...customFieldsConfigurationMock[1] }, { ...customFieldsConfigurationMock[2] }, { ...customFieldsConfigurationMock[3] }, + { ...customFieldsConfigurationMock[4] }, + { ...customFieldsConfigurationMock[5] }, ], templates: [ { @@ -867,6 +871,16 @@ describe('ConfigureCases', () => { type: customFieldsConfigurationMock[3].type, value: false, }, + { + key: customFieldsConfigurationMock[4].key, + type: customFieldsConfigurationMock[4].type, + value: customFieldsConfigurationMock[4].defaultValue, + }, + { + key: customFieldsConfigurationMock[5].key, + type: customFieldsConfigurationMock[5].type, + value: null, + }, { key: expect.anything(), type: CustomFieldTypes.TEXT as const, @@ -930,6 +944,8 @@ describe('ConfigureCases', () => { { ...customFieldsConfigurationMock[1] }, { ...customFieldsConfigurationMock[2] }, { ...customFieldsConfigurationMock[3] }, + { ...customFieldsConfigurationMock[4] }, + { ...customFieldsConfigurationMock[5] }, ], templates: [], id: '', @@ -1107,6 +1123,16 @@ describe('ConfigureCases', () => { type: customFieldsConfigurationMock[3].type, value: false, // when no default value for toggle, we set it to false }, + { + key: customFieldsConfigurationMock[4].key, + type: customFieldsConfigurationMock[4].type, + value: customFieldsConfigurationMock[4].defaultValue, + }, + { + key: customFieldsConfigurationMock[5].key, + type: customFieldsConfigurationMock[5].type, + value: null, + }, ], }, }, diff --git a/x-pack/plugins/cases/public/components/create/form_context.test.tsx b/x-pack/plugins/cases/public/components/create/form_context.test.tsx index 0f28e6f9db1c2..252726ef559c9 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.test.tsx @@ -517,6 +517,7 @@ describe('Create case', () => { const textField = customFieldsConfigurationMock[0]; const toggleField = customFieldsConfigurationMock[1]; + const numberField = customFieldsConfigurationMock[4]; expect(await screen.findByTestId('caseCustomFields')).toBeInTheDocument(); @@ -532,6 +533,14 @@ describe('Create case', () => { await screen.findByTestId(`${toggleField.key}-${toggleField.type}-create-custom-field`) ); + const numberCustomField = await screen.findByTestId( + `${numberField.key}-${numberField.type}-create-custom-field` + ); + + await user.clear(numberCustomField); + await user.click(numberCustomField); + await user.paste('678'); + await user.click(await screen.findByTestId('create-case-submit')); await waitFor(() => expect(postCase).toHaveBeenCalled()); @@ -544,6 +553,8 @@ describe('Create case', () => { { ...customFieldsMock[1], value: false }, // toggled the default customFieldsMock[2], { ...customFieldsMock[3], value: false }, + { ...customFieldsMock[4], value: 678 }, + customFieldsMock[5], { key: 'my_custom_field_key', type: CustomFieldTypes.TEXT, diff --git a/x-pack/plugins/cases/public/components/create/index.test.tsx b/x-pack/plugins/cases/public/components/create/index.test.tsx index bb519b1f6f778..37e817d00f331 100644 --- a/x-pack/plugins/cases/public/components/create/index.test.tsx +++ b/x-pack/plugins/cases/public/components/create/index.test.tsx @@ -172,6 +172,8 @@ describe('CreateCase case', () => { await user.click(screen.getByTestId('create-case-submit')); - expect(defaultProps.onSuccess).toHaveBeenCalled(); + await waitFor(() => { + expect(defaultProps.onSuccess).toHaveBeenCalled(); + }); }); }); diff --git a/x-pack/plugins/cases/public/components/custom_fields/builder.tsx b/x-pack/plugins/cases/public/components/custom_fields/builder.tsx index d2ee25d08bfa6..4baf050fd0f52 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/builder.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/builder.tsx @@ -9,8 +9,10 @@ import type { CustomFieldBuilderMap } from './types'; import { CustomFieldTypes } from '../../../common/types/domain'; import { configureTextCustomFieldFactory } from './text/configure_text_field'; import { configureToggleCustomFieldFactory } from './toggle/configure_toggle_field'; +import { configureNumberCustomFieldFactory } from './number/configure_number_field'; export const builderMap = Object.freeze({ [CustomFieldTypes.TEXT]: configureTextCustomFieldFactory, [CustomFieldTypes.TOGGLE]: configureToggleCustomFieldFactory, + [CustomFieldTypes.NUMBER]: configureNumberCustomFieldFactory, } as const) as CustomFieldBuilderMap; diff --git a/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.test.tsx index eaaa0e28747ea..0f87c04bc9ad3 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.test.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.test.tsx @@ -59,13 +59,20 @@ describe('CustomFieldsList', () => { ) ).toBeInTheDocument(); expect((await screen.findAllByText('Text')).length).toBe(2); - expect((await screen.findAllByText('Required')).length).toBe(2); + expect((await screen.findAllByText('Required')).length).toBe(3); expect( await screen.findByTestId( `custom-field-${customFieldsConfigurationMock[1].key}-${customFieldsConfigurationMock[1].type}` ) ).toBeInTheDocument(); expect((await screen.findAllByText('Toggle')).length).toBe(2); + + expect( + await screen.findByTestId( + `custom-field-${customFieldsConfigurationMock[4].key}-${customFieldsConfigurationMock[4].type}` + ) + ).toBeInTheDocument(); + expect((await screen.findAllByText('Number')).length).toBe(2); }); it('shows single CustomFieldsList correctly', async () => { diff --git a/x-pack/plugins/cases/public/components/custom_fields/number/config.ts b/x-pack/plugins/cases/public/components/custom_fields/number/config.ts new file mode 100644 index 0000000000000..b73bc033883a8 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/number/config.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FieldConfig } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; +import { REQUIRED_FIELD, SAFE_INTEGER_NUMBER_ERROR } from '../translations'; + +const { emptyField } = fieldValidators; + +export const getNumberFieldConfig = ({ + required, + label, + defaultValue, +}: { + required: boolean; + label: string; + defaultValue?: number; +}): FieldConfig => { + const validators = []; + + if (required) { + validators.push({ + validator: emptyField(REQUIRED_FIELD(label)), + }); + } + + return { + ...(defaultValue && { defaultValue }), + validations: [ + ...validators, + { + validator: ({ value }) => { + if (value == null) { + return; + } + const numericValue = Number(value); + + if (!Number.isSafeInteger(numericValue)) { + return { message: SAFE_INTEGER_NUMBER_ERROR(label) }; + } + }, + }, + ], + }; +}; diff --git a/x-pack/plugins/cases/public/components/custom_fields/number/configure.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/number/configure.test.tsx new file mode 100644 index 0000000000000..f96e47ce30918 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/number/configure.test.tsx @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FormTestComponent } from '../../../common/test_utils'; +import * as i18n from '../translations'; +import { Configure } from './configure'; + +describe('Configure ', () => { + const onSubmit = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + render( + + + + ); + + expect(screen.getByText(i18n.FIELD_OPTION_REQUIRED)).toBeInTheDocument(); + }); + + it('updates field options without default value correctly when not required', async () => { + render( + + + + ); + + await userEvent.click(await screen.findByTestId('form-test-component-submit-button')); + + await waitFor(() => { + // data, isValid + expect(onSubmit).toBeCalledWith({}, true); + }); + }); + + it('updates field options with default value correctly when not required', async () => { + render( + + + + ); + + await userEvent.click(await screen.findByTestId('number-custom-field-default-value')); + await userEvent.paste('123'); + await userEvent.click(await screen.findByTestId('form-test-component-submit-button')); + + await waitFor(() => { + // data, isValid + expect(onSubmit).toBeCalledWith({ defaultValue: '123' }, true); + }); + }); + + it('updates field options with default value correctly when required', async () => { + render( + + + + ); + + await userEvent.click(await screen.findByTestId('number-custom-field-required')); + await userEvent.click(await screen.findByTestId('number-custom-field-default-value')); + await userEvent.paste('123'); + await userEvent.click(await screen.findByTestId('form-test-component-submit-button')); + + await waitFor(() => { + // data, isValid + expect(onSubmit).toBeCalledWith( + { + required: true, + defaultValue: '123', + }, + true + ); + }); + }); + + it('updates field options without default value correctly when required', async () => { + render( + + + + ); + + await userEvent.click(await screen.findByTestId('number-custom-field-required')); + await userEvent.click(await screen.findByTestId('form-test-component-submit-button')); + + await waitFor(() => { + // data, isValid + expect(onSubmit).toBeCalledWith( + { + required: true, + }, + true + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/number/configure.tsx b/x-pack/plugins/cases/public/components/custom_fields/number/configure.tsx new file mode 100644 index 0000000000000..db1fcffd0be0b --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/number/configure.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { CheckBoxField, NumericField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import type { CaseCustomFieldNumber } from '../../../../common/types/domain'; +import type { CustomFieldType } from '../types'; +import { getNumberFieldConfig } from './config'; +import * as i18n from '../translations'; + +const ConfigureComponent: CustomFieldType['Configure'] = () => { + const config = getNumberFieldConfig({ + required: false, + label: i18n.DEFAULT_VALUE.toLocaleLowerCase(), + }); + + return ( + <> + + + + ); +}; + +ConfigureComponent.displayName = 'Configure'; + +export const Configure = React.memo(ConfigureComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/number/configure_number_field.test.ts b/x-pack/plugins/cases/public/components/custom_fields/number/configure_number_field.test.ts new file mode 100644 index 0000000000000..aee9a4439792d --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/number/configure_number_field.test.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { configureNumberCustomFieldFactory } from './configure_number_field'; + +describe('configureTextCustomFieldFactory ', () => { + const builder = configureNumberCustomFieldFactory(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + expect(builder).toEqual({ + id: 'number', + label: 'Number', + getEuiTableColumn: expect.any(Function), + build: expect.any(Function), + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/number/configure_number_field.ts b/x-pack/plugins/cases/public/components/custom_fields/number/configure_number_field.ts new file mode 100644 index 0000000000000..428559f5f83c0 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/number/configure_number_field.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 type { CustomFieldFactory } from '../types'; +import type { CaseCustomFieldNumber } from '../../../../common/types/domain'; + +import { CustomFieldTypes } from '../../../../common/types/domain'; +import * as i18n from '../translations'; +import { getEuiTableColumn } from './get_eui_table_column'; +import { Edit } from './edit'; +import { View } from './view'; +import { Configure } from './configure'; +import { Create } from './create'; + +export const configureNumberCustomFieldFactory: CustomFieldFactory = () => ({ + id: CustomFieldTypes.NUMBER, + label: i18n.NUMBER_LABEL, + getEuiTableColumn, + build: () => ({ + Configure, + Edit, + View, + Create, + }), +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/number/create.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/number/create.test.tsx new file mode 100644 index 0000000000000..2a8a515df01ee --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/number/create.test.tsx @@ -0,0 +1,225 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FormTestComponent } from '../../../common/test_utils'; +import { Create } from './create'; +import { customFieldsConfigurationMock } from '../../../containers/mock'; + +describe('Create ', () => { + const onSubmit = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + // required number custom field with a default value + const customFieldConfiguration = customFieldsConfigurationMock[4]; + + it('renders correctly with default value and required', async () => { + render( + + + + ); + + expect(await screen.findByText(customFieldConfiguration.label)).toBeInTheDocument(); + + expect( + await screen.findByTestId(`${customFieldConfiguration.key}-number-create-custom-field`) + ).toHaveValue(customFieldConfiguration.defaultValue as number); + }); + + it('renders correctly without default value and not required', async () => { + const optionalField = customFieldsConfigurationMock[5]; // optional number custom field + + render( + + + + ); + + expect(await screen.findByText(optionalField.label)).toBeInTheDocument(); + expect( + await screen.findByTestId(`${optionalField.key}-number-create-custom-field`) + ).toHaveValue(null); + }); + + it('does not render default value when setDefaultValue is false', async () => { + render( + + + + ); + + expect( + await screen.findByTestId(`${customFieldConfiguration.key}-number-create-custom-field`) + ).toHaveValue(null); + }); + + it('renders loading state correctly', async () => { + render( + + + + ); + + expect(await screen.findByRole('progressbar')).toBeInTheDocument(); + }); + + it('disables the text when loading', async () => { + render( + + + + ); + + expect( + await screen.findByTestId(`${customFieldConfiguration.key}-number-create-custom-field`) + ).toHaveAttribute('disabled'); + }); + + it('updates the value correctly', async () => { + render( + + + + ); + + const numberCustomField = await screen.findByTestId( + `${customFieldConfiguration.key}-number-create-custom-field` + ); + + await userEvent.clear(numberCustomField); + await userEvent.click(numberCustomField); + await userEvent.paste('1234'); + await userEvent.click(await screen.findByText('Submit')); + + await waitFor(() => { + // data, isValid + expect(onSubmit).toHaveBeenCalledWith( + { + customFields: { + [customFieldConfiguration.key]: '1234', + }, + }, + true + ); + }); + }); + + it('shows error when number is too big', async () => { + render( + + + + ); + + const numberCustomField = await screen.findByTestId( + `${customFieldConfiguration.key}-number-create-custom-field` + ); + + await userEvent.clear(numberCustomField); + await userEvent.click(numberCustomField); + await userEvent.paste(`${Number.MAX_SAFE_INTEGER + 1}`); + + await userEvent.click(await screen.findByText('Submit')); + + expect( + await screen.findByText( + 'The value of the My test label 5 should be an integer between -(2^53 - 1) and 2^53 - 1, inclusive.' + ) + ).toBeInTheDocument(); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({}, false); + }); + }); + + it('shows error when number is too small', async () => { + render( + + + + ); + + const numberCustomField = await screen.findByTestId( + `${customFieldConfiguration.key}-number-create-custom-field` + ); + + await userEvent.clear(numberCustomField); + await userEvent.click(numberCustomField); + await userEvent.paste(`${Number.MIN_SAFE_INTEGER - 1}`); + + await userEvent.click(await screen.findByText('Submit')); + + expect( + await screen.findByText( + 'The value of the My test label 5 should be an integer between -(2^53 - 1) and 2^53 - 1, inclusive.' + ) + ).toBeInTheDocument(); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({}, false); + }); + }); + + it('shows error when number is required but is empty', async () => { + render( + + + + ); + + await userEvent.clear( + await screen.findByTestId(`${customFieldConfiguration.key}-number-create-custom-field`) + ); + await userEvent.click(await screen.findByText('Submit')); + + expect( + await screen.findByText(`${customFieldConfiguration.label} is required.`) + ).toBeInTheDocument(); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({}, false); + }); + }); + + it('does not show error when number is not required but is empty', async () => { + render( + + + + ); + + await userEvent.click(await screen.findByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({}, true); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/number/create.tsx b/x-pack/plugins/cases/public/components/custom_fields/number/create.tsx new file mode 100644 index 0000000000000..bc01145fd5d46 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/number/create.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { NumericField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import type { CaseCustomFieldNumber } from '../../../../common/types/domain'; +import type { CustomFieldType } from '../types'; +import { getNumberFieldConfig } from './config'; +import { OptionalFieldLabel } from '../../optional_field_label'; + +const CreateComponent: CustomFieldType['Create'] = ({ + customFieldConfiguration, + isLoading, + setAsOptional, + setDefaultValue = true, +}) => { + const { key, label, required, defaultValue } = customFieldConfiguration; + const config = getNumberFieldConfig({ + required: setAsOptional ? false : required, + label, + ...(defaultValue && + setDefaultValue && + !isNaN(Number(defaultValue)) && { defaultValue: Number(defaultValue) }), + }); + + return ( + + ); +}; + +CreateComponent.displayName = 'Create'; + +export const Create = React.memo(CreateComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/number/edit.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/number/edit.test.tsx new file mode 100644 index 0000000000000..fb19bdb553d41 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/number/edit.test.tsx @@ -0,0 +1,475 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { FormTestComponent } from '../../../common/test_utils'; +import { Edit } from './edit'; +import { customFieldsMock, customFieldsConfigurationMock } from '../../../containers/mock'; +import userEvent from '@testing-library/user-event'; +import type { CaseCustomFieldNumber } from '../../../../common/types/domain'; +import { POPULATED_WITH_DEFAULT } from '../translations'; + +describe('Edit ', () => { + const onSubmit = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const customField = customFieldsMock[4] as CaseCustomFieldNumber; + const customFieldConfiguration = customFieldsConfigurationMock[4]; + + it('renders correctly', async () => { + render( + + + + ); + + expect(await screen.findByTestId('case-number-custom-field-test_key_5')).toBeInTheDocument(); + expect( + await screen.findByTestId('case-number-custom-field-edit-button-test_key_5') + ).toBeInTheDocument(); + expect(await screen.findByText(customFieldConfiguration.label)).toBeInTheDocument(); + expect(await screen.findByText('1234')).toBeInTheDocument(); + }); + + it('does not shows the edit button if the user does not have permissions', async () => { + render( + + + + ); + + expect( + screen.queryByTestId('case-number-custom-field-edit-button-test_key_1') + ).not.toBeInTheDocument(); + }); + + it('does not shows the edit button when loading', async () => { + render( + + + + ); + + expect( + screen.queryByTestId('case-number-custom-field-edit-button-test_key_1') + ).not.toBeInTheDocument(); + }); + + it('shows the loading spinner when loading', async () => { + render( + + + + ); + + expect( + await screen.findByTestId('case-number-custom-field-loading-test_key_5') + ).toBeInTheDocument(); + }); + + it('shows the no value number if the custom field is undefined', async () => { + render( + + + + ); + + expect(await screen.findByText('No value is added')).toBeInTheDocument(); + }); + + it('uses the required value correctly if a required field is empty', async () => { + render( + + + + ); + + expect(await screen.findByText('No value is added')).toBeInTheDocument(); + await userEvent.click( + await screen.findByTestId('case-number-custom-field-edit-button-test_key_5') + ); + + expect( + await screen.findByTestId( + `case-number-custom-field-form-field-${customFieldConfiguration.key}` + ) + ).toHaveValue(customFieldConfiguration.defaultValue as number); + expect( + await screen.findByText('This field is populated with the default value.') + ).toBeInTheDocument(); + + await userEvent.click( + await screen.findByTestId('case-number-custom-field-submit-button-test_key_5') + ); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith({ + ...customField, + value: customFieldConfiguration.defaultValue, + }); + }); + }); + + it('does not show the value when the custom field is undefined', async () => { + render( + + + + ); + + expect(screen.queryByTestId('number-custom-field-view-test_key_5')).not.toBeInTheDocument(); + }); + + it('does not show the value when the value is null', async () => { + render( + + + + ); + + expect(screen.queryByTestId('number-custom-field-view-test_key_5')).not.toBeInTheDocument(); + }); + + it('does not show the form when the user does not have permissions', async () => { + render( + + + + ); + + expect( + screen.queryByTestId('case-number-custom-field-form-field-test_key_5') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('case-number-custom-field-submit-button-test_key_5') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('case-number-custom-field-cancel-button-test_key_5') + ).not.toBeInTheDocument(); + }); + + it('calls onSubmit when changing value', async () => { + render( + + + + ); + + await userEvent.click( + await screen.findByTestId('case-number-custom-field-edit-button-test_key_5') + ); + await userEvent.click( + await screen.findByTestId('case-number-custom-field-form-field-test_key_5') + ); + await userEvent.paste('12345'); + + expect( + await screen.findByTestId('case-number-custom-field-submit-button-test_key_5') + ).not.toBeDisabled(); + + await userEvent.click( + await screen.findByTestId('case-number-custom-field-submit-button-test_key_5') + ); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith({ + ...customField, + value: 123412345, + }); + }); + }); + + it('calls onSubmit with defaultValue if no initialValue exists', async () => { + render( + + + + ); + + await userEvent.click( + await screen.findByTestId('case-number-custom-field-edit-button-test_key_5') + ); + + expect(await screen.findByText(POPULATED_WITH_DEFAULT)).toBeInTheDocument(); + expect(await screen.findByTestId('case-number-custom-field-form-field-test_key_5')).toHaveValue( + customFieldConfiguration.defaultValue as number + ); + expect( + await screen.findByTestId('case-number-custom-field-submit-button-test_key_5') + ).not.toBeDisabled(); + + await userEvent.click( + await screen.findByTestId('case-number-custom-field-submit-button-test_key_5') + ); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith({ + ...customField, + value: customFieldConfiguration.defaultValue, + }); + }); + }); + + it('sets the value to null if the number field is empty', async () => { + render( + + + + ); + + await userEvent.click( + await screen.findByTestId('case-number-custom-field-edit-button-test_key_5') + ); + await userEvent.clear( + await screen.findByTestId('case-number-custom-field-form-field-test_key_5') + ); + + expect( + await screen.findByTestId('case-number-custom-field-submit-button-test_key_5') + ).not.toBeDisabled(); + + await userEvent.click( + await screen.findByTestId('case-number-custom-field-submit-button-test_key_5') + ); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith({ + ...customField, + value: null, + }); + }); + }); + + it('hides the form when clicking the cancel button', async () => { + render( + + + + ); + + await userEvent.click( + await screen.findByTestId('case-number-custom-field-edit-button-test_key_5') + ); + + expect( + await screen.findByTestId('case-number-custom-field-form-field-test_key_5') + ).toBeInTheDocument(); + + await userEvent.click( + await screen.findByTestId('case-number-custom-field-cancel-button-test_key_5') + ); + + expect( + screen.queryByTestId('case-number-custom-field-form-field-test_key_5') + ).not.toBeInTheDocument(); + }); + + it('reset to initial value when canceling', async () => { + render( + + + + ); + + await userEvent.click( + await screen.findByTestId('case-number-custom-field-edit-button-test_key_5') + ); + await userEvent.click( + await screen.findByTestId('case-number-custom-field-form-field-test_key_5') + ); + await userEvent.paste('321'); + + expect( + await screen.findByTestId('case-number-custom-field-submit-button-test_key_5') + ).not.toBeDisabled(); + + await userEvent.click( + await screen.findByTestId('case-number-custom-field-cancel-button-test_key_5') + ); + + expect( + screen.queryByTestId('case-number-custom-field-form-field-test_key_5') + ).not.toBeInTheDocument(); + + await userEvent.click( + await screen.findByTestId('case-number-custom-field-edit-button-test_key_5') + ); + expect(await screen.findByTestId('case-number-custom-field-form-field-test_key_5')).toHaveValue( + 1234 + ); + }); + + it('shows validation error if the field is required', async () => { + render( + + + + ); + + await userEvent.click( + await screen.findByTestId('case-number-custom-field-edit-button-test_key_5') + ); + await userEvent.clear( + await screen.findByTestId('case-number-custom-field-form-field-test_key_5') + ); + + expect(await screen.findByText('My test label 5 is required.')).toBeInTheDocument(); + }); + + it('does not shows a validation error if the field is not required', async () => { + render( + + + + ); + + await userEvent.click( + await screen.findByTestId('case-number-custom-field-edit-button-test_key_5') + ); + await userEvent.clear( + await screen.findByTestId('case-number-custom-field-form-field-test_key_5') + ); + + expect( + await screen.findByTestId('case-number-custom-field-submit-button-test_key_5') + ).not.toBeDisabled(); + + expect(screen.queryByText('My test label 1 is required.')).not.toBeInTheDocument(); + }); + + it('shows validation error if the number is too big', async () => { + render( + + + + ); + + await userEvent.click( + await screen.findByTestId('case-number-custom-field-edit-button-test_key_5') + ); + await userEvent.clear( + await screen.findByTestId('case-number-custom-field-form-field-test_key_5') + ); + await userEvent.click( + await screen.findByTestId('case-number-custom-field-form-field-test_key_5') + ); + await userEvent.paste(`${2 ** 53 + 1}`); + + expect( + await screen.findByText( + 'The value of the My test label 5 should be an integer between -(2^53 - 1) and 2^53 - 1, inclusive.' + ) + ).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/number/edit.tsx b/x-pack/plugins/cases/public/components/custom_fields/number/edit.tsx new file mode 100644 index 0000000000000..3ebb65a9dab8e --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/number/edit.tsx @@ -0,0 +1,246 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useState, useCallback } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiLoadingSpinner, + EuiText, +} from '@elastic/eui'; +import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { + useForm, + UseField, + Form, + useFormData, +} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { NumericField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import type { CaseCustomFieldNumber } from '../../../../common/types/domain'; +import { CustomFieldTypes } from '../../../../common/types/domain'; +import type { CasesConfigurationUICustomField } from '../../../../common/ui'; +import type { CustomFieldType } from '../types'; +import { View } from './view'; +import { + CANCEL, + EDIT_CUSTOM_FIELDS_ARIA_LABEL, + NO_CUSTOM_FIELD_SET, + SAVE, + POPULATED_WITH_DEFAULT, +} from '../translations'; +import { getNumberFieldConfig } from './config'; + +const isEmpty = (value: number | null | undefined) => { + return value == null; +}; + +interface FormState { + value: number | null; + isValid?: boolean; + submit: FormHook<{ value: number | null }>['submit']; +} + +interface FormWrapper { + initialValue: number | null; + isLoading: boolean; + customFieldConfiguration: CasesConfigurationUICustomField; + onChange: (state: FormState) => void; +} + +const FormWrapperComponent: React.FC = ({ + initialValue, + customFieldConfiguration, + isLoading, + onChange, +}) => { + const { form } = useForm<{ value: number | null }>({ + defaultValue: { + value: + customFieldConfiguration?.defaultValue != null && isEmpty(initialValue) + ? Number(customFieldConfiguration.defaultValue) + : initialValue, + }, + }); + + const [{ value }] = useFormData({ form }); + const { submit, isValid } = form; + const formFieldConfig = getNumberFieldConfig({ + required: customFieldConfiguration.required, + label: customFieldConfiguration.label, + }); + const populatedWithDefault = + value === customFieldConfiguration?.defaultValue && isEmpty(initialValue); + + useEffect(() => { + onChange({ + value, + isValid, + submit, + }); + }, [isValid, onChange, submit, value]); + + return ( +
+ + + ); +}; + +FormWrapperComponent.displayName = 'FormWrapper'; + +const EditComponent: CustomFieldType['Edit'] = ({ + customField, + customFieldConfiguration, + onSubmit, + isLoading, + canUpdate, +}) => { + const initialValue = customField?.value ?? null; + const [isEdit, setIsEdit] = useState(false); + const [formState, setFormState] = useState({ + isValid: undefined, + submit: async () => ({ isValid: false, data: { value: null } }), + value: initialValue, + }); + + const onEdit = useCallback(() => { + setIsEdit(true); + }, []); + + const onCancel = useCallback(() => { + setIsEdit(false); + }, []); + + const onSubmitCustomField = useCallback(async () => { + const { isValid, data } = await formState.submit(); + + if (isValid) { + onSubmit({ + ...customField, + key: customField?.key ?? customFieldConfiguration.key, + type: CustomFieldTypes.NUMBER, + value: data.value ? Number(data.value) : null, + }); + } + setIsEdit(false); + }, [customField, customFieldConfiguration.key, formState, onSubmit]); + + const title = customFieldConfiguration.label; + + const isNumberFieldValid = + formState.isValid || + (formState.value === customFieldConfiguration.defaultValue && isEmpty(initialValue)); + + const isCustomFieldValueDefined = !isEmpty(customField?.value); + + return ( + <> + + + +

{title}

+
+
+ {isLoading && ( + + )} + {!isLoading && canUpdate && ( + + + + )} +
+ + + {!isCustomFieldValueDefined && !isEdit && ( +

{NO_CUSTOM_FIELD_SET}

+ )} + {!isEdit && isCustomFieldValueDefined && ( + + + + )} + {isEdit && canUpdate && ( + + + + + + + + + {SAVE} + + + + + {CANCEL} + + + + + + )} +
+ + ); +}; + +EditComponent.displayName = 'Edit'; + +export const Edit = React.memo(EditComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/number/get_eui_table_column.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/number/get_eui_table_column.test.tsx new file mode 100644 index 0000000000000..73e94f9335705 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/number/get_eui_table_column.test.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; + +import { screen } from '@testing-library/react'; + +import { CustomFieldTypes } from '../../../../common/types/domain'; +import type { AppMockRenderer } from '../../../common/mock'; +import { createAppMockRenderer } from '../../../common/mock'; +import { getEuiTableColumn } from './get_eui_table_column'; + +describe('getEuiTableColumn ', () => { + let appMockRender: AppMockRenderer; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + + jest.clearAllMocks(); + }); + + it('returns a name and a render function', async () => { + const label = 'MockLabel'; + + expect(getEuiTableColumn({ label })).toEqual({ + name: label, + render: expect.any(Function), + width: '150px', + 'data-test-subj': 'number-custom-field-column', + }); + }); + + it('render function renders a number column correctly', async () => { + const key = 'test_key_1'; + const value = 1234567; + const column = getEuiTableColumn({ label: 'MockLabel' }); + + appMockRender.render(
{column.render({ key, type: CustomFieldTypes.NUMBER, value })}
); + + expect(screen.getByTestId(`number-custom-field-column-view-${key}`)).toBeInTheDocument(); + expect(screen.getByTestId(`number-custom-field-column-view-${key}`)).toHaveTextContent( + String(value) + ); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/number/get_eui_table_column.tsx b/x-pack/plugins/cases/public/components/custom_fields/number/get_eui_table_column.tsx new file mode 100644 index 0000000000000..a5b68364b9758 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/number/get_eui_table_column.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import type { CaseCustomField } from '../../../../common/types/domain'; +import type { CustomFieldEuiTableColumn } from '../types'; + +export const getEuiTableColumn = ({ label }: { label: string }): CustomFieldEuiTableColumn => ({ + name: label, + width: '150px', + render: (customField: CaseCustomField) => { + return ( +

+ {customField.value} +

+ ); + }, + 'data-test-subj': 'number-custom-field-column', +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/number/view.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/number/view.test.tsx new file mode 100644 index 0000000000000..cdcc3cdacf534 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/number/view.test.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { CustomFieldTypes } from '../../../../common/types/domain'; +import { View } from './view'; + +describe('View ', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const customField = { + type: CustomFieldTypes.NUMBER as const, + key: 'test_key_1', + value: 123 as number, + }; + + it('renders correctly', async () => { + render(); + + expect(screen.getByText('123')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/number/view.tsx b/x-pack/plugins/cases/public/components/custom_fields/number/view.tsx new file mode 100644 index 0000000000000..542ea92def998 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/number/view.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiText } from '@elastic/eui'; +import type { CaseCustomFieldNumber } from '../../../../common/types/domain'; +import type { CustomFieldType } from '../types'; + +const ViewComponent: CustomFieldType['View'] = ({ customField }) => { + const value = customField?.value ?? '-'; + + return ( + + {value} + + ); +}; + +ViewComponent.displayName = 'View'; + +export const View = React.memo(ViewComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/configure_text_field.ts b/x-pack/plugins/cases/public/components/custom_fields/text/configure_text_field.ts index c0f50820d45f3..0f1595135f9b8 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/text/configure_text_field.ts +++ b/x-pack/plugins/cases/public/components/custom_fields/text/configure_text_field.ts @@ -25,5 +25,6 @@ export const configureTextCustomFieldFactory: CustomFieldFactory (value == null ? '' : String(value)), + convertNullToEmpty: (value: string | number | boolean | null) => + value == null ? '' : String(value), }); diff --git a/x-pack/plugins/cases/public/components/custom_fields/translations.ts b/x-pack/plugins/cases/public/components/custom_fields/translations.ts index 5f1a91765193f..22bafbb80f92f 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/translations.ts +++ b/x-pack/plugins/cases/public/components/custom_fields/translations.ts @@ -51,6 +51,10 @@ export const TOGGLE_LABEL = i18n.translate('xpack.cases.customFields.toggleLabel defaultMessage: 'Toggle', }); +export const NUMBER_LABEL = i18n.translate('xpack.cases.customFields.textLabel', { + defaultMessage: 'Number', +}); + export const FIELD_TYPE = i18n.translate('xpack.cases.customFields.fieldType', { defaultMessage: 'Field type', }); diff --git a/x-pack/plugins/cases/public/components/custom_fields/types.ts b/x-pack/plugins/cases/public/components/custom_fields/types.ts index 70caeabd8edd2..ca63caef38748 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/types.ts +++ b/x-pack/plugins/cases/public/components/custom_fields/types.ts @@ -55,7 +55,7 @@ export type CustomFieldFactory = () => { build: () => CustomFieldType; filterOptions?: CustomFieldFactoryFilterOption[]; getDefaultValue?: () => string | boolean | null; - convertNullToEmpty?: (value: string | boolean | null) => string; + convertNullToEmpty?: (value: string | number | boolean | null) => string; }; export type CustomFieldBuilderMap = { diff --git a/x-pack/plugins/cases/public/components/custom_fields/utils.test.ts b/x-pack/plugins/cases/public/components/custom_fields/utils.test.ts index 5a21319645836..61a77fc941451 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/utils.test.ts +++ b/x-pack/plugins/cases/public/components/custom_fields/utils.test.ts @@ -97,5 +97,40 @@ describe('utils ', () => { } `); }); + + it('serializes the data correctly if the default value is integer number', async () => { + const customField = { + key: 'my_test_key', + type: CustomFieldTypes.NUMBER, + required: true, + defaultValue: 1, + } as CustomFieldConfiguration; + + expect(customFieldSerializer(customField)).toMatchInlineSnapshot(` + Object { + "defaultValue": 1, + "key": "my_test_key", + "required": true, + "type": "number", + } + `); + }); + + it('serializes the data correctly if the default value is float number', async () => { + const customField = { + key: 'my_test_key', + type: CustomFieldTypes.NUMBER, + required: true, + defaultValue: 1.5, + } as CustomFieldConfiguration; + + expect(customFieldSerializer(customField)).toMatchInlineSnapshot(` + Object { + "key": "my_test_key", + "required": true, + "type": "number", + } + `); + }); }); }); diff --git a/x-pack/plugins/cases/public/components/custom_fields/utils.ts b/x-pack/plugins/cases/public/components/custom_fields/utils.ts index 3842b75b5a7ea..96438a9337265 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/utils.ts +++ b/x-pack/plugins/cases/public/components/custom_fields/utils.ts @@ -8,6 +8,7 @@ import { isEmptyString } from '@kbn/es-ui-shared-plugin/static/validators/string'; import { isString } from 'lodash'; import type { CustomFieldConfiguration } from '../../../common/types/domain'; +import { CustomFieldTypes } from '../../../common/types/domain'; export const customFieldSerializer = ( field: CustomFieldConfiguration @@ -18,5 +19,13 @@ export const customFieldSerializer = ( return otherProperties; } + if (field.type === CustomFieldTypes.NUMBER) { + if (defaultValue !== null && Number.isSafeInteger(Number(defaultValue))) { + return { ...field, defaultValue: Number(defaultValue) }; + } else { + return otherProperties; + } + } + return field; }; diff --git a/x-pack/plugins/cases/public/components/description/index.test.tsx b/x-pack/plugins/cases/public/components/description/index.test.tsx index 5ce8909a65dc6..678b46eabfbe8 100644 --- a/x-pack/plugins/cases/public/components/description/index.test.tsx +++ b/x-pack/plugins/cases/public/components/description/index.test.tsx @@ -27,7 +27,8 @@ const defaultProps = { isLoadingDescription: false, }; -describe('Description', () => { +// Failing: See https://github.com/elastic/kibana/issues/185879 +describe.skip('Description', () => { const onUpdateField = jest.fn(); let appMockRender: AppMockRenderer; diff --git a/x-pack/plugins/cases/public/components/links/index.tsx b/x-pack/plugins/cases/public/components/links/index.tsx index f1e8ca5cdb4af..9b610db63ed10 100644 --- a/x-pack/plugins/cases/public/components/links/index.tsx +++ b/x-pack/plugins/cases/public/components/links/index.tsx @@ -12,7 +12,7 @@ import { useCaseViewNavigation, useConfigureCasesNavigation } from '../../common import * as i18n from './translations'; export interface CasesNavigation { - href: K extends 'configurable' ? (arg: T) => string : string; + href?: K extends 'configurable' ? (arg: T) => string : string; onClick: K extends 'configurable' ? (arg: T, arg2: React.MouseEvent | MouseEvent) => Promise | void : (arg: T) => Promise | void; diff --git a/x-pack/plugins/cases/public/components/templates/form.test.tsx b/x-pack/plugins/cases/public/components/templates/form.test.tsx index bf5f66aaa3e21..349457c2be98f 100644 --- a/x-pack/plugins/cases/public/components/templates/form.test.tsx +++ b/x-pack/plugins/cases/public/components/templates/form.test.tsx @@ -589,11 +589,14 @@ describe('TemplateForm', () => { expect( await within(customFieldsElement).findAllByTestId('form-optional-field-label') ).toHaveLength( - customFieldsConfigurationMock.filter((field) => field.type === CustomFieldTypes.TEXT).length + customFieldsConfigurationMock.filter( + (field) => field.type === CustomFieldTypes.TEXT || field.type === CustomFieldTypes.NUMBER + ).length ); const textField = customFieldsConfigurationMock[0]; const toggleField = customFieldsConfigurationMock[3]; + const numberField = customFieldsConfigurationMock[4]; const textCustomField = await screen.findByTestId( `${textField.key}-${textField.type}-create-custom-field` @@ -608,6 +611,15 @@ describe('TemplateForm', () => { await screen.findByTestId(`${toggleField.key}-${toggleField.type}-create-custom-field`) ); + const numberCustomField = await screen.findByTestId( + `${numberField.key}-${numberField.type}-create-custom-field` + ); + + await user.clear(numberCustomField); + + await user.click(numberCustomField); + await user.paste('765'); + const submitSpy = jest.spyOn(formState!, 'submit'); await user.click(screen.getByText('testSubmit')); @@ -644,6 +656,16 @@ describe('TemplateForm', () => { type: 'toggle', value: true, }, + { + key: 'test_key_5', + type: 'number', + value: 1234, + }, + { + key: 'test_key_6', + type: 'number', + value: true, + }, ], settings: { syncAlerts: true, diff --git a/x-pack/plugins/cases/public/components/templates/form_fields.test.tsx b/x-pack/plugins/cases/public/components/templates/form_fields.test.tsx index 75cfa58e8d5f8..48c6f956ccc7c 100644 --- a/x-pack/plugins/cases/public/components/templates/form_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/templates/form_fields.test.tsx @@ -311,6 +311,7 @@ describe('form fields', () => { const textField = customFieldsConfigurationMock[0]; const toggleField = customFieldsConfigurationMock[1]; + const numberField = customFieldsConfigurationMock[4]; const textCustomField = await screen.findByTestId( `${textField.key}-${textField.type}-create-custom-field` @@ -324,6 +325,14 @@ describe('form fields', () => { await screen.findByTestId(`${toggleField.key}-${toggleField.type}-create-custom-field`) ); + const numberCustomField = await screen.findByTestId( + `${numberField.key}-${numberField.type}-create-custom-field` + ); + + await userEvent.clear(numberCustomField); + await userEvent.click(numberCustomField); + await userEvent.paste('987'); + await userEvent.click(screen.getByText('Submit')); await waitFor(() => { @@ -336,6 +345,7 @@ describe('form fields', () => { test_key_1: 'My text test value 1', test_key_2: false, test_key_4: false, + test_key_5: '987', }, syncAlerts: true, templateTags: [], diff --git a/x-pack/plugins/cases/public/components/use_push_to_service/index.test.tsx b/x-pack/plugins/cases/public/components/use_push_to_service/index.test.tsx index fccca04bb278f..75c2694f89479 100644 --- a/x-pack/plugins/cases/public/components/use_push_to_service/index.test.tsx +++ b/x-pack/plugins/cases/public/components/use_push_to_service/index.test.tsx @@ -13,7 +13,6 @@ import { usePushToService } from '.'; import { noPushCasesPermissions, readCasesPermissions, TestProviders } from '../../common/mock'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; import { actionLicenses } from '../../containers/mock'; -import { CLOSED_CASE_PUSH_ERROR_ID } from './callout/types'; import { useGetActionLicense } from '../../containers/use_get_action_license'; import { getCaseConnectorsMockResponse } from '../../common/mock/connectors'; import { useRefreshCaseViewPage } from '../case_view/use_on_refresh_case_view_page'; @@ -182,27 +181,6 @@ describe('usePushToService', () => { expect(result.current.hasErrorMessages).toBe(true); }); - it('Displays message when case is closed', async () => { - const { result } = renderHook< - React.PropsWithChildren, - ReturnUsePushToService - >( - () => - usePushToService({ - ...defaultArgs, - caseStatus: CaseStatuses.closed, - }), - { - wrapper: ({ children }) => {children}, - } - ); - - const errorsMsg = result.current.errorsMsg; - expect(errorsMsg).toHaveLength(1); - expect(errorsMsg[0].id).toEqual(CLOSED_CASE_PUSH_ERROR_ID); - expect(result.current.hasErrorMessages).toBe(true); - }); - it('should not call pushCaseToExternalService when the selected connector is none', async () => { const { result } = renderHook< React.PropsWithChildren, @@ -460,7 +438,7 @@ describe('usePushToService', () => { const { result } = renderHook< React.PropsWithChildren, ReturnUsePushToService - >(() => usePushToService({ ...defaultArgs, caseStatus: CaseStatuses.closed }), { + >(() => usePushToService({ ...defaultArgs, isValidConnector: false }), { wrapper: ({ children }) => {children}, }); diff --git a/x-pack/plugins/cases/public/components/use_push_to_service/index.tsx b/x-pack/plugins/cases/public/components/use_push_to_service/index.tsx index 63a016964651e..465e48bddade7 100644 --- a/x-pack/plugins/cases/public/components/use_push_to_service/index.tsx +++ b/x-pack/plugins/cases/public/components/use_push_to_service/index.tsx @@ -13,10 +13,8 @@ import { getKibanaConfigError, getConnectorMissingInfo, getDeletedConnectorError, - getCaseClosedInfo, } from './helpers'; import type { CaseConnector } from '../../../common/types/domain'; -import { CaseStatuses } from '../../../common/types/domain'; import type { ErrorMessage } from './callout/types'; import { useRefreshCaseViewPage } from '../case_view/use_on_refresh_case_view_page'; import { useGetActionLicense } from '../../containers/use_get_action_license'; @@ -44,7 +42,6 @@ export interface ReturnUsePushToService { export const usePushToService = ({ caseId, - caseStatus, caseConnectors, connector, isValidConnector, @@ -108,14 +105,9 @@ export const usePushToService = ({ return [getDeletedConnectorError()]; } - if (caseStatus === CaseStatuses.closed) { - return [getCaseClosedInfo()]; - } - return errors; }, [ actionLicense, - caseStatus, connector.id, hasLicenseError, isValidConnector, diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/alert_event.test.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/alert_event.test.tsx index 60d5759de6e21..d060f1d9d71ac 100644 --- a/x-pack/plugins/cases/public/components/user_actions/comment/alert_event.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/comment/alert_event.test.tsx @@ -47,10 +47,38 @@ describe('Alert events', () => { expect(wrapper.text()).toBe('added an alert from Awesome rule'); }); - it('does NOT render the link when the rule id is null', async () => { + it('renders the link when onClick is provided but href is not valid', async () => { const wrapper = mount( - + + + ); + + expect( + wrapper.find(`[data-test-subj="alert-rule-link-action-id-1"]`).first().exists() + ).toBeTruthy(); + }); + + it('renders the link when href is valid but onClick is not available', async () => { + const wrapper = mount( + + + + ); + + expect( + wrapper.find(`[data-test-subj="alert-rule-link-action-id-1"]`).first().exists() + ).toBeTruthy(); + }); + + it('does NOT render the link when the href and onclick are invalid but it shows the rule name', async () => { + const wrapper = mount( + + ); @@ -61,10 +89,10 @@ describe('Alert events', () => { expect(wrapper.text()).toBe('added an alert from Awesome rule'); }); - it('does NOT render the link when the href is invalid but it shows the rule name', async () => { + it('does NOT render the link when the rule id is null', async () => { const wrapper = mount( - + ); @@ -131,9 +159,28 @@ describe('Alert events', () => { expect(result.getByTestId('alert-rule-link-action-id-1')).toHaveTextContent('Awesome rule'); }); - it('does NOT render the link when the rule id is null', async () => { + it('renders the link when onClick is provided but href is not valid', async () => { const result = appMock.render( - + + ); + expect(result.getByTestId('alert-rule-link-action-id-1')).toHaveTextContent('Awesome rule'); + }); + + it('renders the link when href is valid but onClick is not available', async () => { + const result = appMock.render( + + ); + expect(result.getByTestId('alert-rule-link-action-id-1')).toHaveTextContent('Awesome rule'); + }); + + it('does NOT render the link when the href and onclick are invalid but it shows the rule name', async () => { + const result = appMock.render( + ); expect(result.getByTestId('multiple-alerts-user-action-action-id-1')).toHaveTextContent( @@ -142,9 +189,9 @@ describe('Alert events', () => { expect(result.queryByTestId('alert-rule-link-action-id-1')).toBeFalsy(); }); - it('does NOT render the link when the href is invalid but it shows the rule name', async () => { + it('does NOT render the link when the rule id is null', async () => { const result = appMock.render( - + ); expect(result.getByTestId('multiple-alerts-user-action-action-id-1')).toHaveTextContent( diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/alert_event.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/alert_event.tsx index 42e5e6b9d4427..a8ffbb987a021 100644 --- a/x-pack/plugins/cases/public/components/user_actions/comment/alert_event.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/comment/alert_event.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { memo, useCallback } from 'react'; +import React, { memo, useCallback, useMemo } from 'react'; import { isEmpty } from 'lodash'; import { EuiLoadingSpinner } from '@elastic/eui'; @@ -38,12 +38,18 @@ const RuleLink: React.FC = memo( const ruleDetailsHref = getRuleDetailsHref?.(ruleId); const finalRuleName = ruleName ?? i18n.UNKNOWN_RULE; + const isValidLink = useMemo(() => { + if (!onRuleDetailsClick && !ruleDetailsHref) { + return false; + } + return !isEmpty(ruleId); + }, [onRuleDetailsClick, ruleDetailsHref, ruleId]); if (loadingAlertData) { return ; } - if (!isEmpty(ruleId) && ruleDetailsHref != null) { + if (isValidLink) { return ( { +// FLAKY: https://github.com/elastic/kibana/issues/195672 +describe.skip('DeleteAttachmentConfirmationModal', () => { let appMock: AppMockRenderer; const props = { title: 'My title', diff --git a/x-pack/plugins/cases/public/components/utils.test.ts b/x-pack/plugins/cases/public/components/utils.test.ts index 005f15b78b3d7..f10590cc9a358 100644 --- a/x-pack/plugins/cases/public/components/utils.test.ts +++ b/x-pack/plugins/cases/public/components/utils.test.ts @@ -523,19 +523,46 @@ describe('Utils', () => { }); it('returns the string when the value is a non-empty string', async () => { - expect(convertCustomFieldValue('my text value')).toMatchInlineSnapshot(`"my text value"`); + expect( + convertCustomFieldValue({ value: 'my text value', type: CustomFieldTypes.TEXT }) + ).toMatchInlineSnapshot(`"my text value"`); }); it('returns null when value is empty string', async () => { - expect(convertCustomFieldValue('')).toMatchInlineSnapshot('null'); + expect( + convertCustomFieldValue({ value: '', type: CustomFieldTypes.TEXT }) + ).toMatchInlineSnapshot('null'); }); it('returns value as it is when value is true', async () => { - expect(convertCustomFieldValue(true)).toMatchInlineSnapshot('true'); + expect( + convertCustomFieldValue({ value: true, type: CustomFieldTypes.TOGGLE }) + ).toMatchInlineSnapshot('true'); }); it('returns value as it is when value is false', async () => { - expect(convertCustomFieldValue(false)).toMatchInlineSnapshot('false'); + expect( + convertCustomFieldValue({ value: false, type: CustomFieldTypes.TOGGLE }) + ).toMatchInlineSnapshot('false'); + }); + it('returns value as integer number when value is integer string and type is number', () => { + expect(convertCustomFieldValue({ value: '123', type: CustomFieldTypes.NUMBER })).toEqual(123); + }); + + it('returns value as null when value is float string and type is number', () => { + expect(convertCustomFieldValue({ value: '0.5', type: CustomFieldTypes.NUMBER })).toEqual( + null + ); + }); + + it('returns value as null when value is null and type is number', () => { + expect(convertCustomFieldValue({ value: null, type: CustomFieldTypes.NUMBER })).toEqual(null); + }); + + it('returns value as null when value is characters string and type is number', () => { + expect(convertCustomFieldValue({ value: 'fdgdg', type: CustomFieldTypes.NUMBER })).toEqual( + null + ); }); }); @@ -575,6 +602,16 @@ describe('Utils', () => { "type": "toggle", "value": null, }, + Object { + "key": "test_key_5", + "type": "number", + "value": 1234, + }, + Object { + "key": "test_key_6", + "type": "number", + "value": null, + }, Object { "key": "my_test_key", "type": "text", @@ -598,6 +635,8 @@ describe('Utils', () => { { ...customFieldsMock[1] }, { ...customFieldsMock[2] }, { ...customFieldsMock[3] }, + { ...customFieldsMock[4] }, + { ...customFieldsMock[5] }, ], ` Array [ @@ -626,6 +665,16 @@ describe('Utils', () => { "type": "toggle", "value": null, }, + Object { + "key": "test_key_5", + "type": "number", + "value": 1234, + }, + Object { + "key": "test_key_6", + "type": "number", + "value": null, + }, ] ` ); @@ -669,6 +718,19 @@ describe('Utils', () => { "required": false, "type": "toggle", }, + Object { + "defaultValue": 123, + "key": "test_key_5", + "label": "My test label 5", + "required": true, + "type": "number", + }, + Object { + "key": "test_key_6", + "label": "My test label 6", + "required": false, + "type": "number", + }, Object { "key": "my_test_key", "label": "my_test_label", @@ -693,6 +755,8 @@ describe('Utils', () => { { ...customFieldsConfigurationMock[1] }, { ...customFieldsConfigurationMock[2] }, { ...customFieldsConfigurationMock[3] }, + { ...customFieldsConfigurationMock[4] }, + { ...customFieldsConfigurationMock[5] }, ], ` Array [ @@ -722,6 +786,19 @@ describe('Utils', () => { "required": false, "type": "toggle", }, + Object { + "defaultValue": 123, + "key": "test_key_5", + "label": "My test label 5", + "required": true, + "type": "number", + }, + Object { + "key": "test_key_6", + "label": "My test label 6", + "required": false, + "type": "number", + }, ] ` ); diff --git a/x-pack/plugins/cases/public/components/utils.ts b/x-pack/plugins/cases/public/components/utils.ts index 7e1aa54554f50..bcc6be9a7ae9e 100644 --- a/x-pack/plugins/cases/public/components/utils.ts +++ b/x-pack/plugins/cases/public/components/utils.ts @@ -13,7 +13,7 @@ import type { } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; import type { ConnectorTypeFields } from '../../common/types/domain'; -import { ConnectorTypes } from '../../common/types/domain'; +import { ConnectorTypes, CustomFieldTypes } from '../../common/types/domain'; import type { CasesPublicStartDependencies } from '../types'; import { connectorValidator as swimlaneConnectorValidator } from './connectors/swimlane/validator'; import type { CaseActionConnector } from './types'; @@ -234,11 +234,25 @@ export const parseCaseUsers = ({ return { userProfiles, reporterAsArray }; }; -export const convertCustomFieldValue = (value: string | boolean) => { +export const convertCustomFieldValue = ({ + value, + type, +}: { + value: string | number | boolean | null; + type: CustomFieldTypes; +}) => { if (typeof value === 'string' && isEmpty(value)) { return null; } + if (type === CustomFieldTypes.NUMBER) { + if (value !== null && Number.isSafeInteger(Number(value))) { + return Number(value); + } else { + return null; + } + } + return value; }; @@ -288,7 +302,7 @@ export const customFieldsFormDeserializer = ( }; export const customFieldsFormSerializer = ( - customFields: Record, + customFields: Record, selectedCustomFieldsConfiguration: CasesConfigurationUI['customFields'] ): CaseUI['customFields'] => { const transformedCustomFields: CaseUI['customFields'] = []; @@ -303,7 +317,7 @@ export const customFieldsFormSerializer = ( transformedCustomFields.push({ key: configCustomField.key, type: configCustomField.type, - value: convertCustomFieldValue(value), + value: convertCustomFieldValue({ value, type: configCustomField.type }), } as CaseUICustomField); } } diff --git a/x-pack/plugins/cases/public/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts index 8d2feca6b9be0..c3cee2d60d2b0 100644 --- a/x-pack/plugins/cases/public/containers/mock.ts +++ b/x-pack/plugins/cases/public/containers/mock.ts @@ -1158,6 +1158,8 @@ export const customFieldsMock: CaseUICustomField[] = [ { type: CustomFieldTypes.TOGGLE, key: 'test_key_2', value: true }, { type: CustomFieldTypes.TEXT, key: 'test_key_3', value: null }, { type: CustomFieldTypes.TOGGLE, key: 'test_key_4', value: null }, + { type: CustomFieldTypes.NUMBER, key: 'test_key_5', value: 1234 }, + { type: CustomFieldTypes.NUMBER, key: 'test_key_6', value: null }, ]; export const customFieldsConfigurationMock: CasesConfigurationUICustomField[] = [ @@ -1177,6 +1179,19 @@ export const customFieldsConfigurationMock: CasesConfigurationUICustomField[] = }, { type: CustomFieldTypes.TEXT, key: 'test_key_3', label: 'My test label 3', required: false }, { type: CustomFieldTypes.TOGGLE, key: 'test_key_4', label: 'My test label 4', required: false }, + { + type: CustomFieldTypes.NUMBER, + key: 'test_key_5', + label: 'My test label 5', + required: true, + defaultValue: 123, + }, + { + type: CustomFieldTypes.NUMBER, + key: 'test_key_6', + label: 'My test label 6', + required: false, + }, ]; export const templatesConfigurationMock: CasesConfigurationUITemplate[] = [ diff --git a/x-pack/plugins/cases/public/containers/use_replace_custom_field.tsx b/x-pack/plugins/cases/public/containers/use_replace_custom_field.tsx index 5d2969f6e6d44..f1d0b87ff07e8 100644 --- a/x-pack/plugins/cases/public/containers/use_replace_custom_field.tsx +++ b/x-pack/plugins/cases/public/containers/use_replace_custom_field.tsx @@ -16,7 +16,7 @@ import * as i18n from './translations'; interface ReplaceCustomField { caseId: string; customFieldId: string; - customFieldValue: string | boolean | null; + customFieldValue: string | number | boolean | null; caseVersion: string; } diff --git a/x-pack/plugins/cases/server/client/cases/push.ts b/x-pack/plugins/cases/server/client/cases/push.ts index 85d853f825907..6d4561c7b5119 100644 --- a/x-pack/plugins/cases/server/client/cases/push.ts +++ b/x-pack/plugins/cases/server/client/cases/push.ts @@ -140,12 +140,6 @@ export const push = async ( operation: Operations.pushCase, }); - if (theCase?.status === CaseStatuses.closed) { - throw Boom.conflict( - `The ${theCase.title} case is closed. Pushing a closed case is not allowed.` - ); - } - const alertsInfo = getAlertInfoFromComments(theCase?.comments); const alerts = await getAlerts(alertsInfo, clientArgs); const profiles = await getProfiles(theCase, securityStartPlugin); diff --git a/x-pack/plugins/cases/server/client/utils.test.ts b/x-pack/plugins/cases/server/client/utils.test.ts index eb7aaea6d6938..680887b82c653 100644 --- a/x-pack/plugins/cases/server/client/utils.test.ts +++ b/x-pack/plugins/cases/server/client/utils.test.ts @@ -906,7 +906,7 @@ describe('utils', () => { ...customFieldsConfiguration, { key: 'fourth_key', - type: 'number', + type: 'symbol', label: 'Number field', required: true, }, diff --git a/x-pack/plugins/cases/server/common/types/configure.ts b/x-pack/plugins/cases/server/common/types/configure.ts index faf2517fbe173..27e66ba76eb02 100644 --- a/x-pack/plugins/cases/server/common/types/configure.ts +++ b/x-pack/plugins/cases/server/common/types/configure.ts @@ -39,7 +39,7 @@ type PersistedCustomFieldsConfiguration = Array<{ type: string; label: string; required: boolean; - defaultValue?: string | boolean | null; + defaultValue?: string | number | boolean | null; }>; type PersistedTemplatesConfiguration = Array<{ diff --git a/x-pack/plugins/cases/server/connectors/cases/constants.ts b/x-pack/plugins/cases/server/connectors/cases/constants.ts index fafd1a3e0eaeb..f1d0e548e1f3a 100644 --- a/x-pack/plugins/cases/server/connectors/cases/constants.ts +++ b/x-pack/plugins/cases/server/connectors/cases/constants.ts @@ -12,8 +12,11 @@ export const MAX_OPEN_CASES = 10; export const DEFAULT_MAX_OPEN_CASES = 5; export const INITIAL_ORACLE_RECORD_COUNTER = 1; -export const VALUES_FOR_CUSTOM_FIELDS_MISSING_DEFAULTS: Record = - { - [CustomFieldTypes.TEXT]: 'N/A', - [CustomFieldTypes.TOGGLE]: false, - }; +export const VALUES_FOR_CUSTOM_FIELDS_MISSING_DEFAULTS: Record< + CustomFieldTypes, + string | boolean | number +> = { + [CustomFieldTypes.TEXT]: 'N/A', + [CustomFieldTypes.TOGGLE]: false, + [CustomFieldTypes.NUMBER]: 0, +}; diff --git a/x-pack/plugins/cases/server/custom_fields/factory.ts b/x-pack/plugins/cases/server/custom_fields/factory.ts index d9e1bc86671fe..3b42dcfd6eddb 100644 --- a/x-pack/plugins/cases/server/custom_fields/factory.ts +++ b/x-pack/plugins/cases/server/custom_fields/factory.ts @@ -9,10 +9,12 @@ import { CustomFieldTypes } from '../../common/types/domain'; import type { ICasesCustomField, CasesCustomFieldsMap } from './types'; import { getCasesTextCustomField } from './text'; import { getCasesToggleCustomField } from './toggle'; +import { getCasesNumberCustomField } from './number'; const mapping: Record = { [CustomFieldTypes.TEXT]: getCasesTextCustomField(), [CustomFieldTypes.TOGGLE]: getCasesToggleCustomField(), + [CustomFieldTypes.NUMBER]: getCasesNumberCustomField(), }; export const casesCustomFields: CasesCustomFieldsMap = { diff --git a/x-pack/plugins/cases/server/custom_fields/number.ts b/x-pack/plugins/cases/server/custom_fields/number.ts new file mode 100644 index 0000000000000..f036a01cbe1b8 --- /dev/null +++ b/x-pack/plugins/cases/server/custom_fields/number.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 Boom from '@hapi/boom'; + +export const getCasesNumberCustomField = () => ({ + isFilterable: false, + isSortable: false, + savedObjectMappingType: 'long', + validateFilteringValues: (values: Array) => { + values.forEach((value) => { + if (value !== null && !Number.isSafeInteger(value)) { + throw Boom.badRequest('Unsupported filtering value for custom field of type number.'); + } + }); + }, +}); diff --git a/x-pack/plugins/cases/server/plugin.ts b/x-pack/plugins/cases/server/plugin.ts index fa172b48520a7..b40089ff75050 100644 --- a/x-pack/plugins/cases/server/plugin.ts +++ b/x-pack/plugins/cases/server/plugin.ts @@ -122,9 +122,14 @@ export class CasePlugin const router = core.http.createRouter(); const telemetryUsageCounter = plugins.usageCollection?.createUsageCounter(APP_ID); + const isServerless = plugins.cloud?.isServerlessEnabled; + registerRoutes({ router, - routes: [...getExternalRoutes(), ...getInternalRoutes(this.userProfileService)], + routes: [ + ...getExternalRoutes({ isServerless }), + ...getInternalRoutes(this.userProfileService), + ], logger: this.logger, kibanaVersion: this.kibanaVersion, telemetryUsageCounter, diff --git a/x-pack/plugins/cases/server/routes/api/cases/get_case.test.ts b/x-pack/plugins/cases/server/routes/api/cases/get_case.test.ts new file mode 100644 index 0000000000000..45ee5e8f47163 --- /dev/null +++ b/x-pack/plugins/cases/server/routes/api/cases/get_case.test.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 { createCasesClientMock } from '../../../client/mocks'; +import { getCaseRoute } from './get_case'; +import { httpServerMock, loggingSystemMock } from '@kbn/core/server/mocks'; + +describe('getCaseRoute', () => { + const casesClientMock = createCasesClientMock(); + const logger = loggingSystemMock.createLogger(); + const response = httpServerMock.createResponseFactory(); + const kibanaVersion = '8.17'; + const context = { cases: { getCasesClient: jest.fn().mockResolvedValue(casesClientMock) } }; + + it('throws a bad request if the includeComments is set in serverless', async () => { + const router = getCaseRoute({ isServerless: true }); + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{case_id}/?includeComments=true', + query: { includeComments: true }, + params: { case_id: 'foo' }, + }); + + await expect( + // @ts-expect-error: no need to create the context + router.handler({ response, request, logger, kibanaVersion, context }) + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "Failed to retrieve case in route case id: foo + include comments: true: Error: includeComments is not supported" + `); + }); + + it('does not throw a bad request if the includeComments is set in non-serverless', async () => { + const router = getCaseRoute({ isServerless: false }); + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{case_id}/?includeComments=true', + query: { includeComments: true }, + params: { case_id: 'foo' }, + }); + + await expect( + // @ts-expect-error: no need to create the context + router.handler({ response, request, logger, kibanaVersion, context }) + ).resolves.not.toThrow(); + }); + + it('does not throw a bad request if the includeComments is not set in serverless', async () => { + const router = getCaseRoute({ isServerless: true }); + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{case_id}', + params: { case_id: 'foo' }, + }); + + await expect( + // @ts-expect-error: no need to create the context + router.handler({ response, request, logger, kibanaVersion, context }) + ).resolves.not.toThrow(); + }); +}); diff --git a/x-pack/plugins/cases/server/routes/api/cases/get_case.ts b/x-pack/plugins/cases/server/routes/api/cases/get_case.ts index 831a7be129f70..158360ff7b23f 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/get_case.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/get_case.ts @@ -5,6 +5,7 @@ * 2.0. */ +import Boom from '@hapi/boom'; import { schema } from '@kbn/config-schema'; import type { caseApiV1 } from '../../../../common/types/api'; @@ -26,53 +27,58 @@ const params = { }), }; -export const getCaseRoute = createCasesRoute({ - method: 'get', - path: CASE_DETAILS_URL, - params, - routerOptions: { - access: 'public', - summary: `Get a case`, - tags: ['oas-tag:cases'], - }, - handler: async ({ context, request, response, logger, kibanaVersion }) => { - try { - const isIncludeCommentsParamProvidedByTheUser = - request.url.searchParams.has('includeComments'); +export const getCaseRoute = ({ isServerless }: { isServerless?: boolean }) => + createCasesRoute({ + method: 'get', + path: CASE_DETAILS_URL, + params, + routerOptions: { + access: 'public', + summary: `Get a case`, + tags: ['oas-tag:cases'], + }, + handler: async ({ context, request, response, logger, kibanaVersion }) => { + try { + const isIncludeCommentsParamProvidedByTheUser = + request.url.searchParams.has('includeComments'); - if (isIncludeCommentsParamProvidedByTheUser) { - logDeprecatedEndpoint( - logger, - request.headers, - `The query parameter 'includeComments' of the get case API '${CASE_DETAILS_URL}' is deprecated` - ); - } + if (isServerless && isIncludeCommentsParamProvidedByTheUser) { + throw Boom.badRequest('includeComments is not supported'); + } - const caseContext = await context.cases; - const casesClient = await caseContext.getCasesClient(); - const id = request.params.case_id; + if (isIncludeCommentsParamProvidedByTheUser) { + logDeprecatedEndpoint( + logger, + request.headers, + `The query parameter 'includeComments' of the get case API '${CASE_DETAILS_URL}' is deprecated` + ); + } - const res: caseDomainV1.Case = await casesClient.cases.get({ - id, - includeComments: request.query.includeComments, - }); + const caseContext = await context.cases; + const casesClient = await caseContext.getCasesClient(); + const id = request.params.case_id; - return response.ok({ - ...(isIncludeCommentsParamProvidedByTheUser && { - headers: { - ...getWarningHeader(kibanaVersion, 'Deprecated query parameter includeComments'), - }, - }), - body: res, - }); - } catch (error) { - throw createCaseError({ - message: `Failed to retrieve case in route case id: ${request.params.case_id} \ninclude comments: ${request.query.includeComments}: ${error}`, - error, - }); - } - }, -}); + const res: caseDomainV1.Case = await casesClient.cases.get({ + id, + includeComments: request.query.includeComments, + }); + + return response.ok({ + ...(isIncludeCommentsParamProvidedByTheUser && { + headers: { + ...getWarningHeader(kibanaVersion, 'Deprecated query parameter includeComments'), + }, + }), + body: res, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to retrieve case in route case id: ${request.params.case_id} \ninclude comments: ${request.query.includeComments}: ${error}`, + error, + }); + } + }, + }); export const resolveCaseRoute = createCasesRoute({ method: 'get', diff --git a/x-pack/plugins/cases/server/routes/api/comments/get_all_comment.test.ts b/x-pack/plugins/cases/server/routes/api/comments/get_all_comment.test.ts new file mode 100644 index 0000000000000..9687e73d1f7c8 --- /dev/null +++ b/x-pack/plugins/cases/server/routes/api/comments/get_all_comment.test.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getAllCommentsRoute } from './get_all_comment'; + +describe('getAllCommentsRoute', () => { + it('marks the endpoint internal in serverless', async () => { + const router = getAllCommentsRoute({ isServerless: true }); + + expect(router.routerOptions?.access).toBe('internal'); + }); + + it('marks the endpoint public in non-serverless', async () => { + const router = getAllCommentsRoute({ isServerless: false }); + + expect(router.routerOptions?.access).toBe('public'); + }); +}); diff --git a/x-pack/plugins/cases/server/routes/api/comments/get_all_comment.ts b/x-pack/plugins/cases/server/routes/api/comments/get_all_comment.ts index 6e8ac79bffec9..0f84ed29dce29 100644 --- a/x-pack/plugins/cases/server/routes/api/comments/get_all_comment.ts +++ b/x-pack/plugins/cases/server/routes/api/comments/get_all_comment.ts @@ -15,41 +15,42 @@ import type { attachmentDomainV1 } from '../../../../common/types/domain'; /** * @deprecated since version 8.1.0 */ -export const getAllCommentsRoute = createCasesRoute({ - method: 'get', - path: CASE_COMMENTS_URL, - params: { - params: schema.object({ - case_id: schema.string(), - }), - }, - options: { - deprecated: true, - }, - routerOptions: { - access: 'public', - summary: `Gets all case comments`, - tags: ['oas-tag:cases'], - // description: 'You must have `read` privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the cases with the comments you\'re seeking.', - // @ts-expect-error TODO(https://github.com/elastic/kibana/issues/196095): Replace {RouteDeprecationInfo} - deprecated: true, - }, - handler: async ({ context, request, response }) => { - try { - const caseContext = await context.cases; - const client = await caseContext.getCasesClient(); - const res: attachmentDomainV1.Attachments = await client.attachments.getAll({ - caseID: request.params.case_id, - }); +export const getAllCommentsRoute = ({ isServerless }: { isServerless?: boolean }) => + createCasesRoute({ + method: 'get', + path: CASE_COMMENTS_URL, + params: { + params: schema.object({ + case_id: schema.string(), + }), + }, + options: { + deprecated: true, + }, + routerOptions: { + access: isServerless ? 'internal' : 'public', + summary: `Gets all case comments`, + tags: ['oas-tag:cases'], + // description: 'You must have `read` privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the cases with the comments you\'re seeking.', + // @ts-expect-error TODO(https://github.com/elastic/kibana/issues/196095): Replace {RouteDeprecationInfo} + deprecated: true, + }, + handler: async ({ context, request, response }) => { + try { + const caseContext = await context.cases; + const client = await caseContext.getCasesClient(); + const res: attachmentDomainV1.Attachments = await client.attachments.getAll({ + caseID: request.params.case_id, + }); - return response.ok({ - body: res, - }); - } catch (error) { - throw createCaseError({ - message: `Failed to get all comments in route case id: ${request.params.case_id}: ${error}`, - error, - }); - } - }, -}); + return response.ok({ + body: res, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to get all comments in route case id: ${request.params.case_id}: ${error}`, + error, + }); + } + }, + }); diff --git a/x-pack/plugins/cases/server/routes/api/get_external_routes.ts b/x-pack/plugins/cases/server/routes/api/get_external_routes.ts index bd990deefbdfa..4412d0b695079 100644 --- a/x-pack/plugins/cases/server/routes/api/get_external_routes.ts +++ b/x-pack/plugins/cases/server/routes/api/get_external_routes.ts @@ -31,18 +31,18 @@ import { postCaseConfigureRoute } from './configure/post_configure'; import { getAllAlertsAttachedToCaseRoute } from './comments/get_alerts'; import { findUserActionsRoute } from './user_actions/find_user_actions'; -export const getExternalRoutes = () => +export const getExternalRoutes = ({ isServerless }: { isServerless?: boolean }) => [ deleteCaseRoute, findCaseRoute, - getCaseRoute, + getCaseRoute({ isServerless }), resolveCaseRoute, patchCaseRoute, postCaseRoute, pushCaseRoute, findUserActionsRoute, - getUserActionsRoute, - getStatusRoute, + getUserActionsRoute({ isServerless }), + getStatusRoute({ isServerless }), getCasesByAlertIdRoute, getReportersRoute, getTagsRoute, @@ -50,7 +50,7 @@ export const getExternalRoutes = () => deleteAllCommentsRoute, findCommentsRoute, getCommentRoute, - getAllCommentsRoute, + getAllCommentsRoute({ isServerless }), patchCommentRoute, postCommentRoute, getCaseConfigureRoute, diff --git a/x-pack/plugins/cases/server/routes/api/stats/get_status.test.ts b/x-pack/plugins/cases/server/routes/api/stats/get_status.test.ts new file mode 100644 index 0000000000000..9376a46b76808 --- /dev/null +++ b/x-pack/plugins/cases/server/routes/api/stats/get_status.test.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getStatusRoute } from './get_status'; + +describe('getStatusRoute', () => { + it('marks the endpoint internal in serverless', async () => { + const router = getStatusRoute({ isServerless: true }); + + expect(router.routerOptions?.access).toBe('internal'); + }); + + it('marks the endpoint public in non-serverless', async () => { + const router = getStatusRoute({ isServerless: false }); + + expect(router.routerOptions?.access).toBe('public'); + }); +}); diff --git a/x-pack/plugins/cases/server/routes/api/stats/get_status.ts b/x-pack/plugins/cases/server/routes/api/stats/get_status.ts index dce369e4a0f45..0889644f6a80a 100644 --- a/x-pack/plugins/cases/server/routes/api/stats/get_status.ts +++ b/x-pack/plugins/cases/server/routes/api/stats/get_status.ts @@ -15,37 +15,38 @@ import type { statsApiV1 } from '../../../../common/types/api'; /** * @deprecated since version 8.1.0 */ -export const getStatusRoute: CaseRoute = createCasesRoute({ - method: 'get', - path: CASE_STATUS_URL, - options: { deprecated: true }, - routerOptions: { - access: 'public', - summary: `Get case status summary`, - tags: ['oas-tag:cases'], - description: - 'Returns the number of cases that are open, closed, and in progress in the default space.', - // You must have `read` privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the cases you're seeking. - // @ts-expect-error TODO(https://github.com/elastic/kibana/issues/196095): Replace {RouteDeprecationInfo} - deprecated: true, - }, - handler: async ({ context, request, response }) => { - try { - const caseContext = await context.cases; - const client = await caseContext.getCasesClient(); +export const getStatusRoute = ({ isServerless }: { isServerless?: boolean }): CaseRoute => + createCasesRoute({ + method: 'get', + path: CASE_STATUS_URL, + options: { deprecated: true }, + routerOptions: { + access: isServerless ? 'internal' : 'public', + summary: `Get case status summary`, + tags: ['oas-tag:cases'], + description: + 'Returns the number of cases that are open, closed, and in progress in the default space.', + // You must have `read` privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the cases you're seeking. + // @ts-expect-error TODO(https://github.com/elastic/kibana/issues/196095): Replace {RouteDeprecationInfo} + deprecated: true, + }, + handler: async ({ context, request, response }) => { + try { + const caseContext = await context.cases; + const client = await caseContext.getCasesClient(); - const res: statsApiV1.CasesStatusResponse = await client.metrics.getStatusTotalsByType( - request.query as statsApiV1.CasesStatusRequest - ); + const res: statsApiV1.CasesStatusResponse = await client.metrics.getStatusTotalsByType( + request.query as statsApiV1.CasesStatusRequest + ); - return response.ok({ - body: res, - }); - } catch (error) { - throw createCaseError({ - message: `Failed to get status stats in route: ${error}`, - error, - }); - } - }, -}); + return response.ok({ + body: res, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to get status stats in route: ${error}`, + error, + }); + } + }, + }); diff --git a/x-pack/plugins/cases/server/routes/api/user_actions/get_all_user_actions.test.ts b/x-pack/plugins/cases/server/routes/api/user_actions/get_all_user_actions.test.ts new file mode 100644 index 0000000000000..d99b90c29bbb4 --- /dev/null +++ b/x-pack/plugins/cases/server/routes/api/user_actions/get_all_user_actions.test.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getUserActionsRoute } from './get_all_user_actions'; + +describe('getUserActionsRoute', () => { + it('marks the endpoint internal in serverless', async () => { + const router = getUserActionsRoute({ isServerless: true }); + + expect(router.routerOptions?.access).toBe('internal'); + }); + + it('marks the endpoint public in non-serverless', async () => { + const router = getUserActionsRoute({ isServerless: false }); + + expect(router.routerOptions?.access).toBe('public'); + }); +}); diff --git a/x-pack/plugins/cases/server/routes/api/user_actions/get_all_user_actions.ts b/x-pack/plugins/cases/server/routes/api/user_actions/get_all_user_actions.ts index 17fe0dcdb9012..19d7f1f8956ac 100644 --- a/x-pack/plugins/cases/server/routes/api/user_actions/get_all_user_actions.ts +++ b/x-pack/plugins/cases/server/routes/api/user_actions/get_all_user_actions.ts @@ -15,41 +15,42 @@ import { createCasesRoute } from '../create_cases_route'; /** * @deprecated since version 8.1.0 */ -export const getUserActionsRoute = createCasesRoute({ - method: 'get', - path: CASE_USER_ACTIONS_URL, - params: { - params: schema.object({ - case_id: schema.string(), - }), - }, - options: { deprecated: true }, - routerOptions: { - access: 'public', - summary: 'Get case activity', - description: `Returns all user activity for a case.`, - // You must have `read` privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the case you're seeking. - tags: ['oas-tag:cases'], - // @ts-expect-error TODO(https://github.com/elastic/kibana/issues/196095): Replace {RouteDeprecationInfo} - deprecated: true, - }, - handler: async ({ context, request, response }) => { - try { - const caseContext = await context.cases; - const casesClient = await caseContext.getCasesClient(); - const caseId = request.params.case_id; +export const getUserActionsRoute = ({ isServerless }: { isServerless?: boolean }) => + createCasesRoute({ + method: 'get', + path: CASE_USER_ACTIONS_URL, + params: { + params: schema.object({ + case_id: schema.string(), + }), + }, + options: { deprecated: true }, + routerOptions: { + access: isServerless ? 'internal' : 'public', + summary: 'Get case activity', + description: `Returns all user activity for a case.`, + // You must have `read` privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the case you're seeking. + tags: ['oas-tag:cases'], + // @ts-expect-error TODO(https://github.com/elastic/kibana/issues/196095): Replace {RouteDeprecationInfo} + deprecated: true, + }, + handler: async ({ context, request, response }) => { + try { + const caseContext = await context.cases; + const casesClient = await caseContext.getCasesClient(); + const caseId = request.params.case_id; - const res: userActionApiV1.CaseUserActionsDeprecatedResponse = - await casesClient.userActions.getAll({ caseId }); + const res: userActionApiV1.CaseUserActionsDeprecatedResponse = + await casesClient.userActions.getAll({ caseId }); - return response.ok({ - body: res, - }); - } catch (error) { - throw createCaseError({ - message: `Failed to retrieve case user actions in route case id: ${request.params.case_id}: ${error}`, - error, - }); - } - }, -}); + return response.ok({ + body: res, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to retrieve case user actions in route case id: ${request.params.case_id}: ${error}`, + error, + }); + } + }, + }); diff --git a/x-pack/plugins/cases/server/saved_object_types/cases/model_versions.test.ts b/x-pack/plugins/cases/server/saved_object_types/cases/model_versions.test.ts index 8520fd9673d31..2c301709ca5c9 100644 --- a/x-pack/plugins/cases/server/saved_object_types/cases/model_versions.test.ts +++ b/x-pack/plugins/cases/server/saved_object_types/cases/model_versions.test.ts @@ -10,51 +10,49 @@ import { modelVersion1 } from './model_versions'; describe('Model versions', () => { describe('1', () => { it('returns the model version correctly', () => { - expect(modelVersion1).toMatchInlineSnapshot(` - Object { - "changes": Array [ - Object { - "addedMappings": Object { - "customFields": Object { - "properties": Object { - "key": Object { - "type": "keyword", - }, - "type": Object { - "type": "keyword", - }, - "value": Object { - "fields": Object { - "boolean": Object { - "ignore_malformed": true, - "type": "boolean", - }, - "date": Object { - "ignore_malformed": true, - "type": "date", - }, - "ip": Object { - "ignore_malformed": true, - "type": "ip", - }, - "number": Object { - "ignore_malformed": true, - "type": "long", - }, - "string": Object { - "type": "text", - }, + expect(modelVersion1.changes).toMatchInlineSnapshot(` + Array [ + Object { + "addedMappings": Object { + "customFields": Object { + "properties": Object { + "key": Object { + "type": "keyword", + }, + "type": Object { + "type": "keyword", + }, + "value": Object { + "fields": Object { + "boolean": Object { + "ignore_malformed": true, + "type": "boolean", + }, + "date": Object { + "ignore_malformed": true, + "type": "date", + }, + "ip": Object { + "ignore_malformed": true, + "type": "ip", + }, + "number": Object { + "ignore_malformed": true, + "type": "long", + }, + "string": Object { + "type": "text", }, - "type": "keyword", }, + "type": "keyword", }, - "type": "nested", }, + "type": "nested", }, - "type": "mappings_addition", }, - ], - } + "type": "mappings_addition", + }, + ] `); }); }); diff --git a/x-pack/plugins/cases/server/saved_object_types/cases/model_versions.ts b/x-pack/plugins/cases/server/saved_object_types/cases/model_versions.ts index 56806e7dec607..7d46789a3b79f 100644 --- a/x-pack/plugins/cases/server/saved_object_types/cases/model_versions.ts +++ b/x-pack/plugins/cases/server/saved_object_types/cases/model_versions.ts @@ -6,6 +6,7 @@ */ import type { SavedObjectsModelVersion } from '@kbn/core-saved-objects-server'; +import { casesSchemaV1 } from './schemas'; /** * Adds custom fields to the cases SO. @@ -54,4 +55,7 @@ export const modelVersion1: SavedObjectsModelVersion = { }, }, ], + schemas: { + forwardCompatibility: casesSchemaV1.extends({}, { unknowns: 'ignore' }), + }, }; diff --git a/x-pack/plugins/cases/server/saved_object_types/cases/schemas/index.ts b/x-pack/plugins/cases/server/saved_object_types/cases/schemas/index.ts new file mode 100644 index 0000000000000..85d9239f72dba --- /dev/null +++ b/x-pack/plugins/cases/server/saved_object_types/cases/schemas/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './latest'; + +export { casesSchema as casesSchemaV1 } from './v1'; diff --git a/x-pack/plugins/cases/server/saved_object_types/cases/schemas/latest.ts b/x-pack/plugins/cases/server/saved_object_types/cases/schemas/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/cases/server/saved_object_types/cases/schemas/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/plugins/cases/server/saved_object_types/cases/schemas/v1.ts b/x-pack/plugins/cases/server/saved_object_types/cases/schemas/v1.ts new file mode 100644 index 0000000000000..1a6bb0a8cdd8c --- /dev/null +++ b/x-pack/plugins/cases/server/saved_object_types/cases/schemas/v1.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +const UserSchema = schema.object({ + email: schema.nullable(schema.string()), + full_name: schema.nullable(schema.string()), + username: schema.nullable(schema.string()), + profile_uid: schema.nullable(schema.string()), +}); + +const UserProfileSchema = schema.object({ uid: schema.string() }); + +const ConnectorSchema = schema.object({ + name: schema.string(), + type: schema.string(), + fields: schema.arrayOf(schema.object({ key: schema.string(), value: schema.string() })), +}); + +const ExternalServiceSchema = schema.object({ + connector_name: schema.string(), + external_id: schema.string(), + external_title: schema.string(), + external_url: schema.string(), + pushed_at: schema.string(), + pushed_by: UserSchema, +}); + +const SettingsSchema = schema.object({ syncAlerts: schema.boolean() }); + +const CustomFieldsSchema = schema.arrayOf( + schema.object({ + key: schema.string(), + type: schema.string(), + value: schema.nullable(schema.any()), + }) +); + +export const casesSchema = schema.object({ + assignees: schema.arrayOf(UserProfileSchema), + category: schema.maybe(schema.nullable(schema.string())), + closed_at: schema.nullable(schema.string()), + closed_by: schema.nullable(UserSchema), + created_at: schema.string(), + created_by: UserSchema, + connector: ConnectorSchema, + customFields: schema.maybe(schema.nullable(CustomFieldsSchema)), + description: schema.string(), + duration: schema.nullable(schema.number()), + external_service: schema.nullable(ExternalServiceSchema), + owner: schema.string(), + settings: SettingsSchema, + severity: schema.oneOf([ + schema.literal(10), + schema.literal(20), + schema.literal(30), + schema.literal(40), + ]), + status: schema.oneOf([schema.literal(0), schema.literal(10), schema.literal(20)]), + tags: schema.arrayOf(schema.string()), + title: schema.string(), + total_alerts: schema.number(), + total_comments: schema.number(), + updated_at: schema.nullable(schema.string()), + updated_by: schema.nullable(UserSchema), +}); diff --git a/x-pack/plugins/cloud/common/parse_onboarding_default_solution.ts b/x-pack/plugins/cloud/common/parse_onboarding_default_solution.ts index 5b064eecce12d..483e6771394d2 100644 --- a/x-pack/plugins/cloud/common/parse_onboarding_default_solution.ts +++ b/x-pack/plugins/cloud/common/parse_onboarding_default_solution.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { OnBoardingDefaultSolution } from './types'; +import type { SolutionId } from '@kbn/core-chrome-browser'; /** * Cloud does not type the value of the "use case" that is set during onboarding for a deployment. Any string can @@ -14,12 +14,12 @@ import type { OnBoardingDefaultSolution } from './types'; * @param value The solution value set by Cloud. * @returns The default solution value for onboarding that matches Kibana naming. */ -export function parseOnboardingSolution(value?: string): OnBoardingDefaultSolution | undefined { +export function parseOnboardingSolution(value?: string): SolutionId | undefined { if (!value) return; const solutions: Array<{ cloudValue: 'search' | 'elasticsearch' | 'observability' | 'security'; - kibanaValue: OnBoardingDefaultSolution; + kibanaValue: SolutionId; }> = [ { cloudValue: 'search', diff --git a/x-pack/plugins/cloud/common/types.ts b/x-pack/plugins/cloud/common/types.ts index 0f72caf515058..b3a32270af6cc 100644 --- a/x-pack/plugins/cloud/common/types.ts +++ b/x-pack/plugins/cloud/common/types.ts @@ -5,8 +5,6 @@ * 2.0. */ -export type OnBoardingDefaultSolution = 'es' | 'oblt' | 'security'; - export interface ElasticsearchConfigType { elasticsearch_url?: string; } diff --git a/x-pack/plugins/cloud/kibana.jsonc b/x-pack/plugins/cloud/kibana.jsonc index 17edf376bf5cb..18698c2a654b0 100644 --- a/x-pack/plugins/cloud/kibana.jsonc +++ b/x-pack/plugins/cloud/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/cloud-plugin", - "owner": "@elastic/kibana-core", + "owner": [ + "@elastic/kibana-core" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "cloud", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "cloud" @@ -14,4 +18,4 @@ "usageCollection" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/cloud/public/types.ts b/x-pack/plugins/cloud/public/types.ts index 2a6140ba8e97e..91972b69ec4ef 100644 --- a/x-pack/plugins/cloud/public/types.ts +++ b/x-pack/plugins/cloud/public/types.ts @@ -5,8 +5,8 @@ * 2.0. */ +import type { SolutionId } from '@kbn/core-chrome-browser'; import type { FC, PropsWithChildren } from 'react'; -import type { OnBoardingDefaultSolution } from '../common'; export interface CloudStart { /** @@ -192,7 +192,7 @@ export interface CloudSetup { /** * The default solution selected during onboarding. */ - defaultSolution?: OnBoardingDefaultSolution; + defaultSolution?: SolutionId; }; /** * `true` when running on Serverless Elastic Cloud diff --git a/x-pack/plugins/cloud/server/plugin.ts b/x-pack/plugins/cloud/server/plugin.ts index 9f45b5398ac22..9821aa318e264 100644 --- a/x-pack/plugins/cloud/server/plugin.ts +++ b/x-pack/plugins/cloud/server/plugin.ts @@ -8,10 +8,10 @@ import type { Logger } from '@kbn/logging'; import type { CoreSetup, Plugin, PluginInitializerContext } from '@kbn/core/server'; import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; +import type { SolutionId } from '@kbn/core-chrome-browser'; import { registerCloudDeploymentMetadataAnalyticsContext } from '../common/register_cloud_deployment_id_analytics_context'; import type { CloudConfigType } from './config'; import { registerCloudUsageCollector } from './collectors'; -import type { OnBoardingDefaultSolution } from '../common'; import { getIsCloudEnabled } from '../common/is_cloud_enabled'; import { parseDeploymentIdFromDeploymentUrl } from '../common/parse_deployment_id_from_deployment_url'; import { decodeCloudId, DecodedCloudId } from '../common/decode_cloud_id'; @@ -108,7 +108,7 @@ export interface CloudSetup { /** * The default solution selected during onboarding. */ - defaultSolution?: OnBoardingDefaultSolution; + defaultSolution?: SolutionId; }; /** * `true` when running on Serverless Elastic Cloud diff --git a/x-pack/plugins/cloud/tsconfig.json b/x-pack/plugins/cloud/tsconfig.json index fdf2ad6db58cd..dd25064897758 100644 --- a/x-pack/plugins/cloud/tsconfig.json +++ b/x-pack/plugins/cloud/tsconfig.json @@ -12,6 +12,7 @@ ], "kbn_references": [ "@kbn/core", + "@kbn/core-chrome-browser", "@kbn/usage-collection-plugin", "@kbn/config-schema", "@kbn/logging-mocks", diff --git a/x-pack/plugins/cloud_defend/kibana.jsonc b/x-pack/plugins/cloud_defend/kibana.jsonc index d7854913945ff..f0155401048d2 100644 --- a/x-pack/plugins/cloud_defend/kibana.jsonc +++ b/x-pack/plugins/cloud_defend/kibana.jsonc @@ -1,13 +1,20 @@ { "type": "plugin", "id": "@kbn/cloud-defend-plugin", - "owner": "@elastic/kibana-cloud-security-posture", + "owner": [ + "@elastic/kibana-cloud-security-posture" + ], + "group": "security", + "visibility": "private", "description": "Defend for containers (D4C)", "plugin": { "id": "cloudDefend", - "server": true, "browser": true, - "configPath": ["xpack", "cloudDefend"], + "server": true, + "configPath": [ + "xpack", + "cloudDefend" + ], "requiredPlugins": [ "navigation", "data", @@ -19,7 +26,12 @@ "licensing", "kubernetesSecurity" ], - "optionalPlugins": ["usageCollection"], - "requiredBundles": ["kibanaReact", "usageCollection"] + "optionalPlugins": [ + "usageCollection" + ], + "requiredBundles": [ + "kibanaReact", + "usageCollection" + ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/cloud_integrations/cloud_chat/kibana.jsonc b/x-pack/plugins/cloud_integrations/cloud_chat/kibana.jsonc index dad2a22752df1..1a1c833e59ca8 100644 --- a/x-pack/plugins/cloud_integrations/cloud_chat/kibana.jsonc +++ b/x-pack/plugins/cloud_integrations/cloud_chat/kibana.jsonc @@ -1,7 +1,11 @@ { "type": "plugin", "id": "@kbn/cloud-chat-plugin", - "owner": "@elastic/kibana-core", + "owner": [ + "@elastic/kibana-core" + ], + "group": "platform", + "visibility": "private", "description": "Chat available on Elastic Cloud deployments for quicker assistance.", "plugin": { "id": "cloudChat", @@ -15,9 +19,7 @@ "requiredPlugins": [ "cloud" ], - "requiredBundles": [ - ], - "optionalPlugins": [ - ] + "optionalPlugins": [], + "requiredBundles": [] } } diff --git a/x-pack/plugins/cloud_integrations/cloud_data_migration/kibana.jsonc b/x-pack/plugins/cloud_integrations/cloud_data_migration/kibana.jsonc index ea019ef61b15e..d2b9883a0c741 100644 --- a/x-pack/plugins/cloud_integrations/cloud_data_migration/kibana.jsonc +++ b/x-pack/plugins/cloud_integrations/cloud_data_migration/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/cloud-data-migration-plugin", - "owner": "@elastic/kibana-management", + "owner": [ + "@elastic/kibana-management" + ], + "group": "platform", + "visibility": "private", "description": "Static migration page where self-managed users can see text/copy about migrating to Elastic Cloud", "plugin": { "id": "cloudDataMigration", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "cloud_integrations", @@ -22,4 +26,4 @@ "kibanaReact" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/kibana.jsonc b/x-pack/plugins/cloud_integrations/cloud_experiments/kibana.jsonc index 3c6b9f8279f01..8ea0fd75ea553 100644 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/kibana.jsonc +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/cloud-experiments-plugin", - "owner": "@elastic/kibana-core", + "owner": [ + "@elastic/kibana-core" + ], + "group": "platform", + "visibility": "shared", "description": "Provides the necessary APIs to implement A/B testing scenarios, fetching the variations in configuration and reporting back metrics to track conversion rates of the experiments.", "plugin": { "id": "cloudExperiments", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "cloud_integrations", @@ -18,4 +22,4 @@ "usageCollection" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/cloud_integrations/cloud_full_story/kibana.jsonc b/x-pack/plugins/cloud_integrations/cloud_full_story/kibana.jsonc index 53a42a6e903f2..e9bb4a8df07dd 100644 --- a/x-pack/plugins/cloud_integrations/cloud_full_story/kibana.jsonc +++ b/x-pack/plugins/cloud_integrations/cloud_full_story/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/cloud-full-story-plugin", - "owner": "@elastic/kibana-core", + "owner": [ + "@elastic/kibana-core" + ], + "group": "platform", + "visibility": "private", "description": "When Kibana runs on Elastic Cloud, this plugin registers FullStory as a shipper for telemetry.", "plugin": { "id": "cloudFullStory", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "cloud_integrations", @@ -19,4 +23,4 @@ "security" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/cloud_integrations/cloud_links/kibana.jsonc b/x-pack/plugins/cloud_integrations/cloud_links/kibana.jsonc index a8dbc9b23af63..46259fa3072a5 100644 --- a/x-pack/plugins/cloud_integrations/cloud_links/kibana.jsonc +++ b/x-pack/plugins/cloud_integrations/cloud_links/kibana.jsonc @@ -1,20 +1,24 @@ { "type": "plugin", "id": "@kbn/cloud-links-plugin", - "owner": "@elastic/kibana-core", + "owner": [ + "@elastic/kibana-core" + ], + "group": "platform", + "visibility": "private", "description": "Adds the links to the Elastic Cloud console", "plugin": { "id": "cloudLinks", - "server": false, "browser": true, + "server": false, + "requiredPlugins": [ + "share" + ], "optionalPlugins": [ "cloud", "security", "guidedOnboarding" ], - "requiredBundles": [], - "requiredPlugins": [ - "share" - ] + "requiredBundles": [] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/cloud_security_posture/common/constants.ts b/x-pack/plugins/cloud_security_posture/common/constants.ts index efc56a0da7995..80a6532c4a094 100644 --- a/x-pack/plugins/cloud_security_posture/common/constants.ts +++ b/x-pack/plugins/cloud_security_posture/common/constants.ts @@ -174,4 +174,4 @@ export const SINGLE_ACCOUNT = 'single-account'; export const CLOUD_SECURITY_PLUGIN_VERSION = '1.9.0'; // Cloud Credentials Template url was implemented in 1.10.0-preview01. See PR - https://github.com/elastic/integrations/pull/9828 -export const CLOUD_CREDENTIALS_PACKAGE_VERSION = '1.11.0-preview10'; +export const CLOUD_CREDENTIALS_PACKAGE_VERSION = '1.11.0-preview13'; diff --git a/x-pack/plugins/cloud_security_posture/common/utils/helpers.ts b/x-pack/plugins/cloud_security_posture/common/utils/helpers.ts index 90e11734d72c6..86eb6ba963402 100644 --- a/x-pack/plugins/cloud_security_posture/common/utils/helpers.ts +++ b/x-pack/plugins/cloud_security_posture/common/utils/helpers.ts @@ -214,7 +214,7 @@ export const getBenchmarkApplicableTo = (benchmarkId: BenchmarksCisId) => { }; export const getCloudProviderNameFromAbbreviation = (cloudProvider: string) => { - switch (cloudProvider) { + switch (cloudProvider.toLowerCase()) { case 'azure': return CLOUD_PROVIDER_NAMES.AZURE; case 'aws': diff --git a/x-pack/plugins/cloud_security_posture/kibana.jsonc b/x-pack/plugins/cloud_security_posture/kibana.jsonc index d1aacf2f340fc..d43f37fd70484 100644 --- a/x-pack/plugins/cloud_security_posture/kibana.jsonc +++ b/x-pack/plugins/cloud_security_posture/kibana.jsonc @@ -1,13 +1,20 @@ { "type": "plugin", "id": "@kbn/cloud-security-posture-plugin", - "owner": "@elastic/kibana-cloud-security-posture", + "owner": [ + "@elastic/kibana-cloud-security-posture" + ], + "group": "security", + "visibility": "private", "description": "The cloud security posture plugin", "plugin": { "id": "cloudSecurityPosture", - "server": true, "browser": true, - "configPath": ["xpack", "cloudSecurityPosture"], + "server": true, + "configPath": [ + "xpack", + "cloudSecurityPosture" + ], "requiredPlugins": [ "navigation", "data", @@ -25,7 +32,12 @@ "alerting", "spaces" ], - "optionalPlugins": ["usageCollection"], - "requiredBundles": ["kibanaReact", "usageCollection"] + "optionalPlugins": [ + "usageCollection" + ], + "requiredBundles": [ + "kibanaReact", + "usageCollection" + ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/cloud_security_posture/public/common/constants.ts b/x-pack/plugins/cloud_security_posture/public/common/constants.ts index 50d191cf07167..ea3866cbe1256 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/constants.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/constants.ts @@ -256,3 +256,29 @@ export const VULNERABILITY_GROUPING_OPTIONS = { CLOUD_ACCOUNT_NAME: VULNERABILITY_FIELDS.CLOUD_ACCOUNT_NAME, CVE: VULNERABILITY_FIELDS.VULNERABILITY_ID, }; + +/* +The fields below are default columns of the Cloud Security Data Table that need to have keyword mapping. +The runtime mappings are used to prevent filtering out the data when any of these columns are sorted in the Data Table. +TODO: Remove the fields below once they are mapped as Keyword in the Third Party integrations, or remove +the fields from the runtime mappings if they are removed from the Data Table. +*/ +export const CDR_VULNERABILITY_DATA_TABLE_RUNTIME_MAPPING_FIELDS: string[] = []; +export const CDR_MISCONFIGURATION_DATA_TABLE_RUNTIME_MAPPING_FIELDS: string[] = [ + 'rule.benchmark.rule_number', + 'rule.section', + 'resource.sub_type', +]; + +/* +The fields below are used to group the data in the Cloud Security Data Table. +The keys are the fields that are used to group the data, and the values are the fields that need to have keyword mapping +to prevent filtering out the data when grouping by the key field. +TODO: Remove the fields below once they are mapped as Keyword in the Third Party integrations, or remove +the fields from the runtime mappings if they are removed from the Data Table. +*/ +export const CDR_VULNERABILITY_GROUPING_RUNTIME_MAPPING_FIELDS: Record = {}; +export const CDR_MISCONFIGURATION_GROUPING_RUNTIME_MAPPING_FIELDS: Record = { + [FINDINGS_GROUPING_OPTIONS.ORCHESTRATOR_CLUSTER_NAME]: ['orchestrator.cluster.name'], + [FINDINGS_GROUPING_OPTIONS.CLOUD_ACCOUNT_NAME]: ['cloud.account.name'], +}; diff --git a/x-pack/plugins/cloud_security_posture/public/components/cloud_provider_icon.tsx b/x-pack/plugins/cloud_security_posture/public/components/cloud_provider_icon.tsx index b6acdac0ee1b1..a022e38960894 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/cloud_provider_icon.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/cloud_provider_icon.tsx @@ -18,7 +18,7 @@ interface Props { } const getCloudProviderIcon = (cloudProvider: string) => { - switch (cloudProvider) { + switch (cloudProvider.toLowerCase()) { case 'azure': return 'logoAzure'; case 'aws': diff --git a/x-pack/plugins/cloud_security_posture/public/components/compliance_score_bar.test.tsx b/x-pack/plugins/cloud_security_posture/public/components/compliance_score_bar.test.tsx new file mode 100644 index 0000000000000..166fb1184e0b9 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/components/compliance_score_bar.test.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { ComplianceScoreBar } from './compliance_score_bar'; +import { + COMPLIANCE_SCORE_BAR_UNKNOWN, + COMPLIANCE_SCORE_BAR_PASSED, + COMPLIANCE_SCORE_BAR_FAILED, +} from './test_subjects'; + +describe('', () => { + it('should display 0% compliance score with status unknown when both passed and failed are 0', () => { + render(); + expect(screen.getByText('0%')).toBeInTheDocument(); + expect(screen.queryByTestId(COMPLIANCE_SCORE_BAR_UNKNOWN)).not.toBeNull(); + expect(screen.queryByTestId(COMPLIANCE_SCORE_BAR_FAILED)).toBeNull(); + expect(screen.queryByTestId(COMPLIANCE_SCORE_BAR_PASSED)).toBeNull(); + }); + + it('should display 100% compliance score when passed is greater than 0 and failed is 0', () => { + render(); + expect(screen.getByText('100%')).toBeInTheDocument(); + expect(screen.queryByTestId(COMPLIANCE_SCORE_BAR_PASSED)).not.toBeNull(); + expect(screen.queryByTestId(COMPLIANCE_SCORE_BAR_FAILED)).toBeNull(); + expect(screen.queryByTestId(COMPLIANCE_SCORE_BAR_UNKNOWN)).toBeNull(); + }); + + it('should display 0% compliance score when passed is 0 and failed is greater than 0', () => { + render(); + expect(screen.getByText('0%')).toBeInTheDocument(); + expect(screen.queryByTestId(COMPLIANCE_SCORE_BAR_FAILED)).not.toBeNull(); + expect(screen.queryByTestId(COMPLIANCE_SCORE_BAR_PASSED)).toBeNull(); + expect(screen.queryByTestId(COMPLIANCE_SCORE_BAR_UNKNOWN)).toBeNull(); + }); + + it('should display 50% compliance score when passed is equal to failed', () => { + render(); + expect(screen.getByText('50%')).toBeInTheDocument(); + expect(screen.queryByTestId(COMPLIANCE_SCORE_BAR_FAILED)).not.toBeNull(); + expect(screen.queryByTestId(COMPLIANCE_SCORE_BAR_PASSED)).not.toBeNull(); + expect(screen.queryByTestId(COMPLIANCE_SCORE_BAR_UNKNOWN)).toBeNull(); + }); +}); diff --git a/x-pack/plugins/cloud_security_posture/public/components/compliance_score_bar.tsx b/x-pack/plugins/cloud_security_posture/public/components/compliance_score_bar.tsx index d4acbc97ab10c..3829542829909 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/compliance_score_bar.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/compliance_score_bar.tsx @@ -11,7 +11,12 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { statusColors } from '@kbn/cloud-security-posture'; import { calculatePostureScore } from '../../common/utils/helpers'; -import { CSP_FINDINGS_COMPLIANCE_SCORE } from './test_subjects'; +import { + CSP_FINDINGS_COMPLIANCE_SCORE, + COMPLIANCE_SCORE_BAR_UNKNOWN, + COMPLIANCE_SCORE_BAR_FAILED, + COMPLIANCE_SCORE_BAR_PASSED, +} from './test_subjects'; /** * This component will take 100% of the width set by the parent @@ -59,12 +64,22 @@ export const ComplianceScoreBar = ({ gap: 1px; `} > + {!totalPassed && !totalFailed && ( + + )} {!!totalPassed && ( )} {!!totalFailed && ( @@ -73,6 +88,7 @@ export const ComplianceScoreBar = ({ flex: ${totalFailed}; background: ${statusColors.failed}; `} + data-test-subj={COMPLIANCE_SCORE_BAR_FAILED} /> )} diff --git a/x-pack/plugins/cloud_security_posture/public/components/empty_states_illustration_container.tsx b/x-pack/plugins/cloud_security_posture/public/components/empty_states_illustration_container.tsx new file mode 100644 index 0000000000000..3ae4b64b1c848 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/components/empty_states_illustration_container.tsx @@ -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 React from 'react'; + +// dimensions of the SVGs used in the empty states illustrations +// e.g. x-pack/plugins/cloud_security_posture/public/assets/illustrations/clouds.svg +const SVG_HEIGHT = 209; +const SVG_WIDTH = 376; + +/** + * A container component that maintains a fixed size for child elements. + * used for displaying the empty state illustrations and prevent flickering while the SVGs are loading. + */ +export const EmptyStatesIllustrationContainer: React.FC<{ children: React.ReactNode }> = ({ + children, +}) =>
{children}
; diff --git a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/gcp_credentials_form/gcp_credential_form.tsx b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/gcp_credentials_form/gcp_credential_form.tsx index 638af9617e008..7d6d42c70e767 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/gcp_credentials_form/gcp_credential_form.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/gcp_credentials_form/gcp_credential_form.tsx @@ -500,7 +500,7 @@ export const GcpCredentialsForm = ({ diff --git a/x-pack/plugins/cloud_security_posture/public/components/no_findings_states/no_findings_states.tsx b/x-pack/plugins/cloud_security_posture/public/components/no_findings_states/no_findings_states.tsx index 5a8db618d5495..a475d35cd6885 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/no_findings_states/no_findings_states.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/no_findings_states/no_findings_states.tsx @@ -26,6 +26,7 @@ import type { IndexDetails, CspStatusCode } from '@kbn/cloud-security-posture-co import { useCspSetupStatusApi } from '@kbn/cloud-security-posture/src/hooks/use_csp_setup_status_api'; import { useLocation } from 'react-router-dom'; import { findingsNavigation } from '@kbn/cloud-security-posture'; +import { EmptyStatesIllustrationContainer } from '../empty_states_illustration_container'; import { useAdd3PIntegrationRoute } from '../../common/api/use_wiz_integration_route'; import { FullSizeCenteredPage } from '../full_size_centered_page'; import { useCISIntegrationPoliciesLink } from '../../common/navigation/use_navigate_to_cis_integration_policies'; @@ -191,7 +192,11 @@ const EmptySecurityFindingsPrompt = () => { } + icon={ + + + + } title={

{ style={{ padding: euiTheme.size.l }} data-test-subj={THIRD_PARTY_INTEGRATIONS_NO_MISCONFIGURATIONS_FINDINGS_PROMPT} icon={ - + + + } title={

diff --git a/x-pack/plugins/cloud_security_posture/public/components/no_vulnerabilities_states.tsx b/x-pack/plugins/cloud_security_posture/public/components/no_vulnerabilities_states.tsx index 20438aa341ad6..4e6b487f73cbc 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/no_vulnerabilities_states.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/no_vulnerabilities_states.tsx @@ -25,6 +25,7 @@ import type { IndexDetails } from '@kbn/cloud-security-posture-common'; import { useCspSetupStatusApi } from '@kbn/cloud-security-posture/src/hooks/use_csp_setup_status_api'; import { useLocation } from 'react-router-dom'; import { findingsNavigation } from '@kbn/cloud-security-posture'; +import { EmptyStatesIllustrationContainer } from './empty_states_illustration_container'; import { VULN_MGMT_POLICY_TEMPLATE } from '../../common/constants'; import { FullSizeCenteredPage } from './full_size_centered_page'; import { CloudPosturePage } from './cloud_posture_page'; @@ -84,7 +85,11 @@ const CnvmIntegrationNotInstalledEmptyPrompt = ({ } + icon={ + + + + } title={

+ + + } title={

diff --git a/x-pack/plugins/cloud_security_posture/public/components/test_subjects.ts b/x-pack/plugins/cloud_security_posture/public/components/test_subjects.ts index d29971d3352e3..b609950720ecd 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/test_subjects.ts +++ b/x-pack/plugins/cloud_security_posture/public/components/test_subjects.ts @@ -92,3 +92,7 @@ export const CIS_GCP_INPUT_FIELDS_TEST_SUBJECTS = { }; export const SUBSCRIPTION_NOT_ALLOWED_TEST_SUBJECT = 'cloud_posture_page_subscription_not_allowed'; + +export const COMPLIANCE_SCORE_BAR_UNKNOWN = 'complianceScoreBarUnknown'; +export const COMPLIANCE_SCORE_BAR_FAILED = 'complianceScoreBarFailed'; +export const COMPLIANCE_SCORE_BAR_PASSED = 'complianceScoreBarPassed'; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_group_renderer.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_group_renderer.test.tsx new file mode 100644 index 0000000000000..60aa64aa88141 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_group_renderer.test.tsx @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { useEuiTheme } from '@elastic/eui'; +import { ComplianceBarComponent } from './latest_findings_group_renderer'; +import { RawBucket } from '@kbn/grouping/src'; +import { FindingsGroupingAggregation } from './use_grouped_findings'; +import { ComplianceScoreBar } from '../../../components/compliance_score_bar'; + +jest.mock('@elastic/eui', () => { + const actual = jest.requireActual('@elastic/eui'); + return { + ...actual, + useEuiTheme: jest.fn(), + }; +}); + +jest.mock('../../../components/compliance_score_bar', () => ({ + ComplianceScoreBar: jest.fn(() => null), +})); + +jest.mock('../../../components/cloud_security_grouping'); + +describe('', () => { + beforeEach(() => { + (useEuiTheme as jest.Mock).mockReturnValue({ euiTheme: { size: { s: 's' } } }); + (ComplianceScoreBar as jest.Mock).mockClear(); + }); + + it('renders ComplianceScoreBar with correct totalFailed and totalPassed, when total = failed+passed', () => { + const bucket = { + doc_count: 10, + failedFindings: { + doc_count: 4, + }, + passedFindings: { + doc_count: 6, + }, + } as RawBucket; + + render(); + + expect(ComplianceScoreBar).toHaveBeenCalledWith( + expect.objectContaining({ + totalFailed: 4, + totalPassed: 6, + }), + {} + ); + }); + + it('renders ComplianceScoreBar with correct totalFailed and totalPassed, when there are unknown findings', () => { + const bucket = { + doc_count: 10, + failedFindings: { + doc_count: 3, + }, + passedFindings: { + doc_count: 6, + }, + } as RawBucket; + + render(); + + expect(ComplianceScoreBar).toHaveBeenCalledWith( + expect.objectContaining({ + totalFailed: 3, + totalPassed: 6, + }), + {} + ); + }); + + it('renders ComplianceScoreBar with correct totalFailed and totalPassed, when there are no findings', () => { + const bucket = { + doc_count: 10, + failedFindings: { + doc_count: 0, + }, + passedFindings: { + doc_count: 0, + }, + } as RawBucket; + + render(); + + expect(ComplianceScoreBar).toHaveBeenCalledWith( + expect.objectContaining({ + totalFailed: 0, + totalPassed: 0, + }), + {} + ); + }); +}); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_group_renderer.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_group_renderer.tsx index b4ad5d15ec8e9..b41c5e4996db1 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_group_renderer.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_group_renderer.tsx @@ -198,11 +198,15 @@ const FindingsCountComponent = ({ bucket }: { bucket: RawBucket }) => { +export const ComplianceBarComponent = ({ + bucket, +}: { + bucket: RawBucket; +}) => { const { euiTheme } = useEuiTheme(); const totalFailed = bucket.failedFindings?.doc_count || 0; - const totalPassed = bucket.doc_count - totalFailed; + const totalPassed = bucket.passedFindings?.doc_count || 0; return ( { - return sort.reduce((acc, [field]) => { - // TODO: Add proper type for all fields available in the field selector - const type: RuntimePrimitiveTypes = field === '@timestamp' ? 'date' : 'keyword'; + return sort + .filter(([field]) => CDR_MISCONFIGURATION_DATA_TABLE_RUNTIME_MAPPING_FIELDS.includes(field)) + .reduce((acc, [field]) => { + const type: RuntimePrimitiveTypes = 'keyword'; - return { - ...acc, - [field]: { - type, - }, - }; - }, {}); + return { + ...acc, + [field]: { + type, + }, + }; + }, {}); }; export const getFindingsQuery = ( diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings_grouping.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings_grouping.tsx index e009ee966fb96..45c5418ed5129 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings_grouping.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings_grouping.tsx @@ -21,6 +21,7 @@ import { } from '@kbn/cloud-security-posture-common'; import { useGetCspBenchmarkRulesStatesApi } from '@kbn/cloud-security-posture/src/hooks/use_get_benchmark_rules_state_api'; import { + CDR_MISCONFIGURATION_GROUPING_RUNTIME_MAPPING_FIELDS, FINDINGS_GROUPING_OPTIONS, LOCAL_STORAGE_FINDINGS_GROUPING_KEY, } from '../../../common/constants'; @@ -90,7 +91,6 @@ const getAggregationsByGroupField = (field: string): NamedAggregation[] => { ...aggMetrics, getTermAggregation('resourceName', 'resource.id'), getTermAggregation('resourceSubType', 'resource.sub_type'), - getTermAggregation('resourceType', 'resource.type'), ]; case FINDINGS_GROUPING_OPTIONS.RULE_NAME: return [ @@ -122,62 +122,18 @@ const getAggregationsByGroupField = (field: string): NamedAggregation[] => { const getRuntimeMappingsByGroupField = ( field: string ): Record | undefined => { - switch (field) { - case FINDINGS_GROUPING_OPTIONS.RESOURCE_NAME: - return { - [FINDINGS_GROUPING_OPTIONS.RESOURCE_NAME]: { - type: 'keyword', - }, - 'resource.id': { - type: 'keyword', - }, - 'resource.sub_type': { - type: 'keyword', - }, - 'resource.type': { - type: 'keyword', - }, - }; - case FINDINGS_GROUPING_OPTIONS.RULE_NAME: - return { - [FINDINGS_GROUPING_OPTIONS.RULE_NAME]: { - type: 'keyword', - }, - 'rule.benchmark.version': { - type: 'keyword', - }, - }; - case FINDINGS_GROUPING_OPTIONS.CLOUD_ACCOUNT_NAME: - return { - [FINDINGS_GROUPING_OPTIONS.CLOUD_ACCOUNT_NAME]: { + if (CDR_MISCONFIGURATION_GROUPING_RUNTIME_MAPPING_FIELDS?.[field]) { + return CDR_MISCONFIGURATION_GROUPING_RUNTIME_MAPPING_FIELDS[field].reduce( + (acc, runtimeField) => ({ + ...acc, + [runtimeField]: { type: 'keyword', }, - 'rule.benchmark.name': { - type: 'keyword', - }, - 'rule.benchmark.id': { - type: 'keyword', - }, - }; - case FINDINGS_GROUPING_OPTIONS.ORCHESTRATOR_CLUSTER_NAME: - return { - [FINDINGS_GROUPING_OPTIONS.ORCHESTRATOR_CLUSTER_NAME]: { - type: 'keyword', - }, - 'rule.benchmark.name': { - type: 'keyword', - }, - 'rule.benchmark.id': { - type: 'keyword', - }, - }; - default: - return { - [field]: { - type: 'keyword', - }, - }; + }), + {} + ); } + return {}; }; /** @@ -255,12 +211,7 @@ export const useLatestFindingsGrouping = ({ size: pageSize, sort: [{ groupByField: { order: 'desc' } }, { complianceScore: { order: 'asc' } }], statsAggregations: getAggregationsByGroupField(currentSelectedGroup), - runtimeMappings: { - ...getRuntimeMappingsByGroupField(currentSelectedGroup), - 'result.evaluation': { - type: 'keyword', - }, - }, + runtimeMappings: getRuntimeMappingsByGroupField(currentSelectedGroup), rootAggregations: [ { failedFindings: { diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_latest_vulnerabilities.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_latest_vulnerabilities.tsx index 5f01a4693c8f5..a998707c4704f 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_latest_vulnerabilities.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_latest_vulnerabilities.tsx @@ -24,7 +24,10 @@ import { import { FindingsBaseEsQuery, showErrorToast } from '@kbn/cloud-security-posture'; import type { CspVulnerabilityFinding } from '@kbn/cloud-security-posture-common/schema/vulnerabilities/latest'; import type { RuntimePrimitiveTypes } from '@kbn/data-views-plugin/common'; -import { VULNERABILITY_FIELDS } from '../../../common/constants'; +import { + CDR_VULNERABILITY_DATA_TABLE_RUNTIME_MAPPING_FIELDS, + VULNERABILITY_FIELDS, +} from '../../../common/constants'; import { useKibana } from '../../../common/hooks/use_kibana'; import { getCaseInsensitiveSortScript } from '../utils/custom_sort_script'; type LatestFindingsRequest = IKibanaSearchRequest; @@ -54,22 +57,18 @@ const getMultiFieldsSort = (sort: string[][]) => { }; const getRuntimeMappingsFromSort = (sort: string[][]) => { - return sort.reduce((acc, [field]) => { - // TODO: Add proper type for all fields available in the field selector - const type: RuntimePrimitiveTypes = - field === VULNERABILITY_FIELDS.SCORE_BASE - ? 'double' - : field === '@timestamp' - ? 'date' - : 'keyword'; + return sort + .filter(([field]) => CDR_VULNERABILITY_DATA_TABLE_RUNTIME_MAPPING_FIELDS.includes(field)) + .reduce((acc, [field]) => { + const type: RuntimePrimitiveTypes = 'keyword'; - return { - ...acc, - [field]: { - type, - }, - }; - }, {}); + return { + ...acc, + [field]: { + type, + }, + }; + }, {}); }; export const getVulnerabilitiesQuery = ( diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_latest_vulnerabilities_grouping.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_latest_vulnerabilities_grouping.tsx index d79b4620ec899..1d73b21f083a5 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_latest_vulnerabilities_grouping.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_latest_vulnerabilities_grouping.tsx @@ -23,6 +23,7 @@ import { LOCAL_STORAGE_VULNERABILITIES_GROUPING_KEY, VULNERABILITY_GROUPING_OPTIONS, VULNERABILITY_FIELDS, + CDR_VULNERABILITY_GROUPING_RUNTIME_MAPPING_FIELDS, } from '../../../common/constants'; import { useDataViewContext } from '../../../common/contexts/data_view_context'; import { @@ -102,41 +103,18 @@ const getAggregationsByGroupField = (field: string): NamedAggregation[] => { const getRuntimeMappingsByGroupField = ( field: string ): Record | undefined => { - switch (field) { - case VULNERABILITY_GROUPING_OPTIONS.CLOUD_ACCOUNT_NAME: - return { - [VULNERABILITY_GROUPING_OPTIONS.CLOUD_ACCOUNT_NAME]: { - type: 'keyword', - }, - [VULNERABILITY_FIELDS.CLOUD_PROVIDER]: { - type: 'keyword', - }, - }; - case VULNERABILITY_GROUPING_OPTIONS.RESOURCE_NAME: - return { - [VULNERABILITY_GROUPING_OPTIONS.RESOURCE_NAME]: { - type: 'keyword', - }, - [VULNERABILITY_FIELDS.RESOURCE_ID]: { - type: 'keyword', - }, - }; - case VULNERABILITY_GROUPING_OPTIONS.CVE: - return { - [VULNERABILITY_GROUPING_OPTIONS.CVE]: { - type: 'keyword', - }, - [VULNERABILITY_FIELDS.DESCRIPTION]: { - type: 'keyword', - }, - }; - default: - return { - [field]: { + if (CDR_VULNERABILITY_GROUPING_RUNTIME_MAPPING_FIELDS?.[field]) { + return CDR_VULNERABILITY_GROUPING_RUNTIME_MAPPING_FIELDS[field].reduce( + (acc, runtimeField) => ({ + ...acc, + [runtimeField]: { type: 'keyword', }, - }; + }), + {} + ); } + return {}; }; /** diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/vulnerability_statistics.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/vulnerability_statistics.tsx index 114f28ccfc271..de1f7ec3ba37a 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/vulnerability_statistics.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/vulnerability_statistics.tsx @@ -7,7 +7,7 @@ import React, { useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiHealth } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { useNavigateVulnerabilities } from '@kbn/cloud-security-posture/src/hooks/use_navigate_findings'; +import { useNavigateNativeVulnerabilities } from '@kbn/cloud-security-posture/src/hooks/use_navigate_findings'; import { VULNERABILITIES_SEVERITY } from '@kbn/cloud-security-posture-common'; import { getSeverityStatusColor } from '@kbn/cloud-security-posture'; import { VulnCounterCard, type VulnCounterCardProps } from '../../components/vuln_counter_card'; @@ -15,7 +15,7 @@ import { useVulnerabilityDashboardApi } from '../../common/api/use_vulnerability import { CompactFormattedNumber } from '../../components/compact_formatted_number'; export const VulnerabilityStatistics = () => { - const navToVulnerabilities = useNavigateVulnerabilities(); + const navToVulnerabilities = useNavigateNativeVulnerabilities(); const getVulnerabilityDashboard = useVulnerabilityDashboardApi(); const stats: VulnCounterCardProps[] = useMemo( diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/vulnerability_table_panel_section.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/vulnerability_table_panel_section.tsx index a4e3dd38b28a1..c3a5f21867723 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/vulnerability_table_panel_section.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/vulnerability_table_panel_section.tsx @@ -17,7 +17,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import type { NavFilter } from '@kbn/cloud-security-posture/src/utils/query_utils'; -import { useNavigateVulnerabilities } from '@kbn/cloud-security-posture/src/hooks/use_navigate_findings'; +import { useNavigateNativeVulnerabilities } from '@kbn/cloud-security-posture/src/hooks/use_navigate_findings'; import type { VulnSeverity } from '@kbn/cloud-security-posture-common'; import { CVSScoreBadge, SeverityStatusBadge } from '@kbn/cloud-security-posture'; import { @@ -33,7 +33,7 @@ import { VULNERABILITY_GROUPING_OPTIONS, VULNERABILITY_FIELDS } from '../../comm export const VulnerabilityTablePanelSection = () => { const getVulnerabilityDashboard = useVulnerabilityDashboardApi(); const { euiTheme } = useEuiTheme(); - const navToVulnerabilities = useNavigateVulnerabilities(); + const navToVulnerabilities = useNavigateNativeVulnerabilities(); const onCellClick = useCallback( (filters: NavFilter) => { diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/vulnerability_trend_graph.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/vulnerability_trend_graph.tsx index ff610b640cd3f..599928eea88b8 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/vulnerability_trend_graph.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/vulnerability_trend_graph.tsx @@ -19,7 +19,7 @@ import { EuiButton, EuiComboBox } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { useNavigateVulnerabilities } from '@kbn/cloud-security-posture/src/hooks/use_navigate_findings'; +import { useNavigateNativeVulnerabilities } from '@kbn/cloud-security-posture/src/hooks/use_navigate_findings'; import type { VulnSeverity } from '@kbn/cloud-security-posture-common'; import { VULNERABILITIES_SEVERITY } from '@kbn/cloud-security-posture-common'; import { getSeverityStatusColor } from '@kbn/cloud-security-posture'; @@ -50,7 +50,7 @@ const theme: PartialTheme = { }; const ViewAllButton = () => { - const navToVulnerabilities = useNavigateVulnerabilities(); + const navToVulnerabilities = useNavigateNativeVulnerabilities(); return ( navToVulnerabilities()} size="s"> diff --git a/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/bulk_action/bulk_action.ts b/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/bulk_action/bulk_action.ts index 14b55541a1baf..b72cb27088eda 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/bulk_action/bulk_action.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/bulk_action/bulk_action.ts @@ -44,8 +44,10 @@ export const defineBulkActionCspBenchmarkRulesRoute = (router: CspRouter) => .post({ access: 'internal', path: CSP_BENCHMARK_RULES_BULK_ACTION_ROUTE_PATH, - options: { - tags: ['access:cloud-security-posture-all'], + security: { + authz: { + requiredPrivileges: ['cloud-security-posture-all'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/find/find.ts b/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/find/find.ts index 738a8774266d8..a205ad95419db 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/find/find.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/find/find.ts @@ -25,8 +25,10 @@ export const defineFindCspBenchmarkRuleRoute = (router: CspRouter) => .get({ access: 'internal', path: FIND_CSP_BENCHMARK_RULE_ROUTE_PATH, - options: { - tags: ['access:cloud-security-posture-read'], + security: { + authz: { + requiredPrivileges: ['cloud-security-posture-read'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/get_states/get_states.ts b/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/get_states/get_states.ts index 31ef05abc7ccd..a737313ffc66a 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/get_states/get_states.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/get_states/get_states.ts @@ -16,8 +16,10 @@ export const defineGetCspBenchmarkRulesStatesRoute = (router: CspRouter) => .get({ access: 'internal', path: CSP_GET_BENCHMARK_RULES_STATE_ROUTE_PATH, - options: { - tags: ['access:cloud-security-posture-read'], + security: { + authz: { + requiredPrivileges: ['cloud-security-posture-read'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts index c3854b1dafb4d..efbdedad3d3a5 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts @@ -20,8 +20,10 @@ export const defineGetBenchmarksRoute = (router: CspRouter) => .get({ access: 'internal', path: BENCHMARKS_ROUTE_PATH, - options: { - tags: ['access:cloud-security-posture-read'], + security: { + authz: { + requiredPrivileges: ['cloud-security-posture-read'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.ts index 851fa865566f7..481433e1efd56 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.ts @@ -65,8 +65,10 @@ export const defineGetComplianceDashboardRoute = (router: CspRouter) => .get({ access: 'internal', path: STATS_ROUTE_PATH, - options: { - tags: ['access:cloud-security-posture-read'], + security: { + authz: { + requiredPrivileges: ['cloud-security-posture-read'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/cloud_security_posture/server/routes/detection_engine/get_detection_engine_alerts_count_by_rule_tags.ts b/x-pack/plugins/cloud_security_posture/server/routes/detection_engine/get_detection_engine_alerts_count_by_rule_tags.ts index 6455b34707f70..38a9e356a1446 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/detection_engine/get_detection_engine_alerts_count_by_rule_tags.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/detection_engine/get_detection_engine_alerts_count_by_rule_tags.ts @@ -53,8 +53,10 @@ export const defineGetDetectionEngineAlertsStatus = (router: CspRouter) => .get({ access: 'internal', path: GET_DETECTION_RULE_ALERTS_STATUS_PATH, - options: { - tags: ['access:cloud-security-posture-read'], + security: { + authz: { + requiredPrivileges: ['cloud-security-posture-read'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/cloud_security_posture/server/routes/graph/route.ts b/x-pack/plugins/cloud_security_posture/server/routes/graph/route.ts index 9ff15c2be73e6..9e9744b33d940 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/graph/route.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/graph/route.ts @@ -20,8 +20,10 @@ export const defineGraphRoute = (router: CspRouter) => access: 'internal', enableQueryVersion: true, path: GRAPH_ROUTE_PATH, - options: { - tags: ['access:cloud-security-posture-read'], + security: { + authz: { + requiredPrivileges: ['cloud-security-posture-read'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/cloud_security_posture/server/routes/status/status.ts b/x-pack/plugins/cloud_security_posture/server/routes/status/status.ts index 4f5c84b936fb2..066d0c936e27c 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/status/status.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/status/status.ts @@ -437,8 +437,10 @@ export const defineGetCspStatusRoute = ( .get({ access: 'internal', path: STATUS_ROUTE_PATH, - options: { - tags: ['access:cloud-security-posture-read'], + security: { + authz: { + requiredPrivileges: ['cloud-security-posture-read'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/cloud_security_posture/server/routes/vulnerabilities_dashboard/vulnerabilities_dashboard.ts b/x-pack/plugins/cloud_security_posture/server/routes/vulnerabilities_dashboard/vulnerabilities_dashboard.ts index f7de7f1be4b65..e336e6dbc0c02 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/vulnerabilities_dashboard/vulnerabilities_dashboard.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/vulnerabilities_dashboard/vulnerabilities_dashboard.ts @@ -20,8 +20,10 @@ export const defineGetVulnerabilitiesDashboardRoute = (router: CspRouter): void { path: VULNERABILITIES_DASHBOARD_ROUTE_PATH, validate: false, - options: { - tags: ['access:cloud-security-posture-read'], + security: { + authz: { + requiredPrivileges: ['cloud-security-posture-read'], + }, }, }, async (context, request, response) => { diff --git a/x-pack/plugins/cross_cluster_replication/kibana.jsonc b/x-pack/plugins/cross_cluster_replication/kibana.jsonc index 0b85ba18781d8..5b29c4fb56618 100644 --- a/x-pack/plugins/cross_cluster_replication/kibana.jsonc +++ b/x-pack/plugins/cross_cluster_replication/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/cross-cluster-replication-plugin", - "owner": "@elastic/kibana-management", + "owner": [ + "@elastic/kibana-management" + ], + "group": "platform", + "visibility": "private", "plugin": { "id": "crossClusterReplication", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "ccr" @@ -28,4 +32,4 @@ "data" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/custom_branding/kibana.jsonc b/x-pack/plugins/custom_branding/kibana.jsonc index 5bd0fccb1f020..01e982e86eb3f 100644 --- a/x-pack/plugins/custom_branding/kibana.jsonc +++ b/x-pack/plugins/custom_branding/kibana.jsonc @@ -1,15 +1,19 @@ { "type": "plugin", "id": "@kbn/custom-branding-plugin", - "owner": "@elastic/appex-sharedux", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "private", "description": " Enables customization of Kibana", "plugin": { "id": "customBranding", - "server": true, "browser": false, + "server": true, "requiredPlugins": [ "licensing", "licenseApiGuard" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/dashboard_enhanced/kibana.jsonc b/x-pack/plugins/dashboard_enhanced/kibana.jsonc index e74f2000ce39f..bdf9ac804bac3 100644 --- a/x-pack/plugins/dashboard_enhanced/kibana.jsonc +++ b/x-pack/plugins/dashboard_enhanced/kibana.jsonc @@ -1,12 +1,19 @@ { "type": "plugin", "id": "@kbn/dashboard-enhanced-plugin", - "owner": "@elastic/kibana-presentation", + "owner": [ + "@elastic/kibana-presentation" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "dashboardEnhanced", - "server": true, "browser": true, - "configPath": ["xpack", "dashboardEnhanced"], + "server": true, + "configPath": [ + "xpack", + "dashboardEnhanced" + ], "requiredPlugins": [ "dashboard", "data", @@ -24,4 +31,4 @@ "uiActions" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/data_quality/kibana.jsonc b/x-pack/plugins/data_quality/kibana.jsonc index ad1a64d4ed140..dc54e20f40bd7 100644 --- a/x-pack/plugins/data_quality/kibana.jsonc +++ b/x-pack/plugins/data_quality/kibana.jsonc @@ -2,6 +2,8 @@ "type": "plugin", "id": "@kbn/data-quality-plugin", "owner": "@elastic/obs-ux-logs-team", + "group": "observability", + "visibility": "private", "plugin": { "id": "dataQuality", "server": true, diff --git a/x-pack/plugins/data_usage/common/rest_types/usage_metrics.ts b/x-pack/plugins/data_usage/common/rest_types/usage_metrics.ts index 40194494854fc..853da3b3f8cbd 100644 --- a/x-pack/plugins/data_usage/common/rest_types/usage_metrics.ts +++ b/x-pack/plugins/data_usage/common/rest_types/usage_metrics.ts @@ -123,6 +123,13 @@ export const UsageMetricsAutoOpsResponseSchema = { ), }), }; -export type UsageMetricsAutoOpsResponseSchemaBody = TypeOf< +export type UsageMetricsAutoOpsResponseMetricSeries = TypeOf< typeof UsageMetricsAutoOpsResponseSchema.body ->; +>['metrics'][MetricTypes][number]; + +export type UsageMetricsAutoOpsResponseSchemaBody = Omit< + TypeOf, + 'metrics' +> & { + metrics: Partial>; +}; diff --git a/x-pack/plugins/data_usage/common/test_utils/test_query_client_options.ts b/x-pack/plugins/data_usage/common/test_utils/test_query_client_options.ts new file mode 100644 index 0000000000000..c674e9b342eea --- /dev/null +++ b/x-pack/plugins/data_usage/common/test_utils/test_query_client_options.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. + */ +/* eslint-disable no-console */ +export const dataUsageTestQueryClientOptions = { + defaultOptions: { + queries: { + retry: false, + cacheTime: 0, + }, + }, + logger: { + log: console.log, + warn: console.warn, + error: () => {}, + }, +}; diff --git a/x-pack/plugins/data_usage/kibana.jsonc b/x-pack/plugins/data_usage/kibana.jsonc index ffd8833351267..3706875c1ad94 100644 --- a/x-pack/plugins/data_usage/kibana.jsonc +++ b/x-pack/plugins/data_usage/kibana.jsonc @@ -5,6 +5,8 @@ "@elastic/obs-ai-assistant", "@elastic/security-solution" ], + "group": "platform", + "visibility": "private", "plugin": { "id": "dataUsage", "server": true, diff --git a/x-pack/plugins/data_usage/public/app/components/charts.tsx b/x-pack/plugins/data_usage/public/app/components/charts.tsx index 8d04324fb2246..56857e7a63ff9 100644 --- a/x-pack/plugins/data_usage/public/app/components/charts.tsx +++ b/x-pack/plugins/data_usage/public/app/components/charts.tsx @@ -9,18 +9,21 @@ import { EuiFlexGroup } from '@elastic/eui'; import { MetricTypes } from '../../../common/rest_types'; import { ChartPanel } from './chart_panel'; import { UsageMetricsResponseSchemaBody } from '../../../common/rest_types'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; interface ChartsProps { data: UsageMetricsResponseSchemaBody; + 'data-test-subj'?: string; } -export const Charts: React.FC = ({ data }) => { +export const Charts: React.FC = ({ data, 'data-test-subj': dataTestSubj }) => { + const getTestId = useTestIdGenerator(dataTestSubj); const [popoverOpen, setPopoverOpen] = useState(null); const togglePopover = useCallback((streamName: string | null) => { setPopoverOpen((prev) => (prev === streamName ? null : streamName)); }, []); return ( - + {Object.entries(data.metrics).map(([metricType, series], idx) => ( { + return { + useBreadcrumbs: jest.fn(), + }; +}); + +jest.mock('../../utils/use_kibana', () => { + return { + useKibanaContextForPlugin: () => ({ + services: mockServices, + }), + }; +}); + +jest.mock('../../hooks/use_get_usage_metrics', () => { + const original = jest.requireActual('../../hooks/use_get_usage_metrics'); + return { + ...original, + useGetDataUsageMetrics: jest.fn(original.useGetDataUsageMetrics), + }; +}); + +const mockUseLocation = jest.fn(() => ({ pathname: '/' })); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: () => mockUseLocation(), + useHistory: jest.fn().mockReturnValue({ + push: jest.fn(), + listen: jest.fn(), + location: { + search: '', + }, + }), +})); + +jest.mock('../../hooks/use_get_data_streams', () => { + const original = jest.requireActual('../../hooks/use_get_data_streams'); + return { + ...original, + useGetDataUsageDataStreams: jest.fn(original.useGetDataUsageDataStreams), + }; +}); + +jest.mock('@kbn/kibana-react-plugin/public', () => { + const original = jest.requireActual('@kbn/kibana-react-plugin/public'); + return { + ...original, + useKibana: () => ({ + services: { + uiSettings: { + get: jest.fn().mockImplementation((key) => { + const get = (k: 'dateFormat' | 'timepicker:quickRanges') => { + const x = { + dateFormat: 'MMM D, YYYY @ HH:mm:ss.SSS', + 'timepicker:quickRanges': [ + { + from: 'now/d', + to: 'now/d', + display: 'Today', + }, + { + from: 'now/w', + to: 'now/w', + display: 'This week', + }, + { + from: 'now-15m', + to: 'now', + display: 'Last 15 minutes', + }, + { + from: 'now-30m', + to: 'now', + display: 'Last 30 minutes', + }, + { + from: 'now-1h', + to: 'now', + display: 'Last 1 hour', + }, + { + from: 'now-24h', + to: 'now', + display: 'Last 24 hours', + }, + { + from: 'now-7d', + to: 'now', + display: 'Last 7 days', + }, + { + from: 'now-30d', + to: 'now', + display: 'Last 30 days', + }, + { + from: 'now-90d', + to: 'now', + display: 'Last 90 days', + }, + { + from: 'now-1y', + to: 'now', + display: 'Last 1 year', + }, + ], + }; + return x[k]; + }; + return get(key); + }), + }, + }, + }), + }; +}); +const mockUseGetDataUsageMetrics = useGetDataUsageMetrics as jest.Mock; +const mockUseGetDataUsageDataStreams = useGetDataUsageDataStreams as jest.Mock; +const mockServices = mockCore.createStart(); + +const getBaseMockedDataStreams = () => ({ + error: undefined, + data: undefined, + isFetching: false, + refetch: jest.fn(), +}); +const getBaseMockedDataUsageMetrics = () => ({ + error: undefined, + data: undefined, + isFetching: false, + refetch: jest.fn(), +}); + +describe('DataUsageMetrics', () => { + let user: UserEvent; + const testId = 'test'; + const testIdFilter = `${testId}-filter`; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime, pointerEventsCheck: 0 }); + mockUseGetDataUsageMetrics.mockReturnValue(getBaseMockedDataUsageMetrics); + mockUseGetDataUsageDataStreams.mockReturnValue(getBaseMockedDataStreams); + }); + + it('renders', () => { + const { getByTestId } = render(); + expect(getByTestId(`${testId}`)).toBeTruthy(); + }); + + it('should show date filter', () => { + const { getByTestId } = render(); + expect(getByTestId(`${testIdFilter}-date-range`)).toBeTruthy(); + expect(getByTestId(`${testIdFilter}-date-range`).textContent).toContain('Last 24 hours'); + expect(getByTestId(`${testIdFilter}-super-refresh-button`)).toBeTruthy(); + }); + + it('should not show data streams filter while fetching API', () => { + mockUseGetDataUsageDataStreams.mockReturnValue({ + ...getBaseMockedDataStreams, + isFetching: true, + }); + const { queryByTestId } = render(); + expect(queryByTestId(`${testIdFilter}-dataStreams-popoverButton`)).not.toBeTruthy(); + }); + + it('should show data streams filter', () => { + const { getByTestId } = render(); + expect(getByTestId(`${testIdFilter}-dataStreams-popoverButton`)).toBeTruthy(); + }); + + it('should show selected data streams on the filter', () => { + mockUseGetDataUsageDataStreams.mockReturnValue({ + error: undefined, + data: [ + { + name: '.ds-1', + storageSizeBytes: 10000, + }, + { + name: '.ds-2', + storageSizeBytes: 20000, + }, + { + name: '.ds-3', + storageSizeBytes: 10300, + }, + { + name: '.ds-4', + storageSizeBytes: 23000, + }, + { + name: '.ds-5', + storageSizeBytes: 23200, + }, + ], + isFetching: false, + }); + const { getByTestId } = render(); + expect(getByTestId(`${testIdFilter}-dataStreams-popoverButton`)).toHaveTextContent( + 'Data streams5' + ); + }); + + it('should allow de-selecting all but one data stream option', async () => { + mockUseGetDataUsageDataStreams.mockReturnValue({ + error: undefined, + data: [ + { + name: '.ds-1', + storageSizeBytes: 10000, + }, + { + name: '.ds-2', + storageSizeBytes: 20000, + }, + { + name: '.ds-3', + storageSizeBytes: 10300, + }, + { + name: '.ds-4', + storageSizeBytes: 23000, + }, + { + name: '.ds-5', + storageSizeBytes: 23200, + }, + ], + isFetching: false, + }); + const { getByTestId, getAllByTestId } = render(); + expect(getByTestId(`${testIdFilter}-dataStreams-popoverButton`)).toHaveTextContent( + 'Data streams5' + ); + await user.click(getByTestId(`${testIdFilter}-dataStreams-popoverButton`)); + const allFilterOptions = getAllByTestId('dataStreams-filter-option'); + for (let i = 0; i < allFilterOptions.length - 1; i++) { + await user.click(allFilterOptions[i]); + } + + expect(getByTestId(`${testIdFilter}-dataStreams-popoverButton`)).toHaveTextContent( + 'Data streams1' + ); + }); + + it('should not call usage metrics API if no data streams', async () => { + mockUseGetDataUsageDataStreams.mockReturnValue({ + ...getBaseMockedDataStreams, + data: [], + }); + render(); + expect(mockUseGetDataUsageMetrics).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ enabled: false }) + ); + }); + + it('should show charts loading if data usage metrics API is fetching', () => { + mockUseGetDataUsageMetrics.mockReturnValue({ + ...getBaseMockedDataUsageMetrics, + isFetching: true, + }); + const { getByTestId } = render(); + expect(getByTestId(`${testId}-charts-loading`)).toBeTruthy(); + }); + + it('should show charts', () => { + mockUseGetDataUsageMetrics.mockReturnValue({ + ...getBaseMockedDataUsageMetrics, + isFetched: true, + data: { + metrics: { + ingest_rate: [ + { + name: '.ds-1', + data: [{ x: new Date(), y: 1000 }], + }, + { + name: '.ds-10', + data: [{ x: new Date(), y: 1100 }], + }, + ], + storage_retained: [ + { + name: '.ds-2', + data: [{ x: new Date(), y: 2000 }], + }, + { + name: '.ds-20', + data: [{ x: new Date(), y: 2100 }], + }, + ], + }, + }, + }); + const { getByTestId } = render(); + expect(getByTestId(`${testId}-charts`)).toBeTruthy(); + }); + + it('should refetch usage metrics with `Refresh` button click', async () => { + const refetch = jest.fn(); + mockUseGetDataUsageMetrics.mockReturnValue({ + ...getBaseMockedDataUsageMetrics, + data: ['.ds-1', '.ds-2'], + isFetched: true, + }); + mockUseGetDataUsageMetrics.mockReturnValue({ + ...getBaseMockedDataUsageMetrics, + isFetched: true, + refetch, + }); + const { getByTestId } = render(); + const refreshButton = getByTestId(`${testIdFilter}-super-refresh-button`); + // click refresh 5 times + for (let i = 0; i < 5; i++) { + await user.click(refreshButton); + } + + expect(mockUseGetDataUsageMetrics).toHaveBeenLastCalledWith( + expect.any(Object), + expect.objectContaining({ enabled: false }) + ); + expect(refetch).toHaveBeenCalledTimes(5); + }); + + it('should show error toast if usage metrics API fails', async () => { + mockUseGetDataUsageMetrics.mockReturnValue({ + ...getBaseMockedDataUsageMetrics, + isFetched: true, + error: new Error('Uh oh!'), + }); + render(); + await waitFor(() => { + expect(mockServices.notifications.toasts.addDanger).toHaveBeenCalledWith({ + title: 'Error getting usage metrics', + text: 'Uh oh!', + }); + }); + }); + + it('should show error toast if data streams API fails', async () => { + mockUseGetDataUsageDataStreams.mockReturnValue({ + ...getBaseMockedDataStreams, + isFetched: true, + error: new Error('Uh oh!'), + }); + render(); + await waitFor(() => { + expect(mockServices.notifications.toasts.addDanger).toHaveBeenCalledWith({ + title: 'Error getting data streams', + text: 'Uh oh!', + }); + }); + }); +}); diff --git a/x-pack/plugins/data_usage/public/app/components/data_usage_metrics.tsx b/x-pack/plugins/data_usage/public/app/components/data_usage_metrics.tsx index 929ebf7a02490..59354a1746346 100644 --- a/x-pack/plugins/data_usage/public/app/components/data_usage_metrics.tsx +++ b/x-pack/plugins/data_usage/public/app/components/data_usage_metrics.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { css } from '@emotion/react'; import { EuiFlexGroup, EuiFlexItem, EuiLoadingElastic } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -14,11 +14,12 @@ import { useBreadcrumbs } from '../../utils/use_breadcrumbs'; import { useKibanaContextForPlugin } from '../../utils/use_kibana'; import { PLUGIN_NAME } from '../../../common'; import { useGetDataUsageMetrics } from '../../hooks/use_get_usage_metrics'; +import { useGetDataUsageDataStreams } from '../../hooks/use_get_data_streams'; import { useDataUsageMetricsUrlParams } from '../hooks/use_charts_url_params'; import { DEFAULT_DATE_RANGE_OPTIONS, useDateRangePicker } from '../hooks/use_date_picker'; import { DEFAULT_METRIC_TYPES, UsageMetricsRequestBody } from '../../../common/rest_types'; import { ChartFilters, ChartFiltersProps } from './filters/charts_filters'; -import { useGetDataUsageDataStreams } from '../../hooks/use_get_data_streams'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; const EuiItemCss = css` width: 100%; @@ -28,181 +29,188 @@ const FlexItemWithCss = ({ children }: { children: React.ReactNode }) => ( {children} ); -export const DataUsageMetrics = () => { - const { - services: { chrome, appParams, notifications }, - } = useKibanaContextForPlugin(); - useBreadcrumbs([{ text: PLUGIN_NAME }], appParams, chrome); - - const { - metricTypes: metricTypesFromUrl, - dataStreams: dataStreamsFromUrl, - startDate: startDateFromUrl, - endDate: endDateFromUrl, - setUrlMetricTypesFilter, - setUrlDataStreamsFilter, - setUrlDateRangeFilter, - } = useDataUsageMetricsUrlParams(); - - const { - error: errorFetchingDataStreams, - data: dataStreams, - isFetching: isFetchingDataStreams, - } = useGetDataUsageDataStreams({ - selectedDataStreams: dataStreamsFromUrl, - options: { - enabled: true, - retry: false, - }, - }); - - const [metricsFilters, setMetricsFilters] = useState({ - metricTypes: [...DEFAULT_METRIC_TYPES], - dataStreams: [], - from: DEFAULT_DATE_RANGE_OPTIONS.startDate, - to: DEFAULT_DATE_RANGE_OPTIONS.endDate, - }); - - useEffect(() => { - if (!metricTypesFromUrl) { - setUrlMetricTypesFilter(metricsFilters.metricTypes.join(',')); - } - if (!dataStreamsFromUrl && dataStreams) { - setUrlDataStreamsFilter(dataStreams.map((ds) => ds.name).join(',')); - } - if (!startDateFromUrl || !endDateFromUrl) { - setUrlDateRangeFilter({ startDate: metricsFilters.from, endDate: metricsFilters.to }); - } - }, [ - dataStreams, - dataStreamsFromUrl, - endDateFromUrl, - metricTypesFromUrl, - metricsFilters.dataStreams, - metricsFilters.from, - metricsFilters.metricTypes, - metricsFilters.to, - setUrlDataStreamsFilter, - setUrlDateRangeFilter, - setUrlMetricTypesFilter, - startDateFromUrl, - ]); - - useEffect(() => { - setMetricsFilters((prevState) => ({ - ...prevState, - metricTypes: metricTypesFromUrl?.length ? metricTypesFromUrl : prevState.metricTypes, - dataStreams: dataStreamsFromUrl?.length ? dataStreamsFromUrl : prevState.dataStreams, - })); - }, [metricTypesFromUrl, dataStreamsFromUrl]); - - const { dateRangePickerState, onRefreshChange, onTimeChange } = useDateRangePicker(); - - const { - error: errorFetchingDataUsageMetrics, - data, - isFetching, - isFetched, - refetch: refetchDataUsageMetrics, - } = useGetDataUsageMetrics( - { - ...metricsFilters, - from: dateRangePickerState.startDate, - to: dateRangePickerState.endDate, - }, - { - retry: false, - enabled: !!metricsFilters.dataStreams.length, - } - ); - - const onRefresh = useCallback(() => { - refetchDataUsageMetrics(); - }, [refetchDataUsageMetrics]); - - const onChangeDataStreamsFilter = useCallback( - (selectedDataStreams: string[]) => { - setMetricsFilters((prevState) => ({ ...prevState, dataStreams: selectedDataStreams })); - }, - [setMetricsFilters] - ); - - const onChangeMetricTypesFilter = useCallback( - (selectedMetricTypes: string[]) => { - setMetricsFilters((prevState) => ({ ...prevState, metricTypes: selectedMetricTypes })); - }, - [setMetricsFilters] - ); - - const filterOptions: ChartFiltersProps['filterOptions'] = useMemo(() => { - const dataStreamsOptions = dataStreams?.reduce>((acc, ds) => { - acc[ds.name] = ds.storageSizeBytes; - return acc; - }, {}); - - return { - dataStreams: { - filterName: 'dataStreams', - options: dataStreamsOptions ? Object.keys(dataStreamsOptions) : metricsFilters.dataStreams, - appendOptions: dataStreamsOptions, - selectedOptions: metricsFilters.dataStreams, - onChangeFilterOptions: onChangeDataStreamsFilter, - isFilterLoading: isFetchingDataStreams, - }, - metricTypes: { - filterName: 'metricTypes', - options: metricsFilters.metricTypes, - onChangeFilterOptions: onChangeMetricTypesFilter, +export const DataUsageMetrics = memo( + ({ 'data-test-subj': dataTestSubj = 'data-usage-metrics' }: { 'data-test-subj'?: string }) => { + const getTestId = useTestIdGenerator(dataTestSubj); + + const { + services: { chrome, appParams, notifications }, + } = useKibanaContextForPlugin(); + useBreadcrumbs([{ text: PLUGIN_NAME }], appParams, chrome); + + const { + metricTypes: metricTypesFromUrl, + dataStreams: dataStreamsFromUrl, + startDate: startDateFromUrl, + endDate: endDateFromUrl, + setUrlMetricTypesFilter, + setUrlDataStreamsFilter, + setUrlDateRangeFilter, + } = useDataUsageMetricsUrlParams(); + + const { + error: errorFetchingDataStreams, + data: dataStreams, + isFetching: isFetchingDataStreams, + } = useGetDataUsageDataStreams({ + selectedDataStreams: dataStreamsFromUrl, + options: { + enabled: true, + retry: false, }, - }; - }, [ - dataStreams, - isFetchingDataStreams, - metricsFilters.dataStreams, - metricsFilters.metricTypes, - onChangeDataStreamsFilter, - onChangeMetricTypesFilter, - ]); - - if (errorFetchingDataUsageMetrics) { - notifications.toasts.addDanger({ - title: i18n.translate('xpack.dataUsage.getMetrics.addFailure.toast.title', { - defaultMessage: 'Error getting usage metrics', - }), - text: errorFetchingDataUsageMetrics.message, }); - } - if (errorFetchingDataStreams) { - notifications.toasts.addDanger({ - title: i18n.translate('xpack.dataUsage.getDataStreams.addFailure.toast.title', { - defaultMessage: 'Error getting data streams', - }), - text: errorFetchingDataStreams.message, + + const [metricsFilters, setMetricsFilters] = useState({ + metricTypes: [...DEFAULT_METRIC_TYPES], + dataStreams: [], + from: DEFAULT_DATE_RANGE_OPTIONS.startDate, + to: DEFAULT_DATE_RANGE_OPTIONS.endDate, }); - } - return ( - - - - - - - {isFetched && data?.metrics ? ( - - ) : isFetching ? ( - - ) : null} - - - ); -}; + useEffect(() => { + if (!metricTypesFromUrl) { + setUrlMetricTypesFilter(metricsFilters.metricTypes.join(',')); + } + if (!dataStreamsFromUrl && dataStreams) { + setUrlDataStreamsFilter(dataStreams.map((ds) => ds.name).join(',')); + } + if (!startDateFromUrl || !endDateFromUrl) { + setUrlDateRangeFilter({ startDate: metricsFilters.from, endDate: metricsFilters.to }); + } + }, [ + dataStreams, + dataStreamsFromUrl, + endDateFromUrl, + metricTypesFromUrl, + metricsFilters.dataStreams, + metricsFilters.from, + metricsFilters.metricTypes, + metricsFilters.to, + setUrlDataStreamsFilter, + setUrlDateRangeFilter, + setUrlMetricTypesFilter, + startDateFromUrl, + ]); + + useEffect(() => { + setMetricsFilters((prevState) => ({ + ...prevState, + metricTypes: metricTypesFromUrl?.length ? metricTypesFromUrl : prevState.metricTypes, + dataStreams: dataStreamsFromUrl?.length ? dataStreamsFromUrl : prevState.dataStreams, + })); + }, [metricTypesFromUrl, dataStreamsFromUrl]); + + const { dateRangePickerState, onRefreshChange, onTimeChange } = useDateRangePicker(); + + const { + error: errorFetchingDataUsageMetrics, + data, + isFetching, + isFetched, + refetch: refetchDataUsageMetrics, + } = useGetDataUsageMetrics( + { + ...metricsFilters, + from: dateRangePickerState.startDate, + to: dateRangePickerState.endDate, + }, + { + retry: false, + enabled: !!metricsFilters.dataStreams.length, + } + ); + + const onRefresh = useCallback(() => { + refetchDataUsageMetrics(); + }, [refetchDataUsageMetrics]); + + const onChangeDataStreamsFilter = useCallback( + (selectedDataStreams: string[]) => { + setMetricsFilters((prevState) => ({ ...prevState, dataStreams: selectedDataStreams })); + }, + [setMetricsFilters] + ); + + const onChangeMetricTypesFilter = useCallback( + (selectedMetricTypes: string[]) => { + setMetricsFilters((prevState) => ({ ...prevState, metricTypes: selectedMetricTypes })); + }, + [setMetricsFilters] + ); + + const filterOptions: ChartFiltersProps['filterOptions'] = useMemo(() => { + const dataStreamsOptions = dataStreams?.reduce>((acc, ds) => { + acc[ds.name] = ds.storageSizeBytes; + return acc; + }, {}); + + return { + dataStreams: { + filterName: 'dataStreams', + options: dataStreamsOptions + ? Object.keys(dataStreamsOptions) + : metricsFilters.dataStreams, + appendOptions: dataStreamsOptions, + selectedOptions: metricsFilters.dataStreams, + onChangeFilterOptions: onChangeDataStreamsFilter, + isFilterLoading: isFetchingDataStreams, + }, + metricTypes: { + filterName: 'metricTypes', + options: metricsFilters.metricTypes, + onChangeFilterOptions: onChangeMetricTypesFilter, + }, + }; + }, [ + dataStreams, + isFetchingDataStreams, + metricsFilters.dataStreams, + metricsFilters.metricTypes, + onChangeDataStreamsFilter, + onChangeMetricTypesFilter, + ]); + + if (errorFetchingDataUsageMetrics) { + notifications.toasts.addDanger({ + title: i18n.translate('xpack.dataUsage.getMetrics.addFailure.toast.title', { + defaultMessage: 'Error getting usage metrics', + }), + text: errorFetchingDataUsageMetrics.message, + }); + } + if (errorFetchingDataStreams) { + notifications.toasts.addDanger({ + title: i18n.translate('xpack.dataUsage.getDataStreams.addFailure.toast.title', { + defaultMessage: 'Error getting data streams', + }), + text: errorFetchingDataStreams.message, + }); + } + + return ( + + + + + + + {isFetched && data?.metrics ? ( + + ) : isFetching ? ( + + ) : null} + + + ); + } +); diff --git a/x-pack/plugins/data_usage/public/app/components/filters/charts_filter.tsx b/x-pack/plugins/data_usage/public/app/components/filters/charts_filter.tsx index 83d417565f012..6b4806537e74b 100644 --- a/x-pack/plugins/data_usage/public/app/components/filters/charts_filter.tsx +++ b/x-pack/plugins/data_usage/public/app/components/filters/charts_filter.tsx @@ -193,13 +193,10 @@ export const ChartsFilter = memo( > {(list, search) => { return ( -
+
{isSearchable && ( {search} diff --git a/x-pack/plugins/data_usage/public/app/components/filters/charts_filter_popover.tsx b/x-pack/plugins/data_usage/public/app/components/filters/charts_filter_popover.tsx index 2ed96f012c497..3c0237c84a0c9 100644 --- a/x-pack/plugins/data_usage/public/app/components/filters/charts_filter_popover.tsx +++ b/x-pack/plugins/data_usage/public/app/components/filters/charts_filter_popover.tsx @@ -42,7 +42,7 @@ export const ChartsFilterPopover = memo( const button = useMemo( () => ( ( const filters = useMemo(() => { return ( <> - {showMetricsTypesFilter && } + {showMetricsTypesFilter && ( + + )} {!filterOptions.dataStreams.isFilterLoading && ( - + )} ); - }, [filterOptions, showMetricsTypesFilter]); + }, [dataTestSubj, filterOptions, showMetricsTypesFilter]); const onClickRefreshButton = useCallback(() => onClick(), [onClick]); @@ -68,6 +70,7 @@ export const ChartFilters = memo( onRefresh={onRefresh} onRefreshChange={onRefreshChange} onTimeChange={onTimeChange} + data-test-subj={dataTestSubj} /> diff --git a/x-pack/plugins/data_usage/public/app/components/filters/date_picker.tsx b/x-pack/plugins/data_usage/public/app/components/filters/date_picker.tsx index 4d9b280d763ce..044a036eea61f 100644 --- a/x-pack/plugins/data_usage/public/app/components/filters/date_picker.tsx +++ b/x-pack/plugins/data_usage/public/app/components/filters/date_picker.tsx @@ -15,6 +15,7 @@ import type { OnRefreshChangeProps, } from '@elastic/eui/src/components/date_picker/types'; import { UI_SETTINGS } from '@kbn/data-plugin/common'; +import { useTestIdGenerator } from '../../../hooks/use_test_id_generator'; export interface DateRangePickerValues { autoRefreshOptions: { @@ -32,10 +33,19 @@ interface UsageMetricsDateRangePickerProps { onRefresh: () => void; onRefreshChange: (evt: OnRefreshChangeProps) => void; onTimeChange: ({ start, end }: DurationRange) => void; + 'data-test-subj'?: string; } export const UsageMetricsDateRangePicker = memo( - ({ dateRangePickerState, isDataLoading, onRefresh, onRefreshChange, onTimeChange }) => { + ({ + dateRangePickerState, + isDataLoading, + onRefresh, + onRefreshChange, + onTimeChange, + 'data-test-subj': dataTestSubj, + }) => { + const getTestId = useTestIdGenerator(dataTestSubj); const kibana = useKibana(); const { uiSettings } = kibana.services; const [commonlyUsedRanges] = useState(() => { @@ -54,6 +64,7 @@ export const UsageMetricsDateRangePicker = memo { + const getMetricTypesAsArray = (): MetricTypes[] => { + return [...METRIC_TYPE_VALUES]; + }; + + it('should not use invalid `metricTypes` values from URL params', () => { + expect(getDataUsageMetricsFiltersFromUrlParams({ metricTypes: 'bar,foo' })).toEqual({}); + }); + + it('should use valid `metricTypes` values from URL params', () => { + expect( + getDataUsageMetricsFiltersFromUrlParams({ + metricTypes: `${getMetricTypesAsArray().join()},foo,bar`, + }) + ).toEqual({ + metricTypes: getMetricTypesAsArray().sort(), + }); + }); + + it('should use given `dataStreams` values from URL params', () => { + expect( + getDataUsageMetricsFiltersFromUrlParams({ + dataStreams: 'ds-3,ds-1,ds-2', + }) + ).toEqual({ + dataStreams: ['ds-3', 'ds-1', 'ds-2'], + }); + }); + + it('should use valid `metricTypes` along with given `dataStreams` and date values from URL params', () => { + expect( + getDataUsageMetricsFiltersFromUrlParams({ + metricTypes: getMetricTypesAsArray().join(), + dataStreams: 'ds-5,ds-1,ds-2', + startDate: '2022-09-12T08:00:00.000Z', + endDate: '2022-09-12T08:30:33.140Z', + }) + ).toEqual({ + metricTypes: getMetricTypesAsArray().sort(), + endDate: '2022-09-12T08:30:33.140Z', + dataStreams: ['ds-5', 'ds-1', 'ds-2'], + startDate: '2022-09-12T08:00:00.000Z', + }); + }); + + it('should use given relative startDate and endDate values URL params', () => { + expect( + getDataUsageMetricsFiltersFromUrlParams({ + startDate: 'now-24h/h', + endDate: 'now', + }) + ).toEqual({ + endDate: 'now', + startDate: 'now-24h/h', + }); + }); + + it('should use given absolute startDate and endDate values URL params', () => { + expect( + getDataUsageMetricsFiltersFromUrlParams({ + startDate: '2022-09-12T08:00:00.000Z', + endDate: '2022-09-12T08:30:33.140Z', + }) + ).toEqual({ + endDate: '2022-09-12T08:30:33.140Z', + startDate: '2022-09-12T08:00:00.000Z', + }); + }); +}); diff --git a/x-pack/plugins/data_usage/public/hooks/use_get_data_streams.test.tsx b/x-pack/plugins/data_usage/public/hooks/use_get_data_streams.test.tsx new file mode 100644 index 0000000000000..04cee589a523d --- /dev/null +++ b/x-pack/plugins/data_usage/public/hooks/use_get_data_streams.test.tsx @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { ReactNode } from 'react'; +import { QueryClient, QueryClientProvider, useQuery as _useQuery } from '@tanstack/react-query'; +import { renderHook } from '@testing-library/react-hooks'; +import { useGetDataUsageDataStreams } from './use_get_data_streams'; +import { DATA_USAGE_DATA_STREAMS_API_ROUTE } from '../../common'; +import { coreMock as mockCore } from '@kbn/core/public/mocks'; +import { dataUsageTestQueryClientOptions } from '../../common/test_utils/test_query_client_options'; + +const useQueryMock = _useQuery as jest.Mock; + +jest.mock('@tanstack/react-query', () => { + const actualReactQueryModule = jest.requireActual('@tanstack/react-query'); + + return { + ...actualReactQueryModule, + useQuery: jest.fn((...args) => actualReactQueryModule.useQuery(...args)), + }; +}); + +const mockServices = mockCore.createStart(); +const createWrapper = () => { + const queryClient = new QueryClient(dataUsageTestQueryClientOptions); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +}; + +jest.mock('../utils/use_kibana', () => { + return { + useKibanaContextForPlugin: () => ({ + services: mockServices, + }), + }; +}); + +const defaultDataStreamsRequestParams = { + options: { enabled: true }, +}; + +describe('useGetDataUsageDataStreams', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call the correct API', async () => { + await renderHook(() => useGetDataUsageDataStreams(defaultDataStreamsRequestParams), { + wrapper: createWrapper(), + }); + + expect(mockServices.http.get).toHaveBeenCalledWith(DATA_USAGE_DATA_STREAMS_API_ROUTE, { + signal: expect.any(AbortSignal), + version: '1', + }); + }); + + it('should not send selected data stream names provided in the param when calling the API', async () => { + await renderHook( + () => + useGetDataUsageDataStreams({ + ...defaultDataStreamsRequestParams, + selectedDataStreams: ['ds-1'], + }), + { + wrapper: createWrapper(), + } + ); + + expect(mockServices.http.get).toHaveBeenCalledWith(DATA_USAGE_DATA_STREAMS_API_ROUTE, { + signal: expect.any(AbortSignal), + version: '1', + }); + }); + + it('should not call the API if disabled', async () => { + await renderHook( + () => + useGetDataUsageDataStreams({ + ...defaultDataStreamsRequestParams, + options: { enabled: false }, + }), + { + wrapper: createWrapper(), + } + ); + + expect(mockServices.http.get).not.toHaveBeenCalled(); + }); + + it('should allow custom options to be used', async () => { + await renderHook( + () => + useGetDataUsageDataStreams({ + selectedDataStreams: undefined, + options: { + queryKey: ['test-query-key'], + enabled: true, + retry: false, + }, + }), + { + wrapper: createWrapper(), + } + ); + + expect(useQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['test-query-key'], + enabled: true, + retry: false, + }) + ); + }); +}); diff --git a/x-pack/plugins/data_usage/public/hooks/use_get_data_streams.ts b/x-pack/plugins/data_usage/public/hooks/use_get_data_streams.ts index 598acca3c1faf..acb41e45f4eb6 100644 --- a/x-pack/plugins/data_usage/public/hooks/use_get_data_streams.ts +++ b/x-pack/plugins/data_usage/public/hooks/use_get_data_streams.ts @@ -31,15 +31,16 @@ export const useGetDataUsageDataStreams = ({ selectedDataStreams?: string[]; options?: UseQueryOptions; }): UseQueryResult => { - const http = useKibanaContextForPlugin().services.http; + const { http } = useKibanaContextForPlugin().services; return useQuery({ queryKey: ['get-data-usage-data-streams'], ...options, keepPreviousData: true, - queryFn: async () => { + queryFn: async ({ signal }) => { const dataStreamsResponse = await http .get(DATA_USAGE_DATA_STREAMS_API_ROUTE, { + signal, version: '1', }) .catch((error) => { diff --git a/x-pack/plugins/data_usage/public/hooks/use_get_usage_metrics.test.tsx b/x-pack/plugins/data_usage/public/hooks/use_get_usage_metrics.test.tsx new file mode 100644 index 0000000000000..efc3d2a9f4640 --- /dev/null +++ b/x-pack/plugins/data_usage/public/hooks/use_get_usage_metrics.test.tsx @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { ReactNode } from 'react'; +import { QueryClient, QueryClientProvider, useQuery as _useQuery } from '@tanstack/react-query'; +import { renderHook } from '@testing-library/react-hooks'; +import { useGetDataUsageMetrics } from './use_get_usage_metrics'; +import { DATA_USAGE_METRICS_API_ROUTE } from '../../common'; +import { coreMock as mockCore } from '@kbn/core/public/mocks'; +import { dataUsageTestQueryClientOptions } from '../../common/test_utils/test_query_client_options'; + +const useQueryMock = _useQuery as jest.Mock; + +jest.mock('@tanstack/react-query', () => { + const actualReactQueryModule = jest.requireActual('@tanstack/react-query'); + + return { + ...actualReactQueryModule, + useQuery: jest.fn((...args) => actualReactQueryModule.useQuery(...args)), + }; +}); + +const mockServices = mockCore.createStart(); +const createWrapper = () => { + const queryClient = new QueryClient(dataUsageTestQueryClientOptions); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +}; + +jest.mock('../utils/use_kibana', () => { + return { + useKibanaContextForPlugin: () => ({ + services: mockServices, + }), + }; +}); + +const defaultUsageMetricsRequestBody = { + from: 'now-15m', + to: 'now', + metricTypes: ['ingest_rate'], + dataStreams: ['ds-1'], +}; + +describe('useGetDataUsageMetrics', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call the correct API', async () => { + await renderHook( + () => useGetDataUsageMetrics(defaultUsageMetricsRequestBody, { enabled: true }), + { + wrapper: createWrapper(), + } + ); + + expect(mockServices.http.post).toHaveBeenCalledWith(DATA_USAGE_METRICS_API_ROUTE, { + signal: expect.any(AbortSignal), + version: '1', + body: JSON.stringify(defaultUsageMetricsRequestBody), + }); + }); + + it('should not call the API if disabled', async () => { + await renderHook( + () => useGetDataUsageMetrics(defaultUsageMetricsRequestBody, { enabled: false }), + { + wrapper: createWrapper(), + } + ); + + expect(mockServices.http.post).not.toHaveBeenCalled(); + }); + + it('should allow custom options to be used', async () => { + await renderHook( + () => + useGetDataUsageMetrics(defaultUsageMetricsRequestBody, { + queryKey: ['test-query-key'], + enabled: true, + retry: false, + }), + { + wrapper: createWrapper(), + } + ); + + expect(useQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['test-query-key'], + enabled: true, + retry: false, + }) + ); + }); +}); diff --git a/x-pack/plugins/data_usage/public/hooks/use_get_usage_metrics.ts b/x-pack/plugins/data_usage/public/hooks/use_get_usage_metrics.ts index 7e7406d72b9c0..6b2ef5316b0f6 100644 --- a/x-pack/plugins/data_usage/public/hooks/use_get_usage_metrics.ts +++ b/x-pack/plugins/data_usage/public/hooks/use_get_usage_metrics.ts @@ -21,7 +21,7 @@ export const useGetDataUsageMetrics = ( body: UsageMetricsRequestBody, options: UseQueryOptions> = {} ): UseQueryResult> => { - const http = useKibanaContextForPlugin().services.http; + const { http } = useKibanaContextForPlugin().services; return useQuery>({ queryKey: ['get-data-usage-metrics', body], diff --git a/x-pack/plugins/data_usage/public/utils/format_bytes.test.ts b/x-pack/plugins/data_usage/public/utils/format_bytes.test.ts new file mode 100644 index 0000000000000..ccc7a4c2f0aa2 --- /dev/null +++ b/x-pack/plugins/data_usage/public/utils/format_bytes.test.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { formatBytes } from './format_bytes'; + +const exponentN = (number: number, exponent: number) => number ** exponent; + +describe('formatBytes', () => { + it('should format bytes to human readable format with decimal', () => { + expect(formatBytes(84 + 5)).toBe('89.0 B'); + expect(formatBytes(1024 + 256)).toBe('1.3 KB'); + expect(formatBytes(1024 + 582)).toBe('1.6 KB'); + expect(formatBytes(exponentN(1024, 2) + 582 * 1024)).toBe('1.6 MB'); + expect(formatBytes(exponentN(1024, 3) + 582 * exponentN(1024, 2))).toBe('1.6 GB'); + expect(formatBytes(exponentN(1024, 4) + 582 * exponentN(1024, 3))).toBe('1.6 TB'); + expect(formatBytes(exponentN(1024, 5) + 582 * exponentN(1024, 4))).toBe('1.6 PB'); + expect(formatBytes(exponentN(1024, 6) + 582 * exponentN(1024, 5))).toBe('1.6 EB'); + expect(formatBytes(exponentN(1024, 7) + 582 * exponentN(1024, 6))).toBe('1.6 ZB'); + expect(formatBytes(exponentN(1024, 8) + 582 * exponentN(1024, 7))).toBe('1.6 YB'); + }); +}); diff --git a/x-pack/plugins/data_usage/server/mocks/index.ts b/x-pack/plugins/data_usage/server/mocks/index.ts new file mode 100644 index 0000000000000..54260f7309fc6 --- /dev/null +++ b/x-pack/plugins/data_usage/server/mocks/index.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { DeepReadonly } from 'utility-types'; +import { PluginInitializerContext } from '@kbn/core/server'; +import { Observable } from 'rxjs'; +import { DataUsageContext } from '../types'; +import { DataUsageConfigType } from '../config'; + +export interface MockedDataUsageContext extends DataUsageContext { + logFactory: ReturnType['get']>; + config$?: Observable; + configInitialValue: DataUsageConfigType; + serverConfig: DeepReadonly; + kibanaInstanceId: PluginInitializerContext['env']['instanceUuid']; + kibanaVersion: PluginInitializerContext['env']['packageInfo']['version']; + kibanaBranch: PluginInitializerContext['env']['packageInfo']['branch']; +} + +export const createMockedDataUsageContext = ( + context: PluginInitializerContext +): MockedDataUsageContext => { + return { + logFactory: loggingSystemMock.create().get(), + config$: context.config.create(), + configInitialValue: context.config.get(), + serverConfig: context.config.get(), + kibanaInstanceId: context.env.instanceUuid, + kibanaVersion: context.env.packageInfo.version, + kibanaBranch: context.env.packageInfo.branch, + }; +}; diff --git a/x-pack/plugins/data_usage/server/routes/internal/data_streams.test.ts b/x-pack/plugins/data_usage/server/routes/internal/data_streams.test.ts new file mode 100644 index 0000000000000..7282dbc969fc7 --- /dev/null +++ b/x-pack/plugins/data_usage/server/routes/internal/data_streams.test.ts @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { MockedKeys } from '@kbn/utility-types-jest'; +import type { CoreSetup } from '@kbn/core/server'; +import { registerDataStreamsRoute } from './data_streams'; +import { coreMock } from '@kbn/core/server/mocks'; +import { httpServerMock } from '@kbn/core/server/mocks'; +import { DataUsageService } from '../../services'; +import type { + DataUsageRequestHandlerContext, + DataUsageRouter, + DataUsageServerStart, +} from '../../types'; +import { DATA_USAGE_DATA_STREAMS_API_ROUTE } from '../../../common'; +import { createMockedDataUsageContext } from '../../mocks'; +import { getMeteringStats } from '../../utils/get_metering_stats'; +import { CustomHttpRequestError } from '../../utils'; + +jest.mock('../../utils/get_metering_stats'); +const mockGetMeteringStats = getMeteringStats as jest.Mock; + +describe('registerDataStreamsRoute', () => { + let mockCore: MockedKeys>; + let router: DataUsageRouter; + let dataUsageService: DataUsageService; + let context: DataUsageRequestHandlerContext; + + beforeEach(() => { + mockCore = coreMock.createSetup(); + router = mockCore.http.createRouter(); + context = coreMock.createCustomRequestHandlerContext( + coreMock.createRequestHandlerContext() + ) as unknown as DataUsageRequestHandlerContext; + + const mockedDataUsageContext = createMockedDataUsageContext( + coreMock.createPluginInitializerContext() + ); + dataUsageService = new DataUsageService(mockedDataUsageContext); + registerDataStreamsRoute(router, dataUsageService); + }); + + it('should request correct API', () => { + expect(router.versioned.get).toHaveBeenCalledTimes(1); + expect(router.versioned.get).toHaveBeenCalledWith({ + access: 'internal', + path: DATA_USAGE_DATA_STREAMS_API_ROUTE, + }); + }); + + it('should correctly sort response', async () => { + mockGetMeteringStats.mockResolvedValue({ + datastreams: [ + { + name: 'datastream1', + size_in_bytes: 100, + }, + { + name: 'datastream2', + size_in_bytes: 200, + }, + ], + }); + const mockRequest = httpServerMock.createKibanaRequest({ body: {} }); + const mockResponse = httpServerMock.createResponseFactory(); + const mockRouter = mockCore.http.createRouter.mock.results[0].value; + const [[, handler]] = mockRouter.versioned.get.mock.results[0].value.addVersion.mock.calls; + await handler(context, mockRequest, mockResponse); + + expect(mockResponse.ok).toHaveBeenCalledTimes(1); + expect(mockResponse.ok.mock.calls[0][0]).toEqual({ + body: [ + { + name: 'datastream2', + storageSizeBytes: 200, + }, + { + name: 'datastream1', + storageSizeBytes: 100, + }, + ], + }); + }); + + it('should return correct error if metering stats request fails', async () => { + // using custom error for test here to avoid having to import the actual error class + mockGetMeteringStats.mockRejectedValue( + new CustomHttpRequestError('Error getting metring stats!') + ); + const mockRequest = httpServerMock.createKibanaRequest({ body: {} }); + const mockResponse = httpServerMock.createResponseFactory(); + const mockRouter = mockCore.http.createRouter.mock.results[0].value; + const [[, handler]] = mockRouter.versioned.get.mock.results[0].value.addVersion.mock.calls; + await handler(context, mockRequest, mockResponse); + + expect(mockResponse.customError).toHaveBeenCalledTimes(1); + expect(mockResponse.customError).toHaveBeenCalledWith({ + body: new CustomHttpRequestError('Error getting metring stats!'), + statusCode: 500, + }); + }); + + it.each([ + ['no datastreams', {}, []], + ['empty array', { datastreams: [] }, []], + ['an empty element', { datastreams: [{}] }, [{ name: undefined, storageSizeBytes: 0 }]], + ])('should return empty array when no stats data with %s', async (_, stats, res) => { + mockGetMeteringStats.mockResolvedValue(stats); + const mockRequest = httpServerMock.createKibanaRequest({ body: {} }); + const mockResponse = httpServerMock.createResponseFactory(); + const mockRouter = mockCore.http.createRouter.mock.results[0].value; + const [[, handler]] = mockRouter.versioned.get.mock.results[0].value.addVersion.mock.calls; + await handler(context, mockRequest, mockResponse); + + expect(mockResponse.ok).toHaveBeenCalledTimes(1); + expect(mockResponse.ok.mock.calls[0][0]).toEqual({ + body: res, + }); + }); +}); diff --git a/x-pack/plugins/data_usage/server/routes/internal/data_streams_handler.ts b/x-pack/plugins/data_usage/server/routes/internal/data_streams_handler.ts index bc8c5e898c35e..66c2cc0df3513 100644 --- a/x-pack/plugins/data_usage/server/routes/internal/data_streams_handler.ts +++ b/x-pack/plugins/data_usage/server/routes/internal/data_streams_handler.ts @@ -5,27 +5,11 @@ * 2.0. */ -import { type ElasticsearchClient, RequestHandler } from '@kbn/core/server'; +import { RequestHandler } from '@kbn/core/server'; import { DataUsageRequestHandlerContext } from '../../types'; import { errorHandler } from '../error_handler'; import { DataUsageService } from '../../services'; - -export interface MeteringStats { - name: string; - num_docs: number; - size_in_bytes: number; -} - -interface MeteringStatsResponse { - datastreams: MeteringStats[]; -} - -const getMeteringStats = (client: ElasticsearchClient) => { - return client.transport.request({ - method: 'GET', - path: '/_metering/stats', - }); -}; +import { getMeteringStats } from '../../utils/get_metering_stats'; export const getDataStreamsHandler = ( dataUsageService: DataUsageService @@ -41,12 +25,15 @@ export const getDataStreamsHandler = ( core.elasticsearch.client.asSecondaryAuthUser ); - const body = meteringStats - .sort((a, b) => b.size_in_bytes - a.size_in_bytes) - .map((stat) => ({ - name: stat.name, - storageSizeBytes: stat.size_in_bytes ?? 0, - })); + const body = + meteringStats && !!meteringStats.length + ? meteringStats + .sort((a, b) => b.size_in_bytes - a.size_in_bytes) + .map((stat) => ({ + name: stat.name, + storageSizeBytes: stat.size_in_bytes ?? 0, + })) + : []; return response.ok({ body, diff --git a/x-pack/plugins/data_usage/server/routes/internal/usage_metrics.test.ts b/x-pack/plugins/data_usage/server/routes/internal/usage_metrics.test.ts new file mode 100644 index 0000000000000..e95ffd11807a9 --- /dev/null +++ b/x-pack/plugins/data_usage/server/routes/internal/usage_metrics.test.ts @@ -0,0 +1,208 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { MockedKeys } from '@kbn/utility-types-jest'; +import type { CoreSetup } from '@kbn/core/server'; +import { registerUsageMetricsRoute } from './usage_metrics'; +import { coreMock } from '@kbn/core/server/mocks'; +import { httpServerMock } from '@kbn/core/server/mocks'; +import { DataUsageService } from '../../services'; +import type { + DataUsageRequestHandlerContext, + DataUsageRouter, + DataUsageServerStart, +} from '../../types'; +import { DATA_USAGE_METRICS_API_ROUTE } from '../../../common'; +import { createMockedDataUsageContext } from '../../mocks'; +import { CustomHttpRequestError } from '../../utils'; +import { AutoOpsError } from '../../services/errors'; + +describe('registerUsageMetricsRoute', () => { + let mockCore: MockedKeys>; + let router: DataUsageRouter; + let dataUsageService: DataUsageService; + let context: DataUsageRequestHandlerContext; + + beforeEach(() => { + mockCore = coreMock.createSetup(); + router = mockCore.http.createRouter(); + context = coreMock.createCustomRequestHandlerContext( + coreMock.createRequestHandlerContext() + ) as unknown as DataUsageRequestHandlerContext; + + const mockedDataUsageContext = createMockedDataUsageContext( + coreMock.createPluginInitializerContext() + ); + dataUsageService = new DataUsageService(mockedDataUsageContext); + }); + + it('should request correct API', () => { + registerUsageMetricsRoute(router, dataUsageService); + + expect(router.versioned.post).toHaveBeenCalledTimes(1); + expect(router.versioned.post).toHaveBeenCalledWith({ + access: 'internal', + path: DATA_USAGE_METRICS_API_ROUTE, + }); + }); + + it('should throw error if no data streams in the request', async () => { + registerUsageMetricsRoute(router, dataUsageService); + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { + from: 'now-15m', + to: 'now', + metricTypes: ['ingest_rate'], + dataStreams: [], + }, + }); + const mockResponse = httpServerMock.createResponseFactory(); + const mockRouter = mockCore.http.createRouter.mock.results[0].value; + const [[, handler]] = mockRouter.versioned.post.mock.results[0].value.addVersion.mock.calls; + await handler(context, mockRequest, mockResponse); + + expect(mockResponse.customError).toHaveBeenCalledTimes(1); + expect(mockResponse.customError).toHaveBeenCalledWith({ + body: new CustomHttpRequestError('[request body.dataStreams]: no data streams selected'), + statusCode: 400, + }); + }); + + it('should correctly transform response', async () => { + (await context.core).elasticsearch.client.asCurrentUser.indices.getDataStream = jest + .fn() + .mockResolvedValue({ + data_streams: [{ name: '.ds-1' }, { name: '.ds-2' }], + }); + + dataUsageService.getMetrics = jest.fn().mockResolvedValue({ + metrics: { + ingest_rate: [ + { + name: '.ds-1', + data: [ + [1726858530000, 13756849], + [1726862130000, 14657904], + ], + }, + { + name: '.ds-2', + data: [ + [1726858530000, 12894623], + [1726862130000, 14436905], + ], + }, + ], + storage_retained: [ + { + name: '.ds-1', + data: [ + [1726858530000, 12576413], + [1726862130000, 13956423], + ], + }, + { + name: '.ds-2', + data: [ + [1726858530000, 12894623], + [1726862130000, 14436905], + ], + }, + ], + }, + }); + + registerUsageMetricsRoute(router, dataUsageService); + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { + from: 'now-15m', + to: 'now', + metricTypes: ['ingest_rate', 'storage_retained'], + dataStreams: ['.ds-1', '.ds-2'], + }, + }); + const mockResponse = httpServerMock.createResponseFactory(); + const mockRouter = mockCore.http.createRouter.mock.results[0].value; + const [[, handler]] = mockRouter.versioned.post.mock.results[0].value.addVersion.mock.calls; + await handler(context, mockRequest, mockResponse); + + expect(mockResponse.ok).toHaveBeenCalledTimes(1); + expect(mockResponse.ok.mock.calls[0][0]).toEqual({ + body: { + metrics: { + ingest_rate: [ + { + name: '.ds-1', + data: [ + { x: 1726858530000, y: 13756849 }, + { x: 1726862130000, y: 14657904 }, + ], + }, + { + name: '.ds-2', + data: [ + { x: 1726858530000, y: 12894623 }, + { x: 1726862130000, y: 14436905 }, + ], + }, + ], + storage_retained: [ + { + name: '.ds-1', + data: [ + { x: 1726858530000, y: 12576413 }, + { x: 1726862130000, y: 13956423 }, + ], + }, + { + name: '.ds-2', + data: [ + { x: 1726858530000, y: 12894623 }, + { x: 1726862130000, y: 14436905 }, + ], + }, + ], + }, + }, + }); + }); + + it('should throw error if error on requesting auto ops service', async () => { + (await context.core).elasticsearch.client.asCurrentUser.indices.getDataStream = jest + .fn() + .mockResolvedValue({ + data_streams: [{ name: '.ds-1' }, { name: '.ds-2' }], + }); + + dataUsageService.getMetrics = jest + .fn() + .mockRejectedValue(new AutoOpsError('Uh oh, something went wrong!')); + + registerUsageMetricsRoute(router, dataUsageService); + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { + from: 'now-15m', + to: 'now', + metricTypes: ['ingest_rate'], + dataStreams: ['.ds-1', '.ds-2'], + }, + }); + const mockResponse = httpServerMock.createResponseFactory(); + const mockRouter = mockCore.http.createRouter.mock.results[0].value; + const [[, handler]] = mockRouter.versioned.post.mock.results[0].value.addVersion.mock.calls; + await handler(context, mockRequest, mockResponse); + + expect(mockResponse.customError).toHaveBeenCalledTimes(1); + expect(mockResponse.customError).toHaveBeenCalledWith({ + body: new AutoOpsError('Uh oh, something went wrong!'), + statusCode: 503, + }); + }); +}); diff --git a/x-pack/plugins/data_usage/server/routes/internal/usage_metrics_handler.ts b/x-pack/plugins/data_usage/server/routes/internal/usage_metrics_handler.ts index 93b31033fc4fb..a714259e1e11c 100644 --- a/x-pack/plugins/data_usage/server/routes/internal/usage_metrics_handler.ts +++ b/x-pack/plugins/data_usage/server/routes/internal/usage_metrics_handler.ts @@ -6,7 +6,6 @@ */ import { RequestHandler } from '@kbn/core/server'; -import { IndicesGetDataStreamResponse } from '@elastic/elasticsearch/lib/api/types'; import { MetricTypes, UsageMetricsAutoOpsResponseSchemaBody, @@ -44,12 +43,22 @@ export const getUsageMetricsHandler = ( new CustomHttpRequestError('[request body.dataStreams]: no data streams selected', 400) ); } + let dataStreamsResponse; - const { data_streams: dataStreamsResponse }: IndicesGetDataStreamResponse = - await esClient.indices.getDataStream({ + try { + // Attempt to fetch data streams + const { data_streams: dataStreams } = await esClient.indices.getDataStream({ name: requestDsNames, expand_wildcards: 'all', }); + dataStreamsResponse = dataStreams; + } catch (error) { + return errorHandler( + logger, + response, + new CustomHttpRequestError('Failed to retrieve data streams', 400) + ); + } const metrics = await dataUsageService.getMetrics({ from, to, @@ -69,7 +78,7 @@ export const getUsageMetricsHandler = ( }; }; -function transformMetricsData( +export function transformMetricsData( data: UsageMetricsAutoOpsResponseSchemaBody ): UsageMetricsResponseSchemaBody { return { diff --git a/x-pack/plugins/data_usage/server/services/autoops_api.ts b/x-pack/plugins/data_usage/server/services/autoops_api.ts index e5ffe24c6167a..6a9de27f996f1 100644 --- a/x-pack/plugins/data_usage/server/services/autoops_api.ts +++ b/x-pack/plugins/data_usage/server/services/autoops_api.ts @@ -13,6 +13,7 @@ import type { AxiosError, AxiosRequestConfig } from 'axios'; import axios from 'axios'; import { LogMeta } from '@kbn/core/server'; import { + UsageMetricsAutoOpsResponseSchema, UsageMetricsAutoOpsResponseSchemaBody, UsageMetricsRequestBody, } from '../../common/rest_types'; @@ -134,8 +135,10 @@ export class AutoOpsAPIService { } ); + const validatedResponse = UsageMetricsAutoOpsResponseSchema.body().validate(response.data); + logger.debug(`[AutoOps API] Successfully created an autoops agent ${response}`); - return response; + return validatedResponse; } private createTlsConfig(autoopsConfig: AutoOpsConfig | undefined) { diff --git a/x-pack/plugins/data_usage/server/services/index.ts b/x-pack/plugins/data_usage/server/services/index.ts index 9ccd08861a26c..3752553e50e9f 100644 --- a/x-pack/plugins/data_usage/server/services/index.ts +++ b/x-pack/plugins/data_usage/server/services/index.ts @@ -41,7 +41,7 @@ export class DataUsageService { metricTypes, dataStreams, }); - return response.data; + return response; } catch (error) { if (error instanceof ValidationError) { throw new AutoOpsError(error.message); diff --git a/x-pack/plugins/data_usage/server/utils/get_metering_stats.ts b/x-pack/plugins/data_usage/server/utils/get_metering_stats.ts new file mode 100644 index 0000000000000..4ba30f5bd3601 --- /dev/null +++ b/x-pack/plugins/data_usage/server/utils/get_metering_stats.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { type ElasticsearchClient } from '@kbn/core/server'; + +export interface MeteringStats { + name: string; + num_docs: number; + size_in_bytes: number; +} + +interface MeteringStatsResponse { + datastreams: MeteringStats[]; +} + +export const getMeteringStats = (client: ElasticsearchClient) => { + return client.transport.request({ + method: 'GET', + path: '/_metering/stats', + }); +}; diff --git a/x-pack/plugins/data_usage/tsconfig.json b/x-pack/plugins/data_usage/tsconfig.json index 78c501922f239..66c8a5247858b 100644 --- a/x-pack/plugins/data_usage/tsconfig.json +++ b/x-pack/plugins/data_usage/tsconfig.json @@ -31,6 +31,7 @@ "@kbn/repo-info", "@kbn/cloud-plugin", "@kbn/server-http-tools", + "@kbn/utility-types-jest", ], "exclude": ["target/**/*"] } diff --git a/x-pack/plugins/data_visualizer/kibana.jsonc b/x-pack/plugins/data_visualizer/kibana.jsonc index 06d37106c2480..1e83c34113beb 100644 --- a/x-pack/plugins/data_visualizer/kibana.jsonc +++ b/x-pack/plugins/data_visualizer/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/data-visualizer-plugin", - "owner": "@elastic/ml-ui", + "owner": [ + "@elastic/ml-ui" + ], + "group": "platform", + "visibility": "private", "description": "The Data Visualizer tools help you understand your data, by analyzing the metrics and fields in a log file or an existing Elasticsearch index.", "plugin": { "id": "dataVisualizer", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "data", "usageCollection", @@ -40,4 +44,4 @@ "visualizations" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/data_visualizer/public/application/data_drift/data_drift_page.tsx b/x-pack/plugins/data_visualizer/public/application/data_drift/data_drift_page.tsx index 86bb350849a33..4623e886852d8 100644 --- a/x-pack/plugins/data_visualizer/public/application/data_drift/data_drift_page.tsx +++ b/x-pack/plugins/data_visualizer/public/application/data_drift/data_drift_page.tsx @@ -106,31 +106,31 @@ export const PageHeader: FC = ({ onRefresh, needsUpdate }) => { {dataView.getName()}
} + rightSideGroupProps={{ + gutterSize: 's', + 'data-test-subj': 'dataComparisonTimeRangeSelectorSection', + }} rightSideItems={[ - - {hasValidTimeField ? ( - - - - ) : null} - , + hasValidTimeField && ( + - , - ]} + ), + ].filter(Boolean)} /> ); }; diff --git a/x-pack/plugins/data_visualizer/public/application/data_drift/data_drift_view.tsx b/x-pack/plugins/data_visualizer/public/application/data_drift/data_drift_view.tsx index 170c96d1e0682..06f600928e2a2 100644 --- a/x-pack/plugins/data_visualizer/public/application/data_drift/data_drift_view.tsx +++ b/x-pack/plugins/data_visualizer/public/application/data_drift/data_drift_view.tsx @@ -216,7 +216,7 @@ export const DataDriftView = ({ runAnalysisDisabled={!dataView || requiresWindowParameters} > - + { return drilldown; }; +const renderActionMenuItem = async ( + drilldown: UrlDrilldown, + config: Config, + context: ValueClickContext +) => { + const { getByTestId } = render( + + ); + await waitFor(() => null); // wait for effects to complete + return { + getError: () => getByTestId('urlDrilldown-error'), + }; +}; + describe('UrlDrilldown', () => { const urlDrilldown = createDrilldown(); @@ -119,7 +135,73 @@ describe('UrlDrilldown', () => { await expect(urlDrilldown.isCompatible(config, context)).rejects.toThrowError(); }); - test('compatible if url is valid', async () => { + test('compatible in edit mode if url is valid', async () => { + const config: Config = { + url: { + template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`, + }, + openInNewTab: false, + encodeUrl: true, + }; + + const context: ValueClickContext = { + data: { + data: mockDataPoints, + }, + embeddable: mockEmbeddableApi, + }; + + const result = urlDrilldown.isCompatible(config, context); + await expect(result).resolves.toBe(true); + }); + + test('compatible in edit mode if url is invalid', async () => { + const config: Config = { + url: { + template: `https://elasti.co/?{{event.value}}&{{rison context.panel.somethingFake}}`, + }, + openInNewTab: false, + encodeUrl: true, + }; + + const context: ValueClickContext = { + data: { + data: mockDataPoints, + }, + embeddable: mockEmbeddableApi, + }; + + await expect(urlDrilldown.isCompatible(config, context)).resolves.toBe(true); + }); + + test('compatible in edit mode if external URL is denied', async () => { + const drilldown1 = createDrilldown(true); + const drilldown2 = createDrilldown(false); + const config: Config = { + url: { + template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`, + }, + openInNewTab: false, + encodeUrl: true, + }; + + const context: ValueClickContext = { + data: { + data: mockDataPoints, + }, + embeddable: mockEmbeddableApi, + }; + + const result1 = await drilldown1.isCompatible(config, context); + const result2 = await drilldown2.isCompatible(config, context); + + expect(result1).toBe(true); + expect(result2).toBe(true); + }); + + test('compatible in view mode if url is valid', async () => { + mockEmbeddableApi.parentApi.viewMode.next('view'); + const config: Config = { url: { template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`, @@ -139,7 +221,8 @@ describe('UrlDrilldown', () => { await expect(result).resolves.toBe(true); }); - test('not compatible if url is invalid', async () => { + test('not compatible in view mode if url is invalid', async () => { + mockEmbeddableApi.parentApi.viewMode.next('view'); const config: Config = { url: { template: `https://elasti.co/?{{event.value}}&{{rison context.panel.somethingFake}}`, @@ -158,7 +241,8 @@ describe('UrlDrilldown', () => { await expect(urlDrilldown.isCompatible(config, context)).resolves.toBe(false); }); - test('not compatible if external URL is denied', async () => { + test('not compatible in view mode if external URL is denied', async () => { + mockEmbeddableApi.parentApi.viewMode.next('view'); const drilldown1 = createDrilldown(true); const drilldown2 = createDrilldown(false); const config: Config = { @@ -184,7 +268,7 @@ describe('UrlDrilldown', () => { }); }); - describe('getHref & execute', () => { + describe('getHref & execute & title', () => { beforeEach(() => { mockNavigateToUrl.mockReset(); }); @@ -210,6 +294,9 @@ describe('UrlDrilldown', () => { await urlDrilldown.execute(config, context); expect(mockNavigateToUrl).toBeCalledWith(url); + + const { getError } = await renderActionMenuItem(urlDrilldown, config, context); + expect(() => getError()).toThrow(); }); test('invalid url', async () => { @@ -228,12 +315,17 @@ describe('UrlDrilldown', () => { embeddable: mockEmbeddableApi, }; - await expect(urlDrilldown.getHref(config, context)).rejects.toThrowError(); - await expect(urlDrilldown.execute(config, context)).rejects.toThrowError(); + await expect(urlDrilldown.getHref(config, context)).resolves.toBeUndefined(); + await expect(urlDrilldown.execute(config, context)).resolves.toBeUndefined(); expect(mockNavigateToUrl).not.toBeCalled(); + + const { getError } = await renderActionMenuItem(urlDrilldown, config, context); + expect(getError()).toHaveTextContent( + `Error building URL: The URL template is not valid in the given context.` + ); }); - test('should throw on denied external URL', async () => { + test('should not throw on denied external URL', async () => { const drilldown1 = createDrilldown(true); const drilldown2 = createDrilldown(false); const config: Config = { @@ -257,17 +349,11 @@ describe('UrlDrilldown', () => { expect(url).toMatchInlineSnapshot(`"https://elasti.co/?test&(language:kuery,query:test)"`); expect(mockNavigateToUrl).toBeCalledWith(url); - const [, error1] = await of(drilldown2.getHref(config, context)); - const [, error2] = await of(drilldown2.execute(config, context)); + await expect(drilldown2.getHref(config, context)).resolves.toBeUndefined(); + await expect(drilldown2.execute(config, context)).resolves.toBeUndefined(); - expect(error1).toBeInstanceOf(Error); - expect(error1.message).toMatchInlineSnapshot( - `"External URL [https://elasti.co/?test&(language:kuery,query:test)] was denied by ExternalUrl service. You can configure external URL policies using \\"externalUrl.policy\\" setting in kibana.yml."` - ); - expect(error2).toBeInstanceOf(Error); - expect(error2.message).toMatchInlineSnapshot( - `"External URL [https://elasti.co/?test&(language:kuery,query:test)] was denied by ExternalUrl service. You can configure external URL policies using \\"externalUrl.policy\\" setting in kibana.yml."` - ); + const { getError } = await renderActionMenuItem(drilldown2, config, context); + expect(getError()).toHaveTextContent(`Error building URL: external URL was denied.`); }); }); diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx index 1dd9c94ef329f..fed0542883611 100644 --- a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx @@ -7,7 +7,11 @@ import React from 'react'; import { IExternalUrl, ThemeServiceStart } from '@kbn/core/public'; -import type { EmbeddableApiContext } from '@kbn/presentation-publishing'; +import { + type EmbeddableApiContext, + getInheritedViewMode, + apiCanAccessViewMode, +} from '@kbn/presentation-publishing'; import { ChartActionContext, CONTEXT_MENU_TRIGGER, @@ -17,21 +21,23 @@ import { import { IMAGE_CLICK_TRIGGER } from '@kbn/image-embeddable-plugin/public'; import { ActionExecutionContext, ROW_CLICK_TRIGGER } from '@kbn/ui-actions-plugin/public'; import type { CollectConfigProps as CollectConfigPropsBase } from '@kbn/kibana-utils-plugin/public'; -import { UrlTemplateEditorVariable, KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { KibanaContextProvider, UrlTemplateEditorVariable } from '@kbn/kibana-react-plugin/public'; import { + UiActionsEnhancedBaseActionFactoryContext as BaseActionFactoryContext, UiActionsEnhancedDrilldownDefinition as Drilldown, - UrlDrilldownGlobalScope, - UrlDrilldownConfig, UrlDrilldownCollectConfig, - urlDrilldownValidateUrlTemplate, urlDrilldownCompileUrl, - UiActionsEnhancedBaseActionFactoryContext as BaseActionFactoryContext, + UrlDrilldownConfig, + UrlDrilldownGlobalScope, + urlDrilldownValidateUrlTemplate, } from '@kbn/ui-actions-enhanced-plugin/public'; import type { SerializedAction } from '@kbn/ui-actions-enhanced-plugin/common/types'; import type { SettingsStart } from '@kbn/core-ui-settings-browser'; +import { EuiText, EuiTextBlockTruncate } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { txtUrlDrilldownDisplayName } from './i18n'; -import { getEventVariableList, getEventScopeValues } from './variables/event_variables'; -import { getContextVariableList, getContextScopeValues } from './variables/context_variables'; +import { getEventScopeValues, getEventVariableList } from './variables/event_variables'; +import { getContextScopeValues, getContextVariableList } from './variables/context_variables'; import { getGlobalVariableList } from './variables/global_variables'; interface UrlDrilldownDeps { @@ -58,6 +64,13 @@ export type CollectConfigProps = CollectConfigPropsBase { + if (apiCanAccessViewMode(context.embeddable)) { + return getInheritedViewMode(context.embeddable); + } + throw new Error('Cannot access view mode'); +}; + export class UrlDrilldown implements Drilldown { public readonly id = URL_DRILLDOWN; @@ -75,20 +88,39 @@ export class UrlDrilldown implements Drilldown; }> = ({ config, context }) => { const [title, setTitle] = React.useState(config.name); + const [error, setError] = React.useState(); React.useEffect(() => { - let unmounted = false; const variables = this.getRuntimeVariables(context); urlDrilldownCompileUrl(title, variables, false) .then((result) => { - if (unmounted) return; if (title !== result) setTitle(result); }) .catch(() => {}); - return () => { - unmounted = true; - }; - }); - return <>{title}; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + React.useEffect(() => { + this.buildUrl(config.config, context).catch((e) => { + setError(e.message); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + /* title is used as a tooltip, EuiToolTip doesn't work in this context menu due to hacky zIndex */ + + {title} + {/* note: ideally we'd use EuiIconTip for the error, but it doesn't play well with this context menu*/} + {error ? ( + + + {error} + + + ) : null} + + ); }; public readonly euiIcon = 'link'; @@ -140,53 +172,81 @@ export class UrlDrilldown implements Drilldown { - const scope = this.getRuntimeVariables(context); - const { isValid, error } = await urlDrilldownValidateUrlTemplate(config.url, scope); + const viewMode = getViewMode(context); - if (!isValid) { - // eslint-disable-next-line no-console - console.warn( - `UrlDrilldown [${config.url.template}] is not valid. Error [${error}]. Skipping execution.` - ); - return false; + if (viewMode === 'edit') { + // check if context is compatible by building the scope + const scope = this.getRuntimeVariables(context); + return !!scope; } - const url = await this.buildUrl(config, context); - const validUrl = this.deps.externalUrl.validateUrl(url); - if (!validUrl) { + try { + await this.buildUrl(config, context); + return true; + } catch (e) { + // eslint-disable-next-line no-console + console.warn(e); return false; } - - return true; }; private async buildUrl(config: Config, context: ChartActionContext): Promise { + const scope = this.getRuntimeVariables(context); + const { isValid, error, invalidUrl } = await urlDrilldownValidateUrlTemplate(config.url, scope); + + if (!isValid) { + const errorMessage = i18n.translate('xpack.urlDrilldown.invalidUrlErrorMessage', { + defaultMessage: + 'Error building URL: {error} Use drilldown editor to check your URL template. Invalid URL: {invalidUrl}', + values: { + error, + invalidUrl, + }, + }); + throw new Error(errorMessage); + } + const doEncode = config.encodeUrl ?? true; + const url = await urlDrilldownCompileUrl( config.url.template, this.getRuntimeVariables(context), doEncode ); + + const validUrl = this.deps.externalUrl.validateUrl(url); + if (!validUrl) { + const errorMessage = i18n.translate('xpack.urlDrilldown.invalidUrlErrorMessage', { + defaultMessage: + 'Error building URL: external URL was denied. Administrator can configure external URL policies using "externalUrl.policy" setting in kibana.yml. Invalid URL: {invalidUrl}', + values: { + invalidUrl: url, + }, + }); + throw new Error(errorMessage); + } + return url; } public readonly getHref = async ( config: Config, context: ChartActionContext - ): Promise => { - const url = await this.buildUrl(config, context); - const validUrl = this.deps.externalUrl.validateUrl(url); - if (!validUrl) { - throw new Error( - `External URL [${url}] was denied by ExternalUrl service. ` + - `You can configure external URL policies using "externalUrl.policy" setting in kibana.yml.` - ); + ): Promise => { + try { + const url = await this.buildUrl(config, context); + return url; + } catch (e) { + // eslint-disable-next-line no-console + console.warn(e); + return undefined; } - return url; }; public readonly execute = async (config: Config, context: ChartActionContext) => { const url = await this.getHref(config, context); + if (!url) return; + if (config.openInNewTab) { window.open(url, '_blank', 'noopener'); } else { diff --git a/x-pack/plugins/ecs_data_quality_dashboard/kibana.jsonc b/x-pack/plugins/ecs_data_quality_dashboard/kibana.jsonc index 5adbe3eeee830..63d01bdc23e96 100644 --- a/x-pack/plugins/ecs_data_quality_dashboard/kibana.jsonc +++ b/x-pack/plugins/ecs_data_quality_dashboard/kibana.jsonc @@ -1,17 +1,21 @@ { "type": "plugin", "id": "@kbn/ecs-data-quality-dashboard-plugin", - "owner": "@elastic/security-threat-hunting-explore", + "owner": [ + "@elastic/security-threat-hunting-explore" + ], + "group": "security", + "visibility": "private", "description": "APIs used to assess the quality of data in Elasticsearch indexes", "plugin": { "id": "ecsDataQualityDashboard", - "server": true, "browser": false, + "server": true, "requiredPlugins": [ - "data", + "data" ], "optionalPlugins": [ - "spaces", + "spaces" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/ecs_data_quality_dashboard/server/helpers/get_available_indices.ts b/x-pack/plugins/ecs_data_quality_dashboard/server/helpers/get_available_indices.ts index 8f7fdead51547..85da8b3d539ff 100644 --- a/x-pack/plugins/ecs_data_quality_dashboard/server/helpers/get_available_indices.ts +++ b/x-pack/plugins/ecs_data_quality_dashboard/server/helpers/get_available_indices.ts @@ -8,15 +8,15 @@ import type { SearchRequest } from '@elastic/elasticsearch/lib/api/types'; export const getRequestBody = ({ - indexPattern, + indexNameOrPattern, startDate = 'now-7d/d', endDate = 'now/d', }: { - indexPattern: string; + indexNameOrPattern: string; startDate: string; endDate: string; }): SearchRequest => ({ - index: indexPattern, + index: indexNameOrPattern, aggs: { index: { terms: { diff --git a/x-pack/plugins/ecs_data_quality_dashboard/server/helpers/get_range_filtered_indices.test.ts b/x-pack/plugins/ecs_data_quality_dashboard/server/helpers/get_range_filtered_indices.test.ts new file mode 100644 index 0000000000000..87350abcf8a9c --- /dev/null +++ b/x-pack/plugins/ecs_data_quality_dashboard/server/helpers/get_range_filtered_indices.test.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 { getRangeFilteredIndices } from './get_range_filtered_indices'; +import { fetchAvailableIndices } from '../lib/fetch_available_indices'; +import type { IScopedClusterClient, Logger } from '@kbn/core/server'; + +jest.mock('../lib/fetch_available_indices'); + +const fetchAvailableIndicesMock = fetchAvailableIndices as jest.Mock; + +describe('getRangeFilteredIndices', () => { + let client: jest.Mocked; + let logger: jest.Mocked; + + beforeEach(() => { + client = { + asCurrentUser: jest.fn(), + } as unknown as jest.Mocked; + + logger = { + warn: jest.fn(), + error: jest.fn(), + } as unknown as jest.Mocked; + + jest.clearAllMocks(); + }); + + describe('when fetching available indices is successful', () => { + describe('and there are available indices', () => { + it('should return the flattened available indices', async () => { + fetchAvailableIndicesMock.mockResolvedValueOnce(['index1', 'index2']); + fetchAvailableIndicesMock.mockResolvedValueOnce(['index3']); + + const result = await getRangeFilteredIndices({ + client, + authorizedIndexNames: ['auth1', 'auth2'], + startDate: '2023-01-01', + endDate: '2023-01-31', + logger, + pattern: 'pattern*', + }); + + expect(fetchAvailableIndices).toHaveBeenCalledTimes(2); + expect(result).toEqual(['index1', 'index2', 'index3']); + expect(logger.warn).not.toHaveBeenCalled(); + }); + }); + + describe('and there are no available indices', () => { + it('should log a warning and return an empty array', async () => { + fetchAvailableIndicesMock.mockResolvedValue([]); + + const result = await getRangeFilteredIndices({ + client, + authorizedIndexNames: ['auth1', 'auth2'], + startDate: '2023-01-01', + endDate: '2023-01-31', + logger, + pattern: 'pattern*', + }); + + expect(fetchAvailableIndices).toHaveBeenCalledTimes(2); + expect(result).toEqual([]); + expect(logger.warn).toHaveBeenCalledWith( + 'No available authorized indices found under pattern: pattern*, in the given date range: 2023-01-01 - 2023-01-31' + ); + }); + }); + }); + + describe('when fetching available indices fails', () => { + it('should log an error and return an empty array', async () => { + fetchAvailableIndicesMock.mockRejectedValue(new Error('Fetch error')); + + const result = await getRangeFilteredIndices({ + client, + authorizedIndexNames: ['auth1'], + startDate: '2023-01-01', + endDate: '2023-01-31', + logger, + pattern: 'pattern*', + }); + + expect(fetchAvailableIndices).toHaveBeenCalledTimes(1); + expect(result).toEqual([]); + expect(logger.error).toHaveBeenCalledWith( + 'Error fetching available indices in the given data range: 2023-01-01 - 2023-01-31' + ); + }); + }); +}); diff --git a/x-pack/plugins/ecs_data_quality_dashboard/server/helpers/get_range_filtered_indices.ts b/x-pack/plugins/ecs_data_quality_dashboard/server/helpers/get_range_filtered_indices.ts new file mode 100644 index 0000000000000..45a87424169e8 --- /dev/null +++ b/x-pack/plugins/ecs_data_quality_dashboard/server/helpers/get_range_filtered_indices.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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, Logger } from '@kbn/core/server'; + +import { fetchAvailableIndices } from '../lib/fetch_available_indices'; + +export const getRangeFilteredIndices = async ({ + client, + authorizedIndexNames, + startDate, + endDate, + logger, + pattern, +}: { + client: IScopedClusterClient; + authorizedIndexNames: string[]; + startDate: string; + endDate: string; + logger: Logger; + pattern: string; +}): Promise => { + const decodedStartDate = decodeURIComponent(startDate); + const decodedEndDate = decodeURIComponent(endDate); + try { + const currentUserEsClient = client.asCurrentUser; + + const availableIndicesPromises: Array> = []; + + for (const indexName of authorizedIndexNames) { + availableIndicesPromises.push( + fetchAvailableIndices(currentUserEsClient, { + indexNameOrPattern: indexName, + startDate: decodedStartDate, + endDate: decodedEndDate, + }) + ); + } + + const availableIndices = await Promise.all(availableIndicesPromises); + + const flattenedAvailableIndices = availableIndices.flat(); + + if (flattenedAvailableIndices.length === 0) { + logger.warn( + `No available authorized indices found under pattern: ${pattern}, in the given date range: ${decodedStartDate} - ${decodedEndDate}` + ); + } + + return flattenedAvailableIndices; + } catch (err) { + logger.error( + `Error fetching available indices in the given data range: ${decodedStartDate} - ${decodedEndDate}` + ); + return []; + } +}; diff --git a/x-pack/plugins/ecs_data_quality_dashboard/server/lib/fetch_available_indices.test.ts b/x-pack/plugins/ecs_data_quality_dashboard/server/lib/fetch_available_indices.test.ts new file mode 100644 index 0000000000000..9fe8213b4eb95 --- /dev/null +++ b/x-pack/plugins/ecs_data_quality_dashboard/server/lib/fetch_available_indices.test.ts @@ -0,0 +1,454 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ElasticsearchClient } from '@kbn/core/server'; +import moment from 'moment-timezone'; + +import type { + FetchAvailableCatIndicesResponseRequired, + IndexSearchAggregationResponse, +} from './fetch_available_indices'; +import { fetchAvailableIndices } from './fetch_available_indices'; + +function getEsClientMock() { + return { + search: jest.fn().mockResolvedValue({ + aggregations: { + index: { + buckets: [], + }, + }, + }), + cat: { + indices: jest.fn().mockResolvedValue([]), + }, + } as unknown as ElasticsearchClient & { + cat: { + indices: jest.Mock>; + }; + search: jest.Mock>; + }; +} + +// fixing timezone for both Date and moment +// so when tests are run in different timezones, the results are consistent +process.env.TZ = 'UTC'; +moment.tz.setDefault('UTC'); + +const DAY_IN_MILLIS = 24 * 60 * 60 * 1000; + +// We assume that the dates are in UTC, because es is using UTC +// It also diminishes difference date parsing by Date and moment constructors +// in different timezones, i.e. short ISO format '2021-10-01' is parsed as local +// date by moment and as UTC date by Date, whereas long ISO format '2021-10-01T00:00:00Z' +// is parsed as UTC date by both +const startDateString: string = '2021-10-01T00:00:00Z'; +const endDateString: string = '2021-10-07T00:00:00Z'; + +const startDateMillis: number = new Date(startDateString).getTime(); +const endDateMillis: number = new Date(endDateString).getTime(); + +describe('fetchAvailableIndices', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('aggregate search given index by startDate and endDate', async () => { + const esClientMock = getEsClientMock(); + + await fetchAvailableIndices(esClientMock, { + indexNameOrPattern: 'logs-*', + startDate: startDateString, + endDate: endDateString, + }); + + expect(esClientMock.search).toHaveBeenCalledWith({ + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + format: 'strict_date_optional_time', + gte: startDateString, + lte: endDateString, + }, + }, + }, + ], + must: [], + must_not: [], + should: [], + }, + }, + index: 'logs-*', + size: 0, + aggs: { + index: { + terms: { + field: '_index', + }, + }, + }, + }); + }); + + it('should call esClient.cat.indices for given index', async () => { + const esClientMock = getEsClientMock(); + + await fetchAvailableIndices(esClientMock, { + indexNameOrPattern: 'logs-*', + startDate: startDateString, + endDate: endDateString, + }); + + expect(esClientMock.cat.indices).toHaveBeenCalledWith({ + index: 'logs-*', + format: 'json', + h: 'index,creation.date', + }); + }); + + describe('when indices are created within the date range', () => { + it('returns indices within the date range', async () => { + const esClientMock = getEsClientMock(); + + esClientMock.cat.indices.mockResolvedValue([ + { + index: 'logs-2021.10.01', + 'creation.date': `${startDateMillis}`, + }, + { + index: 'logs-2021.10.05', + 'creation.date': `${startDateMillis + 4 * DAY_IN_MILLIS}`, + }, + { + index: 'logs-2021.09.30', + 'creation.date': `${startDateMillis - DAY_IN_MILLIS}`, + }, + ]); + + const result = await fetchAvailableIndices(esClientMock, { + indexNameOrPattern: 'logs-*', + startDate: startDateString, + endDate: endDateString, + }); + + expect(result).toEqual(['logs-2021.10.01', 'logs-2021.10.05']); + + expect(esClientMock.cat.indices).toHaveBeenCalledWith({ + index: 'logs-*', + format: 'json', + h: 'index,creation.date', + }); + }); + }); + + describe('when indices are outside the date range', () => { + it('returns an empty list', async () => { + const esClientMock = getEsClientMock(); + + esClientMock.cat.indices.mockResolvedValue([ + { + index: 'logs-2021.09.30', + 'creation.date': `${startDateMillis - DAY_IN_MILLIS}`, + }, + { + index: 'logs-2021.10.08', + 'creation.date': `${endDateMillis + DAY_IN_MILLIS}`, + }, + ]); + + const result = await fetchAvailableIndices(esClientMock, { + indexNameOrPattern: 'logs-*', + startDate: startDateString, + endDate: endDateString, + }); + + expect(result).toEqual([]); + }); + }); + + describe('when no indices match the index pattern', () => { + it('returns empty list', async () => { + const esClientMock = getEsClientMock(); + + esClientMock.cat.indices.mockResolvedValue([]); + + const result = await fetchAvailableIndices(esClientMock, { + indexNameOrPattern: 'nonexistent-*', + startDate: startDateString, + endDate: endDateString, + }); + + expect(result).toEqual([]); + }); + }); + + describe('when indices have data in the date range', () => { + it('returns indices with data in the date range', async () => { + const esClientMock = getEsClientMock(); + + // esClient.cat.indices returns no indices + esClientMock.cat.indices.mockResolvedValue([]); + + // esClient.search returns indices with data in the date range + esClientMock.search.mockResolvedValue({ + aggregations: { + index: { + buckets: [ + { key: 'logs-2021.10.02', doc_count: 100 }, + { key: 'logs-2021.10.03', doc_count: 150 }, + ], + }, + }, + }); + + const result = await fetchAvailableIndices(esClientMock, { + indexNameOrPattern: 'logs-*', + startDate: startDateString, + endDate: endDateString, + }); + + expect(result).toEqual(['logs-2021.10.02', 'logs-2021.10.03']); + }); + + it('combines indices from both methods without duplicates', async () => { + const esClientMock = getEsClientMock(); + + esClientMock.cat.indices.mockResolvedValue([ + { + index: 'logs-2021.10.01', + 'creation.date': `${startDateMillis}`, + }, + { + index: 'logs-2021.10.03', + 'creation.date': `${startDateMillis + 2 * DAY_IN_MILLIS}`, + }, + ]); + + esClientMock.search.mockResolvedValue({ + aggregations: { + index: { + buckets: [ + { key: 'logs-2021.10.03', doc_count: 150 }, + { key: 'logs-2021.10.04', doc_count: 200 }, + ], + }, + }, + }); + + const result = await fetchAvailableIndices(esClientMock, { + indexNameOrPattern: 'logs-*', + startDate: startDateString, + endDate: endDateString, + }); + + expect(result).toEqual(['logs-2021.10.01', 'logs-2021.10.03', 'logs-2021.10.04']); + }); + }); + + describe('edge cases for creation dates', () => { + it('includes indices with creation date exactly at startDate and endDate', async () => { + const esClientMock = getEsClientMock(); + + esClientMock.cat.indices.mockResolvedValue([ + { + index: 'logs-2021.10.01', + 'creation.date': `${startDateMillis}`, + }, + { + index: 'logs-2021.10.07', + 'creation.date': `${endDateMillis}`, + }, + ]); + + const result = await fetchAvailableIndices(esClientMock, { + indexNameOrPattern: 'logs-*', + startDate: startDateString, + endDate: endDateString, + }); + + expect(result).toEqual(['logs-2021.10.01', 'logs-2021.10.07']); + }); + }); + + describe('when esClient.search rejects', () => { + it('throws an error', async () => { + const esClientMock = getEsClientMock(); + + esClientMock.search.mockRejectedValue(new Error('Elasticsearch search error')); + + await expect( + fetchAvailableIndices(esClientMock, { + indexNameOrPattern: 'logs-*', + startDate: startDateString, + endDate: endDateString, + }) + ).rejects.toThrow('Elasticsearch search error'); + }); + }); + + describe('when both esClient.cat.indices and esClient.search return empty', () => { + it('returns an empty list', async () => { + const esClientMock = getEsClientMock(); + + esClientMock.cat.indices.mockResolvedValue([]); + esClientMock.search.mockResolvedValue({ + aggregations: { + index: { + buckets: [], + }, + }, + }); + + const result = await fetchAvailableIndices(esClientMock, { + indexNameOrPattern: 'logs-*', + startDate: startDateString, + endDate: endDateString, + }); + + expect(result).toEqual([]); + }); + }); + + describe('when indices are returned with both methods and have duplicates', () => { + it('does not duplicate indices in the result', async () => { + const esClientMock = getEsClientMock(); + + esClientMock.cat.indices.mockResolvedValue([ + { + index: 'logs-2021.10.05', + 'creation.date': `${startDateMillis + 4 * DAY_IN_MILLIS}`, + }, + ]); + + esClientMock.search.mockResolvedValue({ + aggregations: { + index: { + buckets: [{ key: 'logs-2021.10.05', doc_count: 100 }], + }, + }, + }); + + const result = await fetchAvailableIndices(esClientMock, { + indexNameOrPattern: 'logs-*', + startDate: startDateString, + endDate: endDateString, + }); + + expect(result).toEqual(['logs-2021.10.05']); + }); + }); + + describe('given keyword dates', () => { + describe('given 7 days range', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2021-10-07T00:00:00Z').getTime()); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('finds indices created within the date range', async () => { + const esClientMock = getEsClientMock(); + + esClientMock.cat.indices.mockResolvedValue([ + { + index: 'logs-2021.10.01', + 'creation.date': `${startDateMillis}`, + }, + { + index: 'logs-2021.10.05', + 'creation.date': `${startDateMillis + 4 * DAY_IN_MILLIS}`, + }, + ]); + + const results = await fetchAvailableIndices(esClientMock, { + indexNameOrPattern: 'logs-*', + startDate: 'now-7d/d', + endDate: 'now/d', + }); + + expect(results).toEqual(['logs-2021.10.01', 'logs-2021.10.05']); + }); + + it('finds indices with end date rounded up to the end of the day', async () => { + const esClientMock = getEsClientMock(); + + esClientMock.cat.indices.mockResolvedValue([ + { + index: 'logs-2021.10.06', + 'creation.date': `${new Date('2021-10-06T23:59:59Z').getTime()}`, + }, + ]); + + const results = await fetchAvailableIndices(esClientMock, { + indexNameOrPattern: 'logs-*', + startDate: 'now-7d/d', + endDate: 'now-1d/d', + }); + + expect(results).toEqual(['logs-2021.10.06']); + }); + }); + }); + + describe('rejections', () => { + beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + describe('when esClient.cat.indices rejects', () => { + it('throws an error', async () => { + const esClientMock = getEsClientMock(); + + esClientMock.cat.indices.mockRejectedValue(new Error('Elasticsearch error')); + + await expect( + fetchAvailableIndices(esClientMock, { + indexNameOrPattern: 'logs-*', + startDate: startDateString, + endDate: endDateString, + }) + ).rejects.toThrow('Elasticsearch error'); + }); + }); + + describe('when startDate is invalid', () => { + it('throws an error', async () => { + const esClientMock = getEsClientMock(); + + await expect( + fetchAvailableIndices(esClientMock, { + indexNameOrPattern: 'logs-*', + startDate: 'invalid-date', + endDate: endDateString, + }) + ).rejects.toThrow('Invalid date format: invalid-date'); + }); + }); + + describe('when endDate is invalid', () => { + it('throws an error', async () => { + const esClientMock = getEsClientMock(); + + await expect( + fetchAvailableIndices(esClientMock, { + indexNameOrPattern: 'logs-*', + startDate: startDateString, + endDate: 'invalid-date', + }) + ).rejects.toThrow('Invalid date format: invalid-date'); + }); + }); + }); +}); diff --git a/x-pack/plugins/ecs_data_quality_dashboard/server/lib/fetch_available_indices.ts b/x-pack/plugins/ecs_data_quality_dashboard/server/lib/fetch_available_indices.ts index 584a261689113..36009f315010b 100644 --- a/x-pack/plugins/ecs_data_quality_dashboard/server/lib/fetch_available_indices.ts +++ b/x-pack/plugins/ecs_data_quality_dashboard/server/lib/fetch_available_indices.ts @@ -4,18 +4,72 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import type { ElasticsearchClient } from '@kbn/core/server'; +import type { CatIndicesIndicesRecord } from '@elastic/elasticsearch/lib/api/types'; +import dateMath from '@kbn/datemath'; + import { getRequestBody } from '../helpers/get_available_indices'; +export type FetchAvailableCatIndicesResponseRequired = Array< + Required> +>; + type AggregateName = 'index'; -interface Result { +export interface IndexSearchAggregationResponse { index: { - buckets: Array<{ key: string }>; - doc_count: number; + buckets: Array<{ key: string; doc_count: number }>; }; } -export const fetchAvailableIndices = ( +const getParsedDateMs = (dateStr: string, roundUp = false) => { + const date = dateMath.parse(dateStr, roundUp ? { roundUp: true } : undefined); + if (!date?.isValid()) { + throw new Error(`Invalid date format: ${dateStr}`); + } + return date.valueOf(); +}; + +export const fetchAvailableIndices = async ( esClient: ElasticsearchClient, - params: { indexPattern: string; startDate: string; endDate: string } -) => esClient.search(getRequestBody(params)); + params: { indexNameOrPattern: string; startDate: string; endDate: string } +): Promise => { + const { indexNameOrPattern, startDate, endDate } = params; + + const startDateMs = getParsedDateMs(startDate); + const endDateMs = getParsedDateMs(endDate, true); + + const indicesCats = (await esClient.cat.indices({ + index: indexNameOrPattern, + format: 'json', + h: 'index,creation.date', + })) as FetchAvailableCatIndicesResponseRequired; + + const indicesCatsInRange = indicesCats.filter((indexInfo) => { + const creationDateMs = parseInt(indexInfo['creation.date'], 10); + return creationDateMs >= startDateMs && creationDateMs <= endDateMs; + }); + + const timeSeriesIndicesWithDataInRangeSearchResult = await esClient.search< + AggregateName, + IndexSearchAggregationResponse + >(getRequestBody(params)); + + const timeSeriesIndicesWithDataInRange = + timeSeriesIndicesWithDataInRangeSearchResult.aggregations?.index.buckets.map( + (bucket) => bucket.key + ) || []; + + // Combine indices from both sources removing duplicates + const resultingIndices = new Set(); + + for (const indicesCat of indicesCatsInRange) { + resultingIndices.add(indicesCat.index); + } + + for (const timeSeriesIndex of timeSeriesIndicesWithDataInRange) { + resultingIndices.add(timeSeriesIndex); + } + + return Array.from(resultingIndices); +}; diff --git a/x-pack/plugins/ecs_data_quality_dashboard/server/lib/fetch_stats.ts b/x-pack/plugins/ecs_data_quality_dashboard/server/lib/fetch_stats.ts index 536fd461c61c9..40fc59219342c 100644 --- a/x-pack/plugins/ecs_data_quality_dashboard/server/lib/fetch_stats.ts +++ b/x-pack/plugins/ecs_data_quality_dashboard/server/lib/fetch_stats.ts @@ -57,16 +57,16 @@ export const parseMeteringStats = (meteringStatsIndices: MeteringStatsIndex[]) = }, {}); export const pickAvailableMeteringStats = ( - indicesBuckets: Array<{ key: string }>, + indicesBuckets: string[], meteringStatsIndices: Record ) => - indicesBuckets.reduce((acc: Record, { key }: { key: string }) => { - if (meteringStatsIndices?.[key]) { - acc[key] = { - name: meteringStatsIndices?.[key].name, - num_docs: meteringStatsIndices?.[key].num_docs, + indicesBuckets.reduce((acc: Record, indexName: string) => { + if (meteringStatsIndices?.[indexName]) { + acc[indexName] = { + name: meteringStatsIndices?.[indexName].name, + num_docs: meteringStatsIndices?.[indexName].num_docs, size_in_bytes: null, // We don't have size_in_bytes intentionally when ILM is not available - data_stream: meteringStatsIndices?.[key].data_stream, + data_stream: meteringStatsIndices?.[indexName].data_stream, }; } return acc; diff --git a/x-pack/plugins/ecs_data_quality_dashboard/server/routes/get_ilm_explain.ts b/x-pack/plugins/ecs_data_quality_dashboard/server/routes/get_ilm_explain.ts index 31202adffed2c..ee413ea09ef67 100644 --- a/x-pack/plugins/ecs_data_quality_dashboard/server/routes/get_ilm_explain.ts +++ b/x-pack/plugins/ecs_data_quality_dashboard/server/routes/get_ilm_explain.ts @@ -19,7 +19,11 @@ export const getILMExplainRoute = (router: IRouter, logger: Logger) => { .get({ path: GET_ILM_EXPLAIN, access: 'internal', - options: { tags: ['access:securitySolution'] }, + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, + }, }) .addVersion( { diff --git a/x-pack/plugins/ecs_data_quality_dashboard/server/routes/get_index_mappings.ts b/x-pack/plugins/ecs_data_quality_dashboard/server/routes/get_index_mappings.ts index f3c59ccf9f3e2..845e118bc6f05 100755 --- a/x-pack/plugins/ecs_data_quality_dashboard/server/routes/get_index_mappings.ts +++ b/x-pack/plugins/ecs_data_quality_dashboard/server/routes/get_index_mappings.ts @@ -19,7 +19,11 @@ export const getIndexMappingsRoute = (router: IRouter, logger: Logger) => { .get({ path: GET_INDEX_MAPPINGS, access: 'internal', - options: { tags: ['access:securitySolution'] }, + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, + }, }) .addVersion( { diff --git a/x-pack/plugins/ecs_data_quality_dashboard/server/routes/get_index_stats.test.ts b/x-pack/plugins/ecs_data_quality_dashboard/server/routes/get_index_stats.test.ts index f3ff5ec256ad6..91996a4ab9f89 100644 --- a/x-pack/plugins/ecs_data_quality_dashboard/server/routes/get_index_stats.test.ts +++ b/x-pack/plugins/ecs_data_quality_dashboard/server/routes/get_index_stats.test.ts @@ -152,17 +152,7 @@ describe('getIndexStatsRoute route', () => { }, }; (fetchMeteringStats as jest.Mock).mockResolvedValue(mockMeteringStatsIndex); - (fetchAvailableIndices as jest.Mock).mockResolvedValue({ - aggregations: { - index: { - buckets: [ - { - key: 'my-index-000001', - }, - ], - }, - }, - }); + (fetchAvailableIndices as jest.Mock).mockResolvedValue(['my-index-000001']); const response = await server.inject(request, requestContextMock.convertContext(context)); expect(response.status).toEqual(200); @@ -198,7 +188,7 @@ describe('getIndexStatsRoute route', () => { ); }); - test('returns an empty object when "availableIndices" indices are not available', async () => { + test('returns an empty object when "availableIndices" indices are empty', async () => { const request = requestMock.create({ method: 'get', path: GET_INDEX_STATS, @@ -214,9 +204,7 @@ describe('getIndexStatsRoute route', () => { const mockIndices = {}; (fetchMeteringStats as jest.Mock).mockResolvedValue(mockMeteringStatsIndex); - (fetchAvailableIndices as jest.Mock).mockResolvedValue({ - aggregations: undefined, - }); + (fetchAvailableIndices as jest.Mock).mockResolvedValue([]); const response = await server.inject(request, requestContextMock.convertContext(context)); expect(response.status).toEqual(200); diff --git a/x-pack/plugins/ecs_data_quality_dashboard/server/routes/get_index_stats.ts b/x-pack/plugins/ecs_data_quality_dashboard/server/routes/get_index_stats.ts index 665c178c62cdf..fd1ec1694719d 100644 --- a/x-pack/plugins/ecs_data_quality_dashboard/server/routes/get_index_stats.ts +++ b/x-pack/plugins/ecs_data_quality_dashboard/server/routes/get_index_stats.ts @@ -26,7 +26,11 @@ export const getIndexStatsRoute = (router: IRouter, logger: Logger) => { .get({ path: GET_INDEX_STATS, access: 'internal', - options: { tags: ['access:securitySolution'] }, + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, + }, }) .addVersion( { @@ -81,12 +85,12 @@ export const getIndexStatsRoute = (router: IRouter, logger: Logger) => { const meteringStatsIndices = parseMeteringStats(meteringStats.indices); const availableIndices = await fetchAvailableIndices(esClient, { - indexPattern: decodedIndexName, + indexNameOrPattern: decodedIndexName, startDate: decodedStartDate, endDate: decodedEndDate, }); - if (!availableIndices.aggregations?.index?.buckets) { + if (availableIndices.length === 0) { logger.warn( `No available indices found under pattern: ${decodedIndexName}, in the given date range: ${decodedStartDate} - ${decodedEndDate}` ); @@ -95,10 +99,7 @@ export const getIndexStatsRoute = (router: IRouter, logger: Logger) => { }); } - const indices = pickAvailableMeteringStats( - availableIndices.aggregations.index.buckets, - meteringStatsIndices - ); + const indices = pickAvailableMeteringStats(availableIndices, meteringStatsIndices); return response.ok({ body: indices, diff --git a/x-pack/plugins/ecs_data_quality_dashboard/server/routes/get_unallowed_field_values.ts b/x-pack/plugins/ecs_data_quality_dashboard/server/routes/get_unallowed_field_values.ts index 76f0827caaad2..9fb743d207d08 100644 --- a/x-pack/plugins/ecs_data_quality_dashboard/server/routes/get_unallowed_field_values.ts +++ b/x-pack/plugins/ecs_data_quality_dashboard/server/routes/get_unallowed_field_values.ts @@ -19,6 +19,11 @@ export const getUnallowedFieldValuesRoute = (router: IRouter, logger: Logger) => .post({ path: GET_UNALLOWED_FIELD_VALUES, access: 'internal', + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, + }, }) .addVersion( { diff --git a/x-pack/plugins/ecs_data_quality_dashboard/server/routes/results/get_index_results.ts b/x-pack/plugins/ecs_data_quality_dashboard/server/routes/results/get_index_results.ts index bbab7dede3c21..71f2422146f9c 100644 --- a/x-pack/plugins/ecs_data_quality_dashboard/server/routes/results/get_index_results.ts +++ b/x-pack/plugins/ecs_data_quality_dashboard/server/routes/results/get_index_results.ts @@ -87,7 +87,11 @@ export const getIndexResultsRoute = ( .get({ path: GET_INDEX_RESULTS, access: 'internal', - options: { tags: ['access:securitySolution'] }, + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, + }, }) .addVersion( { diff --git a/x-pack/plugins/ecs_data_quality_dashboard/server/routes/results/get_index_results_latest.test.ts b/x-pack/plugins/ecs_data_quality_dashboard/server/routes/results/get_index_results_latest.test.ts index bfb38864916fe..94c892e401b5a 100644 --- a/x-pack/plugins/ecs_data_quality_dashboard/server/routes/results/get_index_results_latest.test.ts +++ b/x-pack/plugins/ecs_data_quality_dashboard/server/routes/results/get_index_results_latest.test.ts @@ -16,6 +16,24 @@ import { resultDocument } from './results.mock'; import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; import type { ResultDocument } from '../../schemas/result'; import type { CheckIndicesPrivilegesParam } from './privileges'; +import { getRangeFilteredIndices } from '../../helpers/get_range_filtered_indices'; + +const mockCheckIndicesPrivileges = jest.fn(({ indices }: CheckIndicesPrivilegesParam) => + Promise.resolve(Object.fromEntries(indices.map((index) => [index, true]))) +); +jest.mock('./privileges', () => ({ + checkIndicesPrivileges: (params: CheckIndicesPrivilegesParam) => + mockCheckIndicesPrivileges(params), +})); + +jest.mock('../../helpers/get_range_filtered_indices', () => ({ + getRangeFilteredIndices: jest.fn(), +})); + +const mockGetRangeFilteredIndices = getRangeFilteredIndices as jest.Mock; + +const startDate = 'now-7d'; +const endDate = 'now'; const searchResponse = { aggregations: { @@ -33,14 +51,6 @@ const searchResponse = { Record >; -const mockCheckIndicesPrivileges = jest.fn(({ indices }: CheckIndicesPrivilegesParam) => - Promise.resolve(Object.fromEntries(indices.map((index) => [index, true]))) -); -jest.mock('./privileges', () => ({ - checkIndicesPrivileges: (params: CheckIndicesPrivilegesParam) => - mockCheckIndicesPrivileges(params), -})); - describe('getIndexResultsLatestRoute route', () => { describe('querying', () => { let server: ReturnType; @@ -68,7 +78,7 @@ describe('getIndexResultsLatestRoute route', () => { getIndexResultsLatestRoute(server.router, logger); }); - it('gets result', async () => { + it('gets result without startDate and endDate', async () => { const mockSearch = context.core.elasticsearch.client.asInternalUser.search; mockSearch.mockResolvedValueOnce(searchResponse); @@ -80,6 +90,159 @@ describe('getIndexResultsLatestRoute route', () => { expect(response.status).toEqual(200); expect(response.body).toEqual([resultDocument]); + + expect(mockGetRangeFilteredIndices).not.toHaveBeenCalled(); + }); + + it('gets result with startDate and endDate', async () => { + const reqWithDate = requestMock.create({ + method: 'get', + path: GET_INDEX_RESULTS_LATEST, + params: { pattern: 'logs-*' }, + query: { startDate, endDate }, + }); + + const filteredIndices = ['filtered-index-1', 'filtered-index-2']; + mockGetRangeFilteredIndices.mockResolvedValueOnce(filteredIndices); + const mockSearch = context.core.elasticsearch.client.asInternalUser.search; + mockSearch.mockResolvedValueOnce(searchResponse); + + const response = await server.inject(reqWithDate, requestContextMock.convertContext(context)); + + expect(mockGetRangeFilteredIndices).toHaveBeenCalledWith({ + client: context.core.elasticsearch.client, + authorizedIndexNames: [resultDocument.indexName], + startDate, + endDate, + logger, + pattern: 'logs-*', + }); + + expect(mockSearch).toHaveBeenCalledWith({ + index: expect.any(String), + ...getQuery(filteredIndices), + }); + + expect(response.status).toEqual(200); + expect(response.body).toEqual([resultDocument]); + }); + + it('handles getRangeFilteredIndices error', async () => { + const errorMessage = 'Range Filter Error'; + + const reqWithDate = requestMock.create({ + method: 'get', + path: GET_INDEX_RESULTS_LATEST, + params: { pattern: 'logs-*' }, + query: { startDate, endDate }, + }); + + mockGetRangeFilteredIndices.mockRejectedValueOnce(new Error(errorMessage)); + + const response = await server.inject(reqWithDate, requestContextMock.convertContext(context)); + + expect(mockGetRangeFilteredIndices).toHaveBeenCalledWith({ + client: context.core.elasticsearch.client, + authorizedIndexNames: [resultDocument.indexName], + startDate, + endDate, + logger, + pattern: 'logs-*', + }); + + expect(response.status).toEqual(500); + expect(response.body).toEqual({ message: errorMessage, status_code: 500 }); + expect(logger.error).toHaveBeenCalledWith(errorMessage); + }); + + it('gets result with startDate and endDate and multiple filtered indices', async () => { + const filteredIndices = ['filtered-index-1', 'filtered-index-2', 'filtered-index-3']; + const filteredIndicesSearchResponse = { + aggregations: { + latest: { + buckets: filteredIndices.map((indexName) => ({ + key: indexName, + latest_doc: { hits: { hits: [{ _source: { indexName } }] } }, + })), + }, + }, + } as unknown as SearchResponse< + ResultDocument, + Record + >; + + const reqWithDate = requestMock.create({ + method: 'get', + path: GET_INDEX_RESULTS_LATEST, + params: { pattern: 'logs-*' }, + query: { startDate, endDate }, + }); + + mockGetRangeFilteredIndices.mockResolvedValueOnce(filteredIndices); + context.core.elasticsearch.client.asInternalUser.search.mockResolvedValueOnce( + filteredIndicesSearchResponse + ); + + const response = await server.inject(reqWithDate, requestContextMock.convertContext(context)); + + expect(mockGetRangeFilteredIndices).toHaveBeenCalledWith({ + client: context.core.elasticsearch.client, + authorizedIndexNames: [resultDocument.indexName], + startDate, + endDate, + logger, + pattern: 'logs-*', + }); + + expect(context.core.elasticsearch.client.asInternalUser.search).toHaveBeenCalledWith({ + index: expect.any(String), + ...getQuery(filteredIndices), + }); + + const expectedResults = filteredIndices.map((indexName) => ({ + indexName, + })) as ResultDocument[]; + expect(response.status).toEqual(200); + expect(response.body).toEqual(expectedResults); + }); + + it('handles partial authorization when using startDate and endDate', async () => { + const authorizationResult = { + 'filtered-index-1': true, + 'filtered-index-2': false, + }; + + mockGetRangeFilteredIndices.mockResolvedValueOnce(['filtered-index-1']); + mockCheckIndicesPrivileges.mockResolvedValueOnce(authorizationResult); + + const mockSearch = context.core.elasticsearch.client.asInternalUser.search; + mockSearch.mockResolvedValueOnce(searchResponse); + + const reqWithDate = requestMock.create({ + method: 'get', + path: GET_INDEX_RESULTS_LATEST, + params: { pattern: 'logs-*' }, + query: { startDate, endDate }, + }); + + const response = await server.inject(reqWithDate, requestContextMock.convertContext(context)); + + expect(mockGetRangeFilteredIndices).toHaveBeenCalledWith({ + client: context.core.elasticsearch.client, + authorizedIndexNames: ['filtered-index-1'], + startDate, + endDate, + logger, + pattern: 'logs-*', + }); + + expect(context.core.elasticsearch.client.asInternalUser.search).toHaveBeenCalledWith({ + index: expect.any(String), + ...getQuery(['filtered-index-1']), + }); + + expect(response.status).toEqual(200); + expect(response.body).toEqual([resultDocument]); }); it('handles results data stream error', async () => { diff --git a/x-pack/plugins/ecs_data_quality_dashboard/server/routes/results/get_index_results_latest.ts b/x-pack/plugins/ecs_data_quality_dashboard/server/routes/results/get_index_results_latest.ts index 55ff8e01a01dc..f7d1d5eed74cc 100644 --- a/x-pack/plugins/ecs_data_quality_dashboard/server/routes/results/get_index_results_latest.ts +++ b/x-pack/plugins/ecs_data_quality_dashboard/server/routes/results/get_index_results_latest.ts @@ -4,18 +4,18 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import type { IRouter, Logger } from '@kbn/core/server'; import { INTERNAL_API_VERSION, GET_INDEX_RESULTS_LATEST } from '../../../common/constants'; import { buildResponse } from '../../lib/build_response'; import { buildRouteValidation } from '../../schemas/common'; -import { GetIndexResultsLatestParams } from '../../schemas/result'; +import { GetIndexResultsLatestParams, GetIndexResultsLatestQuery } from '../../schemas/result'; import type { ResultDocument } from '../../schemas/result'; import { API_DEFAULT_ERROR_MESSAGE } from '../../translations'; import type { DataQualityDashboardRequestHandlerContext } from '../../types'; import { API_RESULTS_INDEX_NOT_AVAILABLE } from './translations'; import { getAuthorizedIndexNames } from '../../helpers/get_authorized_index_names'; +import { getRangeFilteredIndices } from '../../helpers/get_range_filtered_indices'; export const getQuery = (indexName: string[]) => ({ size: 0, @@ -41,7 +41,11 @@ export const getIndexResultsLatestRoute = ( .get({ path: GET_INDEX_RESULTS_LATEST, access: 'internal', - options: { tags: ['access:securitySolution'] }, + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, + }, }) .addVersion( { @@ -49,6 +53,7 @@ export const getIndexResultsLatestRoute = ( validate: { request: { params: buildRouteValidation(GetIndexResultsLatestParams), + query: buildRouteValidation(GetIndexResultsLatestQuery), }, }, }, @@ -77,8 +82,27 @@ export const getIndexResultsLatestRoute = ( return response.ok({ body: [] }); } + const { startDate, endDate } = request.query; + + let resultingIndices: string[] = []; + + if (startDate && endDate) { + resultingIndices = resultingIndices.concat( + await getRangeFilteredIndices({ + client, + authorizedIndexNames, + startDate, + endDate, + logger, + pattern, + }) + ); + } else { + resultingIndices = authorizedIndexNames; + } + // Get the latest result for each indexName - const query = { index, ...getQuery(authorizedIndexNames) }; + const query = { index, ...getQuery(resultingIndices) }; const { aggregations } = await client.asInternalUser.search< ResultDocument, Record diff --git a/x-pack/plugins/ecs_data_quality_dashboard/server/routes/results/post_index_results.ts b/x-pack/plugins/ecs_data_quality_dashboard/server/routes/results/post_index_results.ts index 5e87cadb4dadf..0c627d4cc0364 100644 --- a/x-pack/plugins/ecs_data_quality_dashboard/server/routes/results/post_index_results.ts +++ b/x-pack/plugins/ecs_data_quality_dashboard/server/routes/results/post_index_results.ts @@ -24,7 +24,11 @@ export const postIndexResultsRoute = ( .post({ path: POST_INDEX_RESULTS, access: 'internal', - options: { tags: ['access:securitySolution'] }, + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, + }, }) .addVersion( { diff --git a/x-pack/plugins/ecs_data_quality_dashboard/server/schemas/result.ts b/x-pack/plugins/ecs_data_quality_dashboard/server/schemas/result.ts index 8ccb3fbc3f984..fb264fe10da8f 100644 --- a/x-pack/plugins/ecs_data_quality_dashboard/server/schemas/result.ts +++ b/x-pack/plugins/ecs_data_quality_dashboard/server/schemas/result.ts @@ -69,6 +69,11 @@ export const PostIndexResultBody = ResultDocument; export const GetIndexResultsLatestParams = t.type({ pattern: t.string }); export type GetIndexResultsLatestParams = t.TypeOf; +export const GetIndexResultsLatestQuery = t.partial({ + startDate: t.string, + endDate: t.string, +}); + export const GetIndexResultsParams = t.type({ pattern: t.string, }); diff --git a/x-pack/plugins/ecs_data_quality_dashboard/tsconfig.json b/x-pack/plugins/ecs_data_quality_dashboard/tsconfig.json index ceb43169165b4..cf31d7461b509 100644 --- a/x-pack/plugins/ecs_data_quality_dashboard/tsconfig.json +++ b/x-pack/plugins/ecs_data_quality_dashboard/tsconfig.json @@ -26,6 +26,7 @@ "@kbn/core-elasticsearch-server-mocks", "@kbn/core-elasticsearch-server", "@kbn/core-security-common", + "@kbn/datemath", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/elastic_assistant/kibana.jsonc b/x-pack/plugins/elastic_assistant/kibana.jsonc index 8a3e0725c782a..435ec0b916d01 100644 --- a/x-pack/plugins/elastic_assistant/kibana.jsonc +++ b/x-pack/plugins/elastic_assistant/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/elastic-assistant-plugin", - "owner": "@elastic/security-generative-ai", + "owner": [ + "@elastic/security-generative-ai" + ], + "group": "security", + "visibility": "private", "description": "Server APIs for the Elastic AI Assistant", "plugin": { "id": "elasticAssistant", - "server": true, "browser": false, + "server": true, "requiredPlugins": [ "actions", "data", @@ -18,4 +22,4 @@ "security" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/msearch_query.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/msearch_query.ts index e411dfaa2f1ef..ae5adcfab61aa 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/msearch_query.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/msearch_query.ts @@ -34,12 +34,10 @@ export const mSearchQueryBody: MsearchQueryBody = { ], must: [ { - text_expansion: { - 'vector.tokens': { - model_id: '.elser_model_2', - model_text: - 'Generate an ESQL query that will count the number of connections made to external IP addresses, broken down by user. If the count is greater than 100 for a specific user, add a new field called "follow_up" that contains a value of "true", otherwise, it should contain "false". The user names should also be enriched with their respective group names.', - }, + semantic: { + field: 'semantic_text', + query: + 'Generate an ESQL query that will count the number of connections made to external IP addresses, broken down by user. If the count is greater than 100 for a specific user, add a new field called "follow_up" that contains a value of "true", otherwise, it should contain "false". The user names should also be enriched with their respective group names.', }, }, ], diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/request.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/request.ts index 9dc57bab25ef3..f6f3007c8f948 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/request.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/request.ts @@ -23,6 +23,7 @@ import { ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_BY_ID_MESSAGES, ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_FIND, ELASTIC_AI_ASSISTANT_EVALUATE_URL, + ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_INDICES_URL, ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_URL, ELASTIC_AI_ASSISTANT_PROMPTS_URL_BULK_ACTION, ELASTIC_AI_ASSISTANT_PROMPTS_URL_FIND, @@ -46,6 +47,12 @@ export const requestMock = { create: httpServerMock.createKibanaRequest, }; +export const getGetKnowledgeBaseIndicesRequest = () => + requestMock.create({ + method: 'get', + path: ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_INDICES_URL, + }); + export const getGetKnowledgeBaseStatusRequest = (resource?: string) => requestMock.create({ method: 'get', diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/vector_search_query.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/vector_search_query.ts index 30fbd0ad2c58f..04263c5d242bb 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/vector_search_query.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/vector_search_query.ts @@ -26,12 +26,10 @@ export const mockVectorSearchQuery: QueryDslQueryContainer = { ], must: [ { - text_expansion: { - 'vector.tokens': { - model_id: '.elser_model_2', - model_text: - 'Generate an ES|QL query that will count the number of connections made to external IP addresses, broken down by user. If the count is greater than 100 for a specific user, add a new field called "follow_up" that contains a value of "true", otherwise, it should contain "false". The user names should also be enriched with their respective group names.', - }, + semantic: { + field: 'semantic_text', + query: + 'Generate an ES|QL query that will count the number of connections made to external IP addresses, broken down by user. If the count is greater than 100 for a specific user, add a new field called "follow_up" that contains a value of "true", otherwise, it should contain "false". The user names should also be enriched with their respective group names.', }, }, ], diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/anonymization_fields/helpers.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/anonymization_fields/helpers.ts index 9a4a3b6e1c0ce..0f577df4e56e1 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/anonymization_fields/helpers.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/anonymization_fields/helpers.ts @@ -99,7 +99,8 @@ export const getUpdateScript = ({ isPatch?: boolean; }) => { return { - source: ` + script: { + source: ` if (params.assignEmpty == true || params.containsKey('allowed')) { ctx._source.allowed = params.allowed; } @@ -108,11 +109,12 @@ export const getUpdateScript = ({ } ctx._source.updated_at = params.updated_at; `, - lang: 'painless', - params: { - ...anonymizationField, // when assigning undefined in painless, it will remove property and wil set it to null - // for patch we don't want to remove unspecified value in payload - assignEmpty: !(isPatch ?? true), + lang: 'painless', + params: { + ...anonymizationField, // when assigning undefined in painless, it will remove property and wil set it to null + // for patch we don't want to remove unspecified value in payload + assignEmpty: !(isPatch ?? true), + }, }, }; }; diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/helpers.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/helpers.ts index 9e52b4a7414a6..bdd1107942cc1 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/helpers.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/helpers.ts @@ -15,7 +15,8 @@ export const getUpdateScript = ({ isPatch?: boolean; }) => { return { - source: ` + script: { + source: ` if (params.assignEmpty == true || params.containsKey('api_config')) { if (ctx._source.api_config != null) { if (params.assignEmpty == true || params.api_config.containsKey('connector_id')) { @@ -70,11 +71,12 @@ export const getUpdateScript = ({ } ctx._source.updated_at = params.updated_at; `, - lang: 'painless', - params: { - ...conversation, // when assigning undefined in painless, it will remove property and wil set it to null - // for patch we don't want to remove unspecified value in payload - assignEmpty: !(isPatch ?? true), + lang: 'painless', + params: { + ...conversation, // when assigning undefined in painless, it will remove property and wil set it to null + // for patch we don't want to remove unspecified value in payload + assignEmpty: !(isPatch ?? true), + }, }, }; }; diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/update_conversation.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/update_conversation.ts index 807fea2decd99..7e9ee336f6fe1 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/update_conversation.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/update_conversation.ts @@ -76,7 +76,7 @@ export const updateConversation = async ({ }, }, refresh: true, - script: getUpdateScript({ conversation: params, isPatch }), + script: getUpdateScript({ conversation: params, isPatch }).script, }); if (response.failures && response.failures.length > 0) { diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/create_knowledge_base_entry.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/create_knowledge_base_entry.ts index 23f73501b1056..09bb5b291ef9a 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/create_knowledge_base_entry.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/create_knowledge_base_entry.ts @@ -139,55 +139,11 @@ export const getUpdateScript = ({ entry: UpdateKnowledgeBaseEntrySchema; isPatch?: boolean; }) => { + // Cannot use script for updating documents with semantic_text fields return { - source: ` - if (params.assignEmpty == true || params.containsKey('name')) { - ctx._source.name = params.name; - } - if (params.assignEmpty == true || params.containsKey('type')) { - ctx._source.type = params.type; - } - if (params.assignEmpty == true || params.containsKey('users')) { - ctx._source.users = params.users; - } - if (params.assignEmpty == true || params.containsKey('query_description')) { - ctx._source.query_description = params.query_description; - } - if (params.assignEmpty == true || params.containsKey('input_schema')) { - ctx._source.input_schema = params.input_schema; - } - if (params.assignEmpty == true || params.containsKey('output_fields')) { - ctx._source.output_fields = params.output_fields; - } - if (params.assignEmpty == true || params.containsKey('kb_resource')) { - ctx._source.kb_resource = params.kb_resource; - } - if (params.assignEmpty == true || params.containsKey('required')) { - ctx._source.required = params.required; - } - if (params.assignEmpty == true || params.containsKey('source')) { - ctx._source.source = params.source; - } - if (params.assignEmpty == true || params.containsKey('text')) { - ctx._source.text = params.text; - } - if (params.assignEmpty == true || params.containsKey('description')) { - ctx._source.description = params.description; - } - if (params.assignEmpty == true || params.containsKey('field')) { - ctx._source.field = params.field; - } - if (params.assignEmpty == true || params.containsKey('index')) { - ctx._source.index = params.index; - } - ctx._source.updated_at = params.updated_at; - ctx._source.updated_by = params.updated_by; - `, - lang: 'painless', - params: { - ...entry, // when assigning undefined in painless, it will remove property and wil set it to null - // for patch we don't want to remove unspecified value in payload - assignEmpty: !(isPatch ?? true), + doc: { + ...entry, + semantic_text: entry.text, }, }; }; @@ -247,7 +203,7 @@ export const transformToCreateSchema = ({ required: entry.required ?? false, source: entry.source, text: entry.text, - vector: undefined, + semantic_text: entry.text, }; }; diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/field_maps_configuration.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/field_maps_configuration.ts index 0712664bbfeed..348efb5a18f7d 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/field_maps_configuration.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/field_maps_configuration.ts @@ -6,6 +6,8 @@ */ import { FieldMap } from '@kbn/data-stream-adapter'; +export const ASSISTANT_ELSER_INFERENCE_ID = 'elastic-security-ai-assistant-elser2'; + export const knowledgeBaseFieldMap: FieldMap = { '@timestamp': { type: 'date', @@ -169,6 +171,12 @@ export const knowledgeBaseFieldMapV2: FieldMap = { required: false, }, // Embeddings field + semantic_text: { + type: 'semantic_text', + array: false, + required: false, + inference_id: ASSISTANT_ELSER_INFERENCE_ID, + }, vector: { type: 'object', array: false, diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/helpers.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/helpers.ts index 59816b0b0c264..a19b3f0945086 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/helpers.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/helpers.ts @@ -46,7 +46,7 @@ export const getKBVectorSearchQuery = ({ filter?: QueryDslQueryContainer | undefined; kbResource?: string | undefined; modelId: string; - query: string; + query?: string; required?: boolean | undefined; user: AuthenticatedUser; v2KnowledgeBaseEnabled: boolean; @@ -114,20 +114,37 @@ export const getKBVectorSearchQuery = ({ ], }; - return { - bool: { - must: [ - { - text_expansion: { - 'vector.tokens': { - model_id: modelId, - model_text: query, - }, + let semanticTextFilter: + | Array<{ semantic: { field: string; query: string } }> + | Array<{ + text_expansion: { 'vector.tokens': { model_id: string; model_text: string } }; + }> = []; + + if (v2KnowledgeBaseEnabled && query) { + semanticTextFilter = [ + { + semantic: { + field: 'semantic_text', + query, + }, + }, + ]; + } else if (!v2KnowledgeBaseEnabled) { + semanticTextFilter = [ + { + text_expansion: { + 'vector.tokens': { + model_id: modelId, + model_text: query as string, }, }, - ...requiredFilter, - ...resourceFilter, - ], + }, + ]; + } + + return { + bool: { + must: [...semanticTextFilter, ...requiredFilter, ...resourceFilter], ...userFilter, filter, minimum_should_match: 1, diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/index.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/index.ts index 64e7b00089c08..333fbb796ddd9 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/index.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/index.ts @@ -8,6 +8,7 @@ import { MlTrainedModelDeploymentNodesStats, MlTrainedModelStats, + SearchTotalHits, } from '@elastic/elasticsearch/lib/api/types'; import type { MlPluginSetup } from '@kbn/ml-plugin/server'; import type { KibanaRequest } from '@kbn/core-http-server'; @@ -25,6 +26,8 @@ import pRetry from 'p-retry'; import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { StructuredTool } from '@langchain/core/tools'; import { ElasticsearchClient } from '@kbn/core/server'; +import { IndexPatternsFetcher } from '@kbn/data-views-plugin/server'; +import { map } from 'lodash'; import { AIAssistantDataClient, AIAssistantDataClientParams } from '..'; import { AssistantToolParams, GetElser } from '../../types'; import { @@ -38,6 +41,7 @@ import { transformESSearchToKnowledgeBaseEntry } from './transforms'; import { ESQL_DOCS_LOADED_QUERY, SECURITY_LABS_RESOURCE, + USER_RESOURCE, } from '../../routes/knowledge_base/constants'; import { getKBVectorSearchQuery, @@ -45,7 +49,11 @@ import { isModelAlreadyExistsError, } from './helpers'; import { getKBUserFilter } from '../../routes/knowledge_base/entries/utils'; -import { loadSecurityLabs } from '../../lib/langchain/content_loaders/security_labs_loader'; +import { + loadSecurityLabs, + getSecurityLabsDocsCount, +} from '../../lib/langchain/content_loaders/security_labs_loader'; +import { ASSISTANT_ELSER_INFERENCE_ID } from './field_maps_configuration'; /** * Params for when creating KbDataClient in Request Context Factory. Useful if needing to modify @@ -169,30 +177,83 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient { this.options.logger.debug(`Checking if ELSER model '${elserId}' is deployed...`); try { - const esClient = await this.options.elasticsearchClientPromise; - const getResponse = await esClient.ml.getTrainedModelsStats({ - model_id: elserId, - }); + if (this.isV2KnowledgeBaseEnabled) { + return await this.isInferenceEndpointExists(); + } else { + const esClient = await this.options.elasticsearchClientPromise; + const getResponse = await esClient.ml.getTrainedModelsStats({ + model_id: elserId, + }); - // For standardized way of checking deployment status see: https://github.com/elastic/elasticsearch/issues/106986 - const isReadyESS = (stats: MlTrainedModelStats) => - stats.deployment_stats?.state === 'started' && - stats.deployment_stats?.allocation_status.state === 'fully_allocated'; + // For standardized way of checking deployment status see: https://github.com/elastic/elasticsearch/issues/106986 + const isReadyESS = (stats: MlTrainedModelStats) => + stats.deployment_stats?.state === 'started' && + stats.deployment_stats?.allocation_status.state === 'fully_allocated'; - const isReadyServerless = (stats: MlTrainedModelStats) => - (stats.deployment_stats?.nodes as unknown as MlTrainedModelDeploymentNodesStats[]).some( - (node) => node.routing_state.routing_state === 'started' - ); + const isReadyServerless = (stats: MlTrainedModelStats) => + (stats.deployment_stats?.nodes as unknown as MlTrainedModelDeploymentNodesStats[])?.some( + (node) => node.routing_state.routing_state === 'started' + ); - return getResponse.trained_model_stats.some( - (stats) => isReadyESS(stats) || isReadyServerless(stats) - ); + return getResponse.trained_model_stats?.some( + (stats) => isReadyESS(stats) || isReadyServerless(stats) + ); + } } catch (e) { + this.options.logger.debug(`Error checking if ELSER model '${elserId}' is deployed: ${e}`); // Returns 404 if it doesn't exist return false; } }; + public isInferenceEndpointExists = async (): Promise => { + try { + const esClient = await this.options.elasticsearchClientPromise; + + return !!(await esClient.inference.get({ + inference_id: ASSISTANT_ELSER_INFERENCE_ID, + task_type: 'sparse_embedding', + })); + } catch (error) { + this.options.logger.debug( + `Error checking if Inference endpoint ${ASSISTANT_ELSER_INFERENCE_ID} exists: ${error}` + ); + return false; + } + }; + + public createInferenceEndpoint = async () => { + const elserId = await this.options.getElserId(); + this.options.logger.debug(`Deploying ELSER model '${elserId}'...`); + try { + const esClient = await this.options.elasticsearchClientPromise; + if (this.isV2KnowledgeBaseEnabled) { + await esClient.inference.put({ + task_type: 'sparse_embedding', + inference_id: ASSISTANT_ELSER_INFERENCE_ID, + inference_config: { + service: 'elasticsearch', + service_settings: { + adaptive_allocations: { + enabled: true, + min_number_of_allocations: 0, + max_number_of_allocations: 8, + }, + num_threads: 1, + model_id: elserId, + }, + task_settings: {}, + }, + }); + } + } catch (error) { + this.options.logger.error( + `Error creating inference endpoint for ELSER model '${elserId}':\n${error}` + ); + throw new Error(`Error creating inference endpoint for ELSER model '${elserId}':\n${error}`); + } + }; + /** * Downloads and deploys recommended ELSER (if not already), then loads ES|QL docs * @@ -208,9 +269,11 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient { public setupKnowledgeBase = async ({ soClient, v2KnowledgeBaseEnabled = true, + ignoreSecurityLabs = false, }: { soClient: SavedObjectsClientContract; v2KnowledgeBaseEnabled?: boolean; + ignoreSecurityLabs?: boolean; }): Promise => { if (this.options.getIsKBSetupInProgress()) { this.options.logger.debug('Knowledge Base setup already in progress'); @@ -238,8 +301,22 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient { `Removed ${legacyESQL?.total} ESQL knowledge base docs from knowledge base data stream: ${this.indexTemplateAndPattern.alias}.` ); } + // Delete any existing Security Labs content + const securityLabsDocs = await esClient.deleteByQuery({ + index: this.indexTemplateAndPattern.alias, + query: { + bool: { + must: [{ terms: { kb_resource: [SECURITY_LABS_RESOURCE] } }], + }, + }, + }); + if (securityLabsDocs?.total) { + this.options.logger.info( + `Removed ${securityLabsDocs?.total} Security Labs knowledge base docs from knowledge base data stream: ${this.indexTemplateAndPattern.alias}.` + ); + } } catch (e) { - this.options.logger.info('No legacy ESQL knowledge base docs to delete'); + this.options.logger.info('No legacy ESQL or Security Labs knowledge base docs to delete'); } } @@ -259,24 +336,39 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient { this.options.logger.debug(`ELSER model '${elserId}' is already installed`); } - const isDeployed = await this.isModelDeployed(); - if (!isDeployed) { - await this.deployModel(); - await pRetry( - async () => - (await this.isModelDeployed()) - ? Promise.resolve() - : Promise.reject(new Error('Model not deployed')), - { minTimeout: 2000, retries: 10 } - ); - this.options.logger.debug(`ELSER model '${elserId}' successfully deployed!`); + if (!this.isV2KnowledgeBaseEnabled) { + const isDeployed = await this.isModelDeployed(); + if (!isDeployed) { + await this.deployModel(); + await pRetry( + async () => + (await this.isModelDeployed()) + ? Promise.resolve() + : Promise.reject(new Error('Model not deployed')), + { minTimeout: 2000, retries: 10 } + ); + this.options.logger.debug(`ELSER model '${elserId}' successfully deployed!`); + } else { + this.options.logger.debug(`ELSER model '${elserId}' is already deployed`); + } } else { - this.options.logger.debug(`ELSER model '${elserId}' is already deployed`); + const inferenceExists = await this.isInferenceEndpointExists(); + if (!inferenceExists) { + await this.createInferenceEndpoint(); + + this.options.logger.debug( + `Inference endpoint for ELSER model '${elserId}' successfully deployed!` + ); + } else { + this.options.logger.debug( + `Inference endpoint for ELSER model '${elserId}' is already deployed` + ); + } } this.options.logger.debug(`Checking if Knowledge Base docs have been loaded...`); - if (v2KnowledgeBaseEnabled) { + if (v2KnowledgeBaseEnabled && !ignoreSecurityLabs) { const labsDocsLoaded = await this.isSecurityLabsDocsLoaded(); if (!labsDocsLoaded) { this.options.logger.debug(`Loading Security Labs KB docs...`); @@ -289,8 +381,9 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient { this.options.setIsKBSetupInProgress(false); this.options.logger.error(`Error setting up Knowledge Base: ${e.message}`); throw new Error(`Error setting up Knowledge Base: ${e.message}`); + } finally { + this.options.setIsKBSetupInProgress(false); } - this.options.setIsKBSetupInProgress(false); }; /** @@ -385,15 +478,87 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient { }; /** - * Returns if Security Labs KB docs have been loaded + * Returns if user's KB docs exists + */ + + public isUserDataExists = async (): Promise => { + const user = this.options.currentUser; + if (user == null) { + throw new Error( + 'Authenticated user not found! Ensure kbDataClient was initialized from a request.' + ); + } + + const esClient = await this.options.elasticsearchClientPromise; + const modelId = await this.options.getElserId(); + + try { + const vectorSearchQuery = getKBVectorSearchQuery({ + kbResource: USER_RESOURCE, + required: false, + user, + modelId, + v2KnowledgeBaseEnabled: this.options.v2KnowledgeBaseEnabled, + }); + + const result = await esClient.search({ + index: this.indexTemplateAndPattern.alias, + size: 0, + query: vectorSearchQuery, + track_total_hits: true, + }); + + return !!(result.hits?.total as SearchTotalHits).value; + } catch (e) { + this.options.logger.debug(`Error checking if user's KB docs exist: ${e.message}`); + return false; + } + }; + + /** + * Returns if allSecurity Labs KB docs have been loaded */ public isSecurityLabsDocsLoaded = async (): Promise => { - const securityLabsDocs = await this.getKnowledgeBaseDocumentEntries({ - query: '', - kbResource: SECURITY_LABS_RESOURCE, - required: false, - }); - return securityLabsDocs.length > 0; + const user = this.options.currentUser; + if (user == null) { + throw new Error( + 'Authenticated user not found! Ensure kbDataClient was initialized from a request.' + ); + } + + const expectedDocsCount = await getSecurityLabsDocsCount({ logger: this.options.logger }); + + const esClient = await this.options.elasticsearchClientPromise; + const modelId = await this.options.getElserId(); + + try { + const vectorSearchQuery = getKBVectorSearchQuery({ + kbResource: SECURITY_LABS_RESOURCE, + required: false, + user, + modelId, + v2KnowledgeBaseEnabled: this.options.v2KnowledgeBaseEnabled, + }); + + const result = await esClient.search({ + index: this.indexTemplateAndPattern.alias, + size: 0, + query: vectorSearchQuery, + track_total_hits: true, + }); + + const existingDocs = (result.hits?.total as SearchTotalHits).value; + + if (existingDocs !== expectedDocsCount) { + this.options.logger.debug( + `Security Labs docs are not loaded, existing docs: ${existingDocs}, expected docs: ${expectedDocsCount}` + ); + } + return existingDocs === expectedDocsCount; + } catch (e) { + this.options.logger.info(`Error checking if Security Labs docs are loaded: ${e.message}`); + return false; + } }; /** @@ -423,10 +588,10 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient { const vectorSearchQuery = getKBVectorSearchQuery({ filter, kbResource, - modelId, query, required, user, + modelId, v2KnowledgeBaseEnabled: this.options.v2KnowledgeBaseEnabled, }); @@ -576,7 +741,9 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient { } try { - const elserId = await this.options.getElserId(); + const elserId = this.isV2KnowledgeBaseEnabled + ? ASSISTANT_ELSER_INFERENCE_ID + : await this.options.getElserId(); const userFilter = getKBUserFilter(user); const results = await this.findDocuments({ // Note: This is a magic number to set some upward bound as to not blow the context with too @@ -595,14 +762,21 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient { if (results) { const entries = transformESSearchToKnowledgeBaseEntry(results.data) as IndexEntry[]; - return entries.map((indexEntry) => { - return getStructuredToolForIndexEntry({ - indexEntry, - esClient, - logger: this.options.logger, - elserId, - }); - }); + const indexPatternFetcher = new IndexPatternsFetcher(esClient); + const existingIndices = await indexPatternFetcher.getExistingIndices(map(entries, 'index')); + return ( + entries + // Filter out any IndexEntries that don't have an existing index + .filter((entry) => existingIndices.includes(entry.index)) + .map((indexEntry) => { + return getStructuredToolForIndexEntry({ + indexEntry, + esClient, + logger: this.options.logger, + elserId, + }); + }) + ); } } catch (e) { this.options.logger.error(`kbDataClient.getAssistantTools() - Failed to fetch IndexEntries`); diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/ingest_pipeline.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/ingest_pipeline.ts index e11840b94e660..8f459848af420 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/ingest_pipeline.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/ingest_pipeline.ts @@ -5,22 +5,31 @@ * 2.0. */ -// TODO: Ensure old pipeline is updated/replaced -export const knowledgeBaseIngestPipeline = ({ id, modelId }: { id: string; modelId: string }) => ({ +export const knowledgeBaseIngestPipeline = ({ + id, + modelId, + v2KnowledgeBaseEnabled, +}: { + id: string; + modelId: string; + v2KnowledgeBaseEnabled: boolean; +}) => ({ id, description: 'Embedding pipeline for Elastic AI Assistant ELSER Knowledge Base', - processors: [ - { - inference: { - if: 'ctx?.text != null', - model_id: modelId, - input_output: [ - { - input_field: 'text', - output_field: 'vector.tokens', + processors: !v2KnowledgeBaseEnabled + ? [ + { + inference: { + if: 'ctx?.text != null', + model_id: modelId, + input_output: [ + { + input_field: 'text', + output_field: 'vector.tokens', + }, + ], }, - ], - }, - }, - ], + }, + ] + : [], }); diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/types.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/types.ts index 3de1a15d79b2a..443d03941ccdd 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/types.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/types.ts @@ -27,6 +27,7 @@ export interface EsDocumentEntry { required: boolean; source: string; text: string; + semantic_text?: string; vector?: { tokens: Record; model_id: string; @@ -99,6 +100,7 @@ export interface UpdateKnowledgeBaseEntrySchema { required?: boolean; source?: string; text?: string; + semantic_text?: string; vector?: { tokens: Record; model_id: string; @@ -135,6 +137,7 @@ export interface CreateKnowledgeBaseEntrySchema { required?: boolean; source?: string; text?: string; + semantic_text?: string; vector?: { tokens: Record; model_id: string; diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/prompts/helpers.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/prompts/helpers.ts index a4534972c8478..eb71270127b2a 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/prompts/helpers.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/prompts/helpers.ts @@ -143,7 +143,8 @@ export const getUpdateScript = ({ isPatch?: boolean; }) => { return { - source: ` + script: { + source: ` if (params.assignEmpty == true || params.containsKey('content')) { ctx._source.content = params.content; } @@ -158,11 +159,12 @@ export const getUpdateScript = ({ } ctx._source.updated_at = params.updated_at; `, - lang: 'painless', - params: { - ...prompt, // when assigning undefined in painless, it will remove property and wil set it to null - // for patch we don't want to remove unspecified value in payload - assignEmpty: !(isPatch ?? true), + lang: 'painless', + params: { + ...prompt, // when assigning undefined in painless, it will remove property and wil set it to null + // for patch we don't want to remove unspecified value in payload + assignEmpty: !(isPatch ?? true), + }, }, }; }; diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_service/create_resource_installation_helper.test.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_service/create_resource_installation_helper.test.ts index 8e34581332ff6..85f6de83592ae 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_service/create_resource_installation_helper.test.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_service/create_resource_installation_helper.test.ts @@ -141,7 +141,7 @@ describe('createResourceInstallationHelper', () => { async () => (await getContextInitialized(helper)) === false ); - expect(logger.error).toHaveBeenCalledWith(`Error initializing resources test1 - fail`); + expect(logger.warn).toHaveBeenCalledWith(`Error initializing resources test1 - fail`); expect(await helper.getInitializedResources('test1')).toEqual({ result: false, error: `fail`, @@ -204,7 +204,7 @@ describe('createResourceInstallationHelper', () => { async () => (await getContextInitialized(helper)) === false ); - expect(logger.error).toHaveBeenCalledWith(`Error initializing resources default - first error`); + expect(logger.warn).toHaveBeenCalledWith(`Error initializing resources default - first error`); expect(await helper.getInitializedResources(DEFAULT_NAMESPACE_STRING)).toEqual({ result: false, error: `first error`, @@ -221,9 +221,7 @@ describe('createResourceInstallationHelper', () => { return logger.error.mock.calls.length === 1; }); - expect(logger.error).toHaveBeenCalledWith( - `Error initializing resources default - second error` - ); + expect(logger.warn).toHaveBeenCalledWith(`Error initializing resources default - second error`); // the second retry is throttled so this is never called expect(logger.info).not.toHaveBeenCalledWith('test1_default successfully retried'); diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_service/create_resource_installation_helper.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_service/create_resource_installation_helper.ts index 39e0e69a8fc49..e8d1f1eb1d85d 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_service/create_resource_installation_helper.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_service/create_resource_installation_helper.ts @@ -65,7 +65,7 @@ export function createResourceInstallationHelper( return errorResult(commonInitError); } } catch (err) { - logger.error(`Error initializing resources ${namespace} - ${err.message}`); + logger.warn(`Error initializing resources ${namespace} - ${err.message}`); return errorResult(err.message); } }; @@ -113,7 +113,7 @@ export function createResourceInstallationHelper( const key = namespace; return ( initializedResources.has(key) - ? initializedResources.get(key) + ? await initializedResources.get(key) : errorResult(`Unrecognized spaceId ${key}`) ) as InitializationPromise; }, diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_service/helpers.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_service/helpers.ts index 07da930320712..93338174364fc 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_service/helpers.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_service/helpers.ts @@ -54,6 +54,7 @@ interface CreatePipelineParams { esClient: ElasticsearchClient; id: string; modelId: string; + v2KnowledgeBaseEnabled: boolean; } /** @@ -70,12 +71,14 @@ export const createPipeline = async ({ esClient, id, modelId, + v2KnowledgeBaseEnabled, }: CreatePipelineParams): Promise => { try { const response = await esClient.ingest.putPipeline( knowledgeBaseIngestPipeline({ id, modelId, + v2KnowledgeBaseEnabled, }) ); diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.test.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.test.ts index 8bd1173e93d89..23a1a55564415 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.test.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.test.ts @@ -18,11 +18,17 @@ import { AIAssistantService, AIAssistantServiceOpts } from '.'; import { retryUntil } from './create_resource_installation_helper.test'; import { mlPluginMock } from '@kbn/ml-plugin/public/mocks'; import type { MlPluginSetup } from '@kbn/ml-plugin/server'; +import { licensingMock } from '@kbn/licensing-plugin/server/mocks'; jest.mock('../ai_assistant_data_clients/conversations', () => ({ AIAssistantConversationsDataClient: jest.fn(), })); +const licensing = Promise.resolve( + licensingMock.createRequestHandlerContext({ + license: { type: 'enterprise' }, + }) +); let logger: ReturnType<(typeof loggingSystemMock)['createLogger']>; const clusterClient = elasticsearchServiceMock.createClusterClient().asInternalUser; @@ -191,6 +197,7 @@ describe('AI Assistant Service', () => { logger, spaceId: 'default', currentUser: mockUser1, + licensing, }); expect(AIAssistantConversationsDataClient).toHaveBeenCalledWith({ @@ -221,6 +228,7 @@ describe('AI Assistant Service', () => { logger, spaceId: 'default', currentUser: mockUser1, + licensing, }); expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalled(); @@ -274,11 +282,13 @@ describe('AI Assistant Service', () => { logger, spaceId: 'default', currentUser: mockUser1, + licensing, }), assistantService.createAIAssistantConversationsDataClient({ logger, spaceId: 'default', currentUser: mockUser1, + licensing, }), ]); @@ -340,6 +350,7 @@ describe('AI Assistant Service', () => { logger, spaceId: 'default', currentUser: mockUser1, + licensing, }); expect(AIAssistantConversationsDataClient).toHaveBeenCalledWith({ @@ -400,6 +411,7 @@ describe('AI Assistant Service', () => { logger, spaceId: 'default', currentUser: mockUser1, + licensing, }); }; @@ -472,6 +484,7 @@ describe('AI Assistant Service', () => { logger, spaceId: 'default', currentUser: mockUser1, + licensing, }); }; @@ -513,6 +526,7 @@ describe('AI Assistant Service', () => { logger, spaceId: 'test', currentUser: mockUser1, + licensing, }); expect(clusterClient.indices.putIndexTemplate).not.toHaveBeenCalled(); @@ -560,6 +574,7 @@ describe('AI Assistant Service', () => { logger, spaceId: 'test', currentUser: mockUser1, + licensing, }); expect(clusterClient.indices.putIndexTemplate).not.toHaveBeenCalled(); @@ -607,6 +622,7 @@ describe('AI Assistant Service', () => { logger, spaceId: 'test', currentUser: mockUser1, + licensing, }); expect(AIAssistantConversationsDataClient).not.toHaveBeenCalled(); @@ -752,6 +768,7 @@ describe('AI Assistant Service', () => { logger, spaceId: 'default', currentUser: mockUser1, + licensing, }); await retryUntil( diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.ts index bfdf8b96f44b0..15274f2323259 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.ts @@ -11,6 +11,7 @@ import type { AuthenticatedUser, Logger, ElasticsearchClient } from '@kbn/core/s import type { TaskManagerSetupContract } from '@kbn/task-manager-plugin/server'; import type { MlPluginSetup } from '@kbn/ml-plugin/server'; import { Subject } from 'rxjs'; +import { LicensingApiRequestHandlerContext } from '@kbn/licensing-plugin/server'; import { attackDiscoveryFieldMap } from '../lib/attack_discovery/persistence/field_maps_configuration/field_maps_configuration'; import { getDefaultAnonymizationFields } from '../../common/anonymization'; import { AssistantResourceNames, GetElser } from '../types'; @@ -36,6 +37,7 @@ import { } from '../ai_assistant_data_clients/knowledge_base'; import { AttackDiscoveryDataClient } from '../lib/attack_discovery/persistence'; import { createGetElserId, createPipeline, pipelineExists } from './helpers'; +import { hasAIAssistantLicense } from '../routes/helpers'; const TOTAL_FIELDS_LIMIT = 2500; @@ -56,6 +58,7 @@ export interface CreateAIAssistantClientParams { logger: Logger; spaceId: string; currentUser: AuthenticatedUser | null; + licensing: Promise; } export type CreateDataStream = (params: { @@ -97,7 +100,7 @@ export class AIAssistantService { this.knowledgeBaseDataStream = this.createDataStream({ resource: 'knowledgeBase', kibanaVersion: options.kibanaVersion, - fieldMap: knowledgeBaseFieldMap, // TODO: use V2 if FF is enabled + fieldMap: knowledgeBaseFieldMap, }); this.promptsDataStream = this.createDataStream({ resource: 'prompts', @@ -151,7 +154,9 @@ export class AIAssistantService { name: this.resourceNames.indexTemplate[resource], componentTemplateRefs: [this.resourceNames.componentTemplate[resource]], // Apply `default_pipeline` if pipeline exists for resource - ...(resource in this.resourceNames.pipelines + ...(resource in this.resourceNames.pipelines && + // Remove this param and initialization when the `assistantKnowledgeBaseByDefault` feature flag is removed + !(resource === 'knowledgeBase' && this.v2KnowledgeBaseEnabled) ? { template: { settings: { @@ -202,7 +207,12 @@ export class AIAssistantService { id: this.resourceNames.pipelines.knowledgeBase, }); // TODO: When FF is removed, ensure pipeline is re-created for those upgrading - if (!pipelineCreated || this.v2KnowledgeBaseEnabled) { + if ( + // Install for v1 + (!this.v2KnowledgeBaseEnabled && !pipelineCreated) || + // Upgrade from v1 to v2 + (pipelineCreated && this.v2KnowledgeBaseEnabled) + ) { this.options.logger.debug( `Installing ingest pipeline - ${this.resourceNames.pipelines.knowledgeBase}` ); @@ -210,6 +220,7 @@ export class AIAssistantService { esClient, id: this.resourceNames.pipelines.knowledgeBase, modelId: await this.getElserId(), + v2KnowledgeBaseEnabled: this.v2KnowledgeBaseEnabled, }); this.options.logger.debug(`Installed ingest pipeline: ${response}`); @@ -237,7 +248,7 @@ export class AIAssistantService { pluginStop$: this.options.pluginStop$, }); } catch (error) { - this.options.logger.error(`Error initializing AI assistant resources: ${error.message}`); + this.options.logger.warn(`Error initializing AI assistant resources: ${error.message}`); this.initialized = false; this.isInitializing = false; return errorResult(error.message); @@ -282,6 +293,8 @@ export class AIAssistantService { }; private async checkResourcesInstallation(opts: CreateAIAssistantClientParams) { + const licensing = await opts.licensing; + if (!hasAIAssistantLicense(licensing.license)) return null; // Check if resources installation has succeeded const { result: initialized, error } = await this.getSpaceResourcesInitializationPromise( opts.spaceId @@ -502,7 +515,7 @@ export class AIAssistantService { await this.createDefaultAnonymizationFields(spaceId); } } catch (error) { - this.options.logger.error( + this.options.logger.warn( `Error initializing AI assistant namespace level resources: ${error.message}` ); throw error; diff --git a/x-pack/plugins/elastic_assistant/server/lib/data_stream/documents_data_writer.ts b/x-pack/plugins/elastic_assistant/server/lib/data_stream/documents_data_writer.ts index 32b579fdeb71a..08892038a58b7 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/data_stream/documents_data_writer.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/data_stream/documents_data_writer.ts @@ -34,7 +34,10 @@ interface BulkParams { documentsToCreate?: TCreateParams[]; documentsToUpdate?: TUpdateParams[]; documentsToDelete?: string[]; - getUpdateScript?: (document: TUpdateParams, updatedAt: string) => Script; + getUpdateScript?: ( + document: TUpdateParams, + updatedAt: string + ) => { script?: Script; doc?: TUpdateParams }; authenticatedUser?: AuthenticatedUser; } @@ -73,7 +76,7 @@ export class DocumentsDataWriter implements DocumentsDataWriter { body: await this.buildBulkOperations(params), }, { - // Increasing timout to 2min as KB docs were failing to load after 30s + // Increasing timeout to 2min as KB docs were failing to load after 30s requestTimeout: 120000, } ); @@ -151,7 +154,10 @@ export class DocumentsDataWriter implements DocumentsDataWriter { private getUpdateDocumentsQuery = async ( documentsToUpdate: TUpdateParams[], - getUpdateScript: (document: TUpdateParams, updatedAt: string) => Script, + getUpdateScript: ( + document: TUpdateParams, + updatedAt: string + ) => { script?: Script; doc?: TUpdateParams }, authenticatedUser?: AuthenticatedUser ) => { const updatedAt = new Date().toISOString(); @@ -196,10 +202,7 @@ export class DocumentsDataWriter implements DocumentsDataWriter { _source: true, }, }, - { - script: getUpdateScript(document, updatedAt), - upsert: { counter: 1 }, - }, + getUpdateScript(document, updatedAt), ]); }; diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/content_loaders/security_labs_loader.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/content_loaders/security_labs_loader.ts index 10566b3e5a1d5..f37e20df2bd98 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/content_loaders/security_labs_loader.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/content_loaders/security_labs_loader.ts @@ -5,13 +5,14 @@ * 2.0. */ +import globby from 'globby'; import { Logger } from '@kbn/core/server'; import { DirectoryLoader } from 'langchain/document_loaders/fs/directory'; import { TextLoader } from 'langchain/document_loaders/fs/text'; import { resolve } from 'path'; import { Document } from 'langchain/document'; import { Metadata } from '@kbn/elastic-assistant-common'; - +import pMap from 'p-map'; import { addRequiredKbResourceMetadata } from './add_required_kb_resource_metadata'; import { SECURITY_LABS_RESOURCE } from '../../../routes/knowledge_base/constants'; import { AIAssistantKnowledgeBaseDataClient } from '../../../ai_assistant_data_clients/knowledge_base'; @@ -42,10 +43,22 @@ export const loadSecurityLabs = async ( logger.info(`Loading ${docs.length} Security Labs docs into the Knowledge Base`); - const response = await kbDataClient.addKnowledgeBaseDocuments({ - documents: docs, - global: true, - }); + /** + * Ingest Security Labs docs into the Knowledge Base one by one to avoid blocking + * Inference Endpoint for too long + */ + + const response = ( + await pMap( + docs, + (singleDoc) => + kbDataClient.addKnowledgeBaseDocuments({ + documents: [singleDoc], + global: true, + }), + { concurrency: 1 } + ) + ).flat(); logger.info(`Loaded ${response?.length ?? 0} Security Labs docs into the Knowledge Base`); @@ -55,3 +68,13 @@ export const loadSecurityLabs = async ( return false; } }; + +export const getSecurityLabsDocsCount = async ({ logger }: { logger: Logger }): Promise => { + try { + return (await globby(`${resolve(__dirname, '../../../knowledge_base/security_labs')}/**/*.md`)) + ?.length; + } catch (e) { + logger.error(`Failed to get Security Labs source docs count\n${e}`); + return 0; + } +}; diff --git a/x-pack/plugins/elastic_assistant/server/routes/anonymization_fields/bulk_actions_route.ts b/x-pack/plugins/elastic_assistant/server/routes/anonymization_fields/bulk_actions_route.ts index 5464756739c08..9aedffae5cfb5 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/anonymization_fields/bulk_actions_route.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/anonymization_fields/bulk_actions_route.ts @@ -38,7 +38,7 @@ import { EsAnonymizationFieldsSchema, UpdateAnonymizationFieldSchema, } from '../../ai_assistant_data_clients/anonymization_fields/types'; -import { UPGRADE_LICENSE_MESSAGE, hasAIAssistantLicense } from '../helpers'; +import { performChecks } from '../helpers'; export interface BulkOperationError { message: string; @@ -162,22 +162,18 @@ export const bulkActionAnonymizationFieldsRoute = ( request.events.completed$.subscribe(() => abortController.abort()); try { const ctx = await context.resolve(['core', 'elasticAssistant', 'licensing']); - const license = ctx.licensing.license; - if (!hasAIAssistantLicense(license)) { - return response.forbidden({ - body: { - message: UPGRADE_LICENSE_MESSAGE, - }, - }); - } + // Perform license and authenticated user checks + const checkResponse = performChecks({ + context: ctx, + request, + response, + }); - const authenticatedUser = ctx.elasticAssistant.getCurrentUser(); - if (authenticatedUser == null) { - return assistantResponse.error({ - body: `Authenticated user not found`, - statusCode: 401, - }); + if (!checkResponse.isSuccess) { + return checkResponse.response; } + const authenticatedUser = checkResponse.currentUser; + const dataClient = await ctx.elasticAssistant.getAIAssistantAnonymizationFieldsDataClient(); @@ -199,7 +195,7 @@ export const bulkActionAnonymizationFieldsRoute = ( } const writer = await dataClient?.getWriter(); - const changedAt = new Date().toISOString(); + const createdAt = new Date().toISOString(); const { errors, docs_created: docsCreated, @@ -207,12 +203,12 @@ export const bulkActionAnonymizationFieldsRoute = ( docs_deleted: docsDeleted, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion } = await writer!.bulk({ - documentsToCreate: body.create?.map((f) => - transformToCreateScheme(authenticatedUser, changedAt, f) + documentsToCreate: body.create?.map((doc) => + transformToCreateScheme(authenticatedUser, createdAt, doc) ), documentsToDelete: body.delete?.ids, - documentsToUpdate: body.update?.map((f) => - transformToUpdateScheme(authenticatedUser, changedAt, f) + documentsToUpdate: body.update?.map((doc) => + transformToUpdateScheme(authenticatedUser, createdAt, doc) ), getUpdateScript: (document: UpdateAnonymizationFieldSchema) => getUpdateScript({ anonymizationField: document, isPatch: true }), diff --git a/x-pack/plugins/elastic_assistant/server/routes/anonymization_fields/find_route.test.ts b/x-pack/plugins/elastic_assistant/server/routes/anonymization_fields/find_route.test.ts index 4659503261a6e..7c2b1d330a3db 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/anonymization_fields/find_route.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/anonymization_fields/find_route.test.ts @@ -12,6 +12,7 @@ import { requestContextMock } from '../../__mocks__/request_context'; import { getFindAnonymizationFieldsResultWithSingleHit } from '../../__mocks__/response'; import { findAnonymizationFieldsRoute } from './find_route'; import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import type { AuthenticatedUser } from '@kbn/core-security-common'; describe('Find user anonymization fields route', () => { let server: ReturnType; @@ -21,19 +22,26 @@ describe('Find user anonymization fields route', () => { beforeEach(async () => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); + const mockUser1 = { + username: 'my_username', + authentication_realm: { + type: 'my_realm_type', + name: 'my_realm_name', + }, + } as AuthenticatedUser; clients.elasticAssistant.getAIAssistantAnonymizationFieldsDataClient.findDocuments.mockResolvedValue( Promise.resolve(getFindAnonymizationFieldsResultWithSingleHit()) ); - clients.elasticAssistant.getCurrentUser.mockResolvedValue({ + context.elasticAssistant.getCurrentUser.mockReturnValue({ username: 'my_username', authentication_realm: { type: 'my_realm_type', name: 'my_realm_name', }, - }); + } as AuthenticatedUser); logger = loggingSystemMock.createLogger(); - + context.elasticAssistant.getCurrentUser.mockReturnValue(mockUser1); findAnonymizationFieldsRoute(server.router, logger); }); diff --git a/x-pack/plugins/elastic_assistant/server/routes/anonymization_fields/find_route.ts b/x-pack/plugins/elastic_assistant/server/routes/anonymization_fields/find_route.ts index 904a80d6a3ea4..061dd9ff3eac6 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/anonymization_fields/find_route.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/anonymization_fields/find_route.ts @@ -22,7 +22,7 @@ import { ElasticAssistantPluginRouter } from '../../types'; import { buildResponse } from '../utils'; import { EsAnonymizationFieldsSchema } from '../../ai_assistant_data_clients/anonymization_fields/types'; import { transformESSearchToAnonymizationFields } from '../../ai_assistant_data_clients/anonymization_fields/helpers'; -import { UPGRADE_LICENSE_MESSAGE, hasAIAssistantLicense } from '../helpers'; +import { performChecks } from '../helpers'; export const findAnonymizationFieldsRoute = ( router: ElasticAssistantPluginRouter, @@ -55,14 +55,16 @@ export const findAnonymizationFieldsRoute = ( try { const { query } = request; const ctx = await context.resolve(['core', 'elasticAssistant', 'licensing']); - const license = ctx.licensing.license; - if (!hasAIAssistantLicense(license)) { - return response.forbidden({ - body: { - message: UPGRADE_LICENSE_MESSAGE, - }, - }); + // Perform license and authenticated user checks + const checkResponse = performChecks({ + context: ctx, + request, + response, + }); + if (!checkResponse.isSuccess) { + return checkResponse.response; } + const dataClient = await ctx.elasticAssistant.getAIAssistantAnonymizationFieldsDataClient(); diff --git a/x-pack/plugins/elastic_assistant/server/routes/chat/chat_complete_route.test.ts b/x-pack/plugins/elastic_assistant/server/routes/chat/chat_complete_route.test.ts index 0b05bb2875cb6..f03a3394cdaac 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/chat/chat_complete_route.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/chat/chat_complete_route.test.ts @@ -32,7 +32,17 @@ const actionsClient = actionsClientMock.create(); jest.mock('../../lib/build_response', () => ({ buildResponse: jest.fn().mockImplementation((x) => x), })); -jest.mock('../helpers'); + +jest.mock('../helpers', () => { + const original = jest.requireActual('../helpers'); + + return { + ...original, + appendAssistantMessageToConversation: jest.fn(), + createConversationWithUserInput: jest.fn(), + langChainExecute: jest.fn(), + }; +}); const mockAppendAssistantMessageToConversation = appendAssistantMessageToConversation as jest.Mock; const mockLangChainExecute = langChainExecute as jest.Mock; @@ -280,6 +290,7 @@ describe('chatCompleteRoute', () => { actionTypeId: '.gen-ai', model: 'gpt-4', assistantStreamingEnabled: false, + isEnabledKnowledgeBase: false, }); }), }; diff --git a/x-pack/plugins/elastic_assistant/server/routes/chat/chat_complete_route.ts b/x-pack/plugins/elastic_assistant/server/routes/chat/chat_complete_route.ts index 47f6f1a486957..c6eb81dd86ebd 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/chat/chat_complete_route.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/chat/chat_complete_route.ts @@ -25,6 +25,9 @@ import { buildResponse } from '../../lib/build_response'; import { appendAssistantMessageToConversation, createConversationWithUserInput, + DEFAULT_PLUGIN_NAME, + getIsKnowledgeBaseInstalled, + getPluginNameFromRequest, langChainExecute, performChecks, } from '../helpers'; @@ -63,22 +66,20 @@ export const chatCompleteRoute = ( const assistantResponse = buildResponse(response); let telemetry; let actionTypeId; + const ctx = await context.resolve(['core', 'elasticAssistant', 'licensing']); + const logger: Logger = ctx.elasticAssistant.logger; try { - const ctx = await context.resolve(['core', 'elasticAssistant', 'licensing']); - const logger: Logger = ctx.elasticAssistant.logger; telemetry = ctx.elasticAssistant.telemetry; const inference = ctx.elasticAssistant.inference; // Perform license and authenticated user checks const checkResponse = performChecks({ - authenticatedUser: true, context: ctx, - license: true, request, response, }); - if (checkResponse) { - return checkResponse; + if (!checkResponse.isSuccess) { + return checkResponse.response; } const conversationsDataClient = @@ -221,6 +222,19 @@ export const chatCompleteRoute = ( }); } catch (err) { const error = transformError(err as Error); + const pluginName = getPluginNameFromRequest({ + request, + defaultPluginName: DEFAULT_PLUGIN_NAME, + logger, + }); + const v2KnowledgeBaseEnabled = + ctx.elasticAssistant.getRegisteredFeatures(pluginName).assistantKnowledgeBaseByDefault; + const kbDataClient = + (await ctx.elasticAssistant.getAIAssistantKnowledgeBaseDataClient({ + v2KnowledgeBaseEnabled, + })) ?? undefined; + const isKnowledgeBaseInstalled = await getIsKnowledgeBaseInstalled(kbDataClient); + telemetry?.reportEvent(INVOKE_ASSISTANT_ERROR_EVENT.eventType, { actionTypeId: actionTypeId ?? '', model: request.body.model, @@ -228,6 +242,7 @@ export const chatCompleteRoute = ( // TODO rm actionTypeId check when llmClass for bedrock streaming is implemented // tracked here: https://github.com/elastic/security-team/issues/7363 assistantStreamingEnabled: request.body.isStream ?? false, + isEnabledKnowledgeBase: isKnowledgeBaseInstalled, }); return assistantResponse.error({ body: error.message, diff --git a/x-pack/plugins/elastic_assistant/server/routes/evaluate/get_evaluate.ts b/x-pack/plugins/elastic_assistant/server/routes/evaluate/get_evaluate.ts index 41b455a73598b..dd7462696621b 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/evaluate/get_evaluate.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/evaluate/get_evaluate.ts @@ -48,15 +48,14 @@ export const getEvaluateRoute = (router: IRouter(result); }; @@ -561,55 +560,66 @@ export const updateConversationWithUserInput = async ({ }; interface PerformChecksParams { - authenticatedUser?: boolean; capability?: AssistantFeatureKey; context: AwaitedProperties< Pick >; - license?: boolean; request: KibanaRequest; response: KibanaResponseFactory; } /** - * Helper to perform checks for authenticated user, capability, and license. Perform all or one - * of the checks by providing relevant optional params. Check order is license, authenticated user, - * then capability. + * Helper to perform checks for authenticated user, license, and optionally capability. + * Check order is license, authenticated user, then capability. + * + * Returns either a successful check with an AuthenticatedUser or + * an unsuccessful check with an error IKibanaResponse. * - * @param authenticatedUser - Whether to check for an authenticated user * @param capability - Specific capability to check if enabled, e.g. `assistantModelEvaluation` * @param context - Route context - * @param license - Whether to check for a valid license * @param request - Route KibanaRequest * @param response - Route KibanaResponseFactory + * @returns PerformChecks */ + +type PerformChecks = + | { + isSuccess: true; + currentUser: AuthenticatedUser; + } + | { + isSuccess: false; + response: IKibanaResponse; + }; export const performChecks = ({ - authenticatedUser, capability, context, - license, request, response, -}: PerformChecksParams): IKibanaResponse | undefined => { +}: PerformChecksParams): PerformChecks => { const assistantResponse = buildResponse(response); - if (license) { - if (!hasAIAssistantLicense(context.licensing.license)) { - return response.forbidden({ + if (!hasAIAssistantLicense(context.licensing.license)) { + return { + isSuccess: false, + response: response.forbidden({ body: { message: UPGRADE_LICENSE_MESSAGE, }, - }); - } + }), + }; } - if (authenticatedUser) { - if (context.elasticAssistant.getCurrentUser() == null) { - return assistantResponse.error({ + const currentUser = context.elasticAssistant.getCurrentUser(); + + if (currentUser == null) { + return { + isSuccess: false, + response: assistantResponse.error({ body: `Authenticated user not found`, statusCode: 401, - }); - } + }), + }; } if (capability) { @@ -619,11 +629,17 @@ export const performChecks = ({ }); const registeredFeatures = context.elasticAssistant.getRegisteredFeatures(pluginName); if (!registeredFeatures[capability]) { - return response.notFound(); + return { + isSuccess: false, + response: response.notFound(), + }; } } - return undefined; + return { + isSuccess: true, + currentUser, + }; }; /** @@ -653,23 +669,20 @@ export const isV2KnowledgeBaseEnabled = ({ * Telemetry function to determine whether knowledge base has been installed * @param kbDataClient */ -export const getIsKnowledgeBaseEnabled = async ( +export const getIsKnowledgeBaseInstalled = async ( kbDataClient?: AIAssistantKnowledgeBaseDataClient | null -): Promise<{ - esqlExists: boolean; - isModelDeployed: boolean; -}> => { - let esqlExists = false; +): Promise => { + let securityLabsDocsExist = false; let isModelDeployed = false; if (kbDataClient != null) { try { isModelDeployed = await kbDataClient.isModelDeployed(); if (isModelDeployed) { - esqlExists = + securityLabsDocsExist = ( await kbDataClient.getKnowledgeBaseDocumentEntries({ - query: ESQL_DOCS_LOADED_QUERY, - required: true, + kbResource: SECURITY_LABS_RESOURCE, + query: SECURITY_LABS_LOADED_QUERY, }) ).length > 0; } @@ -678,8 +691,5 @@ export const getIsKnowledgeBaseEnabled = async ( } } - return { - esqlExists, - isModelDeployed, - }; + return isModelDeployed && securityLabsDocsExist; }; diff --git a/x-pack/plugins/elastic_assistant/server/routes/index.ts b/x-pack/plugins/elastic_assistant/server/routes/index.ts index a6d7a4298c2b7..928c3211faa9b 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/index.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/index.ts @@ -14,6 +14,7 @@ export { getAttackDiscoveryRoute } from './attack_discovery/get/get_attack_disco // Knowledge Base export { deleteKnowledgeBaseRoute } from './knowledge_base/delete_knowledge_base'; +export { getKnowledgeBaseIndicesRoute } from './knowledge_base/get_knowledge_base_indices'; export { getKnowledgeBaseStatusRoute } from './knowledge_base/get_knowledge_base_status'; export { postKnowledgeBaseRoute } from './knowledge_base/post_knowledge_base'; diff --git a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/constants.ts b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/constants.ts index 89970611df0e9..052b2cac57609 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/constants.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/constants.ts @@ -12,3 +12,6 @@ export const KNOWLEDGE_BASE_INGEST_PIPELINE = '.kibana-elastic-ai-assistant-kb-i export const ESQL_DOCS_LOADED_QUERY = 'You can chain processing commands, separated by a pipe character: `|`.'; export const SECURITY_LABS_RESOURCE = 'security_labs'; +export const USER_RESOURCE = 'user'; +// Query for determining if Security Labs docs have been loaded. Intended for use with Telemetry +export const SECURITY_LABS_LOADED_QUERY = 'What is Elastic Security Labs'; diff --git a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/bulk_actions_route.ts b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/bulk_actions_route.ts index cfb2303010756..fbe73525578b0 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/bulk_actions_route.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/bulk_actions_route.ts @@ -6,7 +6,7 @@ */ import moment from 'moment'; -import type { AuthenticatedUser, IKibanaResponse, KibanaResponseFactory } from '@kbn/core/server'; +import type { IKibanaResponse, KibanaResponseFactory } from '@kbn/core/server'; import { transformError } from '@kbn/securitysolution-es-utils'; import { @@ -143,15 +143,13 @@ export const bulkActionKnowledgeBaseEntriesRoute = (router: ElasticAssistantPlug // Perform license, authenticated user and FF checks const checkResponse = performChecks({ - authenticatedUser: true, capability: 'assistantKnowledgeBaseByDefault', context: ctx, - license: true, request, response, }); - if (checkResponse) { - return checkResponse; + if (!checkResponse.isSuccess) { + return checkResponse.response; } logger.debug( @@ -181,8 +179,7 @@ export const bulkActionKnowledgeBaseEntriesRoute = (router: ElasticAssistantPlug v2KnowledgeBaseEnabled: true, }); const spaceId = ctx.elasticAssistant.getSpaceId(); - // Authenticated user null check completed in `performChecks()` above - const authenticatedUser = ctx.elasticAssistant.getCurrentUser() as AuthenticatedUser; + const authenticatedUser = checkResponse.currentUser; const userFilter = getKBUserFilter(authenticatedUser); const manageGlobalKnowledgeBaseAIAssistant = kbDataClient?.options.manageGlobalKnowledgeBaseAIAssistant; diff --git a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/create_route.ts b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/create_route.ts index 96753bdd690bd..0bfe9de269f7c 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/create_route.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/create_route.ts @@ -47,15 +47,13 @@ export const createKnowledgeBaseEntryRoute = (router: ElasticAssistantPluginRout // Perform license, authenticated user and FF checks const checkResponse = performChecks({ - authenticatedUser: true, capability: 'assistantKnowledgeBaseByDefault', context: ctx, - license: true, request, response, }); - if (checkResponse) { - return checkResponse; + if (!checkResponse.isSuccess) { + return checkResponse.response; } // Check mappings and upgrade if necessary -- this route only supports v2 KB, so always `true` diff --git a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/find_route.ts b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/find_route.ts index 356d5d9150a67..13334d0d829b1 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/find_route.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/find_route.ts @@ -58,21 +58,19 @@ export const findKnowledgeBaseEntriesRoute = (router: ElasticAssistantPluginRout // Perform license, authenticated user and FF checks const checkResponse = performChecks({ - authenticatedUser: true, capability: 'assistantKnowledgeBaseByDefault', context: ctx, - license: true, request, response, }); - if (checkResponse) { - return checkResponse; + if (!checkResponse.isSuccess) { + return checkResponse.response; } const kbDataClient = await ctx.elasticAssistant.getAIAssistantKnowledgeBaseDataClient({ v2KnowledgeBaseEnabled: true, }); - const currentUser = ctx.elasticAssistant.getCurrentUser(); + const currentUser = checkResponse.currentUser; const userFilter = getKBUserFilter(currentUser); const systemFilter = ` AND (kb_resource:"user" OR type:"index")`; const additionalFilter = query.filter ? ` AND ${query.filter}` : ''; diff --git a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/get_knowledge_base_indices.test.ts b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/get_knowledge_base_indices.test.ts new file mode 100644 index 0000000000000..e7eaa75407248 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/get_knowledge_base_indices.test.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 { getKnowledgeBaseIndicesRoute } from './get_knowledge_base_indices'; +import { serverMock } from '../../__mocks__/server'; +import { requestContextMock } from '../../__mocks__/request_context'; +import { getGetKnowledgeBaseIndicesRequest } from '../../__mocks__/request'; + +const mockFieldCaps = { + indices: [ + '.ds-logs-endpoint.alerts-default-2024.10.31-000001', + '.ds-metrics-endpoint.metadata-default-2024.10.31-000001', + '.internal.alerts-security.alerts-default-000001', + 'metrics-endpoint.metadata_current_default', + 'semantic-index-1', + 'semantic-index-2', + ], + fields: { + content: { + unmapped: { + type: 'unmapped', + metadata_field: false, + searchable: false, + aggregatable: false, + indices: [ + '.ds-logs-endpoint.alerts-default-2024.10.31-000001', + '.ds-metrics-endpoint.metadata-default-2024.10.31-000001', + '.internal.alerts-security.alerts-default-000001', + 'metrics-endpoint.metadata_current_default', + ], + }, + semantic_text: { + type: 'semantic_text', + metadata_field: false, + searchable: true, + aggregatable: false, + indices: ['semantic-index-1', 'semantic-index-2'], + }, + }, + }, +}; + +describe('Get Knowledge Base Status Route', () => { + let server: ReturnType; + + let { context } = requestContextMock.createTools(); + + beforeEach(() => { + server = serverMock.create(); + ({ context } = requestContextMock.createTools()); + context.core.elasticsearch.client.asCurrentUser.fieldCaps.mockResponse(mockFieldCaps); + + getKnowledgeBaseIndicesRoute(server.router); + }); + + describe('Status codes', () => { + test('returns 200 and all indices with `semantic_text` type fields', async () => { + const response = await server.inject( + getGetKnowledgeBaseIndicesRequest(), + requestContextMock.convertContext(context) + ); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + indices: ['semantic-index-1', 'semantic-index-2'], + }); + expect(context.core.elasticsearch.client.asCurrentUser.fieldCaps).toBeCalledWith({ + index: '*', + fields: '*', + types: ['semantic_text'], + include_unmapped: true, + }); + }); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/get_knowledge_base_indices.ts b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/get_knowledge_base_indices.ts new file mode 100644 index 0000000000000..18191291468de --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/get_knowledge_base_indices.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 { transformError } from '@kbn/securitysolution-es-utils'; + +import { + ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION, + ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_INDICES_URL, + GetKnowledgeBaseIndicesResponse, +} from '@kbn/elastic-assistant-common'; +import { IKibanaResponse } from '@kbn/core/server'; +import { buildResponse } from '../../lib/build_response'; +import { ElasticAssistantPluginRouter } from '../../types'; + +/** + * Get the indices that have fields of `sematic_text` type + * + * @param router IRouter for registering routes + */ +export const getKnowledgeBaseIndicesRoute = (router: ElasticAssistantPluginRouter) => { + router.versioned + .get({ + access: 'internal', + path: ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_INDICES_URL, + options: { + tags: ['access:elasticAssistant'], + }, + }) + .addVersion( + { + version: ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION, + validate: false, + }, + async (context, _, response): Promise> => { + const resp = buildResponse(response); + const ctx = await context.resolve(['core', 'elasticAssistant', 'licensing']); + const logger = ctx.elasticAssistant.logger; + const esClient = ctx.core.elasticsearch.client.asCurrentUser; + + try { + const body: GetKnowledgeBaseIndicesResponse = { + indices: [], + }; + + const res = await esClient.fieldCaps({ + index: '*', + fields: '*', + types: ['semantic_text'], + include_unmapped: true, + }); + + const indices = res.fields.content?.semantic_text?.indices; + if (indices) { + body.indices = Array.isArray(indices) ? indices : [indices]; + } + + return response.ok({ body }); + } catch (err) { + logger.error(err); + const error = transformError(err); + + return resp.error({ + body: error.message, + statusCode: error.statusCode, + }); + } + } + ); +}; diff --git a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/get_knowledge_base_status.test.ts b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/get_knowledge_base_status.test.ts index 6244599a2af27..b30e5ac3653ad 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/get_knowledge_base_status.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/get_knowledge_base_status.test.ts @@ -38,6 +38,7 @@ describe('Get Knowledge Base Status Route', () => { isModelDeployed: jest.fn().mockResolvedValue(true), isSetupInProgress: false, isSecurityLabsDocsLoaded: jest.fn().mockResolvedValue(true), + isUserDataExists: jest.fn().mockResolvedValue(true), }); getKnowledgeBaseStatusRoute(server.router); @@ -58,6 +59,7 @@ describe('Get Knowledge Base Status Route', () => { is_setup_available: true, pipeline_exists: true, security_labs_exists: true, + user_data_exists: true, }); }); }); diff --git a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/get_knowledge_base_status.ts b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/get_knowledge_base_status.ts index 833e674b68ffd..f278cd469ac0e 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/get_knowledge_base_status.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/get_knowledge_base_status.ts @@ -74,11 +74,18 @@ export const getKnowledgeBaseStatusRoute = (router: ElasticAssistantPluginRouter }; if (indexExists && isModelDeployed) { - const securityLabsExists = await kbDataClient.isSecurityLabsDocsLoaded(); + const securityLabsExists = v2KnowledgeBaseEnabled + ? await kbDataClient.isSecurityLabsDocsLoaded() + : true; + const userDataExists = v2KnowledgeBaseEnabled + ? await kbDataClient.isUserDataExists() + : true; + return response.ok({ body: { ...body, - security_labs_exists: v2KnowledgeBaseEnabled ? securityLabsExists : true, + security_labs_exists: securityLabsExists, + user_data_exists: userDataExists, }, }); } diff --git a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/post_knowledge_base.ts b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/post_knowledge_base.ts index 96317da303ac1..23604886e4a52 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/post_knowledge_base.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/post_knowledge_base.ts @@ -60,6 +60,7 @@ export const postKnowledgeBaseRoute = (router: ElasticAssistantPluginRouter) => // Only allow modelId override if FF is enabled as this will re-write the ingest pipeline and break any previous KB entries // This is only really needed for API integration tests const modelIdOverride = v2KnowledgeBaseEnabled ? request.query.modelId : undefined; + const ignoreSecurityLabs = request.query.ignoreSecurityLabs; try { const knowledgeBaseDataClient = @@ -74,6 +75,7 @@ export const postKnowledgeBaseRoute = (router: ElasticAssistantPluginRouter) => await knowledgeBaseDataClient.setupKnowledgeBase({ soClient, v2KnowledgeBaseEnabled, + ignoreSecurityLabs, }); return response.ok({ body: { success: true } }); diff --git a/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.test.ts b/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.test.ts index d19127be0d7e8..a7abac27dac6f 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.test.ts @@ -46,7 +46,18 @@ jest.mock('../lib/executor', () => ({ const mockStream = jest.fn().mockImplementation(() => new PassThrough()); const mockLangChainExecute = langChainExecute as jest.Mock; const mockAppendAssistantMessageToConversation = appendAssistantMessageToConversation as jest.Mock; -jest.mock('./helpers'); +jest.mock('./helpers', () => { + const original = jest.requireActual('./helpers'); + + return { + ...original, + getIsKnowledgeBaseInstalled: jest.fn(), + appendAssistantMessageToConversation: jest.fn(), + langChainExecute: jest.fn(), + getPluginNameFromRequest: jest.fn(), + getSystemPromptFromUserConversation: jest.fn(), + }; +}); const existingConversation = getConversationResponseMock(); const reportEvent = jest.fn(); const appendConversationMessages = jest.fn(); diff --git a/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts b/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts index 4b65b5bb3f1e5..bb217f7f5aa3a 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts @@ -24,10 +24,11 @@ import { ElasticAssistantRequestHandlerContext, GetElser } from '../types'; import { appendAssistantMessageToConversation, DEFAULT_PLUGIN_NAME, - getIsKnowledgeBaseEnabled, + getIsKnowledgeBaseInstalled, getPluginNameFromRequest, getSystemPromptFromUserConversation, langChainExecute, + performChecks, } from './helpers'; import { isOpenSourceModel } from './utils'; @@ -66,12 +67,16 @@ export const postActionsConnectorExecuteRoute = ( let onLlmResponse; try { - const authenticatedUser = assistantContext.getCurrentUser(); - if (authenticatedUser == null) { - return response.unauthorized({ - body: `Authenticated user not found`, - }); + const checkResponse = performChecks({ + context: ctx, + request, + response, + }); + + if (!checkResponse.isSuccess) { + return checkResponse.response; } + let latestReplacements: Replacements = request.body.replacements; const onNewReplacements = (newReplacements: Replacements) => { latestReplacements = { ...latestReplacements, ...newReplacements }; @@ -165,14 +170,13 @@ export const postActionsConnectorExecuteRoute = ( (await assistantContext.getAIAssistantKnowledgeBaseDataClient({ v2KnowledgeBaseEnabled, })) ?? undefined; - const isEnabledKnowledgeBase = await getIsKnowledgeBaseEnabled(kbDataClient); - + const isKnowledgeBaseInstalled = await getIsKnowledgeBaseInstalled(kbDataClient); telemetry.reportEvent(INVOKE_ASSISTANT_ERROR_EVENT.eventType, { actionTypeId: request.body.actionTypeId, model: request.body.model, errorMessage: error.message, assistantStreamingEnabled: request.body.subAction !== 'invokeAI', - isEnabledKnowledgeBase, + isEnabledKnowledgeBase: isKnowledgeBaseInstalled, }); return resp.error({ diff --git a/x-pack/plugins/elastic_assistant/server/routes/prompts/bulk_actions_route.ts b/x-pack/plugins/elastic_assistant/server/routes/prompts/bulk_actions_route.ts index 44a949cd22eeb..d3ee47854e7a0 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/prompts/bulk_actions_route.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/prompts/bulk_actions_route.ts @@ -35,7 +35,7 @@ import { transformESSearchToPrompts, } from '../../ai_assistant_data_clients/prompts/helpers'; import { EsPromptsSchema, UpdatePromptSchema } from '../../ai_assistant_data_clients/prompts/types'; -import { UPGRADE_LICENSE_MESSAGE, hasAIAssistantLicense } from '../helpers'; +import { performChecks } from '../helpers'; export interface BulkOperationError { message: string; @@ -156,22 +156,17 @@ export const bulkPromptsRoute = (router: ElasticAssistantPluginRouter, logger: L request.events.completed$.subscribe(() => abortController.abort()); try { const ctx = await context.resolve(['core', 'elasticAssistant', 'licensing']); - const license = ctx.licensing.license; - if (!hasAIAssistantLicense(license)) { - return response.forbidden({ - body: { - message: UPGRADE_LICENSE_MESSAGE, - }, - }); + // Perform license and authenticated user checks + const checkResponse = performChecks({ + context: ctx, + request, + response, + }); + if (!checkResponse.isSuccess) { + return checkResponse.response; } + const authenticatedUser = checkResponse.currentUser; - const authenticatedUser = ctx.elasticAssistant.getCurrentUser(); - if (authenticatedUser == null) { - return assistantResponse.error({ - body: `Authenticated user not found`, - statusCode: 401, - }); - } const dataClient = await ctx.elasticAssistant.getAIAssistantPromptsDataClient(); if (body.create && body.create.length > 0) { @@ -211,7 +206,7 @@ export const bulkPromptsRoute = (router: ElasticAssistantPluginRouter, logger: L ), getUpdateScript: (document: UpdatePromptSchema) => getUpdateScript({ prompt: document, isPatch: true }), - authenticatedUser, + authenticatedUser: authenticatedUser ?? undefined, }); const created = docsCreated.length > 0 diff --git a/x-pack/plugins/elastic_assistant/server/routes/prompts/find_route.test.ts b/x-pack/plugins/elastic_assistant/server/routes/prompts/find_route.test.ts index 68ce67d842a0f..151c1622d0219 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/prompts/find_route.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/prompts/find_route.test.ts @@ -12,6 +12,7 @@ import { requestContextMock } from '../../__mocks__/request_context'; import { getFindPromptsResultWithSingleHit } from '../../__mocks__/response'; import { findPromptsRoute } from './find_route'; import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import type { AuthenticatedUser } from '@kbn/core-security-common'; describe('Find user prompts route', () => { let server: ReturnType; @@ -21,19 +22,26 @@ describe('Find user prompts route', () => { beforeEach(async () => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); + const mockUser1 = { + username: 'my_username', + authentication_realm: { + type: 'my_realm_type', + name: 'my_realm_name', + }, + } as AuthenticatedUser; clients.elasticAssistant.getAIAssistantPromptsDataClient.findDocuments.mockResolvedValue( Promise.resolve(getFindPromptsResultWithSingleHit()) ); - clients.elasticAssistant.getCurrentUser.mockResolvedValue({ + context.elasticAssistant.getCurrentUser.mockReturnValue({ username: 'my_username', authentication_realm: { type: 'my_realm_type', name: 'my_realm_name', }, - }); + } as AuthenticatedUser); logger = loggingSystemMock.createLogger(); - + context.elasticAssistant.getCurrentUser.mockReturnValue(mockUser1); findPromptsRoute(server.router, logger); }); diff --git a/x-pack/plugins/elastic_assistant/server/routes/prompts/find_route.ts b/x-pack/plugins/elastic_assistant/server/routes/prompts/find_route.ts index 848680be662a3..a2980b173d76a 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/prompts/find_route.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/prompts/find_route.ts @@ -18,7 +18,7 @@ import { ElasticAssistantPluginRouter } from '../../types'; import { buildResponse } from '../utils'; import { EsPromptsSchema } from '../../ai_assistant_data_clients/prompts/types'; import { transformESSearchToPrompts } from '../../ai_assistant_data_clients/prompts/helpers'; -import { UPGRADE_LICENSE_MESSAGE, hasAIAssistantLicense } from '../helpers'; +import { performChecks } from '../helpers'; export const findPromptsRoute = (router: ElasticAssistantPluginRouter, logger: Logger) => { router.versioned @@ -44,13 +44,14 @@ export const findPromptsRoute = (router: ElasticAssistantPluginRouter, logger: L try { const { query } = request; const ctx = await context.resolve(['core', 'elasticAssistant', 'licensing']); - const license = ctx.licensing.license; - if (!hasAIAssistantLicense(license)) { - return response.forbidden({ - body: { - message: UPGRADE_LICENSE_MESSAGE, - }, - }); + // Perform license and authenticated user checks + const checkResponse = performChecks({ + context: ctx, + request, + response, + }); + if (!checkResponse.isSuccess) { + return checkResponse.response; } const dataClient = await ctx.elasticAssistant.getAIAssistantPromptsDataClient(); diff --git a/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts b/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts index 7898629e15b5c..d722e31cb2338 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts @@ -18,6 +18,7 @@ import { updateConversationRoute } from './user_conversations/update_route'; import { findUserConversationsRoute } from './user_conversations/find_route'; import { bulkActionConversationsRoute } from './user_conversations/bulk_actions_route'; import { appendConversationMessageRoute } from './user_conversations/append_conversation_messages_route'; +import { getKnowledgeBaseIndicesRoute } from './knowledge_base/get_knowledge_base_indices'; import { getKnowledgeBaseStatusRoute } from './knowledge_base/get_knowledge_base_status'; import { postKnowledgeBaseRoute } from './knowledge_base/post_knowledge_base'; import { getEvaluateRoute } from './evaluate/get_evaluate'; @@ -60,6 +61,7 @@ export const registerRoutes = ( findUserConversationsRoute(router); // Knowledge Base Setup + getKnowledgeBaseIndicesRoute(router); getKnowledgeBaseStatusRoute(router); postKnowledgeBaseRoute(router); diff --git a/x-pack/plugins/elastic_assistant/server/routes/request_context_factory.ts b/x-pack/plugins/elastic_assistant/server/routes/request_context_factory.ts index eeb1a5564d1cf..7d97029e7252a 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/request_context_factory.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/request_context_factory.ts @@ -101,6 +101,7 @@ export class RequestContextFactory implements IRequestContextFactory { return this.assistantService.createAIAssistantKnowledgeBaseDataClient({ spaceId: getSpaceId(), logger: this.logger, + licensing: context.licensing, currentUser, modelIdOverride, v2KnowledgeBaseEnabled, @@ -114,6 +115,7 @@ export class RequestContextFactory implements IRequestContextFactory { const currentUser = getCurrentUser(); return this.assistantService.createAttackDiscoveryDataClient({ spaceId: getSpaceId(), + licensing: context.licensing, logger: this.logger, currentUser, }); @@ -123,6 +125,7 @@ export class RequestContextFactory implements IRequestContextFactory { const currentUser = getCurrentUser(); return this.assistantService.createAIAssistantPromptsDataClient({ spaceId: getSpaceId(), + licensing: context.licensing, logger: this.logger, currentUser, }); @@ -132,6 +135,7 @@ export class RequestContextFactory implements IRequestContextFactory { const currentUser = getCurrentUser(); return this.assistantService.createAIAssistantAnonymizationFieldsDataClient({ spaceId: getSpaceId(), + licensing: context.licensing, logger: this.logger, currentUser, }); @@ -141,6 +145,7 @@ export class RequestContextFactory implements IRequestContextFactory { const currentUser = getCurrentUser(); return this.assistantService.createAIAssistantConversationsDataClient({ spaceId: getSpaceId(), + licensing: context.licensing, logger: this.logger, currentUser, }); diff --git a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/append_conversation_messages_route.ts b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/append_conversation_messages_route.ts index 796c0d617fe5d..06bfa023136d9 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/append_conversation_messages_route.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/append_conversation_messages_route.ts @@ -17,7 +17,7 @@ import { import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common'; import { buildResponse } from '../utils'; import { ElasticAssistantPluginRouter } from '../../types'; -import { UPGRADE_LICENSE_MESSAGE, hasAIAssistantLicense } from '../helpers'; +import { performChecks } from '../helpers'; export const appendConversationMessageRoute = (router: ElasticAssistantPluginRouter) => { router.versioned @@ -43,22 +43,16 @@ export const appendConversationMessageRoute = (router: ElasticAssistantPluginRou const { id } = request.params; try { const ctx = await context.resolve(['core', 'elasticAssistant', 'licensing']); - const license = ctx.licensing.license; - if (!hasAIAssistantLicense(license)) { - return response.forbidden({ - body: { - message: UPGRADE_LICENSE_MESSAGE, - }, - }); + const checkResponse = performChecks({ + context: ctx, + request, + response, + }); + if (!checkResponse.isSuccess) { + return checkResponse.response; } const dataClient = await ctx.elasticAssistant.getAIAssistantConversationsDataClient(); - const authenticatedUser = ctx.elasticAssistant.getCurrentUser(); - if (authenticatedUser == null) { - return assistantResponse.error({ - body: `Authenticated user not found`, - statusCode: 401, - }); - } + const authenticatedUser = checkResponse.currentUser; const existingConversation = await dataClient?.getConversation({ id, authenticatedUser }); if (existingConversation == null) { diff --git a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/bulk_actions_route.ts b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/bulk_actions_route.ts index 6e30acb1a47c7..9c353997f1d46 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/bulk_actions_route.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/bulk_actions_route.ts @@ -35,7 +35,7 @@ import { transformToUpdateScheme, } from '../../ai_assistant_data_clients/conversations/update_conversation'; import { EsConversationSchema } from '../../ai_assistant_data_clients/conversations/types'; -import { UPGRADE_LICENSE_MESSAGE, hasAIAssistantLicense } from '../helpers'; +import { performChecks } from '../helpers'; export interface BulkOperationError { message: string; @@ -156,23 +156,17 @@ export const bulkActionConversationsRoute = ( request.events.completed$.subscribe(() => abortController.abort()); try { const ctx = await context.resolve(['core', 'elasticAssistant', 'licensing']); - const license = ctx.licensing.license; - if (!hasAIAssistantLicense(license)) { - return response.forbidden({ - body: { - message: UPGRADE_LICENSE_MESSAGE, - }, - }); + const checkResponse = performChecks({ + context: ctx, + request, + response, + }); + if (!checkResponse.isSuccess) { + return checkResponse.response; } + const authenticatedUser = checkResponse.currentUser; const dataClient = await ctx.elasticAssistant.getAIAssistantConversationsDataClient(); const spaceId = ctx.elasticAssistant.getSpaceId(); - const authenticatedUser = ctx.elasticAssistant.getCurrentUser(); - if (authenticatedUser == null) { - return assistantResponse.error({ - body: `Authenticated user not found`, - statusCode: 401, - }); - } if (body.create && body.create.length > 0) { const userFilter = authenticatedUser?.username diff --git a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/create_route.ts b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/create_route.ts index ea056f81e7c6c..f98f76438b780 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/create_route.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/create_route.ts @@ -43,14 +43,12 @@ export const createConversationRoute = (router: ElasticAssistantPluginRouter): v const ctx = await context.resolve(['core', 'elasticAssistant', 'licensing']); // Perform license and authenticated user checks const checkResponse = performChecks({ - authenticatedUser: true, context: ctx, - license: true, request, response, }); - if (checkResponse) { - return checkResponse; + if (!checkResponse.isSuccess) { + return checkResponse.response; } const dataClient = await ctx.elasticAssistant.getAIAssistantConversationsDataClient(); diff --git a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/delete_route.ts b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/delete_route.ts index 5d761c09f682c..9c974fdb78de8 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/delete_route.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/delete_route.ts @@ -14,7 +14,7 @@ import { import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common'; import { ElasticAssistantPluginRouter } from '../../types'; import { buildResponse } from '../utils'; -import { UPGRADE_LICENSE_MESSAGE, hasAIAssistantLicense } from '../helpers'; +import { performChecks } from '../helpers'; export const deleteConversationRoute = (router: ElasticAssistantPluginRouter) => { router.versioned @@ -40,23 +40,18 @@ export const deleteConversationRoute = (router: ElasticAssistantPluginRouter) => const { id } = request.params; const ctx = await context.resolve(['core', 'elasticAssistant', 'licensing']); - const license = ctx.licensing.license; - if (!hasAIAssistantLicense(license)) { - return response.forbidden({ - body: { - message: UPGRADE_LICENSE_MESSAGE, - }, - }); + const checkResponse = performChecks({ + context: ctx, + request, + response, + }); + if (!checkResponse.isSuccess) { + return checkResponse.response; } const dataClient = await ctx.elasticAssistant.getAIAssistantConversationsDataClient(); - const authenticatedUser = ctx.elasticAssistant.getCurrentUser(); - if (authenticatedUser == null) { - return assistantResponse.error({ - body: `Authenticated user not found`, - statusCode: 401, - }); - } + const authenticatedUser = checkResponse.currentUser; + const existingConversation = await dataClient?.getConversation({ id, authenticatedUser }); if (existingConversation == null) { return assistantResponse.error({ diff --git a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/find_route.test.ts b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/find_route.test.ts index 63141fe5475a6..2b20ab03371f6 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/find_route.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/find_route.test.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { type AuthenticatedUser } from '@kbn/core/server'; import { getCurrentUserFindRequest, requestMock } from '../../__mocks__/request'; import { ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_FIND } from '@kbn/elastic-assistant-common'; import { serverMock } from '../../__mocks__/server'; @@ -15,7 +15,6 @@ import { findUserConversationsRoute } from './find_route'; describe('Find user conversations route', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); - beforeEach(async () => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); @@ -23,13 +22,13 @@ describe('Find user conversations route', () => { clients.elasticAssistant.getAIAssistantConversationsDataClient.findDocuments.mockResolvedValue( Promise.resolve(getFindConversationsResultWithSingleHit()) ); - clients.elasticAssistant.getCurrentUser.mockResolvedValue({ + context.elasticAssistant.getCurrentUser.mockReturnValue({ username: 'my_username', authentication_realm: { type: 'my_realm_type', name: 'my_realm_name', }, - }); + } as AuthenticatedUser); findUserConversationsRoute(server.router); }); diff --git a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/find_route.ts b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/find_route.ts index e7ce80039beb0..07ba23710b12c 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/find_route.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/find_route.ts @@ -21,7 +21,7 @@ import { ElasticAssistantPluginRouter } from '../../types'; import { buildResponse } from '../utils'; import { EsConversationSchema } from '../../ai_assistant_data_clients/conversations/types'; import { transformESSearchToConversations } from '../../ai_assistant_data_clients/conversations/transforms'; -import { UPGRADE_LICENSE_MESSAGE, hasAIAssistantLicense } from '../helpers'; +import { performChecks } from '../helpers'; export const findUserConversationsRoute = (router: ElasticAssistantPluginRouter) => { router.versioned @@ -46,16 +46,17 @@ export const findUserConversationsRoute = (router: ElasticAssistantPluginRouter) try { const { query } = request; const ctx = await context.resolve(['core', 'elasticAssistant', 'licensing']); - const license = ctx.licensing.license; - if (!hasAIAssistantLicense(license)) { - return response.forbidden({ - body: { - message: UPGRADE_LICENSE_MESSAGE, - }, - }); + // Perform license and authenticated user checks + const checkResponse = performChecks({ + context: ctx, + request, + response, + }); + if (!checkResponse.isSuccess) { + return checkResponse.response; } const dataClient = await ctx.elasticAssistant.getAIAssistantConversationsDataClient(); - const currentUser = ctx.elasticAssistant.getCurrentUser(); + const currentUser = checkResponse.currentUser; const additionalFilter = query.filter ? ` AND ${query.filter}` : ''; const userFilter = currentUser?.username diff --git a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/read_route.ts b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/read_route.ts index dd540897b0ece..ab69dc20999a2 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/read_route.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/read_route.ts @@ -16,7 +16,7 @@ import { ReadConversationRequestParams } from '@kbn/elastic-assistant-common/imp import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common'; import { buildResponse } from '../utils'; import { ElasticAssistantPluginRouter } from '../../types'; -import { UPGRADE_LICENSE_MESSAGE, hasAIAssistantLicense } from '../helpers'; +import { performChecks } from '../helpers'; export const readConversationRoute = (router: ElasticAssistantPluginRouter) => { router.versioned @@ -43,21 +43,15 @@ export const readConversationRoute = (router: ElasticAssistantPluginRouter) => { try { const ctx = await context.resolve(['core', 'elasticAssistant', 'licensing']); - const license = ctx.licensing.license; - if (!hasAIAssistantLicense(license)) { - return response.forbidden({ - body: { - message: UPGRADE_LICENSE_MESSAGE, - }, - }); - } - const authenticatedUser = ctx.elasticAssistant.getCurrentUser(); - if (authenticatedUser == null) { - return assistantResponse.error({ - body: `Authenticated user not found`, - statusCode: 401, - }); + const checkResponse = performChecks({ + context: ctx, + request, + response, + }); + if (!checkResponse.isSuccess) { + return checkResponse.response; } + const authenticatedUser = checkResponse.currentUser; const dataClient = await ctx.elasticAssistant.getAIAssistantConversationsDataClient(); const conversation = await dataClient?.getConversation({ id, authenticatedUser }); diff --git a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/update_route.ts b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/update_route.ts index 4ad819ef0caa0..41956b9bc80f7 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/update_route.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/update_route.ts @@ -45,18 +45,16 @@ export const updateConversationRoute = (router: ElasticAssistantPluginRouter) => const { id } = request.params; try { const ctx = await context.resolve(['core', 'elasticAssistant', 'licensing']); - const authenticatedUser = ctx.elasticAssistant.getCurrentUser(); // Perform license and authenticated user checks const checkResponse = performChecks({ - authenticatedUser: true, context: ctx, - license: true, request, response, }); - if (checkResponse) { - return checkResponse; + if (!checkResponse.isSuccess) { + return checkResponse.response; } + const authenticatedUser = checkResponse.currentUser; const dataClient = await ctx.elasticAssistant.getAIAssistantConversationsDataClient(); diff --git a/x-pack/plugins/elastic_assistant/tsconfig.json b/x-pack/plugins/elastic_assistant/tsconfig.json index 747a58ed930d3..d3436f28a1d3e 100644 --- a/x-pack/plugins/elastic_assistant/tsconfig.json +++ b/x-pack/plugins/elastic_assistant/tsconfig.json @@ -48,7 +48,8 @@ "@kbn/apm-utils", "@kbn/std", "@kbn/zod", - "@kbn/inference-plugin" + "@kbn/inference-plugin", + "@kbn/data-views-plugin" ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/embeddable_enhanced/kibana.jsonc b/x-pack/plugins/embeddable_enhanced/kibana.jsonc index 79c79ee89d649..d795afa4d7938 100644 --- a/x-pack/plugins/embeddable_enhanced/kibana.jsonc +++ b/x-pack/plugins/embeddable_enhanced/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/embeddable-enhanced-plugin", - "owner": "@elastic/kibana-presentation", + "owner": [ + "@elastic/kibana-presentation" + ], + "group": "platform", + "visibility": "private", "description": "Extends embeddable plugin with more functionality", "plugin": { "id": "embeddableEnhanced", - "server": false, "browser": true, + "server": false, "requiredPlugins": [ "embeddable", "kibanaReact", @@ -14,4 +18,4 @@ "uiActionsEnhanced" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/encrypted_saved_objects/kibana.jsonc b/x-pack/plugins/encrypted_saved_objects/kibana.jsonc index 7e0cc158363ec..41097c7b70f0e 100644 --- a/x-pack/plugins/encrypted_saved_objects/kibana.jsonc +++ b/x-pack/plugins/encrypted_saved_objects/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/encrypted-saved-objects-plugin", - "owner": "@elastic/kibana-security", + "owner": [ + "@elastic/kibana-security" + ], + "group": "platform", + "visibility": "shared", "description": "This plugin provides encryption and decryption utilities for saved objects containing sensitive information.", "plugin": { "id": "encryptedSavedObjects", - "server": true, "browser": false, + "server": true, "configPath": [ "xpack", "encryptedSavedObjects" @@ -15,4 +19,4 @@ "security" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.ts index d8ce2daa6efbe..bb07842e2bab5 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.ts @@ -16,6 +16,7 @@ export class EncryptedSavedObjectAttributesDefinition { public readonly attributesToEncrypt: ReadonlySet; private readonly attributesToIncludeInAAD: ReadonlySet | undefined; private readonly attributesToStrip: ReadonlySet; + public readonly enforceRandomId: boolean; constructor(typeRegistration: EncryptedSavedObjectTypeRegistration) { if (typeRegistration.attributesToIncludeInAAD) { @@ -49,6 +50,8 @@ export class EncryptedSavedObjectAttributesDefinition { } } + this.enforceRandomId = typeRegistration.enforceRandomId !== false; + this.attributesToEncrypt = attributesToEncrypt; this.attributesToStrip = attributesToStrip; this.attributesToIncludeInAAD = typeRegistration.attributesToIncludeInAAD; diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts index 1691d3f4c0610..67c972ec5f859 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts @@ -2405,3 +2405,24 @@ describe('#decryptAttributesSync', () => { }); }); }); + +describe('#shouldEnforceRandomId', () => { + it('defaults to true if enforceRandomId is undefined', () => { + service.registerType({ type: 'known-type-1', attributesToEncrypt: new Set(['attr']) }); + expect(service.shouldEnforceRandomId('known-type-1')).toBe(true); + }); + it('should return the value of enforceRandomId if it is defined', () => { + service.registerType({ + type: 'known-type-1', + attributesToEncrypt: new Set(['attr']), + enforceRandomId: false, + }); + service.registerType({ + type: 'known-type-2', + attributesToEncrypt: new Set(['attr']), + enforceRandomId: true, + }); + expect(service.shouldEnforceRandomId('known-type-1')).toBe(false); + expect(service.shouldEnforceRandomId('known-type-2')).toBe(true); + }); +}); diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts index 44072a0828d48..d2c7d9975a9ca 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts @@ -33,6 +33,7 @@ export interface EncryptedSavedObjectTypeRegistration { readonly type: string; readonly attributesToEncrypt: ReadonlySet; readonly attributesToIncludeInAAD?: ReadonlySet; + readonly enforceRandomId?: boolean; } /** @@ -152,6 +153,16 @@ export class EncryptedSavedObjectsService { return this.typeDefinitions.has(type); } + /** + * Checks whether the ESO type has explicitly opted out of enforcing random IDs. + * @param type Saved object type. + * @returns boolean - true unless explicitly opted out by setting enforceRandomId to false + */ + public shouldEnforceRandomId(type: string) { + const typeDefinition = this.typeDefinitions.get(type); + return typeDefinition?.enforceRandomId !== false; + } + /** * Takes saved object attributes for the specified type and, depending on the type definition, * either decrypts or strips encrypted attributes (e.g. in case AAD or encryption key has changed diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/saved_objects_encryption_extension.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/saved_objects_encryption_extension.ts index 01c35c7403fdf..45e0f6a46c892 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/saved_objects_encryption_extension.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/saved_objects_encryption_extension.ts @@ -40,6 +40,10 @@ export class SavedObjectsEncryptionExtension implements ISavedObjectsEncryptionE return this._service.isRegistered(type); } + shouldEnforceRandomId(type: string) { + return this._service.shouldEnforceRandomId(type); + } + async decryptOrStripResponseAttributes>( response: R, originalAttributes?: T diff --git a/x-pack/plugins/enterprise_search/common/constants.ts b/x-pack/plugins/enterprise_search/common/constants.ts index 67dfa03dc3705..797f94fa29e51 100644 --- a/x-pack/plugins/enterprise_search/common/constants.ts +++ b/x-pack/plugins/enterprise_search/common/constants.ts @@ -15,6 +15,10 @@ import { ENTERPRISE_SEARCH_ANALYTICS_APP_ID, ENTERPRISE_SEARCH_APPSEARCH_APP_ID, ENTERPRISE_SEARCH_WORKPLACESEARCH_APP_ID, + SEARCH_ELASTICSEARCH, + SEARCH_VECTOR_SEARCH, + SEARCH_SEMANTIC_SEARCH, + SEARCH_AI_SEARCH, } from '@kbn/deeplinks-search'; import { i18n } from '@kbn/i18n'; @@ -58,7 +62,7 @@ export const ENTERPRISE_SEARCH_CONTENT_PLUGIN = { }; export const AI_SEARCH_PLUGIN = { - ID: 'enterpriseSearchAISearch', + ID: SEARCH_AI_SEARCH, NAME: i18n.translate('xpack.enterpriseSearch.aiSearch.productName', { defaultMessage: 'AI Search', }), @@ -91,7 +95,7 @@ export const ANALYTICS_PLUGIN = { }; export const ELASTICSEARCH_PLUGIN = { - ID: 'enterpriseSearchElasticsearch', + ID: SEARCH_ELASTICSEARCH, NAME: i18n.translate('xpack.enterpriseSearch.elasticsearch.productName', { defaultMessage: 'Elasticsearch', }), @@ -167,7 +171,7 @@ export const VECTOR_SEARCH_PLUGIN = { defaultMessage: 'Elasticsearch can be used as a vector database, which enables vector search and semantic search use cases.', }), - ID: 'enterpriseSearchVectorSearch', + ID: SEARCH_VECTOR_SEARCH, LOGO: 'logoEnterpriseSearch', NAME: i18n.translate('xpack.enterpriseSearch.vectorSearch.productName', { defaultMessage: 'Vector Search', @@ -184,7 +188,7 @@ export const SEMANTIC_SEARCH_PLUGIN = { defaultMessage: 'Easily add semantic search to Elasticsearch with inference endpoints and the semantic_text field type, to boost search relevance.', }), - ID: 'enterpriseSearchSemanticSearch', + ID: SEARCH_SEMANTIC_SEARCH, LOGO: 'logoEnterpriseSearch', NAME: i18n.translate('xpack.enterpriseSearch.SemanticSearch.productName', { defaultMessage: 'Semantic Search', @@ -297,3 +301,14 @@ export const CRAWLER = { // TODO remove this once the connector service types are no longer in "example" state export const EXAMPLE_CONNECTOR_SERVICE_TYPES = ['opentext_documentum']; + +export const GETTING_STARTED_TITLE = i18n.translate('xpack.enterpriseSearch.gettingStarted.title', { + defaultMessage: 'Getting started', +}); + +export const SEARCH_APPS_BREADCRUMB = i18n.translate( + 'xpack.enterpriseSearch.searchApplications.breadcrumb', + { + defaultMessage: 'Search Applications', + } +); diff --git a/x-pack/plugins/enterprise_search/kibana.jsonc b/x-pack/plugins/enterprise_search/kibana.jsonc index f631bd2dc53d1..14a36c85c6c87 100644 --- a/x-pack/plugins/enterprise_search/kibana.jsonc +++ b/x-pack/plugins/enterprise_search/kibana.jsonc @@ -2,6 +2,9 @@ "type": "plugin", "id": "@kbn/enterprise-search-plugin", "owner": "@elastic/search-kibana", + // Could be categorised as Search in the future, but it currently needs to run in Observability too + "group": "platform", + "visibility": "shared", "description": "Adds dashboards for discovering and managing Enterprise Search products.", "plugin": { "id": "enterpriseSearch", diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea_logic/kibana_logic.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea_logic/kibana_logic.mock.ts index cca5523ded681..9b37c661d923a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea_logic/kibana_logic.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea_logic/kibana_logic.mock.ts @@ -41,6 +41,7 @@ export const mockKibanaValues = { data: dataPluginMock.createStartContract(), esConfig: { elasticsearch_host: 'https://your_deployment_url' }, getChromeStyle$: jest.fn().mockReturnValue(of('classic')), + getNavLinks: jest.fn().mockReturnValue([]), guidedOnboarding: {}, history: mockHistory, indexMappingComponent: null, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.tsx index c78bf3e918737..bf8cf009759d0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.tsx @@ -11,7 +11,7 @@ import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { API_DOCS_URL } from '../../../routes'; +import { docLinks } from '../../../../shared/doc_links'; export const EmptyState: React.FC = () => ( (

} actions={ - + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.empty.buttonLabel', { defaultMessage: 'View the API reference', })} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_rules_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_rules_table.tsx index bef8ed4462fdc..2f66dc455442e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_rules_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_rules_table.tsx @@ -23,11 +23,11 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { docLinks } from '../../../../shared/doc_links'; import { clearFlashMessages, flashSuccessToast } from '../../../../shared/flash_messages'; import { GenericEndpointInlineEditableTable } from '../../../../shared/tables/generic_endpoint_inline_editable_table'; import { InlineEditableTableColumn } from '../../../../shared/tables/inline_editable_table/types'; import { ItemWithAnID } from '../../../../shared/tables/types'; -import { CRAWL_RULES_DOCS_URL } from '../../../routes'; import { CrawlerSingleDomainLogic } from '../crawler_single_domain_logic'; import { CrawlerPolicies, @@ -53,7 +53,7 @@ const DEFAULT_DESCRIPTION = ( defaultMessage="Create a crawl rule to include or exclude pages whose URL matches the rule. Rules run in sequential order, and each URL is evaluated according to the first match. {link}" values={{ link: ( - + {i18n.translate( 'xpack.enterpriseSearch.appSearch.crawler.crawlRulesTable.descriptionLinkText', { defaultMessage: 'Learn more about crawl rules' } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/deduplication_panel/deduplication_panel.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/deduplication_panel/deduplication_panel.tsx index 26794d0421353..ef4d7448a5785 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/deduplication_panel/deduplication_panel.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/deduplication_panel/deduplication_panel.tsx @@ -27,7 +27,7 @@ import { EuiSelectableLIOption } from '@elastic/eui/src/components/selectable/se import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { DUPLICATE_DOCS_URL } from '../../../../routes'; +import { docLinks } from '../../../../../shared/doc_links'; import { DataPanel } from '../../../data_panel'; import { CrawlerSingleDomainLogic } from '../../crawler_single_domain_logic'; @@ -84,7 +84,7 @@ export const DeduplicationPanel: React.FC = () => { documents on this domain. {documentationLink}." values={{ documentationLink: ( - + {i18n.translate( 'xpack.enterpriseSearch.appSearch.crawler.deduplicationPanel.learnMoreMessage', { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/entry_points_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/entry_points_table.tsx index 4fc7a0569ba0e..d4bfdf77704b8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/entry_points_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/entry_points_table.tsx @@ -14,10 +14,10 @@ import { EuiFieldText, EuiLink, EuiSpacer, EuiText, EuiTitle } from '@elastic/eu import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { docLinks } from '../../../../shared/doc_links'; import { GenericEndpointInlineEditableTable } from '../../../../shared/tables/generic_endpoint_inline_editable_table'; import { InlineEditableTableColumn } from '../../../../shared/tables/inline_editable_table/types'; import { ItemWithAnID } from '../../../../shared/tables/types'; -import { ENTRY_POINTS_DOCS_URL } from '../../../routes'; import { CrawlerDomain, EntryPoint } from '../types'; import { EntryPointsTableLogic } from './entry_points_table_logic'; @@ -80,7 +80,7 @@ export const EntryPointsTable: React.FC = ({ defaultMessage: 'Include the most important URLs for your website here. Entry point URLs will be the first pages to be indexed and processed for links to other pages.', })}{' '} - + {i18n.translate( 'xpack.enterpriseSearch.appSearch.crawler.entryPointsTable.learnMoreLinkText', { defaultMessage: 'Learn more about entry points.' } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/manage_crawls_popover/automatic_crawl_scheduler.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/manage_crawls_popover/automatic_crawl_scheduler.tsx index 4533ca04c75bc..cb0377a471b93 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/manage_crawls_popover/automatic_crawl_scheduler.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/manage_crawls_popover/automatic_crawl_scheduler.tsx @@ -36,8 +36,8 @@ import { MONTHS_UNIT_LABEL, WEEKS_UNIT_LABEL, } from '../../../../../shared/constants/units'; +import { docLinks } from '../../../../../shared/doc_links'; -import { WEB_CRAWLER_DOCS_URL } from '../../../../routes'; import { CrawlUnits } from '../../types'; import { AutomaticCrawlSchedulerLogic } from './automatic_crawl_scheduler_logic'; @@ -81,7 +81,7 @@ export const AutomaticCrawlScheduler: React.FC = () => { defaultMessage="Don't worry about it, we'll start a crawl for you. {readMoreMessage}." values={{ readMoreMessage: ( - + {i18n.translate( 'xpack.enterpriseSearch.appSearch.crawler.automaticCrawlSchedule.readMoreLink', { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx index 13a13c25a5ad8..d18f40c4c9f23 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx @@ -13,7 +13,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiSpacer, EuiText, EuiTitle } from import { i18n } from '@kbn/i18n'; -import { WEB_CRAWLER_DOCS_URL, WEB_CRAWLER_LOG_DOCS_URL } from '../../routes'; +import { docLinks } from '../../../shared/doc_links'; import { getEngineBreadcrumbs } from '../engine'; import { AppSearchPageTemplate } from '../layout'; @@ -82,7 +82,7 @@ export const CrawlerOverview: React.FC = () => { defaultMessage: "Easily index your website's content. To get started, enter your domain name, provide optional entry points and crawl rules, and we will handle the rest.", })}{' '} - + {i18n.translate( 'xpack.enterpriseSearch.appSearch.crawler.empty.crawlerDocumentationLinkDescription', { @@ -125,7 +125,7 @@ export const CrawlerOverview: React.FC = () => { defaultMessage: "Recent crawl requests are logged here. Using the request ID of each crawl, you can track progress and examine crawl events in Kibana's Discover or Logs user interfaces.", })}{' '} - + {i18n.translate( 'xpack.enterpriseSearch.appSearch.crawler.configurationDocumentationLinkDescription', { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/constants.ts index 9676e7f859ac5..7468597294026 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/constants.ts @@ -7,8 +7,6 @@ import { i18n } from '@kbn/i18n'; -import { AUTHENTICATION_DOCS_URL } from '../../routes'; - export const CREDENTIALS_TITLE = i18n.translate( 'xpack.enterpriseSearch.appSearch.credentials.title', { defaultMessage: 'Credentials' } @@ -108,5 +106,3 @@ export const TOKEN_TYPE_INFO = [ ]; export const FLYOUT_ARIA_LABEL_ID = 'credentialsFlyoutTitle'; - -export const DOCS_HREF = AUTHENTICATION_DOCS_URL; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_flyout/form_components/key_type.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_flyout/form_components/key_type.tsx index 2cf381d8f604f..5213f786dfead 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_flyout/form_components/key_type.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_flyout/form_components/key_type.tsx @@ -12,8 +12,10 @@ import { useValues, useActions } from 'kea'; import { EuiFormRow, EuiSelect, EuiText, EuiLink } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { docLinks } from '../../../../../shared/doc_links'; + import { AppLogic } from '../../../../app_logic'; -import { TOKEN_TYPE_DESCRIPTION, TOKEN_TYPE_INFO, DOCS_HREF } from '../../constants'; +import { TOKEN_TYPE_DESCRIPTION, TOKEN_TYPE_INFO } from '../../constants'; import { CredentialsLogic } from '../../credentials_logic'; export const FormKeyType: React.FC = () => { @@ -36,7 +38,7 @@ export const FormKeyType: React.FC = () => {

{tokenDescription}{' '} - + {i18n.translate('xpack.enterpriseSearch.appSearch.credentials.documentationLink1', { defaultMessage: 'Visit the documentation', })} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_list/credentials_list.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_list/credentials_list.tsx index e351cdf36c657..4f45da9e26046 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_list/credentials_list.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_list/credentials_list.tsx @@ -19,9 +19,9 @@ import { import { i18n } from '@kbn/i18n'; import { EDIT_BUTTON_LABEL, DELETE_BUTTON_LABEL } from '../../../../shared/constants'; +import { docLinks } from '../../../../shared/doc_links'; import { HiddenText } from '../../../../shared/hidden_text'; import { convertMetaToPagination, handlePageChange } from '../../../../shared/table_pagination'; -import { API_KEYS_DOCS_URL } from '../../../routes'; import { TOKEN_TYPE_DISPLAY_NAMES } from '../constants'; import { CredentialsLogic } from '../credentials_logic'; import { ApiToken } from '../types'; @@ -141,7 +141,7 @@ export const CredentialsList: React.FC = () => { defaultMessage: 'Allow applications to access Elastic App Search on your behalf.', })} actions={ - + {i18n.translate('xpack.enterpriseSearch.appSearch.credentials.empty.buttonLabel', { defaultMessage: 'Learn about API keys', })} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/empty_state.tsx index 10d81f1623959..363da83d56aac 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/empty_state.tsx @@ -10,7 +10,7 @@ import React from 'react'; import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { CURATIONS_DOCS_URL } from '../../../routes'; +import { docLinks } from '../../../../shared/doc_links'; export const EmptyState: React.FC = () => ( (

} actions={ - + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.curations.empty.buttonLabel', { defaultMessage: 'Read the curations guide', })} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/api_code_example.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/api_code_example.tsx index 7bbe276aedf69..98da6dc88ef57 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/api_code_example.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/api_code_example.tsx @@ -30,8 +30,8 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { DocumentCreationLogic } from '..'; import { CANCEL_BUTTON_LABEL } from '../../../../shared/constants'; +import { docLinks } from '../../../../shared/doc_links'; import { getEnterpriseSearchUrl } from '../../../../shared/enterprise_search_url'; -import { API_CLIENTS_DOCS_URL, INDEXING_DOCS_URL } from '../../../routes'; import { EngineLogic } from '../../engine'; import { EngineDetails } from '../../engine/types'; @@ -74,12 +74,12 @@ export const FlyoutBody: React.FC = () => { defaultMessage="The {documentsApiLink} can be used to add new documents to your engine, update documents, retrieve documents by id, and delete documents. There are a variety of {clientLibrariesLink} to help you get started." values={{ documentsApiLink: ( - + documents API ), clientLibrariesLink: ( - + client libraries ), diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.tsx index 07de7b3ec0c34..80e087e007671 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.tsx @@ -26,9 +26,10 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { docLinks } from '../../../shared/doc_links'; import { parseQueryParams } from '../../../shared/query_params'; import { EuiCardTo } from '../../../shared/react_router_helpers'; -import { INDEXING_DOCS_URL, ENGINE_CRAWLER_PATH } from '../../routes'; +import { ENGINE_CRAWLER_PATH } from '../../routes'; import { generateEnginePath } from '../engine'; import illustration from './illustration.svg'; @@ -106,7 +107,7 @@ export const DocumentCreationButtons: React.FC = ({ )} {' '} - + {i18n.translate( 'xpack.enterpriseSearch.appSearch.documentCreation.buttons.emptyStateFooterLink', { defaultMessage: 'Read documentation' } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/components/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/components/empty_state.tsx index 85e834b320751..a311899d380e9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/components/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/components/empty_state.tsx @@ -10,7 +10,7 @@ import React from 'react'; import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { INDEXING_DOCS_URL } from '../../../routes'; +import { docLinks } from '../../../../shared/doc_links'; export const EmptyState = () => ( (

} actions={ - + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.documents.empty.buttonLabel', { defaultMessage: 'Read the documents guide', })} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx index e31a17406ffd3..e4bcf810d58f8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx @@ -12,8 +12,8 @@ import { useValues } from 'kea'; import { EuiButton, EuiEmptyPrompt, EuiImage, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { docLinks } from '../../../shared/doc_links'; import { EuiButtonTo } from '../../../shared/react_router_helpers'; -import { DOCS_URL } from '../../routes'; import { DocumentCreationButtons, DocumentCreationFlyout } from '../document_creation'; import illustration from '../document_creation/illustration.svg'; @@ -85,7 +85,7 @@ export const EmptyEngineOverview: React.FC = () => { { defaultMessage: 'Engine setup' } ), rightSideItems: [ - + {i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.overview.empty.headingAction', { defaultMessage: 'View documentation' } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_meta_engines_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_meta_engines_state.tsx index 3cf461e3f7d45..0824997ba8896 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_meta_engines_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_meta_engines_state.tsx @@ -10,7 +10,7 @@ import React from 'react'; import { EuiEmptyPrompt, EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { META_ENGINES_DOCS_URL } from '../../../routes'; +import { docLinks } from '../../../../shared/doc_links'; export const EmptyMetaEnginesState: React.FC = () => ( (

} actions={ - + {i18n.translate( 'xpack.enterpriseSearch.appSearch.engines.metaEngines.emptyPromptButtonLabel', { defaultMessage: 'Learn more about meta engines' } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/constants.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/constants.tsx index e30868beeb209..8b32ffe77e701 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/constants.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/constants.tsx @@ -11,7 +11,7 @@ import { EuiLink } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { META_ENGINES_DOCS_URL } from '../../routes'; +import { docLinks } from '../../../shared/doc_links'; export const DEFAULT_LANGUAGE = 'Universal'; @@ -57,7 +57,7 @@ export const META_ENGINE_CREATION_FORM_DOCUMENTATION_DESCRIPTION = ( defaultMessage="{documentationLink} for information about how to get started." values={{ documentationLink: ( - + {META_ENGINE_CREATION_FORM_DOCUMENTATION_LINK} ), diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/components/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/components/empty_state.tsx index f17f7a582efdf..b792dec2cba0e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/components/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/components/empty_state.tsx @@ -10,7 +10,7 @@ import React from 'react'; import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { RELEVANCE_DOCS_URL } from '../../../routes'; +import { docLinks } from '../../../../shared/doc_links'; export const EmptyState: React.FC = () => ( ( } )} actions={ - + {i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.empty.buttonLabel', { defaultMessage: 'Read the relevance tuning guide' } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/components/precision_slider/precision_slider.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/components/precision_slider/precision_slider.tsx index e4b2027aa3d6d..d78949d0fbe74 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/components/precision_slider/precision_slider.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/components/precision_slider/precision_slider.tsx @@ -21,7 +21,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { PRECISION_DOCS_URL } from '../../../../routes'; +import { docLinks } from '../../../../../shared/doc_links'; import { RelevanceTuningLogic } from '../../relevance_tuning_logic'; import { STEP_DESCRIPTIONS } from './constants'; @@ -57,7 +57,11 @@ export const PrecisionSlider: React.FC = () => { defaultMessage: 'Fine tune the precision vs. recall settings on your engine.', } )}{' '} - + {i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.precisionSlider.learnMore.link', { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_callouts.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_callouts.tsx index bf2c21a1003f5..ef0bea39439c5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_callouts.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_callouts.tsx @@ -13,8 +13,9 @@ import { EuiCallOut, EuiLink } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { docLinks } from '../../../shared/doc_links'; import { EuiLinkTo } from '../../../shared/react_router_helpers'; -import { META_ENGINES_DOCS_URL, ENGINE_SCHEMA_PATH } from '../../routes'; +import { ENGINE_SCHEMA_PATH } from '../../routes'; import { EngineLogic, generateEnginePath } from '../engine'; import { RelevanceTuningLogic } from '.'; @@ -98,7 +99,7 @@ export const RelevanceTuningCallouts: React.FC = () => { values={{ schemaFieldsWithConflictsCount, link: ( - + {i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.whatsThisLinkLabel', { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/components/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/components/empty_state.tsx index 7f91447b910b6..6434a877ead5e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/components/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/components/empty_state.tsx @@ -10,7 +10,7 @@ import React from 'react'; import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { RESULT_SETTINGS_DOCS_URL } from '../../../routes'; +import { docLinks } from '../../../../shared/doc_links'; export const EmptyState: React.FC = () => ( ( } )} actions={ - + {i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.resultSettings.empty.buttonLabel', { defaultMessage: 'Read the result settings guide' } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx index aff138b9c3884..2ffe6cb357e54 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx @@ -12,6 +12,7 @@ import { useActions, useValues } from 'kea'; import { EuiSpacer } from '@elastic/eui'; import { APP_SEARCH_PLUGIN } from '../../../../../common/constants'; +import { docLinks } from '../../../shared/doc_links'; import { RoleMappingsTable, RoleMappingsHeading, @@ -22,7 +23,6 @@ import { } from '../../../shared/role_mapping'; import { ROLE_MAPPINGS_TITLE } from '../../../shared/role_mapping/constants'; -import { SECURITY_DOCS_URL } from '../../routes'; import { AppSearchPageTemplate } from '../layout'; import { ROLE_MAPPINGS_ENGINE_ACCESS_HEADING } from './constants'; @@ -57,7 +57,7 @@ export const RoleMappings: React.FC = () => { const rolesEmptyState = ( ); @@ -66,7 +66,7 @@ export const RoleMappings: React.FC = () => {
initializeRoleMapping()} /> { @@ -40,7 +40,12 @@ export const EmptyState: React.FC = () => {

} actions={ - + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.schema.empty.buttonLabel', { defaultMessage: 'Read the indexing schema guide', })} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/empty_state.tsx index 9a663e1372211..e5b0f2facedbd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/empty_state.tsx @@ -10,7 +10,7 @@ import React from 'react'; import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { SEARCH_UI_DOCS_URL } from '../../../routes'; +import { docLinks } from '../../../../shared/doc_links'; export const EmptyState: React.FC = () => ( (

} actions={ - + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.searchUI.empty.buttonLabel', { defaultMessage: 'Read the Search UI guide', })} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.tsx index d7398357a5e58..cfef71d34fb9f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.tsx @@ -12,7 +12,7 @@ import { useActions, useValues } from 'kea'; import { EuiText, EuiFlexItem, EuiFlexGroup, EuiSpacer, EuiLink } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { SEARCH_UI_DOCS_URL } from '../../routes'; +import { docLinks } from '../../../shared/doc_links'; import { EngineLogic, getEngineBreadcrumbs } from '../engine'; import { AppSearchPageTemplate } from '../layout'; @@ -63,7 +63,7 @@ export const SearchUI: React.FC = () => { defaultMessage="Use the fields below to generate a sample search experience built with Search UI. Use the sample to preview search results, or build upon it to create your own custom search experience. {link}." values={{ link: ( - + { defaultMessage: 'Log retention is determined by the ILM policies for your deployment.', })}
- + {i18n.translate('xpack.enterpriseSearch.appSearch.settings.logRetention.learnMore', { defaultMessage: 'Learn more about log retention for Enterprise Search.', })} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/empty_state.tsx index ef5e1dafa443f..ff7e8ce16c6d2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/empty_state.tsx @@ -10,7 +10,7 @@ import React from 'react'; import { EuiEmptyPrompt, EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { SYNONYMS_DOCS_URL } from '../../../routes'; +import { docLinks } from '../../../../shared/doc_links'; import { SynonymModal, SynonymIcon } from '.'; @@ -35,7 +35,7 @@ export const EmptyState: React.FC = () => {

} actions={ - + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.synonyms.empty.buttonLabel', { defaultMessage: 'Read the synonyms guide', })} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts index 1a41004c882e3..128af5adacfad 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts @@ -5,30 +5,6 @@ * 2.0. */ -import { docLinks } from '../shared/doc_links'; - -export const API_DOCS_URL = docLinks.appSearchApis; -export const API_CLIENTS_DOCS_URL = docLinks.appSearchApiClients; -export const API_KEYS_DOCS_URL = docLinks.appSearchApiKeys; -export const AUTHENTICATION_DOCS_URL = docLinks.appSearchAuthentication; -export const CRAWL_RULES_DOCS_URL = docLinks.appSearchCrawlRules; -export const CURATIONS_DOCS_URL = docLinks.appSearchCurations; -export const DOCS_URL = docLinks.appSearchGuide; -export const DUPLICATE_DOCS_URL = docLinks.appSearchDuplicateDocuments; -export const ENTRY_POINTS_DOCS_URL = docLinks.appSearchEntryPoints; -export const INDEXING_DOCS_URL = docLinks.appSearchIndexingDocs; -export const INDEXING_SCHEMA_DOCS_URL = docLinks.appSearchIndexingDocsSchema; -export const LOG_SETTINGS_DOCS_URL = docLinks.appSearchLogSettings; -export const META_ENGINES_DOCS_URL = docLinks.appSearchMetaEngines; -export const PRECISION_DOCS_URL = docLinks.appSearchPrecision; -export const RELEVANCE_DOCS_URL = docLinks.appSearchRelevance; -export const RESULT_SETTINGS_DOCS_URL = docLinks.appSearchResultSettings; -export const SEARCH_UI_DOCS_URL = docLinks.appSearchSearchUI; -export const SECURITY_DOCS_URL = docLinks.appSearchSecurity; -export const SYNONYMS_DOCS_URL = docLinks.appSearchSynonyms; -export const WEB_CRAWLER_DOCS_URL = docLinks.appSearchWebCrawler; -export const WEB_CRAWLER_LOG_DOCS_URL = docLinks.appSearchWebCrawlerEventLogs; - export const ROOT_PATH = '/'; export const SETUP_GUIDE_PATH = '/setup_guide'; export const LIBRARY_PATH = '/library'; diff --git a/x-pack/plugins/enterprise_search/public/applications/applications/components/playground/page_template.tsx b/x-pack/plugins/enterprise_search/public/applications/applications/components/playground/page_template.tsx new file mode 100644 index 0000000000000..40698b273730b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/applications/components/playground/page_template.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useLayoutEffect } from 'react'; + +import { useValues } from 'kea'; + +import useObservable from 'react-use/lib/useObservable'; + +import { SEARCH_PRODUCT_NAME } from '../../../../../common/constants'; +import { KibanaLogic } from '../../../shared/kibana'; +import { SetSearchPlaygroundChrome } from '../../../shared/kibana_chrome/set_chrome'; +import { EnterpriseSearchPageTemplateWrapper, PageTemplateProps } from '../../../shared/layout'; +import { useEnterpriseSearchNav } from '../../../shared/layout'; +import { SendEnterpriseSearchTelemetry } from '../../../shared/telemetry'; + +import { PlaygroundHeaderDocsAction } from './header_docs_action'; + +export type SearchPlaygroundPageTemplateProps = Omit< + PageTemplateProps, + 'useEndpointHeaderActions' +> & { + hasSchemaConflicts?: boolean; + restrictWidth?: boolean; + searchApplicationName?: string; +}; + +export const SearchPlaygroundPageTemplate: React.FC = ({ + children, + pageChrome, + pageViewTelemetry, + searchApplicationName, + hasSchemaConflicts, + restrictWidth = true, + ...pageTemplateProps +}) => { + const navItems = useEnterpriseSearchNav(); + + const { renderHeaderActions, getChromeStyle$ } = useValues(KibanaLogic); + const chromeStyle = useObservable(getChromeStyle$(), 'classic'); + + useLayoutEffect(() => { + renderHeaderActions(PlaygroundHeaderDocsAction); + + return () => { + renderHeaderActions(); + }; + }, []); + + return ( + } + useEndpointHeaderActions={false} + > + {pageViewTelemetry && ( + + )} + {children} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/applications/components/playground/playground.tsx b/x-pack/plugins/enterprise_search/public/applications/applications/components/playground/playground.tsx index b117518d3a6e0..c198062cb759b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/applications/components/playground/playground.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/applications/components/playground/playground.tsx @@ -12,9 +12,14 @@ import { useValues } from 'kea'; import { i18n } from '@kbn/i18n'; import { KibanaLogic } from '../../../shared/kibana'; -import { EnterpriseSearchApplicationsPageTemplate } from '../layout/page_template'; -export const Playground: React.FC = () => { +import { SearchPlaygroundPageTemplate } from './page_template'; + +interface PlaygroundProps { + pageMode?: 'chat' | 'search'; +} + +export const Playground: React.FC = ({ pageMode = 'chat' }) => { const { searchPlayground } = useValues(KibanaLogic); if (!searchPlayground) { @@ -22,7 +27,7 @@ export const Playground: React.FC = () => { } return ( - { panelled={false} customPageSections bottomBorder="extended" - docLink="playground" > - - + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/applications/index.tsx b/x-pack/plugins/enterprise_search/public/applications/applications/index.tsx index c9676137e70f2..a04ebf2e3edbb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/applications/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/applications/index.tsx @@ -13,7 +13,13 @@ import { Routes, Route } from '@kbn/shared-ux-router'; import { NotFound } from './components/not_found'; import { Playground } from './components/playground/playground'; import { SearchApplicationsRouter } from './components/search_applications/search_applications_router'; -import { PLAYGROUND_PATH, ROOT_PATH, SEARCH_APPLICATIONS_PATH } from './routes'; +import { + PLAYGROUND_CHAT_PATH, + PLAYGROUND_PATH, + PLAYGROUND_SEARCH_PATH, + ROOT_PATH, + SEARCH_APPLICATIONS_PATH, +} from './routes'; export const Applications = () => { return ( @@ -22,8 +28,12 @@ export const Applications = () => { - - + + + + + + diff --git a/x-pack/plugins/enterprise_search/public/applications/applications/routes.ts b/x-pack/plugins/enterprise_search/public/applications/applications/routes.ts index 5779f544c3f34..2df42a129938c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/applications/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/applications/routes.ts @@ -17,7 +17,9 @@ export enum SearchApplicationViewTabs { export const SEARCH_APPLICATION_CREATION_PATH = `${SEARCH_APPLICATIONS_PATH}/new`; export const SEARCH_APPLICATION_PATH = `${SEARCH_APPLICATIONS_PATH}/:searchApplicationName`; export const SEARCH_APPLICATION_TAB_PATH = `${SEARCH_APPLICATION_PATH}/:tabId`; -export const PLAYGROUND_PATH = `${ROOT_PATH}playground`; +export const PLAYGROUND_PATH = `${ROOT_PATH}playground/`; +export const PLAYGROUND_CHAT_PATH = `${PLAYGROUND_PATH}chat`; +export const PLAYGROUND_SEARCH_PATH = `${PLAYGROUND_PATH}search`; export const SEARCH_APPLICATION_CONNECT_PATH = `${SEARCH_APPLICATION_PATH}/${SearchApplicationViewTabs.CONNECT}/:connectTabId`; export enum SearchApplicationConnectTabs { diff --git a/x-pack/plugins/enterprise_search/public/applications/elasticsearch/components/elasticsearch_guide/elasticsearch_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/elasticsearch/components/elasticsearch_guide/elasticsearch_guide.tsx index 80a8de9acdc21..be470577cd519 100644 --- a/x-pack/plugins/enterprise_search/public/applications/elasticsearch/components/elasticsearch_guide/elasticsearch_guide.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/elasticsearch/components/elasticsearch_guide/elasticsearch_guide.tsx @@ -40,7 +40,7 @@ export const ElasticsearchGuide = () => { }, []); return ( - + {isFlyoutOpen && setIsFlyoutOpen(false)} />}

diff --git a/x-pack/plugins/enterprise_search/public/applications/elasticsearch/components/layout/page_template.tsx b/x-pack/plugins/enterprise_search/public/applications/elasticsearch/components/layout/page_template.tsx index 7f2eded8a6565..c5c777cb74773 100644 --- a/x-pack/plugins/enterprise_search/public/applications/elasticsearch/components/layout/page_template.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/elasticsearch/components/layout/page_template.tsx @@ -19,13 +19,14 @@ export const EnterpriseSearchElasticsearchPageTemplate: React.FC { + const navItems = useEnterpriseSearchNav(); return ( } > diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/generated_config_fields.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/generated_config_fields.tsx index 133c15f97f61c..53cbf579a940f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/generated_config_fields.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/generated_config_fields.tsx @@ -156,6 +156,10 @@ export const GeneratedConfigFields: React.FC = ({ data-test-subj="enterpriseSearchConnectorDeploymentButton" iconType="copyClipboard" onClick={copy} + aria-label={i18n.translate( + 'xpack.enterpriseSearch.connectorDeployment.copyConnectorId', + { defaultMessage: 'Copy connector ID' } + )} /> )} @@ -237,6 +241,10 @@ export const GeneratedConfigFields: React.FC = ({ isLoading={isGenerateLoading} onClick={refreshButtonClick} disabled={!connector.index_name} + aria-label={i18n.translate( + 'xpack.enterpriseSearch.connectorDeployment.refreshAPIKey', + { defaultMessage: 'Refresh an Elasticsearch API key' } + )} /> )} @@ -246,6 +254,10 @@ export const GeneratedConfigFields: React.FC = ({ data-test-subj="enterpriseSearchConnectorDeploymentButton" iconType="copyClipboard" onClick={copy} + aria-label={i18n.translate( + 'xpack.enterpriseSearch.connectorDeployment.copyIndexName', + { defaultMessage: 'Copy index name' } + )} /> diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/create_connector.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/create_connector.tsx index a4ed43e2a8fcd..6e83bf98c2371 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/create_connector.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/create_connector.tsx @@ -67,10 +67,8 @@ export const CreateConnector: React.FC = () => { useEffect(() => { // TODO: separate this to ability and preference - if (!selectedConnector?.isNative || !selfManagePreference) { + if (selectedConnector && !selectedConnector.isNative && selfManagePreference === 'native') { setSelfManagePreference('selfManaged'); - } else { - setSelfManagePreference('native'); } }, [selectedConnector]); @@ -276,11 +274,11 @@ export const CreateConnector: React.FC = () => { - {selfManagePreference + {selfManagePreference === 'selfManaged' ? i18n.translate( 'xpack.enterpriseSearch.createConnector.badgeType.selfManaged', { - defaultMessage: 'Self managed', + defaultMessage: 'Self-managed', } ) : i18n.translate( diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/start_step.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/start_step.tsx index 9a93b43f6b751..7e23474b207f1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/start_step.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/start_step.tsx @@ -27,6 +27,7 @@ import { import { i18n } from '@kbn/i18n'; import * as Constants from '../../../../shared/constants'; +import { isValidIndexName } from '../../../utils/validate_index_name'; import { GeneratedConfigFields } from '../../connector_detail/components/generated_config_fields'; import { ConnectorViewLogic } from '../../connector_detail/connector_view_logic'; @@ -71,6 +72,18 @@ export const StartStep: React.FC = ({ setRawName(e.target.value); }; + const formError = isValidIndexName(rawName) + ? error + : i18n.translate( + 'xpack.enterpriseSearch.createConnector.startStep.euiFormRow.nameInputHelpText.lineOne', + { + defaultMessage: '{connectorName} is an invalid index name', + values: { + connectorName: rawName, + }, + } + ); + return ( @@ -100,6 +113,22 @@ export const StartStep: React.FC = ({ 'xpack.enterpriseSearch.createConnector.startStep.euiFormRow.connectorNameLabel', { defaultMessage: 'Connector name' } )} + helpText={ + <> + + {formError} + + + {i18n.translate( + 'xpack.enterpriseSearch.startStep.namesShouldBeLowercaseTextLabel', + { + defaultMessage: + 'The connector name should be lowercase and cannot contain spaces or special characters.', + } + )} + + + } > = ({ 'xpack.enterpriseSearch.createConnector.startStep.euiFormRow.descriptionLabel', { defaultMessage: 'Description' } )} + labelAppend={ + + {i18n.translate( + 'xpack.enterpriseSearch.createConnector.startStep.euiFormRow.descriptionLabelAppend', + { defaultMessage: 'Optional' } + )} + + } > = ({ hasShadow={false} hasBorder paddingSize="l" - color={selectedConnector?.name ? 'plain' : 'subdued'} + color={ + selectedConnector?.name && isValidIndexName(rawName) && !error ? 'plain' : 'subdued' + } > - +

{i18n.translate( 'xpack.enterpriseSearch.createConnector.startStep.h4.deploymentLabel', @@ -218,7 +263,10 @@ export const StartStep: React.FC = ({

- +

{i18n.translate( 'xpack.enterpriseSearch.createConnector.startStep.p.youWillStartTheLabel', @@ -242,7 +290,7 @@ export const StartStep: React.FC = ({ } }} fill - disabled={!canConfigureConnector} + disabled={!canConfigureConnector || !isValidIndexName(rawName) || Boolean(error)} isLoading={isCreateLoading || isGenerateLoading} > {Constants.NEXT_BUTTON_LABEL} @@ -252,12 +300,20 @@ export const StartStep: React.FC = ({ ) : ( - +

{i18n.translate( 'xpack.enterpriseSearch.createConnector.startStep.h4.configureIndexAndAPILabel', @@ -268,7 +324,14 @@ export const StartStep: React.FC = ({

- +

{i18n.translate( 'xpack.enterpriseSearch.createConnector.startStep.p.thisProcessWillCreateLabel', @@ -309,7 +372,9 @@ export const StartStep: React.FC = ({ = ({ diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/new_index/method_connector/new_connector_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/new_index/method_connector/new_connector_logic.ts index 0d21db6e03baf..0c8a81d90149a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/new_index/method_connector/new_connector_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/new_index/method_connector/new_connector_logic.ts @@ -7,8 +7,7 @@ import { kea, MakeLogicType } from 'kea'; -import { Connector } from '@kbn/search-connectors'; -import { ConnectorDefinition } from '@kbn/search-connectors-plugin/public'; +import { Connector, ConnectorDefinition } from '@kbn/search-connectors'; import { Status } from '../../../../../../common/types/api'; import { Actions } from '../../../../shared/api_logic/create_api_logic'; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/new_index/new_search_index_page.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/new_index/new_search_index_page.tsx index 1b2889301d6a9..7aac8d87b89b4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/new_index/new_search_index_page.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/new_index/new_search_index_page.tsx @@ -15,7 +15,7 @@ import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { ConnectorDefinition } from '@kbn/search-connectors-plugin/public'; +import { ConnectorDefinition } from '@kbn/search-connectors'; import { CONNECTOR_CLIENTS_TYPE, diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/generate_api_key_modal/modal.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/generate_api_key_modal/modal.tsx index 18514ef93d9d9..d19568bea9e3c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/generate_api_key_modal/modal.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/components/generate_api_key_modal/modal.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useRef, useEffect } from 'react'; import { useValues, useActions } from 'kea'; @@ -26,11 +26,12 @@ import { EuiText, EuiSpacer, EuiLink, - EuiFormLabel, EuiCodeBlock, + EuiCallOut, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; import { docLinks } from '../../../../../shared/doc_links'; @@ -49,6 +50,13 @@ export const GenerateApiKeyModal: React.FC = ({ indexN const { ingestionMethod } = useValues(IndexViewLogic); const { setKeyName } = useActions(GenerateApiKeyModalLogic); const { makeRequest } = useActions(GenerateApiKeyLogic); + const copyApiKeyRef = useRef(null); + + useEffect(() => { + if (isSuccess) { + copyApiKeyRef.current?.focus(); + } + }, [isSuccess]); return ( @@ -68,7 +76,11 @@ export const GenerateApiKeyModal: React.FC = ({ indexN "Before you can start posting documents to your Elasticsearch index you'll need to create at least one API key.", })}   - + {i18n.translate( 'xpack.enterpriseSearch.content.overview.generateApiKeyModal.learnMore', { defaultMessage: 'Learn more about API keys' } @@ -77,15 +89,25 @@ export const GenerateApiKeyModal: React.FC = ({ indexN

- + + {!isSuccess ? ( <> - + + } + fullWidth + > = ({ indexN ) : ( - {keyName} - + {keyName}, + }} + /> + } + color="success" + iconType="check" + role="alert" + /> = ({ indexN = ({ indexN diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/native_connector_configuration/native_connector_configuration_config.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/native_connector_configuration/native_connector_configuration_config.tsx index d2681a5d3df97..d0edaacb3c712 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/native_connector_configuration/native_connector_configuration_config.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/native_connector_configuration/native_connector_configuration_config.tsx @@ -14,11 +14,12 @@ import { EuiSpacer, EuiLink, EuiFlexGroup, EuiFlexItem, EuiCallOut } from '@elas import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { Connector, ConnectorStatus } from '@kbn/search-connectors'; - -import { ConnectorConfigurationComponent } from '@kbn/search-connectors/components/configuration/connector_configuration'; - -import { ConnectorDefinition } from '@kbn/search-connectors-plugin/common/types'; +import { + Connector, + ConnectorConfigurationComponent, + ConnectorDefinition, + ConnectorStatus, +} from '@kbn/search-connectors'; import { Status } from '../../../../../../../common/types/api'; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/native_connector_configuration/research_configuration.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/native_connector_configuration/research_configuration.tsx index 0625c60a354f7..764f952e0c02d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/native_connector_configuration/research_configuration.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/native_connector_configuration/research_configuration.tsx @@ -11,8 +11,7 @@ import { EuiText, EuiFlexGroup, EuiFlexItem, EuiLink, EuiCallOut } from '@elasti import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; - -import { ConnectorDefinition } from '@kbn/search-connectors-plugin/common/types'; +import { ConnectorDefinition } from '@kbn/search-connectors'; interface ResearchConfigurationProps { nativeConnector: ConnectorDefinition; diff --git a/x-pack/plugins/enterprise_search/public/applications/index.tsx b/x-pack/plugins/enterprise_search/public/applications/index.tsx index eafa8827869d8..717379d433dd1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.tsx @@ -114,6 +114,7 @@ export const renderApp = ( data: plugins.data, esConfig, getChromeStyle$: chrome.getChromeStyle$, + getNavLinks: chrome.navLinks.getAll, guidedOnboarding, history, indexMappingComponent, diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/api_key/api_key_panel.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/api_key/api_key_panel.tsx index 34c7ac66343c9..2a99b60f3745f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/api_key/api_key_panel.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/api_key/api_key_panel.tsx @@ -76,6 +76,7 @@ export const ApiKeyPanel: React.FC = () => { {(copy) => ( { {(copy) => ( { - - - - 0 ? 'success' : 'warning'} - data-test-subj="api-keys-count-badge" - > - {apiKeys.length} -
- ), - }} - /> - - - + 0 ? 'success' : 'warning'} + data-test-subj="api-keys-count-badge" + > + + diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/api_key/create_api_key_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/api_key/create_api_key_flyout.tsx index 38217df269fd1..c72f56c656e49 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/api_key/create_api_key_flyout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/api_key/create_api_key_flyout.tsx @@ -210,6 +210,7 @@ export const CreateApiKeyFlyout: React.FC = ({ onClose defaultMessage: 'Store this API key', })} titleSize="xs" + role="alert" > {i18n.translate('xpack.enterpriseSearch.apiKey.apiKeyStepDescription', { diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/getting_started/panels/api_key_panel_content.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/getting_started/panels/api_key_panel_content.tsx index ff271a3a3d79e..117c817ac8bf1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/getting_started/panels/api_key_panel_content.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/getting_started/panels/api_key_panel_content.tsx @@ -102,26 +102,18 @@ export const ApiKeyPanelContent: React.FC = ({ apiKeys, open - - - - 0 ? 'success' : 'warning'} - data-test-subj="api-keys-count-badge" - > - {apiKeys?.length || 0} - - ), - }} - /> - - - + 0 ? 'success' : 'warning'} + data-test-subj="api-keys-count-badge" + > + + diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts index f74345a1c75c1..592e20f25f382 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts @@ -28,7 +28,7 @@ import { IndexMappingProps } from '@kbn/index-management-shared-types'; import { LensPublicStart } from '@kbn/lens-plugin/public'; import { MlPluginStart } from '@kbn/ml-plugin/public'; import { ELASTICSEARCH_URL_PLACEHOLDER } from '@kbn/search-api-panels/constants'; -import { ConnectorDefinition } from '@kbn/search-connectors-plugin/public'; +import { ConnectorDefinition } from '@kbn/search-connectors'; import { SearchInferenceEndpointsPluginStart } from '@kbn/search-inference-endpoints/public'; import { SearchPlaygroundPluginStart } from '@kbn/search-playground/public'; import { AuthenticatedUser, SecurityPluginStart } from '@kbn/security-plugin/public'; @@ -55,6 +55,7 @@ export interface KibanaLogicProps { data?: DataPublicPluginStart; esConfig: ESConfig; getChromeStyle$: ChromeStart['getChromeStyle$']; + getNavLinks: ChromeStart['navLinks']['getAll']; guidedOnboarding?: GuidedOnboardingPluginStart; history: ScopedHistory; indexMappingComponent?: React.FC; @@ -87,6 +88,7 @@ export interface KibanaValues { data: DataPublicPluginStart | null; esConfig: ESConfig; getChromeStyle$: ChromeStart['getChromeStyle$']; + getNavLinks: ChromeStart['navLinks']['getAll']; guidedOnboarding: GuidedOnboardingPluginStart | null; history: ScopedHistory; indexMappingComponent: React.FC | null; @@ -126,6 +128,7 @@ export const KibanaLogic = kea>({ data: [props.data || null, {}], esConfig: [props.esConfig || { elasticsearch_host: ELASTICSEARCH_URL_PLACEHOLDER }, {}], getChromeStyle$: [props.getChromeStyle$, {}], + getNavLinks: [props.getNavLinks, {}], guidedOnboarding: [props.guidedOnboarding || null, {}], history: [props.history, {}], indexMappingComponent: [props.indexMappingComponent || null, {}], diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.ts index ea6bda26be450..189ca53e362e1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.ts @@ -22,6 +22,8 @@ import { VECTOR_SEARCH_PLUGIN, WORKPLACE_SEARCH_PLUGIN, SEMANTIC_SEARCH_PLUGIN, + APPLICATIONS_PLUGIN, + GETTING_STARTED_TITLE, } from '../../../../common/constants'; import { stripLeadingSlash } from '../../../../common/strip_slashes'; @@ -126,7 +128,11 @@ export const useEnterpriseSearchBreadcrumbs = (breadcrumbs: Breadcrumbs = []) => ]); export const useAnalyticsBreadcrumbs = (breadcrumbs: Breadcrumbs = []) => - useSearchBreadcrumbs([{ text: ANALYTICS_PLUGIN.NAME, path: '/' }, ...breadcrumbs]); + useSearchBreadcrumbs([ + { text: APPLICATIONS_PLUGIN.NAV_TITLE }, + { text: ANALYTICS_PLUGIN.NAME, path: '/' }, + ...breadcrumbs, + ]); export const useElasticsearchBreadcrumbs = (breadcrumbs: Breadcrumbs = []) => useSearchBreadcrumbs([ @@ -161,13 +167,25 @@ export const useSearchExperiencesBreadcrumbs = (breadcrumbs: Breadcrumbs = []) = useSearchBreadcrumbs([{ text: SEARCH_EXPERIENCES_PLUGIN.NAV_TITLE, path: '/' }, ...breadcrumbs]); export const useEnterpriseSearchApplicationsBreadcrumbs = (breadcrumbs: Breadcrumbs = []) => - useSearchBreadcrumbs(breadcrumbs); + useSearchBreadcrumbs([{ text: APPLICATIONS_PLUGIN.NAV_TITLE }, ...breadcrumbs]); export const useAiSearchBreadcrumbs = (breadcrumbs: Breadcrumbs = []) => - useSearchBreadcrumbs([{ text: AI_SEARCH_PLUGIN.NAME, path: '/' }, ...breadcrumbs]); + useSearchBreadcrumbs([ + { text: GETTING_STARTED_TITLE }, + { text: AI_SEARCH_PLUGIN.NAME, path: '/' }, + ...breadcrumbs, + ]); export const useVectorSearchBreadcrumbs = (breadcrumbs: Breadcrumbs = []) => - useSearchBreadcrumbs([{ text: VECTOR_SEARCH_PLUGIN.NAV_TITLE, path: '/' }, ...breadcrumbs]); + useSearchBreadcrumbs([ + { text: GETTING_STARTED_TITLE }, + { text: VECTOR_SEARCH_PLUGIN.NAV_TITLE, path: '/' }, + ...breadcrumbs, + ]); export const useSemanticSearchBreadcrumbs = (breadcrumbs: Breadcrumbs = []) => - useSearchBreadcrumbs([{ text: SEMANTIC_SEARCH_PLUGIN.NAME, path: '/' }, ...breadcrumbs]); + useSearchBreadcrumbs([ + { text: GETTING_STARTED_TITLE }, + { text: SEMANTIC_SEARCH_PLUGIN.NAME, path: '/' }, + ...breadcrumbs, + ]); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_title.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_title.ts index eaeb30f1540d0..df7d16cddc4d4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_title.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_title.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; + import { AI_SEARCH_PLUGIN, ANALYTICS_PLUGIN, @@ -40,7 +42,12 @@ export const searchTitle = (page: Title = []) => generateTitle([...page, SEARCH_ export const analyticsTitle = (page: Title = []) => generateTitle([...page, ANALYTICS_PLUGIN.NAME]); export const elasticsearchTitle = (page: Title = []) => - generateTitle([...page, 'Getting started with Elasticsearch']); + generateTitle([ + ...page, + i18n.translate('xpack.enterpriseSearch.titles.elasticsearch', { + defaultMessage: 'Getting started with Elasticsearch', + }), + ]); export const appSearchTitle = (page: Title = []) => generateTitle([...page, APP_SEARCH_PLUGIN.NAME]); @@ -61,3 +68,11 @@ export const semanticSearchTitle = (page: Title = []) => export const enterpriseSearchContentTitle = (page: Title = []) => generateTitle([...page, ENTERPRISE_SEARCH_CONTENT_PLUGIN.NAME]); + +export const searchApplicationsTitle = (page: Title = []) => + generateTitle([ + ...page, + i18n.translate('xpack.enterpriseSearch.titles.searchApplications', { + defaultMessage: 'Search Applications', + }), + ]); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.tsx index 8f7c71d1309c0..0c05cb0c02ca0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.tsx @@ -9,8 +9,7 @@ import React, { useEffect } from 'react'; import { useValues } from 'kea'; -import { APPLICATIONS_PLUGIN } from '../../../../common/constants'; - +import { SEARCH_APPS_BREADCRUMB } from '../../../../common/constants'; import { KibanaLogic } from '../kibana'; import { @@ -35,6 +34,8 @@ import { appSearchTitle, elasticsearchTitle, enterpriseSearchContentTitle, + generateTitle, + searchApplicationsTitle, searchExperiencesTitle, searchTitle, semanticSearchTitle, @@ -210,14 +211,30 @@ export const SetSearchExperiencesChrome: React.FC = ({ trail = [ return null; }; +export const SetSearchPlaygroundChrome: React.FC = ({ trail = [] }) => { + const { setBreadcrumbs, setDocTitle } = useValues(KibanaLogic); + + const title = reverseArray(trail); + const docTitle = generateTitle(title); + + const breadcrumbs = useEnterpriseSearchApplicationsBreadcrumbs(useGenerateBreadcrumbs(trail)); + + useEffect(() => { + setBreadcrumbs(breadcrumbs); + setDocTitle(docTitle); + }, [trail]); + + return null; +}; + export const SetEnterpriseSearchApplicationsChrome: React.FC = ({ trail = [] }) => { const { setBreadcrumbs, setDocTitle } = useValues(KibanaLogic); const title = reverseArray(trail); - const docTitle = appSearchTitle(title); + const docTitle = searchApplicationsTitle(title); const breadcrumbs = useEnterpriseSearchApplicationsBreadcrumbs( - useGenerateBreadcrumbs([APPLICATIONS_PLUGIN.NAV_TITLE, ...trail]) + useGenerateBreadcrumbs([SEARCH_APPS_BREADCRUMB, ...trail]) ); useEffect(() => { diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/base_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/base_nav.tsx new file mode 100644 index 0000000000000..b971ab6deff53 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/base_nav.tsx @@ -0,0 +1,201 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 } from '@elastic/eui'; +import { + ENTERPRISE_SEARCH_APP_ID, + ENTERPRISE_SEARCH_ANALYTICS_APP_ID, + SEARCH_ELASTICSEARCH, + SEARCH_VECTOR_SEARCH, + SEARCH_SEMANTIC_SEARCH, + SEARCH_AI_SEARCH, +} from '@kbn/deeplinks-search'; +import { i18n } from '@kbn/i18n'; + +import { GETTING_STARTED_TITLE } from '../../../../common/constants'; + +import { ClassicNavItem, BuildClassicNavParameters } from '../types'; + +export const buildBaseClassicNavItems = ({ + productAccess, +}: BuildClassicNavParameters): ClassicNavItem[] => { + const navItems: ClassicNavItem[] = []; + + // Home + navItems.push({ + 'data-test-subj': 'searchSideNav-Home', + deepLink: { + link: ENTERPRISE_SEARCH_APP_ID, + shouldShowActiveForSubroutes: true, + }, + id: 'home', + name: ( + + {i18n.translate('xpack.enterpriseSearch.nav.homeTitle', { + defaultMessage: 'Home', + })} + + ), + }); + + // Content + navItems.push({ + 'data-test-subj': 'searchSideNav-Content', + id: 'content', + items: [ + { + 'data-test-subj': 'searchSideNav-Indices', + deepLink: { + link: 'enterpriseSearchContent:searchIndices', + shouldShowActiveForSubroutes: true, + }, + id: 'search_indices', + }, + { + 'data-test-subj': 'searchSideNav-Connectors', + deepLink: { + link: 'enterpriseSearchContent:connectors', + shouldShowActiveForSubroutes: true, + }, + id: 'connectors', + }, + { + 'data-test-subj': 'searchSideNav-Crawlers', + deepLink: { + link: 'enterpriseSearchContent:webCrawlers', + shouldShowActiveForSubroutes: true, + }, + id: 'crawlers', + }, + ], + name: i18n.translate('xpack.enterpriseSearch.nav.contentTitle', { + defaultMessage: 'Content', + }), + }); + + // Build + navItems.push({ + 'data-test-subj': 'searchSideNav-Build', + id: 'build', + items: [ + { + 'data-test-subj': 'searchSideNav-Playground', + deepLink: { + link: 'enterpriseSearchApplications:playground', + shouldShowActiveForSubroutes: true, + }, + id: 'playground', + }, + { + 'data-test-subj': 'searchSideNav-SearchApplications', + deepLink: { + link: 'enterpriseSearchApplications:searchApplications', + }, + id: 'searchApplications', + }, + { + 'data-test-subj': 'searchSideNav-BehavioralAnalytics', + deepLink: { + link: ENTERPRISE_SEARCH_ANALYTICS_APP_ID, + }, + id: 'analyticsCollections', + }, + ], + name: i18n.translate('xpack.enterpriseSearch.nav.applicationsTitle', { + defaultMessage: 'Build', + }), + }); + + navItems.push({ + 'data-test-subj': 'searchSideNav-Relevance', + id: 'relevance', + items: [ + { + 'data-test-subj': 'searchSideNav-InferenceEndpoints', + deepLink: { + link: 'searchInferenceEndpoints:inferenceEndpoints', + shouldShowActiveForSubroutes: true, + }, + id: 'inference_endpoints', + }, + ], + name: i18n.translate('xpack.enterpriseSearch.nav.relevanceTitle', { + defaultMessage: 'Relevance', + }), + }); + + // Getting Started + navItems.push({ + 'data-test-subj': 'searchSideNav-GettingStarted', + id: 'es_getting_started', + items: [ + { + 'data-test-subj': 'searchSideNav-Elasticsearch', + deepLink: { + link: SEARCH_ELASTICSEARCH, + }, + id: 'elasticsearch', + }, + { + 'data-test-subj': 'searchSideNav-VectorSearch', + deepLink: { + link: SEARCH_VECTOR_SEARCH, + }, + id: 'vectorSearch', + }, + { + 'data-test-subj': 'searchSideNav-SemanticSearch', + deepLink: { + link: SEARCH_SEMANTIC_SEARCH, + }, + id: 'semanticSearch', + }, + { + 'data-test-subj': 'searchSideNav-AISearch', + deepLink: { + link: SEARCH_AI_SEARCH, + }, + id: 'aiSearch', + }, + ], + name: GETTING_STARTED_TITLE, + }); + + if (productAccess.hasAppSearchAccess || productAccess.hasWorkplaceSearchAccess) { + const entSearchItems: ClassicNavItem[] = []; + if (productAccess.hasAppSearchAccess) { + entSearchItems.push({ + 'data-test-subj': 'searchSideNav-AppSearch', + deepLink: { + link: 'appSearch:engines', + }, + id: 'app_search', + }); + } + if (productAccess.hasWorkplaceSearchAccess) { + entSearchItems.push({ + 'data-test-subj': 'searchSideNav-WorkplaceSearch', + deepLink: { + link: 'workplaceSearch', + }, + id: 'workplace_search', + }); + } + navItems.push({ + 'data-test-subj': 'searchSideNav-EnterpriseSearch', + id: 'enterpriseSearch', + items: entSearchItems, + name: i18n.translate('xpack.enterpriseSearch.nav.title', { + defaultMessage: 'Enterprise Search', + }), + }); + } + + return navItems; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/classic_nav_helpers.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/layout/classic_nav_helpers.test.ts new file mode 100644 index 0000000000000..514072ba297aa --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/classic_nav_helpers.test.ts @@ -0,0 +1,189 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mockKibanaValues } from '../../__mocks__/kea_logic'; + +import type { ChromeNavLink } from '@kbn/core-chrome-browser'; + +import '../../__mocks__/react_router'; + +jest.mock('../react_router_helpers/link_events', () => ({ + letBrowserHandleEvent: jest.fn(), +})); + +import { ClassicNavItem } from '../types'; + +import { generateSideNavItems } from './classic_nav_helpers'; + +describe('generateSideNavItems', () => { + const deepLinksMap = { + enterpriseSearch: { + id: 'enterpriseSearch', + url: '/app/enterprise_search/overview', + title: 'Overview', + }, + 'enterpriseSearchContent:searchIndices': { + id: 'enterpriseSearchContent:searchIndices', + title: 'Indices', + url: '/app/enterprise_search/content/search_indices', + }, + 'enterpriseSearchContent:connectors': { + id: 'enterpriseSearchContent:connectors', + title: 'Connectors', + url: '/app/enterprise_search/content/connectors', + }, + 'enterpriseSearchContent:webCrawlers': { + id: 'enterpriseSearchContent:webCrawlers', + title: 'Web crawlers', + url: '/app/enterprise_search/content/crawlers', + }, + } as unknown as Record; + beforeEach(() => { + jest.clearAllMocks(); + mockKibanaValues.history.location.pathname = '/'; + }); + + it('renders top-level items', () => { + const classicNavItems: ClassicNavItem[] = [ + { + id: 'unit-test', + deepLink: { + link: 'enterpriseSearch', + }, + }, + ]; + + expect(generateSideNavItems(classicNavItems, deepLinksMap)).toEqual([ + { + href: '/app/enterprise_search/overview', + id: 'unit-test', + isSelected: false, + name: 'Overview', + onClick: expect.any(Function), + }, + ]); + }); + + it('renders items with children', () => { + const classicNavItems: ClassicNavItem[] = [ + { + id: 'parent', + name: 'Parent', + items: [ + { + id: 'unit-test', + deepLink: { + link: 'enterpriseSearch', + }, + }, + ], + }, + ]; + + expect(generateSideNavItems(classicNavItems, deepLinksMap)).toEqual([ + { + id: 'parent', + items: [ + { + href: '/app/enterprise_search/overview', + id: 'unit-test', + isSelected: false, + name: 'Overview', + onClick: expect.any(Function), + }, + ], + name: 'Parent', + }, + ]); + }); + + it('renders classic nav name over deep link title if provided', () => { + const classicNavItems: ClassicNavItem[] = [ + { + deepLink: { + link: 'enterpriseSearch', + }, + id: 'unit-test', + name: 'Home', + }, + ]; + + expect(generateSideNavItems(classicNavItems, deepLinksMap)).toEqual([ + { + href: '/app/enterprise_search/overview', + id: 'unit-test', + isSelected: false, + name: 'Home', + onClick: expect.any(Function), + }, + ]); + }); + + it('removes item if deep link is not defined', () => { + const classicNavItems: ClassicNavItem[] = [ + { + deepLink: { + link: 'enterpriseSearch', + }, + id: 'unit-test', + name: 'Home', + }, + { + deepLink: { + link: 'enterpriseSearchApplications:playground', + }, + id: 'unit-test-missing', + }, + ]; + + expect(generateSideNavItems(classicNavItems, deepLinksMap)).toEqual([ + { + href: '/app/enterprise_search/overview', + id: 'unit-test', + isSelected: false, + name: 'Home', + onClick: expect.any(Function), + }, + ]); + }); + + it('adds pre-rendered child items provided', () => { + const classicNavItems: ClassicNavItem[] = [ + { + id: 'unit-test', + name: 'Indices', + }, + ]; + const subItems = { + 'unit-test': [ + { + href: '/app/unit-test', + id: 'child', + isSelected: true, + name: 'Index', + onClick: jest.fn(), + }, + ], + }; + + expect(generateSideNavItems(classicNavItems, deepLinksMap, subItems)).toEqual([ + { + id: 'unit-test', + items: [ + { + href: '/app/unit-test', + id: 'child', + isSelected: true, + name: 'Index', + onClick: expect.any(Function), + }, + ], + name: 'Indices', + }, + ]); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/classic_nav_helpers.ts b/x-pack/plugins/enterprise_search/public/applications/shared/layout/classic_nav_helpers.ts new file mode 100644 index 0000000000000..89f3c2ab5b59a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/classic_nav_helpers.ts @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ChromeNavLink, EuiSideNavItemTypeEnhanced } from '@kbn/core-chrome-browser'; + +import { + ClassicNavItem, + GenerateNavLinkFromDeepLinkParameters, + GenerateNavLinkParameters, +} from '../types'; + +import { generateNavLink } from './nav_link_helpers'; + +export const generateSideNavItems = ( + navItems: ClassicNavItem[], + deepLinks: Record, + subItemsMap: Record> | undefined> = {} +): Array> => { + const sideNavItems: Array> = []; + + for (const navItem of navItems) { + let sideNavChildItems: Array> | undefined; + + const { deepLink, items, ...rest } = navItem; + const subItems = subItemsMap?.[navItem.id]; + + if (items || subItems) { + sideNavChildItems = []; + if (items) { + sideNavChildItems.push(...generateSideNavItems(items, deepLinks, subItemsMap)); + } + if (subItems) { + sideNavChildItems.push(...subItems); + } + } + + let sideNavItem: EuiSideNavItemTypeEnhanced | undefined; + if (deepLink) { + const navLinkParams = getNavLinkParameters(deepLink, deepLinks); + if (navLinkParams !== undefined) { + const name = navItem.name ?? getDeepLinkTitle(deepLink.link, deepLinks); + sideNavItem = { + ...rest, + name, + ...generateNavLink({ + ...navLinkParams, + items: sideNavChildItems, + }), + }; + } + } else { + sideNavItem = { + ...rest, + items: sideNavChildItems, + name: navItem.name, + }; + } + + if (isValidSideNavItem(sideNavItem)) { + sideNavItems.push(sideNavItem); + } + } + + return sideNavItems; +}; + +const getNavLinkParameters = ( + navLink: GenerateNavLinkFromDeepLinkParameters, + deepLinks: Record +): GenerateNavLinkParameters | undefined => { + const { link, ...navLinkProps } = navLink; + const deepLink = deepLinks[link]; + if (!deepLink || !deepLink.url) return undefined; + return { + ...navLinkProps, + shouldNotCreateHref: true, + shouldNotPrepend: true, + to: deepLink.url, + }; +}; +const getDeepLinkTitle = ( + link: string, + deepLinks: Record +): string | undefined => { + const deepLink = deepLinks[link]; + if (!deepLink || !deepLink.url) return undefined; + return deepLink.title; +}; + +function isValidSideNavItem( + item: EuiSideNavItemTypeEnhanced | undefined +): item is EuiSideNavItemTypeEnhanced { + if (item === undefined) return false; + if (item.href || item.onClick) return true; + if (item?.items?.length ?? 0 > 0) return true; + + return false; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.test.tsx index b2c31ff4868bc..3305e92dd8d9e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.test.tsx @@ -15,6 +15,8 @@ jest.mock('../../enterprise_search_content/components/search_index/indices/indic import { setMockValues, mockKibanaValues } from '../../__mocks__/kea_logic'; +import { renderHook } from '@testing-library/react-hooks'; + import { EuiSideNavItemType } from '@elastic/eui'; import { DEFAULT_PRODUCT_FEATURES } from '../../../../common/constants'; @@ -32,26 +34,31 @@ const DEFAULT_PRODUCT_ACCESS: ProductAccess = { }; const baseNavItems = [ expect.objectContaining({ + 'data-test-subj': 'searchSideNav-Home', href: '/app/enterprise_search/overview', id: 'home', items: undefined, }), { + 'data-test-subj': 'searchSideNav-Content', id: 'content', items: [ { + 'data-test-subj': 'searchSideNav-Indices', href: '/app/enterprise_search/content/search_indices', id: 'search_indices', items: [], name: 'Indices', }, { + 'data-test-subj': 'searchSideNav-Connectors', href: '/app/enterprise_search/content/connectors', id: 'connectors', items: undefined, name: 'Connectors', }, { + 'data-test-subj': 'searchSideNav-Crawlers', href: '/app/enterprise_search/content/crawlers', id: 'crawlers', items: undefined, @@ -61,21 +68,25 @@ const baseNavItems = [ name: 'Content', }, { + 'data-test-subj': 'searchSideNav-Build', id: 'build', items: [ { + 'data-test-subj': 'searchSideNav-Playground', href: '/app/enterprise_search/applications/playground', id: 'playground', items: undefined, name: 'Playground', }, { + 'data-test-subj': 'searchSideNav-SearchApplications', href: '/app/enterprise_search/applications/search_applications', id: 'searchApplications', items: undefined, name: 'Search Applications', }, { + 'data-test-subj': 'searchSideNav-BehavioralAnalytics', href: '/app/enterprise_search/analytics', id: 'analyticsCollections', items: undefined, @@ -85,9 +96,11 @@ const baseNavItems = [ name: 'Build', }, { + 'data-test-subj': 'searchSideNav-Relevance', id: 'relevance', items: [ { + 'data-test-subj': 'searchSideNav-InferenceEndpoints', href: '/app/enterprise_search/relevance/inference_endpoints', id: 'inference_endpoints', items: undefined, @@ -97,27 +110,32 @@ const baseNavItems = [ name: 'Relevance', }, { + 'data-test-subj': 'searchSideNav-GettingStarted', id: 'es_getting_started', items: [ { + 'data-test-subj': 'searchSideNav-Elasticsearch', href: '/app/enterprise_search/elasticsearch', id: 'elasticsearch', items: undefined, name: 'Elasticsearch', }, { + 'data-test-subj': 'searchSideNav-VectorSearch', href: '/app/enterprise_search/vector_search', id: 'vectorSearch', items: undefined, name: 'Vector Search', }, { + 'data-test-subj': 'searchSideNav-SemanticSearch', href: '/app/enterprise_search/semantic_search', id: 'semanticSearch', items: undefined, name: 'Semantic Search', }, { + 'data-test-subj': 'searchSideNav-AISearch', href: '/app/enterprise_search/ai_search', id: 'aiSearch', items: undefined, @@ -127,15 +145,18 @@ const baseNavItems = [ name: 'Getting started', }, { + 'data-test-subj': 'searchSideNav-EnterpriseSearch', id: 'enterpriseSearch', items: [ { + 'data-test-subj': 'searchSideNav-AppSearch', href: '/app/enterprise_search/app_search', id: 'app_search', items: undefined, name: 'App Search', }, { + 'data-test-subj': 'searchSideNav-WorkplaceSearch', href: '/app/enterprise_search/workplace_search', id: 'workplace_search', items: undefined, @@ -146,21 +167,102 @@ const baseNavItems = [ }, ]; +const mockNavLinks = [ + { + id: 'enterpriseSearch', + url: '/app/enterprise_search/overview', + }, + { + id: 'enterpriseSearchContent:searchIndices', + title: 'Indices', + url: '/app/enterprise_search/content/search_indices', + }, + { + id: 'enterpriseSearchContent:connectors', + title: 'Connectors', + url: '/app/enterprise_search/content/connectors', + }, + { + id: 'enterpriseSearchContent:webCrawlers', + title: 'Web crawlers', + url: '/app/enterprise_search/content/crawlers', + }, + { + id: 'enterpriseSearchApplications:playground', + title: 'Playground', + url: '/app/enterprise_search/applications/playground', + }, + { + id: 'enterpriseSearchApplications:searchApplications', + title: 'Search Applications', + url: '/app/enterprise_search/applications/search_applications', + }, + { + id: 'enterpriseSearchAnalytics', + title: 'Behavioral Analytics', + url: '/app/enterprise_search/analytics', + }, + { + id: 'searchInferenceEndpoints:inferenceEndpoints', + title: 'Inference Endpoints', + url: '/app/enterprise_search/relevance/inference_endpoints', + }, + { + id: 'appSearch:engines', + title: 'App Search', + url: '/app/enterprise_search/app_search', + }, + { + id: 'workplaceSearch', + title: 'Workplace Search', + url: '/app/enterprise_search/workplace_search', + }, + { + id: 'enterpriseSearchElasticsearch', + title: 'Elasticsearch', + url: '/app/enterprise_search/elasticsearch', + }, + { + id: 'enterpriseSearchVectorSearch', + title: 'Vector Search', + url: '/app/enterprise_search/vector_search', + }, + { + id: 'enterpriseSearchSemanticSearch', + title: 'Semantic Search', + url: '/app/enterprise_search/semantic_search', + }, + { + id: 'enterpriseSearchAISearch', + title: 'AI Search', + url: '/app/enterprise_search/ai_search', + }, +]; + +const defaultMockValues = { + hasEnterpriseLicense: true, + isSidebarEnabled: true, + productAccess: DEFAULT_PRODUCT_ACCESS, + productFeatures: DEFAULT_PRODUCT_FEATURES, +}; + describe('useEnterpriseSearchContentNav', () => { beforeEach(() => { jest.clearAllMocks(); mockKibanaValues.uiSettings.get.mockReturnValue(false); + mockKibanaValues.getNavLinks.mockReturnValue(mockNavLinks); }); it('returns an array of top-level Enterprise Search nav items', () => { const fullProductAccess: ProductAccess = DEFAULT_PRODUCT_ACCESS; setMockValues({ - isSidebarEnabled: true, + ...defaultMockValues, productAccess: fullProductAccess, - productFeatures: DEFAULT_PRODUCT_FEATURES, }); - expect(useEnterpriseSearchNav()).toEqual(baseNavItems); + const { result } = renderHook(() => useEnterpriseSearchNav()); + + expect(result.current).toEqual(baseNavItems); }); it('excludes legacy products when the user has no access to them', () => { @@ -171,13 +273,13 @@ describe('useEnterpriseSearchContentNav', () => { }; setMockValues({ - isSidebarEnabled: true, + ...defaultMockValues, productAccess: noProductAccess, - productFeatures: DEFAULT_PRODUCT_FEATURES, }); mockKibanaValues.uiSettings.get.mockReturnValue(false); - const esNav = useEnterpriseSearchNav(); + const { result } = renderHook(() => useEnterpriseSearchNav()); + const esNav = result.current; const legacyESNav = esNav?.find((item) => item.id === 'enterpriseSearch'); expect(legacyESNav).toBeUndefined(); }); @@ -190,18 +292,20 @@ describe('useEnterpriseSearchContentNav', () => { }; setMockValues({ - isSidebarEnabled: true, + ...defaultMockValues, productAccess: workplaceSearchProductAccess, - productFeatures: DEFAULT_PRODUCT_FEATURES, }); - const esNav = useEnterpriseSearchNav(); + const { result } = renderHook(() => useEnterpriseSearchNav()); + const esNav = result.current; const legacyESNav = esNav?.find((item) => item.id === 'enterpriseSearch'); expect(legacyESNav).not.toBeUndefined(); expect(legacyESNav).toEqual({ + 'data-test-subj': 'searchSideNav-EnterpriseSearch', id: 'enterpriseSearch', items: [ { + 'data-test-subj': 'searchSideNav-WorkplaceSearch', href: '/app/enterprise_search/workplace_search', id: 'workplace_search', name: 'Workplace Search', @@ -218,18 +322,20 @@ describe('useEnterpriseSearchContentNav', () => { }; setMockValues({ - isSidebarEnabled: true, + ...defaultMockValues, productAccess: appSearchProductAccess, - productFeatures: DEFAULT_PRODUCT_FEATURES, }); - const esNav = useEnterpriseSearchNav(); + const { result } = renderHook(() => useEnterpriseSearchNav()); + const esNav = result.current; const legacyESNav = esNav?.find((item) => item.id === 'enterpriseSearch'); expect(legacyESNav).not.toBeUndefined(); expect(legacyESNav).toEqual({ + 'data-test-subj': 'searchSideNav-EnterpriseSearch', id: 'enterpriseSearch', items: [ { + 'data-test-subj': 'searchSideNav-AppSearch', href: '/app/enterprise_search/app_search', id: 'app_search', name: 'App Search', @@ -243,21 +349,21 @@ describe('useEnterpriseSearchContentNav', () => { describe('useEnterpriseSearchApplicationNav', () => { beforeEach(() => { jest.clearAllMocks(); + mockKibanaValues.getNavLinks.mockReturnValue(mockNavLinks); mockKibanaValues.uiSettings.get.mockReturnValue(true); - setMockValues({ - isSidebarEnabled: true, - productAccess: DEFAULT_PRODUCT_ACCESS, - productFeatures: DEFAULT_PRODUCT_FEATURES, - }); + setMockValues(defaultMockValues); }); it('returns an array of top-level Enterprise Search nav items', () => { - expect(useEnterpriseSearchApplicationNav()).toEqual(baseNavItems); + const { result } = renderHook(() => useEnterpriseSearchApplicationNav()); + expect(result.current).toEqual(baseNavItems); }); it('returns selected engine sub nav items', () => { const engineName = 'my-test-engine'; - const navItems = useEnterpriseSearchApplicationNav(engineName); + const { + result: { current: navItems }, + } = renderHook(() => useEnterpriseSearchApplicationNav(engineName)); expect(navItems![0].id).toEqual('home'); expect(navItems?.slice(1).map((ni) => ni.name)).toEqual([ 'Content', @@ -317,7 +423,9 @@ describe('useEnterpriseSearchApplicationNav', () => { it('returns selected engine without tabs when isEmpty', () => { const engineName = 'my-test-engine'; - const navItems = useEnterpriseSearchApplicationNav(engineName, true); + const { + result: { current: navItems }, + } = renderHook(() => useEnterpriseSearchApplicationNav(engineName, true)); expect(navItems![0].id).toEqual('home'); expect(navItems?.slice(1).map((ni) => ni.name)).toEqual([ 'Content', @@ -348,7 +456,9 @@ describe('useEnterpriseSearchApplicationNav', () => { it('returns selected engine with conflict warning when hasSchemaConflicts', () => { const engineName = 'my-test-engine'; - const navItems = useEnterpriseSearchApplicationNav(engineName, false, true); + const { + result: { current: navItems }, + } = renderHook(() => useEnterpriseSearchApplicationNav(engineName, false, true)); // @ts-ignore const engineItem = navItems @@ -383,27 +493,20 @@ describe('useEnterpriseSearchApplicationNav', () => { describe('useEnterpriseSearchAnalyticsNav', () => { beforeEach(() => { jest.clearAllMocks(); - setMockValues({ - isSidebarEnabled: true, - }); + setMockValues(defaultMockValues); + mockKibanaValues.getNavLinks.mockReturnValue(mockNavLinks); }); it('returns basic nav all params are empty', () => { - const navItems = useEnterpriseSearchAnalyticsNav(); - expect(navItems).toEqual( - baseNavItems.map((item) => - item.id === 'content' - ? { - ...item, - items: item.items, - } - : item - ) - ); + const { result } = renderHook(() => useEnterpriseSearchAnalyticsNav()); + + expect(result.current).toEqual(baseNavItems); }); it('returns basic nav if only name provided', () => { - const navItems = useEnterpriseSearchAnalyticsNav('my-test-collection'); + const { + result: { current: navItems }, + } = renderHook(() => useEnterpriseSearchAnalyticsNav('my-test-collection')); expect(navItems).toEqual( baseNavItems.map((item) => item.id === 'content' @@ -417,16 +520,21 @@ describe('useEnterpriseSearchAnalyticsNav', () => { }); it('returns nav with sub items when name and paths provided', () => { - const navItems = useEnterpriseSearchAnalyticsNav('my-test-collection', { - explorer: '/explorer-path', - integration: '/integration-path', - overview: '/overview-path', - }); + const { + result: { current: navItems }, + } = renderHook(() => + useEnterpriseSearchAnalyticsNav('my-test-collection', { + explorer: '/explorer-path', + integration: '/integration-path', + overview: '/overview-path', + }) + ); const applicationsNav = navItems?.find((item) => item.id === 'build'); expect(applicationsNav).not.toBeUndefined(); const analyticsNav = applicationsNav?.items?.[2]; expect(analyticsNav).not.toBeUndefined(); expect(analyticsNav).toEqual({ + 'data-test-subj': 'searchSideNav-BehavioralAnalytics', href: '/app/enterprise_search/analytics', id: 'analyticsCollections', items: [ diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.tsx index 3b3960a7a92ba..8f83b6c73402e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.tsx @@ -5,44 +5,22 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { useValues } from 'kea'; -import { EuiFlexGroup, EuiIcon, EuiText } from '@elastic/eui'; -import type { EuiSideNavItemTypeEnhanced } from '@kbn/core-chrome-browser'; +import { EuiFlexGroup, EuiIcon } from '@elastic/eui'; +import type { ChromeNavLink, EuiSideNavItemTypeEnhanced } from '@kbn/core-chrome-browser'; import { i18n } from '@kbn/i18n'; -import { - ANALYTICS_PLUGIN, - APPLICATIONS_PLUGIN, - APP_SEARCH_PLUGIN, - ELASTICSEARCH_PLUGIN, - ENTERPRISE_SEARCH_CONTENT_PLUGIN, - ENTERPRISE_SEARCH_OVERVIEW_PLUGIN, - AI_SEARCH_PLUGIN, - VECTOR_SEARCH_PLUGIN, - WORKPLACE_SEARCH_PLUGIN, - SEARCH_RELEVANCE_PLUGIN, - SEMANTIC_SEARCH_PLUGIN, -} from '../../../../common/constants'; -import { - SEARCH_APPLICATIONS_PATH, - SearchApplicationViewTabs, - PLAYGROUND_PATH, -} from '../../applications/routes'; +import { ANALYTICS_PLUGIN, APPLICATIONS_PLUGIN } from '../../../../common/constants'; +import { SEARCH_APPLICATIONS_PATH, SearchApplicationViewTabs } from '../../applications/routes'; import { useIndicesNav } from '../../enterprise_search_content/components/search_index/indices/indices_nav'; -import { - CONNECTORS_PATH, - CRAWLERS_PATH, - SEARCH_INDICES_PATH, -} from '../../enterprise_search_content/routes'; -import { INFERENCE_ENDPOINTS_PATH } from '../../enterprise_search_relevance/routes'; import { KibanaLogic } from '../kibana'; -import { LicensingLogic } from '../licensing'; - +import { buildBaseClassicNavItems } from './base_nav'; +import { generateSideNavItems } from './classic_nav_helpers'; import { generateNavLink } from './nav_link_helpers'; /** @@ -52,219 +30,21 @@ import { generateNavLink } from './nav_link_helpers'; * @returns The Enterprise Search navigation items */ export const useEnterpriseSearchNav = (alwaysReturn = false) => { - const { isSidebarEnabled, productAccess } = useValues(KibanaLogic); - - const { hasEnterpriseLicense } = useValues(LicensingLogic); + const { isSidebarEnabled, productAccess, getNavLinks } = useValues(KibanaLogic); const indicesNavItems = useIndicesNav(); - if (!isSidebarEnabled && !alwaysReturn) return undefined; + const navItems: Array> = useMemo(() => { + const baseNavItems = buildBaseClassicNavItems({ productAccess }); + const deepLinks = getNavLinks().reduce((links, link) => { + links[link.id] = link; + return links; + }, {} as Record); - const navItems: Array> = [ - { - id: 'home', - name: ( - - {i18n.translate('xpack.enterpriseSearch.nav.homeTitle', { - defaultMessage: 'Home', - })} - - ), - ...generateNavLink({ - shouldNotCreateHref: true, - shouldShowActiveForSubroutes: true, - to: ENTERPRISE_SEARCH_OVERVIEW_PLUGIN.URL, - }), - }, - { - id: 'content', - items: [ - { - id: 'search_indices', - name: i18n.translate('xpack.enterpriseSearch.nav.searchIndicesTitle', { - defaultMessage: 'Indices', - }), - ...generateNavLink({ - items: indicesNavItems, - shouldNotCreateHref: true, - shouldShowActiveForSubroutes: true, - to: ENTERPRISE_SEARCH_CONTENT_PLUGIN.URL + SEARCH_INDICES_PATH, - }), - }, - { - id: 'connectors', - name: i18n.translate('xpack.enterpriseSearch.nav.connectorsTitle', { - defaultMessage: 'Connectors', - }), - ...generateNavLink({ - shouldNotCreateHref: true, - shouldShowActiveForSubroutes: true, - to: ENTERPRISE_SEARCH_CONTENT_PLUGIN.URL + CONNECTORS_PATH, - }), - }, - { - id: 'crawlers', - name: i18n.translate('xpack.enterpriseSearch.nav.crawlersTitle', { - defaultMessage: 'Web crawlers', - }), - ...generateNavLink({ - shouldNotCreateHref: true, - shouldShowActiveForSubroutes: true, - to: ENTERPRISE_SEARCH_CONTENT_PLUGIN.URL + CRAWLERS_PATH, - }), - }, - ], - name: i18n.translate('xpack.enterpriseSearch.nav.contentTitle', { - defaultMessage: 'Content', - }), - }, - { - id: 'build', - items: [ - { - id: 'playground', - name: i18n.translate('xpack.enterpriseSearch.nav.PlaygroundTitle', { - defaultMessage: 'Playground', - }), - ...generateNavLink({ - shouldNotCreateHref: true, - shouldShowActiveForSubroutes: true, - to: APPLICATIONS_PLUGIN.URL + PLAYGROUND_PATH, - }), - }, - { - id: 'searchApplications', - name: i18n.translate('xpack.enterpriseSearch.nav.searchApplicationsTitle', { - defaultMessage: 'Search Applications', - }), - ...generateNavLink({ - shouldNotCreateHref: true, - to: APPLICATIONS_PLUGIN.URL + SEARCH_APPLICATIONS_PATH, - }), - }, - { - id: 'analyticsCollections', - name: i18n.translate('xpack.enterpriseSearch.nav.analyticsTitle', { - defaultMessage: 'Behavioral Analytics', - }), - ...generateNavLink({ - shouldNotCreateHref: true, - to: ANALYTICS_PLUGIN.URL, - }), - }, - ], - name: i18n.translate('xpack.enterpriseSearch.nav.applicationsTitle', { - defaultMessage: 'Build', - }), - }, - ...(hasEnterpriseLicense - ? [ - { - id: 'relevance', - items: [ - { - id: 'inference_endpoints', - name: i18n.translate('xpack.enterpriseSearch.nav.inferenceEndpointsTitle', { - defaultMessage: 'Inference Endpoints', - }), - ...generateNavLink({ - shouldNotCreateHref: true, - shouldShowActiveForSubroutes: true, - to: SEARCH_RELEVANCE_PLUGIN.URL + INFERENCE_ENDPOINTS_PATH, - }), - }, - ], - name: i18n.translate('xpack.enterpriseSearch.nav.relevanceTitle', { - defaultMessage: 'Relevance', - }), - }, - ] - : []), - { - id: 'es_getting_started', - items: [ - { - id: 'elasticsearch', - name: i18n.translate('xpack.enterpriseSearch.nav.elasticsearchTitle', { - defaultMessage: 'Elasticsearch', - }), - ...generateNavLink({ - shouldNotCreateHref: true, - to: ELASTICSEARCH_PLUGIN.URL, - }), - }, - { - id: 'vectorSearch', - name: VECTOR_SEARCH_PLUGIN.NAME, - ...generateNavLink({ - shouldNotCreateHref: true, - to: VECTOR_SEARCH_PLUGIN.URL, - }), - }, - { - id: 'semanticSearch', - name: SEMANTIC_SEARCH_PLUGIN.NAME, - ...generateNavLink({ - shouldNotCreateHref: true, - to: SEMANTIC_SEARCH_PLUGIN.URL, - }), - }, - { - id: 'aiSearch', - name: i18n.translate('xpack.enterpriseSearch.nav.aiSearchTitle', { - defaultMessage: 'AI Search', - }), - ...generateNavLink({ - shouldNotCreateHref: true, - to: AI_SEARCH_PLUGIN.URL, - }), - }, - ], - name: i18n.translate('xpack.enterpriseSearch.nav.enterpriseSearchOverviewTitle', { - defaultMessage: 'Getting started', - }), - }, - ...(productAccess.hasAppSearchAccess || productAccess.hasWorkplaceSearchAccess - ? [ - { - id: 'enterpriseSearch', - items: [ - ...(productAccess.hasAppSearchAccess - ? [ - { - id: 'app_search', - name: i18n.translate('xpack.enterpriseSearch.nav.appSearchTitle', { - defaultMessage: 'App Search', - }), - ...generateNavLink({ - shouldNotCreateHref: true, - to: APP_SEARCH_PLUGIN.URL, - }), - }, - ] - : []), - ...(productAccess.hasWorkplaceSearchAccess - ? [ - { - id: 'workplace_search', - name: i18n.translate('xpack.enterpriseSearch.nav.workplaceSearchTitle', { - defaultMessage: 'Workplace Search', - }), - ...generateNavLink({ - shouldNotCreateHref: true, - to: WORKPLACE_SEARCH_PLUGIN.URL, - }), - }, - ] - : []), - ], - name: i18n.translate('xpack.enterpriseSearch.nav.title', { - defaultMessage: 'Enterprise Search', - }), - }, - ] - : []), - ]; + return generateSideNavItems(baseNavItems, deepLinks, { search_indices: indicesNavItems }); + }, [productAccess, indicesNavItems]); + + if (!isSidebarEnabled && !alwaysReturn) return undefined; return navItems; }; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav_link_helpers.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav_link_helpers.test.ts index fff28345bb1bb..50c85a268e366 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav_link_helpers.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav_link_helpers.test.ts @@ -36,6 +36,7 @@ describe('generateNavLink', () => { navItem.onClick({ preventDefault: jest.fn() } as any); expect(mockKibanaValues.navigateToUrl).toHaveBeenCalledWith('/test', { shouldNotCreateHref: false, + shouldNotPrepend: false, }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav_link_helpers.ts b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav_link_helpers.ts index f086433c9fc0e..36000307adcc3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav_link_helpers.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav_link_helpers.ts @@ -5,27 +5,32 @@ * 2.0. */ -import { EuiSideNavItemType } from '@elastic/eui'; +import { EuiSideNavItemTypeEnhanced } from '@kbn/core-chrome-browser'; import { stripTrailingSlash } from '../../../../common/strip_slashes'; import { KibanaLogic } from '../kibana'; -import { generateReactRouterProps, ReactRouterProps } from '../react_router_helpers'; -import { GeneratedReactRouterProps } from '../react_router_helpers/generate_react_router_props'; +import { + type GeneratedReactRouterProps, + generateReactRouterProps, +} from '../react_router_helpers/generate_react_router_props'; +import { ReactRouterProps } from '../types'; interface Params { - items?: Array>; // Primarily passed if using `items` to determine isSelected - if not, you can just set `items` outside of this helper + items?: Array>; // Primarily passed if using `items` to determine isSelected - if not, you can just set `items` outside of this helper shouldShowActiveForSubroutes?: boolean; to: string; } type NavLinkProps = GeneratedReactRouterProps & - Pick, 'isSelected' | 'items'>; + Pick, 'isSelected' | 'items'>; + +export type GenerateNavLinkParameters = Params & ReactRouterProps; export const generateNavLink = ({ items, ...rest -}: Params & ReactRouterProps): NavLinkProps => { +}: GenerateNavLinkParameters): NavLinkProps => { const linkProps = { ...generateReactRouterProps({ ...rest }), isSelected: getNavLinkActive({ items, ...rest }), @@ -38,14 +43,15 @@ export const getNavLinkActive = ({ shouldShowActiveForSubroutes = false, items = [], shouldNotCreateHref = false, -}: Params & ReactRouterProps): boolean => { + shouldNotPrepend = false, +}: GenerateNavLinkParameters): boolean => { const { pathname } = KibanaLogic.values.history.location; const currentPath = stripTrailingSlash(pathname); const { href: currentPathHref } = generateReactRouterProps({ shouldNotCreateHref: false, to: currentPath, }); - const { href: toHref } = generateReactRouterProps({ shouldNotCreateHref, to }); + const { href: toHref } = generateReactRouterProps({ shouldNotCreateHref, shouldNotPrepend, to }); if (currentPathHref === toHref) return true; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/create_href.ts b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/create_href.ts index a399d632140b6..cf02c3ed74f71 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/create_href.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/create_href.ts @@ -30,12 +30,19 @@ interface CreateHrefDeps { } export interface CreateHrefOptions { shouldNotCreateHref?: boolean; + shouldNotPrepend?: boolean; } export const createHref = ( path: string, { history, http }: CreateHrefDeps, - { shouldNotCreateHref }: CreateHrefOptions = {} + { shouldNotCreateHref, shouldNotPrepend }: CreateHrefOptions = {} ): string => { - return shouldNotCreateHref ? http.basePath.prepend(path) : history.createHref({ pathname: path }); + if (shouldNotCreateHref) { + if (shouldNotPrepend) { + return path; + } + return http.basePath.prepend(path); + } + return history.createHref({ pathname: path }); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_components.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_components.tsx index 8271f49f9f39a..708cc597e582d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_components.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_components.tsx @@ -26,7 +26,9 @@ import { } from '@elastic/eui'; import { EuiPanelProps } from '@elastic/eui/src/components/panel/panel'; -import { generateReactRouterProps, ReactRouterProps } from '.'; +import { ReactRouterProps } from '../types'; + +import { generateReactRouterProps } from '.'; /** * Correctly typed component helpers with React-Router-friendly `href` and `onClick` props diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/generate_react_router_props.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/generate_react_router_props.test.ts index 309f94fcf55b4..de2a80ee5eaf4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/generate_react_router_props.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/generate_react_router_props.test.ts @@ -44,6 +44,7 @@ describe('generateReactRouterProps', () => { expect(mockEvent.preventDefault).toHaveBeenCalled(); expect(mockKibanaValues.navigateToUrl).toHaveBeenCalledWith('/test', { shouldNotCreateHref: false, + shouldNotPrepend: false, }); }); @@ -63,6 +64,7 @@ describe('generateReactRouterProps', () => { expect(mockEvent.preventDefault).toHaveBeenCalled(); expect(mockKibanaValues.navigateToUrl).toHaveBeenCalledWith('/app/enterprise_search/test', { shouldNotCreateHref: true, + shouldNotPrepend: false, }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/generate_react_router_props.ts b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/generate_react_router_props.ts index 2ef7f556eb2d1..89219362e5be4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/generate_react_router_props.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/generate_react_router_props.ts @@ -11,6 +11,7 @@ import { EuiSideNavItemType } from '@elastic/eui'; import { HttpLogic } from '../http'; import { KibanaLogic } from '../kibana'; +import { ReactRouterProps } from '../types'; import { letBrowserHandleEvent, createHref } from '.'; @@ -23,14 +24,6 @@ import { letBrowserHandleEvent, createHref } from '.'; * but separated out from EuiLink portion as we use this for multiple EUI components */ -export interface ReactRouterProps { - to: string; - onClick?(): void; - // Used to navigate outside of the React Router plugin basename but still within Kibana, - // e.g. if we need to go from Enterprise Search to App Search - shouldNotCreateHref?: boolean; -} - export type GeneratedReactRouterProps = Required< Pick, 'href' | 'onClick'> >; @@ -39,12 +32,13 @@ export const generateReactRouterProps = ({ to, onClick, shouldNotCreateHref = false, + shouldNotPrepend = false, }: ReactRouterProps): GeneratedReactRouterProps => { const { navigateToUrl, history } = KibanaLogic.values; const { http } = HttpLogic.values; // Generate the correct link href (with basename etc. accounted for) - const href = createHref(to, { history, http }, { shouldNotCreateHref }); + const href = createHref(to, { history, http }, { shouldNotCreateHref, shouldNotPrepend }); const reactRouterLinkClick = (event: React.MouseEvent) => { if (onClick) onClick(); // Run any passed click events (e.g. telemetry) @@ -54,7 +48,7 @@ export const generateReactRouterProps = ({ event.preventDefault(); // Perform SPA navigation. - navigateToUrl(to, { shouldNotCreateHref }); + navigateToUrl(to, { shouldNotCreateHref, shouldNotPrepend }); }; return { href, onClick: reactRouterLinkClick }; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/index.ts index ded9310fe361a..237e0d342ed1f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/index.ts @@ -8,7 +8,6 @@ export { letBrowserHandleEvent } from './link_events'; export type { CreateHrefOptions } from './create_href'; export { createHref } from './create_href'; -export type { ReactRouterProps } from './generate_react_router_props'; export { generateReactRouterProps } from './generate_react_router_props'; export { EuiLinkTo, diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/types.ts b/x-pack/plugins/enterprise_search/public/applications/shared/types.ts index 51a83cb15cca5..095f1dddfcc4a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/types.ts @@ -5,7 +5,12 @@ * 2.0. */ +import type { ReactNode } from 'react'; + +import type { AppDeepLinkId, EuiSideNavItemTypeEnhanced } from '@kbn/core-chrome-browser'; + import { APP_SEARCH_PLUGIN, WORKPLACE_SEARCH_PLUGIN } from '../../../common/constants'; +import type { ProductAccess } from '../../../common/types'; import { ADD, UPDATE } from './constants/operations'; @@ -57,3 +62,37 @@ export interface SingleUserRoleMapping { roleMapping: T; hasEnterpriseSearchRole?: boolean; } + +export interface ReactRouterProps { + to: string; + onClick?(): void; + // Used to navigate outside of the React Router plugin basename but still within Kibana, + // e.g. if we need to go from Enterprise Search to App Search + shouldNotCreateHref?: boolean; + // Used if to is already a fully qualified URL that doesn't need basePath prepended + shouldNotPrepend?: boolean; +} + +export type GenerateNavLinkParameters = { + items?: Array>; // Primarily passed if using `items` to determine isSelected - if not, you can just set `items` outside of this helper + shouldShowActiveForSubroutes?: boolean; + to: string; +} & ReactRouterProps; + +export interface GenerateNavLinkFromDeepLinkParameters { + link: AppDeepLinkId; + shouldShowActiveForSubroutes?: boolean; +} + +export interface BuildClassicNavParameters { + productAccess: ProductAccess; +} + +export interface ClassicNavItem { + 'data-test-subj'?: string; + deepLink?: GenerateNavLinkFromDeepLinkParameters; + iconToString?: string; + id: string; + items?: ClassicNavItem[]; + name?: ReactNode; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/test_helpers/test_utils.test_helper.tsx b/x-pack/plugins/enterprise_search/public/applications/test_helpers/test_utils.test_helper.tsx index d1729a50909ed..da30e6e93fadb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/test_helpers/test_utils.test_helper.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/test_helpers/test_utils.test_helper.tsx @@ -58,6 +58,7 @@ export const mockKibanaProps: KibanaLogicProps = { elasticsearch_host: 'https://your_deployment_url', }, getChromeStyle$: jest.fn().mockReturnValue(of('classic')), + getNavLinks: jest.fn().mockReturnValue([]), guidedOnboarding: {}, history: mockHistory, indexMappingComponent: () => { diff --git a/x-pack/plugins/enterprise_search/server/integrations.ts b/x-pack/plugins/enterprise_search/server/integrations.ts index 2918ef862dbdf..b3d0a6a3295ea 100644 --- a/x-pack/plugins/enterprise_search/server/integrations.ts +++ b/x-pack/plugins/enterprise_search/server/integrations.ts @@ -4,9 +4,10 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import type { CustomIntegrationsPluginSetup } from '@kbn/custom-integrations-plugin/server'; import { i18n } from '@kbn/i18n'; -import { ConnectorServerSideDefinition } from '@kbn/search-connectors-plugin/server'; +import { ConnectorServerSideDefinition } from '@kbn/search-connectors'; import { ConfigType } from '.'; diff --git a/x-pack/plugins/enterprise_search/server/utils/search_result_provider.ts b/x-pack/plugins/enterprise_search/server/utils/search_result_provider.ts index 15b1971c6aecd..a5ce3aeb367da 100644 --- a/x-pack/plugins/enterprise_search/server/utils/search_result_provider.ts +++ b/x-pack/plugins/enterprise_search/server/utils/search_result_provider.ts @@ -10,7 +10,7 @@ import { takeUntil, of, map } from 'rxjs'; import { GlobalSearchResultProvider } from '@kbn/global-search-plugin/server'; import { i18n } from '@kbn/i18n'; -import { ConnectorServerSideDefinition } from '@kbn/search-connectors-plugin/server'; +import { ConnectorServerSideDefinition } from '@kbn/search-connectors'; import { ConfigType } from '..'; import { diff --git a/x-pack/plugins/entity_manager/kibana.jsonc b/x-pack/plugins/entity_manager/kibana.jsonc index efd6d3a445b3f..d5dadcf8fd2b7 100644 --- a/x-pack/plugins/entity_manager/kibana.jsonc +++ b/x-pack/plugins/entity_manager/kibana.jsonc @@ -2,6 +2,8 @@ "type": "plugin", "id": "@kbn/entityManager-plugin", "owner": "@elastic/obs-entities", + "group": "platform", + "visibility": "shared", "description": "Entity manager plugin for entity assets (inventory, topology, etc)", "plugin": { "id": "entityManager", diff --git a/x-pack/plugins/entity_manager/public/lib/entity_client.test.ts b/x-pack/plugins/entity_manager/public/lib/entity_client.test.ts new file mode 100644 index 0000000000000..dbaf1205cdf98 --- /dev/null +++ b/x-pack/plugins/entity_manager/public/lib/entity_client.test.ts @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EntityClient, EnitityInstance } from './entity_client'; +import { coreMock } from '@kbn/core/public/mocks'; + +const commonEntityFields: EnitityInstance = { + entity: { + last_seen_timestamp: '2023-10-09T00:00:00Z', + id: '1', + display_name: 'entity_name', + definition_id: 'entity_definition_id', + } as EnitityInstance['entity'], +}; + +describe('EntityClient', () => { + let entityClient: EntityClient; + + beforeEach(() => { + entityClient = new EntityClient(coreMock.createStart()); + }); + + describe('asKqlFilter', () => { + it('should return the kql filter', () => { + const entityLatest: EnitityInstance = { + entity: { + ...commonEntityFields.entity, + identity_fields: ['service.name', 'service.environment'], + type: 'service', + }, + service: { + name: 'my-service', + }, + }; + + const result = entityClient.asKqlFilter(entityLatest); + expect(result).toEqual('service.name: my-service'); + }); + + it('should return the kql filter when indentity_fields is composed by multiple fields', () => { + const entityLatest: EnitityInstance = { + entity: { + ...commonEntityFields.entity, + identity_fields: ['service.name', 'service.environment'], + type: 'service', + }, + service: { + name: 'my-service', + environment: 'staging', + }, + }; + + const result = entityClient.asKqlFilter(entityLatest); + expect(result).toEqual('(service.name: my-service AND service.environment: staging)'); + }); + + it('should ignore fields that are not present in the entity', () => { + const entityLatest: EnitityInstance = { + entity: { + ...commonEntityFields.entity, + identity_fields: ['host.name', 'foo.bar'], + }, + host: { + name: 'my-host', + }, + }; + + const result = entityClient.asKqlFilter(entityLatest); + expect(result).toEqual('host.name: my-host'); + }); + }); + + describe('getIdentityFieldsValue', () => { + it('should return identity fields values', () => { + const entityLatest: EnitityInstance = { + entity: { + ...commonEntityFields.entity, + identity_fields: ['service.name', 'service.environment'], + type: 'service', + }, + service: { + name: 'my-service', + }, + }; + + expect(entityClient.getIdentityFieldsValue(entityLatest)).toEqual({ + 'service.name': 'my-service', + }); + }); + + it('should return identity fields values when indentity_fields is composed by multiple fields', () => { + const entityLatest: EnitityInstance = { + entity: { + ...commonEntityFields.entity, + identity_fields: ['service.name', 'service.environment'], + type: 'service', + }, + service: { + name: 'my-service', + environment: 'staging', + }, + }; + + expect(entityClient.getIdentityFieldsValue(entityLatest)).toEqual({ + 'service.name': 'my-service', + 'service.environment': 'staging', + }); + }); + + it('should return identity fields when field is in the root', () => { + const entityLatest: EnitityInstance = { + entity: { + ...commonEntityFields.entity, + identity_fields: ['name'], + type: 'service', + }, + name: 'foo', + }; + + expect(entityClient.getIdentityFieldsValue(entityLatest)).toEqual({ + name: 'foo', + }); + }); + + it('should throw an error when identity fields are missing', () => { + const entityLatest: EnitityInstance = { + ...commonEntityFields, + }; + + expect(() => entityClient.getIdentityFieldsValue(entityLatest)).toThrow( + 'Identity fields are missing' + ); + }); + }); +}); diff --git a/x-pack/plugins/entity_manager/public/lib/entity_client.ts b/x-pack/plugins/entity_manager/public/lib/entity_client.ts index dc22a0b991b0d..08794873ba930 100644 --- a/x-pack/plugins/entity_manager/public/lib/entity_client.ts +++ b/x-pack/plugins/entity_manager/public/lib/entity_client.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { z } from '@kbn/zod'; import { CoreSetup, CoreStart } from '@kbn/core/public'; import { ClientRequestParamsOf, @@ -12,6 +13,9 @@ import { createRepositoryClient, isHttpFetchError, } from '@kbn/server-route-repository-client'; +import { type KueryNode, nodeTypes, toKqlExpression } from '@kbn/es-query'; +import { entityLatestSchema } from '@kbn/entities-schema'; +import { castArray } from 'lodash'; import { DisableManagedEntityResponse, EnableManagedEntityResponse, @@ -35,6 +39,8 @@ type CreateEntityDefinitionQuery = QueryParamOf< ClientRequestParamsOf >; +export type EnitityInstance = z.infer; + export class EntityClient { public readonly repositoryClient: EntityManagerRepositoryClient['fetch']; @@ -83,4 +89,38 @@ export class EntityClient { throw err; } } + + asKqlFilter(entityLatest: EnitityInstance) { + const identityFieldsValue = this.getIdentityFieldsValue(entityLatest); + + const nodes: KueryNode[] = Object.entries(identityFieldsValue).map(([identityField, value]) => { + return nodeTypes.function.buildNode('is', identityField, value); + }); + + if (nodes.length === 0) return ''; + + const kqlExpression = nodes.length > 1 ? nodeTypes.function.buildNode('and', nodes) : nodes[0]; + + return toKqlExpression(kqlExpression); + } + + getIdentityFieldsValue(entityLatest: EnitityInstance) { + const { identity_fields: identityFields } = entityLatest.entity; + + if (!identityFields) { + throw new Error('Identity fields are missing'); + } + + return castArray(identityFields).reduce((acc, field) => { + const value = field.split('.').reduce((obj: any, part: string) => { + return obj && typeof obj === 'object' ? (obj as Record)[part] : undefined; + }, entityLatest); + + if (value) { + acc[field] = value; + } + + return acc; + }, {} as Record); + } } 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 67e9f52e32bf5..4e1dd263f9ca3 100644 --- a/x-pack/plugins/entity_manager/server/lib/entity_client.ts +++ b/x-pack/plugins/entity_manager/server/lib/entity_client.ts @@ -117,9 +117,7 @@ export class EntityClient { }); if (!definition) { - const message = `Unable to find entity definition [${id}]`; - this.options.logger.error(message); - throw new EntityDefinitionNotFound(message); + throw new EntityDefinitionNotFound(`Unable to find entity definition [${id}]`); } this.options.logger.info( diff --git a/x-pack/plugins/event_log/kibana.jsonc b/x-pack/plugins/event_log/kibana.jsonc index ae1da1389b1eb..8a792f2a43914 100644 --- a/x-pack/plugins/event_log/kibana.jsonc +++ b/x-pack/plugins/event_log/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/event-log-plugin", - "owner": "@elastic/response-ops", + "owner": [ + "@elastic/response-ops" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "eventLog", - "server": true, "browser": false, + "server": true, "configPath": [ "xpack", "eventLog" @@ -15,4 +19,4 @@ "serverless" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/features/kibana.jsonc b/x-pack/plugins/features/kibana.jsonc index ac9f52175f458..f4b3e364a94fe 100644 --- a/x-pack/plugins/features/kibana.jsonc +++ b/x-pack/plugins/features/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/features-plugin", - "owner": "@elastic/kibana-core", + "owner": [ + "@elastic/kibana-core" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "features", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "features" @@ -17,4 +21,4 @@ "common" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/features/server/mocks.ts b/x-pack/plugins/features/server/mocks.ts index bb2292a45377f..15339b068e7e8 100644 --- a/x-pack/plugins/features/server/mocks.ts +++ b/x-pack/plugins/features/server/mocks.ts @@ -25,8 +25,8 @@ const createSetup = (): jest.Mocked => { const createStart = (): jest.Mocked => { return { - getKibanaFeatures: jest.fn(), - getElasticsearchFeatures: jest.fn(), + getKibanaFeatures: jest.fn().mockReturnValue([]), + getElasticsearchFeatures: jest.fn().mockReturnValue([]), }; }; diff --git a/x-pack/plugins/features/server/plugin.ts b/x-pack/plugins/features/server/plugin.ts index 15888358bb773..9f6cae36f6aee 100644 --- a/x-pack/plugins/features/server/plugin.ts +++ b/x-pack/plugins/features/server/plugin.ts @@ -138,7 +138,8 @@ export class FeaturesPlugin this.featureRegistry.validateFeatures(); this.capabilities = uiCapabilitiesForFeatures( - this.featureRegistry.getAllKibanaFeatures(), + // Don't expose capabilities of the deprecated features. + this.featureRegistry.getAllKibanaFeatures({ omitDeprecated: true }), this.featureRegistry.getAllElasticsearchFeatures() ); diff --git a/x-pack/plugins/features/server/routes/index.ts b/x-pack/plugins/features/server/routes/index.ts index b0da6cf4a0659..b06efbb170ad4 100644 --- a/x-pack/plugins/features/server/routes/index.ts +++ b/x-pack/plugins/features/server/routes/index.ts @@ -21,8 +21,12 @@ export function defineRoutes({ router, featureRegistry }: RouteDefinitionParams) router.get( { path: '/api/features', + security: { + authz: { + requiredPrivileges: ['read_features'], + }, + }, options: { - tags: ['access:features'], access: 'public', summary: `Get features`, }, diff --git a/x-pack/plugins/fields_metadata/kibana.jsonc b/x-pack/plugins/fields_metadata/kibana.jsonc index 2befc0c7be07b..37cdaaf92c2b3 100644 --- a/x-pack/plugins/fields_metadata/kibana.jsonc +++ b/x-pack/plugins/fields_metadata/kibana.jsonc @@ -2,6 +2,8 @@ "type": "plugin", "id": "@kbn/fields-metadata-plugin", "owner": "@elastic/obs-ux-logs-team", + "group": "platform", + "visibility": "shared", "description": "Exposes services for async usage and search of fields metadata.", "plugin": { "id": "fieldsMetadata", diff --git a/x-pack/plugins/file_upload/kibana.jsonc b/x-pack/plugins/file_upload/kibana.jsonc index 6c6e3fddd0e7c..5d49da5beb4a7 100644 --- a/x-pack/plugins/file_upload/kibana.jsonc +++ b/x-pack/plugins/file_upload/kibana.jsonc @@ -1,12 +1,17 @@ { "type": "plugin", "id": "@kbn/file-upload-plugin", - "owner": ["@elastic/kibana-gis", "@elastic/ml-ui"], + "owner": [ + "@elastic/kibana-presentation", + "@elastic/ml-ui" + ], + "group": "platform", + "visibility": "private", "description": "The file upload plugin contains components and services for uploading a file, analyzing its data, and then importing the data into an Elasticsearch index. Supported file types include CSV, TSV, newline-delimited JSON and GeoJSON.", "plugin": { "id": "fileUpload", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "data", "usageCollection" diff --git a/x-pack/plugins/file_upload/public/components/__snapshots__/import_complete_view.test.tsx.snap b/x-pack/plugins/file_upload/public/components/__snapshots__/import_complete_view.test.tsx.snap index 8dd4858dfea5d..4108062e4fec4 100644 --- a/x-pack/plugins/file_upload/public/components/__snapshots__/import_complete_view.test.tsx.snap +++ b/x-pack/plugins/file_upload/public/components/__snapshots__/import_complete_view.test.tsx.snap @@ -348,7 +348,7 @@ exports[`Should render success 1`] = ` /> { onIndexNameValidationEnd={this.props.onIndexNameValidationEnd} /> - + { , logge .addVersion( { version: '1', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because permissions will be checked by elasticsearch', + }, + }, validate: { request: { query: schema.object({ @@ -155,6 +162,13 @@ export function fileUploadRoutes(coreSetup: CoreSetup, logge .addVersion( { version: '1', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because permissions will be checked by elasticsearch', + }, + }, validate: { request: { query: importFileQuerySchema, @@ -206,6 +220,13 @@ export function fileUploadRoutes(coreSetup: CoreSetup, logge .addVersion( { version: '1', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because permissions will be checked by elasticsearch', + }, + }, validate: { request: { body: schema.object({ index: schema.string() }), diff --git a/x-pack/plugins/fleet/common/services/agent_policies_helpers.ts b/x-pack/plugins/fleet/common/services/agent_policies_helpers.ts index 8a1e268614684..5729947feea31 100644 --- a/x-pack/plugins/fleet/common/services/agent_policies_helpers.ts +++ b/x-pack/plugins/fleet/common/services/agent_policies_helpers.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; + import type { NewAgentPolicy, AgentPolicy } from '../types'; import { FLEET_SERVER_PACKAGE, @@ -13,6 +15,12 @@ import { FLEET_ENDPOINT_PACKAGE, } from '../constants'; +export function getDefaultFleetServerpolicyId(spaceId?: string) { + return !spaceId || spaceId === '' || spaceId === DEFAULT_SPACE_ID + ? 'fleet-server-policy' + : `${spaceId}-fleet-server-policy`; +} + export function policyHasFleetServer( agentPolicy: Pick ) { diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index 95b03787112e2..45d2908faa0b2 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -124,10 +124,25 @@ export type InstallablePackage = RegistryPackage | ArchivePackage; export type AssetsMap = Map; +export interface ArchiveEntry { + path: string; + buffer?: Buffer; +} + +export interface ArchiveIterator { + traverseEntries: (onEntry: (entry: ArchiveEntry) => Promise) => Promise; + getPaths: () => Promise; +} + export interface PackageInstallContext { packageInfo: InstallablePackage; + /** + * @deprecated Use `archiveIterator` to access the package archive entries + * without loading them all into memory at once. + */ assetsMap: AssetsMap; paths: string[]; + archiveIterator: ArchiveIterator; } export type ArchivePackage = PackageSpecManifest & diff --git a/x-pack/plugins/fleet/cypress/tasks/common.ts b/x-pack/plugins/fleet/cypress/tasks/common.ts index cf161640bf03f..2bf201b11a498 100644 --- a/x-pack/plugins/fleet/cypress/tasks/common.ts +++ b/x-pack/plugins/fleet/cypress/tasks/common.ts @@ -67,7 +67,6 @@ export const internalRequest = ({ const NEW_FEATURES_TOUR_STORAGE_KEYS = { RULE_MANAGEMENT_PAGE: 'securitySolution.rulesManagementPage.newFeaturesTour.v8.9', TIMELINES: 'securitySolution.security.timelineFlyoutHeader.saveTimelineTour', - FLYOUT: 'securitySolution.documentDetails.newFeaturesTour.v8.14', }; const disableNewFeaturesTours = (window: Window) => { diff --git a/x-pack/plugins/fleet/kibana.jsonc b/x-pack/plugins/fleet/kibana.jsonc index dded2caf4c7e2..823328da8ada6 100644 --- a/x-pack/plugins/fleet/kibana.jsonc +++ b/x-pack/plugins/fleet/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/fleet-plugin", - "owner": "@elastic/fleet", + "owner": [ + "@elastic/fleet" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "fleet", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "fleet" @@ -25,7 +29,8 @@ "uiActions", "dashboard", "fieldsMetadata", - "logsDataAccess" + "logsDataAccess", + "spaces" ], "optionalPlugins": [ "features", @@ -36,9 +41,8 @@ "telemetry", "discover", "ingestPipelines", - "spaces", "guidedOnboarding", - "integrationAssistant", + "integrationAssistant" ], "requiredBundles": [ "kibanaReact", @@ -52,4 +56,4 @@ "common" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/hooks/use_quick_start_form.ts b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/hooks/use_quick_start_form.ts index e56ae45b1661a..559fcad522351 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/hooks/use_quick_start_form.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/hooks/use_quick_start_form.ts @@ -5,30 +5,35 @@ * 2.0. */ -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback, useEffect, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; +import { getDefaultFleetServerpolicyId } from '../../../../../../common/services/agent_policies_helpers'; import type { useComboInput, useInput, useSwitchInput } from '../../../hooks'; -import { sendCreateAgentPolicy, sendGetOneAgentPolicy, useStartServices } from '../../../hooks'; - +import { + sendCreateAgentPolicy, + sendGetOneAgentPolicy, + useFleetStatus, + useStartServices, +} from '../../../hooks'; import type { NewAgentPolicy } from '../../../types'; - import type { FleetServerHost } from '../../../types'; - import { useServiceToken } from '../../../hooks/use_service_token'; import { useSelectFleetServerPolicy } from './use_select_fleet_server_policy'; import { useFleetServerHost } from './use_fleet_server_host'; -const QUICK_START_FLEET_SERVER_POLICY_FIELDS: NewAgentPolicy = { - id: 'fleet-server-policy', - name: 'Fleet Server Policy', - description: 'Fleet Server policy generated by Kibana', - namespace: 'default', - has_fleet_server: true, - monitoring_enabled: ['logs', 'metrics'], - is_default_fleet_server: true, -}; +function getQuickStartFleetServerPolicyFields(spaceId?: string): NewAgentPolicy { + return { + id: getDefaultFleetServerpolicyId(spaceId), + name: 'Fleet Server Policy', + description: 'Fleet Server policy generated by Kibana', + namespace: 'default', + has_fleet_server: true, + monitoring_enabled: ['logs', 'metrics'], + is_default_fleet_server: true, + }; +} export type QuickStartCreateFormStatus = 'initial' | 'loading' | 'error' | 'success'; @@ -69,6 +74,7 @@ export const useQuickStartCreateForm = (): QuickStartCreateForm => { setFleetServerHost, inputs, } = useFleetServerHost(); + const { spaceId } = useFleetStatus(); // When a validation error is surfaced from the Fleet Server host form, we want to treat it // the same way we do errors from the service token or policy creation steps @@ -81,6 +87,11 @@ export const useQuickStartCreateForm = (): QuickStartCreateForm => { const { fleetServerPolicyId, setFleetServerPolicyId } = useSelectFleetServerPolicy(); const { serviceToken, generateServiceToken } = useServiceToken(); + const quickStartFleetServerPolicyFields = useMemo( + () => getQuickStartFleetServerPolicyFields(spaceId), + [spaceId] + ); + const submit = useCallback(async () => { try { if (!fleetServerHost || fleetServerHost) { @@ -98,16 +109,14 @@ export const useQuickStartCreateForm = (): QuickStartCreateForm => { await generateServiceToken(); - const existingPolicy = await sendGetOneAgentPolicy( - QUICK_START_FLEET_SERVER_POLICY_FIELDS.id! - ); + const existingPolicy = await sendGetOneAgentPolicy(quickStartFleetServerPolicyFields.id!); // Don't attempt to create the policy if it's already been created in a previous quick start flow if (existingPolicy.data?.item) { setFleetServerPolicyId(existingPolicy.data?.item.id); } else { const createPolicyResponse = await sendCreateAgentPolicy( - QUICK_START_FLEET_SERVER_POLICY_FIELDS, + quickStartFleetServerPolicyFields, { withSysMonitoring: true, } @@ -134,6 +143,7 @@ export const useQuickStartCreateForm = (): QuickStartCreateForm => { generateServiceToken, setFleetServerPolicyId, notifications.toasts, + quickStartFleetServerPolicyFields, ]); return { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.test.tsx index 1ebfe5a897b07..62b39e2e8708a 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.test.tsx @@ -6,7 +6,9 @@ */ import React from 'react'; -import { act, fireEvent, waitFor } from '@testing-library/react'; +import { waitFor, act } from '@testing-library/react'; + +import { userEvent } from '@testing-library/user-event'; import { getInheritedNamespace } from '../../../../../../../../common/services'; @@ -60,18 +62,6 @@ describe('StepDefinePackagePolicy', () => { package_policies: [], is_protected: false, }, - { - id: 'agent-policy-2', - namespace: 'default', - name: 'Agent policy 2', - is_managed: false, - status: 'active', - updated_at: '', - updated_by: '', - revision: 1, - package_policies: [], - is_protected: false, - }, ]; let packagePolicy: NewPackagePolicy; const mockUpdatePackagePolicy = jest.fn().mockImplementation((val: any) => { @@ -86,20 +76,23 @@ describe('StepDefinePackagePolicy', () => { description: null, namespace: null, inputs: {}, - vars: {}, + vars: { + 'Required var': ['Required var is required'], + }, }; let testRenderer: TestRenderer; let renderResult: ReturnType; - const render = () => + + const render = (namespacePlaceholder = getInheritedNamespace(agentPolicies)) => (renderResult = testRenderer.render( )); @@ -107,57 +100,100 @@ describe('StepDefinePackagePolicy', () => { packagePolicy = { name: '', description: 'desc', - namespace: 'default', + namespace: 'package-policy-ns', + enabled: true, policy_id: '', policy_ids: [''], - enabled: true, + package: { + name: 'apache', + title: 'Apache', + version: '1.0.0', + }, inputs: [], + vars: { + 'Show user var': { + type: 'string', + value: 'showUserVarVal', + }, + 'Required var': { + type: 'bool', + value: undefined, + }, + 'Advanced var': { + type: 'bool', + value: true, + }, + }, }; testRenderer = createFleetTestRendererMock(); }); describe('default API response', () => { - beforeEach(() => { - render(); - }); - it('should display vars coming from package policy', async () => { - waitFor(() => { - expect(renderResult.getByDisplayValue('showUserVarVal')).toBeInTheDocument(); - expect(renderResult.getByRole('switch')).toHaveAttribute('aria-label', 'Required var'); - expect(renderResult.getByText('Required var is required')).toHaveAttribute( - 'class', - 'euiFormErrorText' + act(() => { + render(); + }); + expect(renderResult.getByDisplayValue('showUserVarVal')).toBeInTheDocument(); + expect(renderResult.getByRole('switch', { name: 'Required var' })).toBeInTheDocument(); + expect(renderResult.queryByRole('switch', { name: 'Advanced var' })).not.toBeInTheDocument(); + + expect(renderResult.getByText('Required var is required')).toHaveClass('euiFormErrorText'); + + await userEvent.click(renderResult.getByText('Advanced options').closest('button')!); + + await waitFor(() => { + expect(renderResult.getByRole('switch', { name: 'Advanced var' })).toBeInTheDocument(); + expect(renderResult.getByTestId('packagePolicyNamespaceInput')).toHaveTextContent( + 'package-policy-ns' ); }); + }); - await act(async () => { - fireEvent.click(renderResult.getByText('Advanced options').closest('button')!); + it(`should display namespace from agent policy when there's no package policy namespace`, async () => { + packagePolicy.namespace = ''; + act(() => { + render(); }); - waitFor(() => { - expect(renderResult.getByRole('switch')).toHaveAttribute('aria-label', 'Advanced var'); - expect(renderResult.getByTestId('packagePolicyNamespaceInput')).toHaveAttribute( + await userEvent.click(renderResult.getByText('Advanced options').closest('button')!); + + await waitFor(() => { + expect(renderResult.getByTestId('comboBoxSearchInput')).toHaveAttribute( 'placeholder', 'ns' ); }); }); + + it(`should fallback to the default namespace when namespace is not set in package policy and there's no agent policy`, async () => { + packagePolicy.namespace = ''; + act(() => { + render(getInheritedNamespace([])); + }); + + await userEvent.click(renderResult.getByText('Advanced options').closest('button')!); + + await waitFor(() => { + expect(renderResult.getByTestId('comboBoxSearchInput')).toHaveAttribute( + 'placeholder', + 'default' + ); + }); + }); }); describe('update', () => { describe('when package vars are introduced in a new package version', () => { - it('should display new package vars', () => { - render(); - - waitFor(async () => { - expect(renderResult.getByDisplayValue('showUserVarVal')).toBeInTheDocument(); - expect(renderResult.getByText('Required var')).toBeInTheDocument(); + it('should display new package vars', async () => { + act(() => { + render(); + }); + expect(renderResult.getByDisplayValue('showUserVarVal')).toBeInTheDocument(); + expect(renderResult.getByText('Required var')).toBeInTheDocument(); - await act(async () => { - fireEvent.click(renderResult.getByText('Advanced options').closest('button')!); - }); + await userEvent.click(renderResult.getByText('Advanced options').closest('button')!); + await waitFor(async () => { expect(renderResult.getByText('Advanced var')).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_hosts.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_hosts.test.tsx index 7d1962939d1fa..583957861bd79 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_hosts.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_hosts.test.tsx @@ -6,7 +6,9 @@ */ import React from 'react'; -import { act, fireEvent, waitFor } from '@testing-library/react'; +import { waitFor } from '@testing-library/react'; + +import { userEvent } from '@testing-library/user-event'; import type { TestRenderer } from '../../../../../../../mock'; import { createFleetTestRendererMock } from '../../../../../../../mock'; @@ -108,22 +110,23 @@ describe('StepSelectHosts', () => { testRenderer = createFleetTestRendererMock(); }); - it('should display create form when no agent policies', () => { + it('should display create form when no agent policies', async () => { (useGetAgentPolicies as jest.MockedFunction).mockReturnValue({ data: { items: [], }, }); + (useAllNonManagedAgentPolicies as jest.MockedFunction).mockReturnValue([]); render(); - waitFor(() => { - expect(renderResult.getByText('Agent policy 1')).toBeInTheDocument(); + await waitFor(() => { + expect(renderResult.getByText('New agent policy name')).toBeInTheDocument(); }); expect(renderResult.queryByRole('tablist')).not.toBeInTheDocument(); }); - it('should display tabs with New hosts selected when agent policies exist', () => { + it('should display tabs with New hosts selected when agent policies exist', async () => { (useGetAgentPolicies as jest.MockedFunction).mockReturnValue({ data: { items: [{ id: '1', name: 'Agent policy 1', namespace: 'default' }], @@ -135,10 +138,7 @@ describe('StepSelectHosts', () => { render(); - waitFor(() => { - expect(renderResult.getByRole('tablist')).toBeInTheDocument(); - expect(renderResult.getByText('Agent policy 3')).toBeInTheDocument(); - }); + expect(renderResult.getByRole('tablist')).toBeInTheDocument(); expect(renderResult.getByText('New hosts').closest('button')).toHaveAttribute( 'aria-selected', 'true' @@ -157,16 +157,15 @@ describe('StepSelectHosts', () => { render(); - waitFor(() => { - expect(renderResult.getByRole('tablist')).toBeInTheDocument(); - }); - act(() => { - fireEvent.click(renderResult.getByText('Existing hosts').closest('button')!); - }); + expect(renderResult.getByRole('tablist')).toBeInTheDocument(); + + await userEvent.click(renderResult.getByText('Existing hosts').closest('button')!); - expect( - renderResult.container.querySelector('[data-test-subj="agentPolicySelect"]')?.textContent - ).toContain('Agent policy 1'); + await waitFor(() => { + expect( + renderResult.container.querySelector('[data-test-subj="agentPolicySelect"]')?.textContent + ).toContain('Agent policy 1'); + }); }); it('should display dropdown without preselected value when Existing hosts selected with mulitple agent policies', async () => { @@ -185,14 +184,11 @@ describe('StepSelectHosts', () => { render(); - waitFor(() => { - expect(renderResult.getByRole('tablist')).toBeInTheDocument(); - }); - act(() => { - fireEvent.click(renderResult.getByText('Existing hosts').closest('button')!); - }); + expect(renderResult.getByRole('tablist')).toBeInTheDocument(); + + await userEvent.click(renderResult.getByText('Existing hosts').closest('button')!); - await act(async () => { + await waitFor(() => { const select = renderResult.container.querySelector('[data-test-subj="agentPolicySelect"]'); expect((select as any)?.value).toEqual(''); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.test.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.test.ts index ebdc578a04752..3fe1890e75f72 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.test.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { renderHook, act } from '@testing-library/react-hooks'; +import { renderHook, act } from '@testing-library/react-hooks/dom'; import { waitFor } from '@testing-library/react'; @@ -228,7 +228,7 @@ describe('useSetupTechnology', () => { }); it('should fetch agentless policy if agentless feature is enabled and isServerless is true', async () => { - const { waitForNextUpdate } = renderHook(() => + renderHook(() => useSetupTechnology({ setNewAgentPolicy, newAgentPolicy: newAgentPolicyMock, @@ -238,9 +238,9 @@ describe('useSetupTechnology', () => { }) ); - await waitForNextUpdate(); - - expect(sendGetOneAgentPolicy).toHaveBeenCalled(); + await waitFor(() => { + expect(sendGetOneAgentPolicy).toHaveBeenCalled(); + }); }); it('should set agentless setup technology if agent policy supports agentless in edit page', async () => { @@ -286,7 +286,7 @@ describe('useSetupTechnology', () => { isCloudEnabled: true, }, }); - const { result, waitForNextUpdate } = renderHook(() => + const { result } = renderHook(() => useSetupTechnology({ setNewAgentPolicy, newAgentPolicy: newAgentPolicyMock, @@ -301,14 +301,13 @@ describe('useSetupTechnology', () => { act(() => { result.current.handleSetupTechnologyChange(SetupTechnology.AGENTLESS); }); - - waitForNextUpdate(); - - expect(result.current.selectedSetupTechnology).toBe(SetupTechnology.AGENTLESS); - expect(setNewAgentPolicy).toHaveBeenCalledWith({ - name: 'Agentless policy for endpoint-1', - supports_agentless: true, - inactivity_timeout: 3600, + await waitFor(() => { + expect(result.current.selectedSetupTechnology).toBe(SetupTechnology.AGENTLESS); + expect(setNewAgentPolicy).toHaveBeenCalledWith({ + name: 'Agentless policy for endpoint-1', + supports_agentless: true, + inactivity_timeout: 3600, + }); }); }); @@ -326,15 +325,18 @@ describe('useSetupTechnology', () => { isCloudEnabled: true, }, }); - const { result, rerender } = renderHook(() => - useSetupTechnology({ - setNewAgentPolicy, - newAgentPolicy: newAgentPolicyMock, - updateAgentPolicies: updateAgentPoliciesMock, - setSelectedPolicyTab: setSelectedPolicyTabMock, - packagePolicy: packagePolicyMock, - }) - ); + + const initialProps = { + setNewAgentPolicy, + newAgentPolicy: newAgentPolicyMock, + updateAgentPolicies: updateAgentPoliciesMock, + setSelectedPolicyTab: setSelectedPolicyTabMock, + packagePolicy: packagePolicyMock, + }; + + const { result, rerender } = renderHook((props = initialProps) => useSetupTechnology(props), { + initialProps, + }); expect(generateNewAgentPolicyWithDefaults).toHaveBeenCalled(); @@ -360,7 +362,7 @@ describe('useSetupTechnology', () => { }, }); - waitFor(() => { + await waitFor(() => { expect(setNewAgentPolicy).toHaveBeenCalledWith({ name: 'Agentless policy for endpoint-2', inactivity_timeout: 3600, @@ -377,7 +379,7 @@ describe('useSetupTechnology', () => { }, }); - const { result, waitForNextUpdate } = renderHook(() => + const { result } = renderHook(() => useSetupTechnology({ setNewAgentPolicy, newAgentPolicy: newAgentPolicyMock, @@ -393,8 +395,7 @@ describe('useSetupTechnology', () => { result.current.handleSetupTechnologyChange(SetupTechnology.AGENT_BASED); }); - waitForNextUpdate(); - expect(setNewAgentPolicy).toHaveBeenCalledTimes(0); + await waitFor(() => expect(setNewAgentPolicy).toHaveBeenCalledTimes(0)); }); it('should not fetch agentless policy if agentless is enabled but serverless is disabled', async () => { @@ -419,7 +420,7 @@ describe('useSetupTechnology', () => { }); it('should update agent policy and selected policy tab when setup technology is agentless', async () => { - const { result, waitForNextUpdate } = renderHook(() => + const { result } = renderHook(() => useSetupTechnology({ setNewAgentPolicy, newAgentPolicy: newAgentPolicyMock, @@ -429,18 +430,24 @@ describe('useSetupTechnology', () => { }) ); - await waitForNextUpdate(); - act(() => { result.current.handleSetupTechnologyChange(SetupTechnology.AGENTLESS); }); - expect(updateAgentPoliciesMock).toHaveBeenCalledWith([{ id: 'agentless-policy-id' }]); - expect(setSelectedPolicyTabMock).toHaveBeenCalledWith(SelectedPolicyTab.EXISTING); + await waitFor(() => { + expect(updateAgentPoliciesMock).toHaveBeenCalledWith([ + { + inactivity_timeout: 3600, + name: 'Agentless policy for endpoint-1', + supports_agentless: true, + }, + ]); + expect(setSelectedPolicyTabMock).toHaveBeenCalledWith(SelectedPolicyTab.EXISTING); + }); }); it('should update new agent policy and selected policy tab when setup technology is agent-based', async () => { - const { result, waitForNextUpdate } = renderHook(() => + const { result } = renderHook(() => useSetupTechnology({ setNewAgentPolicy, newAgentPolicy: newAgentPolicyMock, @@ -450,8 +457,6 @@ describe('useSetupTechnology', () => { }) ); - await waitForNextUpdate(); - expect(result.current.selectedSetupTechnology).toBe(SetupTechnology.AGENT_BASED); act(() => { @@ -466,8 +471,10 @@ describe('useSetupTechnology', () => { expect(result.current.selectedSetupTechnology).toBe(SetupTechnology.AGENT_BASED); - expect(setNewAgentPolicy).toHaveBeenCalledWith(newAgentPolicyMock); - expect(setSelectedPolicyTabMock).toHaveBeenCalledWith(SelectedPolicyTab.NEW); + await waitFor(() => { + expect(setNewAgentPolicy).toHaveBeenCalledWith(newAgentPolicyMock); + expect(setSelectedPolicyTabMock).toHaveBeenCalledWith(SelectedPolicyTab.NEW); + }); }); it('should not update agent policy and selected policy tab when agentless is disabled', async () => { @@ -495,7 +502,7 @@ describe('useSetupTechnology', () => { }); it('should not update agent policy and selected policy tab when setup technology matches the current one ', async () => { - const { result, waitForNextUpdate } = renderHook(() => + const { result } = renderHook(() => useSetupTechnology({ setNewAgentPolicy, newAgentPolicy: newAgentPolicyMock, @@ -505,7 +512,7 @@ describe('useSetupTechnology', () => { }) ); - await waitForNextUpdate(); + await waitFor(() => new Promise((resolve) => resolve(null))); expect(result.current.selectedSetupTechnology).toBe(SetupTechnology.AGENT_BASED); @@ -520,7 +527,7 @@ describe('useSetupTechnology', () => { }); it('should revert the agent policy name to the original value when switching from agentless back to agent-based', async () => { - const { result, rerender } = renderHook(() => + const { result } = renderHook(() => useSetupTechnology({ setNewAgentPolicy, newAgentPolicy: newAgentPolicyMock, @@ -529,7 +536,6 @@ describe('useSetupTechnology', () => { packagePolicy: packagePolicyMock, }) ); - await rerender(); expect(result.current.selectedSetupTechnology).toBe(SetupTechnology.AGENT_BASED); @@ -538,20 +544,68 @@ describe('useSetupTechnology', () => { }); expect(result.current.selectedSetupTechnology).toBe(SetupTechnology.AGENTLESS); - expect(setNewAgentPolicy).toHaveBeenCalledWith({ - id: 'agentless-policy-id', + + await waitFor(() => { + expect(setNewAgentPolicy).toHaveBeenCalledWith({ + name: 'Agentless policy for endpoint-1', + supports_agentless: true, + inactivity_timeout: 3600, + }); }); act(() => { result.current.handleSetupTechnologyChange(SetupTechnology.AGENT_BASED); }); + + expect(result.current.selectedSetupTechnology).toBe(SetupTechnology.AGENT_BASED); + expect(setNewAgentPolicy).toHaveBeenCalledWith(newAgentPolicyMock); + }); + + it('should have global_data_tags with the integration team when creating agentless policy with global_data_tags', async () => { + (useConfig as MockFn).mockReturnValue({ + agentless: { + enabled: true, + api: { + url: 'https://agentless.api.url', + }, + }, + } as any); + (useStartServices as MockFn).mockReturnValue({ + cloud: { + isCloudEnabled: true, + }, + }); + + const { result } = renderHook(() => + useSetupTechnology({ + setNewAgentPolicy, + newAgentPolicy: newAgentPolicyMock, + updateAgentPolicies: updateAgentPoliciesMock, + setSelectedPolicyTab: setSelectedPolicyTabMock, + packagePolicy: packagePolicyMock, + packageInfo: packageInfoMock, + }) + ); + + act(() => { + result.current.handleSetupTechnologyChange(SetupTechnology.AGENTLESS, 'cspm'); + }); + await waitFor(() => { - expect(result.current.selectedSetupTechnology).toBe(SetupTechnology.AGENT_BASED); - expect(setNewAgentPolicy).toHaveBeenCalledWith(newAgentPolicyMock); + expect(setNewAgentPolicy).toHaveBeenCalledWith( + expect.objectContaining({ + supports_agentless: true, + global_data_tags: [ + { name: 'organization', value: 'org' }, + { name: 'division', value: 'div' }, + { name: 'team', value: 'team' }, + ], + }) + ); }); }); - it('should have global_data_tags with the integration team when updating the agentless policy', async () => { + it('should not fail and not have global_data_tags when creating the agentless policy when it cannot find the policy template', async () => { (useConfig as MockFn).mockReturnValue({ agentless: { enabled: true, @@ -574,19 +628,23 @@ describe('useSetupTechnology', () => { setSelectedPolicyTab: setSelectedPolicyTabMock, packagePolicy: packagePolicyMock, packageInfo: packageInfoMock, - isEditPage: true, - agentPolicies: [{ id: 'agentless-policy-id', supports_agentless: true } as any], }) ); act(() => { - result.current.handleSetupTechnologyChange(SetupTechnology.AGENTLESS, 'cspm'); + result.current.handleSetupTechnologyChange( + SetupTechnology.AGENTLESS, + 'never-gonna-give-you-up' + ); }); - waitFor(() => { + await waitFor(() => { expect(setNewAgentPolicy).toHaveBeenCalledWith({ - ...newAgentPolicyMock, + name: 'Agentless policy for endpoint-1', supports_agentless: true, + inactivity_timeout: 3600, + }); + expect(setNewAgentPolicy).not.toHaveBeenCalledWith({ global_data_tags: [ { name: 'organization', value: 'org' }, { name: 'division', value: 'div' }, @@ -596,7 +654,7 @@ describe('useSetupTechnology', () => { }); }); - it('should not fail and not have global_data_tags when updating the agentless policy when it cannot find the policy template', async () => { + it('should not fail and not have global_data_tags when creating the agentless policy without the policy template name', async () => { (useConfig as MockFn).mockReturnValue({ agentless: { enabled: true, @@ -618,27 +676,31 @@ describe('useSetupTechnology', () => { updateAgentPolicies: updateAgentPoliciesMock, setSelectedPolicyTab: setSelectedPolicyTabMock, packagePolicy: packagePolicyMock, - isEditPage: true, - agentPolicies: [{ id: 'agentless-policy-id', supports_agentless: true } as any], + packageInfo: packageInfoMock, }) ); act(() => { - result.current.handleSetupTechnologyChange( - SetupTechnology.AGENTLESS, - 'never-gonna-give-you-up' - ); + result.current.handleSetupTechnologyChange(SetupTechnology.AGENTLESS); }); - waitFor(() => { + await waitFor(() => { expect(setNewAgentPolicy).toHaveBeenCalledWith({ - ...newAgentPolicyMock, + name: 'Agentless policy for endpoint-1', supports_agentless: true, + inactivity_timeout: 3600, + }); + expect(setNewAgentPolicy).not.toHaveBeenCalledWith({ + global_data_tags: [ + { name: 'organization', value: 'org' }, + { name: 'division', value: 'div' }, + { name: 'team', value: 'team' }, + ], }); }); }); - it('should not fail and not have global_data_tags when updating the agentless policy without the policy temaplte name', async () => { + it('should not fail and not have global_data_tags when creating the agentless policy without the packageInfo', async () => { (useConfig as MockFn).mockReturnValue({ agentless: { enabled: true, @@ -660,25 +722,30 @@ describe('useSetupTechnology', () => { updateAgentPolicies: updateAgentPoliciesMock, setSelectedPolicyTab: setSelectedPolicyTabMock, packagePolicy: packagePolicyMock, - packageInfo: packageInfoMock, - isEditPage: true, - agentPolicies: [{ id: 'agentless-policy-id', supports_agentless: true } as any], }) ); act(() => { - result.current.handleSetupTechnologyChange(SetupTechnology.AGENTLESS); + result.current.handleSetupTechnologyChange(SetupTechnology.AGENTLESS, 'cspm'); }); - waitFor(() => { + await waitFor(() => { expect(setNewAgentPolicy).toHaveBeenCalledWith({ - ...newAgentPolicyMock, + name: 'Agentless policy for endpoint-1', supports_agentless: true, + inactivity_timeout: 3600, + }); + expect(setNewAgentPolicy).not.toHaveBeenCalledWith({ + global_data_tags: [ + { name: 'organization', value: 'org' }, + { name: 'division', value: 'div' }, + { name: 'team', value: 'team' }, + ], }); }); }); - it('should not fail and not have global_data_tags when updating the agentless policy without the packageInfo', async () => { + it('should not have global_data_tags when switching from agentless to agent-based policy', async () => { (useConfig as MockFn).mockReturnValue({ agentless: { enabled: true, @@ -700,8 +767,7 @@ describe('useSetupTechnology', () => { updateAgentPolicies: updateAgentPoliciesMock, setSelectedPolicyTab: setSelectedPolicyTabMock, packagePolicy: packagePolicyMock, - isEditPage: true, - agentPolicies: [{ id: 'agentless-policy-id', supports_agentless: true } as any], + packageInfo: packageInfoMock, }) ); @@ -709,10 +775,31 @@ describe('useSetupTechnology', () => { result.current.handleSetupTechnologyChange(SetupTechnology.AGENTLESS, 'cspm'); }); - waitFor(() => { - expect(setNewAgentPolicy).toHaveBeenCalledWith({ - ...newAgentPolicyMock, - supports_agentless: true, + await waitFor(() => { + expect(setNewAgentPolicy).toHaveBeenCalledWith( + expect.objectContaining({ + supports_agentless: true, + global_data_tags: [ + { name: 'organization', value: 'org' }, + { name: 'division', value: 'div' }, + { name: 'team', value: 'team' }, + ], + }) + ); + }); + + act(() => { + result.current.handleSetupTechnologyChange(SetupTechnology.AGENT_BASED); + }); + + await waitFor(() => { + expect(setNewAgentPolicy).toHaveBeenCalledWith(newAgentPolicyMock); + expect(setNewAgentPolicy).not.toHaveBeenCalledWith({ + global_data_tags: [ + { name: 'organization', value: 'org' }, + { name: 'division', value: 'div' }, + { name: 'team', value: 'team' }, + ], }); }); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.ts index 2a88fecc6b145..52841905b3c6d 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.ts @@ -168,6 +168,7 @@ export function useSetupTechnology({ } as NewAgentPolicy; setNewAgentPolicy(agentlessPolicy); + setNewAgentlessPolicy(agentlessPolicy); setSelectedPolicyTab(SelectedPolicyTab.NEW); updateAgentPolicies([agentlessPolicy] as AgentPolicy[]); } diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.test.tsx index ff89db3e0c842..4826a6b329bf5 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.test.tsx @@ -154,14 +154,7 @@ afterAll(() => { consoleDebugMock.mockRestore(); }); -// FLAKY: https://github.com/elastic/kibana/issues/196463 -// FLAKY: https://github.com/elastic/kibana/issues/196464 -// FLAKY: https://github.com/elastic/kibana/issues/196465 -// FLAKY: https://github.com/elastic/kibana/issues/196466 -// FLAKY: https://github.com/elastic/kibana/issues/196467 -// FLAKY: https://github.com/elastic/kibana/issues/196468 -// FLAKY: https://github.com/elastic/kibana/issues/196469 -describe.skip('When on the package policy create page', () => { +describe('When on the package policy create page', () => { afterEach(() => { jest.clearAllMocks(); }); @@ -868,7 +861,7 @@ describe.skip('When on the package policy create page', () => { test('should create agentless agent policy and package policy when in cloud and agentless API url is set', async () => { fireEvent.click(renderResult.getByTestId(SETUP_TECHNOLOGY_SELECTOR_TEST_SUBJ)); - fireEvent.click(renderResult.getByText('Agentless')); + fireEvent.click(renderResult.getAllByText('Agentless')[0]); await act(async () => { fireEvent.click(renderResult.getByText(/Save and continue/).closest('button')!); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/components/step_edit_hosts.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/components/step_edit_hosts.test.tsx index 064624d364a92..e30fa6c22c5ce 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/components/step_edit_hosts.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/components/step_edit_hosts.test.tsx @@ -6,7 +6,9 @@ */ import React from 'react'; -import { act, fireEvent, waitFor } from '@testing-library/react'; +import { waitFor } from '@testing-library/react'; + +import { userEvent } from '@testing-library/user-event'; import type { TestRenderer } from '../../../../../../mock'; import { createFleetTestRendererMock } from '../../../../../../mock'; @@ -111,18 +113,18 @@ describe('StepEditHosts', () => { testRenderer = createFleetTestRendererMock(); }); - it('should display create form when no agent policies', () => { + it('should display create form when no agent policies', async () => { (useGetAgentPolicies as jest.MockedFunction).mockReturnValue({ data: { items: [], }, }); + (useAllNonManagedAgentPolicies as jest.MockedFunction).mockReturnValue([]); + render(); - waitFor(() => { - expect(renderResult.getByText('Agent policy 1')).toBeInTheDocument(); - }); + expect(renderResult.getByText('New agent policy name')).toBeInTheDocument(); expect(renderResult.queryByRole('tablist')).not.toBeInTheDocument(); }); @@ -144,7 +146,7 @@ describe('StepEditHosts', () => { ).toContain('Agent policy 1'); }); - it('should display dropdown without preselected value when mulitple agent policies', () => { + it('should display dropdown without preselected value when multiple agent policies', async () => { (useGetAgentPolicies as jest.MockedFunction).mockReturnValue({ data: { items: [ @@ -156,12 +158,12 @@ describe('StepEditHosts', () => { render(); - waitFor(() => { - expect(renderResult.getByText('At least one agent policy is required.')).toBeInTheDocument(); - }); + expect( + renderResult.getByText('Select an agent policy to add this integration to') + ).toBeInTheDocument(); }); - it('should display delete button when add button clicked', () => { + it('should display delete button when add button clicked', async () => { (useGetAgentPolicies as jest.MockedFunction).mockReturnValue({ data: { items: [{ id: '1', name: 'Agent policy 1', namespace: 'default' }], @@ -173,10 +175,12 @@ describe('StepEditHosts', () => { render(); - act(() => { - fireEvent.click(renderResult.getByTestId('createNewAgentPolicyButton').closest('button')!); - }); + await userEvent.click( + renderResult.getByTestId('createNewAgentPolicyButton').closest('button')! + ); - expect(renderResult.getByTestId('deleteNewAgentPolicyButton')).toBeInTheDocument(); + await waitFor(() => { + expect(renderResult.getByTestId('deleteNewAgentPolicyButton')).toBeInTheDocument(); + }); }); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_fetch_agents_data.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_fetch_agents_data.tsx index 35233a5b79fea..e27eb43bfad10 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_fetch_agents_data.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_fetch_agents_data.tsx @@ -52,6 +52,7 @@ function useFullAgentPolicyFetcher() { if (policiesToFetchIds.length) { const bulkGetAgentPoliciesResponse = await sendBulkGetAgentPolicies(policiesToFetchIds, { full: authz.fleet.readAgentPolicies, + ignoreMissing: true, }); if (bulkGetAgentPoliciesResponse.error) { diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/documentation/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/documentation/index.tsx index a418c38cd8f33..c4fc2863868a3 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/documentation/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/documentation/index.tsx @@ -79,7 +79,7 @@ export const DocumentationPage: React.FunctionComponent = ({ packageInfo, defaultMessage="This documents all the inputs, streams, and variables available to use this integration programmatically via the Fleet Kibana API. {learnMore}" values={{ learnMore: ( - +
{children}
, h6: ({ children }) =>
{children}
, - link: ({ children, href }: { children: React.ReactNode[]; href?: string }) => ( + a: ({ children, href }: { children: React.ReactNode[]; href?: string }) => ( , @@ -69,6 +70,7 @@ export const installPackageKibanaAssetsHandler: FleetRequestHandler< packageInfo, paths: installedPkgWithAssets.paths, assetsMap: installedPkgWithAssets.assetsMap, + archiveIterator: createArchiveIteratorFromMap(installedPkgWithAssets.assetsMap), }, }); diff --git a/x-pack/plugins/fleet/server/services/agent_policies/package_policies_to_agent_inputs.ts b/x-pack/plugins/fleet/server/services/agent_policies/package_policies_to_agent_inputs.ts index 807312fe5e7cb..f2a39c6f8800b 100644 --- a/x-pack/plugins/fleet/server/services/agent_policies/package_policies_to_agent_inputs.ts +++ b/x-pack/plugins/fleet/server/services/agent_policies/package_policies_to_agent_inputs.ts @@ -112,7 +112,8 @@ export const mergeInputsOverrides = ( export const getFullInputStreams = ( input: PackagePolicyInput, - allStreamEnabled: boolean = false + allStreamEnabled: boolean = false, + streamsOriginalIdsMap?: Map // Map of stream ids ): FullAgentPolicyInputStream => { return { ...(input.compiled_input || {}), @@ -121,8 +122,9 @@ export const getFullInputStreams = ( streams: input.streams .filter((stream) => stream.enabled || allStreamEnabled) .map((stream) => { + const streamId = stream.id; const fullStream: FullAgentPolicyInputStream = { - id: stream.id, + id: streamId, data_stream: stream.data_stream, ...stream.compiled_stream, ...Object.entries(stream.config || {}).reduce((acc, [key, { value }]) => { @@ -130,6 +132,8 @@ export const getFullInputStreams = ( return acc; }, {} as { [k: string]: any }), }; + streamsOriginalIdsMap?.set(fullStream.id, streamId); + return fullStream; }), } diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 672b623afb030..a7ffee033dc13 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -30,6 +30,8 @@ import { asyncForEach } from '@kbn/std'; import type { SavedObjectError } from '@kbn/core-saved-objects-common'; +import { withSpan } from '@kbn/apm-utils'; + import { getAllowedOutputTypeForPolicy, packageToPackagePolicy, @@ -167,11 +169,13 @@ class AgentPolicyService { removeProtection: boolean; skipValidation: boolean; returnUpdatedPolicy?: boolean; + asyncDeploy?: boolean; } = { bumpRevision: true, removeProtection: false, skipValidation: false, returnUpdatedPolicy: true, + asyncDeploy: false, } ): Promise { const savedObjectType = await getAgentPolicySavedObjectType(); @@ -225,10 +229,19 @@ class AgentPolicyService { newAgentPolicy!.package_policies = existingAgentPolicy.package_policies; if (options.bumpRevision || options.removeProtection) { - await this.triggerAgentPolicyUpdatedEvent(esClient, 'updated', id, { - spaceId: soClient.getCurrentNamespace(), - agentPolicy: newAgentPolicy, - }); + if (!options.asyncDeploy) { + await this.triggerAgentPolicyUpdatedEvent(esClient, 'updated', id, { + spaceId: soClient.getCurrentNamespace(), + agentPolicy: newAgentPolicy, + }); + } else { + await scheduleDeployAgentPoliciesTask(appContextService.getTaskManagerStart()!, [ + { + id, + spaceId: soClient.getCurrentNamespace(), + }, + ]); + } } logger.debug( `Agent policy ${id} update completed, revision: ${ @@ -875,13 +888,16 @@ class AgentPolicyService { soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, id: string, - options?: { user?: AuthenticatedUser; removeProtection?: boolean } + options?: { user?: AuthenticatedUser; removeProtection?: boolean; asyncDeploy?: boolean } ): Promise { - await this._update(soClient, esClient, id, {}, options?.user, { - bumpRevision: true, - removeProtection: options?.removeProtection ?? false, - skipValidation: false, - returnUpdatedPolicy: false, + return withSpan('bump_agent_policy_revision', async () => { + await this._update(soClient, esClient, id, {}, options?.user, { + bumpRevision: true, + removeProtection: options?.removeProtection ?? false, + skipValidation: false, + returnUpdatedPolicy: false, + asyncDeploy: options?.asyncDeploy, + }); }); } diff --git a/x-pack/plugins/fleet/server/services/agent_policy_create.ts b/x-pack/plugins/fleet/server/services/agent_policy_create.ts index f370867fc493b..3902548581595 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy_create.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy_create.ts @@ -11,6 +11,7 @@ import type { SavedObjectsClientContract, } from '@kbn/core/server'; +import { getDefaultFleetServerpolicyId } from '../../common/services/agent_policies_helpers'; import type { HTTPAuthorizationHeader } from '../../common/http_authorization_header'; import { @@ -27,23 +28,25 @@ import { bulkInstallPackages } from './epm/packages'; import { ensureDefaultEnrollmentAPIKeyForAgentPolicy } from './api_keys'; import { agentlessAgentService } from './agents/agentless_agent'; -const FLEET_SERVER_POLICY_ID = 'fleet-server-policy'; - async function getFleetServerAgentPolicyId( soClient: SavedObjectsClientContract ): Promise { let agentPolicyId; - // creating first fleet server policy with id 'fleet-server-policy' + // creating first fleet server policy with id '(space-)?fleet-server-policy' let agentPolicy; try { - agentPolicy = await agentPolicyService.get(soClient, FLEET_SERVER_POLICY_ID, false); + agentPolicy = await agentPolicyService.get( + soClient, + getDefaultFleetServerpolicyId(soClient.getCurrentNamespace()), + false + ); } catch (err) { if (!err.isBoom || err.output.statusCode !== 404) { throw err; } } if (!agentPolicy) { - agentPolicyId = FLEET_SERVER_POLICY_ID; + agentPolicyId = getDefaultFleetServerpolicyId(soClient.getCurrentNamespace()); } return agentPolicyId; } @@ -118,7 +121,7 @@ export async function createAgentPolicyWithPackages({ packagesToInstall.push(FLEET_SERVER_PACKAGE); agentPolicyId = agentPolicyId || (await getFleetServerAgentPolicyId(soClient)); - if (agentPolicyId === FLEET_SERVER_POLICY_ID) { + if (agentPolicyId === getDefaultFleetServerpolicyId(spaceId)) { // setting first fleet server policy to default, so that fleet server can enroll without setting policy_id newPolicy.is_default_fleet_server = true; } diff --git a/x-pack/plugins/fleet/server/services/agents/agentless_agent.test.ts b/x-pack/plugins/fleet/server/services/agents/agentless_agent.test.ts index fe8b7a220470d..b278cda4fc278 100644 --- a/x-pack/plugins/fleet/server/services/agents/agentless_agent.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/agentless_agent.test.ts @@ -814,9 +814,11 @@ describe('Agentless Agent service', () => { policy_id: 'mocked-agentless-agent-policy-id', stack_version: 'mocked-kibana-version-infinite', labels: { - organization: 'elastic', - division: 'cloud', - team: 'fleet', + owner: { + org: 'elastic', + division: 'cloud', + team: 'fleet', + }, }, }), headers: expect.anything(), @@ -911,9 +913,11 @@ describe('Agentless Agent service', () => { fleet_url: 'http://fleetserver:8220', policy_id: 'mocked-agentless-agent-policy-id', labels: { - organization: 'elastic', - division: 'cloud', - team: 'fleet', + owner: { + org: 'elastic', + division: 'cloud', + team: 'fleet', + }, }, }, headers: expect.anything(), diff --git a/x-pack/plugins/fleet/server/services/agents/agentless_agent.ts b/x-pack/plugins/fleet/server/services/agents/agentless_agent.ts index 7400b5958eb65..314af0ada7bf4 100644 --- a/x-pack/plugins/fleet/server/services/agents/agentless_agent.ts +++ b/x-pack/plugins/fleet/server/services/agents/agentless_agent.ts @@ -220,9 +220,11 @@ class AgentlessAgentService { agentlessAgentPolicy.global_data_tags?.find((tag) => tag.name === name)?.value; return { - organization: getGlobalTagValueByName(AGENTLESS_GLOBAL_TAG_NAME_ORGANIZATION), - division: getGlobalTagValueByName(AGENTLESS_GLOBAL_TAG_NAME_DIVISION), - team: getGlobalTagValueByName(AGENTLESS_GLOBAL_TAG_NAME_TEAM), + owner: { + org: getGlobalTagValueByName(AGENTLESS_GLOBAL_TAG_NAME_ORGANIZATION), + division: getGlobalTagValueByName(AGENTLESS_GLOBAL_TAG_NAME_DIVISION), + team: getGlobalTagValueByName(AGENTLESS_GLOBAL_TAG_NAME_TEAM), + }, }; } diff --git a/x-pack/plugins/fleet/server/services/epm/archive/archive_iterator.ts b/x-pack/plugins/fleet/server/services/epm/archive/archive_iterator.ts new file mode 100644 index 0000000000000..369b32412bd82 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/archive/archive_iterator.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AssetsMap, ArchiveIterator, ArchiveEntry } from '../../../../common/types'; + +import { traverseArchiveEntries } from '.'; + +/** + * Creates an iterator for traversing and extracting paths from an archive + * buffer. This iterator is intended to be used for memory efficient traversal + * of archive contents without extracting the entire archive into memory. + * + * @param archiveBuffer - The buffer containing the archive data. + * @param contentType - The content type of the archive (e.g., + * 'application/zip'). + * @returns ArchiveIterator instance. + * + */ +export const createArchiveIterator = ( + archiveBuffer: Buffer, + contentType: string +): ArchiveIterator => { + const paths: string[] = []; + + const traverseEntries = async ( + onEntry: (entry: ArchiveEntry) => Promise + ): Promise => { + await traverseArchiveEntries(archiveBuffer, contentType, async (entry) => { + await onEntry(entry); + }); + }; + + const getPaths = async (): Promise => { + if (paths.length) { + return paths; + } + + await traverseEntries(async (entry) => { + paths.push(entry.path); + }); + + return paths; + }; + + return { + traverseEntries, + getPaths, + }; +}; + +/** + * Creates an archive iterator from the assetsMap. This is a stop-gap solution + * to provide a uniform interface for traversing assets while assetsMap is still + * in use. It works with a map of assets loaded into memory and is not intended + * for use with large archives. + * + * @param assetsMap - A map where the keys are asset paths and the values are + * asset buffers. + * @returns ArchiveIterator instance. + * + */ +export const createArchiveIteratorFromMap = (assetsMap: AssetsMap): ArchiveIterator => { + const traverseEntries = async ( + onEntry: (entry: ArchiveEntry) => Promise + ): Promise => { + for (const [path, buffer] of assetsMap) { + await onEntry({ path, buffer }); + } + }; + + const getPaths = async (): Promise => { + return [...assetsMap.keys()]; + }; + + return { + traverseEntries, + getPaths, + }; +}; diff --git a/x-pack/plugins/fleet/server/services/epm/archive/extract.ts b/x-pack/plugins/fleet/server/services/epm/archive/extract.ts index 84aa161385cb3..9f5f90959d144 100644 --- a/x-pack/plugins/fleet/server/services/epm/archive/extract.ts +++ b/x-pack/plugins/fleet/server/services/epm/archive/extract.ts @@ -11,13 +11,12 @@ import * as tar from 'tar'; import yauzl from 'yauzl'; import { bufferToStream, streamToBuffer } from '../streams'; - -import type { ArchiveEntry } from '.'; +import type { ArchiveEntry } from '../../../../common/types'; export async function untarBuffer( buffer: Buffer, filter = (entry: ArchiveEntry): boolean => true, - onEntry = (entry: ArchiveEntry): void => {} + onEntry = async (entry: ArchiveEntry): Promise => {} ) { const deflatedStream = bufferToStream(buffer); // use tar.list vs .extract to avoid writing to disk @@ -37,7 +36,7 @@ export async function untarBuffer( export async function unzipBuffer( buffer: Buffer, filter = (entry: ArchiveEntry): boolean => true, - onEntry = (entry: ArchiveEntry): void => {} + onEntry = async (entry: ArchiveEntry): Promise => {} ): Promise { const zipfile = await yauzlFromBuffer(buffer, { lazyEntries: true }); zipfile.readEntry(); @@ -45,9 +44,12 @@ export async function unzipBuffer( const path = entry.fileName; if (!filter({ path })) return zipfile.readEntry(); - const entryBuffer = await getZipReadStream(zipfile, entry).then(streamToBuffer); - onEntry({ buffer: entryBuffer, path }); - zipfile.readEntry(); + try { + const entryBuffer = await getZipReadStream(zipfile, entry).then(streamToBuffer); + await onEntry({ buffer: entryBuffer, path }); + } finally { + zipfile.readEntry(); + } }); return new Promise((resolve, reject) => zipfile.on('end', resolve).on('error', reject)); } diff --git a/x-pack/plugins/fleet/server/services/epm/archive/index.ts b/x-pack/plugins/fleet/server/services/epm/archive/index.ts index 5943f8f838fcb..ed9ff2a5e4b72 100644 --- a/x-pack/plugins/fleet/server/services/epm/archive/index.ts +++ b/x-pack/plugins/fleet/server/services/epm/archive/index.ts @@ -5,13 +5,20 @@ * 2.0. */ -import type { AssetParts, AssetsMap } from '../../../../common/types'; +import type { + ArchiveEntry, + ArchiveIterator, + AssetParts, + AssetsMap, +} from '../../../../common/types'; import { PackageInvalidArchiveError, PackageUnsupportedMediaTypeError, PackageNotFoundError, } from '../../../errors'; +import { createArchiveIterator } from './archive_iterator'; + import { deletePackageInfo } from './cache'; import type { SharedKey } from './cache'; import { getBufferExtractor } from './extract'; @@ -20,66 +27,85 @@ export * from './cache'; export { getBufferExtractor, untarBuffer, unzipBuffer } from './extract'; export { generatePackageInfoFromArchiveBuffer } from './parse'; -export interface ArchiveEntry { - path: string; - buffer?: Buffer; -} - export async function unpackBufferToAssetsMap({ - name, - version, contentType, archiveBuffer, + useStreaming, }: { - name: string; - version: string; contentType: string; archiveBuffer: Buffer; -}): Promise<{ paths: string[]; assetsMap: AssetsMap }> { - const assetsMap = new Map(); - const paths: string[] = []; - const entries = await unpackBufferEntries(archiveBuffer, contentType); - - entries.forEach((entry) => { - const { path, buffer } = entry; - if (buffer) { - assetsMap.set(path, buffer); - paths.push(path); - } - }); - - return { assetsMap, paths }; + useStreaming: boolean | undefined; +}): Promise<{ paths: string[]; assetsMap: AssetsMap; archiveIterator: ArchiveIterator }> { + const archiveIterator = createArchiveIterator(archiveBuffer, contentType); + let paths: string[] = []; + let assetsMap: AssetsMap = new Map(); + if (useStreaming) { + paths = await archiveIterator.getPaths(); + // We keep the assetsMap empty as we don't want to load all the assets in memory + assetsMap = new Map(); + } else { + const entries = await unpackArchiveEntriesIntoMemory(archiveBuffer, contentType); + + entries.forEach((entry) => { + const { path, buffer } = entry; + if (buffer) { + assetsMap.set(path, buffer); + paths.push(path); + } + }); + } + + return { paths, assetsMap, archiveIterator }; } -export async function unpackBufferEntries( +/** + * This function extracts all archive entries into memory. + * + * NOTE: This is potentially dangerous for large archives and can cause OOM + * errors. Use 'traverseArchiveEntries' instead to iterate over the entries + * without storing them all in memory at once. + * + * @param archiveBuffer + * @param contentType + * @returns All the entries in the archive buffer + */ +export async function unpackArchiveEntriesIntoMemory( archiveBuffer: Buffer, contentType: string ): Promise { + const entries: ArchiveEntry[] = []; + const addToEntries = async (entry: ArchiveEntry) => void entries.push(entry); + await traverseArchiveEntries(archiveBuffer, contentType, addToEntries); + + // While unpacking a tar.gz file with unzipBuffer() will result in a thrown + // error, unpacking a zip file with untarBuffer() just results in nothing. + if (entries.length === 0) { + throw new PackageInvalidArchiveError( + `Archive seems empty. Assumed content type was ${contentType}, check if this matches the archive type.` + ); + } + return entries; +} + +export async function traverseArchiveEntries( + archiveBuffer: Buffer, + contentType: string, + onEntry: (entry: ArchiveEntry) => Promise +) { const bufferExtractor = getBufferExtractor({ contentType }); if (!bufferExtractor) { throw new PackageUnsupportedMediaTypeError( `Unsupported media type ${contentType}. Please use 'application/gzip' or 'application/zip'` ); } - const entries: ArchiveEntry[] = []; try { const onlyFiles = ({ path }: ArchiveEntry): boolean => !path.endsWith('/'); - const addToEntries = (entry: ArchiveEntry) => entries.push(entry); - await bufferExtractor(archiveBuffer, onlyFiles, addToEntries); + await bufferExtractor(archiveBuffer, onlyFiles, onEntry); } catch (error) { throw new PackageInvalidArchiveError( `Error during extraction of package: ${error}. Assumed content type was ${contentType}, check if this matches the archive type.` ); } - - // While unpacking a tar.gz file with unzipBuffer() will result in a thrown error in the try-catch above, - // unpacking a zip file with untarBuffer() just results in nothing. - if (entries.length === 0) { - throw new PackageInvalidArchiveError( - `Archive seems empty. Assumed content type was ${contentType}, check if this matches the archive type.` - ); - } - return entries; } export const deletePackageCache = ({ name, version }: SharedKey) => { diff --git a/x-pack/plugins/fleet/server/services/epm/archive/parse.ts b/x-pack/plugins/fleet/server/services/epm/archive/parse.ts index 7baa071ed47f7..d9dc0e63d8b29 100644 --- a/x-pack/plugins/fleet/server/services/epm/archive/parse.ts +++ b/x-pack/plugins/fleet/server/services/epm/archive/parse.ts @@ -40,7 +40,7 @@ import { import { PackageInvalidArchiveError } from '../../../errors'; import { pkgToPkgKey } from '../registry'; -import { unpackBufferEntries } from '.'; +import { traverseArchiveEntries } from '.'; const readFileAsync = promisify(readFile); export const MANIFEST_NAME = 'manifest.yml'; @@ -160,9 +160,8 @@ export async function generatePackageInfoFromArchiveBuffer( contentType: string ): Promise<{ paths: string[]; packageInfo: ArchivePackage }> { const assetsMap: AssetsBufferMap = {}; - const entries = await unpackBufferEntries(archiveBuffer, contentType); const paths: string[] = []; - entries.forEach(({ path: bufferPath, buffer }) => { + await traverseArchiveEntries(archiveBuffer, contentType, async ({ path: bufferPath, buffer }) => { paths.push(bufferPath); if (buffer && filterAssetPathForParseAndVerifyArchive(bufferPath)) { assetsMap[bufferPath] = buffer; diff --git a/x-pack/plugins/fleet/server/services/epm/archive/storage.ts b/x-pack/plugins/fleet/server/services/epm/archive/storage.ts index dd6321445df75..8f6f151383d5a 100644 --- a/x-pack/plugins/fleet/server/services/epm/archive/storage.ts +++ b/x-pack/plugins/fleet/server/services/epm/archive/storage.ts @@ -15,6 +15,7 @@ import { SavedObjectsErrorHelpers } from '@kbn/core/server'; import { ASSETS_SAVED_OBJECT_TYPE } from '../../../../common'; import type { + ArchiveEntry, InstallablePackage, InstallSource, PackageAssetReference, @@ -24,7 +25,6 @@ import { PackageInvalidArchiveError, PackageNotFoundError } from '../../../error import { appContextService } from '../../app_context'; import { setPackageInfo } from '.'; -import type { ArchiveEntry } from '.'; import { filterAssetPathForParseAndVerifyArchive, parseAndVerifyArchive } from './parse'; const ONE_BYTE = 1024 * 1024; diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts index a456734747324..5a4672f67fe53 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts @@ -16,14 +16,13 @@ import type { PackageInfo, } from '../../../../types'; import { getAssetFromAssetsMap, getPathParts } from '../../archive'; -import type { ArchiveEntry } from '../../archive'; import { FLEET_FINAL_PIPELINE_CONTENT, FLEET_FINAL_PIPELINE_ID, FLEET_FINAL_PIPELINE_VERSION, } from '../../../../constants'; import { getPipelineNameForDatastream } from '../../../../../common/services'; -import type { PackageInstallContext } from '../../../../../common/types'; +import type { ArchiveEntry, PackageInstallContext } from '../../../../../common/types'; import { appendMetadataToIngestPipeline } from '../meta'; import { retryTransientEsErrors } from '../retry'; diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts index 2ee8477e04f42..2a17768ac1f9c 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts @@ -319,6 +319,7 @@ export function buildComponentTemplates(params: { experimentalDataStreamFeature?: ExperimentalDataStreamFeature; lifecycle?: IndexTemplate['template']['lifecycle']; fieldCount?: number; + type?: string; }) { const { templateName, @@ -330,6 +331,7 @@ export function buildComponentTemplates(params: { experimentalDataStreamFeature, lifecycle, fieldCount, + type, } = params; const packageTemplateName = `${templateName}${PACKAGE_TEMPLATE_SUFFIX}`; const userSettingsTemplateName = `${templateName}${USER_SETTINGS_TEMPLATE_SUFFIX}`; @@ -417,6 +419,17 @@ export function buildComponentTemplates(params: { _meta, }; + // Stub custom template + if (type) { + const customTemplateName = `${type}${USER_SETTINGS_TEMPLATE_SUFFIX}`; + templatesMap[customTemplateName] = { + template: { + settings: {}, + }, + _meta, + }; + } + // return empty/stub template templatesMap[userSettingsTemplateName] = { template: { @@ -580,6 +593,7 @@ export function prepareTemplate({ experimentalDataStreamFeature, lifecycle: lifecyle, fieldCount: countFields(validFields), + type: dataStream.type, }); const template = getTemplate({ diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/mappings.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/mappings.test.ts index f34015bf77697..de962850fba8c 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/mappings.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/mappings.test.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { createArchiveIteratorFromMap } from '../../archive/archive_iterator'; + import { loadMappingForTransform } from './mappings'; describe('loadMappingForTransform', () => { @@ -13,6 +15,7 @@ describe('loadMappingForTransform', () => { { packageInfo: {} as any, assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), paths: [], }, 'test' @@ -49,6 +52,7 @@ describe('loadMappingForTransform', () => { ), ], ]), + archiveIterator: createArchiveIteratorFromMap(new Map()), paths: [ '/package/ti_opencti/2.1.0/elasticsearch/transform/latest_ioc/fields/ecs.yml', '/package/ti_opencti/2.1.0/elasticsearch/transform/latest_ioc/fields/ecs-extra.yml', diff --git a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts index 276478099daf8..6361aa1bf935b 100644 --- a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts @@ -133,6 +133,20 @@ export async function installKibanaAssets(options: { return []; } + await createDefaultIndexPatterns(savedObjectsImporter); + await makeManagedIndexPatternsGlobal(savedObjectsClient); + + return await installKibanaSavedObjects({ + logger, + savedObjectsImporter, + kibanaAssets: assetsToInstall, + assetsChunkSize: MAX_ASSETS_TO_INSTALL_IN_PARALLEL, + }); +} + +export async function createDefaultIndexPatterns( + savedObjectsImporter: SavedObjectsImporterContract +) { // Create index patterns separately with `overwrite: false` to prevent blowing away users' runtime fields. // These don't get retried on conflict, because we expect that they exist once an integration has been installed. const indexPatternSavedObjects = getIndexPatternSavedObjects() as ArchiveAsset[]; @@ -143,15 +157,6 @@ export async function installKibanaAssets(options: { refresh: false, managed: true, }); - - await makeManagedIndexPatternsGlobal(savedObjectsClient); - - return await installKibanaSavedObjects({ - logger, - savedObjectsImporter, - kibanaAssets: assetsToInstall, - assetsChunkSize: MAX_ASSETS_TO_INSTALL_IN_PARALLEL, - }); } export async function installKibanaAssetsAndReferencesMultispace({ @@ -325,16 +330,16 @@ export async function deleteKibanaAssetsAndReferencesForSpace({ await saveKibanaAssetsRefs(savedObjectsClient, pkgName, [], true); } +const kibanaAssetTypes = Object.values(KibanaAssetType); +export const isKibanaAssetType = (path: string) => { + const parts = getPathParts(path); + + return parts.service === 'kibana' && (kibanaAssetTypes as string[]).includes(parts.type); +}; + export function getKibanaAssets( packageInstallContext: PackageInstallContext ): Record { - const kibanaAssetTypes = Object.values(KibanaAssetType); - const isKibanaAssetType = (path: string) => { - const parts = getPathParts(path); - - return parts.service === 'kibana' && (kibanaAssetTypes as string[]).includes(parts.type); - }; - const result = Object.fromEntries( kibanaAssetTypes.map((type) => [type, []]) ) as Record; diff --git a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install_with_streaming.ts b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install_with_streaming.ts new file mode 100644 index 0000000000000..45c4aee73b583 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install_with_streaming.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SavedObjectsClientContract } from '@kbn/core/server'; + +import type { PackageInstallContext } from '../../../../../common/types'; +import type { KibanaAssetReference, KibanaAssetType } from '../../../../types'; +import { getPathParts } from '../../archive'; + +import { saveKibanaAssetsRefs } from '../../packages/install'; + +import { makeManagedIndexPatternsGlobal } from '../index_pattern/install'; + +import type { ArchiveAsset } from './install'; +import { + KibanaSavedObjectTypeMapping, + createDefaultIndexPatterns, + createSavedObjectKibanaAsset, + isKibanaAssetType, + toAssetReference, +} from './install'; +import { getSpaceAwareSaveobjectsClients } from './saved_objects'; + +interface InstallKibanaAssetsWithStreamingArgs { + pkgName: string; + packageInstallContext: PackageInstallContext; + spaceId: string; + savedObjectsClient: SavedObjectsClientContract; +} + +const MAX_ASSETS_TO_INSTALL_IN_PARALLEL = 100; + +export async function installKibanaAssetsWithStreaming({ + spaceId, + packageInstallContext, + savedObjectsClient, + pkgName, +}: InstallKibanaAssetsWithStreamingArgs): Promise { + const { archiveIterator } = packageInstallContext; + + const { savedObjectClientWithSpace, savedObjectsImporter } = + getSpaceAwareSaveobjectsClients(spaceId); + + await createDefaultIndexPatterns(savedObjectsImporter); + await makeManagedIndexPatternsGlobal(savedObjectsClient); + + const assetRefs: KibanaAssetReference[] = []; + let batch: ArchiveAsset[] = []; + + await archiveIterator.traverseEntries(async ({ path, buffer }) => { + if (!buffer || !isKibanaAssetType(path)) { + return; + } + const savedObject = JSON.parse(buffer.toString('utf8')) as ArchiveAsset; + const assetType = getPathParts(path).type as KibanaAssetType; + const soType = KibanaSavedObjectTypeMapping[assetType]; + if (savedObject.type !== soType) { + return; + } + + batch.push(savedObject); + assetRefs.push(toAssetReference(savedObject)); + + if (batch.length >= MAX_ASSETS_TO_INSTALL_IN_PARALLEL) { + await bulkCreateSavedObjects({ + savedObjectsClient: savedObjectClientWithSpace, + kibanaAssets: batch, + refresh: false, + }); + batch = []; + } + }); + + // install any remaining assets + if (batch.length) { + await bulkCreateSavedObjects({ + savedObjectsClient: savedObjectClientWithSpace, + kibanaAssets: batch, + // Use wait_for with the last batch to ensure all assets are readable once the install is complete + refresh: 'wait_for', + }); + } + + // Update the installation saved object with installed kibana assets + await saveKibanaAssetsRefs(savedObjectsClient, pkgName, assetRefs); + + return assetRefs; +} + +async function bulkCreateSavedObjects({ + savedObjectsClient, + kibanaAssets, + refresh, +}: { + kibanaAssets: ArchiveAsset[]; + savedObjectsClient: SavedObjectsClientContract; + refresh?: boolean | 'wait_for'; +}) { + if (!kibanaAssets.length) { + return []; + } + + const toBeSavedObjects = kibanaAssets.map((asset) => createSavedObjectKibanaAsset(asset)); + + const { saved_objects: createdSavedObjects } = await savedObjectsClient.bulkCreate( + toBeSavedObjects, + { + // We only want to install new saved objects without overwriting existing ones + overwrite: false, + managed: true, + refresh, + } + ); + + return createdSavedObjects; +} diff --git a/x-pack/plugins/fleet/server/services/epm/package_service.ts b/x-pack/plugins/fleet/server/services/epm/package_service.ts index 1911ed14a7c80..7882af4b72138 100644 --- a/x-pack/plugins/fleet/server/services/epm/package_service.ts +++ b/x-pack/plugins/fleet/server/services/epm/package_service.ts @@ -40,7 +40,10 @@ import type { InstallResult } from '../../../common'; import { appContextService } from '..'; -import type { CustomPackageDatasetConfiguration, EnsurePackageResult } from './packages/install'; +import { + type CustomPackageDatasetConfiguration, + type EnsurePackageResult, +} from './packages/install'; import type { FetchFindLatestPackageOptions } from './registry'; import { getPackageFieldsMetadata } from './registry'; @@ -57,6 +60,7 @@ import { } from './packages'; import { generatePackageInfoFromArchiveBuffer } from './archive'; import { getEsPackage } from './archive/storage'; +import { createArchiveIteratorFromMap } from './archive/archive_iterator'; export type InstalledAssetType = EsAssetReference; @@ -384,12 +388,14 @@ class PackageClientImpl implements PackageClient { } const { assetsMap } = esPackage; + const archiveIterator = createArchiveIteratorFromMap(assetsMap); const { installedTransforms } = await installTransforms({ packageInstallContext: { assetsMap, packageInfo, paths, + archiveIterator, }, esClient: this.internalEsClient, savedObjectsClient: this.internalSoClient, diff --git a/x-pack/plugins/fleet/server/services/epm/packages/__fixtures__/docker_2_11_0.ts b/x-pack/plugins/fleet/server/services/epm/packages/__fixtures__/docker_2_11_0.ts new file mode 100644 index 0000000000000..67bde1882cfed --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/__fixtures__/docker_2_11_0.ts @@ -0,0 +1,230 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const DOCKER_2_11_0_PACKAGE_INFO = { + name: 'docker', + title: 'Docker', + version: '2.11.0', + release: 'ga', + description: 'Collect metrics and logs from Docker instances with Elastic Agent.', + type: 'integration', + download: '/epr/docker/docker-2.11.0.zip', + path: '/package/docker/2.11.0', + icons: [ + { + src: '/img/logo_docker.svg', + path: '/package/docker/2.11.0/img/logo_docker.svg', + title: 'logo docker', + size: '32x32', + type: 'image/svg+xml', + }, + ], + conditions: { + kibana: { + version: '^8.8.0', + }, + }, + owner: { + type: 'elastic', + github: 'elastic/obs-cloudnative-monitoring', + }, + categories: ['observability', 'containers'], + signature_path: '/epr/docker/docker-2.11.0.zip.sig', + format_version: '3.2.2', + readme: '/package/docker/2.11.0/docs/README.md', + license: 'basic', + screenshots: [ + { + src: '/img/docker-overview.png', + path: '/package/docker/2.11.0/img/docker-overview.png', + title: 'Docker Overview', + size: '5120x2562', + type: 'image/png', + }, + ], + assets: [ + '/package/docker/2.11.0/LICENSE.txt', + '/package/docker/2.11.0/changelog.yml', + '/package/docker/2.11.0/manifest.yml', + '/package/docker/2.11.0/docs/README.md', + '/package/docker/2.11.0/img/docker-overview.png', + '/package/docker/2.11.0/img/logo_docker.svg', + '/package/docker/2.11.0/data_stream/container/manifest.yml', + '/package/docker/2.11.0/data_stream/container/sample_event.json', + '/package/docker/2.11.0/data_stream/container_logs/manifest.yml', + '/package/docker/2.11.0/data_stream/container_logs/sample_event.json', + '/package/docker/2.11.0/data_stream/cpu/manifest.yml', + '/package/docker/2.11.0/data_stream/cpu/sample_event.json', + '/package/docker/2.11.0/data_stream/diskio/manifest.yml', + '/package/docker/2.11.0/data_stream/diskio/sample_event.json', + '/package/docker/2.11.0/data_stream/event/manifest.yml', + '/package/docker/2.11.0/data_stream/event/sample_event.json', + '/package/docker/2.11.0/data_stream/healthcheck/manifest.yml', + '/package/docker/2.11.0/data_stream/healthcheck/sample_event.json', + '/package/docker/2.11.0/data_stream/image/manifest.yml', + '/package/docker/2.11.0/data_stream/image/sample_event.json', + '/package/docker/2.11.0/data_stream/info/manifest.yml', + '/package/docker/2.11.0/data_stream/info/sample_event.json', + '/package/docker/2.11.0/data_stream/memory/manifest.yml', + '/package/docker/2.11.0/data_stream/memory/sample_event.json', + '/package/docker/2.11.0/data_stream/network/manifest.yml', + '/package/docker/2.11.0/data_stream/network/sample_event.json', + '/package/docker/2.11.0/kibana/dashboard/docker-AV4REOpp5NkDleZmzKkE.json', + '/package/docker/2.11.0/kibana/search/docker-Metrics-Docker.json', + '/package/docker/2.11.0/data_stream/container/fields/base-fields.yml', + '/package/docker/2.11.0/data_stream/container/fields/ecs.yml', + '/package/docker/2.11.0/data_stream/container/fields/fields.yml', + '/package/docker/2.11.0/data_stream/container_logs/fields/agent.yml', + '/package/docker/2.11.0/data_stream/container_logs/fields/base-fields.yml', + '/package/docker/2.11.0/data_stream/container_logs/fields/ecs.yml', + '/package/docker/2.11.0/data_stream/container_logs/fields/fields.yml', + '/package/docker/2.11.0/data_stream/cpu/fields/base-fields.yml', + '/package/docker/2.11.0/data_stream/cpu/fields/ecs.yml', + '/package/docker/2.11.0/data_stream/cpu/fields/fields.yml', + '/package/docker/2.11.0/data_stream/diskio/fields/base-fields.yml', + '/package/docker/2.11.0/data_stream/diskio/fields/ecs.yml', + '/package/docker/2.11.0/data_stream/diskio/fields/fields.yml', + '/package/docker/2.11.0/data_stream/event/fields/base-fields.yml', + '/package/docker/2.11.0/data_stream/event/fields/ecs.yml', + '/package/docker/2.11.0/data_stream/event/fields/fields.yml', + '/package/docker/2.11.0/data_stream/healthcheck/fields/base-fields.yml', + '/package/docker/2.11.0/data_stream/healthcheck/fields/ecs.yml', + '/package/docker/2.11.0/data_stream/healthcheck/fields/fields.yml', + '/package/docker/2.11.0/data_stream/image/fields/base-fields.yml', + '/package/docker/2.11.0/data_stream/image/fields/ecs.yml', + '/package/docker/2.11.0/data_stream/image/fields/fields.yml', + '/package/docker/2.11.0/data_stream/info/fields/base-fields.yml', + '/package/docker/2.11.0/data_stream/info/fields/ecs.yml', + '/package/docker/2.11.0/data_stream/info/fields/fields.yml', + '/package/docker/2.11.0/data_stream/memory/fields/base-fields.yml', + '/package/docker/2.11.0/data_stream/memory/fields/ecs.yml', + '/package/docker/2.11.0/data_stream/memory/fields/fields.yml', + '/package/docker/2.11.0/data_stream/network/fields/base-fields.yml', + '/package/docker/2.11.0/data_stream/network/fields/ecs.yml', + '/package/docker/2.11.0/data_stream/network/fields/fields.yml', + '/package/docker/2.11.0/data_stream/container/agent/stream/stream.yml.hbs', + '/package/docker/2.11.0/data_stream/container_logs/agent/stream/stream.yml.hbs', + '/package/docker/2.11.0/data_stream/cpu/agent/stream/stream.yml.hbs', + '/package/docker/2.11.0/data_stream/diskio/agent/stream/stream.yml.hbs', + '/package/docker/2.11.0/data_stream/event/agent/stream/stream.yml.hbs', + '/package/docker/2.11.0/data_stream/healthcheck/agent/stream/stream.yml.hbs', + '/package/docker/2.11.0/data_stream/image/agent/stream/stream.yml.hbs', + '/package/docker/2.11.0/data_stream/info/agent/stream/stream.yml.hbs', + '/package/docker/2.11.0/data_stream/memory/agent/stream/stream.yml.hbs', + '/package/docker/2.11.0/data_stream/network/agent/stream/stream.yml.hbs', + ], + policy_templates: [ + { + name: 'docker', + title: 'Docker logs and metrics', + description: 'Collect logs and metrics from Docker instances', + inputs: [ + { + type: 'filestream', + title: 'Collect Docker container logs', + description: 'Collecting docker container logs', + }, + ], + multiple: true, + }, + ], + data_streams: [ + { + type: 'logs', + dataset: 'docker.container_logs', + title: 'Docker container logs', + release: 'ga', + streams: [ + { + input: 'filestream', + vars: [ + { + name: 'paths', + type: 'text', + title: 'Docker container log path', + multi: true, + required: true, + show_user: false, + default: ['/var/lib/docker/containers/${docker.container.id}/*-json.log'], + }, + { + name: 'containerParserStream', + type: 'text', + title: "Container parser's stream configuration", + multi: false, + required: true, + show_user: false, + default: 'all', + }, + { + name: 'condition', + type: 'text', + title: 'Condition', + description: + 'Condition to filter when to apply this datastream. Refer to [Docker provider](https://www.elastic.co/guide/en/fleet/current/docker-provider.html) to find the available keys and to [Conditions](https://www.elastic.co/guide/en/fleet/current/dynamic-input-configuration.html#conditions) on how to use the available keys in conditions.', + multi: false, + required: false, + show_user: true, + }, + { + name: 'additionalParsersConfig', + type: 'yaml', + title: 'Additional parsers configuration', + multi: false, + required: true, + show_user: false, + default: + "# - ndjson:\n# target: json\n# ignore_decoding_error: true\n# - multiline:\n# type: pattern\n# pattern: '^\\['\n# negate: true\n# match: after\n", + }, + { + name: 'processors', + type: 'yaml', + title: 'Processors', + description: + 'Processors are used to reduce the number of fields in the exported event or to enhance the event with metadata. This executes in the agent before the events are shipped. See [Processors](https://www.elastic.co/guide/en/beats/filebeat/current/filtering-and-enhancing-data.html) for details.', + multi: false, + required: false, + show_user: false, + }, + ], + template_path: 'stream.yml.hbs', + title: 'Collect Docker container logs', + description: 'Collect Docker container logs', + enabled: true, + }, + ], + package: 'docker', + elasticsearch: {}, + path: 'container_logs', + }, + ], +}; + +export const DOCKER_2_11_0_ASSETS_MAP = new Map([ + [ + 'docker-2.11.0/data_stream/container_logs/agent/stream/stream.yml.hbs', + Buffer.from(`id: docker-container-logs-\${docker.container.name}-\${docker.container.id} +paths: +{{#each paths}} + - {{this}} +{{/each}} +{{#if condition}} +condition: {{ condition }} +{{/if}} +parsers: +- container: + stream: {{ containerParserStream }} + format: docker +{{ additionalParsersConfig }} + +{{#if processors}} +processors: +{{processors}} +{{/if}} +`), + ], +]); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/__fixtures__/redis_1_18_0_package_info.json b/x-pack/plugins/fleet/server/services/epm/packages/__fixtures__/redis_1_18_0_package_info.json index 57c9b0c68fac9..5aa5c4a878baf 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/__fixtures__/redis_1_18_0_package_info.json +++ b/x-pack/plugins/fleet/server/services/epm/packages/__fixtures__/redis_1_18_0_package_info.json @@ -139,6 +139,15 @@ "show_user": false, "default": "20s" }, + { + "name": "tags", + "type": "text", + "title": "Tags", + "multi": true, + "required": false, + "show_user": false, + "default": "" + }, { "name": "maxconn", "type": "integer", @@ -203,6 +212,15 @@ { "input": "redis/metrics", "vars": [ + { + "name": "tags_streams", + "type": "text", + "title": "Tags in streams", + "multi": true, + "required": false, + "show_user": false, + "default": "" + }, { "name": "key.patterns", "type": "yaml", diff --git a/x-pack/plugins/fleet/server/services/epm/packages/__fixtures__/redis_1_18_0_streams_template.ts b/x-pack/plugins/fleet/server/services/epm/packages/__fixtures__/redis_1_18_0_streams_template.ts index 5ff46f358bbe7..207a48d7132a5 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/__fixtures__/redis_1_18_0_streams_template.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/__fixtures__/redis_1_18_0_streams_template.ts @@ -76,6 +76,15 @@ period: {{period}} processors: {{processors}} {{/if}} +tags: + - test +{{#each tags as |tag i|}} + - {{tag}} +{{/each}} +tags_streams: +{{#each tags_streams as |tag i|}} + - {{tag}} +{{/each}} `), ], ]); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/__snapshots__/get_templates_inputs.test.ts.snap b/x-pack/plugins/fleet/server/services/epm/packages/__snapshots__/get_templates_inputs.test.ts.snap index 786e8ce9acec4..5f67aea3c4847 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/__snapshots__/get_templates_inputs.test.ts.snap +++ b/x-pack/plugins/fleet/server/services/epm/packages/__snapshots__/get_templates_inputs.test.ts.snap @@ -8,17 +8,16 @@ exports[`Fleet - getTemplateInputs should work for input package 1`] = ` streams: # Custom log file: Custom log file - id: logfile-log.logs - data_stream: - dataset: - # Dataset name: Set the name for your dataset. Changing the dataset will send the data to a different index. You can't use \`-\` in the name of a dataset and only valid characters for [Elasticsearch index names](https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-index_.html). - - paths: - - # Log file path: Path to log files to be collected - exclude_files: - - # Exclude files: Patterns to be ignored ignore_older: 72h - tags: - - # Tags: Tags to include in the published event + # data_stream: + # dataset: + # # Dataset name: Set the name for your dataset. Changing the dataset will send the data to a different index. You can't use \`-\` in the name of a dataset and only valid characters for [Elasticsearch index names](https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-index_.html). + # paths: + # - # Log file path: Path to log files to be collected + # exclude_files: + # - # Exclude files: Patterns to be ignored + # tags: + # - # Tags: Tags to include in the published event " `; @@ -49,8 +48,34 @@ exports[`Fleet - getTemplateInputs should work for integration package 1`] = ` pattern: '*' maxconn: 10 network: tcp - username: # Username - password: # Password period: 10s + tags: + - test + # - # Tags + # username: # Username + # password: # Password + # tags_streams: + # - # Tags in streams +" +`; + +exports[`Fleet - getTemplateInputs should work for package with dynamic ids 1`] = ` +"inputs: + # Collect Docker container logs: Collecting docker container logs + - id: docker-filestream + type: filestream + streams: + # Collect Docker container logs: Collect Docker container logs + - id: 'docker-container-logs-\${docker.container.name}-\${docker.container.id}' + data_stream: + dataset: docker.container_logs + type: logs + paths: + - '/var/lib/docker/containers/\${docker.container.id}/*-json.log' + parsers: + - container: + stream: all + format: docker + # condition: # Condition: Condition to filter when to apply this datastream. Refer to [Docker provider](https://www.elastic.co/guide/en/fleet/current/docker-provider.html) to find the available keys and to [Conditions](https://www.elastic.co/guide/en/fleet/current/dynamic-input-configuration.html#conditions) on how to use the available keys in conditions. " `; diff --git a/x-pack/plugins/fleet/server/services/epm/packages/assets.ts b/x-pack/plugins/fleet/server/services/epm/packages/assets.ts index a82b5c0d103b2..3bb84c0d23163 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/assets.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/assets.ts @@ -5,9 +5,9 @@ * 2.0. */ +import type { ArchiveEntry } from '../../../../common/types'; import type { AssetsMap, PackageInfo } from '../../../types'; import { getAssetFromAssetsMap } from '../archive'; -import type { ArchiveEntry } from '../archive'; const maybeFilterByDataset = (packageInfo: Pick, datasetName: string) => diff --git a/x-pack/plugins/fleet/server/services/epm/packages/get.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/get.test.ts index 2dc295762e33a..5711c8fcccaf4 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/get.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/get.test.ts @@ -27,6 +27,8 @@ import { auditLoggingService } from '../../audit_logging'; import * as Registry from '../registry'; +import { createArchiveIteratorFromMap } from '../archive/archive_iterator'; + import { getInstalledPackages, getPackageInfo, getPackages, getPackageUsageStats } from './get'; jest.mock('../registry'); @@ -915,6 +917,7 @@ owner: elastic`, MockRegistry.getPackage.mockResolvedValue({ paths: [], assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), packageInfo: { name: 'my-package', version: '1.0.0', diff --git a/x-pack/plugins/fleet/server/services/epm/packages/get_template_inputs.ts b/x-pack/plugins/fleet/server/services/epm/packages/get_template_inputs.ts index d3c3bf7345ce6..ec72a14e06f96 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/get_template_inputs.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/get_template_inputs.ts @@ -51,20 +51,31 @@ type PackageWithInputAndStreamIndexed = Record< // Function based off storedPackagePolicyToAgentInputs, it only creates the `streams` section instead of the FullAgentPolicyInput export const templatePackagePolicyToFullInputStreams = ( - packagePolicyInputs: PackagePolicyInput[] + packagePolicyInputs: PackagePolicyInput[], + inputAndStreamsIdsMap?: Map }> ): TemplateAgentPolicyInput[] => { const fullInputsStreams: TemplateAgentPolicyInput[] = []; if (!packagePolicyInputs || packagePolicyInputs.length === 0) return fullInputsStreams; packagePolicyInputs.forEach((input) => { + const streamsIdsMap = new Map(); + + const inputId = input.policy_template + ? `${input.policy_template}-${input.type}` + : `${input.type}`; const fullInputStream = { // @ts-ignore-next-line the following id is actually one level above the one in fullInputStream, but the linter thinks it gets overwritten - id: input.policy_template ? `${input.policy_template}-${input.type}` : `${input.type}`, + id: inputId, type: input.type, - ...getFullInputStreams(input, true), + ...getFullInputStreams(input, true, streamsIdsMap), }; + inputAndStreamsIdsMap?.set(fullInputStream.id, { + originalId: inputId, + streams: streamsIdsMap, + }); + // deeply merge the input.config values with the full policy input stream merge( fullInputStream, @@ -167,8 +178,13 @@ export async function getTemplateInputs( ...emptyPackagePolicy, inputs: compiledInputs, }; + const inputIdsDestinationMap = new Map< + string, + { originalId: string; streams: Map } + >(); const inputs = templatePackagePolicyToFullInputStreams( - packagePolicyWithInputs.inputs as PackagePolicyInput[] + packagePolicyWithInputs.inputs as PackagePolicyInput[], + inputIdsDestinationMap ); if (format === 'json') { @@ -181,7 +197,7 @@ export async function getTemplateInputs( sortKeys: _sortYamlKeys, } ); - return addCommentsToYaml(yaml, buildIndexedPackage(packageInfo)); + return addCommentsToYaml(yaml, buildIndexedPackage(packageInfo), inputIdsDestinationMap); } return { inputs: [] }; @@ -247,7 +263,8 @@ function buildIndexedPackage(packageInfo: PackageInfo): PackageWithInputAndStrea function addCommentsToYaml( yaml: string, - packageIndexInputAndStreams: PackageWithInputAndStreamIndexed + packageIndexInputAndStreams: PackageWithInputAndStreamIndexed, + inputIdsDestinationMap: Map }> ) { const doc = yamlDoc.parseDocument(yaml); // Add input and streams comments @@ -261,28 +278,16 @@ function addCommentsToYaml( if (!yamlDoc.isScalar(inputIdNode)) { return; } - const inputId = inputIdNode.value as string; + const inputId = + inputIdsDestinationMap.get(inputIdNode.value as string)?.originalId ?? + (inputIdNode.value as string); const pkgInput = packageIndexInputAndStreams[inputId]; if (pkgInput) { inputItem.commentBefore = ` ${pkgInput.title}${ pkgInput.description ? `: ${pkgInput.description}` : '' }`; - yamlDoc.visit(inputItem, { - Scalar(key, node) { - if (node.value) { - const val = node.value.toString(); - for (const varDef of pkgInput.vars ?? []) { - const placeholder = getPlaceholder(varDef); - if (val.includes(placeholder)) { - node.comment = ` ${varDef.title}${ - varDef.description ? `: ${varDef.description}` : '' - }`; - } - } - } - }, - }); + commentVariablesInYaml(inputItem, pkgInput.vars ?? []); const yamlStreams = inputItem.get('streams'); if (!yamlDoc.isCollection(yamlStreams)) { @@ -294,27 +299,16 @@ function addCommentsToYaml( } const streamIdNode = streamItem.get('id', true); if (yamlDoc.isScalar(streamIdNode)) { - const streamId = streamIdNode.value as string; + const streamId = + inputIdsDestinationMap + .get(inputIdNode.value as string) + ?.streams?.get(streamIdNode.value as string) ?? (streamIdNode.value as string); const pkgStream = pkgInput.streams[streamId]; if (pkgStream) { streamItem.commentBefore = ` ${pkgStream.title}${ pkgStream.description ? `: ${pkgStream.description}` : '' }`; - yamlDoc.visit(streamItem, { - Scalar(key, node) { - if (node.value) { - const val = node.value.toString(); - for (const varDef of pkgStream.vars ?? []) { - const placeholder = getPlaceholder(varDef); - if (val.includes(placeholder)) { - node.comment = ` ${varDef.title}${ - varDef.description ? `: ${varDef.description}` : '' - }`; - } - } - } - }, - }); + commentVariablesInYaml(streamItem, pkgStream.vars ?? []); } } }); @@ -324,3 +318,71 @@ function addCommentsToYaml( return doc.toString(); } + +function commentVariablesInYaml(rootNode: yamlDoc.Node, vars: RegistryVarsEntry[] = []) { + // Node need to be deleted after the end of the visit to be able to visit every node + const toDeleteFn: Array<() => void> = []; + yamlDoc.visit(rootNode, { + Scalar(key, node, path) { + if (node.value) { + const val = node.value.toString(); + for (const varDef of vars) { + const placeholder = getPlaceholder(varDef); + if (val.includes(placeholder)) { + node.comment = ` ${varDef.title}${varDef.description ? `: ${varDef.description}` : ''}`; + + const paths = [...path].reverse(); + + let prevPart: yamlDoc.Node | yamlDoc.Document | yamlDoc.Pair = node; + + for (const pathPart of paths) { + if (yamlDoc.isCollection(pathPart)) { + // If only one items in the collection comment the whole collection + if (pathPart.items.length === 1) { + continue; + } + } + if (yamlDoc.isSeq(pathPart)) { + const commentDoc = new yamlDoc.Document(new yamlDoc.YAMLSeq()); + commentDoc.add(prevPart); + const commentStr = commentDoc.toString().trimEnd(); + pathPart.comment = pathPart.comment + ? `${pathPart.comment} ${commentStr}` + : ` ${commentStr}`; + const keyToDelete = prevPart; + + toDeleteFn.push(() => { + pathPart.items.forEach((item, index) => { + if (item === keyToDelete) { + pathPart.delete(new yamlDoc.Scalar(index)); + } + }); + }); + return; + } + + if (yamlDoc.isMap(pathPart)) { + if (yamlDoc.isPair(prevPart)) { + const commentDoc = new yamlDoc.Document(new yamlDoc.YAMLMap()); + commentDoc.add(prevPart); + const commentStr = commentDoc.toString().trimEnd(); + + pathPart.comment = pathPart.comment + ? `${pathPart.comment}\n ${commentStr.toString()}` + : ` ${commentStr.toString()}`; + const keyToDelete = prevPart.key; + toDeleteFn.push(() => pathPart.delete(keyToDelete)); + } + return; + } + + prevPart = pathPart; + } + } + } + } + }, + }); + + toDeleteFn.forEach((deleteFn) => deleteFn()); +} diff --git a/x-pack/plugins/fleet/server/services/epm/packages/get_templates_inputs.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/get_templates_inputs.test.ts index 087002f212852..1a3738d8eaa82 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/get_templates_inputs.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/get_templates_inputs.test.ts @@ -16,6 +16,7 @@ import REDIS_1_18_0_PACKAGE_INFO from './__fixtures__/redis_1_18_0_package_info. import { getPackageAssetsMap, getPackageInfo } from './get'; import { REDIS_ASSETS_MAP } from './__fixtures__/redis_1_18_0_streams_template'; import { LOGS_2_3_0_ASSETS_MAP, LOGS_2_3_0_PACKAGE_INFO } from './__fixtures__/logs_2_3_0'; +import { DOCKER_2_11_0_PACKAGE_INFO, DOCKER_2_11_0_ASSETS_MAP } from './__fixtures__/docker_2_11_0'; jest.mock('./get'); @@ -41,6 +42,7 @@ packageInfoCache.set('limited_package-0.0.0', { packageInfoCache.set('redis-1.18.0', REDIS_1_18_0_PACKAGE_INFO); packageInfoCache.set('log-2.3.0', LOGS_2_3_0_PACKAGE_INFO); +packageInfoCache.set('docker-2.11.0', DOCKER_2_11_0_PACKAGE_INFO); describe('Fleet - templatePackagePolicyToFullInputStreams', () => { const mockInput: PackagePolicyInput = { @@ -330,6 +332,9 @@ describe('Fleet - getTemplateInputs', () => { if (packageInfo.name === 'log') { return LOGS_2_3_0_ASSETS_MAP; } + if (packageInfo.name === 'docker') { + return DOCKER_2_11_0_ASSETS_MAP; + } return new Map(); }); @@ -350,6 +355,14 @@ describe('Fleet - getTemplateInputs', () => { expect(template).toMatchSnapshot(); }); + it('should work for package with dynamic ids', async () => { + const soMock = savedObjectsClientMock.create(); + soMock.get.mockResolvedValue({ attributes: {} } as any); + const template = await getTemplateInputs(soMock, 'docker', '2.11.0', 'yml'); + + expect(template).toMatchSnapshot(); + }); + it('should work for input package', async () => { const soMock = savedObjectsClientMock.create(); soMock.get.mockResolvedValue({ attributes: {} } as any); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts index 709e0d84d70fc..6b3a31eda649e 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts @@ -442,6 +442,24 @@ describe('install', () => { expect(response.status).toEqual('installed'); }); + + it('should use streaming installation for the detection rules package', async () => { + jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true); + + const response = await installPackage({ + spaceId: DEFAULT_SPACE_ID, + installSource: 'registry', + pkgkey: 'security_detection_engine', + savedObjectsClient: savedObjectsClientMock.create(), + esClient: {} as ElasticsearchClient, + }); + + expect(response.error).toBeUndefined(); + + expect(installStateMachine._stateMachineInstallPackage).toHaveBeenCalledWith( + expect.objectContaining({ useStreaming: true }) + ); + }); }); describe('upload', () => { diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install.ts b/x-pack/plugins/fleet/server/services/epm/packages/install.ts index 1ea6f29cad839..ebe5acc35178d 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install.ts @@ -76,6 +76,7 @@ import { deleteVerificationResult, unpackBufferToAssetsMap, } from '../archive'; +import { createArchiveIteratorFromMap } from '../archive/archive_iterator'; import { toAssetReference } from '../kibana/assets/install'; import type { ArchiveAsset } from '../kibana/assets/install'; import type { PackageUpdateEvent } from '../../upgrade_sender'; @@ -107,6 +108,12 @@ import { removeInstallation } from './remove'; export const UPLOAD_RETRY_AFTER_MS = 10000; // 10s const MAX_ENSURE_INSTALL_TIME = 60 * 1000; +const PACKAGES_TO_INSTALL_WITH_STREAMING = [ + // The security_detection_engine package contains a large number of assets and + // is not suitable for regular installation as it might cause OOM errors. + 'security_detection_engine', +]; + export async function isPackageInstalled(options: { savedObjectsClient: SavedObjectsClientContract; pkgName: string; @@ -449,6 +456,7 @@ async function installPackageFromRegistry({ // TODO: change epm API to /packageName/version so we don't need to do this const { pkgName, pkgVersion: version } = Registry.splitPkgKey(pkgkey); let pkgVersion = version ?? ''; + const useStreaming = PACKAGES_TO_INSTALL_WITH_STREAMING.includes(pkgName); // if an error happens during getInstallType, report that we don't know let installType: InstallType = 'unknown'; @@ -478,11 +486,12 @@ async function installPackageFromRegistry({ } // get latest package version and requested version in parallel for performance - const [latestPackage, { paths, packageInfo, assetsMap, verificationResult }] = + const [latestPackage, { paths, packageInfo, assetsMap, archiveIterator, verificationResult }] = await Promise.all([ latestPkg ? Promise.resolve(latestPkg) : queryLatest(), Registry.getPackage(pkgName, pkgVersion, { ignoreUnverified: force && !neverIgnoreVerificationError, + useStreaming, }), ]); @@ -490,6 +499,7 @@ async function installPackageFromRegistry({ packageInfo, assetsMap, paths, + archiveIterator, }; // let the user install if using the force flag or needing to reinstall or install a previous version due to failed update @@ -542,6 +552,7 @@ async function installPackageFromRegistry({ ignoreMappingUpdateErrors, skipDataStreamRollover, retryFromLastState, + useStreaming, }); } catch (e) { sendEvent({ @@ -580,6 +591,7 @@ async function installPackageWithStateMachine(options: { ignoreMappingUpdateErrors?: boolean; skipDataStreamRollover?: boolean; retryFromLastState?: boolean; + useStreaming?: boolean; }): Promise { const packageInfo = options.packageInstallContext.packageInfo; @@ -599,6 +611,7 @@ async function installPackageWithStateMachine(options: { skipDataStreamRollover, packageInstallContext, retryFromLastState, + useStreaming, } = options; let { telemetryEvent } = options; const logger = appContextService.getLogger(); @@ -696,6 +709,7 @@ async function installPackageWithStateMachine(options: { ignoreMappingUpdateErrors, skipDataStreamRollover, retryFromLastState, + useStreaming, }) .then(async (assets) => { logger.debug(`Removing old assets from previous versions of ${pkgName}`); @@ -785,6 +799,7 @@ async function installPackageByUpload({ } const { packageInfo } = await generatePackageInfoFromArchiveBuffer(archiveBuffer, contentType); const pkgName = packageInfo.name; + const useStreaming = PACKAGES_TO_INSTALL_WITH_STREAMING.includes(pkgName); // Allow for overriding the version in the manifest for cases where we install // stack-aligned bundled packages to support special cases around the @@ -807,17 +822,17 @@ async function installPackageByUpload({ packageInfo, }); - const { assetsMap, paths } = await unpackBufferToAssetsMap({ - name: packageInfo.name, - version: pkgVersion, + const { paths, assetsMap, archiveIterator } = await unpackBufferToAssetsMap({ archiveBuffer, contentType, + useStreaming, }); const packageInstallContext: PackageInstallContext = { packageInfo: { ...packageInfo, version: pkgVersion }, assetsMap, paths, + archiveIterator, }; // update the timestamp of latest installation setLastUploadInstallCache(); @@ -837,6 +852,7 @@ async function installPackageByUpload({ authorizationHeader, ignoreMappingUpdateErrors, skipDataStreamRollover, + useStreaming, }); } catch (e) { return { @@ -1004,12 +1020,14 @@ export async function installCustomPackage( acc.set(asset.path, asset.content); return acc; }, new Map()); - const paths = [...assetsMap.keys()]; + const paths = assets.map((asset) => asset.path); + const archiveIterator = createArchiveIteratorFromMap(assetsMap); const packageInstallContext: PackageInstallContext = { assetsMap, paths, packageInfo, + archiveIterator, }; return await installPackageWithStateMachine({ packageInstallContext, @@ -1341,16 +1359,20 @@ export async function installAssetsForInputPackagePolicy(opts: { ignoreUnverified: force, }); + const archiveIterator = createArchiveIteratorFromMap(pkg.assetsMap); packageInstallContext = { assetsMap: pkg.assetsMap, packageInfo: pkg.packageInfo, paths: pkg.paths, + archiveIterator, }; } else { + const archiveIterator = createArchiveIteratorFromMap(installedPkgWithAssets.assetsMap); packageInstallContext = { assetsMap: installedPkgWithAssets.assetsMap, packageInfo: installedPkgWithAssets.packageInfo, paths: installedPkgWithAssets.paths, + archiveIterator, }; } diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/_state_machine_package_install.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/_state_machine_package_install.test.ts index 174076a9e9b1b..73b78a6cc4aa0 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/_state_machine_package_install.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/_state_machine_package_install.test.ts @@ -38,6 +38,8 @@ import { updateCurrentWriteIndices } from '../../elasticsearch/template/template import { installIndexTemplatesAndPipelines } from '../install_index_template_pipeline'; +import { createArchiveIteratorFromMap } from '../../archive/archive_iterator'; + import { handleState } from './state_machine'; import { _stateMachineInstallPackage } from './_state_machine_package_install'; import { cleanupLatestExecutedState } from './steps'; @@ -110,6 +112,7 @@ describe('_stateMachineInstallPackage', () => { logger: loggerMock.create(), packageInstallContext: { assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), paths: [], packageInfo: { title: 'title', @@ -172,6 +175,7 @@ describe('_stateMachineInstallPackage', () => { logger: loggerMock.create(), packageInstallContext: { assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), paths: [], packageInfo: { title: 'title', @@ -208,6 +212,7 @@ describe('_stateMachineInstallPackage', () => { logger: loggerMock.create(), packageInstallContext: { assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), paths: [], packageInfo: { title: 'title', @@ -257,6 +262,7 @@ describe('_stateMachineInstallPackage', () => { logger: loggerMock.create(), packageInstallContext: { assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), paths: [], packageInfo: { title: 'title', @@ -336,6 +342,7 @@ describe('_stateMachineInstallPackage', () => { owner: { github: 'elastic/fleet' }, } as any, assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), paths: [], }, installType: 'install', diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/_state_machine_package_install.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/_state_machine_package_install.ts index 1f10d40feba38..c941b6d60d63b 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/_state_machine_package_install.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/_state_machine_package_install.ts @@ -48,11 +48,13 @@ import { updateLatestExecutedState, cleanupLatestExecutedState, cleanUpKibanaAssetsStep, + cleanUpUnusedKibanaAssetsStep, cleanupILMPoliciesStep, cleanUpMlModelStep, cleanupIndexTemplatePipelinesStep, cleanupTransformsStep, cleanupArchiveEntriesStep, + stepInstallKibanaAssetsWithStreaming, } from './steps'; import type { StateMachineDefinition, StateMachineStates } from './state_machine'; import { handleState } from './state_machine'; @@ -73,6 +75,7 @@ export interface InstallContext extends StateContext { skipDataStreamRollover?: boolean; retryFromLastState?: boolean; initialState?: INSTALL_STATES; + useStreaming?: boolean; indexTemplates?: IndexTemplateEntry[]; packageAssetRefs?: PackageAssetReference[]; @@ -83,7 +86,7 @@ export interface InstallContext extends StateContext { /** * This data structure defines the sequence of the states and the transitions */ -const statesDefinition: StateMachineStates = { +const regularStatesDefinition: StateMachineStates = { create_restart_installation: { nextState: INSTALL_STATES.INSTALL_KIBANA_ASSETS, onTransition: stepCreateRestartInstallation, @@ -152,6 +155,31 @@ const statesDefinition: StateMachineStates = { }, }; +const streamingStatesDefinition: StateMachineStates = { + create_restart_installation: { + nextState: INSTALL_STATES.INSTALL_KIBANA_ASSETS, + onTransition: stepCreateRestartInstallation, + onPostTransition: updateLatestExecutedState, + }, + install_kibana_assets: { + onTransition: stepInstallKibanaAssetsWithStreaming, + nextState: INSTALL_STATES.SAVE_ARCHIVE_ENTRIES, + onPostTransition: updateLatestExecutedState, + }, + save_archive_entries_from_assets_map: { + onPreTransition: cleanupArchiveEntriesStep, + onTransition: stepSaveArchiveEntries, + nextState: INSTALL_STATES.UPDATE_SO, + onPostTransition: updateLatestExecutedState, + }, + update_so: { + onPreTransition: cleanUpUnusedKibanaAssetsStep, + onTransition: stepSaveSystemObject, + nextState: 'end', + onPostTransition: updateLatestExecutedState, + }, +}; + /* * _stateMachineInstallPackage installs packages using the generic state machine in ./state_machine * installStates is the data structure providing the state machine definition @@ -166,6 +194,10 @@ export async function _stateMachineInstallPackage( const logger = appContextService.getLogger(); let initialState = INSTALL_STATES.CREATE_RESTART_INSTALLATION; + const statesDefinition = context.useStreaming + ? streamingStatesDefinition + : regularStatesDefinition; + // if retryFromLastState, restart install from last install state // if force is passed, the install should be executed from the beginning if (retryFromLastState && !force && installedPkg?.attributes?.latest_executed_state?.name) { diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_create_restart_installation.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_create_restart_installation.test.ts index 2b653728d6574..e5a7fed55fe87 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_create_restart_installation.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_create_restart_installation.test.ts @@ -31,6 +31,8 @@ import { auditLoggingService } from '../../../../audit_logging'; import { restartInstallation, createInstallation } from '../../install'; import type { Installation } from '../../../../../../common'; +import { createArchiveIteratorFromMap } from '../../../archive/archive_iterator'; + import { stepCreateRestartInstallation } from './step_create_restart_installation'; jest.mock('../../../../audit_logging'); @@ -84,6 +86,7 @@ describe('stepCreateRestartInstallation', () => { logger, packageInstallContext: { assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), paths: [], packageInfo: { title: 'title', @@ -120,6 +123,7 @@ describe('stepCreateRestartInstallation', () => { logger, packageInstallContext: { assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), paths: [], packageInfo: { title: 'title', @@ -164,6 +168,7 @@ describe('stepCreateRestartInstallation', () => { logger, packageInstallContext: { assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), paths: [], packageInfo: { title: 'title', @@ -208,6 +213,7 @@ describe('stepCreateRestartInstallation', () => { logger, packageInstallContext: { assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), paths: [], packageInfo: { title: 'title', diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_delete_previous_pipelines.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_delete_previous_pipelines.test.ts index 7d8a251433bb5..06201770ee2e2 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_delete_previous_pipelines.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_delete_previous_pipelines.test.ts @@ -24,6 +24,8 @@ import { deletePreviousPipelines, } from '../../../elasticsearch/ingest_pipeline'; +import { createArchiveIteratorFromMap } from '../../../archive/archive_iterator'; + import { stepDeletePreviousPipelines } from './step_delete_previous_pipelines'; jest.mock('../../../elasticsearch/ingest_pipeline'); @@ -84,6 +86,7 @@ describe('stepDeletePreviousPipelines', () => { owner: { github: 'elastic/fleet' }, } as any, assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), paths: [], }; appContextService.start( @@ -276,6 +279,7 @@ describe('stepDeletePreviousPipelines', () => { owner: { github: 'elastic/fleet' }, } as any, assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), paths: [], }; appContextService.start( diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_ilm_policies.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_ilm_policies.test.ts index 2cf9b23bb9adb..4c106a0c68f15 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_ilm_policies.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_ilm_policies.test.ts @@ -24,6 +24,8 @@ import { installIlmForDataStream } from '../../../elasticsearch/datastream_ilm/i import { ElasticsearchAssetType } from '../../../../../types'; import { deleteILMPolicies, deletePrerequisiteAssets } from '../../remove'; +import { createArchiveIteratorFromMap } from '../../../archive/archive_iterator'; + import { stepInstallILMPolicies, cleanupILMPoliciesStep } from './step_install_ilm_policies'; jest.mock('../../../archive/storage'); @@ -56,6 +58,7 @@ const packageInstallContext = { owner: { github: 'elastic/fleet' }, } as any, assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), paths: [], }; let soClient: jest.Mocked; @@ -239,6 +242,7 @@ describe('stepInstallILMPolicies', () => { owner: { github: 'elastic/fleet' }, } as any, assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), paths: [], }, installType: 'install', diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_index_template_pipelines.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_index_template_pipelines.test.ts index d258747edc6ef..1c368cfd998d3 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_index_template_pipelines.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_index_template_pipelines.test.ts @@ -37,6 +37,8 @@ const mockDeletePrerequisiteAssets = deletePrerequisiteAssets as jest.MockedFunc typeof deletePrerequisiteAssets >; +import { createArchiveIteratorFromMap } from '../../../archive/archive_iterator'; + import { stepInstallIndexTemplatePipelines, cleanupIndexTemplatePipelinesStep, @@ -122,6 +124,7 @@ describe('stepInstallIndexTemplatePipelines', () => { owner: { github: 'elastic/fleet' }, } as any, assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), paths: [], }; appContextService.start( @@ -281,6 +284,7 @@ describe('stepInstallIndexTemplatePipelines', () => { ], } as any, assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), paths: [], }; appContextService.start( @@ -431,6 +435,7 @@ describe('stepInstallIndexTemplatePipelines', () => { ], } as any, assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), paths: [], }; appContextService.start( @@ -521,6 +526,7 @@ describe('stepInstallIndexTemplatePipelines', () => { ], } as any, assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), paths: [], }; appContextService.start( @@ -574,6 +580,7 @@ describe('stepInstallIndexTemplatePipelines', () => { owner: { github: 'elastic/fleet' }, } as any, assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), paths: [], }; appContextService.start( @@ -647,6 +654,7 @@ describe('cleanupIndexTemplatePipelinesStep', () => { ], } as any, assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), paths: [], }; const mockInstalledPackageSo: SavedObject = { diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_kibana_assets.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_kibana_assets.test.ts index 52c93c61c16e1..cf9d953868b6a 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_kibana_assets.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_kibana_assets.test.ts @@ -23,8 +23,25 @@ import { deleteKibanaAssets } from '../../remove'; import { KibanaSavedObjectType, type Installation } from '../../../../../types'; -import { stepInstallKibanaAssets, cleanUpKibanaAssetsStep } from './step_install_kibana_assets'; +import { createArchiveIteratorFromMap } from '../../../archive/archive_iterator'; +import { + stepInstallKibanaAssets, + cleanUpKibanaAssetsStep, + stepInstallKibanaAssetsWithStreaming, + cleanUpUnusedKibanaAssetsStep, +} from './step_install_kibana_assets'; + +jest.mock('../../../kibana/assets/saved_objects', () => { + return { + getSpaceAwareSaveobjectsClients: jest.fn().mockReturnValue({ + savedObjectClientWithSpace: jest.fn(), + savedObjectsImporter: jest.fn(), + savedObjectTagAssignmentService: jest.fn(), + savedObjectTagClient: jest.fn(), + }), + }; +}); jest.mock('../../../kibana/assets/install'); jest.mock('../../remove', () => { return { @@ -58,6 +75,7 @@ const packageInstallContext = { } as any, paths: ['some/path/1', 'some/path/2'], assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), }; describe('stepInstallKibanaAssets', () => { @@ -82,6 +100,7 @@ describe('stepInstallKibanaAssets', () => { logger: loggerMock.create(), packageInstallContext: { assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), paths: [], packageInfo: { title: 'title', @@ -102,7 +121,7 @@ describe('stepInstallKibanaAssets', () => { }); await expect(installationPromise).resolves.not.toThrowError(); - expect(mockedInstallKibanaAssetsAndReferencesMultispace).toBeCalledTimes(1); + expect(installKibanaAssetsAndReferencesMultispace).toBeCalledTimes(1); }); esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; appContextService.start(createAppContextStartContractMock()); @@ -121,6 +140,7 @@ describe('stepInstallKibanaAssets', () => { logger: loggerMock.create(), packageInstallContext: { assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), paths: [], packageInfo: { title: 'title', @@ -144,6 +164,60 @@ describe('stepInstallKibanaAssets', () => { }); }); +describe('stepInstallKibanaAssetsWithStreaming', () => { + beforeEach(async () => { + soClient = savedObjectsClientMock.create(); + esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + appContextService.start(createAppContextStartContractMock()); + }); + + it('should rely on archiveIterator instead of in-memory assetsMap', async () => { + const assetsMap = new Map(); + assetsMap.get = jest.fn(); + assetsMap.set = jest.fn(); + + const archiveIterator = { + traverseEntries: jest.fn(), + getPaths: jest.fn(), + }; + + const result = await stepInstallKibanaAssetsWithStreaming({ + savedObjectsClient: soClient, + esClient, + logger: loggerMock.create(), + packageInstallContext: { + assetsMap, + archiveIterator, + paths: [], + packageInfo: { + title: 'title', + name: 'xyz', + version: '4.5.6', + description: 'test', + type: 'integration', + categories: ['cloud', 'custom'], + format_version: 'string', + release: 'experimental', + conditions: { kibana: { version: 'x.y.z' } }, + owner: { github: 'elastic/fleet' }, + }, + }, + installType: 'install', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + }); + + expect(result).toEqual({ installedKibanaAssetsRefs: [] }); + + // Verify that assetsMap was not used + expect(assetsMap.get).not.toBeCalled(); + expect(assetsMap.set).not.toBeCalled(); + + // Verify that archiveIterator was used + expect(archiveIterator.traverseEntries).toBeCalled(); + }); +}); + describe('cleanUpKibanaAssetsStep', () => { const mockInstalledPackageSo: SavedObject = { id: 'mocked-package', @@ -302,3 +376,84 @@ describe('cleanUpKibanaAssetsStep', () => { expect(mockedDeleteKibanaAssets).not.toBeCalled(); }); }); + +describe('cleanUpUnusedKibanaAssetsStep', () => { + const mockInstalledPackageSo: SavedObject = { + id: 'mocked-package', + attributes: { + name: 'test-package', + version: '1.0.0', + install_status: 'installing', + install_version: '1.0.0', + install_started_at: new Date().toISOString(), + install_source: 'registry', + verification_status: 'verified', + installed_kibana: [] as any, + installed_es: [] as any, + es_index_patterns: {}, + }, + type: PACKAGES_SAVED_OBJECT_TYPE, + references: [], + }; + + const installationContext = { + savedObjectsClient: soClient, + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext, + installType: 'install' as const, + installSource: 'registry' as const, + spaceId: DEFAULT_SPACE_ID, + retryFromLastState: true, + initialState: 'install_kibana_assets' as any, + }; + + beforeEach(async () => { + soClient = savedObjectsClientMock.create(); + esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + appContextService.start(createAppContextStartContractMock()); + }); + + it('should not clean up assets if they all present in the new package', async () => { + const installedAssets = [{ type: KibanaSavedObjectType.dashboard, id: 'dashboard-1' }]; + await cleanUpUnusedKibanaAssetsStep({ + ...installationContext, + installedPkg: { + ...mockInstalledPackageSo, + attributes: { + ...mockInstalledPackageSo.attributes, + installed_kibana: installedAssets, + }, + }, + installedKibanaAssetsRefs: installedAssets, + }); + + expect(mockedDeleteKibanaAssets).not.toBeCalled(); + }); + + it('should clean up assets that are not present in the new package', async () => { + const installedAssets = [ + { type: KibanaSavedObjectType.dashboard, id: 'dashboard-1' }, + { type: KibanaSavedObjectType.dashboard, id: 'dashboard-2' }, + ]; + const newAssets = [{ type: KibanaSavedObjectType.dashboard, id: 'dashboard-1' }]; + await cleanUpUnusedKibanaAssetsStep({ + ...installationContext, + installedPkg: { + ...mockInstalledPackageSo, + attributes: { + ...mockInstalledPackageSo.attributes, + installed_kibana: installedAssets, + }, + }, + installedKibanaAssetsRefs: newAssets, + }); + + expect(mockedDeleteKibanaAssets).toBeCalledWith({ + installedObjects: [installedAssets[1]], + spaceId: 'default', + packageInfo: packageInstallContext.packageInfo, + }); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_kibana_assets.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_kibana_assets.ts index b5a1fff91d3b8..22c785f568402 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_kibana_assets.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_kibana_assets.ts @@ -11,7 +11,9 @@ import { withPackageSpan } from '../../utils'; import type { InstallContext } from '../_state_machine_package_install'; import { deleteKibanaAssets } from '../../remove'; +import type { KibanaAssetReference } from '../../../../../../common/types'; import { INSTALL_STATES } from '../../../../../../common/types'; +import { installKibanaAssetsWithStreaming } from '../../../kibana/assets/install_with_streaming'; export async function stepInstallKibanaAssets(context: InstallContext) { const { savedObjectsClient, logger, installedPkg, packageInstallContext, spaceId } = context; @@ -37,6 +39,25 @@ export async function stepInstallKibanaAssets(context: InstallContext) { return { kibanaAssetPromise }; } +export async function stepInstallKibanaAssetsWithStreaming(context: InstallContext) { + const { savedObjectsClient, packageInstallContext, spaceId } = context; + const { packageInfo } = packageInstallContext; + const { name: pkgName } = packageInfo; + + const installedKibanaAssetsRefs = await withPackageSpan( + 'Install Kibana assets with streaming', + () => + installKibanaAssetsWithStreaming({ + savedObjectsClient, + pkgName, + packageInstallContext, + spaceId, + }) + ); + + return { installedKibanaAssetsRefs }; +} + export async function cleanUpKibanaAssetsStep(context: InstallContext) { const { logger, @@ -65,3 +86,44 @@ export async function cleanUpKibanaAssetsStep(context: InstallContext) { }); } } + +/** + * Cleans up Kibana assets that are no longer in the package. As opposite to + * `cleanUpKibanaAssetsStep`, this one is used after the package assets are + * installed. + * + * This function compares the currently installed Kibana assets with the assets + * in the previous package and removes any assets that are no longer present in the + * new installation. + * + */ +export async function cleanUpUnusedKibanaAssetsStep(context: InstallContext) { + const { logger, installedPkg, packageInstallContext, spaceId, installedKibanaAssetsRefs } = + context; + const { packageInfo } = packageInstallContext; + + if (!installedKibanaAssetsRefs) { + return; + } + + logger.debug('Clean up Kibana assets that are no longer in the package'); + + // Get the assets installed by the previous package + const previousAssetRefs = installedPkg?.attributes.installed_kibana ?? []; + + // Remove any assets that are not in the new package + const nextAssetRefKeys = new Set( + installedKibanaAssetsRefs.map((asset: KibanaAssetReference) => `${asset.id}-${asset.type}`) + ); + const assetsToRemove = previousAssetRefs.filter( + (existingAsset) => !nextAssetRefKeys.has(`${existingAsset.id}-${existingAsset.type}`) + ); + + if (assetsToRemove.length === 0) { + return; + } + + await withPackageSpan('Clean up Kibana assets that are no longer in the package', async () => { + await deleteKibanaAssets({ installedObjects: assetsToRemove, spaceId, packageInfo }); + }); +} diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_mlmodel.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_mlmodel.test.ts index 1afb436eb4361..df939f3a458b6 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_mlmodel.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_mlmodel.test.ts @@ -22,6 +22,8 @@ import { createAppContextStartContractMock } from '../../../../../mocks'; import { installMlModel } from '../../../elasticsearch/ml_model'; import { deleteMLModels, deletePrerequisiteAssets } from '../../remove'; +import { createArchiveIteratorFromMap } from '../../../archive/archive_iterator'; + import { stepInstallMlModel, cleanUpMlModelStep } from './step_install_mlmodel'; jest.mock('../../../elasticsearch/ml_model'); @@ -53,6 +55,7 @@ const packageInstallContext = { } as any, paths: ['some/path/1', 'some/path/2'], assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), }; let soClient: jest.Mocked; let esClient: jest.Mocked; diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_transforms.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_transforms.test.ts index 1ac2383950b05..3bf07d52c6cbf 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_transforms.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_transforms.test.ts @@ -22,6 +22,8 @@ import { createAppContextStartContractMock } from '../../../../../mocks'; import { installTransforms } from '../../../elasticsearch/transform/install'; import { cleanupTransforms } from '../../remove'; +import { createArchiveIteratorFromMap } from '../../../archive/archive_iterator'; + import { stepInstallTransforms, cleanupTransformsStep } from './step_install_transforms'; jest.mock('../../../elasticsearch/transform/install'); @@ -52,6 +54,7 @@ const packageInstallContext = { } as any, paths: ['some/path/1', 'some/path/2'], assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), }; describe('stepInstallTransforms', () => { diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_remove_legacy_templates.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_remove_legacy_templates.test.ts index 39e7159596ba8..7fa00a1c57f57 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_remove_legacy_templates.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_remove_legacy_templates.test.ts @@ -24,6 +24,8 @@ import { appContextService } from '../../../../app_context'; import { createAppContextStartContractMock } from '../../../../../mocks'; import { removeLegacyTemplates } from '../../../elasticsearch/template/remove_legacy'; +import { createArchiveIteratorFromMap } from '../../../archive/archive_iterator'; + import { stepRemoveLegacyTemplates } from './step_remove_legacy_templates'; jest.mock('../../../elasticsearch/template/remove_legacy'); @@ -82,6 +84,7 @@ describe('stepRemoveLegacyTemplates', () => { } as any, paths: ['some/path/1', 'some/path/2'], assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), }; appContextService.start( createAppContextStartContractMock({ diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_save_archive_entries.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_save_archive_entries.test.ts index b03c146640488..255572d57cf49 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_save_archive_entries.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_save_archive_entries.test.ts @@ -21,6 +21,8 @@ import { appContextService } from '../../../../app_context'; import { createAppContextStartContractMock } from '../../../../../mocks'; import { saveArchiveEntriesFromAssetsMap, removeArchiveEntries } from '../../../archive/storage'; +import { createArchiveIteratorFromMap } from '../../../archive/archive_iterator'; + import { stepSaveArchiveEntries, cleanupArchiveEntriesStep } from './step_save_archive_entries'; jest.mock('../../../archive/storage', () => { @@ -60,6 +62,7 @@ const packageInstallContext = { Buffer.from('{"content": "data"}'), ], ]), + archiveIterator: createArchiveIteratorFromMap(new Map()), }; const getMockInstalledPackageSo = ( installedEs: EsAssetReference[] = [] diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_save_archive_entries.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_save_archive_entries.ts index b0d5bb67627a6..7db44bb243f85 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_save_archive_entries.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_save_archive_entries.ts @@ -14,17 +14,32 @@ import { withPackageSpan } from '../../utils'; import type { InstallContext } from '../_state_machine_package_install'; import { INSTALL_STATES } from '../../../../../../common/types'; +import { MANIFEST_NAME } from '../../../archive/parse'; export async function stepSaveArchiveEntries(context: InstallContext) { - const { packageInstallContext, savedObjectsClient, installSource } = context; + const { packageInstallContext, savedObjectsClient, installSource, useStreaming } = context; - const { packageInfo } = packageInstallContext; + const { packageInfo, archiveIterator } = packageInstallContext; + + let assetsMap = packageInstallContext?.assetsMap; + let paths = packageInstallContext?.paths; + // For stream based installations, we don't want to save any assets but + // manifest.yaml due to the large number of assets in the package. + if (useStreaming) { + assetsMap = new Map(); + await archiveIterator.traverseEntries(async (entry) => { + if (entry.path.endsWith(MANIFEST_NAME)) { + assetsMap.set(entry.path, entry.buffer); + } + }); + paths = Array.from(assetsMap.keys()); + } const packageAssetResults = await withPackageSpan('Update archive entries', () => saveArchiveEntriesFromAssetsMap({ savedObjectsClient, - assetsMap: packageInstallContext?.assetsMap, - paths: packageInstallContext?.paths, + assetsMap, + paths, packageInfo, installSource, }) diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_save_system_object.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_save_system_object.test.ts index e91826c99793c..8d80c236aefb0 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_save_system_object.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_save_system_object.test.ts @@ -21,6 +21,8 @@ import { createAppContextStartContractMock } from '../../../../../mocks'; import { auditLoggingService } from '../../../../audit_logging'; import { packagePolicyService } from '../../../../package_policy'; +import { createArchiveIteratorFromMap } from '../../../archive/archive_iterator'; + import { stepSaveSystemObject } from './step_save_system_object'; jest.mock('../../../../audit_logging'); @@ -67,6 +69,7 @@ describe('updateLatestExecutedState', () => { logger, packageInstallContext: { assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), paths: [], packageInfo: { title: 'title', @@ -91,7 +94,7 @@ describe('updateLatestExecutedState', () => { 'epm-packages', 'test-integration', { - install_format_schema_version: '1.2.0', + install_format_schema_version: '1.3.0', install_status: 'installed', install_version: '1.0.0', latest_install_failed_attempts: [], @@ -133,6 +136,7 @@ describe('updateLatestExecutedState', () => { logger, packageInstallContext: { assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), paths: [], packageInfo: { title: 'title', @@ -157,7 +161,7 @@ describe('updateLatestExecutedState', () => { 'epm-packages', 'test-integration', { - install_format_schema_version: '1.2.0', + install_format_schema_version: '1.3.0', install_status: 'installed', install_version: '1.0.0', latest_install_failed_attempts: [], diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_update_current_write_indices.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_update_current_write_indices.test.ts index c7f3c040b7966..017805d34efef 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_update_current_write_indices.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_update_current_write_indices.test.ts @@ -22,6 +22,8 @@ import { appContextService } from '../../../../app_context'; import { createAppContextStartContractMock } from '../../../../../mocks'; import { updateCurrentWriteIndices } from '../../../elasticsearch/template/template'; +import { createArchiveIteratorFromMap } from '../../../archive/archive_iterator'; + import { stepUpdateCurrentWriteIndices } from './step_update_current_write_indices'; jest.mock('../../../elasticsearch/template/template'); @@ -86,6 +88,7 @@ describe('stepUpdateCurrentWriteIndices', () => { } as any, paths: ['some/path/1', 'some/path/2'], assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), }; appContextService.start( createAppContextStartContractMock({ diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/update_latest_executed_state.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/update_latest_executed_state.test.ts index d963e5fea44c9..aea879aba5479 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/update_latest_executed_state.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/update_latest_executed_state.test.ts @@ -32,6 +32,8 @@ import { auditLoggingService } from '../../../../audit_logging'; import type { PackagePolicySOAttributes } from '../../../../../types'; +import { createArchiveIteratorFromMap } from '../../../archive/archive_iterator'; + import { updateLatestExecutedState } from './update_latest_executed_state'; jest.mock('../../../../audit_logging'); @@ -61,6 +63,7 @@ describe('updateLatestExecutedState', () => { logger, packageInstallContext: { assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), paths: [], packageInfo: { title: 'title', @@ -116,6 +119,7 @@ describe('updateLatestExecutedState', () => { logger, packageInstallContext: { assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), paths: [], packageInfo: { title: 'title', @@ -153,6 +157,7 @@ describe('updateLatestExecutedState', () => { logger, packageInstallContext: { assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), paths: [], packageInfo: { title: 'title', @@ -198,6 +203,7 @@ describe('updateLatestExecutedState', () => { logger, packageInstallContext: { assetsMap: new Map(), + archiveIterator: createArchiveIteratorFromMap(new Map()), paths: [], packageInfo: { title: 'title', diff --git a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts index ac3f5def5d09c..3892eaa951e5f 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts @@ -148,6 +148,7 @@ export async function deleteKibanaAssets({ const namespace = SavedObjectsUtils.namespaceStringToId(spaceId); + // TODO this should be the installed package info, not the package that is being installed const minKibana = packageInfo.conditions?.kibana?.version ? minVersion(packageInfo.conditions.kibana.version) : null; diff --git a/x-pack/plugins/fleet/server/services/epm/registry/index.ts b/x-pack/plugins/fleet/server/services/epm/registry/index.ts index bb4d612aa7de3..75b9869d0a7c6 100644 --- a/x-pack/plugins/fleet/server/services/epm/registry/index.ts +++ b/x-pack/plugins/fleet/server/services/epm/registry/index.ts @@ -54,6 +54,8 @@ import { resolveDataStreamFields, resolveDataStreamsMap, withPackageSpan } from import { verifyPackageArchiveSignature } from '../packages/package_verification'; +import type { ArchiveIterator } from '../../../../common/types'; + import { fetchUrl, getResponse, getResponseStream } from './requests'; import { getRegistryUrl } from './registry_url'; @@ -309,11 +311,12 @@ async function getPackageInfoFromArchiveOrCache( export async function getPackage( name: string, version: string, - options?: { ignoreUnverified?: boolean } + options?: { ignoreUnverified?: boolean; useStreaming?: boolean } ): Promise<{ paths: string[]; packageInfo: ArchivePackage; assetsMap: AssetsMap; + archiveIterator: ArchiveIterator; verificationResult?: PackageVerificationResult; }> { const verifyPackage = appContextService.getExperimentalFeatures().packageVerification; @@ -340,18 +343,18 @@ export async function getPackage( setVerificationResult({ name, version }, latestVerificationResult); } - const { assetsMap, paths } = await unpackBufferToAssetsMap({ - name, - version, + const contentType = ensureContentType(archivePath); + const { paths, assetsMap, archiveIterator } = await unpackBufferToAssetsMap({ archiveBuffer, - contentType: ensureContentType(archivePath), + contentType, + useStreaming: options?.useStreaming, }); if (!packageInfo) { packageInfo = await getPackageInfoFromArchiveOrCache(name, version, archiveBuffer, archivePath); } - return { paths, packageInfo, assetsMap, verificationResult }; + return { paths, packageInfo, assetsMap, archiveIterator, verificationResult }; } export async function getPackageFieldsMetadata( @@ -397,7 +400,7 @@ export async function getPackageFieldsMetadata( } } -function ensureContentType(archivePath: string) { +export function ensureContentType(archivePath: string) { const contentType = mime.lookup(archivePath); if (!contentType) { diff --git a/x-pack/plugins/fleet/server/services/package_policies/experimental_datastream_features.ts b/x-pack/plugins/fleet/server/services/package_policies/experimental_datastream_features.ts index edf31991634b9..cd1c26942aa0c 100644 --- a/x-pack/plugins/fleet/server/services/package_policies/experimental_datastream_features.ts +++ b/x-pack/plugins/fleet/server/services/package_policies/experimental_datastream_features.ts @@ -30,6 +30,7 @@ import { applyDocOnlyValueToMapping, forEachMappings, } from '../experimental_datastream_features_helper'; +import { createArchiveIteratorFromMap } from '../epm/archive/archive_iterator'; export async function handleExperimentalDatastreamFeatureOptIn({ soClient, @@ -75,6 +76,7 @@ export async function handleExperimentalDatastreamFeatureOptIn({ return prepareTemplate({ packageInstallContext: { assetsMap, + archiveIterator: createArchiveIteratorFromMap(assetsMap), packageInfo, paths, }, diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index f46ece3a88d9d..d96dccdc4dce8 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -480,22 +480,21 @@ class PackagePolicyClientImpl implements PackagePolicyClient { user?: AuthenticatedUser; bumpRevision?: boolean; force?: true; + asyncDeploy?: boolean; } ): Promise<{ created: PackagePolicy[]; failed: Array<{ packagePolicy: NewPackagePolicy; error?: Error | SavedObjectError }>; }> { - const useSpaceAwareness = await isSpaceAwarenessEnabled(); - const savedObjectType = await getPackagePolicySavedObjectType(); - for (const packagePolicy of packagePolicies) { + const [useSpaceAwareness, savedObjectType, packageInfos] = await Promise.all([ + isSpaceAwarenessEnabled(), + getPackagePolicySavedObjectType(), + getPackageInfoForPackagePolicies(packagePolicies, soClient), + ]); + + await pMap(packagePolicies, async (packagePolicy) => { const basePkgInfo = packagePolicy.package - ? await getPackageInfo({ - savedObjectsClient: soClient, - pkgName: packagePolicy.package.name, - pkgVersion: packagePolicy.package.version, - ignoreUnverified: true, - prerelease: true, - }) + ? packageInfos.get(`${packagePolicy.package.name}-${packagePolicy.package.version}`) : undefined; if (!packagePolicy.id) { packagePolicy.id = SavedObjectsUtils.generateId(); @@ -508,7 +507,7 @@ class PackagePolicyClientImpl implements PackagePolicyClient { this.keepPolicyIdInSync(packagePolicy); await preflightCheckPackagePolicy(soClient, packagePolicy, basePkgInfo); - } + }); const agentPolicyIds = new Set(packagePolicies.flatMap((pkgPolicy) => pkgPolicy.policy_ids)); @@ -528,8 +527,6 @@ class PackagePolicyClientImpl implements PackagePolicyClient { } } - const packageInfos = await getPackageInfoForPackagePolicies(packagePolicies, soClient); - const isoDate = new Date().toISOString(); const policiesToCreate: Array> = []; @@ -665,6 +662,7 @@ class PackagePolicyClientImpl implements PackagePolicyClient { for (const agentPolicyId of agentPolicyIds) { await agentPolicyService.bumpRevision(soClient, esClient, agentPolicyId, { user: options?.user, + asyncDeploy: options?.asyncDeploy, }); } } @@ -1176,7 +1174,7 @@ class PackagePolicyClientImpl implements PackagePolicyClient { soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, packagePolicyUpdates: Array, - options?: { user?: AuthenticatedUser; force?: boolean } + options?: { user?: AuthenticatedUser; force?: boolean; asyncDeploy?: boolean } ): Promise<{ updatedPolicies: PackagePolicy[] | null; failedPolicies: Array<{ @@ -1347,6 +1345,7 @@ class PackagePolicyClientImpl implements PackagePolicyClient { await agentPolicyService.bumpRevision(soClient, esClient, agentPolicyId, { user: options?.user, removeProtection, + asyncDeploy: options?.asyncDeploy, }); }); @@ -2368,6 +2367,7 @@ class PackagePolicyClientWithAuthz extends PackagePolicyClientImpl { user?: AuthenticatedUser | undefined; bumpRevision?: boolean | undefined; force?: true | undefined; + asyncDeploy?: boolean; } | undefined ): Promise<{ diff --git a/x-pack/plugins/fleet/server/services/package_policy_service.ts b/x-pack/plugins/fleet/server/services/package_policy_service.ts index 967efb1055cfb..5a83c2adf97ab 100644 --- a/x-pack/plugins/fleet/server/services/package_policy_service.ts +++ b/x-pack/plugins/fleet/server/services/package_policy_service.ts @@ -105,6 +105,7 @@ export interface PackagePolicyClient { bumpRevision?: boolean; force?: true; authorizationHeader?: HTTPAuthorizationHeader | null; + asyncDeploy?: boolean; } ): Promise<{ created: PackagePolicy[]; @@ -115,7 +116,7 @@ export interface PackagePolicyClient { soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, packagePolicyUpdates: UpdatePackagePolicy[], - options?: { user?: AuthenticatedUser; force?: boolean }, + options?: { user?: AuthenticatedUser; force?: boolean; asyncDeploy?: boolean }, currentVersion?: string ): Promise<{ updatedPolicies: PackagePolicy[] | null; @@ -165,6 +166,7 @@ export interface PackagePolicyClient { user?: AuthenticatedUser; skipUnassignFromAgentPolicies?: boolean; force?: boolean; + asyncDeploy?: boolean; }, context?: RequestHandlerContext, request?: KibanaRequest diff --git a/x-pack/plugins/fleet/server/services/spaces/agent_policy.ts b/x-pack/plugins/fleet/server/services/spaces/agent_policy.ts index 14d7f45f2c47c..2f8d5ff1b14c7 100644 --- a/x-pack/plugins/fleet/server/services/spaces/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/spaces/agent_policy.ts @@ -115,12 +115,30 @@ export async function updateAgentPolicySpaces({ // Update fleet server index agents, enrollment api keys await esClient.updateByQuery({ index: ENROLLMENT_API_KEYS_INDEX, + query: { + bool: { + must: { + terms: { + policy_id: [agentPolicyId], + }, + }, + }, + }, script: `ctx._source.namespaces = [${newSpaceIds.map((spaceId) => `"${spaceId}"`).join(',')}]`, ignore_unavailable: true, refresh: true, }); await esClient.updateByQuery({ index: AGENTS_INDEX, + query: { + bool: { + must: { + terms: { + policy_id: [agentPolicyId], + }, + }, + }, + }, script: `ctx._source.namespaces = [${newSpaceIds.map((spaceId) => `"${spaceId}"`).join(',')}]`, ignore_unavailable: true, refresh: true, diff --git a/x-pack/plugins/global_search/kibana.jsonc b/x-pack/plugins/global_search/kibana.jsonc index 080d327dec4cb..9eb188986901d 100644 --- a/x-pack/plugins/global_search/kibana.jsonc +++ b/x-pack/plugins/global_search/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/global-search-plugin", - "owner": "@elastic/appex-sharedux", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "globalSearch", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "global_search" @@ -14,4 +18,4 @@ "licensing" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/global_search_bar/kibana.jsonc b/x-pack/plugins/global_search_bar/kibana.jsonc index 6412f7c8ed890..b61cce43fe485 100644 --- a/x-pack/plugins/global_search_bar/kibana.jsonc +++ b/x-pack/plugins/global_search_bar/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/global-search-bar-plugin", - "owner": "@elastic/appex-sharedux", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "private", "plugin": { "id": "globalSearchBar", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "global_search_bar" @@ -18,4 +22,4 @@ "savedObjectsTagging" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/global_search_providers/kibana.jsonc b/x-pack/plugins/global_search_providers/kibana.jsonc index cdfed2ebbaf5d..aa8228118be09 100644 --- a/x-pack/plugins/global_search_providers/kibana.jsonc +++ b/x-pack/plugins/global_search_providers/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/global-search-providers-plugin", - "owner": "@elastic/appex-sharedux", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "private", "plugin": { "id": "globalSearchProviders", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "global_search_providers" @@ -14,4 +18,4 @@ "globalSearch" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/graph/kibana.jsonc b/x-pack/plugins/graph/kibana.jsonc index 3c299bbeb4a2b..33862384394b4 100644 --- a/x-pack/plugins/graph/kibana.jsonc +++ b/x-pack/plugins/graph/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/graph-plugin", - "owner": "@elastic/kibana-visualizations", + "owner": [ + "@elastic/kibana-visualizations" + ], + "group": "platform", + "visibility": "private", "plugin": { "id": "graph", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "graph" @@ -31,4 +35,4 @@ "savedObjects" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/grokdebugger/kibana.jsonc b/x-pack/plugins/grokdebugger/kibana.jsonc index c006355cc9265..f72fe281c1942 100644 --- a/x-pack/plugins/grokdebugger/kibana.jsonc +++ b/x-pack/plugins/grokdebugger/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/grokdebugger-plugin", - "owner": "@elastic/kibana-management", + "owner": [ + "@elastic/kibana-management" + ], + "group": "platform", + "visibility": "private", "plugin": { "id": "grokdebugger", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "grokdebugger" @@ -19,4 +23,4 @@ "kibanaReact" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts index bd119a77378af..608d2ce5390da 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts @@ -298,6 +298,7 @@ export const createDataStreamPayload = (dataStream: Partial): DataSt enabled: true, data_retention: '7d', }, + indexMode: 'standard', ...dataStream, }); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts index a4ea7b9296e28..1d7ee65790cfd 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts @@ -205,8 +205,8 @@ describe('Data Streams tab', () => { const { tableCellsValues } = table.getMetaData('dataStreamTable'); expect(tableCellsValues).toEqual([ - ['', 'dataStream1', 'green', '1', '7 days', 'Delete'], - ['', 'dataStream2', 'green', '1', '5 days ', 'Delete'], + ['', 'dataStream1', 'green', '1', 'Standard', '7 days', 'Delete'], + ['', 'dataStream2', 'green', '1', 'Standard', '5 days ', 'Delete'], ]); }); @@ -254,6 +254,7 @@ describe('Data Streams tab', () => { 'December 31st, 1969 7:00:00 PM', '5b', '1', + 'Standard', '7 days', 'Delete', ], @@ -264,6 +265,7 @@ describe('Data Streams tab', () => { 'December 31st, 1969 7:00:00 PM', '1kb', '1', + 'Standard', '5 days ', 'Delete', ], @@ -289,6 +291,7 @@ describe('Data Streams tab', () => { 'December 31st, 1969 7:00:00 PM', '5b', '1', + 'Standard', '7 days', 'Delete', ], @@ -299,6 +302,7 @@ describe('Data Streams tab', () => { 'December 31st, 1969 7:00:00 PM', '1kb', '1', + 'Standard', '5 days ', 'Delete', ], @@ -346,8 +350,8 @@ describe('Data Streams tab', () => { const { tableCellsValues } = table.getMetaData('dataStreamTable'); expect(tableCellsValues).toEqual([ - ['', 'dataStream1', 'green', '156kb', '10000', '1', '7 days', 'Delete'], - ['', 'dataStream2', 'green', '156kb', '10000', '1', '5 days ', 'Delete'], + ['', 'dataStream1', 'green', '156kb', '10000', '1', 'Standard', '7 days', 'Delete'], + ['', 'dataStream2', 'green', '156kb', '10000', '1', 'Standard', '5 days ', 'Delete'], ]); }); @@ -378,6 +382,7 @@ describe('Data Streams tab', () => { 'December 31st, 1969 7:00:00 PM', '5b', '1', + 'Standard', '7 days', 'Delete', ], @@ -388,6 +393,7 @@ describe('Data Streams tab', () => { 'December 31st, 1969 7:00:00 PM', '1kb', '1', + 'Standard', '5 days ', 'Delete', ], @@ -509,8 +515,8 @@ describe('Data Streams tab', () => { const { tableCellsValues } = table.getMetaData('dataStreamTable'); expect(tableCellsValues).toEqual([ - ['', 'dataStream1', 'green', '1', 'Disabled', 'Delete'], - ['', 'dataStream2', 'green', '1', '', 'Delete'], + ['', 'dataStream1', 'green', '1', 'Standard', 'Disabled', 'Delete'], + ['', 'dataStream2', 'green', '1', 'Standard', '', 'Delete'], ]); await actions.clickNameAt(0); @@ -892,8 +898,16 @@ describe('Data Streams tab', () => { const { tableCellsValues } = table.getMetaData('dataStreamTable'); expect(tableCellsValues).toEqual([ - ['', `managed-data-stream${nonBreakingSpace}Managed`, 'green', '1', '7 days', 'Delete'], - ['', 'non-managed-data-stream', 'green', '1', '7 days', 'Delete'], + [ + '', + `managed-data-stream${nonBreakingSpace}Managed`, + 'green', + '1', + 'Standard', + '7 days', + 'Delete', + ], + ['', 'non-managed-data-stream', 'green', '1', 'Standard', '7 days', 'Delete'], ]); }); @@ -902,15 +916,23 @@ describe('Data Streams tab', () => { let { tableCellsValues } = table.getMetaData('dataStreamTable'); expect(tableCellsValues).toEqual([ - ['', `managed-data-stream${nonBreakingSpace}Managed`, 'green', '1', '7 days', 'Delete'], - ['', 'non-managed-data-stream', 'green', '1', '7 days', 'Delete'], + [ + '', + `managed-data-stream${nonBreakingSpace}Managed`, + 'green', + '1', + 'Standard', + '7 days', + 'Delete', + ], + ['', 'non-managed-data-stream', 'green', '1', 'Standard', '7 days', 'Delete'], ]); actions.toggleViewFilterAt(0); ({ tableCellsValues } = table.getMetaData('dataStreamTable')); expect(tableCellsValues).toEqual([ - ['', 'non-managed-data-stream', 'green', '1', '7 days', 'Delete'], + ['', 'non-managed-data-stream', 'green', '1', 'Standard', '7 days', 'Delete'], ]); }); }); @@ -942,7 +964,15 @@ describe('Data Streams tab', () => { const { tableCellsValues } = table.getMetaData('dataStreamTable'); expect(tableCellsValues).toEqual([ - ['', `hidden-data-stream${nonBreakingSpace}Hidden`, 'green', '1', '7 days', 'Delete'], + [ + '', + `hidden-data-stream${nonBreakingSpace}Hidden`, + 'green', + '1', + 'Standard', + '7 days', + 'Delete', + ], ]); }); }); @@ -989,10 +1019,10 @@ describe('Data Streams tab', () => { const { tableCellsValues } = table.getMetaData('dataStreamTable'); expect(tableCellsValues).toEqual([ - ['', 'dataStreamNoDelete', 'green', '1', '7 days', ''], - ['', 'dataStreamNoEditRetention', 'green', '1', '7 days', 'Delete'], - ['', 'dataStreamNoPermissions', 'green', '1', '7 days', ''], - ['', 'dataStreamWithDelete', 'green', '1', '7 days', 'Delete'], + ['', 'dataStreamNoDelete', 'green', '1', 'Standard', '7 days', ''], + ['', 'dataStreamNoEditRetention', 'green', '1', 'Standard', '7 days', 'Delete'], + ['', 'dataStreamNoPermissions', 'green', '1', 'Standard', '7 days', ''], + ['', 'dataStreamWithDelete', 'green', '1', 'Standard', '7 days', 'Delete'], ]); }); diff --git a/x-pack/plugins/index_management/common/lib/template_serialization.ts b/x-pack/plugins/index_management/common/lib/template_serialization.ts index f8b4ed47a22f7..0ed52e3f04ba0 100644 --- a/x-pack/plugins/index_management/common/lib/template_serialization.ts +++ b/x-pack/plugins/index_management/common/lib/template_serialization.ts @@ -73,6 +73,8 @@ export function deserializeTemplate( type = 'managed'; } + const ilmPolicyName = settings?.index?.lifecycle?.name; + const deserializedTemplate: TemplateDeserialized = { name, version, @@ -80,7 +82,7 @@ export function deserializeTemplate( ...(template.lifecycle ? { lifecycle: deserializeESLifecycle(template.lifecycle) } : {}), indexPatterns: indexPatterns.sort(), template, - ilmPolicy: settings?.index?.lifecycle, + ilmPolicy: ilmPolicyName ? { name: ilmPolicyName } : undefined, composedOf: composedOf ?? [], ignoreMissingComponentTemplates: ignoreMissingComponentTemplates ?? [], dataStream, diff --git a/x-pack/plugins/index_management/common/types/data_streams.ts b/x-pack/plugins/index_management/common/types/data_streams.ts index 78c671969f579..993d32f32bee1 100644 --- a/x-pack/plugins/index_management/common/types/data_streams.ts +++ b/x-pack/plugins/index_management/common/types/data_streams.ts @@ -33,6 +33,8 @@ export type DataStreamIndexFromEs = IndicesDataStreamIndex; export type Health = 'green' | 'yellow' | 'red'; +export type IndexMode = 'standard' | 'logsdb' | 'time_series'; + export interface EnhancedDataStreamFromEs extends IndicesDataStream { global_max_retention?: string; store_size?: IndicesDataStreamsStatsDataStreamsStatsItem['store_size']; @@ -45,6 +47,7 @@ export interface EnhancedDataStreamFromEs extends IndicesDataStream { delete_index: boolean; manage_data_stream_lifecycle: boolean; }; + index_mode?: string | null; } export interface DataStream { @@ -71,6 +74,7 @@ export interface DataStream { retention_determined_by?: string; globalMaxRetention?: string; }; + indexMode: IndexMode; } export interface DataStreamIndex { diff --git a/x-pack/plugins/index_management/common/types/indices.ts b/x-pack/plugins/index_management/common/types/indices.ts index 612aaf3bd6c9b..804a1bce1e299 100644 --- a/x-pack/plugins/index_management/common/types/indices.ts +++ b/x-pack/plugins/index_management/common/types/indices.ts @@ -5,29 +5,9 @@ * 2.0. */ -export type { Index } from '@kbn/index-management-shared-types'; +import { IndicesIndexSettingsKeys } from '@elastic/elasticsearch/lib/api/types'; -export interface IndexModule { - number_of_shards: number | string; - codec: string; - routing_partition_size: number; - refresh_interval: string; - load_fixed_bitset_filters_eagerly: boolean; - shard: { - check_on_startup: boolean | 'checksum'; - }; - number_of_replicas: number; - auto_expand_replicas: false | string; - lifecycle: LifecycleModule; - routing: { - allocation: { - enable: 'all' | 'primaries' | 'new_primaries' | 'none'; - }; - rebalance: { - enable: 'all' | 'primaries' | 'replicas' | 'none'; - }; - }; -} +export type { Index } from '@kbn/index-management-shared-types'; interface AnalysisModule { analyzer: { @@ -41,15 +21,8 @@ interface AnalysisModule { }; } -interface LifecycleModule { - name: string; - rollover_alias?: string; - parse_origination_date?: boolean; - origination_date?: number; -} - export interface IndexSettings { - index?: Partial; + index?: IndicesIndexSettingsKeys; analysis?: AnalysisModule; [key: string]: any; } diff --git a/x-pack/plugins/index_management/kibana.jsonc b/x-pack/plugins/index_management/kibana.jsonc index b9bec8115e019..de2aa50a20eac 100644 --- a/x-pack/plugins/index_management/kibana.jsonc +++ b/x-pack/plugins/index_management/kibana.jsonc @@ -1,14 +1,38 @@ { "type": "plugin", "id": "@kbn/index-management-plugin", - "owner": "@elastic/kibana-management", + "owner": [ + "@elastic/kibana-management" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "indexManagement", - "server": true, "browser": true, - "configPath": ["xpack", "index_management"], - "requiredPlugins": ["home", "management", "features", "share"], - "optionalPlugins": ["security", "usageCollection", "fleet", "cloud", "ml", "console","licensing"], - "requiredBundles": ["kibanaReact", "esUiShared", "runtimeFields"] + "server": true, + "configPath": [ + "xpack", + "index_management" + ], + "requiredPlugins": [ + "home", + "management", + "features", + "share" + ], + "optionalPlugins": [ + "security", + "usageCollection", + "fleet", + "cloud", + "ml", + "console", + "licensing" + ], + "requiredBundles": [ + "kibanaReact", + "esUiShared", + "runtimeFields" + ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts index 728ba79eedd81..71231c89b673c 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts @@ -623,7 +623,7 @@ export const getFieldsMatchingFilterFromState = ( } => { return Object.fromEntries( Object.entries(state.fields.byId).filter(([_, fieldId]) => - filteredDataTypes.includes(TYPE_DEFINITION[state.fields.byId[fieldId.id].source.type].label) + filteredDataTypes.includes(getTypeLabelFromField(state.fields.byId[fieldId.id].source)) ) ); }; @@ -646,9 +646,7 @@ export const getFieldsFromState = ( const getField = (fieldId: string) => { if (filteredDataTypes) { if ( - filteredDataTypes.includes( - TYPE_DEFINITION[normalizedFields.byId[fieldId].source.type].label - ) + filteredDataTypes.includes(getTypeLabelFromField(normalizedFields.byId[fieldId].source)) ) { return normalizedFields.byId[fieldId]; } diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/use_state_listener.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/use_state_listener.tsx index 5c5e1c6a289fa..26610773ddbf4 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/use_state_listener.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/use_state_listener.tsx @@ -26,9 +26,9 @@ import { deNormalizeRuntimeFields, getAllFieldTypesFromState, getFieldsFromState, + getTypeLabelFromField, } from './lib'; import { useMappingsState, useDispatch } from './mappings_state_context'; -import { TYPE_DEFINITION } from './constants'; interface Args { onChange?: OnUpdateHandler; @@ -56,7 +56,7 @@ export const useMappingsStateListener = ({ onChange, value, status }: Args) => { const allFieldsTypes = getAllFieldTypesFromState(deNormalize(normalize(mappedFields))); return allFieldsTypes.map((dataType) => ({ checked: undefined, - label: TYPE_DEFINITION[dataType].label, + label: getTypeLabelFromField({ type: dataType }), 'data-test-subj': `indexDetailsMappingsSelectFilter-${dataType}`, })); }, [mappedFields]); diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review.tsx index 24544db04498b..9cb5c481b6b50 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review.tsx @@ -22,6 +22,7 @@ import { EuiCodeBlock, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import { getIndexModeLabel } from '../../../lib/index_mode_labels'; import { allowAutoCreateRadioIds } from '../../../../../common/constants'; import { serializers } from '../../../../shared_imports'; @@ -268,6 +269,19 @@ export const StepReview: React.FunctionComponent = React.memo( {getDescriptionText(serializedSettings)} + {/* Index mode */} + + + + + {getIndexModeLabel( + serializedSettings?.['index.mode'] ?? serializedSettings?.index?.mode + )} + + {/* Mappings */} { describe('getLifecycleValue', () => { @@ -45,4 +45,36 @@ describe('Data stream helpers', () => { ).toBe('5 days'); }); }); + + describe('deserializeGlobalMaxRetention', () => { + it('if globalMaxRetention is undefined', () => { + expect(deserializeGlobalMaxRetention(undefined)).toEqual({}); + }); + + it('split globalMaxRetention size and units', () => { + expect(deserializeGlobalMaxRetention('1000h')).toEqual({ + size: '1000', + unit: 'h', + unitText: 'hours', + }); + }); + + it('support all of the units that are accepted by es', () => { + expect(deserializeGlobalMaxRetention('1000ms')).toEqual({ + size: '1000', + unit: 'ms', + unitText: 'milliseconds', + }); + expect(deserializeGlobalMaxRetention('1000micros')).toEqual({ + size: '1000', + unit: 'micros', + unitText: 'microseconds', + }); + expect(deserializeGlobalMaxRetention('1000nanos')).toEqual({ + size: '1000', + unit: 'nanos', + unitText: 'nanoseconds', + }); + }); + }); }); diff --git a/x-pack/plugins/index_management/public/application/lib/data_streams.tsx b/x-pack/plugins/index_management/public/application/lib/data_streams.tsx index 71c8bb0f61177..6747e84df751d 100644 --- a/x-pack/plugins/index_management/public/application/lib/data_streams.tsx +++ b/x-pack/plugins/index_management/public/application/lib/data_streams.tsx @@ -121,3 +121,19 @@ export const isDSLWithILMIndices = (dataStream?: DataStream | null) => { return; }; + +export const deserializeGlobalMaxRetention = (globalMaxRetention?: string) => { + if (!globalMaxRetention) { + return {}; + } + + const { size, unit } = splitSizeAndUnits(globalMaxRetention); + const availableTimeUnits = [...timeUnits, ...extraTimeUnits]; + const match = availableTimeUnits.find((timeUnit) => timeUnit.value === unit); + + return { + size, + unit, + unitText: match?.text ?? unit, + }; +}; diff --git a/x-pack/plugins/index_management/public/application/lib/index_mode_labels.ts b/x-pack/plugins/index_management/public/application/lib/index_mode_labels.ts new file mode 100644 index 0000000000000..409659b8133c3 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/lib/index_mode_labels.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const getIndexModeLabel = (mode?: string | null) => { + switch (mode) { + case 'standard': + case null: + case undefined: + return i18n.translate('xpack.idxMgmt.indexModeLabels.standardModeLabel', { + defaultMessage: 'Standard', + }); + case 'logsdb': + return i18n.translate('xpack.idxMgmt.indexModeLabels.logsdbModeLabel', { + defaultMessage: 'LogsDB', + }); + case 'time_series': + return i18n.translate('xpack.idxMgmt.indexModeLabels.tsdbModeLabel', { + defaultMessage: 'Time series', + }); + default: + return mode; + } +}; diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx index 974ba6f082042..5b3bf0920c3b7 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx @@ -34,6 +34,7 @@ import { EuiSpacer, } from '@elastic/eui'; +import { getIndexModeLabel } from '../../../../lib/index_mode_labels'; import { DiscoverLink } from '../../../../lib/discover_link'; import { getLifecycleValue } from '../../../../lib/data_streams'; import { SectionLoading, reactRouterNavigate } from '../../../../../shared_imports'; @@ -166,6 +167,7 @@ export const DataStreamDetailPanel: React.FunctionComponent = ({ meteringStorageSize, meteringDocsCount, lifecycle, + indexMode, } = dataStream; const getManagementDetails = () => { @@ -345,6 +347,17 @@ export const DataStreamDetailPanel: React.FunctionComponent = ({ ), dataTestSubj: 'indexTemplateDetail', }, + { + name: i18n.translate('xpack.idxMgmt.dataStreamDetailPanel.indexModeTitle', { + defaultMessage: 'Index mode', + }), + toolTip: i18n.translate('xpack.idxMgmt.dataStreamDetailPanel.indexModeToolTip', { + defaultMessage: + "The index mode applied to the data stream's backing indices, as defined in its associated index template.", + }), + content: getIndexModeLabel(indexMode), + dataTestSubj: 'indexModeDetail', + }, { name: i18n.translate('xpack.idxMgmt.dataStreamDetailPanel.dataRetentionTitle', { defaultMessage: 'Effective data retention', diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_table/data_stream_table.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_table/data_stream_table.tsx index 927907757fe7b..e91fd644f795c 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_table/data_stream_table.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_table/data_stream_table.tsx @@ -36,6 +36,7 @@ import { humanizeTimeStamp } from '../humanize_time_stamp'; import { DataStreamsBadges } from '../data_stream_badges'; import { ConditionalWrap } from '../data_stream_detail_panel'; import { isDataStreamFullyManagedByILM } from '../../../../lib/data_streams'; +import { getIndexModeLabel } from '../../../../lib/index_mode_labels'; import { FilterListButton, Filters } from '../../components'; import { type DataStreamFilterName } from '../data_stream_list'; @@ -184,6 +185,16 @@ export const DataStreamTable: React.FunctionComponent = ({ ), }); + columns.push({ + field: 'indexMode', + name: i18n.translate('xpack.idxMgmt.dataStreamList.table.indexModeColumnTitle', { + defaultMessage: 'Index mode', + }), + truncateText: true, + sortable: true, + render: (indexMode: DataStream['indexMode']) => getIndexModeLabel(indexMode), + }); + columns.push({ field: 'lifecycle', name: ( diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/edit_data_retention_modal.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/edit_data_retention_modal.tsx index f5eee4671481a..2c60e04be31a6 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/edit_data_retention_modal.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/edit_data_retention_modal.tsx @@ -43,7 +43,7 @@ import { getIndexListUri } from '../../../../services/routing'; import { documentationService } from '../../../../services/documentation'; import { splitSizeAndUnits, DataStream } from '../../../../../../common'; import { timeUnits } from '../../../../constants/time_units'; -import { isDSLWithILMIndices } from '../../../../lib/data_streams'; +import { deserializeGlobalMaxRetention, isDSLWithILMIndices } from '../../../../lib/data_streams'; import { useAppContext } from '../../../../app_context'; import { UnitField } from '../../../../components/shared'; import { updateDataRetention } from '../../../../services/api'; @@ -214,6 +214,7 @@ export const EditDataRetentionModal: React.FunctionComponent = ({ const { history } = useAppContext(); const dslWithIlmIndices = isDSLWithILMIndices(dataStream); const { size, unit } = splitSizeAndUnits(lifecycle?.data_retention as string); + const globalMaxRetention = deserializeGlobalMaxRetention(lifecycle?.globalMaxRetention); const { services: { notificationService }, config: { enableTogglingDataRetention, enableProjectLevelRetentionChecks }, @@ -331,8 +332,11 @@ export const EditDataRetentionModal: React.FunctionComponent = ({ <> diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/validations.test.ts b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/validations.test.ts index 0768d0990cdc0..87cc1d36526fb 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/validations.test.ts +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/validations.test.ts @@ -47,6 +47,26 @@ describe('isBiggerThanGlobalMaxRetention', () => { }); }); + it('should correctly compare retention in all of the units that are accepted by es', () => { + // 1000 milliseconds = 1 seconds + expect(isBiggerThanGlobalMaxRetention(1, 's', '1000ms')).toBeUndefined(); + expect(isBiggerThanGlobalMaxRetention(2, 's', '1000ms')).toEqual({ + message: 'Maximum data retention period on this project is 1000 milliseconds.', + }); + + // 1000000 microseconds = 1 seconds + expect(isBiggerThanGlobalMaxRetention(1, 's', '1000000micros')).toBeUndefined(); + expect(isBiggerThanGlobalMaxRetention(2, 'm', '1000000micros')).toEqual({ + message: 'Maximum data retention period on this project is 1000000 microseconds.', + }); + + // 1000000000 microseconds = 1 seconds + expect(isBiggerThanGlobalMaxRetention(2, 's', '1000000000nanos')); + expect(isBiggerThanGlobalMaxRetention(2, 'h', '1000000000nanos')).toEqual({ + message: 'Maximum data retention period on this project is 1000000000 nanoseconds.', + }); + }); + it('should throw an error for unknown time units', () => { expect(() => isBiggerThanGlobalMaxRetention(10, 'x', '30d')).toThrow('Unknown unit: x'); }); diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/validations.ts b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/validations.ts index 831ac2f4c26b9..8486f01fb5b44 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/validations.ts +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/edit_data_retention_modal/validations.ts @@ -7,34 +7,44 @@ import { i18n } from '@kbn/i18n'; import { splitSizeAndUnits } from '../../../../../../common'; +import { deserializeGlobalMaxRetention } from '../../../../lib/data_streams'; -const convertToMinutes = (value: string) => { +const convertToSeconds = (value: string) => { const { size, unit } = splitSizeAndUnits(value); const sizeNum = parseInt(size, 10); switch (unit) { case 'd': - // days to minutes - return sizeNum * 24 * 60; + // days to seconds + return sizeNum * 24 * 60 * 60; case 'h': - // hours to minutes - return sizeNum * 60; + // hours to seconds + return sizeNum * 60 * 60; case 'm': - // minutes to minutes - return sizeNum; + // minutes to seconds + return sizeNum * 60; case 's': - // seconds to minutes (round up if any remainder) - return Math.ceil(sizeNum / 60); + // seconds to seconds + return sizeNum; + case 'ms': + // milliseconds to seconds + return sizeNum / 1000; + case 'micros': + // microseconds to seconds + return sizeNum / 1000 / 1000; + case 'nanos': + // nanoseconds to seconds + return sizeNum / 1000 / 1000 / 1000; default: throw new Error(`Unknown unit: ${unit}`); } }; const isRetentionBiggerThan = (valueA: string, valueB: string) => { - const minutesA = convertToMinutes(valueA); - const minutesB = convertToMinutes(valueB); + const secondsA = convertToSeconds(valueA); + const secondsB = convertToSeconds(valueB); - return minutesA > minutesB; + return secondsA > secondsB; }; export const isBiggerThanGlobalMaxRetention = ( @@ -46,14 +56,19 @@ export const isBiggerThanGlobalMaxRetention = ( return undefined; } + const { size, unitText } = deserializeGlobalMaxRetention(globalMaxRetention); return isRetentionBiggerThan(`${retentionValue}${retentionTimeUnit}`, globalMaxRetention) ? { message: i18n.translate( 'xpack.idxMgmt.dataStreamsDetailsPanel.editDataRetentionModal.dataRetentionFieldMaxError', { - defaultMessage: 'Maximum data retention period on this project is {maxRetention} days.', + defaultMessage: + 'Maximum data retention period on this project is {maxRetention} {unitText}.', // Remove the unit from the globalMaxRetention value - values: { maxRetention: globalMaxRetention.slice(0, -1) }, + values: { + maxRetention: size, + unitText, + }, } ), } diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/trained_models_deployment_modal.tsx b/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/trained_models_deployment_modal.tsx index b2e9a1339c3fb..b3dc5244165fb 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/trained_models_deployment_modal.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/trained_models_deployment_modal.tsx @@ -26,10 +26,8 @@ import React from 'react'; import { EuiLink } from '@elastic/eui'; import { useEffect, useMemo, useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { ModelIdMapEntry } from '../../../../components/mappings_editor/components/document_fields/fields'; import { isSemanticTextField } from '../../../../components/mappings_editor/lib/utils'; import { deNormalize } from '../../../../components/mappings_editor/lib'; -import { useMLModelNotificationToasts } from '../../../../../hooks/use_ml_model_status_toasts'; import { useMappingsState } from '../../../../components/mappings_editor/mappings_state_context'; import { useAppContext } from '../../../../app_context'; @@ -55,15 +53,11 @@ export function TrainedModelsDeploymentModal({ }: TrainedModelsDeploymentModalProps) { const modalTitleId = useGeneratedHtmlId(); const { fields, inferenceToModelIdMap } = useMappingsState(); - const { - plugins: { ml }, - url, - } = useAppContext(); + const { url } = useAppContext(); const [isModalVisible, setIsModalVisible] = useState(false); const closeModal = () => setIsModalVisible(false); const [mlManagementPageUrl, setMlManagementPageUrl] = useState(''); const [allowForceSaveMappings, setAllowForceSaveMappings] = useState(false); - const { showErrorToasts, showSuccessfullyDeployedToast } = useMLModelNotificationToasts(); useEffect(() => { const mlLocator = url?.locators.get(ML_APP_LOCATOR); @@ -86,25 +80,6 @@ export function TrainedModelsDeploymentModal({ const [pendingDeployments, setPendingDeployments] = useState([]); - const startModelAllocation = async (entry: ModelIdMapEntry & { inferenceId: string }) => { - try { - await ml?.mlApi?.trainedModels.startModelAllocation(entry.trainedModelId, { - number_of_allocations: 1, - threads_per_allocation: 1, - priority: 'normal', - deployment_id: entry.inferenceId, - }); - showSuccessfullyDeployedToast(entry.trainedModelId); - } catch (error) { - setErrorsInTrainedModelDeployment((previousState) => ({ - ...previousState, - [entry.inferenceId]: error.message, - })); - showErrorToasts(error); - setIsModalVisible(true); - } - }; - useEffect(() => { const models = inferenceIdsInPendingList.map((inferenceId) => inferenceToModelIdMap?.[inferenceId] @@ -114,18 +89,6 @@ export function TrainedModelsDeploymentModal({ } : undefined ); // filter out third-party models - for (const model of models) { - if ( - model?.trainedModelId && - model.isDeployable && - !model.isDownloading && - !model.isDeployed - ) { - // Sometimes the model gets stuck in a ready to deploy state, so we need to trigger deployment manually - // This is currently the only way to surface a specific error message to the user - startModelAllocation(model); - } - } const allPendingDeployments = models .map((model) => { return model?.trainedModelId && !model?.isDeployed ? model?.inferenceId : ''; @@ -135,7 +98,6 @@ export function TrainedModelsDeploymentModal({ (deployment, index) => allPendingDeployments.indexOf(deployment) === index ); setPendingDeployments(uniqueDeployments); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [inferenceIdsInPendingList, inferenceToModelIdMap]); const erroredDeployments = pendingDeployments.filter( diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table_pagination.tsx b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table_pagination.tsx index b9dd98e21a426..a0988aec797f6 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table_pagination.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table_pagination.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { EuiTablePagination } from '@elastic/eui'; import { useEuiTablePersist } from '@kbn/shared-ux-table-persist'; -import { IndexModule } from '../../../../../../common'; +import { Index } from '../../../../../../common'; interface IndexTablePaginationProps { pager: any; @@ -27,7 +27,7 @@ export const IndexTablePagination = ({ readURLParams, setURLParam, }: IndexTablePaginationProps) => { - const { pageSize, onTableChange } = useEuiTablePersist({ + const { pageSize, onTableChange } = useEuiTablePersist({ tableId: 'indices', initialPageSize: pager.itemsPerPage, pageSizeOptions: PAGE_SIZE_OPTIONS, diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_summary.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_summary.tsx index 513377714ffe0..ff06a08014f61 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_summary.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_summary.tsx @@ -28,6 +28,7 @@ import { TemplateDeserialized } from '../../../../../../../common'; import { ILM_PAGES_POLICY_EDIT } from '../../../../../constants'; import { useIlmLocator } from '../../../../../services/use_ilm_locator'; import { allowAutoCreateRadioIds } from '../../../../../../../common/constants'; +import { getIndexModeLabel } from '../../../../../lib/index_mode_labels'; interface Props { templateDetails: TemplateDeserialized; @@ -57,6 +58,7 @@ export const TabSummary: React.FunctionComponent = ({ templateDetails }) _meta, _kbnMeta: { isLegacy, hasDatastream }, allowAutoCreate, + template, } = templateDetails; const numIndexPatterns = indexPatterns.length; @@ -221,6 +223,17 @@ export const TabSummary: React.FunctionComponent = ({ templateDetails }) )} + {/* Index mode */} + + + + + {getIndexModeLabel(template?.settings?.index?.mode)} + + {/* Allow auto create */} {isLegacy !== true && allowAutoCreate !== allowAutoCreateRadioIds.NO_OVERWRITE_RADIO_OPTION && ( diff --git a/x-pack/plugins/index_management/server/lib/data_stream_serialization.ts b/x-pack/plugins/index_management/server/lib/data_stream_serialization.ts index 2e493ca02aa79..31c0baa6c6b8c 100644 --- a/x-pack/plugins/index_management/server/lib/data_stream_serialization.ts +++ b/x-pack/plugins/index_management/server/lib/data_stream_serialization.ts @@ -6,6 +6,7 @@ */ import { ByteSizeValue } from '@kbn/config-schema'; +import { IndexMode } from '../../common/types/data_streams'; import type { DataStream, EnhancedDataStreamFromEs, Health } from '../../common'; export function deserializeDataStream(dataStreamFromEs: EnhancedDataStreamFromEs): DataStream { @@ -28,6 +29,7 @@ export function deserializeDataStream(dataStreamFromEs: EnhancedDataStreamFromEs lifecycle, global_max_retention: globalMaxRetention, next_generation_managed_by: nextGenerationManagedBy, + index_mode: indexMode, } = dataStreamFromEs; const meteringStorageSize = meteringStorageSizeBytes !== undefined @@ -73,6 +75,7 @@ export function deserializeDataStream(dataStreamFromEs: EnhancedDataStreamFromEs globalMaxRetention, }, nextGenerationManagedBy, + indexMode: (indexMode ?? 'standard') as IndexMode, }; } diff --git a/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts b/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts index 8b62c2b3a25cb..cd47b8cc9e0bb 100644 --- a/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts +++ b/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts @@ -11,6 +11,7 @@ import { IScopedClusterClient } from '@kbn/core/server'; import { IndicesDataStream, IndicesDataStreamsStatsDataStreamsStatsItem, + IndicesGetIndexTemplateIndexTemplateItem, SecurityHasPrivilegesResponse, } from '@elastic/elasticsearch/lib/api/types'; import type { MeteringStats } from '../../../lib/types'; @@ -31,12 +32,14 @@ const enhanceDataStreams = ({ meteringStats, dataStreamsPrivileges, globalMaxRetention, + indexTemplates, }: { dataStreams: IndicesDataStream[]; dataStreamsStats?: IndicesDataStreamsStatsDataStreamsStatsItem[]; meteringStats?: MeteringStats[]; dataStreamsPrivileges?: SecurityHasPrivilegesResponse; globalMaxRetention?: string; + indexTemplates?: IndicesGetIndexTemplateIndexTemplateItem[]; }): EnhancedDataStreamFromEs[] => { return dataStreams.map((dataStream) => { const enhancedDataStream: EnhancedDataStreamFromEs = { @@ -71,6 +74,16 @@ const enhanceDataStreams = ({ } } + if (indexTemplates) { + const indexTemplate = indexTemplates.find( + (template) => template.name === dataStream.template + ); + if (indexTemplate) { + enhancedDataStream.index_mode = + indexTemplate.index_template?.template?.settings?.index?.mode; + } + } + return enhancedDataStream; }); }; @@ -152,11 +165,15 @@ export function registerGetAllRoute({ router, lib: { handleEsError }, config }: ); } + const { index_templates: indexTemplates } = + await client.asCurrentUser.indices.getIndexTemplate(); + const enhancedDataStreams = enhanceDataStreams({ dataStreams, dataStreamsStats, meteringStats, dataStreamsPrivileges, + indexTemplates, }); return response.ok({ body: deserializeDataStreamList(enhancedDataStreams) }); @@ -199,17 +216,30 @@ export function registerGetOneRoute({ router, lib: { handleEsError }, config }: if (dataStreams[0]) { let dataStreamsPrivileges; + let indexTemplates; if (config.isSecurityEnabled()) { dataStreamsPrivileges = await getDataStreamsPrivileges(client, [dataStreams[0].name]); } + if (dataStreams[0].template) { + const { index_templates: templates } = + await client.asCurrentUser.indices.getIndexTemplate({ + name: dataStreams[0].template, + }); + + if (templates) { + indexTemplates = templates; + } + } + const enhancedDataStreams = enhanceDataStreams({ dataStreams, dataStreamsStats, meteringStats, dataStreamsPrivileges, globalMaxRetention, + indexTemplates, }); const body = deserializeDataStream(enhancedDataStreams[0]); return response.ok({ body }); diff --git a/x-pack/plugins/inference/README.md b/x-pack/plugins/inference/README.md index 1807da7f29faa..935ae31bd6bc6 100644 --- a/x-pack/plugins/inference/README.md +++ b/x-pack/plugins/inference/README.md @@ -4,13 +4,12 @@ The inference plugin is a central place to handle all interactions with the Elas external LLM APIs. Its goals are: - Provide a single place for all interactions with large language models and other generative AI adjacent tasks. -- Abstract away differences between different LLM providers like OpenAI, Bedrock and Gemini -- Host commonly used LLM-based tasks like generating ES|QL from natural language and knowledge base recall. +- Abstract away differences between different LLM providers like OpenAI, Bedrock and Gemini. - Allow us to move gradually to the \_inference endpoint without disrupting engineers. ## Architecture and examples -![CleanShot 2024-07-14 at 14 45 27@2x](https://github.com/user-attachments/assets/e65a3e47-bce1-4dcf-bbed-4f8ac12a104f) +![architecture-schema](https://github.com/user-attachments/assets/e65a3e47-bce1-4dcf-bbed-4f8ac12a104f) ## Terminology @@ -21,8 +20,22 @@ The following concepts are commonly used throughout the plugin: - **tools**: a set of tools that the LLM can choose to use when generating the next message. In essence, it allows the consumer of the API to define a schema for structured output instead of plain text, and having the LLM select the most appropriate one. - **tool call**: when the LLM has chosen a tool (schema) to use for its output, and returns a document that matches the schema, this is referred to as a tool call. +## Inference connectors + +Performing inference, or more globally communicating with the LLM, is done using stack connectors. + +The subset of connectors that can be used for inference are called `genAI`, or `inference` connectors. +Calling any inference APIs with the ID of a connector that is not inference-compatible will result in the API throwing an error. + +The list of inference connector types: +- `.gen-ai`: OpenAI connector +- `.bedrock`: Bedrock Claude connector +- `.gemini`: Vertex Gemini connector + ## Usage examples +The inference APIs are available via the inference client, which can be created using the inference plugin's start contract: + ```ts class MyPlugin { setup(coreSetup, pluginsSetup) { @@ -40,9 +53,9 @@ class MyPlugin { async (context, request, response) => { const [coreStart, pluginsStart] = await coreSetup.getStartServices(); - const inferenceClient = pluginsSetup.inference.getClient({ request }); + const inferenceClient = pluginsStart.inference.getClient({ request }); - const chatComplete$ = inferenceClient.chatComplete({ + const chatResponse = inferenceClient.chatComplete({ connectorId: request.body.connectorId, system: `Here is my system message`, messages: [ @@ -53,13 +66,9 @@ class MyPlugin { ], }); - const message = await lastValueFrom( - chatComplete$.pipe(withoutTokenCountEvents(), withoutChunkEvents()) - ); - return response.ok({ body: { - message, + chatResponse, }, }); } @@ -68,33 +77,190 @@ class MyPlugin { } ``` -## Services +## APIs -### `chatComplete`: +### `chatComplete` API: `chatComplete` generates a response to a prompt or a conversation using the LLM. Here's what is supported: -- Normalizing request and response formats from different connector types (e.g. OpenAI, Bedrock, Claude, Elastic Inference Service) +- Normalizing request and response formats from all supported connector types - Tool calling and validation of tool calls -- Emits token count events -- Emits message events, which is the concatenated message based on the response chunks +- Token usage stats / events +- Streaming mode to work with chunks in real time instead of waiting for the full response + +#### Standard usage + +In standard mode, the API returns a promise resolving with the full LLM response once the generation is complete. +The response will also contain the token count info, if available. + +```ts +const chatResponse = inferenceClient.chatComplete({ + connectorId: 'some-gen-ai-connector', + system: `Here is my system message`, + messages: [ + { + role: MessageRole.User, + content: 'Do something', + }, + ], +}); + +const { content, tokens } = chatResponse; +// do something with the output +``` + +#### Streaming mode + +Passing `stream: true` when calling the API enables streaming mode. +In that mode, the API returns an observable instead of a promise, emitting chunks in real time. + +That observable emits three types of events: + +- `chunk` the completion chunks, emitted in real time +- `tokenCount` token count event, containing info about token usages, eventually emitted after the chunks +- `message` full message event, emitted once the source is done sending chunks + +The `@kbn/inference-common` package exposes various utilities to work with this multi-events observable: + +- `isChatCompletionChunkEvent`, `isChatCompletionMessageEvent` and `isChatCompletionTokenCountEvent` which are type guard for the corresponding event types +- `withoutChunkEvents` and `withoutTokenCountEvents` + +```ts +import { + isChatCompletionChunkEvent, + isChatCompletionMessageEvent, + withoutTokenCountEvents, + withoutChunkEvents, +} from '@kbn/inference-common'; -### `output` +const chatComplete$ = inferenceClient.chatComplete({ + connectorId: 'some-gen-ai-connector', + stream: true, + system: `Here is my system message`, + messages: [ + { + role: MessageRole.User, + content: 'Do something', + }, + ], +}); -`output` is a wrapper around `chatComplete` that is catered towards a single use case: having the LLM output a structured response, based on a schema. It also drops the token count events to simplify usage. +// using and filtering the events +chatComplete$.pipe(withoutTokenCountEvents()).subscribe((event) => { + if (isChatCompletionChunkEvent(event)) { + // do something with the chunk event + } else { + // do something with the message event + } +}); + +// or retrieving the final message +const message = await lastValueFrom( + chatComplete$.pipe(withoutTokenCountEvents(), withoutChunkEvents()) +); +``` + +#### Defining and using tools + +Tools are defined as a record, with a `description` and optionally a `schema`. The reason why it's a record is because of type-safety. +This allows us to have fully typed tool calls (e.g. when the name of the tool being called is `x`, its arguments are typed as the schema of `x`). + +The description and schema of a tool will be converted and sent to the LLM, so it's important +to be explicit about what each tool does. + +```ts +const chatResponse = inferenceClient.chatComplete({ + connectorId: 'some-gen-ai-connector', + system: `Here is my system message`, + messages: [ + { + role: MessageRole.User, + content: 'How much is 4 plus 9?', + }, + ], + toolChoice: ToolChoiceType.required, // MUST call a tool + tools: { + date: { + description: 'Call this tool if you need to know the current date' + }, + add: { + description: 'This tool can be used to add two numbers', + schema: { + type: 'object', + properties: { + a: { type: 'number', description: 'the first number' }, + b: { type: 'number', description: 'the second number'} + }, + required: ['a', 'b'] + } + } + } as const // as const is required to have type inference on the schema +}); -### Observable event streams +const { content, toolCalls } = chatResponse; +const toolCall = toolCalls[0]; +// process the tool call and eventually continue the conversation with the LLM +``` + +### `output` API + +`output` is a wrapper around the `chatComplete` API that is catered towards a specific use case: having the LLM output a structured response, based on a schema. +It's basically just making sure that the LLM will call the single tool that is exposed via the provided `schema`. +It also drops the token count info to simplify usage. + +Similar to `chatComplete`, `output` supports two modes: normal full response mode by default, and optional streaming mode by passing the `stream: true` parameter. + +```ts +import { ToolSchema } from '@kbn/inference-common'; -These APIs, both on the client and the server, return Observables that emit events. When converting the Observable into a stream, the following things happen: +// schema must be defined as full const or using the `satisfies ToolSchema` modifier for TS type inference to work +const mySchema = { + type: 'object', + properties: { + animals: { + description: 'the list of animals that are mentioned in the provided article', + type: 'array', + items: { + type: 'string', + }, + }, + vegetables: { + description: 'the list of vegetables that are mentioned in the provided article', + type: 'array', + items: { + type: 'string', + }, + }, + }, +} as const; -- Errors are caught and serialized as events sent over the stream (after an error, the stream ends). -- The response stream outputs data as [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events) -- The client that reads the stream, parses the event source as an Observable, and if it encounters a serialized error, it deserializes it and throws an error in the Observable. +const response = inferenceClient.outputApi({ + id: 'extract_from_article', + connectorId: 'some-gen-ai-connector', + schema: mySchema, + system: + 'You are a helpful assistant and your current task is to extract informations from the provided document', + input: ` + Please find all the animals and vegetables that are mentioned in the following document: + + ## Document + + ${theDoc} + `, +}); + +// output is properly typed from the provided schema +const { animals, vegetables } = response.output; +``` ### Errors -All known errors are instances, and not extensions, from the `InferenceTaskError` base class, which has a `code`, a `message`, and `meta` information about the error. This allows us to serialize and deserialize errors over the wire without a complicated factory pattern. +All known errors are instances, and not extensions, of the `InferenceTaskError` base class, which has a `code`, a `message`, and `meta` information about the error. +This allows us to serialize and deserialize errors over the wire without a complicated factory pattern. -### Tools +Type guards for each type of error are exposed from the `@kbn/inference-common` package, such as: -Tools are defined as a record, with a `description` and optionally a `schema`. The reason why it's a record is because of type-safety. This allows us to have fully typed tool calls (e.g. when the name of the tool being called is `x`, its arguments are typed as the schema of `x`). +- `isInferenceError` +- `isInferenceInternalError` +- `isInferenceRequestError` +- ...`isXXXError` diff --git a/x-pack/plugins/inference/common/chat_complete/index.ts b/x-pack/plugins/inference/common/chat_complete/index.ts deleted file mode 100644 index aef9de12ba7a9..0000000000000 --- a/x-pack/plugins/inference/common/chat_complete/index.ts +++ /dev/null @@ -1,99 +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 { Observable } from 'rxjs'; -import type { InferenceTaskEventBase } from '../inference_task'; -import type { ToolCall, ToolCallsOf, ToolOptions } from './tools'; - -export enum MessageRole { - User = 'user', - Assistant = 'assistant', - Tool = 'tool', -} - -interface MessageBase { - role: TRole; -} - -export type UserMessage = MessageBase & { content: string }; - -export type AssistantMessage = MessageBase & { - content: string | null; - toolCalls?: Array | undefined>>; -}; - -export type ToolMessage | unknown> = - MessageBase & { - toolCallId: string; - response: TToolResponse; - }; - -export type Message = UserMessage | AssistantMessage | ToolMessage; - -export type ChatCompletionMessageEvent = - InferenceTaskEventBase & { - content: string; - } & { toolCalls: ToolCallsOf['toolCalls'] }; - -export type ChatCompletionResponse = Observable< - ChatCompletionEvent ->; - -export enum ChatCompletionEventType { - ChatCompletionChunk = 'chatCompletionChunk', - ChatCompletionTokenCount = 'chatCompletionTokenCount', - ChatCompletionMessage = 'chatCompletionMessage', -} - -export interface ChatCompletionChunkToolCall { - index: number; - toolCallId: string; - function: { - name: string; - arguments: string; - }; -} - -export type ChatCompletionChunkEvent = - InferenceTaskEventBase & { - content: string; - tool_calls: ChatCompletionChunkToolCall[]; - }; - -export type ChatCompletionTokenCountEvent = - InferenceTaskEventBase & { - tokens: { - prompt: number; - completion: number; - total: number; - }; - }; - -export type ChatCompletionEvent = - | ChatCompletionChunkEvent - | ChatCompletionTokenCountEvent - | ChatCompletionMessageEvent; - -export type FunctionCallingMode = 'native' | 'simulated'; - -/** - * Request a completion from the LLM based on a prompt or conversation. - * - * @param {string} options.connectorId The ID of the connector to use - * @param {string} [options.system] A system message that defines the behavior of the LLM. - * @param {Message[]} options.message A list of messages that make up the conversation to be completed. - * @param {ToolChoice} [options.toolChoice] Force the LLM to call a (specific) tool, or no tool - * @param {Record} [options.tools] A map of tools that can be called by the LLM - */ -export type ChatCompleteAPI = ( - options: { - connectorId: string; - system?: string; - messages: Message[]; - functionCalling?: FunctionCallingMode; - } & TToolOptions -) => ChatCompletionResponse; diff --git a/x-pack/plugins/inference/common/chat_complete/is_chat_completion_chunk_event.ts b/x-pack/plugins/inference/common/chat_complete/is_chat_completion_chunk_event.ts deleted file mode 100644 index 1630d765ab81e..0000000000000 --- a/x-pack/plugins/inference/common/chat_complete/is_chat_completion_chunk_event.ts +++ /dev/null @@ -1,14 +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 { ChatCompletionChunkEvent, ChatCompletionEvent, ChatCompletionEventType } from '.'; - -export function isChatCompletionChunkEvent( - event: ChatCompletionEvent -): event is ChatCompletionChunkEvent { - return event.type === ChatCompletionEventType.ChatCompletionChunk; -} diff --git a/x-pack/plugins/inference/common/chat_complete/is_chat_completion_event.ts b/x-pack/plugins/inference/common/chat_complete/is_chat_completion_event.ts deleted file mode 100644 index d4d9305cac94b..0000000000000 --- a/x-pack/plugins/inference/common/chat_complete/is_chat_completion_event.ts +++ /dev/null @@ -1,17 +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 { ChatCompletionEvent, ChatCompletionEventType } from '.'; -import { InferenceTaskEvent } from '../inference_task'; - -export function isChatCompletionEvent(event: InferenceTaskEvent): event is ChatCompletionEvent { - return ( - event.type === ChatCompletionEventType.ChatCompletionChunk || - event.type === ChatCompletionEventType.ChatCompletionMessage || - event.type === ChatCompletionEventType.ChatCompletionTokenCount - ); -} diff --git a/x-pack/plugins/inference/common/chat_complete/is_chat_completion_message_event.ts b/x-pack/plugins/inference/common/chat_complete/is_chat_completion_message_event.ts deleted file mode 100644 index 172e55df9e4b4..0000000000000 --- a/x-pack/plugins/inference/common/chat_complete/is_chat_completion_message_event.ts +++ /dev/null @@ -1,15 +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 { ChatCompletionEvent, ChatCompletionEventType, ChatCompletionMessageEvent } from '.'; -import type { ToolOptions } from './tools'; - -export function isChatCompletionMessageEvent>( - event: ChatCompletionEvent -): event is ChatCompletionMessageEvent { - return event.type === ChatCompletionEventType.ChatCompletionMessage; -} diff --git a/x-pack/plugins/inference/common/chat_complete/without_chunk_events.ts b/x-pack/plugins/inference/common/chat_complete/without_chunk_events.ts deleted file mode 100644 index 58e72e2c90903..0000000000000 --- a/x-pack/plugins/inference/common/chat_complete/without_chunk_events.ts +++ /dev/null @@ -1,19 +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 { filter, OperatorFunction } from 'rxjs'; -import { ChatCompletionChunkEvent, ChatCompletionEvent, ChatCompletionEventType } from '.'; - -export function withoutChunkEvents(): OperatorFunction< - T, - Exclude -> { - return filter( - (event): event is Exclude => - event.type !== ChatCompletionEventType.ChatCompletionChunk - ); -} diff --git a/x-pack/plugins/inference/common/chat_complete/without_token_count_events.ts b/x-pack/plugins/inference/common/chat_complete/without_token_count_events.ts deleted file mode 100644 index 1b7dbdb9c1372..0000000000000 --- a/x-pack/plugins/inference/common/chat_complete/without_token_count_events.ts +++ /dev/null @@ -1,19 +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 { filter, OperatorFunction } from 'rxjs'; -import { ChatCompletionEvent, ChatCompletionEventType, ChatCompletionTokenCountEvent } from '.'; - -export function withoutTokenCountEvents(): OperatorFunction< - T, - Exclude -> { - return filter( - (event): event is Exclude => - event.type !== ChatCompletionEventType.ChatCompletionTokenCount - ); -} diff --git a/x-pack/plugins/inference/common/connectors.ts b/x-pack/plugins/inference/common/connectors.ts index f7ad616741d79..ee628f520feff 100644 --- a/x-pack/plugins/inference/common/connectors.ts +++ b/x-pack/plugins/inference/common/connectors.ts @@ -22,7 +22,3 @@ export interface InferenceConnector { export function isSupportedConnectorType(id: string): id is InferenceConnectorType { return allSupportedConnectorTypes.includes(id as InferenceConnectorType); } - -export interface GetConnectorsResponseBody { - connectors: InferenceConnector[]; -} diff --git a/x-pack/plugins/inference/common/create_output_api.test.ts b/x-pack/plugins/inference/common/create_output_api.test.ts new file mode 100644 index 0000000000000..b5d380fa9aac6 --- /dev/null +++ b/x-pack/plugins/inference/common/create_output_api.test.ts @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { firstValueFrom, isObservable, of, toArray } from 'rxjs'; +import { + ChatCompleteResponse, + ChatCompletionEvent, + ChatCompletionEventType, +} from '@kbn/inference-common'; +import { createOutputApi } from './create_output_api'; + +describe('createOutputApi', () => { + let chatComplete: jest.Mock; + + beforeEach(() => { + chatComplete = jest.fn(); + }); + + it('calls `chatComplete` with the right parameters', async () => { + chatComplete.mockResolvedValue(Promise.resolve({ content: 'content', toolCalls: [] })); + + const output = createOutputApi(chatComplete); + + await output({ + id: 'id', + stream: false, + functionCalling: 'native', + connectorId: '.my-connector', + system: 'system', + input: 'input message', + }); + + expect(chatComplete).toHaveBeenCalledTimes(1); + expect(chatComplete).toHaveBeenCalledWith({ + connectorId: '.my-connector', + functionCalling: 'native', + stream: false, + system: 'system', + messages: [ + { + content: 'input message', + role: 'user', + }, + ], + }); + }); + + it('returns the expected value when stream=false', async () => { + const chatCompleteResponse: ChatCompleteResponse = { + content: 'content', + toolCalls: [{ toolCallId: 'a', function: { name: 'foo', arguments: { arg: 1 } } }], + }; + + chatComplete.mockResolvedValue(Promise.resolve(chatCompleteResponse)); + + const output = createOutputApi(chatComplete); + + const response = await output({ + id: 'my-id', + stream: false, + connectorId: '.my-connector', + input: 'input message', + }); + + expect(response).toEqual({ + id: 'my-id', + content: chatCompleteResponse.content, + output: chatCompleteResponse.toolCalls[0].function.arguments, + }); + }); + + it('returns the expected value when stream=true', async () => { + const sourceEvents: ChatCompletionEvent[] = [ + { type: ChatCompletionEventType.ChatCompletionChunk, content: 'chunk-1', tool_calls: [] }, + { type: ChatCompletionEventType.ChatCompletionChunk, content: 'chunk-2', tool_calls: [] }, + { + type: ChatCompletionEventType.ChatCompletionMessage, + content: 'message', + toolCalls: [{ toolCallId: 'a', function: { name: 'foo', arguments: { arg: 1 } } }], + }, + ]; + + chatComplete.mockReturnValue(of(...sourceEvents)); + + const output = createOutputApi(chatComplete); + + const response$ = await output({ + id: 'my-id', + stream: true, + connectorId: '.my-connector', + input: 'input message', + }); + + expect(isObservable(response$)).toEqual(true); + const events = await firstValueFrom(response$.pipe(toArray())); + + expect(events).toEqual([ + { + content: 'chunk-1', + id: 'my-id', + type: 'output', + }, + { + content: 'chunk-2', + id: 'my-id', + type: 'output', + }, + { + content: 'message', + id: 'my-id', + output: { + arg: 1, + }, + type: 'complete', + }, + ]); + }); +}); diff --git a/x-pack/plugins/inference/common/create_output_api.ts b/x-pack/plugins/inference/common/create_output_api.ts new file mode 100644 index 0000000000000..e5dd2eeda2cbd --- /dev/null +++ b/x-pack/plugins/inference/common/create_output_api.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + ChatCompleteAPI, + ChatCompletionEventType, + MessageRole, + OutputAPI, + OutputEventType, + OutputOptions, + ToolSchema, + withoutTokenCountEvents, +} from '@kbn/inference-common'; +import { isObservable, map } from 'rxjs'; +import { ensureMultiTurn } from './utils/ensure_multi_turn'; + +export function createOutputApi(chatCompleteApi: ChatCompleteAPI): OutputAPI; +export function createOutputApi(chatCompleteApi: ChatCompleteAPI) { + return ({ + id, + connectorId, + input, + schema, + system, + previousMessages, + functionCalling, + stream, + }: OutputOptions) => { + const response = chatCompleteApi({ + connectorId, + stream, + functionCalling, + system, + messages: ensureMultiTurn([ + ...(previousMessages || []), + { + role: MessageRole.User, + content: input, + }, + ]), + ...(schema + ? { + tools: { + structuredOutput: { + description: `Use the following schema to respond to the user's request in structured data, so it can be parsed and handled.`, + schema, + }, + }, + toolChoice: { function: 'structuredOutput' as const }, + } + : {}), + }); + + if (isObservable(response)) { + return response.pipe( + withoutTokenCountEvents(), + map((event) => { + if (event.type === ChatCompletionEventType.ChatCompletionChunk) { + return { + type: OutputEventType.OutputUpdate, + id, + content: event.content, + }; + } + + return { + id, + output: + event.toolCalls.length && 'arguments' in event.toolCalls[0].function + ? event.toolCalls[0].function.arguments + : undefined, + content: event.content, + type: OutputEventType.OutputComplete, + }; + }) + ); + } else { + return response.then((chatResponse) => { + return { + id, + content: chatResponse.content, + output: + chatResponse.toolCalls.length && 'arguments' in chatResponse.toolCalls[0].function + ? chatResponse.toolCalls[0].function.arguments + : undefined, + }; + }); + } + }; +} diff --git a/x-pack/plugins/inference/common/chat_complete/request.ts b/x-pack/plugins/inference/common/http_apis.ts similarity index 66% rename from x-pack/plugins/inference/common/chat_complete/request.ts rename to x-pack/plugins/inference/common/http_apis.ts index 1038e481a6260..c07fcd29b2211 100644 --- a/x-pack/plugins/inference/common/chat_complete/request.ts +++ b/x-pack/plugins/inference/common/http_apis.ts @@ -5,8 +5,8 @@ * 2.0. */ -import type { Message, FunctionCallingMode } from '.'; -import type { ToolOptions } from './tools'; +import type { FunctionCallingMode, Message, ToolOptions } from '@kbn/inference-common'; +import { InferenceConnector } from './connectors'; export type ChatCompleteRequestBody = { connectorId: string; @@ -15,3 +15,7 @@ export type ChatCompleteRequestBody = { messages: Message[]; functionCalling?: FunctionCallingMode; } & ToolOptions; + +export interface GetConnectorsResponseBody { + connectors: InferenceConnector[]; +} diff --git a/x-pack/plugins/inference/common/index.ts b/x-pack/plugins/inference/common/index.ts index 58c84a47c1804..19b24d53a389a 100644 --- a/x-pack/plugins/inference/common/index.ts +++ b/x-pack/plugins/inference/common/index.ts @@ -10,22 +10,8 @@ export { splitIntoCommands, } from './tasks/nl_to_esql/correct_common_esql_mistakes'; -export { isChatCompletionChunkEvent } from './chat_complete/is_chat_completion_chunk_event'; -export { isChatCompletionMessageEvent } from './chat_complete/is_chat_completion_message_event'; -export { isChatCompletionEvent } from './chat_complete/is_chat_completion_event'; +export { generateFakeToolCallId } from './utils/generate_fake_tool_call_id'; -export { isOutputUpdateEvent } from './output/is_output_update_event'; -export { isOutputCompleteEvent } from './output/is_output_complete_event'; -export { isOutputEvent } from './output/is_output_event'; +export { createOutputApi } from './create_output_api'; -export type { ToolSchema } from './chat_complete/tool_schema'; - -export { - type Message, - MessageRole, - type ToolMessage, - type AssistantMessage, - type UserMessage, -} from './chat_complete'; - -export { generateFakeToolCallId } from './chat_complete/generate_fake_tool_call_id'; +export type { ChatCompleteRequestBody, GetConnectorsResponseBody } from './http_apis'; diff --git a/x-pack/plugins/inference/common/output/create_output_api.ts b/x-pack/plugins/inference/common/output/create_output_api.ts deleted file mode 100644 index 848135beefb0f..0000000000000 --- a/x-pack/plugins/inference/common/output/create_output_api.ts +++ /dev/null @@ -1,61 +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 { map } from 'rxjs'; -import { ChatCompleteAPI, ChatCompletionEventType, MessageRole } from '../chat_complete'; -import { withoutTokenCountEvents } from '../chat_complete/without_token_count_events'; -import { OutputAPI, OutputEvent, OutputEventType } from '.'; -import { ensureMultiTurn } from '../ensure_multi_turn'; - -export function createOutputApi(chatCompleteApi: ChatCompleteAPI): OutputAPI { - return (id, { connectorId, input, schema, system, previousMessages, functionCalling }) => { - return chatCompleteApi({ - connectorId, - system, - functionCalling, - messages: ensureMultiTurn([ - ...(previousMessages || []), - { - role: MessageRole.User, - content: input, - }, - ]), - ...(schema - ? { - tools: { - structuredOutput: { - description: `Use the following schema to respond to the user's request in structured data, so it can be parsed and handled.`, - schema, - }, - }, - toolChoice: { function: 'structuredOutput' as const }, - } - : {}), - }).pipe( - withoutTokenCountEvents(), - map((event): OutputEvent => { - if (event.type === ChatCompletionEventType.ChatCompletionChunk) { - return { - type: OutputEventType.OutputUpdate, - id, - content: event.content, - }; - } - - return { - id, - output: - event.toolCalls.length && 'arguments' in event.toolCalls[0].function - ? event.toolCalls[0].function.arguments - : undefined, - content: event.content, - type: OutputEventType.OutputComplete, - }; - }) - ); - }; -} diff --git a/x-pack/plugins/inference/common/output/index.ts b/x-pack/plugins/inference/common/output/index.ts deleted file mode 100644 index 0f7655f8f1cd4..0000000000000 --- a/x-pack/plugins/inference/common/output/index.ts +++ /dev/null @@ -1,81 +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 { Observable } from 'rxjs'; -import { ServerSentEventBase } from '@kbn/sse-utils'; -import { FromToolSchema, ToolSchema } from '../chat_complete/tool_schema'; -import type { Message, FunctionCallingMode } from '../chat_complete'; - -export enum OutputEventType { - OutputUpdate = 'output', - OutputComplete = 'complete', -} - -type Output = Record | undefined | unknown; - -export type OutputUpdateEvent = ServerSentEventBase< - OutputEventType.OutputUpdate, - { - id: TId; - content: string; - } ->; - -export type OutputCompleteEvent< - TId extends string = string, - TOutput extends Output = Output -> = ServerSentEventBase< - OutputEventType.OutputComplete, - { - id: TId; - output: TOutput; - content: string; - } ->; - -export type OutputEvent = - | OutputUpdateEvent - | OutputCompleteEvent; - -/** - * Generate a response with the LLM for a prompt, optionally based on a schema. - * - * @param {string} id The id of the operation - * @param {string} options.connectorId The ID of the connector that is to be used. - * @param {string} options.input The prompt for the LLM. - * @param {string} options.messages Previous messages in a conversation. - * @param {ToolSchema} [options.schema] The schema the response from the LLM should adhere to. - */ -export type OutputAPI = < - TId extends string = string, - TOutputSchema extends ToolSchema | undefined = ToolSchema | undefined ->( - id: TId, - options: { - connectorId: string; - system?: string; - input: string; - schema?: TOutputSchema; - previousMessages?: Message[]; - functionCalling?: FunctionCallingMode; - } -) => Observable< - OutputEvent : undefined> ->; - -export function createOutputCompleteEvent( - id: TId, - output: TOutput, - content?: string -): OutputCompleteEvent { - return { - type: OutputEventType.OutputComplete, - id, - output, - content: content ?? '', - }; -} diff --git a/x-pack/plugins/inference/common/output/is_output_complete_event.ts b/x-pack/plugins/inference/common/output/is_output_complete_event.ts deleted file mode 100644 index bac3443b8258c..0000000000000 --- a/x-pack/plugins/inference/common/output/is_output_complete_event.ts +++ /dev/null @@ -1,14 +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 { OutputEvent, OutputEventType, OutputUpdateEvent } from '.'; - -export function isOutputCompleteEvent( - event: TOutputEvent -): event is Exclude { - return event.type === OutputEventType.OutputComplete; -} diff --git a/x-pack/plugins/inference/common/output/is_output_event.ts b/x-pack/plugins/inference/common/output/is_output_event.ts deleted file mode 100644 index dad2b0967a6ac..0000000000000 --- a/x-pack/plugins/inference/common/output/is_output_event.ts +++ /dev/null @@ -1,15 +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 { OutputEvent, OutputEventType } from '.'; -import type { InferenceTaskEvent } from '../inference_task'; - -export function isOutputEvent(event: InferenceTaskEvent): event is OutputEvent { - return ( - event.type === OutputEventType.OutputComplete || event.type === OutputEventType.OutputUpdate - ); -} diff --git a/x-pack/plugins/inference/common/output/without_output_update_events.ts b/x-pack/plugins/inference/common/output/without_output_update_events.ts deleted file mode 100644 index 38f26c8c8ece1..0000000000000 --- a/x-pack/plugins/inference/common/output/without_output_update_events.ts +++ /dev/null @@ -1,18 +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 { filter, OperatorFunction } from 'rxjs'; -import { OutputEvent, OutputEventType, OutputUpdateEvent } from '.'; - -export function withoutOutputUpdateEvents(): OperatorFunction< - T, - Exclude -> { - return filter( - (event): event is Exclude => event.type !== OutputEventType.OutputUpdate - ); -} diff --git a/x-pack/plugins/inference/common/tasks/nl_to_esql/correct_query_with_actions.ts b/x-pack/plugins/inference/common/tasks/nl_to_esql/correct_query_with_actions.ts index 15b050c3a3897..30e2c11adb6de 100644 --- a/x-pack/plugins/inference/common/tasks/nl_to_esql/correct_query_with_actions.ts +++ b/x-pack/plugins/inference/common/tasks/nl_to_esql/correct_query_with_actions.ts @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import { validateQuery, getActions } from '@kbn/esql-validation-autocomplete'; import { getAstAndSyntaxErrors } from '@kbn/esql-ast'; diff --git a/x-pack/plugins/inference/common/ensure_multi_turn.ts b/x-pack/plugins/inference/common/utils/ensure_multi_turn.ts similarity index 92% rename from x-pack/plugins/inference/common/ensure_multi_turn.ts rename to x-pack/plugins/inference/common/utils/ensure_multi_turn.ts index 8d222564f3e72..476ecec108e94 100644 --- a/x-pack/plugins/inference/common/ensure_multi_turn.ts +++ b/x-pack/plugins/inference/common/utils/ensure_multi_turn.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { Message, MessageRole } from './chat_complete'; +import { Message, MessageRole } from '@kbn/inference-common'; function isUserMessage(message: Message): boolean { return message.role !== MessageRole.Assistant; diff --git a/x-pack/plugins/inference/common/chat_complete/generate_fake_tool_call_id.ts b/x-pack/plugins/inference/common/utils/generate_fake_tool_call_id.ts similarity index 100% rename from x-pack/plugins/inference/common/chat_complete/generate_fake_tool_call_id.ts rename to x-pack/plugins/inference/common/utils/generate_fake_tool_call_id.ts diff --git a/x-pack/plugins/inference/common/util/truncate_list.ts b/x-pack/plugins/inference/common/utils/truncate_list.ts similarity index 100% rename from x-pack/plugins/inference/common/util/truncate_list.ts rename to x-pack/plugins/inference/common/utils/truncate_list.ts diff --git a/x-pack/plugins/inference/kibana.jsonc b/x-pack/plugins/inference/kibana.jsonc index 6e4e389bdc5ff..f184a848cf982 100644 --- a/x-pack/plugins/inference/kibana.jsonc +++ b/x-pack/plugins/inference/kibana.jsonc @@ -2,6 +2,8 @@ "type": "plugin", "id": "@kbn/inference-plugin", "owner": "@elastic/appex-ai-infra", + "group": "platform", + "visibility": "shared", "plugin": { "id": "inference", "server": true, diff --git a/x-pack/plugins/inference/public/chat_complete.test.ts b/x-pack/plugins/inference/public/chat_complete.test.ts new file mode 100644 index 0000000000000..b297db1f2fb2c --- /dev/null +++ b/x-pack/plugins/inference/public/chat_complete.test.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { omit } from 'lodash'; +import { httpServiceMock } from '@kbn/core/public/mocks'; +import { ChatCompleteAPI, MessageRole, ChatCompleteOptions } from '@kbn/inference-common'; +import { createChatCompleteApi } from './chat_complete'; + +describe('createChatCompleteApi', () => { + let http: ReturnType; + let chatComplete: ChatCompleteAPI; + + beforeEach(() => { + http = httpServiceMock.createStartContract(); + chatComplete = createChatCompleteApi({ http }); + }); + + it('calls http.post with the right parameters when stream is not true', async () => { + const params = { + connectorId: 'my-connector', + functionCalling: 'native', + system: 'system', + messages: [{ role: MessageRole.User, content: 'question' }], + }; + await chatComplete(params as ChatCompleteOptions); + + expect(http.post).toHaveBeenCalledTimes(1); + expect(http.post).toHaveBeenCalledWith('/internal/inference/chat_complete', { + body: expect.any(String), + }); + const callBody = http.post.mock.lastCall!; + + expect(JSON.parse((callBody as any[])[1].body as string)).toEqual(params); + }); + + it('calls http.post with the right parameters when stream is true', async () => { + http.post.mockResolvedValue({}); + + const params = { + connectorId: 'my-connector', + functionCalling: 'native', + stream: true, + system: 'system', + messages: [{ role: MessageRole.User, content: 'question' }], + }; + + await chatComplete(params as ChatCompleteOptions); + + expect(http.post).toHaveBeenCalledTimes(1); + expect(http.post).toHaveBeenCalledWith('/internal/inference/chat_complete/stream', { + asResponse: true, + rawResponse: true, + body: expect.any(String), + }); + const callBody = http.post.mock.lastCall!; + + expect(JSON.parse((callBody as any[])[1].body as string)).toEqual(omit(params, 'stream')); + }); +}); diff --git a/x-pack/plugins/inference/public/chat_complete.ts b/x-pack/plugins/inference/public/chat_complete.ts new file mode 100644 index 0000000000000..64cb5533f20be --- /dev/null +++ b/x-pack/plugins/inference/public/chat_complete.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { HttpStart } from '@kbn/core/public'; +import { + ChatCompleteAPI, + ChatCompleteCompositeResponse, + ChatCompleteOptions, + ToolOptions, +} from '@kbn/inference-common'; +import { from } from 'rxjs'; +import type { ChatCompleteRequestBody } from '../common/http_apis'; +import { httpResponseIntoObservable } from './util/http_response_into_observable'; + +export function createChatCompleteApi({ http }: { http: HttpStart }): ChatCompleteAPI; +export function createChatCompleteApi({ http }: { http: HttpStart }) { + return ({ + connectorId, + messages, + system, + toolChoice, + tools, + functionCalling, + stream, + }: ChatCompleteOptions): ChatCompleteCompositeResponse< + ToolOptions, + boolean + > => { + const body: ChatCompleteRequestBody = { + connectorId, + system, + messages, + toolChoice, + tools, + functionCalling, + }; + + if (stream) { + return from( + http.post('/internal/inference/chat_complete/stream', { + asResponse: true, + rawResponse: true, + body: JSON.stringify(body), + }) + ).pipe(httpResponseIntoObservable()); + } else { + return http.post('/internal/inference/chat_complete', { + body: JSON.stringify(body), + }); + } + }; +} diff --git a/x-pack/plugins/inference/public/chat_complete/index.ts b/x-pack/plugins/inference/public/chat_complete/index.ts deleted file mode 100644 index e229f6c8f8eae..0000000000000 --- a/x-pack/plugins/inference/public/chat_complete/index.ts +++ /dev/null @@ -1,33 +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 { from } from 'rxjs'; -import type { HttpStart } from '@kbn/core/public'; -import type { ChatCompleteAPI } from '../../common/chat_complete'; -import type { ChatCompleteRequestBody } from '../../common/chat_complete/request'; -import { httpResponseIntoObservable } from '../util/http_response_into_observable'; - -export function createChatCompleteApi({ http }: { http: HttpStart }): ChatCompleteAPI { - return ({ connectorId, messages, system, toolChoice, tools, functionCalling }) => { - const body: ChatCompleteRequestBody = { - connectorId, - system, - messages, - toolChoice, - tools, - functionCalling, - }; - - return from( - http.post('/internal/inference/chat_complete', { - asResponse: true, - rawResponse: true, - body: JSON.stringify(body), - }) - ).pipe(httpResponseIntoObservable()); - }; -} diff --git a/x-pack/plugins/inference/public/index.ts b/x-pack/plugins/inference/public/index.ts index 82d36a7abe82d..4928242879b3b 100644 --- a/x-pack/plugins/inference/public/index.ts +++ b/x-pack/plugins/inference/public/index.ts @@ -4,9 +4,8 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type { PluginInitializer, PluginInitializerContext } from '@kbn/core/public'; -import { InferencePlugin } from './plugin'; +import type { PluginInitializer, PluginInitializerContext } from '@kbn/core/public'; import type { InferencePublicSetup, InferencePublicStart, @@ -14,6 +13,7 @@ import type { InferenceStartDependencies, ConfigSchema, } from './types'; +import { InferencePlugin } from './plugin'; export { httpResponseIntoObservable } from './util/http_response_into_observable'; diff --git a/x-pack/plugins/inference/public/plugin.tsx b/x-pack/plugins/inference/public/plugin.tsx index 13ef4a0373845..f1023bc9c2546 100644 --- a/x-pack/plugins/inference/public/plugin.tsx +++ b/x-pack/plugins/inference/public/plugin.tsx @@ -7,8 +7,8 @@ import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public'; import type { Logger } from '@kbn/logging'; -import { createOutputApi } from '../common/output/create_output_api'; -import type { GetConnectorsResponseBody } from '../common/connectors'; +import { createOutputApi } from '../common/create_output_api'; +import type { GetConnectorsResponseBody } from '../common/http_apis'; import { createChatCompleteApi } from './chat_complete'; import type { ConfigSchema, @@ -41,10 +41,11 @@ export class InferencePlugin start(coreStart: CoreStart, pluginsStart: InferenceStartDependencies): InferencePublicStart { const chatComplete = createChatCompleteApi({ http: coreStart.http }); + const output = createOutputApi(chatComplete); return { chatComplete, - output: createOutputApi(chatComplete), + output, getConnectors: async () => { const res = await coreStart.http.get( '/internal/inference/connectors' diff --git a/x-pack/plugins/inference/public/types.ts b/x-pack/plugins/inference/public/types.ts index df80256679ab4..735abfb5459a0 100644 --- a/x-pack/plugins/inference/public/types.ts +++ b/x-pack/plugins/inference/public/types.ts @@ -4,9 +4,9 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type { ChatCompleteAPI } from '../common/chat_complete'; + +import type { ChatCompleteAPI, OutputAPI } from '@kbn/inference-common'; import type { InferenceConnector } from '../common/connectors'; -import type { OutputAPI } from '../common/output'; /* eslint-disable @typescript-eslint/no-empty-interface*/ 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 09e9b9b2d5f5e..862986ce1c73a 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 @@ -4,9 +4,10 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import { createParser } from 'eventsource-parser'; import { Observable, throwError } from 'rxjs'; -import { createInferenceInternalError } from '../../common/errors'; +import { createInferenceInternalError } from '@kbn/inference-common'; export interface StreamedHttpResponse { response?: { body: ReadableStream | null | undefined }; diff --git a/x-pack/plugins/inference/public/util/http_response_into_observable.test.ts b/x-pack/plugins/inference/public/util/http_response_into_observable.test.ts index 2b99b6f1db6f5..a0964da025af8 100644 --- a/x-pack/plugins/inference/public/util/http_response_into_observable.test.ts +++ b/x-pack/plugins/inference/public/util/http_response_into_observable.test.ts @@ -7,10 +7,12 @@ import { lastValueFrom, of, toArray } from 'rxjs'; import { httpResponseIntoObservable } from './http_response_into_observable'; +import { + ChatCompletionEventType, + InferenceTaskEventType, + InferenceTaskErrorCode, +} from '@kbn/inference-common'; import type { StreamedHttpResponse } from './create_observable_from_http_response'; -import { ChatCompletionEventType } from '../../common/chat_complete'; -import { InferenceTaskEventType } from '../../common/inference_task'; -import { InferenceTaskErrorCode } from '../../common/errors'; function toSse(...events: Array>) { return events.map((event) => new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`)); diff --git a/x-pack/plugins/inference/public/util/http_response_into_observable.ts b/x-pack/plugins/inference/public/util/http_response_into_observable.ts index c63a7bcb3cd15..0aab09cdebe0c 100644 --- a/x-pack/plugins/inference/public/util/http_response_into_observable.ts +++ b/x-pack/plugins/inference/public/util/http_response_into_observable.ts @@ -10,8 +10,9 @@ import { createInferenceInternalError, InferenceTaskError, InferenceTaskErrorEvent, -} from '../../common/errors'; -import { InferenceTaskEvent, InferenceTaskEventType } from '../../common/inference_task'; + InferenceTaskEvent, + InferenceTaskEventType, +} from '@kbn/inference-common'; import { createObservableFromHttpResponse, StreamedHttpResponse, diff --git a/x-pack/plugins/inference/scripts/evaluation/evaluation.ts b/x-pack/plugins/inference/scripts/evaluation/evaluation.ts index 0c70bf81eddad..425b7e334074a 100644 --- a/x-pack/plugins/inference/scripts/evaluation/evaluation.ts +++ b/x-pack/plugins/inference/scripts/evaluation/evaluation.ts @@ -89,8 +89,8 @@ function runEvaluations() { const evaluationClient = createInferenceEvaluationClient({ connectorId: connector.connectorId, evaluationConnectorId, - outputApi: (id, parameters) => - chatClient.output(id, { + outputApi: (parameters) => + chatClient.output({ ...parameters, connectorId: evaluationConnectorId, }) as any, diff --git a/x-pack/plugins/inference/scripts/evaluation/evaluation_client.ts b/x-pack/plugins/inference/scripts/evaluation/evaluation_client.ts index acf2fece1d0ff..99eaf02494af7 100644 --- a/x-pack/plugins/inference/scripts/evaluation/evaluation_client.ts +++ b/x-pack/plugins/inference/scripts/evaluation/evaluation_client.ts @@ -6,9 +6,7 @@ */ import { remove } from 'lodash'; -import { lastValueFrom } from 'rxjs'; -import type { OutputAPI } from '../../common/output'; -import { withoutOutputUpdateEvents } from '../../common/output/without_output_update_events'; +import type { OutputAPI } from '@kbn/inference-common'; import type { EvaluationResult } from './types'; export interface InferenceEvaluationClient { @@ -68,11 +66,12 @@ export function createInferenceEvaluationClient({ output: outputApi, getEvaluationConnectorId: () => evaluationConnectorId, evaluate: async ({ input, criteria = [], system }) => { - const evaluation = await lastValueFrom( - outputApi('evaluate', { - connectorId, - system: withAdditionalSystemContext( - `You are a helpful, respected assistant for evaluating task + const evaluation = await outputApi({ + id: 'evaluate', + stream: false, + connectorId, + system: withAdditionalSystemContext( + `You are a helpful, respected assistant for evaluating task inputs and outputs in the Elastic Platform. Your goal is to verify whether the output of a task @@ -85,10 +84,10 @@ export function createInferenceEvaluationClient({ quoting what the assistant did wrong, where it could improve, and what the root cause was in case of a failure. `, - system - ), + system + ), - input: ` + input: ` ## Criteria ${criteria @@ -100,37 +99,36 @@ export function createInferenceEvaluationClient({ ## Input ${input}`, - schema: { - type: 'object', - properties: { - criteria: { - type: 'array', - items: { - type: 'object', - properties: { - index: { - type: 'number', - description: 'The number of the criterion', - }, - score: { - type: 'number', - description: - 'The score you calculated for the criterion, between 0 (criterion fully failed) and 1 (criterion fully succeeded).', - }, - reasoning: { - type: 'string', - description: - 'Your reasoning for the score. Explain your score by mentioning what you expected to happen and what did happen.', - }, + schema: { + type: 'object', + properties: { + criteria: { + type: 'array', + items: { + type: 'object', + properties: { + index: { + type: 'number', + description: 'The number of the criterion', + }, + score: { + type: 'number', + description: + 'The score you calculated for the criterion, between 0 (criterion fully failed) and 1 (criterion fully succeeded).', + }, + reasoning: { + type: 'string', + description: + 'Your reasoning for the score. Explain your score by mentioning what you expected to happen and what did happen.', }, - required: ['index', 'score', 'reasoning'], }, + required: ['index', 'score', 'reasoning'], }, }, - required: ['criteria'], - } as const, - }).pipe(withoutOutputUpdateEvents()) - ); + }, + required: ['criteria'], + } as const, + }); const scoredCriteria = evaluation.output.criteria; diff --git a/x-pack/plugins/inference/scripts/evaluation/scenarios/esql/index.spec.ts b/x-pack/plugins/inference/scripts/evaluation/scenarios/esql/index.spec.ts index 3aeca67030366..49a82db8124e9 100644 --- a/x-pack/plugins/inference/scripts/evaluation/scenarios/esql/index.spec.ts +++ b/x-pack/plugins/inference/scripts/evaluation/scenarios/esql/index.spec.ts @@ -8,11 +8,11 @@ /// import expect from '@kbn/expect'; -import { firstValueFrom, lastValueFrom, filter } from 'rxjs'; +import type { Logger } from '@kbn/logging'; +import { lastValueFrom } from 'rxjs'; import { naturalLanguageToEsql } from '../../../../server/tasks/nl_to_esql'; import { chatClient, evaluationClient, logger } from '../../services'; import { EsqlDocumentBase } from '../../../../server/tasks/nl_to_esql/doc_base'; -import { isOutputCompleteEvent } from '../../../../common'; interface TestCase { title: string; @@ -40,7 +40,7 @@ const callNaturalLanguageToEsql = async (question: string) => { debug: (source) => { logger.debug(typeof source === 'function' ? source() : source); }, - }, + } as Logger, }) ); }; @@ -65,11 +65,10 @@ const retrieveUsedCommands = async ({ answer: string; esqlDescription: string; }) => { - const commandsListOutput = await firstValueFrom( - evaluationClient - .output('retrieve_commands', { - connectorId: evaluationClient.getEvaluationConnectorId(), - system: ` + const commandsListOutput = await evaluationClient.output({ + id: 'retrieve_commands', + connectorId: evaluationClient.getEvaluationConnectorId(), + system: ` You are a helpful, respected Elastic ES|QL assistant. Your role is to enumerate the list of ES|QL commands and functions that were used @@ -81,34 +80,32 @@ const retrieveUsedCommands = async ({ ${esqlDescription} `, - input: ` + input: ` # Question ${question} # Answer ${answer} `, - schema: { - type: 'object', - properties: { - commands: { - description: - 'The list of commands that were used in the provided ES|QL question and answer', - type: 'array', - items: { type: 'string' }, - }, - functions: { - description: - 'The list of functions that were used in the provided ES|QL question and answer', - type: 'array', - items: { type: 'string' }, - }, - }, - required: ['commands', 'functions'], - } as const, - }) - .pipe(filter(isOutputCompleteEvent)) - ); + schema: { + type: 'object', + properties: { + commands: { + description: + 'The list of commands that were used in the provided ES|QL question and answer', + type: 'array', + items: { type: 'string' }, + }, + functions: { + description: + 'The list of functions that were used in the provided ES|QL question and answer', + type: 'array', + items: { type: 'string' }, + }, + }, + required: ['commands', 'functions'], + } as const, + }); const output = commandsListOutput.output; diff --git a/x-pack/plugins/inference/scripts/load_esql_docs/utils/output_executor.ts b/x-pack/plugins/inference/scripts/load_esql_docs/utils/output_executor.ts index 6697446f93cec..f4014db0e6e8d 100644 --- a/x-pack/plugins/inference/scripts/load_esql_docs/utils/output_executor.ts +++ b/x-pack/plugins/inference/scripts/load_esql_docs/utils/output_executor.ts @@ -5,8 +5,7 @@ * 2.0. */ -import { lastValueFrom } from 'rxjs'; -import type { OutputAPI } from '../../../common/output'; +import type { OutputAPI } from '@kbn/inference-common'; export interface Prompt { system?: string; @@ -27,13 +26,13 @@ export type PromptCallerFactory = ({ export const bindOutput: PromptCallerFactory = ({ connectorId, output }) => { return async ({ input, system }) => { - const response = await lastValueFrom( - output('', { - connectorId, - input, - system, - }) - ); + const response = await output({ + id: 'output', + connectorId, + input, + system, + }); + return response.content ?? ''; }; }; diff --git a/x-pack/plugins/inference/scripts/util/cli_options.ts b/x-pack/plugins/inference/scripts/util/cli_options.ts index 13bac131922ff..8bbb6dabe406e 100644 --- a/x-pack/plugins/inference/scripts/util/cli_options.ts +++ b/x-pack/plugins/inference/scripts/util/cli_options.ts @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import { format, parse } from 'url'; import { readKibanaConfig } from './read_kibana_config'; diff --git a/x-pack/plugins/inference/scripts/util/kibana_client.ts b/x-pack/plugins/inference/scripts/util/kibana_client.ts index ca26ef76b2c72..ad6c21cf4b248 100644 --- a/x-pack/plugins/inference/scripts/util/kibana_client.ts +++ b/x-pack/plugins/inference/scripts/util/kibana_client.ts @@ -13,18 +13,22 @@ import { from, map, switchMap, throwError } from 'rxjs'; import { UrlObject, format, parse } from 'url'; import { inspect } from 'util'; import { isReadable } from 'stream'; -import type { ChatCompleteAPI, ChatCompletionEvent } from '../../common/chat_complete'; -import { ChatCompleteRequestBody } from '../../common/chat_complete/request'; -import type { InferenceConnector } from '../../common/connectors'; import { + ChatCompleteAPI, + ChatCompleteCompositeResponse, + OutputAPI, + ChatCompletionEvent, InferenceTaskError, InferenceTaskErrorEvent, + InferenceTaskEventType, createInferenceInternalError, -} from '../../common/errors'; -import { InferenceTaskEventType } from '../../common/inference_task'; -import type { OutputAPI } from '../../common/output'; -import { createOutputApi } from '../../common/output/create_output_api'; -import { withoutOutputUpdateEvents } from '../../common/output/without_output_update_events'; + withoutOutputUpdateEvents, + type ToolOptions, + ChatCompleteOptions, +} from '@kbn/inference-common'; +import type { ChatCompleteRequestBody } from '../../common/http_apis'; +import type { InferenceConnector } from '../../common/connectors'; +import { createOutputApi } from '../../common/create_output_api'; import { eventSourceStreamIntoObservable } from '../../server/util/event_source_stream_into_observable'; // eslint-disable-next-line spaced-comment @@ -153,7 +157,7 @@ export class KibanaClient { } createInferenceClient({ connectorId }: { connectorId: string }): ScriptInferenceClient { - function stream(responsePromise: Promise) { + function streamResponse(responsePromise: Promise) { return from(responsePromise).pipe( switchMap((response) => { if (isReadable(response.data)) { @@ -173,14 +177,18 @@ export class KibanaClient { ); } - const chatCompleteApi: ChatCompleteAPI = ({ + const chatCompleteApi: ChatCompleteAPI = < + TToolOptions extends ToolOptions = ToolOptions, + TStream extends boolean = false + >({ connectorId: chatCompleteConnectorId, messages, system, toolChoice, tools, functionCalling, - }) => { + stream, + }: ChatCompleteOptions) => { const body: ChatCompleteRequestBody = { connectorId: chatCompleteConnectorId, system, @@ -190,15 +198,29 @@ export class KibanaClient { functionCalling, }; - return stream( - this.axios.post( - this.getUrl({ - pathname: `/internal/inference/chat_complete`, - }), - body, - { responseType: 'stream', timeout: NaN } - ) - ); + if (stream) { + return streamResponse( + this.axios.post( + this.getUrl({ + pathname: `/internal/inference/chat_complete/stream`, + }), + body, + { responseType: 'stream', timeout: NaN } + ) + ) as ChatCompleteCompositeResponse; + } else { + return this.axios + .post( + this.getUrl({ + pathname: `/internal/inference/chat_complete/stream`, + }), + body, + { responseType: 'stream', timeout: NaN } + ) + .then((response) => { + return response.data; + }) as ChatCompleteCompositeResponse; + } }; const outputApi: OutputAPI = createOutputApi(chatCompleteApi); @@ -210,8 +232,13 @@ export class KibanaClient { ...options, }); }, - output: (id, options) => { - return outputApi(id, { ...options }).pipe(withoutOutputUpdateEvents()); + output: (options) => { + const response = outputApi({ ...options }); + if (options.stream) { + return (response as any).pipe(withoutOutputUpdateEvents()); + } else { + return response; + } }, }; } diff --git a/x-pack/plugins/inference/server/chat_complete/adapters/bedrock/bedrock_claude_adapter.test.ts b/x-pack/plugins/inference/server/chat_complete/adapters/bedrock/bedrock_claude_adapter.test.ts index d34b8693cb85f..ca6f60dd45a55 100644 --- a/x-pack/plugins/inference/server/chat_complete/adapters/bedrock/bedrock_claude_adapter.test.ts +++ b/x-pack/plugins/inference/server/chat_complete/adapters/bedrock/bedrock_claude_adapter.test.ts @@ -8,8 +8,7 @@ import { PassThrough } from 'stream'; import { loggerMock } from '@kbn/logging-mocks'; import type { InferenceExecutor } from '../../utils/inference_executor'; -import { MessageRole } from '../../../../common/chat_complete'; -import { ToolChoiceType } from '../../../../common/chat_complete/tools'; +import { MessageRole, ToolChoiceType } from '@kbn/inference-common'; import { bedrockClaudeAdapter } from './bedrock_claude_adapter'; import { addNoToolUsageDirective } from './prompts'; diff --git a/x-pack/plugins/inference/server/chat_complete/adapters/bedrock/bedrock_claude_adapter.ts b/x-pack/plugins/inference/server/chat_complete/adapters/bedrock/bedrock_claude_adapter.ts index a0b48e6fc8631..e73d9c9344c98 100644 --- a/x-pack/plugins/inference/server/chat_complete/adapters/bedrock/bedrock_claude_adapter.ts +++ b/x-pack/plugins/inference/server/chat_complete/adapters/bedrock/bedrock_claude_adapter.ts @@ -7,10 +7,15 @@ import { filter, from, map, switchMap, tap } from 'rxjs'; import { Readable } from 'stream'; +import { + Message, + MessageRole, + createInferenceInternalError, + ToolChoiceType, + ToolSchemaType, + type ToolOptions, +} from '@kbn/inference-common'; import { parseSerdeChunkMessage } from './serde_utils'; -import { Message, MessageRole } from '../../../../common/chat_complete'; -import { createInferenceInternalError } from '../../../../common/errors'; -import { ToolChoiceType, type ToolOptions } from '../../../../common/chat_complete/tools'; import { InferenceConnectorAdapter } from '../../types'; import type { BedRockMessage, BedrockToolChoice } from './types'; import { @@ -19,7 +24,6 @@ import { } from './serde_eventstream_into_observable'; import { processCompletionChunks } from './process_completion_chunks'; import { addNoToolUsageDirective } from './prompts'; -import { ToolSchemaType } from '../../../../common/chat_complete/tool_schema'; export const bedrockClaudeAdapter: InferenceConnectorAdapter = { chatComplete: ({ executor, system, messages, toolChoice, tools }) => { diff --git a/x-pack/plugins/inference/server/chat_complete/adapters/bedrock/process_completion_chunks.ts b/x-pack/plugins/inference/server/chat_complete/adapters/bedrock/process_completion_chunks.ts index 5513cc9028ac9..8a5c9805ddf63 100644 --- a/x-pack/plugins/inference/server/chat_complete/adapters/bedrock/process_completion_chunks.ts +++ b/x-pack/plugins/inference/server/chat_complete/adapters/bedrock/process_completion_chunks.ts @@ -11,7 +11,7 @@ import { ChatCompletionTokenCountEvent, ChatCompletionChunkToolCall, ChatCompletionEventType, -} from '../../../../common/chat_complete'; +} from '@kbn/inference-common'; import type { CompletionChunk, MessageStopChunk } from './types'; export function processCompletionChunks() { diff --git a/x-pack/plugins/inference/server/chat_complete/adapters/bedrock/serde_eventstream_into_observable.ts b/x-pack/plugins/inference/server/chat_complete/adapters/bedrock/serde_eventstream_into_observable.ts index 24a245ab2efcc..5ab264750e5a9 100644 --- a/x-pack/plugins/inference/server/chat_complete/adapters/bedrock/serde_eventstream_into_observable.ts +++ b/x-pack/plugins/inference/server/chat_complete/adapters/bedrock/serde_eventstream_into_observable.ts @@ -11,7 +11,7 @@ import { identity } from 'lodash'; import { Observable } from 'rxjs'; import { Readable } from 'stream'; import { Message } from '@smithy/types'; -import { createInferenceInternalError } from '../../../../common/errors'; +import { createInferenceInternalError } from '@kbn/inference-common'; interface ModelStreamErrorException { name: 'ModelStreamErrorException'; diff --git a/x-pack/plugins/inference/server/chat_complete/adapters/gemini/gemini_adapter.test.ts b/x-pack/plugins/inference/server/chat_complete/adapters/gemini/gemini_adapter.test.ts index a9f4305a3c532..c3410b2af3623 100644 --- a/x-pack/plugins/inference/server/chat_complete/adapters/gemini/gemini_adapter.test.ts +++ b/x-pack/plugins/inference/server/chat_complete/adapters/gemini/gemini_adapter.test.ts @@ -11,8 +11,7 @@ import { noop, tap, lastValueFrom, toArray, Subject } from 'rxjs'; import { loggerMock } from '@kbn/logging-mocks'; import type { InferenceExecutor } from '../../utils/inference_executor'; import { observableIntoEventSourceStream } from '../../../util/observable_into_event_source_stream'; -import { MessageRole } from '../../../../common/chat_complete'; -import { ToolChoiceType } from '../../../../common/chat_complete/tools'; +import { MessageRole, ToolChoiceType } from '@kbn/inference-common'; import { geminiAdapter } from './gemini_adapter'; describe('geminiAdapter', () => { diff --git a/x-pack/plugins/inference/server/chat_complete/adapters/gemini/gemini_adapter.ts b/x-pack/plugins/inference/server/chat_complete/adapters/gemini/gemini_adapter.ts index 2e86adcc82a85..80d0439449066 100644 --- a/x-pack/plugins/inference/server/chat_complete/adapters/gemini/gemini_adapter.ts +++ b/x-pack/plugins/inference/server/chat_complete/adapters/gemini/gemini_adapter.ts @@ -8,10 +8,15 @@ import * as Gemini from '@google/generative-ai'; import { from, map, switchMap } from 'rxjs'; import { Readable } from 'stream'; +import { + Message, + MessageRole, + ToolChoiceType, + ToolOptions, + ToolSchema, + ToolSchemaType, +} from '@kbn/inference-common'; import type { InferenceConnectorAdapter } from '../../types'; -import { Message, MessageRole } from '../../../../common/chat_complete'; -import { ToolChoiceType, ToolOptions } from '../../../../common/chat_complete/tools'; -import type { ToolSchema, ToolSchemaType } from '../../../../common/chat_complete/tool_schema'; import { eventSourceStreamIntoObservable } from '../../../util/event_source_stream_into_observable'; import { processVertexStream } from './process_vertex_stream'; import type { GenerateContentResponseChunk, GeminiMessage, GeminiToolConfig } from './types'; 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 78e0da0a384b8..8613799846e3b 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 @@ -6,7 +6,7 @@ */ import { TestScheduler } from 'rxjs/testing'; -import { ChatCompletionEventType } from '../../../../common/chat_complete'; +import { ChatCompletionEventType } from '@kbn/inference-common'; import { processVertexStream } from './process_vertex_stream'; import type { GenerateContentResponseChunk } from './types'; 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 e2a6c74a0447f..3081317882c65 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 @@ -10,7 +10,7 @@ import { ChatCompletionChunkEvent, ChatCompletionTokenCountEvent, ChatCompletionEventType, -} from '../../../../common/chat_complete'; +} from '@kbn/inference-common'; import { generateFakeToolCallId } from '../../../../common'; import type { GenerateContentResponseChunk } from './types'; diff --git a/x-pack/plugins/inference/server/chat_complete/adapters/openai/openai_adapter.test.ts b/x-pack/plugins/inference/server/chat_complete/adapters/openai/openai_adapter.test.ts index 813e88760de8c..ff1bbc71a876d 100644 --- a/x-pack/plugins/inference/server/chat_complete/adapters/openai/openai_adapter.test.ts +++ b/x-pack/plugins/inference/server/chat_complete/adapters/openai/openai_adapter.test.ts @@ -12,7 +12,7 @@ import { pick } from 'lodash'; import { lastValueFrom, Subject, toArray } from 'rxjs'; import type { Logger } from '@kbn/logging'; import { loggerMock } from '@kbn/logging-mocks'; -import { ChatCompletionEventType, MessageRole } from '../../../../common/chat_complete'; +import { ChatCompletionEventType, MessageRole } from '@kbn/inference-common'; import { observableIntoEventSourceStream } from '../../../util/observable_into_event_source_stream'; import { InferenceExecutor } from '../../utils/inference_executor'; import { openAIAdapter } from '.'; diff --git a/x-pack/plugins/inference/server/chat_complete/adapters/openai/openai_adapter.ts b/x-pack/plugins/inference/server/chat_complete/adapters/openai/openai_adapter.ts index f1821be4d4d57..121ba96ab115a 100644 --- a/x-pack/plugins/inference/server/chat_complete/adapters/openai/openai_adapter.ts +++ b/x-pack/plugins/inference/server/chat_complete/adapters/openai/openai_adapter.ts @@ -20,10 +20,10 @@ import { ChatCompletionEventType, Message, MessageRole, -} from '../../../../common/chat_complete'; -import type { ToolOptions } from '../../../../common/chat_complete/tools'; -import { createTokenLimitReachedError } from '../../../../common/chat_complete/errors'; -import { createInferenceInternalError } from '../../../../common/errors'; + ToolOptions, + createInferenceInternalError, +} from '@kbn/inference-common'; +import { createTokenLimitReachedError } from '../../errors'; import { eventSourceStreamIntoObservable } from '../../../util/event_source_stream_into_observable'; import type { InferenceConnectorAdapter } from '../../types'; import { diff --git a/x-pack/plugins/inference/server/chat_complete/api.ts b/x-pack/plugins/inference/server/chat_complete/api.ts index ca9e61ff3627f..cf325e72ddf3a 100644 --- a/x-pack/plugins/inference/server/chat_complete/api.ts +++ b/x-pack/plugins/inference/server/chat_complete/api.ts @@ -9,31 +9,39 @@ import { last } from 'lodash'; import { defer, switchMap, throwError } from 'rxjs'; import type { Logger } from '@kbn/logging'; import type { KibanaRequest } from '@kbn/core-http-server'; -import type { ChatCompleteAPI, ChatCompletionResponse } from '../../common/chat_complete'; -import { createInferenceRequestError } from '../../common/errors'; +import { + type ChatCompleteAPI, + type ChatCompleteCompositeResponse, + createInferenceRequestError, + type ToolOptions, + ChatCompleteOptions, +} from '@kbn/inference-common'; import type { InferenceStartDependencies } from '../types'; import { getConnectorById } from '../util/get_connector_by_id'; import { getInferenceAdapter } from './adapters'; -import { createInferenceExecutor, chunksIntoMessage } from './utils'; +import { createInferenceExecutor, chunksIntoMessage, streamToResponse } from './utils'; -export function createChatCompleteApi({ - request, - actions, - logger, -}: { +interface CreateChatCompleteApiOptions { request: KibanaRequest; actions: InferenceStartDependencies['actions']; logger: Logger; -}) { - const chatCompleteAPI: ChatCompleteAPI = ({ +} + +export function createChatCompleteApi(options: CreateChatCompleteApiOptions): ChatCompleteAPI; +export function createChatCompleteApi({ request, actions, logger }: CreateChatCompleteApiOptions) { + return ({ connectorId, messages, toolChoice, tools, system, functionCalling, - }): ChatCompletionResponse => { - return defer(async () => { + stream, + }: ChatCompleteOptions): ChatCompleteCompositeResponse< + ToolOptions, + boolean + > => { + const obs$ = defer(async () => { const actionsClient = await actions.getActionsClientWithRequest(request); const connector = await getConnectorById({ connectorId, actionsClient }); const executor = createInferenceExecutor({ actionsClient, connector }); @@ -70,7 +78,11 @@ export function createChatCompleteApi({ logger, }) ); - }; - return chatCompleteAPI; + if (stream) { + return obs$; + } else { + return streamToResponse(obs$); + } + }; } diff --git a/x-pack/plugins/inference/server/chat_complete/errors.ts b/x-pack/plugins/inference/server/chat_complete/errors.ts new file mode 100644 index 0000000000000..a830f57fec559 --- /dev/null +++ b/x-pack/plugins/inference/server/chat_complete/errors.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { InferenceTaskError, type UnvalidatedToolCall } from '@kbn/inference-common'; +import { i18n } from '@kbn/i18n'; +import { + ChatCompletionErrorCode, + ChatCompletionTokenLimitReachedError, + ChatCompletionToolNotFoundError, + ChatCompletionToolValidationError, +} from '@kbn/inference-common/src/chat_complete/errors'; + +export function createTokenLimitReachedError( + tokenLimit?: number, + tokenCount?: number +): ChatCompletionTokenLimitReachedError { + return new InferenceTaskError( + ChatCompletionErrorCode.TokenLimitReachedError, + i18n.translate('xpack.inference.chatCompletionError.tokenLimitReachedError', { + defaultMessage: `Token limit reached. Token limit is {tokenLimit}, but the current conversation has {tokenCount} tokens.`, + values: { tokenLimit, tokenCount }, + }), + { tokenLimit, tokenCount } + ); +} + +export function createToolNotFoundError(name: string): ChatCompletionToolNotFoundError { + return new InferenceTaskError( + ChatCompletionErrorCode.ToolNotFoundError, + `Tool ${name} called but was not available`, + { + name, + } + ); +} + +export function createToolValidationError( + message: string, + meta: { + name?: string; + arguments?: string; + errorsText?: string; + toolCalls?: UnvalidatedToolCall[]; + } +): ChatCompletionToolValidationError { + return new InferenceTaskError(ChatCompletionErrorCode.ToolValidationError, message, meta); +} diff --git a/x-pack/plugins/inference/server/chat_complete/simulated_function_calling/get_system_instructions.ts b/x-pack/plugins/inference/server/chat_complete/simulated_function_calling/get_system_instructions.ts index abfc48dfa2ef2..c4adfae7e3f19 100644 --- a/x-pack/plugins/inference/server/chat_complete/simulated_function_calling/get_system_instructions.ts +++ b/x-pack/plugins/inference/server/chat_complete/simulated_function_calling/get_system_instructions.ts @@ -5,8 +5,8 @@ * 2.0. */ +import { ToolDefinition } from '@kbn/inference-common'; import { TOOL_USE_END, TOOL_USE_START } from './constants'; -import { ToolDefinition } from '../../../common/chat_complete/tools'; export function getSystemMessageInstructions({ tools, diff --git a/x-pack/plugins/inference/server/chat_complete/simulated_function_calling/parse_inline_function_calls.ts b/x-pack/plugins/inference/server/chat_complete/simulated_function_calling/parse_inline_function_calls.ts index 3436d7a7edac5..73d03ee2f00af 100644 --- a/x-pack/plugins/inference/server/chat_complete/simulated_function_calling/parse_inline_function_calls.ts +++ b/x-pack/plugins/inference/server/chat_complete/simulated_function_calling/parse_inline_function_calls.ts @@ -8,11 +8,11 @@ import { Observable } from 'rxjs'; import { Logger } from '@kbn/logging'; import { + createInferenceInternalError, ChatCompletionChunkEvent, ChatCompletionTokenCountEvent, ChatCompletionEventType, -} from '../../../common/chat_complete'; -import { createInferenceInternalError } from '../../../common/errors'; +} from '@kbn/inference-common'; import { TOOL_USE_END, TOOL_USE_START } from './constants'; function matchOnSignalStart(buffer: string) { diff --git a/x-pack/plugins/inference/server/chat_complete/simulated_function_calling/wrap_with_simulated_function_calling.ts b/x-pack/plugins/inference/server/chat_complete/simulated_function_calling/wrap_with_simulated_function_calling.ts index d8cfc373b66cc..4eb6cfd8d50e1 100644 --- a/x-pack/plugins/inference/server/chat_complete/simulated_function_calling/wrap_with_simulated_function_calling.ts +++ b/x-pack/plugins/inference/server/chat_complete/simulated_function_calling/wrap_with_simulated_function_calling.ts @@ -5,9 +5,16 @@ * 2.0. */ -import { AssistantMessage, Message, ToolMessage, UserMessage } from '../../../common'; -import { MessageRole } from '../../../common/chat_complete'; -import { ToolChoice, ToolChoiceType, ToolDefinition } from '../../../common/chat_complete/tools'; +import { + MessageRole, + AssistantMessage, + Message, + ToolMessage, + UserMessage, + ToolChoice, + ToolChoiceType, + ToolDefinition, +} from '@kbn/inference-common'; import { TOOL_USE_END, TOOL_USE_START } from './constants'; import { getSystemMessageInstructions } from './get_system_instructions'; diff --git a/x-pack/plugins/inference/server/chat_complete/types.ts b/x-pack/plugins/inference/server/chat_complete/types.ts index 394fe370240ef..64cc542ff6119 100644 --- a/x-pack/plugins/inference/server/chat_complete/types.ts +++ b/x-pack/plugins/inference/server/chat_complete/types.ts @@ -12,8 +12,8 @@ import type { ChatCompletionTokenCountEvent, FunctionCallingMode, Message, -} from '../../common/chat_complete'; -import type { ToolOptions } from '../../common/chat_complete/tools'; + ToolOptions, +} from '@kbn/inference-common'; import type { InferenceExecutor } from './utils'; /** diff --git a/x-pack/plugins/inference/server/chat_complete/utils/chunks_into_message.test.ts b/x-pack/plugins/inference/server/chat_complete/utils/chunks_into_message.test.ts index 0c5552a0113b8..c6e5b032120a3 100644 --- a/x-pack/plugins/inference/server/chat_complete/utils/chunks_into_message.test.ts +++ b/x-pack/plugins/inference/server/chat_complete/utils/chunks_into_message.test.ts @@ -4,13 +4,14 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import { lastValueFrom, of } from 'rxjs'; import { + ToolChoiceType, ChatCompletionChunkEvent, ChatCompletionEventType, ChatCompletionTokenCountEvent, -} from '../../../common/chat_complete'; -import { ToolChoiceType } from '../../../common/chat_complete/tools'; +} from '@kbn/inference-common'; import { chunksIntoMessage } from './chunks_into_message'; import type { Logger } from '@kbn/logging'; diff --git a/x-pack/plugins/inference/server/chat_complete/utils/chunks_into_message.ts b/x-pack/plugins/inference/server/chat_complete/utils/chunks_into_message.ts index 902289182a37a..fe9b745f442fc 100644 --- a/x-pack/plugins/inference/server/chat_complete/utils/chunks_into_message.ts +++ b/x-pack/plugins/inference/server/chat_complete/utils/chunks_into_message.ts @@ -7,14 +7,15 @@ import { last, map, merge, OperatorFunction, scan, share } from 'rxjs'; import type { Logger } from '@kbn/logging'; -import type { UnvalidatedToolCall, ToolOptions } from '../../../common/chat_complete/tools'; import { + UnvalidatedToolCall, + ToolOptions, ChatCompletionChunkEvent, ChatCompletionEventType, ChatCompletionMessageEvent, ChatCompletionTokenCountEvent, -} from '../../../common/chat_complete'; -import { withoutTokenCountEvents } from '../../../common/chat_complete/without_token_count_events'; + withoutTokenCountEvents, +} from '@kbn/inference-common'; import { validateToolCalls } from '../../util/validate_tool_calls'; export function chunksIntoMessage({ diff --git a/x-pack/plugins/inference/server/chat_complete/utils/index.ts b/x-pack/plugins/inference/server/chat_complete/utils/index.ts index dea2ac65f4755..d3dc2010cba3a 100644 --- a/x-pack/plugins/inference/server/chat_complete/utils/index.ts +++ b/x-pack/plugins/inference/server/chat_complete/utils/index.ts @@ -12,3 +12,4 @@ export { type InferenceExecutor, } from './inference_executor'; export { chunksIntoMessage } from './chunks_into_message'; +export { streamToResponse } from './stream_to_response'; diff --git a/x-pack/plugins/inference/server/chat_complete/utils/stream_to_response.test.ts b/x-pack/plugins/inference/server/chat_complete/utils/stream_to_response.test.ts new file mode 100644 index 0000000000000..939997a5fef15 --- /dev/null +++ b/x-pack/plugins/inference/server/chat_complete/utils/stream_to_response.test.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 { of } from 'rxjs'; +import { ChatCompletionEvent } from '@kbn/inference-common'; +import { chunkEvent, tokensEvent, messageEvent } from '../../test_utils/chat_complete_events'; +import { streamToResponse } from './stream_to_response'; + +describe('streamToResponse', () => { + function fromEvents(...events: ChatCompletionEvent[]) { + return of(...events); + } + + it('returns a response with token count if both message and token events got emitted', async () => { + const response = await streamToResponse( + fromEvents( + chunkEvent('chunk_1'), + chunkEvent('chunk_2'), + tokensEvent({ prompt: 1, completion: 2, total: 3 }), + messageEvent('message') + ) + ); + + expect(response).toEqual({ + content: 'message', + tokens: { + completion: 2, + prompt: 1, + total: 3, + }, + toolCalls: [], + }); + }); + + it('returns a response with tool calls if present', async () => { + const someToolCall = { + toolCallId: '42', + function: { + name: 'my_tool', + arguments: {}, + }, + }; + const response = await streamToResponse( + fromEvents(chunkEvent('chunk_1'), messageEvent('message', [someToolCall])) + ); + + expect(response).toEqual({ + content: 'message', + toolCalls: [someToolCall], + }); + }); + + it('returns a response without token count if only message got emitted', async () => { + const response = await streamToResponse( + fromEvents(chunkEvent('chunk_1'), messageEvent('message')) + ); + + expect(response).toEqual({ + content: 'message', + toolCalls: [], + }); + }); + + it('rejects an error if message event is not emitted', async () => { + await expect( + streamToResponse(fromEvents(chunkEvent('chunk_1'), tokensEvent())) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"No message event found"`); + }); +}); diff --git a/x-pack/plugins/inference/server/chat_complete/utils/stream_to_response.ts b/x-pack/plugins/inference/server/chat_complete/utils/stream_to_response.ts new file mode 100644 index 0000000000000..4bae4fda767cb --- /dev/null +++ b/x-pack/plugins/inference/server/chat_complete/utils/stream_to_response.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { toArray, map, firstValueFrom } from 'rxjs'; +import { + ChatCompleteResponse, + ChatCompleteStreamResponse, + createInferenceInternalError, + isChatCompletionMessageEvent, + isChatCompletionTokenCountEvent, + ToolOptions, + withoutChunkEvents, +} from '@kbn/inference-common'; + +export const streamToResponse = ( + streamResponse$: ChatCompleteStreamResponse +): Promise> => { + return firstValueFrom( + streamResponse$.pipe( + withoutChunkEvents(), + toArray(), + map((events) => { + const messageEvent = events.find(isChatCompletionMessageEvent); + const tokenEvent = events.find(isChatCompletionTokenCountEvent); + + if (!messageEvent) { + throw createInferenceInternalError('No message event found'); + } + + return { + content: messageEvent.content, + toolCalls: messageEvent.toolCalls, + tokens: tokenEvent?.tokens, + }; + }) + ) + ); +}; diff --git a/x-pack/plugins/inference/server/index.ts b/x-pack/plugins/inference/server/index.ts index d02dfec733941..60ce870020feb 100644 --- a/x-pack/plugins/inference/server/index.ts +++ b/x-pack/plugins/inference/server/index.ts @@ -4,25 +4,22 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import type { PluginInitializer, PluginInitializerContext } from '@kbn/core/server'; import type { InferenceConfig } from './config'; -import { InferencePlugin } from './plugin'; import type { InferenceServerSetup, InferenceServerStart, InferenceSetupDependencies, InferenceStartDependencies, } from './types'; - -export { withoutTokenCountEvents } from '../common/chat_complete/without_token_count_events'; -export { withoutChunkEvents } from '../common/chat_complete/without_chunk_events'; -export { withoutOutputUpdateEvents } from '../common/output/without_output_update_events'; +import { InferencePlugin } from './plugin'; export type { InferenceClient } from './types'; -export { naturalLanguageToEsql } from './tasks/nl_to_esql'; - export type { InferenceServerSetup, InferenceServerStart }; +export { naturalLanguageToEsql } from './tasks/nl_to_esql'; + export const plugin: PluginInitializer< InferenceServerSetup, InferenceServerStart, diff --git a/x-pack/plugins/inference/server/inference_client/index.ts b/x-pack/plugins/inference/server/inference_client/index.ts index 25208bebc54bb..03da0e3da200f 100644 --- a/x-pack/plugins/inference/server/inference_client/index.ts +++ b/x-pack/plugins/inference/server/inference_client/index.ts @@ -9,7 +9,7 @@ import type { Logger } from '@kbn/logging'; import type { KibanaRequest } from '@kbn/core-http-server'; import type { InferenceClient, InferenceStartDependencies } from '../types'; import { createChatCompleteApi } from '../chat_complete'; -import { createOutputApi } from '../../common/output/create_output_api'; +import { createOutputApi } from '../../common/create_output_api'; import { getConnectorById } from '../util/get_connector_by_id'; export function createInferenceClient({ diff --git a/x-pack/plugins/inference/server/routes/chat_complete.ts b/x-pack/plugins/inference/server/routes/chat_complete.ts index fdf33fbf0af82..582d4ceb97d45 100644 --- a/x-pack/plugins/inference/server/routes/chat_complete.ts +++ b/x-pack/plugins/inference/server/routes/chat_complete.ts @@ -5,11 +5,16 @@ * 2.0. */ -import { schema, Type } from '@kbn/config-schema'; -import type { CoreSetup, IRouter, Logger, RequestHandlerContext } from '@kbn/core/server'; -import { MessageRole } from '../../common/chat_complete'; -import type { ChatCompleteRequestBody } from '../../common/chat_complete/request'; -import { ToolCall, ToolChoiceType } from '../../common/chat_complete/tools'; +import { schema, Type, TypeOf } from '@kbn/config-schema'; +import type { + CoreSetup, + IRouter, + Logger, + RequestHandlerContext, + KibanaRequest, +} from '@kbn/core/server'; +import { MessageRole, ToolCall, ToolChoiceType } from '@kbn/inference-common'; +import type { ChatCompleteRequestBody } from '../../common/http_apis'; import { createInferenceClient } from '../inference_client'; import { InferenceServerStart, InferenceStartDependencies } from '../types'; import { observableIntoEventSourceStream } from '../util/observable_into_event_source_stream'; @@ -85,6 +90,32 @@ export function registerChatCompleteRoute({ router: IRouter; logger: Logger; }) { + async function callChatComplete({ + request, + stream, + }: { + request: KibanaRequest>; + stream: T; + }) { + const actions = await coreSetup + .getStartServices() + .then(([coreStart, pluginsStart]) => pluginsStart.actions); + + const client = createInferenceClient({ request, actions, logger }); + + const { connectorId, messages, system, toolChoice, tools, functionCalling } = request.body; + + return client.chatComplete({ + connectorId, + messages, + system, + toolChoice, + tools, + functionCalling, + stream, + }); + } + router.post( { path: '/internal/inference/chat_complete', @@ -93,23 +124,22 @@ export function registerChatCompleteRoute({ }, }, async (context, request, response) => { - const actions = await coreSetup - .getStartServices() - .then(([coreStart, pluginsStart]) => pluginsStart.actions); - - const client = createInferenceClient({ request, actions, logger }); - - const { connectorId, messages, system, toolChoice, tools, functionCalling } = request.body; - - const chatCompleteResponse = client.chatComplete({ - connectorId, - messages, - system, - toolChoice, - tools, - functionCalling, + const chatCompleteResponse = await callChatComplete({ request, stream: false }); + return response.ok({ + body: chatCompleteResponse, }); + } + ); + router.post( + { + path: '/internal/inference/chat_complete/stream', + validate: { + body: chatCompleteBodySchema, + }, + }, + async (context, request, response) => { + const chatCompleteResponse = await callChatComplete({ request, stream: true }); return response.ok({ body: observableIntoEventSourceStream(chatCompleteResponse, logger), }); diff --git a/x-pack/plugins/inference/server/tasks/nl_to_esql/actions/generate_esql.ts b/x-pack/plugins/inference/server/tasks/nl_to_esql/actions/generate_esql.ts index d31952e2f5252..3d8701eba72db 100644 --- a/x-pack/plugins/inference/server/tasks/nl_to_esql/actions/generate_esql.ts +++ b/x-pack/plugins/inference/server/tasks/nl_to_esql/actions/generate_esql.ts @@ -7,21 +7,23 @@ import { Observable, map, merge, of, switchMap } from 'rxjs'; import type { Logger } from '@kbn/logging'; -import { ToolCall, ToolOptions } from '../../../../common/chat_complete/tools'; import { - correctCommonEsqlMistakes, - generateFakeToolCallId, + ToolCall, + ToolOptions, + withoutTokenCountEvents, isChatCompletionMessageEvent, Message, MessageRole, -} from '../../../../common'; -import { InferenceClient, withoutTokenCountEvents } from '../../..'; -import { OutputCompleteEvent, OutputEventType } from '../../../../common/output'; + OutputCompleteEvent, + OutputEventType, + FunctionCallingMode, +} from '@kbn/inference-common'; +import { correctCommonEsqlMistakes, generateFakeToolCallId } from '../../../../common'; +import { InferenceClient } from '../../..'; import { INLINE_ESQL_QUERY_REGEX } from '../../../../common/tasks/nl_to_esql/constants'; import { EsqlDocumentBase } from '../doc_base'; import { requestDocumentationSchema } from './shared'; import type { NlToEsqlTaskEvent } from '../types'; -import type { FunctionCallingMode } from '../../../../common/chat_complete'; export const generateEsqlTask = ({ chatCompleteApi, @@ -69,6 +71,7 @@ export const generateEsqlTask = ({ chatCompleteApi({ connectorId, functionCalling, + stream: true, system: `${systemMessage} # Current task diff --git a/x-pack/plugins/inference/server/tasks/nl_to_esql/actions/request_documentation.ts b/x-pack/plugins/inference/server/tasks/nl_to_esql/actions/request_documentation.ts index d4eb3060f59bb..06e75db09bdc9 100644 --- a/x-pack/plugins/inference/server/tasks/nl_to_esql/actions/request_documentation.ts +++ b/x-pack/plugins/inference/server/tasks/nl_to_esql/actions/request_documentation.ts @@ -6,11 +6,15 @@ */ import { isEmpty } from 'lodash'; -import { InferenceClient, withoutOutputUpdateEvents } from '../../..'; -import { Message } from '../../../../common'; -import { ToolChoiceType, ToolOptions } from '../../../../common/chat_complete/tools'; +import { + ToolChoiceType, + ToolOptions, + Message, + withoutOutputUpdateEvents, + FunctionCallingMode, +} from '@kbn/inference-common'; +import { InferenceClient } from '../../..'; import { requestDocumentationSchema } from './shared'; -import type { FunctionCallingMode } from '../../../../common/chat_complete'; export const requestDocumentation = ({ outputApi, @@ -29,8 +33,10 @@ export const requestDocumentation = ({ }) => { const hasTools = !isEmpty(tools) && toolChoice !== ToolChoiceType.none; - return outputApi('request_documentation', { + return outputApi({ + id: 'request_documentation', connectorId, + stream: true, functionCalling, system, previousMessages: messages, diff --git a/x-pack/plugins/inference/server/tasks/nl_to_esql/actions/shared.ts b/x-pack/plugins/inference/server/tasks/nl_to_esql/actions/shared.ts index f0fc796173b23..60114188ea37f 100644 --- a/x-pack/plugins/inference/server/tasks/nl_to_esql/actions/shared.ts +++ b/x-pack/plugins/inference/server/tasks/nl_to_esql/actions/shared.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ToolSchema } from '../../../../common'; +import { ToolSchema } from '@kbn/inference-common'; export const requestDocumentationSchema = { type: 'object', diff --git a/x-pack/plugins/inference/server/tasks/nl_to_esql/task.ts b/x-pack/plugins/inference/server/tasks/nl_to_esql/task.ts index e0c5a838ea148..56c48b73f4994 100644 --- a/x-pack/plugins/inference/server/tasks/nl_to_esql/task.ts +++ b/x-pack/plugins/inference/server/tasks/nl_to_esql/task.ts @@ -7,8 +7,7 @@ import { once } from 'lodash'; import { Observable, from, switchMap } from 'rxjs'; -import { Message, MessageRole } from '../../../common/chat_complete'; -import type { ToolOptions } from '../../../common/chat_complete/tools'; +import { Message, MessageRole, ToolOptions } from '@kbn/inference-common'; import { EsqlDocumentBase } from './doc_base'; import { requestDocumentation, generateEsqlTask } from './actions'; import { NlToEsqlTaskParams, NlToEsqlTaskEvent } from './types'; diff --git a/x-pack/plugins/inference/server/tasks/nl_to_esql/types.ts b/x-pack/plugins/inference/server/tasks/nl_to_esql/types.ts index a0bcd635081ea..ce45d9a15e4b3 100644 --- a/x-pack/plugins/inference/server/tasks/nl_to_esql/types.ts +++ b/x-pack/plugins/inference/server/tasks/nl_to_esql/types.ts @@ -11,9 +11,9 @@ import type { ChatCompletionMessageEvent, FunctionCallingMode, Message, -} from '../../../common/chat_complete'; -import type { ToolOptions } from '../../../common/chat_complete/tools'; -import type { OutputCompleteEvent } from '../../../common/output'; + ToolOptions, + OutputCompleteEvent, +} from '@kbn/inference-common'; import type { InferenceClient } from '../../types'; export type NlToEsqlTaskEvent = diff --git a/x-pack/plugins/inference/server/test_utils/chat_complete_events.ts b/x-pack/plugins/inference/server/test_utils/chat_complete_events.ts new file mode 100644 index 0000000000000..4b09ca9c4dc5a --- /dev/null +++ b/x-pack/plugins/inference/server/test_utils/chat_complete_events.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + ChatCompletionChunkEvent, + ChatCompletionEventType, + ChatCompletionTokenCountEvent, + ChatCompletionMessageEvent, + ChatCompletionTokenCount, + ToolCall, +} from '@kbn/inference-common'; + +export const chunkEvent = (content: string = 'chunk'): ChatCompletionChunkEvent => ({ + type: ChatCompletionEventType.ChatCompletionChunk, + content, + tool_calls: [], +}); + +export const messageEvent = ( + content: string = 'message', + toolCalls: Array> = [] +): ChatCompletionMessageEvent => ({ + type: ChatCompletionEventType.ChatCompletionMessage, + content, + toolCalls, +}); + +export const tokensEvent = (tokens?: ChatCompletionTokenCount): ChatCompletionTokenCountEvent => ({ + type: ChatCompletionEventType.ChatCompletionTokenCount, + tokens: { + prompt: tokens?.prompt ?? 10, + completion: tokens?.completion ?? 20, + total: tokens?.total ?? 30, + }, +}); diff --git a/x-pack/plugins/inference/server/types.ts b/x-pack/plugins/inference/server/types.ts index 20679ffd4cedf..f538448372e36 100644 --- a/x-pack/plugins/inference/server/types.ts +++ b/x-pack/plugins/inference/server/types.ts @@ -10,9 +10,8 @@ import type { PluginSetupContract as ActionsPluginSetup, } from '@kbn/actions-plugin/server'; import type { KibanaRequest } from '@kbn/core-http-server'; -import { ChatCompleteAPI } from '../common/chat_complete'; +import { ChatCompleteAPI, OutputAPI } from '@kbn/inference-common'; import { InferenceConnector } from '../common/connectors'; -import { OutputAPI } from '../common/output'; /* eslint-disable @typescript-eslint/no-empty-interface*/ diff --git a/x-pack/plugins/inference/server/util/get_connector_by_id.ts b/x-pack/plugins/inference/server/util/get_connector_by_id.ts index 3fd77630ad3d1..1dbf9a6f0d75e 100644 --- a/x-pack/plugins/inference/server/util/get_connector_by_id.ts +++ b/x-pack/plugins/inference/server/util/get_connector_by_id.ts @@ -6,8 +6,8 @@ */ import type { ActionsClient, ActionResult as ActionConnector } from '@kbn/actions-plugin/server'; +import { createInferenceRequestError } from '@kbn/inference-common'; import { isSupportedConnectorType, type InferenceConnector } from '../../common/connectors'; -import { createInferenceRequestError } from '../../common/errors'; /** * Retrieves a connector given the provided `connectorId` and asserts it's an inference connector 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 ed5466ba1e027..8ece214c27599 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 @@ -8,7 +8,7 @@ import { createParser } from 'eventsource-parser'; import { partition } from 'lodash'; import { merge, of, throwError } from 'rxjs'; -import type { InferenceTaskEvent } from '../../common/inference_task'; +import type { InferenceTaskEvent } from '@kbn/inference-common'; import { observableIntoEventSourceStream } from './observable_into_event_source_stream'; import type { Logger } from '@kbn/logging'; diff --git a/x-pack/plugins/inference/server/util/observable_into_event_source_stream.ts b/x-pack/plugins/inference/server/util/observable_into_event_source_stream.ts index bcd1ef60ce1da..62eae6609441f 100644 --- a/x-pack/plugins/inference/server/util/observable_into_event_source_stream.ts +++ b/x-pack/plugins/inference/server/util/observable_into_event_source_stream.ts @@ -9,11 +9,11 @@ import { catchError, map, Observable, of } from 'rxjs'; import { PassThrough } from 'stream'; import type { Logger } from '@kbn/logging'; import { + InferenceTaskEventType, InferenceTaskErrorCode, InferenceTaskErrorEvent, isInferenceError, -} from '../../common/errors'; -import { InferenceTaskEventType } from '../../common/inference_task'; +} from '@kbn/inference-common'; export function observableIntoEventSourceStream( source$: Observable, diff --git a/x-pack/plugins/inference/server/util/validate_tool_calls.test.ts b/x-pack/plugins/inference/server/util/validate_tool_calls.test.ts index 96bf202fa236b..57b030771c6c0 100644 --- a/x-pack/plugins/inference/server/util/validate_tool_calls.test.ts +++ b/x-pack/plugins/inference/server/util/validate_tool_calls.test.ts @@ -5,8 +5,7 @@ * 2.0. */ -import { isToolValidationError } from '../../common/chat_complete/errors'; -import { ToolChoiceType } from '../../common/chat_complete/tools'; +import { ToolChoiceType, isToolValidationError } from '@kbn/inference-common'; import { validateToolCalls } from './validate_tool_calls'; describe('validateToolCalls', () => { diff --git a/x-pack/plugins/inference/server/util/validate_tool_calls.ts b/x-pack/plugins/inference/server/util/validate_tool_calls.ts index 5d1e659bc36f5..ffc2482774b23 100644 --- a/x-pack/plugins/inference/server/util/validate_tool_calls.ts +++ b/x-pack/plugins/inference/server/util/validate_tool_calls.ts @@ -5,16 +5,13 @@ * 2.0. */ import Ajv from 'ajv'; -import { - createToolNotFoundError, - createToolValidationError, -} from '../../common/chat_complete/errors'; import { ToolCallsOf, ToolChoiceType, ToolOptions, UnvalidatedToolCall, -} from '../../common/chat_complete/tools'; +} from '@kbn/inference-common'; +import { createToolNotFoundError, createToolValidationError } from '../chat_complete/errors'; export function validateToolCalls({ toolCalls, diff --git a/x-pack/plugins/inference/tsconfig.json b/x-pack/plugins/inference/tsconfig.json index cc81eec1da96c..92327007829a9 100644 --- a/x-pack/plugins/inference/tsconfig.json +++ b/x-pack/plugins/inference/tsconfig.json @@ -19,7 +19,6 @@ ], "kbn_references": [ "@kbn/i18n", - "@kbn/sse-utils", "@kbn/esql-ast", "@kbn/esql-validation-autocomplete", "@kbn/core", @@ -33,6 +32,7 @@ "@kbn/core-http-server", "@kbn/actions-plugin", "@kbn/config-schema", + "@kbn/inference-common", "@kbn/es-types", "@kbn/field-types", "@kbn/expressions-plugin", diff --git a/x-pack/plugins/ingest_pipelines/kibana.jsonc b/x-pack/plugins/ingest_pipelines/kibana.jsonc index 55fa46c61b377..85b3e43aedf4d 100644 --- a/x-pack/plugins/ingest_pipelines/kibana.jsonc +++ b/x-pack/plugins/ingest_pipelines/kibana.jsonc @@ -2,6 +2,8 @@ "type": "plugin", "id": "@kbn/ingest-pipelines-plugin", "owner": "@elastic/kibana-management", + "group": "platform", + "visibility": "shared", "plugin": { "id": "ingestPipelines", "server": true, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/gsub.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/gsub.tsx index 7e72848485c11..8e12be6880d00 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/gsub.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/gsub.tsx @@ -37,7 +37,8 @@ const fieldsConfig: FieldsConfig = { validator: emptyField( i18n.translate('xpack.ingestPipelines.pipelineEditor.gsubForm.patternRequiredError', { defaultMessage: 'A value is required.', - }) + }), + false ), }, { diff --git a/x-pack/plugins/integration_assistant/docs/imgs/categorization_graph.png b/x-pack/plugins/integration_assistant/docs/imgs/categorization_graph.png index a15dbf54d905a..de45a18546b40 100644 Binary files a/x-pack/plugins/integration_assistant/docs/imgs/categorization_graph.png and b/x-pack/plugins/integration_assistant/docs/imgs/categorization_graph.png differ diff --git a/x-pack/plugins/integration_assistant/docs/imgs/cel_graph.png b/x-pack/plugins/integration_assistant/docs/imgs/cel_graph.png new file mode 100644 index 0000000000000..8b339caa83798 Binary files /dev/null and b/x-pack/plugins/integration_assistant/docs/imgs/cel_graph.png differ diff --git a/x-pack/plugins/integration_assistant/docs/imgs/log_detection_graph.png b/x-pack/plugins/integration_assistant/docs/imgs/log_detection_graph.png index 285e012c57a14..f89ffc800f0a5 100644 Binary files a/x-pack/plugins/integration_assistant/docs/imgs/log_detection_graph.png and b/x-pack/plugins/integration_assistant/docs/imgs/log_detection_graph.png differ diff --git a/x-pack/plugins/integration_assistant/docs/imgs/related_graph.png b/x-pack/plugins/integration_assistant/docs/imgs/related_graph.png index 73a2c3acac0d4..222c864f3115f 100644 Binary files a/x-pack/plugins/integration_assistant/docs/imgs/related_graph.png and b/x-pack/plugins/integration_assistant/docs/imgs/related_graph.png differ diff --git a/x-pack/plugins/integration_assistant/docs/imgs/unstructured_graph.png b/x-pack/plugins/integration_assistant/docs/imgs/unstructured_graph.png new file mode 100644 index 0000000000000..45ab5abc8d6b3 Binary files /dev/null and b/x-pack/plugins/integration_assistant/docs/imgs/unstructured_graph.png differ diff --git a/x-pack/plugins/integration_assistant/kibana.jsonc b/x-pack/plugins/integration_assistant/kibana.jsonc index 94840008a3344..66ee0775fb481 100644 --- a/x-pack/plugins/integration_assistant/kibana.jsonc +++ b/x-pack/plugins/integration_assistant/kibana.jsonc @@ -2,6 +2,8 @@ "type": "plugin", "id": "@kbn/integration-assistant-plugin", "owner": "@elastic/security-scalability", + "group": "platform", + "visibility": "shared", "description": "Plugin implementing the Integration Assistant API and UI", "plugin": { "id": "integrationAssistant", diff --git a/x-pack/plugins/integration_assistant/scripts/draw_graphs_script.ts b/x-pack/plugins/integration_assistant/scripts/draw_graphs_script.ts index 12a37f71b184a..f9fd71c1f934f 100644 --- a/x-pack/plugins/integration_assistant/scripts/draw_graphs_script.ts +++ b/x-pack/plugins/integration_assistant/scripts/draw_graphs_script.ts @@ -20,6 +20,8 @@ import { getEcsGraph, getEcsSubGraph } from '../server/graphs/ecs/graph'; import { getLogFormatDetectionGraph } from '../server/graphs/log_type_detection/graph'; import { getRelatedGraph } from '../server/graphs/related/graph'; import { getKVGraph } from '../server/graphs/kv/graph'; +import { getUnstructuredGraph } from '../server/graphs/unstructured'; +import { getCelGraph } from '../server/graphs/cel/graph'; // Some mock elements just to get the graph to compile const model = new FakeLLM({ @@ -45,17 +47,20 @@ async function drawGraph(compiledGraph: RunnableGraph, graphName: string) { await saveFile(`${graphName}.png`, buffer); } +const GRAPH_LIST = { + related_graph: getRelatedGraph, + log_detection_graph: getLogFormatDetectionGraph, + categorization_graph: getCategorizationGraph, + kv_graph: getKVGraph, + ecs_graph: getEcsGraph, + ecs_subgraph: getEcsSubGraph, + unstructured_graph: getUnstructuredGraph, + cel_graph: getCelGraph, +}; + export async function drawGraphs() { - const relatedGraph = (await getRelatedGraph({ client, model })).getGraph(); - const logFormatDetectionGraph = (await getLogFormatDetectionGraph({ client, model })).getGraph(); - const categorizationGraph = (await getCategorizationGraph({ client, model })).getGraph(); - const ecsSubGraph = (await getEcsSubGraph({ model })).getGraph(); - const ecsGraph = (await getEcsGraph({ model })).getGraph(); - const kvGraph = (await getKVGraph({ client, model })).getGraph(); - drawGraph(relatedGraph, 'related_graph'); - drawGraph(logFormatDetectionGraph, 'log_detection_graph'); - drawGraph(categorizationGraph, 'categorization_graph'); - drawGraph(ecsSubGraph, 'ecs_subgraph'); - drawGraph(ecsGraph, 'ecs_graph'); - drawGraph(kvGraph, 'kv_graph'); + for (const [name, graph] of Object.entries(GRAPH_LIST)) { + const compiledGraph = (await graph({ client, model })).getGraph(); + drawGraph(compiledGraph, name); + } } diff --git a/x-pack/plugins/integration_assistant/server/graphs/categorization/graph.ts b/x-pack/plugins/integration_assistant/server/graphs/categorization/graph.ts index 2f07bcd106862..cc1601095da62 100644 --- a/x-pack/plugins/integration_assistant/server/graphs/categorization/graph.ts +++ b/x-pack/plugins/integration_assistant/server/graphs/categorization/graph.ts @@ -233,6 +233,6 @@ export async function getCategorizationGraph({ client, model }: CategorizationGr } ); - const compiledCategorizationGraph = workflow.compile().withConfig({ runName: 'Categorization' }); + const compiledCategorizationGraph = workflow.compile(); return compiledCategorizationGraph; } diff --git a/x-pack/plugins/integration_assistant/server/graphs/cel/graph.ts b/x-pack/plugins/integration_assistant/server/graphs/cel/graph.ts index a8f2e0521c788..5d58f82f6f744 100644 --- a/x-pack/plugins/integration_assistant/server/graphs/cel/graph.ts +++ b/x-pack/plugins/integration_assistant/server/graphs/cel/graph.ts @@ -104,6 +104,6 @@ export async function getCelGraph({ model }: CelInputGraphParams) { .addEdge('handleGetStateVariables', 'handleGetStateDetails') .addEdge('handleGetStateDetails', 'modelOutput'); - const compiledCelGraph = workflow.compile().withConfig({ runName: 'CEL' }); + const compiledCelGraph = workflow.compile(); return compiledCelGraph; } diff --git a/x-pack/plugins/integration_assistant/server/graphs/ecs/graph.ts b/x-pack/plugins/integration_assistant/server/graphs/ecs/graph.ts index 89a7e5c600723..dc2f26f9505e4 100644 --- a/x-pack/plugins/integration_assistant/server/graphs/ecs/graph.ts +++ b/x-pack/plugins/integration_assistant/server/graphs/ecs/graph.ts @@ -78,7 +78,7 @@ export async function getEcsSubGraph({ model }: EcsGraphParams) { }) .addEdge('modelSubOutput', END); - const compiledEcsSubGraph = workflow.compile().withConfig({ runName: 'ECS Mapping (Chunk)' }); + const compiledEcsSubGraph = workflow.compile(); return compiledEcsSubGraph; } @@ -96,7 +96,7 @@ export async function getEcsGraph({ model }: EcsGraphParams) { .addNode('handleMergedSubGraphResponse', (state: EcsMappingState) => modelMergedInputFromSubGraph({ state }) ) - .addNode('subGraph', subGraph) + .addNode('subGraph', subGraph.withConfig({ runName: 'ECS Mapping (Chunk)' })) .addEdge(START, 'modelInput') .addEdge('subGraph', 'handleMergedSubGraphResponse') .addEdge('handleDuplicates', 'handleValidation') @@ -119,6 +119,6 @@ export async function getEcsGraph({ model }: EcsGraphParams) { }) .addEdge('modelOutput', END); - const compiledEcsGraph = workflow.compile().withConfig({ runName: 'ECS Mapping' }); + const compiledEcsGraph = workflow.compile(); return compiledEcsGraph; } diff --git a/x-pack/plugins/integration_assistant/server/graphs/kv/graph.ts b/x-pack/plugins/integration_assistant/server/graphs/kv/graph.ts index f72984655c1f8..6f7b43ba40f22 100644 --- a/x-pack/plugins/integration_assistant/server/graphs/kv/graph.ts +++ b/x-pack/plugins/integration_assistant/server/graphs/kv/graph.ts @@ -139,6 +139,6 @@ export async function getKVGraph({ model, client }: KVGraphParams) { }) .addEdge('modelOutput', END); - const compiledKVGraph = workflow.compile().withConfig({ runName: 'Key-Value' }); + const compiledKVGraph = workflow.compile(); return compiledKVGraph; } diff --git a/x-pack/plugins/integration_assistant/server/graphs/log_type_detection/graph.ts b/x-pack/plugins/integration_assistant/server/graphs/log_type_detection/graph.ts index 95d624a7436c7..ae4c607ab3f68 100644 --- a/x-pack/plugins/integration_assistant/server/graphs/log_type_detection/graph.ts +++ b/x-pack/plugins/integration_assistant/server/graphs/log_type_detection/graph.ts @@ -118,8 +118,14 @@ export async function getLogFormatDetectionGraph({ model, client }: LogDetection .addNode('handleLogFormatDetection', (state: LogFormatDetectionState) => handleLogFormatDetection({ state, model, client }) ) - .addNode('handleKVGraph', await getKVGraph({ model, client })) - .addNode('handleUnstructuredGraph', await getUnstructuredGraph({ model, client })) + .addNode( + 'handleKVGraph', + (await getKVGraph({ model, client })).withConfig({ runName: 'Key-Value' }) + ) + .addNode( + 'handleUnstructuredGraph', + (await getUnstructuredGraph({ model, client })).withConfig({ runName: 'Unstructured' }) + ) .addNode('handleCSV', (state: LogFormatDetectionState) => handleCSV({ state, model, client })) .addEdge(START, 'modelInput') .addEdge('modelInput', 'handleLogFormatDetection') @@ -138,6 +144,6 @@ export async function getLogFormatDetectionGraph({ model, client }: LogDetection } ); - const compiledLogFormatDetectionGraph = workflow.compile().withConfig({ runName: 'Log Format' }); + const compiledLogFormatDetectionGraph = workflow.compile(); return compiledLogFormatDetectionGraph; } diff --git a/x-pack/plugins/integration_assistant/server/graphs/related/graph.ts b/x-pack/plugins/integration_assistant/server/graphs/related/graph.ts index e8dc44a152e80..20ac1c639dcf4 100644 --- a/x-pack/plugins/integration_assistant/server/graphs/related/graph.ts +++ b/x-pack/plugins/integration_assistant/server/graphs/related/graph.ts @@ -179,6 +179,6 @@ export async function getRelatedGraph({ client, model }: RelatedGraphParams) { } ); - const compiledRelatedGraph = workflow.compile().withConfig({ runName: 'Related' }); + const compiledRelatedGraph = workflow.compile(); return compiledRelatedGraph; } diff --git a/x-pack/plugins/integration_assistant/server/graphs/unstructured/graph.ts b/x-pack/plugins/integration_assistant/server/graphs/unstructured/graph.ts index cf3a645effa68..6048404728bfb 100644 --- a/x-pack/plugins/integration_assistant/server/graphs/unstructured/graph.ts +++ b/x-pack/plugins/integration_assistant/server/graphs/unstructured/graph.ts @@ -107,6 +107,6 @@ export async function getUnstructuredGraph({ model, client }: UnstructuredGraphP .addEdge('handleUnstructuredError', 'handleUnstructuredValidate') .addEdge('modelOutput', END); - const compiledUnstructuredGraph = workflow.compile().withConfig({ runName: 'Unstructured' }); + const compiledUnstructuredGraph = workflow.compile(); return compiledUnstructuredGraph; } diff --git a/x-pack/plugins/integration_assistant/server/routes/analyze_logs_routes.ts b/x-pack/plugins/integration_assistant/server/routes/analyze_logs_routes.ts index 639cd62f275b1..34f05fcc82025 100644 --- a/x-pack/plugins/integration_assistant/server/routes/analyze_logs_routes.ts +++ b/x-pack/plugins/integration_assistant/server/routes/analyze_logs_routes.ts @@ -36,6 +36,13 @@ export function registerAnalyzeLogsRoutes( .addVersion( { version: '1', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because the privileges are not defined yet.', + }, + }, validate: { request: { body: buildRouteValidationWithZod(AnalyzeLogsRequestBody), @@ -91,7 +98,9 @@ export function registerAnalyzeLogsRoutes( logSamples, }; const graph = await getLogFormatDetectionGraph({ model, client }); - const graphResults = await graph.invoke(logFormatParameters, options); + const graphResults = await graph + .withConfig({ runName: 'Log Format' }) + .invoke(logFormatParameters, options); const graphLogFormat = graphResults.results.samplesFormat.name; if (graphLogFormat === 'unsupported') { throw new UnsupportedLogFormatError(GenerationErrorCode.UNSUPPORTED_LOG_SAMPLES_FORMAT); diff --git a/x-pack/plugins/integration_assistant/server/routes/build_integration_routes.ts b/x-pack/plugins/integration_assistant/server/routes/build_integration_routes.ts index 6d7e5155a3d23..f62d6d55f933d 100644 --- a/x-pack/plugins/integration_assistant/server/routes/build_integration_routes.ts +++ b/x-pack/plugins/integration_assistant/server/routes/build_integration_routes.ts @@ -25,6 +25,13 @@ export function registerIntegrationBuilderRoutes( .addVersion( { version: '1', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because the privileges are not defined yet.', + }, + }, validate: { request: { body: buildRouteValidationWithZod(BuildIntegrationRequestBody), diff --git a/x-pack/plugins/integration_assistant/server/routes/categorization_routes.test.ts b/x-pack/plugins/integration_assistant/server/routes/categorization_routes.test.ts index abe626cf7ae73..0e6f4ffa0491a 100644 --- a/x-pack/plugins/integration_assistant/server/routes/categorization_routes.test.ts +++ b/x-pack/plugins/integration_assistant/server/routes/categorization_routes.test.ts @@ -23,7 +23,9 @@ const mockResult = jest.fn().mockResolvedValue({ jest.mock('../graphs/categorization', () => { return { getCategorizationGraph: jest.fn().mockResolvedValue({ - invoke: () => mockResult(), + withConfig: () => ({ + invoke: () => mockResult(), + }), }), }; }); diff --git a/x-pack/plugins/integration_assistant/server/routes/categorization_routes.ts b/x-pack/plugins/integration_assistant/server/routes/categorization_routes.ts index 77ce549f589f4..5f63ed9c7bf3c 100644 --- a/x-pack/plugins/integration_assistant/server/routes/categorization_routes.ts +++ b/x-pack/plugins/integration_assistant/server/routes/categorization_routes.ts @@ -40,6 +40,13 @@ export function registerCategorizationRoutes( .addVersion( { version: '1', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because the privileges are not defined yet.', + }, + }, validate: { request: { body: buildRouteValidationWithZod(CategorizationRequestBody), @@ -99,7 +106,9 @@ export function registerCategorizationRoutes( }; const graph = await getCategorizationGraph({ client, model }); - const results = await graph.invoke(parameters, options); + const results = await graph + .withConfig({ runName: 'Categorization' }) + .invoke(parameters, options); return res.ok({ body: CategorizationResponse.parse(results) }); } catch (err) { diff --git a/x-pack/plugins/integration_assistant/server/routes/cel_route.test.ts b/x-pack/plugins/integration_assistant/server/routes/cel_route.test.ts index be435aa9866bb..02b5f03948a12 100644 --- a/x-pack/plugins/integration_assistant/server/routes/cel_route.test.ts +++ b/x-pack/plugins/integration_assistant/server/routes/cel_route.test.ts @@ -22,7 +22,9 @@ const mockResult = jest.fn().mockResolvedValue({ jest.mock('../graphs/cel', () => { return { getCelGraph: jest.fn().mockResolvedValue({ - invoke: () => mockResult(), + withConfig: () => ({ + invoke: () => mockResult(), + }), }), }; }); diff --git a/x-pack/plugins/integration_assistant/server/routes/cel_routes.ts b/x-pack/plugins/integration_assistant/server/routes/cel_routes.ts index ecf012a88cfe5..9ce16c3909119 100644 --- a/x-pack/plugins/integration_assistant/server/routes/cel_routes.ts +++ b/x-pack/plugins/integration_assistant/server/routes/cel_routes.ts @@ -32,6 +32,13 @@ export function registerCelInputRoutes(router: IRouter { return { getEcsGraph: jest.fn().mockResolvedValue({ - invoke: () => mockResult(), + withConfig: () => ({ + invoke: () => mockResult(), + }), }), }; }); diff --git a/x-pack/plugins/integration_assistant/server/routes/ecs_routes.ts b/x-pack/plugins/integration_assistant/server/routes/ecs_routes.ts index 43ca0fe396cae..adb30d6c03fba 100644 --- a/x-pack/plugins/integration_assistant/server/routes/ecs_routes.ts +++ b/x-pack/plugins/integration_assistant/server/routes/ecs_routes.ts @@ -34,6 +34,13 @@ export function registerEcsRoutes(router: IRouter { return { getRelatedGraph: jest.fn().mockResolvedValue({ - invoke: () => mockResult(), + withConfig: () => ({ + invoke: () => mockResult(), + }), }), }; }); diff --git a/x-pack/plugins/integration_assistant/server/routes/related_routes.ts b/x-pack/plugins/integration_assistant/server/routes/related_routes.ts index fe3a63abd4ce9..d839ce2a08620 100644 --- a/x-pack/plugins/integration_assistant/server/routes/related_routes.ts +++ b/x-pack/plugins/integration_assistant/server/routes/related_routes.ts @@ -34,6 +34,13 @@ export function registerRelatedRoutes(router: IRouter RenderResult; +type UiRender = (ui: React.ReactNode, options?: RenderOptions) => RenderResult; /** * Mocked app root context renderer @@ -113,7 +113,7 @@ export const createAppRootMockRenderer = (): AppContextTestRender => { }, }); - const AppWrapper: React.FC<{ children: React.ReactElement }> = ({ children }) => ( + const AppWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( {children} diff --git a/x-pack/plugins/lens/common/embeddable_factory/index.ts b/x-pack/plugins/lens/common/embeddable_factory/index.ts index b794ec642f40a..68e6c77e9daeb 100644 --- a/x-pack/plugins/lens/common/embeddable_factory/index.ts +++ b/x-pack/plugins/lens/common/embeddable_factory/index.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { cloneDeep } from 'lodash'; import type { SerializableRecord, Serializable } from '@kbn/utility-types'; import type { SavedObjectReference } from '@kbn/core/types'; import type { @@ -17,7 +18,8 @@ export type LensEmbeddablePersistableState = EmbeddableStateWithType & { }; export const inject: EmbeddableRegistryDefinition['inject'] = (state, references) => { - const typedState = state as LensEmbeddablePersistableState; + // We need to clone the state because we can not modify the original state object. + const typedState = cloneDeep(state) as LensEmbeddablePersistableState; if ('attributes' in typedState && typedState.attributes !== undefined) { // match references based on name, so only references associated with this lens panel are injected. diff --git a/x-pack/plugins/lens/kibana.jsonc b/x-pack/plugins/lens/kibana.jsonc index 6a3f9875d1da9..4b0b14141474f 100644 --- a/x-pack/plugins/lens/kibana.jsonc +++ b/x-pack/plugins/lens/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/lens-plugin", - "owner": "@elastic/kibana-visualizations", + "owner": [ + "@elastic/kibana-visualizations" + ], + "group": "platform", + "visibility": "shared", "description": "Visualization editor allowing to quickly and easily configure compelling visualizations to use on dashboards and canvas workpads. Exposes components to embed visualizations and link into the Lens editor from within other apps in Kibana.", "plugin": { "id": "lens", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "lens" @@ -35,7 +39,7 @@ "expressionTagcloud", "eventAnnotation", "unifiedSearch", - "contentManagement", + "contentManagement" ], "optionalPlugins": [ "expressionLegacyMetricVis", @@ -57,10 +61,10 @@ "fieldFormats", "charts", "esqlDataGrid", - "esql", + "esql" ], "extraPublicDirs": [ "common/constants" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/lens_configuration_flyout.tsx b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/lens_configuration_flyout.tsx index ef452f20fdf7d..fd3bcdc8bed8a 100644 --- a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/lens_configuration_flyout.tsx +++ b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/lens_configuration_flyout.tsx @@ -17,6 +17,8 @@ import { EuiFlexGroup, EuiFlexItem, euiScrollBarStyles, + EuiWindowEvent, + keys, } from '@elastic/eui'; import { euiThemeVars } from '@kbn/ui-theme'; import type { Datatable } from '@kbn/expressions-plugin/public'; @@ -392,40 +394,51 @@ export function LensEditConfigurationFlyout({ getUserMessages, ]); + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === keys.ESCAPE) { + closeFlyout?.(); + setIsInlineFlyoutVisible(false); + } + }; + if (isLoading) return null; // Example is the Discover editing where we dont want to render the text based editor on the panel, neither the suggestions (for now) if (!canEditTextBasedQuery && hidesSuggestions) { return ( - - - + <> + {isInlineFlyoutVisible && } + + + + ); } return ( <> + {isInlineFlyoutVisible && } )} - {layerDatasource?.LayerSettingsComponent && visualizationLayerSettings.data ? ( - - ) : null} {activeVisualization?.LayerSettingsComponent && visualizationLayerSettings.data ? ( { + describe('mergeSuggestionWithVisContext', () => { + it('should return the suggestion as it is if the visualization types do not match', async () => { + const suggestion = mockAllSuggestions[0]; + const visAttributes = { + visualizationType: 'lnsXY', + state: { + visualization: { + preferredSeriesType: 'bar_stacked', + }, + datasourceStates: { textBased: { layers: {} } }, + }, + } as unknown as TypedLensByValueInput['attributes']; + expect(mergeSuggestionWithVisContext({ suggestion, visAttributes, context })).toStrictEqual( + suggestion + ); + }); + + it('should return the suggestion as it is if the context is not from ES|QL', async () => { + const nonESQLContext = { + dataViewSpec: { + id: 'index1', + title: 'index1', + name: 'DataView', + }, + fieldName: 'field1', + }; + const suggestion = mockAllSuggestions[0]; + const visAttributes = { + visualizationType: 'lnsHeatmap', + state: { + visualization: { + preferredSeriesType: 'bar_stacked', + }, + datasourceStates: { textBased: { layers: {} } }, + }, + } as unknown as TypedLensByValueInput['attributes']; + expect( + mergeSuggestionWithVisContext({ suggestion, visAttributes, context: nonESQLContext }) + ).toStrictEqual(suggestion); + }); + + it('should return the suggestion as it is for DSL config (formbased)', async () => { + const suggestion = mockAllSuggestions[0]; + const visAttributes = { + visualizationType: 'lnsHeatmap', + state: { + visualization: { + preferredSeriesType: 'bar_stacked', + }, + datasourceStates: { formBased: { layers: {} } }, + }, + } as unknown as TypedLensByValueInput['attributes']; + expect(mergeSuggestionWithVisContext({ suggestion, visAttributes, context })).toStrictEqual( + suggestion + ); + }); + + it('should return the suggestion as it is for columns that dont match the context', async () => { + const suggestion = mockAllSuggestions[0]; + const visAttributes = { + visualizationType: 'lnsHeatmap', + state: { + visualization: { + shape: 'heatmap', + }, + datasourceStates: { + textBased: { + layers: { + layer1: { + index: 'layer1', + query: { + esql: 'FROM kibana_sample_data_flights | keep Dest, AvgTicketPrice', + }, + columns: [ + { + columnId: 'colA', + fieldName: 'Dest', + meta: { + type: 'string', + }, + }, + { + columnId: 'colB', + fieldName: 'AvgTicketPrice', + meta: { + type: 'number', + }, + }, + ], + timeField: 'timestamp', + }, + }, + }, + }, + }, + } as unknown as TypedLensByValueInput['attributes']; + expect(mergeSuggestionWithVisContext({ suggestion, visAttributes, context })).toStrictEqual( + suggestion + ); + }); + + it('should return the suggestion updated with the attributes if the visualization types and the context columns match', async () => { + const suggestion = mockAllSuggestions[0]; + const visAttributes = { + visualizationType: 'lnsHeatmap', + state: { + visualization: { + shape: 'heatmap', + layerId: 'layer1', + layerType: 'data', + legend: { + isVisible: false, + position: 'left', + type: 'heatmap_legend', + }, + gridConfig: { + type: 'heatmap_grid', + isCellLabelVisible: true, + isYAxisLabelVisible: false, + isXAxisLabelVisible: false, + isYAxisTitleVisible: false, + isXAxisTitleVisible: false, + }, + valueAccessor: 'acc1', + xAccessor: 'acc2', + }, + datasourceStates: { + textBased: { + layers: { + layer1: { + index: 'layer1', + query: { + esql: 'FROM index1 | keep field1, field2', + }, + columns: [ + { + columnId: 'field2', + fieldName: 'field2', + meta: { + type: 'string', + }, + }, + { + columnId: 'field1', + fieldName: 'field1', + meta: { + type: 'number', + }, + }, + ], + timeField: 'timestamp', + }, + }, + }, + }, + }, + } as unknown as TypedLensByValueInput['attributes']; + const updatedSuggestion = mergeSuggestionWithVisContext({ + suggestion, + visAttributes, + context, + }); + expect(updatedSuggestion.visualizationState).toStrictEqual(visAttributes.state.visualization); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/lens_suggestions_api/helpers.ts b/x-pack/plugins/lens/public/lens_suggestions_api/helpers.ts new file mode 100644 index 0000000000000..394d32e8c5bb7 --- /dev/null +++ b/x-pack/plugins/lens/public/lens_suggestions_api/helpers.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { VisualizeFieldContext } from '@kbn/ui-actions-plugin/public'; +import { getDatasourceId } from '@kbn/visualization-utils'; +import type { VisualizeEditorContext, Suggestion } from '../types'; +import type { TypedLensByValueInput } from '../embeddable/embeddable_component'; + +/** + * Returns the suggestion updated with external visualization state for ES|QL charts + * The visualization state is merged with the suggestion if the datasource is textBased, the columns match the context and the visualization type matches + * @param suggestion the suggestion to be updated + * @param visAttributes the preferred visualization attributes + * @param context the lens suggestions api context as being set by the consumers + * @returns updated suggestion + */ + +export function mergeSuggestionWithVisContext({ + suggestion, + visAttributes, + context, +}: { + suggestion: Suggestion; + visAttributes: TypedLensByValueInput['attributes']; + context: VisualizeFieldContext | VisualizeEditorContext; +}): Suggestion { + if ( + visAttributes.visualizationType !== suggestion.visualizationId || + !('textBasedColumns' in context) + ) { + return suggestion; + } + + // it should be one of 'formBased'/'textBased' and have value + const datasourceId = getDatasourceId(visAttributes.state.datasourceStates); + + // if the datasource is formBased, we should not merge + if (!datasourceId || datasourceId === 'formBased') { + return suggestion; + } + const datasourceState = Object.assign({}, visAttributes.state.datasourceStates[datasourceId]); + + // should be based on same columns + if ( + !datasourceState?.layers || + Object.values(datasourceState?.layers).some( + (layer) => + layer.columns?.some( + (c: { fieldName: string }) => + !context?.textBasedColumns?.find((col) => col.name === c.fieldName) + ) || layer.columns?.length !== context?.textBasedColumns?.length + ) + ) { + return suggestion; + } + const layerIds = Object.keys(datasourceState.layers); + try { + return { + title: visAttributes.title, + visualizationId: visAttributes.visualizationType, + visualizationState: visAttributes.state.visualization, + keptLayerIds: layerIds, + datasourceState, + datasourceId, + columns: suggestion.columns, + changeType: suggestion.changeType, + score: suggestion.score, + previewIcon: suggestion.previewIcon, + }; + } catch { + return suggestion; + } +} diff --git a/x-pack/plugins/lens/public/lens_suggestions_api.ts b/x-pack/plugins/lens/public/lens_suggestions_api/index.ts similarity index 76% rename from x-pack/plugins/lens/public/lens_suggestions_api.ts rename to x-pack/plugins/lens/public/lens_suggestions_api/index.ts index 3bdadbf337227..c73379d9a42cd 100644 --- a/x-pack/plugins/lens/public/lens_suggestions_api.ts +++ b/x-pack/plugins/lens/public/lens_suggestions_api/index.ts @@ -6,22 +6,12 @@ */ import type { VisualizeFieldContext } from '@kbn/ui-actions-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/public'; -import { getSuggestions } from './editor_frame_service/editor_frame/suggestion_helpers'; -import type { DatasourceMap, VisualizationMap, VisualizeEditorContext } from './types'; -import type { DataViewsState } from './state_management'; - -export enum ChartType { - XY = 'XY', - Bar = 'Bar', - Line = 'Line', - Area = 'Area', - Donut = 'Donut', - Heatmap = 'Heat map', - Treemap = 'Treemap', - Tagcloud = 'Tag cloud', - Waffle = 'Waffle', - Table = 'Table', -} +import type { ChartType } from '@kbn/visualization-utils'; +import { getSuggestions } from '../editor_frame_service/editor_frame/suggestion_helpers'; +import type { DatasourceMap, VisualizationMap, VisualizeEditorContext } from '../types'; +import type { DataViewsState } from '../state_management'; +import type { TypedLensByValueInput } from '../embeddable/embeddable_component'; +import { mergeSuggestionWithVisContext } from './helpers'; interface SuggestionsApiProps { context: VisualizeFieldContext | VisualizeEditorContext; @@ -30,6 +20,7 @@ interface SuggestionsApiProps { datasourceMap?: DatasourceMap; excludedVisualizations?: string[]; preferredChartType?: ChartType; + preferredVisAttributes?: TypedLensByValueInput['attributes']; } export const suggestionsApi = ({ @@ -39,6 +30,7 @@ export const suggestionsApi = ({ visualizationMap, excludedVisualizations, preferredChartType, + preferredVisAttributes, }: SuggestionsApiProps) => { const initialContext = context; if (!datasourceMap || !visualizationMap || !dataView.id) return undefined; @@ -79,32 +71,7 @@ export const suggestionsApi = ({ dataViews, }); if (!suggestions.length) return []; - // check if there is an XY chart suggested - // if user has requested for a line or area, we want to sligthly change the state - // to return line / area instead of a bar chart - const chartType = preferredChartType?.toLowerCase(); - const XYSuggestion = suggestions.find((sug) => sug.visualizationId === 'lnsXY'); - if (XYSuggestion && chartType && ['area', 'line'].includes(chartType)) { - const visualizationState = visualizationMap[ - XYSuggestion.visualizationId - ]?.switchVisualizationType?.(chartType, XYSuggestion?.visualizationState); - return [ - { - ...XYSuggestion, - visualizationState, - }, - ]; - } - // in case the user asks for another type (except from area, line) check if it exists - // in suggestions and return this instead - if (suggestions.length > 1 && preferredChartType) { - const suggestionFromModel = suggestions.find( - (s) => s.title.includes(preferredChartType) || s.visualizationId.includes(preferredChartType) - ); - if (suggestionFromModel) { - return [suggestionFromModel]; - } - } + const activeVisualization = suggestions[0]; if ( activeVisualization.incomplete || @@ -126,7 +93,46 @@ export const suggestionsApi = ({ visualizationState: activeVisualization.visualizationState, dataViews, }).filter((sug) => !sug.hide && sug.visualizationId !== 'lnsLegacyMetric'); + + // check if there is an XY chart suggested + // if user has requested for a line or area, we want to sligthly change the state + // to return line / area instead of a bar chart + const chartType = preferredChartType?.toLowerCase(); + const XYSuggestion = newSuggestions.find((s) => s.visualizationId === 'lnsXY'); + // a type can be area, line, area_stacked, area_percentage etc + const isAreaOrLine = ['area', 'line'].some((type) => chartType?.includes(type)); + if (XYSuggestion && chartType && isAreaOrLine) { + const visualizationState = visualizationMap[ + XYSuggestion.visualizationId + ]?.switchVisualizationType?.(chartType, XYSuggestion?.visualizationState); + + return [ + { + ...XYSuggestion, + visualizationState, + }, + ]; + } + // in case the user asks for another type (except from area, line) check if it exists + // in suggestions and return this instead const suggestionsList = [activeVisualization, ...newSuggestions]; + if (suggestionsList.length > 1 && preferredChartType) { + const compatibleSuggestion = suggestionsList.find( + (s) => s.title.includes(preferredChartType) || s.visualizationId.includes(preferredChartType) + ); + + if (compatibleSuggestion) { + const suggestion = preferredVisAttributes + ? mergeSuggestionWithVisContext({ + suggestion: compatibleSuggestion, + visAttributes: preferredVisAttributes, + context, + }) + : compatibleSuggestion; + + return [suggestion]; + } + } // if there is no preference from the user, send everything // until we separate the text based suggestions logic from the dataview one, diff --git a/x-pack/plugins/lens/public/lens_suggestions_api.test.ts b/x-pack/plugins/lens/public/lens_suggestions_api/lens_suggestions_api.test.ts similarity index 74% rename from x-pack/plugins/lens/public/lens_suggestions_api.test.ts rename to x-pack/plugins/lens/public/lens_suggestions_api/lens_suggestions_api.test.ts index 80d2f7a71f6ee..e5e60284e4919 100644 --- a/x-pack/plugins/lens/public/lens_suggestions_api.test.ts +++ b/x-pack/plugins/lens/public/lens_suggestions_api/lens_suggestions_api.test.ts @@ -6,9 +6,11 @@ */ import type { DataView } from '@kbn/data-views-plugin/public'; import type { DatatableColumn } from '@kbn/expressions-plugin/common'; -import { createMockVisualization, DatasourceMock, createMockDatasource } from './mocks'; -import { DatasourceSuggestion } from './types'; -import { suggestionsApi, ChartType } from './lens_suggestions_api'; +import { ChartType } from '@kbn/visualization-utils'; +import { createMockVisualization, DatasourceMock, createMockDatasource } from '../mocks'; +import { DatasourceSuggestion } from '../types'; +import { suggestionsApi } from '.'; +import type { TypedLensByValueInput } from '../embeddable/embeddable_component'; const generateSuggestion = (state = {}, layerId: string = 'first'): DatasourceSuggestion => ({ state, @@ -264,6 +266,9 @@ describe('suggestionsApi', () => { datasourceMap.textBased.getDatasourceSuggestionsForVisualizeField.mockReturnValue([ generateSuggestion(), ]); + datasourceMap.textBased.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ + generateSuggestion(), + ]); const context = { dataViewSpec: { id: 'index1', @@ -284,8 +289,7 @@ describe('suggestionsApi', () => { preferredChartType: ChartType.Line, }); expect(suggestions?.length).toEqual(1); - expect(suggestions?.[0]).toMatchInlineSnapshot( - ` + expect(suggestions?.[0]).toMatchInlineSnapshot(` Object { "changeType": "unchanged", "columns": 0, @@ -302,8 +306,111 @@ describe('suggestionsApi', () => { "preferredSeriesType": "line", }, } - ` - ); + `); + }); + + test('returns the suggestion with the preferred attributes ', async () => { + const dataView = { id: 'index1' } as unknown as DataView; + const visualizationMap = { + lnsXY: { + ...mockVis, + switchVisualizationType(seriesType: string, state: unknown) { + return { + ...(state as Record), + preferredSeriesType: seriesType, + }; + }, + getSuggestions: () => [ + { + score: 0.8, + title: 'bar', + state: { + preferredSeriesType: 'bar_stacked', + legend: { + isVisible: true, + position: 'right', + }, + }, + previewIcon: 'empty', + visualizationId: 'lnsXY', + }, + { + score: 0.8, + title: 'Test2', + state: {}, + previewIcon: 'empty', + }, + { + score: 0.8, + title: 'Test2', + state: {}, + previewIcon: 'empty', + incomplete: true, + }, + ], + }, + }; + datasourceMap.textBased.getDatasourceSuggestionsForVisualizeField.mockReturnValue([ + generateSuggestion(), + ]); + datasourceMap.textBased.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ + generateSuggestion(), + ]); + const context = { + dataViewSpec: { + id: 'index1', + title: 'index1', + name: 'DataView', + }, + fieldName: '', + textBasedColumns: textBasedQueryColumns, + query: { + esql: 'FROM "index1" | keep field1, field2', + }, + }; + const suggestions = suggestionsApi({ + context, + dataView, + datasourceMap, + visualizationMap, + preferredChartType: ChartType.XY, + preferredVisAttributes: { + visualizationType: 'lnsXY', + state: { + visualization: { + preferredSeriesType: 'bar_stacked', + legend: { + isVisible: false, + position: 'left', + }, + }, + datasourceStates: { textBased: { layers: {} } }, + }, + } as unknown as TypedLensByValueInput['attributes'], + }); + expect(suggestions?.length).toEqual(1); + expect(suggestions?.[0]).toMatchInlineSnapshot(` + Object { + "changeType": "unchanged", + "columns": 0, + "datasourceId": "textBased", + "datasourceState": Object { + "layers": Object {}, + }, + "keptLayerIds": Array [], + "previewIcon": "empty", + "score": 0.8, + "title": undefined, + "visualizationId": "lnsXY", + "visualizationState": Object { + "legend": Object { + "isVisible": false, + "position": "left", + }, + "preferredSeriesType": "bar_stacked", + }, + } + `); }); test('filters out the suggestion if exists on excludedVisualizations', async () => { diff --git a/x-pack/plugins/lens/public/lens_suggestions_api/readme.md b/x-pack/plugins/lens/public/lens_suggestions_api/readme.md new file mode 100644 index 0000000000000..5a9bbef55d32a --- /dev/null +++ b/x-pack/plugins/lens/public/lens_suggestions_api/readme.md @@ -0,0 +1,77 @@ +# Lens Suggestions API + +This document provides an overview of the Lens Suggestions API. It is used mostly for suggesting ES|QL charts based on an ES|QL query. It is used by the observability assistant, Discover and Dashboards ES|QL charts. + +## Overview + +The Lens Suggestions API is designed to provide suggestions for visualizations based on a given ES|QL query. It helps users to quickly find the most relevant visualizations for their data. + +## Getting Started + +To use the Lens Suggestions API, you need to import it from the Lens plugin: + +```typescript +import useAsync from 'react-use/lib/useAsync'; + +const lensHelpersAsync = useAsync(() => { + return lensService?.stateHelperApi() ?? Promise.resolve(null); + }, [lensService]); + + if (lensHelpersAsync.value) { + const suggestionsApi = lensHelpersAsync.value.suggestions; + } +``` + +## The api + +The api returns an array of suggestions. + +#### Parameters + + dataView: DataView; + visualizationMap?: VisualizationMap; + datasourceMap?: DatasourceMap; + excludedVisualizations?: string[]; + preferredChartType?: ChartType; + preferredVisAttributes?: TypedLensByValueInput['attributes']; + +- `context`: The context as descibed by the VisualizeFieldContext. +- `dataView`: The dataView, can be an adhoc one too. For ES|QL you can create a dataview like this + +```typescript +const indexName = (await getIndexForESQLQuery({ dataViews })) ?? '*'; +const dataView = await getESQLAdHocDataview(`from ${indexName}`, dataViews); +``` +Optional parameters: +- `preferredChartType`: Use this if you want the suggestions api to prioritize a specific suggestion type. +- `preferredVisAttributes`: Use this with the preferredChartType if you want to prioritize a specific suggestion type with a non-default visualization state. + +#### Returns + +An array of suggestion objects + +## Example Usage + +```typescript +const abc = new AbortController(); + +const columns = await getESQLQueryColumns({ + esqlQuery, + search: dataService.search.search, + signal: abc.signal, + timeRange: dataService.query.timefilter.timefilter.getAbsoluteTime(), +}); + +const context = { + dataViewSpec: dataView?.toSpec(false), + fieldName: '', + textBasedColumns: columns, + query: { esql: esqlQuery }, +}; + +const chartSuggestions = lensHelpersAsync.value.suggestions(context, dataView); + +suggestions.forEach(suggestion => { + console.log(`Suggestion: ${suggestion.title}, Score: ${suggestion.score}`); +}); +``` \ No newline at end of file diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index b2293ea43b109..3145606abaf6c 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -62,6 +62,7 @@ import { ContentManagementPublicStart, } from '@kbn/content-management-plugin/public'; import { i18n } from '@kbn/i18n'; +import type { ChartType } from '@kbn/visualization-utils'; import type { ServerlessPluginStart } from '@kbn/serverless/public'; import { LicensingPluginStart } from '@kbn/licensing-plugin/public'; import type { EditorFrameService as EditorFrameServiceType } from './editor_frame_service'; @@ -137,7 +138,7 @@ import { } from '../common/content_management'; import type { EditLensConfigurationProps } from './app_plugin/shared/edit_on_the_fly/get_edit_lens_configuration'; import { savedObjectToEmbeddableAttributes } from './lens_attribute_service'; -import { ChartType } from './lens_suggestions_api'; +import type { TypedLensByValueInput } from './embeddable/embeddable_component'; export type { SaveProps } from './app_plugin'; @@ -281,7 +282,8 @@ export type LensSuggestionsApi = ( context: VisualizeFieldContext | VisualizeEditorContext, dataViews: DataView, excludedVisualizations?: string[], - preferredChartType?: ChartType + preferredChartType?: ChartType, + preferredVisAttributes?: TypedLensByValueInput['attributes'] ) => Suggestion[] | undefined; export class LensPlugin { @@ -713,7 +715,13 @@ export class LensPlugin { return { formula: createFormulaPublicApi(), chartInfo: createChartInfoApi(startDependencies.dataViews, this.editorFrameService), - suggestions: (context, dataView, excludedVisualizations, preferredChartType) => { + suggestions: ( + context, + dataView, + excludedVisualizations, + preferredChartType, + preferredVisAttributes + ) => { return suggestionsApi({ datasourceMap, visualizationMap, @@ -721,6 +729,7 @@ export class LensPlugin { dataView, excludedVisualizations, preferredChartType, + preferredVisAttributes, }); }, }; diff --git a/x-pack/plugins/lens/public/shared_components/axis/extent/axis_extent_settings.tsx b/x-pack/plugins/lens/public/shared_components/axis/extent/axis_extent_settings.tsx index 27a03b49e8e7f..ef0a17fe8353c 100644 --- a/x-pack/plugins/lens/public/shared_components/axis/extent/axis_extent_settings.tsx +++ b/x-pack/plugins/lens/public/shared_components/axis/extent/axis_extent_settings.tsx @@ -111,7 +111,7 @@ export function AxisBoundsControl({ label={i18n.translate('xpack.lens.fullExtent.niceValues', { defaultMessage: 'Round to nice values', })} - display="columnCompressedSwitch" + display="columnCompressed" fullWidth > return ( <> ) /> = ({ label={i18n.translate('xpack.lens.xyChart.missingValuesStyle', { defaultMessage: 'Show as dotted line', })} - display="columnCompressedSwitch" + display="columnCompressed" > { expect((suggestions[0].state.layers[0] as XYDataLayerConfig).seriesType).toEqual('line'); }); - test('suggests line if changeType is initial and date column is involved', () => { + test('suggests bar if changeType is initial and date column is involved', () => { const currentState: XYState = { legend: { isVisible: true, position: 'bottom' }, valueLabels: 'hide', @@ -885,8 +885,8 @@ describe('xy_suggestions', () => { expect(suggestions).toHaveLength(1); expect(suggestions[0].hide).toEqual(false); - expect(suggestions[0].state.preferredSeriesType).toEqual('line'); - expect((suggestions[0].state.layers[0] as XYDataLayerConfig).seriesType).toEqual('line'); + expect(suggestions[0].state.preferredSeriesType).toEqual('bar_stacked'); + expect((suggestions[0].state.layers[0] as XYDataLayerConfig).seriesType).toEqual('bar_stacked'); }); test('makes a visible seriesType suggestion for unchanged table without split', () => { diff --git a/x-pack/plugins/lens/public/visualizations/xy/xy_suggestions.ts b/x-pack/plugins/lens/public/visualizations/xy/xy_suggestions.ts index 5efaf4d8c949e..f13bcc57e3c84 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/xy_suggestions.ts +++ b/x-pack/plugins/lens/public/visualizations/xy/xy_suggestions.ts @@ -233,9 +233,6 @@ function getSuggestionsForLayer({ allowMixed, }; - if (changeType === 'initial' && xValue?.operation.dataType === 'date') { - return buildSuggestion({ ...options, seriesType: 'line' }); - } // handles the simplest cases, acting as a chart switcher if (!currentState && changeType === 'unchanged') { // Chart switcher needs to include every chart type diff --git a/x-pack/plugins/license_api_guard/kibana.jsonc b/x-pack/plugins/license_api_guard/kibana.jsonc index a8d0ed45794d5..5f03763403d11 100644 --- a/x-pack/plugins/license_api_guard/kibana.jsonc +++ b/x-pack/plugins/license_api_guard/kibana.jsonc @@ -1,14 +1,18 @@ { "type": "plugin", "id": "@kbn/license-api-guard-plugin", - "owner": "@elastic/kibana-management", + "owner": [ + "@elastic/kibana-management" + ], + "group": "platform", + "visibility": "private", "plugin": { "id": "licenseApiGuard", - "server": true, "browser": false, + "server": true, "configPath": [ "xpack", "licenseApiGuard" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/license_management/kibana.jsonc b/x-pack/plugins/license_management/kibana.jsonc index 72f1dc8b824ea..7fe037ed6b702 100644 --- a/x-pack/plugins/license_management/kibana.jsonc +++ b/x-pack/plugins/license_management/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/license-management-plugin", - "owner": "@elastic/kibana-management", + "owner": [ + "@elastic/kibana-management" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "licenseManagement", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "license_management" @@ -29,4 +33,4 @@ "common/constants" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/licensing/kibana.jsonc b/x-pack/plugins/licensing/kibana.jsonc index 91eaa2eb4f38a..0fa69adb14b3a 100644 --- a/x-pack/plugins/licensing/kibana.jsonc +++ b/x-pack/plugins/licensing/kibana.jsonc @@ -1,15 +1,19 @@ { "type": "plugin", "id": "@kbn/licensing-plugin", - "owner": "@elastic/kibana-core", + "owner": [ + "@elastic/kibana-core" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "licensing", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "licensing" ], "requiredBundles": [] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/lists/kibana.jsonc b/x-pack/plugins/lists/kibana.jsonc index 83be4431ce8e8..92b668dc7bb13 100644 --- a/x-pack/plugins/lists/kibana.jsonc +++ b/x-pack/plugins/lists/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/lists-plugin", - "owner": "@elastic/security-detection-engine", + "owner": [ + "@elastic/security-detection-engine" + ], + "group": "security", + "visibility": "private", "plugin": { "id": "lists", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "lists" @@ -18,4 +22,4 @@ "common" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/logstash/kibana.jsonc b/x-pack/plugins/logstash/kibana.jsonc index a59fe4ea61af0..83665c1ed344e 100644 --- a/x-pack/plugins/logstash/kibana.jsonc +++ b/x-pack/plugins/logstash/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/logstash-plugin", - "owner": "@elastic/logstash", + "owner": [ + "@elastic/logstash" + ], + "group": "observability", + "visibility": "private", "plugin": { "id": "logstash", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "logstash" @@ -22,4 +26,4 @@ ], "requiredBundles": [] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/maps/kibana.jsonc b/x-pack/plugins/maps/kibana.jsonc index b042d0250b0c2..8dacbf3c1b7f8 100644 --- a/x-pack/plugins/maps/kibana.jsonc +++ b/x-pack/plugins/maps/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/maps-plugin", - "owner": "@elastic/kibana-gis", + "owner": [ + "@elastic/kibana-presentation" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "maps", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "maps" @@ -50,7 +54,7 @@ "unifiedSearch", "fieldFormats", "esql", - "savedObjects", + "savedObjects" ], "extraPublicDirs": [ "common" diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/__snapshots__/vector_style_editor.test.tsx.snap b/x-pack/plugins/maps/public/classes/styles/vector/components/__snapshots__/vector_style_editor.test.tsx.snap index c0ff79d051e62..691327928eb67 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/__snapshots__/vector_style_editor.test.tsx.snap +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/__snapshots__/vector_style_editor.test.tsx.snap @@ -392,7 +392,7 @@ exports[`should render 1`] = ` size="m" /> {!!styleOptions.useCustomColorRamp ? null : ( - + (props: Props - + <> (props: Props - + <> - + { } return ( - + + + + + + { return !isVectorLayer(props.layer) ? null : ( - + { _renderSwitches() { return mapEmbeddablesSingleton.getMapPanels().map((mapPanel) => { return ( - + { const hasErrors = synchronizedPanels.length === 1 && mapPanel.getIsMovementSynchronized(); return ( void; header: React.ReactElement; headerItems?: React.ReactElement[]; + ariaLabel: string; } export const CollapsiblePanel: FC> = ({ @@ -32,6 +34,7 @@ export const CollapsiblePanel: FC> = ({ children, header, headerItems, + ariaLabel, }) => { const { euiTheme } = useCurrentThemeVars(); @@ -51,6 +54,17 @@ export const CollapsiblePanel: FC> = ({ { diff --git a/x-pack/plugins/ml/public/application/components/color_range_legend/_color_range_legend.scss b/x-pack/plugins/ml/public/application/components/color_range_legend/_color_range_legend.scss deleted file mode 100644 index b164e605a2488..0000000000000 --- a/x-pack/plugins/ml/public/application/components/color_range_legend/_color_range_legend.scss +++ /dev/null @@ -1,18 +0,0 @@ -/* Overrides for d3/svg default styles */ -.mlColorRangeLegend { - text { - @include fontSize($euiFontSizeXS - 2px); - fill: $euiColorDarkShade; - } - - .axis path { - fill: none; - stroke: none; - } - - .axis line { - fill: none; - stroke: $euiColorMediumShade; - shape-rendering: crispEdges; - } -} diff --git a/x-pack/plugins/ml/public/application/components/color_range_legend/_index.scss b/x-pack/plugins/ml/public/application/components/color_range_legend/_index.scss deleted file mode 100644 index c7cd3faac0dcf..0000000000000 --- a/x-pack/plugins/ml/public/application/components/color_range_legend/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import 'color_range_legend'; diff --git a/x-pack/plugins/ml/public/application/components/color_range_legend/color_range_legend.tsx b/x-pack/plugins/ml/public/application/components/color_range_legend/color_range_legend.tsx index f6a301f5eacce..9c121853cf6b4 100644 --- a/x-pack/plugins/ml/public/application/components/color_range_legend/color_range_legend.tsx +++ b/x-pack/plugins/ml/public/application/components/color_range_legend/color_range_legend.tsx @@ -7,12 +7,36 @@ import type { FC } from 'react'; import React, { useEffect, useRef } from 'react'; +import { css } from '@emotion/react'; import d3 from 'd3'; import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { euiThemeVars } from '@kbn/ui-theme'; + const COLOR_RANGE_RESOLUTION = 10; +// Overrides for d3/svg default styles +const cssOverride = css({ + // Override default font size and color for axis + text: { + fontSize: `calc(${euiThemeVars.euiFontSizeXS} - 2px)`, + fill: euiThemeVars.euiColorDarkShade, + }, + // Override default styles for axis lines + '.axis': { + path: { + fill: 'none', + stroke: 'none', + }, + line: { + fill: 'none', + stroke: euiThemeVars.euiColorMediumShade, + shapeRendering: 'crispEdges', + }, + }, +}); + interface ColorRangeLegendProps { colorRange: (d: number) => string; justifyTicks?: boolean; @@ -65,7 +89,6 @@ export const ColorRangeLegend: FC = ({ const wrapper = d3 .select(d3Container.current) - .classed('mlColorRangeLegend', true) .attr('width', wrapperWidth) .attr('height', wrapperHeight) .append('g') @@ -144,7 +167,7 @@ export const ColorRangeLegend: FC = ({ - + ); diff --git a/x-pack/plugins/ml/public/application/components/custom_urls/custom_url_editor/utils.ts b/x-pack/plugins/ml/public/application/components/custom_urls/custom_url_editor/utils.ts index c154abb6f5f69..d2dff81c32621 100644 --- a/x-pack/plugins/ml/public/application/components/custom_urls/custom_url_editor/utils.ts +++ b/x-pack/plugins/ml/public/application/components/custom_urls/custom_url_editor/utils.ts @@ -285,13 +285,12 @@ async function buildDashboardUrlFromSettings( let query; // Override with filters and queries from saved dashboard if they are available. - const searchSourceJSON = dashboard.attributes.kibanaSavedObjectMeta.searchSourceJSON; - if (searchSourceJSON !== undefined) { - const searchSourceData = JSON.parse(searchSourceJSON); - if (Array.isArray(searchSourceData.filter) && searchSourceData.filter.length > 0) { - filters = searchSourceData.filter; + const { searchSource } = dashboard.attributes.kibanaSavedObjectMeta; + if (searchSource !== undefined) { + if (Array.isArray(searchSource.filter) && searchSource.filter.length > 0) { + filters = searchSource.filter; } - query = searchSourceData.query; + query = searchSource.query; } const queryFromEntityFieldNames = buildAppStateQueryParam(queryFieldNames ?? []); diff --git a/x-pack/plugins/ml/public/application/components/job_selector/use_job_selection.ts b/x-pack/plugins/ml/public/application/components/job_selector/use_job_selection.ts index 7fcc1e71e1808..51d1882084d3e 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/use_job_selection.ts +++ b/x-pack/plugins/ml/public/application/components/job_selector/use_job_selection.ts @@ -25,6 +25,24 @@ function getInvalidJobIds(jobs: MlJobWithTimeRange[], ids: string[]) { }); } +// This is useful when redirecting from dashboards where groupIds are treated as jobIds +const getJobIdsFromGroups = (jobIds: string[], jobs: MlJobWithTimeRange[]) => { + const result = new Set(); + + jobIds.forEach((id) => { + const jobsInGroup = jobs.filter((job) => job.groups?.includes(id)); + + if (jobsInGroup.length > 0) { + jobsInGroup.forEach((job) => result.add(job.job_id)); + } else { + // If it's not a group ID, keep it (regardless of whether it's valid or not) + result.add(id); + } + }); + + return Array.from(result); +}; + export interface JobSelection { jobIds: string[]; selectedGroups: string[]; @@ -37,9 +55,9 @@ export const useJobSelection = (jobs: MlJobWithTimeRange[]) => { const getJobSelection = useJobSelectionFlyout(); const tmpIds = useMemo(() => { - const ids = globalState?.ml?.jobIds || []; + const ids = getJobIdsFromGroups(globalState?.ml?.jobIds || [], jobs); return (typeof ids === 'string' ? [ids] : ids).map((id: string) => String(id)); - }, [globalState?.ml?.jobIds]); + }, [globalState?.ml?.jobIds, jobs]); const invalidIds = useMemo(() => { return getInvalidJobIds(jobs, tmpIds); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/_index.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/_index.scss index a043a691c9ef6..9b97275417d50 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/_index.scss +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/_index.scss @@ -1,5 +1,3 @@ -@import 'pages/analytics_exploration/components/regression_exploration/index'; @import 'pages/job_map/components/index'; @import 'pages/analytics_management/components/analytics_list/index'; -@import 'pages/analytics_management/components/create_analytics_button/index'; -@import 'pages/analytics_creation/components/index'; +@import 'pages/analytics_creation/components/index'; \ No newline at end of file diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx index 3212eba8b2ddd..7fd678e98f6fd 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx @@ -576,6 +576,7 @@ export const ConfigurationStepForm: FC = ({ fieldStatsServices={fieldStatsServices} timeRangeMs={indexData.timeRangeMs} dslQuery={jobConfigQuery} + theme={services.theme} > diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_classification_exploration.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_classification_exploration.scss deleted file mode 100644 index c429daaf3c8dc..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_classification_exploration.scss +++ /dev/null @@ -1,47 +0,0 @@ -/* Fixed width so we can align it with the padding of the AUC ROC chart. */ -$labelColumnWidth: 80px; - -/* - Workaround for EuiDataGrid within a Flex Layout, - this tricks browsers treating the width as a px value instead of % -*/ -.mlDataFrameAnalyticsClassification { - width: 100%; -} - -.mlDataFrameAnalyticsClassification__evaluateSectionContent { - padding: 0 5%; -} - -/* - The following two classes are a workaround to avoid having EuiDataGrid in a flex layout - and just uses a legacy approach for a two column layout so we don't break IE11. -*/ -.mlDataFrameAnalyticsClassification__evaluateSectionContent:after { - content: ''; - display: table; - clear: both; -} - -.mlDataFrameAnalyticsClassification__actualLabel { - float: left; - width: $labelColumnWidth; - padding-top: $euiSize * 4; -} - -/* - Gives EuiDataGrid a min-width of 480px, otherwise the columns options will disappear if you hide all columns. -*/ -.mlDataFrameAnalyticsClassification__dataGridMinWidth { - float: left; - min-width: 480px; - width: calc(100% - #{$labelColumnWidth}); - - .euiDataGridRowCell--boolean { - text-transform: none; - } -} - -.mlDataFrameAnalyticsClassification__evaluationMetrics { - width: 60%; -} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/classification_exploration.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/classification_exploration.tsx index 8a198666a9732..fac1c8e76a759 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/classification_exploration.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/classification_exploration.tsx @@ -19,18 +19,16 @@ interface Props { } export const ClassificationExploration: FC = ({ jobId }) => ( -
- -
+ ); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx index 0298a70ba4afa..0d30b0371a027 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx @@ -5,8 +5,6 @@ * 2.0. */ -import './_classification_exploration.scss'; - import type { FC } from 'react'; import React, { useEffect, useState } from 'react'; @@ -291,190 +289,218 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, se } contentPadding={true} content={ - <> - {!isLoadingConfusionMatrix ? ( - <> - {errorConfusionMatrix !== null && } - {errorConfusionMatrix === null && ( + + + {/* Confusion matrix title and table */} + + {!isLoadingConfusionMatrix ? ( <> - - - {getHelpText(dataSubsetTitle)} - - - - - - {/* BEGIN TABLE ELEMENTS */} - -
-
- - - -
-
- {columns.length > 0 && columnsData.length > 0 && ( - <> -
+ {errorConfusionMatrix !== null && } + {errorConfusionMatrix === null && ( + <> + {/* confusion matrix title */} + + + + + {getHelpText(dataSubsetTitle)} + + + + + + + + {/* confusion matrix table */} + + + -
- - - - )} -
-
- {/* END TABLE ELEMENTS */} - - )} - - ) : null} - {/* Accuracy and Recall */} - - - {evaluationQualityMetricsHelpText} - - - - - - - - - - - - - - - - - - {/* AUC ROC Chart */} - - - - - - - - - - - - {Array.isArray(errorRocCurve) && ( - - {errorRocCurve.map((e) => ( - <> - {e} -
+
+ + + {columns.length > 0 && columnsData.length > 0 ? ( + <> + + + + + + + + + + ) : null} + + +
+ - ))} + )} - } - /> - )} - {!isLoadingRocCurve && errorRocCurve === null && rocCurveData.length > 0 && ( -
- + + + {/* evaluation quality metrics */} + + + {/* evaluation title */} + + {evaluationQualityMetricsHelpText} + + + + {/* evaluation stats */} + + + + + + + + + + + + + + + + + {/* AUC ROC Chart */} + + + + + + + + + + + + + + + {Array.isArray(errorRocCurve) && ( + + {errorRocCurve.map((e) => ( + <> + {e} +
+ + ))} + + } + /> + )} + {!isLoadingRocCurve && errorRocCurve === null && rocCurveData.length > 0 && ( +
+ +
)} - /> -
- )} - {isLoadingRocCurve && } - + {isLoadingRocCurve && } + + + + } /> diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_stat.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_stat.tsx index c4ebd2da2ead9..279744e479b80 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_stat.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_stat.tsx @@ -7,7 +7,7 @@ import type { FC } from 'react'; import React from 'react'; -import { EuiStat, EuiIconTip, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiStat, EuiIconTip, EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui'; import { EMPTY_STAT } from '../../../../common/analytics'; interface Props { @@ -24,22 +24,25 @@ export const EvaluateStat: FC = ({ description, dataTestSubj, tooltipContent, -}) => ( - - - - - - - - -); +}) => { + const { + euiTheme: { size }, + } = useEuiTheme(); + + return ( + + + + + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/data_view_prompt/data_view_prompt.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/data_view_prompt/data_view_prompt.tsx index 9f3cfaffc53fb..6de4a59521313 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/data_view_prompt/data_view_prompt.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/data_view_prompt/data_view_prompt.tsx @@ -8,7 +8,7 @@ import type { FC } from 'react'; import React, { useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiLink, EuiText } from '@elastic/eui'; +import { EuiLink, EuiText, useEuiTheme } from '@elastic/eui'; import { useMlKibana } from '../../../../../contexts/kibana'; interface Props { @@ -24,6 +24,10 @@ export const DataViewPrompt: FC = ({ destIndex, color }) => { }, } = useMlKibana(); + const { + euiTheme: { size }, + } = useEuiTheme(); + const canCreateDataView = useMemo( () => capabilities.savedObjectsManagement.edit === true || capabilities.indexPatterns.save === true, @@ -31,36 +35,34 @@ export const DataViewPrompt: FC = ({ destIndex, color }) => { ); return ( - <> - + + + {canCreateDataView === true ? ( + +
+ ), }} /> - {canCreateDataView === true ? ( - - -
- ), - }} - /> - ) : null} - - + ) : null} + ); }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section.scss deleted file mode 100644 index 59fd59d69c4a5..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section.scss +++ /dev/null @@ -1,13 +0,0 @@ -.mlExpandableSection { - padding: $euiSizeS $euiSize; -} - -.mlExpandableSection-contentPadding { - padding: $euiSizeS; -} - -// Make sure the charts tooltip in popover -// have higher zIndex than Eui popover cells -[id^='echTooltipPortal'] { - z-index: $euiZLevel9 !important; -} \ No newline at end of file diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section.tsx index 68de97b30b575..9ba13f16926fe 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section.tsx @@ -5,8 +5,6 @@ * 2.0. */ -import './expandable_section.scss'; - import type { FC, ReactNode } from 'react'; import React, { useCallback, useMemo } from 'react'; @@ -18,6 +16,7 @@ import { EuiSkeletonText, EuiPanel, EuiText, + useEuiTheme, } from '@elastic/eui'; import { getDefaultExplorationPageUrlState, @@ -59,6 +58,10 @@ export const ExpandableSection: FC = ({ docsLink, urlStateKey, }) => { + const { + euiTheme: { size }, + } = useEuiTheme(); + const overrides = useMemo( () => (isExpandedDefault !== undefined ? { [urlStateKey]: isExpandedDefault } : undefined), [urlStateKey, isExpandedDefault] @@ -77,68 +80,65 @@ export const ExpandableSection: FC = ({ return ( -
- - - - - - -

{title}

-
-
-
- {headerItems === HEADER_ITEMS_LOADING && } - {isHeaderItems(headerItems) - ? headerItems.map(({ label, value, id }) => ( - - {label !== undefined && value !== undefined ? ( - - - -

{label}

-
-
- - {value} - -
- ) : null} - {label === undefined ? ( - - - - {value} - - - - ) : null} -
- )) - : null} -
-
- {docsLink !== undefined && {docsLink}} -
-
+ + + + + + +

{title}

+
+
+
+ {headerItems === HEADER_ITEMS_LOADING && } + {isHeaderItems(headerItems) + ? headerItems.map(({ label, value, id }) => ( + + {label !== undefined && value !== undefined ? ( + + + +

{label}

+
+
+ + {value} + +
+ ) : null} + {label === undefined ? ( + + + + {value} + + + + ) : null} +
+ )) + : null} +
+
+ {docsLink !== undefined && {docsLink}} +
{isExpanded && (
{content} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section_results.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section_results.tsx index cc296a42afbae..366debdc3fea3 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section_results.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section_results.tsx @@ -23,6 +23,7 @@ import { EuiSpacer, EuiText, EuiToolTip, + useEuiTheme, } from '@elastic/eui'; import type { DataView } from '@kbn/data-views-plugin/public'; @@ -142,6 +143,9 @@ export const ExpandableSectionResults: FC = ({ notifications: { toasts }, }, } = useMlKibana(); + const { + euiTheme: { size }, + } = useEuiTheme(); const dataViewId = dataView?.id; @@ -333,7 +337,12 @@ export const ExpandableSectionResults: FC = ({ anchorPosition="upCenter" button={ setIsPopoverVisible(!isPopoverVisible)} @@ -371,14 +380,12 @@ export const ExpandableSectionResults: FC = ({ const resultsSectionContent = ( <> {jobConfig !== undefined && needsDestDataView && ( -
- -
+ )} {jobConfig !== undefined && (isRegressionAnalysis(jobConfig.analysis) || isClassificationAnalysis(jobConfig.analysis)) && ( - + {tableItems.length === SEARCH_SIZE ? showingFirstDocs : showingDocs} )} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section_splom.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section_splom.tsx index 22b31abb17661..8ada4cab23410 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section_splom.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section_splom.tsx @@ -5,14 +5,12 @@ * 2.0. */ -import './expandable_section.scss'; - import type { FC } from 'react'; import React from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; +import { EuiHorizontalRule, EuiSpacer, useEuiTheme } from '@elastic/eui'; import type { ScatterplotMatrixProps } from '../../../../../components/scatterplot_matrix'; import { ScatterplotMatrix } from '../../../../../components/scatterplot_matrix'; @@ -20,11 +18,15 @@ import { ScatterplotMatrix } from '../../../../../components/scatterplot_matrix' import { ExpandableSection } from './expandable_section'; export const ExpandableSectionSplom: FC = (props) => { + const { + euiTheme: { size }, + } = useEuiTheme(); + const splomSectionHeaderItems = undefined; const splomSectionContent = ( <> -
+
diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/_index.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/_index.scss deleted file mode 100644 index bb948785d3efa..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import 'regression_exploration'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/_regression_exploration.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/_regression_exploration.scss deleted file mode 100644 index edcc9870ff93b..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/_regression_exploration.scss +++ /dev/null @@ -1,3 +0,0 @@ -.mlDataFrameAnalyticsRegression__evaluateStat { - padding-top: $euiSizeL; -} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_stat.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_stat.tsx index 89582c51b68f0..f56e1b1dc53f7 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_stat.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_stat.tsx @@ -9,7 +9,7 @@ import type { FC } from 'react'; import React from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiStat, EuiIconTip, EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; +import { EuiStat, EuiIconTip, EuiFlexGroup, EuiFlexItem, EuiLink, useEuiTheme } from '@elastic/eui'; import { REGRESSION_STATS } from '../../../../common/analytics'; interface Props { @@ -83,24 +83,25 @@ const tooltipContent = { ), }; -export const EvaluateStat: FC = ({ isLoading, statType, title, dataTestSubj }) => ( - - - - - - {statType !== REGRESSION_STATS.HUBER && ( - = ({ isLoading, statType, title, dataTestSubj }) => { + const { + euiTheme: { size }, + } = useEuiTheme(); + + return ( + + + - )} - - -); + + + {statType !== REGRESSION_STATS.HUBER && } + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_details_pane.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_details_pane.scss deleted file mode 100644 index 5343760b1fe9f..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_details_pane.scss +++ /dev/null @@ -1,8 +0,0 @@ -.mlExpandedRowDetails { - padding: $euiSizeS $euiSize $euiSize; -} - -/* Hide the basic table's header */ -.mlExpandedRowDetailsSection thead { - display: none; -} \ No newline at end of file diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_details_pane.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_details_pane.tsx index 32394ec3d1dd4..2c9a79fbc1c0d 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_details_pane.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_details_pane.tsx @@ -5,10 +5,9 @@ * 2.0. */ -import './expanded_row_details_pane.scss'; - import type { FC, ReactElement } from 'react'; import React from 'react'; +import { i18n } from '@kbn/i18n'; import { EuiBasicTable, @@ -21,6 +20,7 @@ import { EuiText, EuiTitle, EuiSpacer, + useEuiTheme, } from '@elastic/eui'; export interface SectionItem { @@ -106,11 +106,21 @@ export const Section: FC = ({ section }) => { const columns = [ { field: 'title', - name: '', + name: i18n.translate( + 'xpack.ml.dataframe.analytics.expandedRowDetails.analysisStatsHeaderField', + { + defaultMessage: 'Field', + } + ), }, { field: 'description', - name: '', + name: i18n.translate( + 'xpack.ml.dataframe.analytics.expandedRowDetails.analysisStatsHeaderValue', + { + defaultMessage: 'Value', + } + ), render: (v: SectionItem['description']) => <>{v}, }, ]; @@ -126,7 +136,6 @@ export const Section: FC = ({ section }) => { columns={columns} tableCaption={section.title} tableLayout="auto" - className="mlExpandedRowDetailsSection" data-test-subj={`${section.dataTestSubj}-table`} />
@@ -150,12 +159,16 @@ export const ExpandedRowDetailsPane: FC = ({ progress, dataTestSubj, }) => { + const { + euiTheme: { size }, + } = useEuiTheme(); + return ( <> diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/_index.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/_index.scss deleted file mode 100644 index 14ff9de7ded4d..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/_index.scss +++ /dev/null @@ -1,4 +0,0 @@ -.dataFrameAnalyticsCreateSearchDialog { - width: $euiSizeL * 30; - min-height: $euiSizeL * 25; -} diff --git a/x-pack/plugins/ml/public/application/explorer/alerts/alerts_panel.tsx b/x-pack/plugins/ml/public/application/explorer/alerts/alerts_panel.tsx index 4c76ebe628f4f..b9b680a9fbac5 100644 --- a/x-pack/plugins/ml/public/application/explorer/alerts/alerts_panel.tsx +++ b/x-pack/plugins/ml/public/application/explorer/alerts/alerts_panel.tsx @@ -101,6 +101,9 @@ export const AlertsPanel: FC = () => { ); })} + ariaLabel={i18n.translate('xpack.ml.explorer.alertsPanel.ariaLabel', { + defaultMessage: 'alerts panel', + })} > diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/time_range_selector/_time_range_selector.scss b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/time_range_selector/_time_range_selector.scss deleted file mode 100644 index faa69e90ecab5..0000000000000 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/time_range_selector/_time_range_selector.scss +++ /dev/null @@ -1,61 +0,0 @@ -// stylelint-disable selector-no-qualifying-type -// SASSTODO: Looks like this could use a rewrite. Needs selectors -.time-range-selector { - .time-range-section-title { - font-weight: bold; - margin-bottom: $euiSizeS; - } - .time-range-section { - flex: 50%; - padding: 0 $euiSizeS; - border-right: $euiBorderThin; - } - - .tab-stack { - margin-bottom: 0; - padding-left: 0; - list-style: none; - - & > li { - float: none; - position: relative; - display: block; - margin-bottom: $euiSizeXS; - - & > a { - position: relative; - display: block; - padding: $euiSizeS $euiSize; - border-radius: $euiSizeXS; - } - & > a:hover { - background-color: $euiColorLightestShade; - } - .body { - display: none; - } - } - & > li.active { - & > a { - color: $euiColorEmptyShade; - background-color: $euiColorPrimary; - - } - .body { - display: block; - } - } - & > li.has-body.active { - & > a { - border-radius: $euiBorderRadius $euiBorderRadius 0 0; - } - .react-datepicker { - border-radius: 0 0 $euiBorderRadius $euiBorderRadius; - border-top: none; - } - } - } - .time-range-section:last-child { - border-right: none; - } -} diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/time_range_selector/time_range_selector.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/time_range_selector/time_range_selector.js index af3a4d22c1e7e..a6889c745f763 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/time_range_selector/time_range_selector.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/time_range_selector/time_range_selector.js @@ -5,7 +5,6 @@ * 2.0. */ -import './_time_range_selector.scss'; import PropTypes from 'prop-types'; import React, { Component, useState, useEffect } from 'react'; @@ -16,6 +15,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { TIME_FORMAT } from '@kbn/ml-date-utils'; import { ManagedJobsWarningCallout } from '../../confirm_modals/managed_jobs_warning_callout'; +import { TimeRangeSelectorWrapper } from './time_range_selector_wrapper'; export class TimeRangeSelector extends Component { constructor(props) { @@ -166,7 +166,7 @@ export class TimeRangeSelector extends Component { render() { const { startItems, endItems } = this.getTabItems(); return ( -
+ {this.props.hasManagedJob === true && this.state.endTab !== 0 ? ( <> -
+ ); } } diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/time_range_selector/time_range_selector_wrapper.tsx b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/time_range_selector/time_range_selector_wrapper.tsx new file mode 100644 index 0000000000000..fed58a975eabb --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/time_range_selector/time_range_selector_wrapper.tsx @@ -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 type { FC, PropsWithChildren } from 'react'; +import React from 'react'; +import { useEuiTheme } from '@elastic/eui'; + +export const TimeRangeSelectorWrapper: FC = ({ children }) => { + const { euiTheme } = useEuiTheme(); + const style = { + '.time-range-section-title': { + fontWeight: 'bold', + marginBottom: euiTheme.size.s, + }, + '.time-range-section': { + flex: '50%', + padding: `0 ${euiTheme.size.s}`, + borderRight: euiTheme.border.thin, + }, + + '.tab-stack': { + marginBottom: 0, + paddingLeft: 0, + listStyle: 'none', + + '& > li': { + float: 'none', + position: 'relative', + display: 'block', + marginBottom: euiTheme.size.xs, + + '& > a': { + position: 'relative', + display: 'block', + padding: `${euiTheme.size.s} ${euiTheme.size.base}`, + borderRadius: euiTheme.border.radius.medium, + }, + '& > a:hover': { + backgroundColor: euiTheme.colors.lightestShade, + }, + '.body': { + display: 'none', + }, + }, + '& > li.active': { + '& > a': { + color: euiTheme.colors.emptyShade, + backgroundColor: euiTheme.colors.primary, + }, + '.body': { + display: 'block', + '.euiFieldText': { + borderRadius: `0 0 ${euiTheme.border.radius.medium} ${euiTheme.border.radius.medium}`, + }, + }, + }, + '& > li.has-body.active': { + '& > a': { + borderRadius: `${euiTheme.border.radius.medium} ${euiTheme.border.radius.medium} 0 0`, + }, + '.react-datepicker': { + borderRadius: `0 0 ${euiTheme.border.radius.medium} ${euiTheme.border.radius.medium}`, + borderTop: 'none', + }, + }, + }, + '.time-range-section:last-child': { + borderRight: 'none', + }, + }; + + // @ts-expect-error style object strings cause a type error + return
{children}
; +}; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/function_help.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/function_help.tsx index a72380828a6db..e93b446d486ad 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/function_help.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/function_help.tsx @@ -28,7 +28,18 @@ export const FunctionHelpPopover = memo(() => { const onHelpClick = () => setIsHelpOpen((prevIsHelpOpen) => !prevIsHelpOpen); const closeHelp = () => setIsHelpOpen(false); - const helpButton = ; + const helpButton = ( + + ); const columns = [ { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/detector_title/detector_title.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/detector_title/detector_title.tsx index d153ee2bf2842..439bdc0c4e2f5 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/detector_title/detector_title.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/detector_title/detector_title.tsx @@ -51,7 +51,12 @@ export const DetectorTitle: FC> = ({ onClick={() => deleteDetector(index)} iconType="cross" size="s" - aria-label="Next" + aria-label={i18n.translate( + 'xpack.ml.newJob.wizard.pickFieldsStep.nextButtonAriaLabel', + { + defaultMessage: 'Next', + } + )} /> )} diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_cards/split_cards.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_cards/split_cards.tsx index 7966a73c85faa..d09791941a379 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_cards/split_cards.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_cards/split_cards.tsx @@ -8,10 +8,16 @@ import type { FC, PropsWithChildren } from 'react'; import React, { memo, Fragment } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiHorizontalRule, + EuiSpacer, + useEuiTheme, +} from '@elastic/eui'; import type { SplitField } from '@kbn/ml-anomaly-utils'; import { JOB_TYPE } from '../../../../../../../../../common/constants/new_job'; -import './style.scss'; interface Props { fieldValues: string[]; @@ -28,8 +34,14 @@ interface Panel { export const SplitCards: FC> = memo( ({ fieldValues, splitField, children, numberOfDetectors, jobType, animate = false }) => { + const { euiTheme } = useEuiTheme(); const panels: Panel[] = []; + const splitCardStyle = { + border: euiTheme.border.thin, + paddingTop: euiTheme.size.xs, + }; + function storePanels(panel: HTMLDivElement | null, marginBottom: number) { if (panel !== null) { if (animate === false) { @@ -70,14 +82,10 @@ export const SplitCards: FC> = memo( ...(animate ? { transition: 'margin 0.5s' } : {}), }; return ( -
storePanels(ref, marginBottom)} style={style}> - +
storePanels(ref, marginBottom)} css={style}> +
{fieldName} @@ -97,7 +105,7 @@ export const SplitCards: FC> = memo( {(jobType === JOB_TYPE.MULTI_METRIC || jobType === JOB_TYPE.GEO) && (
> = memo( )} {getBackPanels()} - +
{fieldValues[0]} diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_cards/style.scss b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_cards/style.scss deleted file mode 100644 index b6b4be7ab5c9d..0000000000000 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_cards/style.scss +++ /dev/null @@ -1,4 +0,0 @@ -.mlPickFields__splitCard { - padding-top: $euiSizeXS; - border: $euiBorderThin; -} diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/new_job/wizard_steps.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/new_job/wizard_steps.tsx index 6c4600be5d25e..b44c523bc57cf 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/new_job/wizard_steps.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/new_job/wizard_steps.tsx @@ -123,6 +123,7 @@ export const WizardSteps: FC = ({ currentStep, setCurrentStep }) => { fieldStatsServices={fieldStatsServices} timeRangeMs={timeRangeMs} dslQuery={jobCreator.query} + theme={services.theme} > <> 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 f218030c65ad3..a717995d4ee14 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 @@ -316,6 +316,16 @@ export const ModelsList: FC = ({ }; }); }); + + setItemIdToExpandedRowMap((prev) => { + // Refresh expanded rows + return Object.fromEntries( + Object.keys(prev).map((modelId) => { + const item = resultItems.find((i) => i.model_id === modelId); + return item ? [modelId, ] : []; + }) + ); + }); } catch (error) { displayErrorToast( error, @@ -947,6 +957,14 @@ export const ModelsList: FC = ({ } }); + setItemIdToExpandedRowMap((prev) => { + const newMap = { ...prev }; + modelsToDelete.forEach((model) => { + delete newMap[model.model_id]; + }); + return newMap; + }); + setModelsToDelete([]); if (refreshList) { diff --git a/x-pack/plugins/ml/public/application/overview/components/analytics_panel/analytics_panel.tsx b/x-pack/plugins/ml/public/application/overview/components/analytics_panel/analytics_panel.tsx index c94f594f548c5..176633ecfd3a2 100644 --- a/x-pack/plugins/ml/public/application/overview/components/analytics_panel/analytics_panel.tsx +++ b/x-pack/plugins/ml/public/application/overview/components/analytics_panel/analytics_panel.tsx @@ -119,6 +119,9 @@ export const AnalyticsPanel: FC = ({ setLazyJobCount }) => { })} , ]} + ariaLabel={i18n.translate('xpack.ml.overview.analyticsListPanel.ariaLabel', { + defaultMessage: 'data frame analytics panel', + })} > {noDFAJobs ? : null} diff --git a/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/anomaly_detection_panel.tsx b/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/anomaly_detection_panel.tsx index 5e17304fff1c4..42aca6f330a91 100644 --- a/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/anomaly_detection_panel.tsx +++ b/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/anomaly_detection_panel.tsx @@ -209,6 +209,9 @@ export const AnomalyDetectionPanel: FC = ({ anomalyTimelineService, setLa })} , ]} + ariaLabel={i18n.translate('xpack.ml.overview.adJobsPanel.ariaLabel', { + defaultMessage: 'anomaly detection panel', + })} > {noAdJobs ? : null} diff --git a/x-pack/plugins/ml/public/application/overview/overview_page.tsx b/x-pack/plugins/ml/public/application/overview/overview_page.tsx index c66bc6e1ea7e4..57e453b15af73 100644 --- a/x-pack/plugins/ml/public/application/overview/overview_page.tsx +++ b/x-pack/plugins/ml/public/application/overview/overview_page.tsx @@ -112,6 +112,9 @@ export const OverviewPage: FC = () => { })} , ]} + ariaLabel={i18n.translate('xpack.ml.overview.nodesPanel.ariaLabel', { + defaultMessage: 'overview panel', + })} > diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts index f69e60453bfd4..3552b8f006091 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts @@ -11,7 +11,7 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { RuntimeMappings } from '@kbn/ml-runtime-field-utils'; -import { isNumber } from 'lodash'; +import { chunk, isNumber } from 'lodash'; import { ML_INTERNAL_BASE_PATH } from '../../../../common/constants/app'; import type { MlServerDefaults, @@ -27,7 +27,6 @@ import type { JobStats, Datafeed, CombinedJob, - Detector, AnalysisConfig, ModelSnapshot, IndicesOptions, @@ -350,15 +349,6 @@ export function mlApiProvider(httpService: HttpService) { }); }, - validateDetector({ detector }: { detector: Detector }) { - const body = JSON.stringify(detector); - return httpService.http({ - path: `${ML_INTERNAL_BASE_PATH}/anomaly_detectors/_validate/detector`, - method: 'POST', - body, - }); - }, - forecast({ jobId, duration, @@ -397,13 +387,13 @@ export function mlApiProvider(httpService: HttpService) { end, overallScore, }: { - jobId: string; + jobId: string[]; topN: string; bucketSpan: string; start: number; end: number; overallScore?: number; - }) { + }): Promise { const body = JSON.stringify({ topN, bucketSpan, @@ -411,11 +401,31 @@ export function mlApiProvider(httpService: HttpService) { end, ...(overallScore ? { overall_score: overallScore } : {}), }); - return httpService.http({ - path: `${ML_INTERNAL_BASE_PATH}/anomaly_detectors/${jobId}/results/overall_buckets`, - method: 'POST', - body, - version: '1', + + // Max permitted job_id is 64 characters, so we can fit around 30 jobs per request + const maxJobsPerRequest = 30; + + return Promise.all( + chunk(jobId, maxJobsPerRequest).map((jobIdsChunk) => { + return httpService.http({ + path: `${ML_INTERNAL_BASE_PATH}/anomaly_detectors/${jobIdsChunk.join( + ',' + )}/results/overall_buckets`, + method: 'POST', + body, + version: '1', + }); + }) + ).then((responses) => { + // Merge responses + return responses.reduce( + (acc, response) => { + acc.count += response.count; + acc.overall_buckets.push(...response.overall_buckets); + return acc; + }, + { count: 0, overall_buckets: [] } + ); }); }, diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js index d78ed1ed6e7fc..f39bab106c643 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js @@ -1370,8 +1370,6 @@ class TimeseriesChartIntl extends Component { .attr('y', -2) .attr('height', contextChartLineTopMargin); - // Draw the brush handles using SVG foreignObject elements. - // Note these are not supported on IE11 and below, so will not appear in IE. const leftHandle = contextGroup .append('foreignObject') .attr('width', 10) diff --git a/x-pack/plugins/ml/server/lib/ml_client/ml_client.ts b/x-pack/plugins/ml/server/lib/ml_client/ml_client.ts index c7bf9ca8bc5d8..aa8bb89c47ea5 100644 --- a/x-pack/plugins/ml/server/lib/ml_client/ml_client.ts +++ b/x-pack/plugins/ml/server/lib/ml_client/ml_client.ts @@ -615,10 +615,6 @@ export function getMlClient( p ); }, - async postData(...p: Parameters) { - await jobIdsCheck('anomaly-detector', p); - return mlClient.postData(...p); - }, async previewDatafeed(...p: Parameters) { await datafeedIdsCheck(p); return mlClient.previewDatafeed(...p); diff --git a/x-pack/plugins/ml/server/lib/ml_client/types.ts b/x-pack/plugins/ml/server/lib/ml_client/types.ts index 93977257cdc22..d610baa92bc53 100644 --- a/x-pack/plugins/ml/server/lib/ml_client/types.ts +++ b/x-pack/plugins/ml/server/lib/ml_client/types.ts @@ -101,7 +101,6 @@ export type MlClientParams = | Parameters | Parameters | Parameters - | Parameters | Parameters | Parameters | Parameters @@ -121,8 +120,7 @@ export type MlClientParams = | Parameters | Parameters | Parameters - | Parameters - | Parameters; + | Parameters; export type MlGetADParams = Parameters | Parameters; diff --git a/x-pack/plugins/ml/server/routes/anomaly_detectors.ts b/x-pack/plugins/ml/server/routes/anomaly_detectors.ts index 8cd9f45a4217e..4c75b7a85556a 100644 --- a/x-pack/plugins/ml/server/routes/anomaly_detectors.ts +++ b/x-pack/plugins/ml/server/routes/anomaly_detectors.ts @@ -349,37 +349,6 @@ export function jobRoutes({ router, routeGuard }: RouteInitialization) { }) ); - router.versioned - .post({ - path: `${ML_INTERNAL_BASE_PATH}/anomaly_detectors/_validate/detector`, - access: 'internal', - options: { - tags: ['access:ml:canCreateJob'], - }, - summary: 'Validates detector', - description: 'Validates specified detector.', - }) - .addVersion( - { - version: '1', - validate: { - request: { - body: schema.any(), - }, - }, - }, - routeGuard.fullLicenseAPIGuard(async ({ mlClient, request, response }) => { - try { - const body = await mlClient.validateDetector({ body: request.body }); - return response.ok({ - body, - }); - } catch (e) { - return response.customError(wrapError(e)); - } - }) - ); - router.versioned .delete({ path: `${ML_INTERNAL_BASE_PATH}/anomaly_detectors/{jobId}/_forecast/{forecastId}`, diff --git a/x-pack/plugins/ml/server/routes/system.ts b/x-pack/plugins/ml/server/routes/system.ts index bf4fa3161f5b9..b6765c4b5f16c 100644 --- a/x-pack/plugins/ml/server/routes/system.ts +++ b/x-pack/plugins/ml/server/routes/system.ts @@ -97,6 +97,13 @@ export function systemRoutes( .addVersion( { version: '1', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because permissions will be checked by elasticsearch', + }, + }, validate: false, }, routeGuard.basicLicenseAPIGuard(async ({ mlClient, request, response }) => { diff --git a/x-pack/plugins/monitoring/kibana.jsonc b/x-pack/plugins/monitoring/kibana.jsonc index 6ffcba1496163..51272f995b012 100644 --- a/x-pack/plugins/monitoring/kibana.jsonc +++ b/x-pack/plugins/monitoring/kibana.jsonc @@ -1,12 +1,18 @@ { "type": "plugin", "id": "@kbn/monitoring-plugin", - "owner": "@elastic/stack-monitoring", + "owner": [ + "@elastic/stack-monitoring" + ], + "group": "observability", + "visibility": "private", "plugin": { "id": "monitoring", - "server": true, "browser": true, - "configPath": ["monitoring"], + "server": true, + "configPath": [ + "monitoring" + ], "requiredPlugins": [ "licensing", "features", @@ -30,6 +36,10 @@ "dashboard", "fleet" ], - "requiredBundles": ["kibanaUtils", "alerting", "kibanaReact"] + "requiredBundles": [ + "kibanaUtils", + "alerting", + "kibanaReact" + ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_licenses.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_licenses.ts index 4ea0077c21ba8..98f3932984546 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/get_licenses.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_licenses.ts @@ -7,7 +7,7 @@ import { ElasticsearchClient } from '@kbn/core/server'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { ESLicense } from '@kbn/telemetry-collection-xpack-plugin/server'; +import type { LicenseGetLicenseInformation } from '@elastic/elasticsearch/lib/api/types'; import { INDEX_PATTERN_ELASTICSEARCH, USAGE_FETCH_INTERVAL } from '../../common/constants'; /** @@ -18,7 +18,7 @@ export async function getLicenses( callCluster: ElasticsearchClient, timestamp: number, maxBucketSize: number -): Promise<{ [clusterUuid: string]: ESLicense | undefined }> { +): Promise<{ [clusterUuid: string]: LicenseGetLicenseInformation | undefined }> { const response = await fetchLicenses(callCluster, clusterUuids, timestamp, maxBucketSize); return handleLicenses(response); } @@ -76,7 +76,7 @@ export async function fetchLicenses( export interface ESClusterStatsWithLicense { cluster_uuid: string; type: 'cluster_stats'; - license?: ESLicense; + license?: LicenseGetLicenseInformation; } /** diff --git a/x-pack/plugins/monitoring/tsconfig.json b/x-pack/plugins/monitoring/tsconfig.json index 957b256bd726b..3b78104e65b8c 100644 --- a/x-pack/plugins/monitoring/tsconfig.json +++ b/x-pack/plugins/monitoring/tsconfig.json @@ -19,7 +19,6 @@ "@kbn/features-plugin", "@kbn/infra-plugin", "@kbn/licensing-plugin", - "@kbn/telemetry-collection-xpack-plugin", "@kbn/triggers-actions-ui-plugin", "@kbn/expect", "@kbn/i18n", diff --git a/x-pack/plugins/monitoring_collection/kibana.jsonc b/x-pack/plugins/monitoring_collection/kibana.jsonc index c2df8e9015326..0e779a6f532a5 100644 --- a/x-pack/plugins/monitoring_collection/kibana.jsonc +++ b/x-pack/plugins/monitoring_collection/kibana.jsonc @@ -2,6 +2,8 @@ "type": "plugin", "id": "@kbn/monitoring-collection-plugin", "owner": "@elastic/stack-monitoring", + "group": "platform", + "visibility": "private", "plugin": { "id": "monitoringCollection", "server": true, diff --git a/x-pack/plugins/notifications/kibana.jsonc b/x-pack/plugins/notifications/kibana.jsonc index e223a12dbc793..fad93b4261b55 100644 --- a/x-pack/plugins/notifications/kibana.jsonc +++ b/x-pack/plugins/notifications/kibana.jsonc @@ -1,14 +1,18 @@ { "type": "plugin", "id": "@kbn/notifications-plugin", - "owner": "@elastic/appex-sharedux", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "notifications", - "server": true, "browser": false, + "server": true, "optionalPlugins": [ "actions", "licensing" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/observability_solution/apm/common/entities/types.ts b/x-pack/plugins/observability_solution/apm/common/entities/types.ts deleted file mode 100644 index 9775b1e32eae6..0000000000000 --- a/x-pack/plugins/observability_solution/apm/common/entities/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export enum EntityDataStreamType { - METRICS = 'metrics', - TRACES = 'traces', - LOGS = 'logs', -} diff --git a/x-pack/plugins/observability_solution/apm/common/es_fields/entities.ts b/x-pack/plugins/observability_solution/apm/common/es_fields/entities.ts deleted file mode 100644 index 28e4a3ec79165..0000000000000 --- a/x-pack/plugins/observability_solution/apm/common/es_fields/entities.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const ENTITY_METRICS_LATENCY = 'entity.metrics.latency'; -export const ENTITY_METRICS_LOG_ERROR_RATE = 'entity.metrics.logErrorRate'; -export const ENTITY_METRICS_LOG_RATE = 'entity.metrics.logRate'; -export const ENTITY_METRICS_THROUGHPUT = 'entity.metrics.throughput'; -export const ENTITY_METRICS_FAILED_TRANSACTION_RATE = 'entity.metrics.failedTransactionRate'; diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm.yaml deleted file mode 100644 index d37137302fd21..0000000000000 --- a/x-pack/plugins/observability_solution/apm/docs/openapi/apm.yaml +++ /dev/null @@ -1,186 +0,0 @@ -openapi: 3.0.0 -info: - title: APM UI - version: 1.0.0 -tags: - - name: APM agent keys - description: > - Configure APM agent keys to authorize requests from APM agents to the APM Server. - - name: APM annotations - description: > - Annotate visualizations in the APM app with significant events. - Annotations enable you to easily see how events are impacting the performance of your applications. -paths: - /api/apm/agent_keys: - post: - summary: Create an APM agent key - description: Create a new agent key for APM. - operationId: createAgentKey - tags: - - APM agent keys - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - name: - type: string - privileges: - type: array - items: - type: string - enum: - - event:write - - config_agent:read - responses: - "200": - description: Agent key created successfully - content: - application/json: - schema: - type: object - properties: - api_key: - type: string - expiration: - type: integer - format: int64 - id: - type: string - name: - type: string - encoded: - type: string - /api/apm/services/{serviceName}/annotation/search: - get: - summary: Search for annotations - description: Search for annotations related to a specific service. - operationId: getAnnotation - tags: - - APM annotations - parameters: - - name: serviceName - in: path - required: true - description: The name of the service - schema: - type: string - - name: environment - in: query - required: false - description: The environment to filter annotations by - schema: - type: string - - name: start - in: query - required: false - description: The start date for the search - schema: - type: string - - name: end - in: query - required: false - description: The end date for the search - schema: - type: string - responses: - "200": - description: Successful response - content: - application/json: - schema: - type: object - properties: - annotations: - type: array - items: - type: object - properties: - type: - type: string - enum: - - version - id: - type: string - "@timestamp": - type: number - text: - type: string - /api/apm/services/{serviceName}/annotation: - post: - summary: Create a service annotation - description: Create a new annotation for a specific service. - operationId: createAnnotation - tags: - - APM annotations - parameters: - - name: serviceName - in: path - required: true - description: The name of the service - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - '@timestamp': - type: string - service: - type: object - properties: - version: - type: string - environment: - type: string - message: - type: string - tags: - type: array - items: - type: string - - responses: - '200': - description: Annotation created successfully - content: - application/json: - schema: - type: object - properties: - _id: - type: string - _index: - type: string - _source: - type: object - properties: - annotation: - type: string - tags: - type: array - items: - type: string - message: - type: string - service: - type: object - properties: - name: - type: string - environment: - type: string - version: - type: string - event: - type: object - properties: - created: - type: string - '@timestamp': - type: string diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/README.md b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/README.md index da35d6b891239..74b9c6a034821 100644 --- a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/README.md +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/README.md @@ -2,16 +2,26 @@ This directory contains [OpenAPI specifications](https://swagger.io/specification/) for the [APM app API](https://www.elastic.co/guide/en/kibana/current/apm-api.html) in Kibana. -Included: +# OpenAPI (Experimental) -* [Agent Configuration API](https://www.elastic.co/guide/en/kibana/current/agent-config-api.html) -* [Annotation API](https://www.elastic.co/guide/en/kibana/current/apm-annotation-api.html) +The current self-contained spec file is available as `bundled.json` or `bundled.yaml` and can be used for online tools like those found at . +This spec is experimental and may be incomplete or change later. -Not included: +A guide about the openApi specification can be found at [https://swagger.io/docs/specification/about/](https://swagger.io/docs/specification/about/). -* [APM agent Key API](https://www.elastic.co/guide/en/kibana/current/agent-key-api.html) -* [RUM source map API](https://www.elastic.co/guide/en/kibana/current/rum-sourcemap-api.html) +## The `openapi` folder -The specifications for the included APIs are in the apm.yaml file in this directory. +* `entrypoint.yaml` is the overview file which pulls together all the paths and components. +* [Paths](paths/README.md): Defines each endpoint. A path can have one operation per http method. +* [Components](components/README.md): Defines reusable components. -These specifications are manually written. The missing ones will be included in the future. +## Tools + +Generate the `bundled` files by running the following commands: + +```bash +npx @redocly/cli bundle entrypoint.yaml --output bundled.yaml --ext yaml +npx @redocly/cli bundle entrypoint.yaml --output bundled.json --ext json +``` + +Then join these files with the rest of the Kibana APIs per `oas_docs/README.md` diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/bundled.json b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/bundled.json new file mode 100644 index 0000000000000..9fdcc3cdb6294 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/bundled.json @@ -0,0 +1,1827 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "APM UI", + "version": "1.0.0" + }, + "tags": [ + { + "name": "APM agent keys", + "description": "Configure APM agent keys to authorize requests from APM agents to the APM Server.\n" + }, + { + "name": "APM agent configuration", + "description": "Adjust APM agent configuration without need to redeploy your application.\n" + }, + { + "name": "APM sourcemaps", + "description": "Configure APM source maps." + }, + { + "name": "APM annotations", + "description": "Annotate visualizations in the APM app with significant events. Annotations enable you to easily see how events are impacting the performance of your applications.\n" + }, + { + "name": "APM server schema", + "description": "Create APM fleet server schema." + } + ], + "paths": { + "/api/apm/agent_keys": { + "post": { + "summary": "Create an APM agent key", + "description": "Create a new agent key for APM.", + "operationId": "createAgentKey", + "tags": [ + "APM agent keys" + ], + "parameters": [ + { + "$ref": "#/components/parameters/elastic_api_version" + }, + { + "$ref": "#/components/parameters/kbn_xsrf" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/agent_keys_object" + } + } + } + }, + "responses": { + "200": { + "description": "Agent key created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/agent_keys_response" + } + } + } + }, + "400": { + "description": "Bad Request response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/400_response" + } + } + } + }, + "401": { + "description": "Unauthorized response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/401_response" + } + } + } + }, + "403": { + "description": "Forbidden response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/403_response" + } + } + } + }, + "500": { + "description": "Internal Server Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/500_response" + } + } + } + } + } + } + }, + "/api/apm/services/{serviceName}/annotation/search": { + "get": { + "summary": "Search for annotations", + "description": "Search for annotations related to a specific service.", + "operationId": "getAnnotation", + "tags": [ + "APM annotations" + ], + "parameters": [ + { + "$ref": "#/components/parameters/elastic_api_version" + }, + { + "name": "serviceName", + "in": "path", + "required": true, + "description": "The name of the service", + "schema": { + "type": "string" + } + }, + { + "name": "environment", + "in": "query", + "required": false, + "description": "The environment to filter annotations by", + "schema": { + "type": "string" + } + }, + { + "name": "start", + "in": "query", + "required": false, + "description": "The start date for the search", + "schema": { + "type": "string" + } + }, + { + "name": "end", + "in": "query", + "required": false, + "description": "The end date for the search", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/annotation_search_response" + } + } + } + }, + "400": { + "description": "Bad Request response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/400_response" + } + } + } + }, + "401": { + "description": "Unauthorized response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/401_response" + } + } + } + }, + "500": { + "description": "Internal Server Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/500_response" + } + } + } + } + } + } + }, + "/api/apm/services/{serviceName}/annotation": { + "post": { + "summary": "Create a service annotation", + "description": "Create a new annotation for a specific service.", + "operationId": "createAnnotation", + "tags": [ + "APM annotations" + ], + "parameters": [ + { + "$ref": "#/components/parameters/elastic_api_version" + }, + { + "$ref": "#/components/parameters/kbn_xsrf" + }, + { + "name": "serviceName", + "in": "path", + "required": true, + "description": "The name of the service", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/create_annotation_object" + } + } + } + }, + "responses": { + "200": { + "description": "Annotation created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/create_annotation_response" + } + } + } + }, + "400": { + "description": "Bad Request response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/400_response" + } + } + } + }, + "401": { + "description": "Unauthorized response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/401_response" + } + } + } + }, + "403": { + "description": "Forbidden response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/403_response" + } + } + } + }, + "404": { + "description": "Not found response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/404_response" + } + } + } + } + } + } + }, + "/api/apm/settings/agent-configuration": { + "get": { + "summary": "Get a list of agent configurations", + "operationId": "getAgentConfigurations", + "tags": [ + "APM agent configuration" + ], + "parameters": [ + { + "$ref": "#/components/parameters/elastic_api_version" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/agent_configurations_response" + } + } + } + }, + "400": { + "description": "Bad Request response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/400_response" + } + } + } + }, + "401": { + "description": "Unauthorized response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/401_response" + } + } + } + }, + "404": { + "description": "Not found response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/404_response" + } + } + } + } + } + }, + "delete": { + "summary": "Delete agent configuration", + "operationId": "deleteAgentConfiguration", + "tags": [ + "APM agent configuration" + ], + "parameters": [ + { + "$ref": "#/components/parameters/elastic_api_version" + }, + { + "$ref": "#/components/parameters/kbn_xsrf" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/service_object" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/delete_agent_configurations_response" + } + } + } + }, + "400": { + "description": "Bad Request response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/400_response" + } + } + } + }, + "401": { + "description": "Unauthorized response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/401_response" + } + } + } + }, + "403": { + "description": "Forbidden response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/403_response" + } + } + } + }, + "404": { + "description": "Not found response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/404_response" + } + } + } + } + } + }, + "put": { + "summary": "Create or update agent configuration", + "operationId": "createUpdateAgentConfiguration", + "tags": [ + "APM agent configuration" + ], + "parameters": [ + { + "$ref": "#/components/parameters/elastic_api_version" + }, + { + "$ref": "#/components/parameters/kbn_xsrf" + }, + { + "name": "overwrite", + "in": "query", + "description": "If the config exists ?overwrite=true is required", + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/agent_configuration_intake_object" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false + } + } + } + }, + "400": { + "description": "Bad Request response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/400_response" + } + } + } + }, + "401": { + "description": "Unauthorized response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/401_response" + } + } + } + }, + "403": { + "description": "Forbidden response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/403_response" + } + } + } + }, + "404": { + "description": "Not found response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/404_response" + } + } + } + } + } + } + }, + "/api/apm/settings/agent-configuration/view": { + "get": { + "summary": "Get single agent configuration", + "operationId": "getSingleAgentConfiguration", + "tags": [ + "APM agent configuration" + ], + "parameters": [ + { + "$ref": "#/components/parameters/elastic_api_version" + }, + { + "name": "name", + "in": "query", + "description": "Service name", + "schema": { + "type": "string" + }, + "example": "node" + }, + { + "name": "environment", + "in": "query", + "description": "Service environment", + "schema": { + "type": "string" + }, + "example": "prod" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/single_agent_configuration_response" + } + } + } + }, + "400": { + "description": "Bad Request response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/400_response" + } + } + } + }, + "401": { + "description": "Unauthorized response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/401_response" + } + } + } + }, + "404": { + "description": "Not found response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/404_response" + } + } + } + } + } + } + }, + "/api/apm/settings/agent-configuration/search": { + "post": { + "summary": "Lookup single agent configuration", + "description": "This endpoint allows to search for single agent configuration and update 'applied_by_agent' field.\n", + "operationId": "searchSingleConfiguration", + "tags": [ + "APM agent configuration" + ], + "parameters": [ + { + "$ref": "#/components/parameters/elastic_api_version" + }, + { + "$ref": "#/components/parameters/kbn_xsrf" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/search_agent_configuration_object" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/search_agent_configuration_response" + } + } + } + }, + "400": { + "description": "Bad Request response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/400_response" + } + } + } + }, + "401": { + "description": "Unauthorized response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/401_response" + } + } + } + }, + "404": { + "description": "Not found response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/404_response" + } + } + } + } + } + } + }, + "/api/apm/settings/agent-configuration/environments": { + "get": { + "summary": "Get environments for service", + "operationId": "getEnvironmentsForService", + "tags": [ + "APM agent configuration" + ], + "parameters": [ + { + "$ref": "#/components/parameters/elastic_api_version" + }, + { + "name": "serviceName", + "in": "query", + "description": "The name of the service", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/service_environments_response" + } + } + } + }, + "400": { + "description": "Bad Request response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/400_response" + } + } + } + }, + "401": { + "description": "Unauthorized response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/401_response" + } + } + } + }, + "404": { + "description": "Not found response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/404_response" + } + } + } + } + } + } + }, + "/api/apm/settings/agent-configuration/agent_name": { + "get": { + "summary": "Get agent name for service", + "description": "Retrieve `agentName` for a service.", + "operationId": "getAgentNameForService", + "tags": [ + "APM agent configuration" + ], + "parameters": [ + { + "$ref": "#/components/parameters/elastic_api_version" + }, + { + "name": "serviceName", + "in": "query", + "description": "The name of the service", + "required": true, + "schema": { + "type": "string" + }, + "example": "node" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/service_agent_name_response" + } + } + } + }, + "400": { + "description": "Bad Request response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/400_response" + } + } + } + }, + "401": { + "description": "Unauthorized response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/401_response" + } + } + } + }, + "404": { + "description": "Not found response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/404_response" + } + } + } + } + } + } + }, + "/api/apm/sourcemaps": { + "get": { + "summary": "Get source maps", + "description": "Returns an array of Fleet artifacts, including source map uploads.", + "operationId": "getSourceMaps", + "tags": [ + "APM sourcemaps" + ], + "parameters": [ + { + "$ref": "#/components/parameters/elastic_api_version" + }, + { + "name": "page", + "in": "query", + "description": "Page number", + "schema": { + "type": "number" + } + }, + { + "name": "perPage", + "in": "query", + "description": "Number of records per page", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/source_maps_response" + } + } + } + }, + "400": { + "description": "Bad Request response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/400_response" + } + } + } + }, + "401": { + "description": "Unauthorized response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/401_response" + } + } + } + }, + "500": { + "description": "Internal Server Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/500_response" + } + } + } + }, + "501": { + "description": "Not Implemented response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/501_response" + } + } + } + } + } + }, + "post": { + "summary": "Upload source map", + "description": "Upload a source map for a specific service and version.", + "operationId": "uploadSourceMap", + "tags": [ + "APM sourcemaps" + ], + "parameters": [ + { + "$ref": "#/components/parameters/elastic_api_version" + }, + { + "$ref": "#/components/parameters/kbn_xsrf" + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/upload_source_map_object" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/upload_source_maps_response" + } + } + } + }, + "400": { + "description": "Bad Request response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/400_response" + } + } + } + }, + "401": { + "description": "Unauthorized response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/401_response" + } + } + } + }, + "403": { + "description": "Forbidden response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/403_response" + } + } + } + }, + "500": { + "description": "Internal Server Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/500_response" + } + } + } + }, + "501": { + "description": "Not Implemented response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/501_response" + } + } + } + } + } + } + }, + "/api/apm/sourcemaps/{id}": { + "delete": { + "summary": "Delete source map", + "description": "Delete a previously uploaded source map.", + "operationId": "deleteSourceMap", + "tags": [ + "APM sourcemaps" + ], + "parameters": [ + { + "$ref": "#/components/parameters/elastic_api_version" + }, + { + "$ref": "#/components/parameters/kbn_xsrf" + }, + { + "name": "id", + "in": "path", + "description": "Source map identifier", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false + } + } + } + }, + "400": { + "description": "Bad Request response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/400_response" + } + } + } + }, + "401": { + "description": "Unauthorized response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/401_response" + } + } + } + }, + "403": { + "description": "Forbidden response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/403_response" + } + } + } + }, + "500": { + "description": "Internal Server Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/500_response" + } + } + } + }, + "501": { + "description": "Not Implemented response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/501_response" + } + } + } + } + } + } + }, + "/api/apm/fleet/apm_server_schema": { + "post": { + "summary": "Save APM server schema", + "operationId": "saveApmServerSchema", + "tags": [ + "APM server schema" + ], + "parameters": [ + { + "$ref": "#/components/parameters/elastic_api_version" + }, + { + "$ref": "#/components/parameters/kbn_xsrf" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "schema": { + "type": "object", + "description": "Schema object", + "additionalProperties": true, + "example": { + "foo": "bar" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false + } + } + } + }, + "400": { + "description": "Bad Request response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/400_response" + } + } + } + }, + "401": { + "description": "Unauthorized response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/401_response" + } + } + } + }, + "403": { + "description": "Forbidden response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/403_response" + } + } + } + }, + "404": { + "description": "Not found response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/404_response" + } + } + } + } + } + } + } + }, + "components": { + "parameters": { + "elastic_api_version": { + "description": "The version of the API to use", + "in": "header", + "name": "elastic-api-version", + "required": true, + "schema": { + "default": "2023-10-31", + "enum": [ + "2023-10-31" + ], + "type": "string" + } + }, + "kbn_xsrf": { + "description": "A required header to protect against CSRF attacks", + "in": "header", + "name": "kbn-xsrf", + "required": true, + "schema": { + "example": "true", + "type": "string" + } + } + }, + "schemas": { + "agent_keys_object": { + "type": "object", + "required": [ + "name", + "privileges" + ], + "properties": { + "name": { + "type": "string", + "description": "Agent name" + }, + "privileges": { + "type": "array", + "description": "Privileges configuration", + "items": { + "type": "string", + "enum": [ + "event:write", + "config_agent:read" + ] + } + } + } + }, + "agent_keys_response": { + "type": "object", + "properties": { + "agentKey": { + "type": "object", + "description": "Agent key", + "required": [ + "id", + "name", + "api_key", + "encoded" + ], + "properties": { + "expiration": { + "type": "integer", + "format": "int64" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "api_key": { + "type": "string" + }, + "encoded": { + "type": "string" + } + } + } + } + }, + "400_response": { + "type": "object", + "properties": { + "statusCode": { + "type": "number", + "example": 400, + "description": "Error status code" + }, + "error": { + "type": "string", + "example": "Not Found", + "description": "Error type" + }, + "message": { + "type": "string", + "example": "Not Found", + "description": "Error message" + } + } + }, + "401_response": { + "type": "object", + "properties": { + "statusCode": { + "type": "number", + "example": 401, + "description": "Error status code" + }, + "error": { + "type": "string", + "example": "Unauthorized", + "description": "Error type" + }, + "message": { + "type": "string", + "description": "Error message" + } + } + }, + "403_response": { + "type": "object", + "properties": { + "statusCode": { + "type": "number", + "example": 403, + "description": "Error status code" + }, + "error": { + "type": "string", + "example": "Forbidden", + "description": "Error type" + }, + "message": { + "type": "string", + "description": "Error message" + } + } + }, + "500_response": { + "type": "object", + "properties": { + "statusCode": { + "type": "number", + "example": 500, + "description": "Error status code" + }, + "error": { + "type": "string", + "example": "Internal Server Error", + "description": "Error type" + }, + "message": { + "type": "string", + "description": "Error message" + } + } + }, + "annotation_search_response": { + "type": "object", + "properties": { + "annotations": { + "type": "array", + "description": "Annotations", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "version" + ] + }, + "id": { + "type": "string" + }, + "@timestamp": { + "type": "number" + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "create_annotation_object": { + "type": "object", + "required": [ + "@timestamp", + "service" + ], + "properties": { + "@timestamp": { + "type": "string", + "description": "Timestamp" + }, + "service": { + "type": "object", + "description": "Service", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "string" + }, + "environment": { + "type": "string" + } + } + }, + "message": { + "type": "string", + "description": "Message" + }, + "tags": { + "type": "array", + "description": "Tags", + "items": { + "type": "string" + } + } + } + }, + "create_annotation_response": { + "type": "object", + "properties": { + "_id": { + "type": "string", + "description": "Identifier" + }, + "_index": { + "type": "string", + "description": "Index" + }, + "_source": { + "type": "object", + "description": "Response", + "properties": { + "annotation": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "message": { + "type": "string" + }, + "service": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "event": { + "type": "object", + "properties": { + "created": { + "type": "string" + } + } + }, + "@timestamp": { + "type": "string" + } + } + } + } + }, + "404_response": { + "type": "object", + "properties": { + "statusCode": { + "type": "number", + "example": 404, + "description": "Error status code" + }, + "error": { + "type": "string", + "example": "Not Found", + "description": "Error type" + }, + "message": { + "type": "string", + "example": "Not Found", + "description": "Error message" + } + } + }, + "service_object": { + "type": "object", + "description": "Service", + "properties": { + "name": { + "type": "string", + "example": "node", + "description": "Name" + }, + "environment": { + "type": "string", + "example": "prod", + "description": "Environment" + } + } + }, + "settings_object": { + "type": "object", + "description": "Agent configuration settings", + "additionalProperties": { + "type": "string" + } + }, + "agent_configuration_object": { + "type": "object", + "required": [ + "service", + "settings", + "@timestamp", + "etag" + ], + "description": "Agent configuration", + "properties": { + "agent_name": { + "type": "string", + "description": "Agent name" + }, + "service": { + "$ref": "#/components/schemas/service_object" + }, + "settings": { + "$ref": "#/components/schemas/settings_object" + }, + "@timestamp": { + "type": "number", + "example": 1730194190636, + "description": "Timestamp" + }, + "applied_by_agent": { + "type": "boolean", + "example": true, + "description": "Applied by agent" + }, + "etag": { + "type": "string", + "example": "0bc3b5ebf18fba8163fe4c96f491e3767a358f85", + "description": "Etag" + } + } + }, + "agent_configurations_response": { + "type": "object", + "properties": { + "configurations": { + "type": "array", + "description": "Agent configuration", + "items": { + "$ref": "#/components/schemas/agent_configuration_object" + } + } + } + }, + "agent_configuration_intake_object": { + "type": "object", + "required": [ + "service", + "settings" + ], + "properties": { + "agent_name": { + "type": "string", + "description": "Agent name" + }, + "service": { + "$ref": "#/components/schemas/service_object" + }, + "settings": { + "$ref": "#/components/schemas/settings_object" + } + } + }, + "delete_agent_configurations_response": { + "type": "object", + "properties": { + "result": { + "type": "string", + "description": "Result" + } + } + }, + "single_agent_configuration_response": { + "allOf": [ + { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + } + } + }, + { + "$ref": "#/components/schemas/agent_configuration_object" + } + ] + }, + "search_agent_configuration_object": { + "type": "object", + "required": [ + "service" + ], + "properties": { + "service": { + "$ref": "#/components/schemas/service_object" + }, + "etag": { + "type": "string", + "description": "If etags match then `applied_by_agent` field will be set to `true`", + "example": "0bc3b5ebf18fba8163fe4c96f491e3767a358f85" + }, + "mark_as_applied_by_agent": { + "type": "boolean", + "description": "`markAsAppliedByAgent=true` means \"force setting it to true regardless of etag\".\nThis is needed for Jaeger agent that doesn't have etags\n" + } + } + }, + "search_agent_configuration_response": { + "type": "object", + "properties": { + "_index": { + "type": "string", + "description": "Index" + }, + "_id": { + "type": "string", + "description": "Identifier" + }, + "_score": { + "type": "number", + "description": "Score" + }, + "_source": { + "$ref": "#/components/schemas/agent_configuration_object" + } + } + }, + "service_environment_object": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "ALL_OPTION_VALUE", + "description": "Service environment name" + }, + "alreadyConfigured": { + "type": "boolean", + "description": "Already configured" + } + } + }, + "service_environments_response": { + "type": "object", + "properties": { + "environments": { + "type": "array", + "description": "Service environment list", + "items": { + "$ref": "#/components/schemas/service_environment_object" + } + } + } + }, + "service_agent_name_response": { + "type": "object", + "properties": { + "agentName": { + "type": "string", + "description": "Agent name", + "example": "nodejs" + } + } + }, + "base_source_map_object": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Type" + }, + "identifier": { + "type": "string", + "description": "Identifier" + }, + "relative_url": { + "type": "string", + "description": "Relative URL" + }, + "created": { + "type": "string", + "description": "Created date" + }, + "id": { + "type": "string", + "description": "Identifier" + }, + "compressionAlgorithm": { + "type": "string", + "description": "Compression Algorithm" + }, + "decodedSha256": { + "type": "string", + "description": "Decoded SHA-256" + }, + "decodedSize": { + "type": "number", + "description": "Decoded size" + }, + "encodedSha256": { + "type": "string", + "description": "Encoded SHA-256" + }, + "encodedSize": { + "type": "number", + "description": "Encoded size" + }, + "encryptionAlgorithm": { + "type": "string", + "description": "Encryption Algorithm" + }, + "packageName": { + "type": "string", + "description": "Package name" + } + } + }, + "source_maps_response": { + "type": "object", + "properties": { + "artifacts": { + "type": "array", + "description": "Artifacts", + "items": { + "allOf": [ + { + "type": "object", + "properties": { + "body": { + "type": "object", + "properties": { + "serviceName": { + "type": "string" + }, + "serviceVersion": { + "type": "string" + }, + "bundleFilepath": { + "type": "string" + }, + "sourceMap": { + "type": "object", + "properties": { + "version": { + "type": "number" + }, + "file": { + "type": "string" + }, + "sources": { + "type": "array", + "items": { + "type": "string" + } + }, + "sourcesContent": { + "type": "array", + "items": { + "type": "string" + } + }, + "mappings": { + "type": "string" + }, + "sourceRoot": { + "type": "string" + } + } + } + } + } + } + }, + { + "$ref": "#/components/schemas/base_source_map_object" + } + ] + } + } + } + }, + "501_response": { + "type": "object", + "properties": { + "statusCode": { + "type": "number", + "example": 501, + "description": "Error status code" + }, + "error": { + "type": "string", + "example": "Not Implemented", + "description": "Error type" + }, + "message": { + "type": "string", + "example": "Not Implemented", + "description": "Error message" + } + } + }, + "upload_source_map_object": { + "type": "object", + "required": [ + "service_name", + "service_version", + "bundle_filepath", + "sourcemap" + ], + "properties": { + "service_name": { + "type": "string", + "description": "The name of the service that the service map should apply to." + }, + "service_version": { + "type": "string", + "description": "The version of the service that the service map should apply to." + }, + "bundle_filepath": { + "type": "string", + "description": "The absolute path of the final bundle as used in the web application." + }, + "sourcemap": { + "type": "string", + "format": "binary", + "description": "The source map. String or file upload. It must follow the\n[source map revision 3 proposal](https://docs.google.com/document/d/1U1RGAehQwRypUTovF1KRlpiOFze0b-_2gc6fAH0KY0k).\n" + } + } + }, + "upload_source_maps_response": { + "allOf": [ + { + "type": "object", + "properties": { + "body": { + "type": "string" + } + } + }, + { + "$ref": "#/components/schemas/base_source_map_object" + } + ] + } + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/bundled.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/bundled.yaml new file mode 100644 index 0000000000000..caa71f6645e77 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/bundled.yaml @@ -0,0 +1,1162 @@ +openapi: 3.0.2 +info: + title: APM UI + version: 1.0.0 +tags: + - name: APM agent keys + description: | + Configure APM agent keys to authorize requests from APM agents to the APM Server. + - name: APM agent configuration + description: | + Adjust APM agent configuration without need to redeploy your application. + - name: APM sourcemaps + description: Configure APM source maps. + - name: APM annotations + description: | + Annotate visualizations in the APM app with significant events. Annotations enable you to easily see how events are impacting the performance of your applications. + - name: APM server schema + description: Create APM fleet server schema. +paths: + /api/apm/agent_keys: + post: + summary: Create an APM agent key + description: Create a new agent key for APM. + operationId: createAgentKey + tags: + - APM agent keys + parameters: + - $ref: '#/components/parameters/elastic_api_version' + - $ref: '#/components/parameters/kbn_xsrf' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/agent_keys_object' + responses: + '200': + description: Agent key created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/agent_keys_response' + '400': + description: Bad Request response + content: + application/json: + schema: + $ref: '#/components/schemas/400_response' + '401': + description: Unauthorized response + content: + application/json: + schema: + $ref: '#/components/schemas/401_response' + '403': + description: Forbidden response + content: + application/json: + schema: + $ref: '#/components/schemas/403_response' + '500': + description: Internal Server Error response + content: + application/json: + schema: + $ref: '#/components/schemas/500_response' + /api/apm/services/{serviceName}/annotation/search: + get: + summary: Search for annotations + description: Search for annotations related to a specific service. + operationId: getAnnotation + tags: + - APM annotations + parameters: + - $ref: '#/components/parameters/elastic_api_version' + - name: serviceName + in: path + required: true + description: The name of the service + schema: + type: string + - name: environment + in: query + required: false + description: The environment to filter annotations by + schema: + type: string + - name: start + in: query + required: false + description: The start date for the search + schema: + type: string + - name: end + in: query + required: false + description: The end date for the search + schema: + type: string + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/annotation_search_response' + '400': + description: Bad Request response + content: + application/json: + schema: + $ref: '#/components/schemas/400_response' + '401': + description: Unauthorized response + content: + application/json: + schema: + $ref: '#/components/schemas/401_response' + '500': + description: Internal Server Error response + content: + application/json: + schema: + $ref: '#/components/schemas/500_response' + /api/apm/services/{serviceName}/annotation: + post: + summary: Create a service annotation + description: Create a new annotation for a specific service. + operationId: createAnnotation + tags: + - APM annotations + parameters: + - $ref: '#/components/parameters/elastic_api_version' + - $ref: '#/components/parameters/kbn_xsrf' + - name: serviceName + in: path + required: true + description: The name of the service + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/create_annotation_object' + responses: + '200': + description: Annotation created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/create_annotation_response' + '400': + description: Bad Request response + content: + application/json: + schema: + $ref: '#/components/schemas/400_response' + '401': + description: Unauthorized response + content: + application/json: + schema: + $ref: '#/components/schemas/401_response' + '403': + description: Forbidden response + content: + application/json: + schema: + $ref: '#/components/schemas/403_response' + '404': + description: Not found response + content: + application/json: + schema: + $ref: '#/components/schemas/404_response' + /api/apm/settings/agent-configuration: + get: + summary: Get a list of agent configurations + operationId: getAgentConfigurations + tags: + - APM agent configuration + parameters: + - $ref: '#/components/parameters/elastic_api_version' + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/agent_configurations_response' + '400': + description: Bad Request response + content: + application/json: + schema: + $ref: '#/components/schemas/400_response' + '401': + description: Unauthorized response + content: + application/json: + schema: + $ref: '#/components/schemas/401_response' + '404': + description: Not found response + content: + application/json: + schema: + $ref: '#/components/schemas/404_response' + delete: + summary: Delete agent configuration + operationId: deleteAgentConfiguration + tags: + - APM agent configuration + parameters: + - $ref: '#/components/parameters/elastic_api_version' + - $ref: '#/components/parameters/kbn_xsrf' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/service_object' + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/delete_agent_configurations_response' + '400': + description: Bad Request response + content: + application/json: + schema: + $ref: '#/components/schemas/400_response' + '401': + description: Unauthorized response + content: + application/json: + schema: + $ref: '#/components/schemas/401_response' + '403': + description: Forbidden response + content: + application/json: + schema: + $ref: '#/components/schemas/403_response' + '404': + description: Not found response + content: + application/json: + schema: + $ref: '#/components/schemas/404_response' + put: + summary: Create or update agent configuration + operationId: createUpdateAgentConfiguration + tags: + - APM agent configuration + parameters: + - $ref: '#/components/parameters/elastic_api_version' + - $ref: '#/components/parameters/kbn_xsrf' + - name: overwrite + in: query + description: If the config exists ?overwrite=true is required + schema: + type: boolean + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/agent_configuration_intake_object' + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + additionalProperties: false + '400': + description: Bad Request response + content: + application/json: + schema: + $ref: '#/components/schemas/400_response' + '401': + description: Unauthorized response + content: + application/json: + schema: + $ref: '#/components/schemas/401_response' + '403': + description: Forbidden response + content: + application/json: + schema: + $ref: '#/components/schemas/403_response' + '404': + description: Not found response + content: + application/json: + schema: + $ref: '#/components/schemas/404_response' + /api/apm/settings/agent-configuration/view: + get: + summary: Get single agent configuration + operationId: getSingleAgentConfiguration + tags: + - APM agent configuration + parameters: + - $ref: '#/components/parameters/elastic_api_version' + - name: name + in: query + description: Service name + schema: + type: string + example: node + - name: environment + in: query + description: Service environment + schema: + type: string + example: prod + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/single_agent_configuration_response' + '400': + description: Bad Request response + content: + application/json: + schema: + $ref: '#/components/schemas/400_response' + '401': + description: Unauthorized response + content: + application/json: + schema: + $ref: '#/components/schemas/401_response' + '404': + description: Not found response + content: + application/json: + schema: + $ref: '#/components/schemas/404_response' + /api/apm/settings/agent-configuration/search: + post: + summary: Lookup single agent configuration + description: | + This endpoint allows to search for single agent configuration and update 'applied_by_agent' field. + operationId: searchSingleConfiguration + tags: + - APM agent configuration + parameters: + - $ref: '#/components/parameters/elastic_api_version' + - $ref: '#/components/parameters/kbn_xsrf' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/search_agent_configuration_object' + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/search_agent_configuration_response' + '400': + description: Bad Request response + content: + application/json: + schema: + $ref: '#/components/schemas/400_response' + '401': + description: Unauthorized response + content: + application/json: + schema: + $ref: '#/components/schemas/401_response' + '404': + description: Not found response + content: + application/json: + schema: + $ref: '#/components/schemas/404_response' + /api/apm/settings/agent-configuration/environments: + get: + summary: Get environments for service + operationId: getEnvironmentsForService + tags: + - APM agent configuration + parameters: + - $ref: '#/components/parameters/elastic_api_version' + - name: serviceName + in: query + description: The name of the service + schema: + type: string + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/service_environments_response' + '400': + description: Bad Request response + content: + application/json: + schema: + $ref: '#/components/schemas/400_response' + '401': + description: Unauthorized response + content: + application/json: + schema: + $ref: '#/components/schemas/401_response' + '404': + description: Not found response + content: + application/json: + schema: + $ref: '#/components/schemas/404_response' + /api/apm/settings/agent-configuration/agent_name: + get: + summary: Get agent name for service + description: Retrieve `agentName` for a service. + operationId: getAgentNameForService + tags: + - APM agent configuration + parameters: + - $ref: '#/components/parameters/elastic_api_version' + - name: serviceName + in: query + description: The name of the service + required: true + schema: + type: string + example: node + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/service_agent_name_response' + '400': + description: Bad Request response + content: + application/json: + schema: + $ref: '#/components/schemas/400_response' + '401': + description: Unauthorized response + content: + application/json: + schema: + $ref: '#/components/schemas/401_response' + '404': + description: Not found response + content: + application/json: + schema: + $ref: '#/components/schemas/404_response' + /api/apm/sourcemaps: + get: + summary: Get source maps + description: Returns an array of Fleet artifacts, including source map uploads. + operationId: getSourceMaps + tags: + - APM sourcemaps + parameters: + - $ref: '#/components/parameters/elastic_api_version' + - name: page + in: query + description: Page number + schema: + type: number + - name: perPage + in: query + description: Number of records per page + schema: + type: number + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/source_maps_response' + '400': + description: Bad Request response + content: + application/json: + schema: + $ref: '#/components/schemas/400_response' + '401': + description: Unauthorized response + content: + application/json: + schema: + $ref: '#/components/schemas/401_response' + '500': + description: Internal Server Error response + content: + application/json: + schema: + $ref: '#/components/schemas/500_response' + '501': + description: Not Implemented response + content: + application/json: + schema: + $ref: '#/components/schemas/501_response' + post: + summary: Upload source map + description: Upload a source map for a specific service and version. + operationId: uploadSourceMap + tags: + - APM sourcemaps + parameters: + - $ref: '#/components/parameters/elastic_api_version' + - $ref: '#/components/parameters/kbn_xsrf' + requestBody: + required: true + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/upload_source_map_object' + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/upload_source_maps_response' + '400': + description: Bad Request response + content: + application/json: + schema: + $ref: '#/components/schemas/400_response' + '401': + description: Unauthorized response + content: + application/json: + schema: + $ref: '#/components/schemas/401_response' + '403': + description: Forbidden response + content: + application/json: + schema: + $ref: '#/components/schemas/403_response' + '500': + description: Internal Server Error response + content: + application/json: + schema: + $ref: '#/components/schemas/500_response' + '501': + description: Not Implemented response + content: + application/json: + schema: + $ref: '#/components/schemas/501_response' + /api/apm/sourcemaps/{id}: + delete: + summary: Delete source map + description: Delete a previously uploaded source map. + operationId: deleteSourceMap + tags: + - APM sourcemaps + parameters: + - $ref: '#/components/parameters/elastic_api_version' + - $ref: '#/components/parameters/kbn_xsrf' + - name: id + in: path + description: Source map identifier + required: true + schema: + type: string + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + additionalProperties: false + '400': + description: Bad Request response + content: + application/json: + schema: + $ref: '#/components/schemas/400_response' + '401': + description: Unauthorized response + content: + application/json: + schema: + $ref: '#/components/schemas/401_response' + '403': + description: Forbidden response + content: + application/json: + schema: + $ref: '#/components/schemas/403_response' + '500': + description: Internal Server Error response + content: + application/json: + schema: + $ref: '#/components/schemas/500_response' + '501': + description: Not Implemented response + content: + application/json: + schema: + $ref: '#/components/schemas/501_response' + /api/apm/fleet/apm_server_schema: + post: + summary: Save APM server schema + operationId: saveApmServerSchema + tags: + - APM server schema + parameters: + - $ref: '#/components/parameters/elastic_api_version' + - $ref: '#/components/parameters/kbn_xsrf' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + schema: + type: object + description: Schema object + additionalProperties: true + example: + foo: bar + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + additionalProperties: false + '400': + description: Bad Request response + content: + application/json: + schema: + $ref: '#/components/schemas/400_response' + '401': + description: Unauthorized response + content: + application/json: + schema: + $ref: '#/components/schemas/401_response' + '403': + description: Forbidden response + content: + application/json: + schema: + $ref: '#/components/schemas/403_response' + '404': + description: Not found response + content: + application/json: + schema: + $ref: '#/components/schemas/404_response' +components: + parameters: + elastic_api_version: + description: The version of the API to use + in: header + name: elastic-api-version + required: true + schema: + default: '2023-10-31' + enum: + - '2023-10-31' + type: string + kbn_xsrf: + description: A required header to protect against CSRF attacks + in: header + name: kbn-xsrf + required: true + schema: + example: 'true' + type: string + schemas: + agent_keys_object: + type: object + required: + - name + - privileges + properties: + name: + type: string + description: Agent name + privileges: + type: array + description: Privileges configuration + items: + type: string + enum: + - event:write + - config_agent:read + agent_keys_response: + type: object + properties: + agentKey: + type: object + description: Agent key + required: + - id + - name + - api_key + - encoded + properties: + expiration: + type: integer + format: int64 + id: + type: string + name: + type: string + api_key: + type: string + encoded: + type: string + 400_response: + type: object + properties: + statusCode: + type: number + example: 400 + description: Error status code + error: + type: string + example: Not Found + description: Error type + message: + type: string + example: Not Found + description: Error message + 401_response: + type: object + properties: + statusCode: + type: number + example: 401 + description: Error status code + error: + type: string + example: Unauthorized + description: Error type + message: + type: string + description: Error message + 403_response: + type: object + properties: + statusCode: + type: number + example: 403 + description: Error status code + error: + type: string + example: Forbidden + description: Error type + message: + type: string + description: Error message + 500_response: + type: object + properties: + statusCode: + type: number + example: 500 + description: Error status code + error: + type: string + example: Internal Server Error + description: Error type + message: + type: string + description: Error message + annotation_search_response: + type: object + properties: + annotations: + type: array + description: Annotations + items: + type: object + properties: + type: + type: string + enum: + - version + id: + type: string + '@timestamp': + type: number + text: + type: string + create_annotation_object: + type: object + required: + - '@timestamp' + - service + properties: + '@timestamp': + type: string + description: Timestamp + service: + type: object + description: Service + required: + - version + properties: + version: + type: string + environment: + type: string + message: + type: string + description: Message + tags: + type: array + description: Tags + items: + type: string + create_annotation_response: + type: object + properties: + _id: + type: string + description: Identifier + _index: + type: string + description: Index + _source: + type: object + description: Response + properties: + annotation: + type: object + properties: + type: + type: string + title: + type: string + tags: + type: array + items: + type: string + message: + type: string + service: + type: object + properties: + name: + type: string + environment: + type: string + version: + type: string + event: + type: object + properties: + created: + type: string + '@timestamp': + type: string + 404_response: + type: object + properties: + statusCode: + type: number + example: 404 + description: Error status code + error: + type: string + example: Not Found + description: Error type + message: + type: string + example: Not Found + description: Error message + service_object: + type: object + description: Service + properties: + name: + type: string + example: node + description: Name + environment: + type: string + example: prod + description: Environment + settings_object: + type: object + description: Agent configuration settings + additionalProperties: + type: string + agent_configuration_object: + type: object + required: + - service + - settings + - '@timestamp' + - etag + description: Agent configuration + properties: + agent_name: + type: string + description: Agent name + service: + $ref: '#/components/schemas/service_object' + settings: + $ref: '#/components/schemas/settings_object' + '@timestamp': + type: number + example: 1730194190636 + description: Timestamp + applied_by_agent: + type: boolean + example: true + description: Applied by agent + etag: + type: string + example: 0bc3b5ebf18fba8163fe4c96f491e3767a358f85 + description: Etag + agent_configurations_response: + type: object + properties: + configurations: + type: array + description: Agent configuration + items: + $ref: '#/components/schemas/agent_configuration_object' + agent_configuration_intake_object: + type: object + required: + - service + - settings + properties: + agent_name: + type: string + description: Agent name + service: + $ref: '#/components/schemas/service_object' + settings: + $ref: '#/components/schemas/settings_object' + delete_agent_configurations_response: + type: object + properties: + result: + type: string + description: Result + single_agent_configuration_response: + allOf: + - type: object + required: + - id + properties: + id: + type: string + - $ref: '#/components/schemas/agent_configuration_object' + search_agent_configuration_object: + type: object + required: + - service + properties: + service: + $ref: '#/components/schemas/service_object' + etag: + type: string + description: If etags match then `applied_by_agent` field will be set to `true` + example: 0bc3b5ebf18fba8163fe4c96f491e3767a358f85 + mark_as_applied_by_agent: + type: boolean + description: | + `markAsAppliedByAgent=true` means "force setting it to true regardless of etag". + This is needed for Jaeger agent that doesn't have etags + search_agent_configuration_response: + type: object + properties: + _index: + type: string + description: Index + _id: + type: string + description: Identifier + _score: + type: number + description: Score + _source: + $ref: '#/components/schemas/agent_configuration_object' + service_environment_object: + type: object + properties: + name: + type: string + example: ALL_OPTION_VALUE + description: Service environment name + alreadyConfigured: + type: boolean + description: Already configured + service_environments_response: + type: object + properties: + environments: + type: array + description: Service environment list + items: + $ref: '#/components/schemas/service_environment_object' + service_agent_name_response: + type: object + properties: + agentName: + type: string + description: Agent name + example: nodejs + base_source_map_object: + type: object + properties: + type: + type: string + description: Type + identifier: + type: string + description: Identifier + relative_url: + type: string + description: Relative URL + created: + type: string + description: Created date + id: + type: string + description: Identifier + compressionAlgorithm: + type: string + description: Compression Algorithm + decodedSha256: + type: string + description: Decoded SHA-256 + decodedSize: + type: number + description: Decoded size + encodedSha256: + type: string + description: Encoded SHA-256 + encodedSize: + type: number + description: Encoded size + encryptionAlgorithm: + type: string + description: Encryption Algorithm + packageName: + type: string + description: Package name + source_maps_response: + type: object + properties: + artifacts: + type: array + description: Artifacts + items: + allOf: + - type: object + properties: + body: + type: object + properties: + serviceName: + type: string + serviceVersion: + type: string + bundleFilepath: + type: string + sourceMap: + type: object + properties: + version: + type: number + file: + type: string + sources: + type: array + items: + type: string + sourcesContent: + type: array + items: + type: string + mappings: + type: string + sourceRoot: + type: string + - $ref: '#/components/schemas/base_source_map_object' + 501_response: + type: object + properties: + statusCode: + type: number + example: 501 + description: Error status code + error: + type: string + example: Not Implemented + description: Error type + message: + type: string + example: Not Implemented + description: Error message + upload_source_map_object: + type: object + required: + - service_name + - service_version + - bundle_filepath + - sourcemap + properties: + service_name: + type: string + description: The name of the service that the service map should apply to. + service_version: + type: string + description: The version of the service that the service map should apply to. + bundle_filepath: + type: string + description: The absolute path of the final bundle as used in the web application. + sourcemap: + type: string + format: binary + description: | + The source map. String or file upload. It must follow the + [source map revision 3 proposal](https://docs.google.com/document/d/1U1RGAehQwRypUTovF1KRlpiOFze0b-_2gc6fAH0KY0k). + upload_source_maps_response: + allOf: + - type: object + properties: + body: + type: string + - $ref: '#/components/schemas/base_source_map_object' diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/README.md b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/README.md new file mode 100644 index 0000000000000..6beadcd86e1e9 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/README.md @@ -0,0 +1,7 @@ +Reusable components +=========== + + - `examples` - reusable [Example objects](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.2.md#example-object) + - `headers` - reusable [Header objects](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#header-object) + - `parameters` - reusable [Parameter objects](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#parameter-object) + - `schemas` - reusable [Schema objects](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.2.md#schema-object) diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/headers/elastic_api_version.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/headers/elastic_api_version.yaml new file mode 100644 index 0000000000000..b11a093b7b581 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/headers/elastic_api_version.yaml @@ -0,0 +1,9 @@ +description: The version of the API to use +in: header +name: elastic-api-version +required: true +schema: + default: '2023-10-31' + enum: + - '2023-10-31' + type: string diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/headers/kbn_xsrf.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/headers/kbn_xsrf.yaml new file mode 100644 index 0000000000000..25fcd49c04e65 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/headers/kbn_xsrf.yaml @@ -0,0 +1,7 @@ +description: A required header to protect against CSRF attacks +in: header +name: kbn-xsrf +required: true +schema: + example: 'true' + type: string diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/400_response.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/400_response.yaml new file mode 100644 index 0000000000000..3f09203cd49d3 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/400_response.yaml @@ -0,0 +1,14 @@ +type: object +properties: + statusCode: + type: number + example: 400 + description: Error status code + error: + type: string + example: Not Found + description: Error type + message: + type: string + example: Not Found + description: Error message diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/401_response.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/401_response.yaml new file mode 100644 index 0000000000000..cf5afb3482e6c --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/401_response.yaml @@ -0,0 +1,13 @@ +type: object +properties: + statusCode: + type: number + example: 401 + description: Error status code + error: + type: string + example: Unauthorized + description: Error type + message: + type: string + description: Error message diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/403_response.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/403_response.yaml new file mode 100644 index 0000000000000..f04184dcb1bb2 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/403_response.yaml @@ -0,0 +1,13 @@ +type: object +properties: + statusCode: + type: number + example: 403 + description: Error status code + error: + type: string + example: Forbidden + description: Error type + message: + type: string + description: Error message diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/404_response.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/404_response.yaml new file mode 100644 index 0000000000000..9e539dcbda096 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/404_response.yaml @@ -0,0 +1,14 @@ +type: object +properties: + statusCode: + type: number + example: 404 + description: Error status code + error: + type: string + example: Not Found + description: Error type + message: + type: string + example: Not Found + description: Error message diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/500_response.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/500_response.yaml new file mode 100644 index 0000000000000..e952739138146 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/500_response.yaml @@ -0,0 +1,13 @@ +type: object +properties: + statusCode: + type: number + example: 500 + description: Error status code + error: + type: string + example: Internal Server Error + description: Error type + message: + type: string + description: Error message diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/501_response.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/501_response.yaml new file mode 100644 index 0000000000000..f5a1545f7183e --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/501_response.yaml @@ -0,0 +1,14 @@ +type: object +properties: + statusCode: + type: number + example: 501 + description: Error status code + error: + type: string + example: Not Implemented + description: Error type + message: + type: string + example: Not Implemented + description: Error message diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/agent_configuration_intake_object.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/agent_configuration_intake_object.yaml new file mode 100644 index 0000000000000..0be4a414cd35a --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/agent_configuration_intake_object.yaml @@ -0,0 +1,12 @@ +type: object +required: + - service + - settings +properties: + agent_name: + type: string + description: Agent name + service: + $ref: 'service_object.yaml' + settings: + $ref: 'settings_object.yaml' diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/agent_configuration_object.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/agent_configuration_object.yaml new file mode 100644 index 0000000000000..8dfa922c25874 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/agent_configuration_object.yaml @@ -0,0 +1,27 @@ +type: object +required: + - service + - settings + - '@timestamp' + - etag +description: Agent configuration +properties: + agent_name: + type: string + description: Agent name + service: + $ref: 'service_object.yaml' + settings: + $ref: 'settings_object.yaml' + '@timestamp': + type: number + example: 1730194190636 + description: Timestamp + applied_by_agent: + type: boolean + example: true + description: Applied by agent + etag: + type: string + example: 0bc3b5ebf18fba8163fe4c96f491e3767a358f85 + description: Etag diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/agent_configurations_response.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/agent_configurations_response.yaml new file mode 100644 index 0000000000000..a6bdb51466fb4 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/agent_configurations_response.yaml @@ -0,0 +1,7 @@ +type: object +properties: + configurations: + type: array + description: Agent configuration + items: + $ref: 'agent_configuration_object.yaml' diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/agent_keys_object.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/agent_keys_object.yaml new file mode 100644 index 0000000000000..c5e248471db90 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/agent_keys_object.yaml @@ -0,0 +1,16 @@ +type: object +required: + - name + - privileges +properties: + name: + type: string + description: Agent name + privileges: + type: array + description: Privileges configuration + items: + type: string + enum: + - event:write + - config_agent:read diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/agent_keys_response.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/agent_keys_response.yaml new file mode 100644 index 0000000000000..3507dae535faf --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/agent_keys_response.yaml @@ -0,0 +1,22 @@ +type: object +properties: + agentKey: + type: object + description: Agent key + required: + - id + - name + - api_key + - encoded + properties: + expiration: + type: integer + format: int64 + id: + type: string + name: + type: string + api_key: + type: string + encoded: + type: string diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/annotation_search_response.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/annotation_search_response.yaml new file mode 100644 index 0000000000000..7827f17ffb979 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/annotation_search_response.yaml @@ -0,0 +1,18 @@ +type: object +properties: + annotations: + type: array + description: Annotations + items: + type: object + properties: + type: + type: string + enum: + - version + id: + type: string + "@timestamp": + type: number + text: + type: string diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/base_source_map_object.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/base_source_map_object.yaml new file mode 100644 index 0000000000000..f642c933f2b71 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/base_source_map_object.yaml @@ -0,0 +1,38 @@ +type: object +properties: + type: + type: string + description: Type + identifier: + type: string + description: Identifier + relative_url: + type: string + description: Relative URL + created: + type: string + description: Created date + id: + type: string + description: Identifier + compressionAlgorithm: + type: string + description: Compression Algorithm + decodedSha256: + type: string + description: Decoded SHA-256 + decodedSize: + type: number + description: Decoded size + encodedSha256: + type: string + description: Encoded SHA-256 + encodedSize: + type: number + description: Encoded size + encryptionAlgorithm: + type: string + description: Encryption Algorithm + packageName: + type: string + description: Package name diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/create_annotation_object.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/create_annotation_object.yaml new file mode 100644 index 0000000000000..6a8f8ba9b96d0 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/create_annotation_object.yaml @@ -0,0 +1,26 @@ +type: object +required: + - '@timestamp' + - service +properties: + '@timestamp': + type: string + description: Timestamp + service: + type: object + description: Service + required: + - version + properties: + version: + type: string + environment: + type: string + message: + type: string + description: Message + tags: + type: array + description: Tags + items: + type: string diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/create_annotation_response.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/create_annotation_response.yaml new file mode 100644 index 0000000000000..4738406a1e765 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/create_annotation_response.yaml @@ -0,0 +1,41 @@ +type: object +properties: + _id: + type: string + description: Identifier + _index: + type: string + description: Index + _source: + type: object + description: Response + properties: + annotation: + type: object + properties: + type: + type: string + title: + type: string + tags: + type: array + items: + type: string + message: + type: string + service: + type: object + properties: + name: + type: string + environment: + type: string + version: + type: string + event: + type: object + properties: + created: + type: string + '@timestamp': + type: string diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/delete_agent_configurations_response.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/delete_agent_configurations_response.yaml new file mode 100644 index 0000000000000..3dc7c58998b1f --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/delete_agent_configurations_response.yaml @@ -0,0 +1,5 @@ +type: object +properties: + result: + type: string + description: Result diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/search_agent_configuration_object.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/search_agent_configuration_object.yaml new file mode 100644 index 0000000000000..abbbf91b77b89 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/search_agent_configuration_object.yaml @@ -0,0 +1,15 @@ +type: object +required: + - service +properties: + service: + $ref: 'service_object.yaml' + etag: + type: string + description: If etags match then `applied_by_agent` field will be set to `true` + example: 0bc3b5ebf18fba8163fe4c96f491e3767a358f85 + mark_as_applied_by_agent: + type: boolean + description: | + `markAsAppliedByAgent=true` means "force setting it to true regardless of etag". + This is needed for Jaeger agent that doesn't have etags diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/search_agent_configuration_response.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/search_agent_configuration_response.yaml new file mode 100644 index 0000000000000..d0a5ff1d91a78 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/search_agent_configuration_response.yaml @@ -0,0 +1,13 @@ +type: object +properties: + _index: + type: string + description: Index + _id: + type: string + description: Identifier + _score: + type: number + description: Score + _source: + $ref: 'agent_configuration_object.yaml' diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/service_agent_name_response.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/service_agent_name_response.yaml new file mode 100644 index 0000000000000..b67c34b65df8e --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/service_agent_name_response.yaml @@ -0,0 +1,6 @@ +type: object +properties: + agentName: + type: string + description: Agent name + example: nodejs diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/service_environment_object.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/service_environment_object.yaml new file mode 100644 index 0000000000000..0f7f11371e59c --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/service_environment_object.yaml @@ -0,0 +1,9 @@ +type: object +properties: + name: + type: string + example: ALL_OPTION_VALUE + description: Service environment name + alreadyConfigured: + type: boolean + description: Already configured diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/service_environments_response.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/service_environments_response.yaml new file mode 100644 index 0000000000000..1b8cdc1cab48e --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/service_environments_response.yaml @@ -0,0 +1,7 @@ +type: object +properties: + environments: + type: array + description: Service environment list + items: + $ref: 'service_environment_object.yaml' diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/service_object.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/service_object.yaml new file mode 100644 index 0000000000000..c73dd8bc0eb19 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/service_object.yaml @@ -0,0 +1,11 @@ +type: object +description: Service +properties: + name: + type: string + example: node + description: Name + environment: + type: string + example: prod + description: Environment diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/settings_object.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/settings_object.yaml new file mode 100644 index 0000000000000..cf09f1b6254bd --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/settings_object.yaml @@ -0,0 +1,4 @@ +type: object +description: Agent configuration settings +additionalProperties: + type: string diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/single_agent_configuration_response.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/single_agent_configuration_response.yaml new file mode 100644 index 0000000000000..4ef312304cc60 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/single_agent_configuration_response.yaml @@ -0,0 +1,8 @@ +allOf: + - type: object + required: + - id + properties: + id: + type: string + - $ref: 'agent_configuration_object.yaml' diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/source_maps_response.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/source_maps_response.yaml new file mode 100644 index 0000000000000..f5590110fecd2 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/source_maps_response.yaml @@ -0,0 +1,38 @@ +type: object +properties: + artifacts: + type: array + description: Artifacts + items: + allOf: + - type: object + properties: + body: + type: object + properties: + serviceName: + type: string + serviceVersion: + type: string + bundleFilepath: + type: string + sourceMap: + type: object + properties: + version: + type: number + file: + type: string + sources: + type: array + items: + type: string + sourcesContent: + type: array + items: + type: string + mappings: + type: string + sourceRoot: + type: string + - $ref: 'base_source_map_object.yaml' diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/upload_source_map_object.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/upload_source_map_object.yaml new file mode 100644 index 0000000000000..00483e3606df0 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/upload_source_map_object.yaml @@ -0,0 +1,22 @@ +type: object +required: + - service_name + - service_version + - bundle_filepath + - sourcemap +properties: + service_name: + type: string + description: The name of the service that the service map should apply to. + service_version: + type: string + description: The version of the service that the service map should apply to. + bundle_filepath: + type: string + description: The absolute path of the final bundle as used in the web application. + sourcemap: + type: string + format: binary + description: | + The source map. String or file upload. It must follow the + [source map revision 3 proposal](https://docs.google.com/document/d/1U1RGAehQwRypUTovF1KRlpiOFze0b-_2gc6fAH0KY0k). diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/upload_source_maps_response.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/upload_source_maps_response.yaml new file mode 100644 index 0000000000000..6b677374de4ab --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/components/schemas/upload_source_maps_response.yaml @@ -0,0 +1,6 @@ +allOf: + - type: object + properties: + body: + type: string + - $ref: 'base_source_map_object.yaml' diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/entrypoint.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/entrypoint.yaml new file mode 100644 index 0000000000000..abb21fb980a9a --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/entrypoint.yaml @@ -0,0 +1,42 @@ +openapi: 3.0.2 +info: + title: APM UI + version: 1.0.0 +tags: + - name: APM agent keys + description: > + Configure APM agent keys to authorize requests from APM agents to the APM Server. + - name: APM agent configuration + description: > + Adjust APM agent configuration without need to redeploy your application. + - name: APM sourcemaps + description: Configure APM source maps. + - name: APM annotations + description: > + Annotate visualizations in the APM app with significant events. + Annotations enable you to easily see how events are impacting the performance of your applications. + - name: APM server schema + description: Create APM fleet server schema. +paths: + /api/apm/agent_keys: + $ref: 'paths/api@apm@agent_keys.yaml' + /api/apm/services/{serviceName}/annotation/search: + $ref: 'paths/api@apm@services@{service_name}@annotation@search.yaml' + /api/apm/services/{serviceName}/annotation: + $ref: 'paths/api@apm@services@{service_name}@annotation.yaml' + /api/apm/settings/agent-configuration: + $ref: 'paths/api@apm@settings@agent_configuration.yaml' + /api/apm/settings/agent-configuration/view: + $ref: 'paths/api@apm@settings@agent_configuration@view.yaml' + /api/apm/settings/agent-configuration/search: + $ref: 'paths/api@apm@settings@agent_configuration@search.yaml' + /api/apm/settings/agent-configuration/environments: + $ref: 'paths/api@apm@settings@agent_configuration@environments.yaml' + /api/apm/settings/agent-configuration/agent_name: + $ref: 'paths/api@apm@settings@agent_configuration@agent_name.yaml' + /api/apm/sourcemaps: + $ref: 'paths/api@apm@sourcemaps.yaml' + /api/apm/sourcemaps/{id}: + $ref: 'paths/api@apm@sourcemaps@{id}.yaml' + /api/apm/fleet/apm_server_schema: + $ref: 'paths/api@apm@fleet@apm_server_schema.yaml' diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/README.md b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/README.md new file mode 100644 index 0000000000000..b7818c8474fc8 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/README.md @@ -0,0 +1,10 @@ +Paths +===== + +Each path definition for which there is a specification exists within this folder. + +These files currently use the following conventions: + +* path separator token (e.g. `@`) is included in the file name +* path parameter (e.g. `{example}`) is included in the file name +* there is one file per path; each file can contain multiple operations diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@agent_keys.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@agent_keys.yaml new file mode 100644 index 0000000000000..46b1588517761 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@agent_keys.yaml @@ -0,0 +1,46 @@ +post: + summary: Create an APM agent key + description: Create a new agent key for APM. + operationId: createAgentKey + tags: + - APM agent keys + parameters: + - $ref: '../components/headers/elastic_api_version.yaml' + - $ref: '../components/headers/kbn_xsrf.yaml' + requestBody: + required: true + content: + application/json: + schema: + $ref: '../components/schemas/agent_keys_object.yaml' + responses: + "200": + description: Agent key created successfully + content: + application/json: + schema: + $ref: '../components/schemas/agent_keys_response.yaml' + '400': + description: Bad Request response + content: + application/json: + schema: + $ref: '../components/schemas/400_response.yaml' + '401': + description: Unauthorized response + content: + application/json: + schema: + $ref: '../components/schemas/401_response.yaml' + '403': + description: Forbidden response + content: + application/json: + schema: + $ref: '../components/schemas/403_response.yaml' + '500': + description: Internal Server Error response + content: + application/json: + schema: + $ref: '../components/schemas/500_response.yaml' diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@fleet@apm_server_schema.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@fleet@apm_server_schema.yaml new file mode 100644 index 0000000000000..2c4629b44a211 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@fleet@apm_server_schema.yaml @@ -0,0 +1,53 @@ +post: + summary: Save APM server schema + operationId: saveApmServerSchema + tags: + - APM server schema + parameters: + - $ref: '../components/headers/elastic_api_version.yaml' + - $ref: '../components/headers/kbn_xsrf.yaml' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + schema: + type: object + description: Schema object + additionalProperties: true + example: + foo: "bar" + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + additionalProperties: false + '400': + description: Bad Request response + content: + application/json: + schema: + $ref: '../components/schemas/400_response.yaml' + '401': + description: Unauthorized response + content: + application/json: + schema: + $ref: '../components/schemas/401_response.yaml' + '403': + description: Forbidden response + content: + application/json: + schema: + $ref: '../components/schemas/403_response.yaml' + '404': + description: Not found response + content: + application/json: + schema: + $ref: '../components/schemas/404_response.yaml' diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@services@{service_name}@annotation.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@services@{service_name}@annotation.yaml new file mode 100644 index 0000000000000..3894bd6da2015 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@services@{service_name}@annotation.yaml @@ -0,0 +1,52 @@ +post: + summary: Create a service annotation + description: Create a new annotation for a specific service. + operationId: createAnnotation + tags: + - APM annotations + parameters: + - $ref: '../components/headers/elastic_api_version.yaml' + - $ref: '../components/headers/kbn_xsrf.yaml' + - name: serviceName + in: path + required: true + description: The name of the service + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '../components/schemas/create_annotation_object.yaml' + responses: + '200': + description: Annotation created successfully + content: + application/json: + schema: + $ref: '../components/schemas/create_annotation_response.yaml' + '400': + description: Bad Request response + content: + application/json: + schema: + $ref: '../components/schemas/400_response.yaml' + '401': + description: Unauthorized response + content: + application/json: + schema: + $ref: '../components/schemas/401_response.yaml' + '403': + description: Forbidden response + content: + application/json: + schema: + $ref: '../components/schemas/403_response.yaml' + '404': + description: Not found response + content: + application/json: + schema: + $ref: '../components/schemas/404_response.yaml' diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@services@{service_name}@annotation@search.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@services@{service_name}@annotation@search.yaml new file mode 100644 index 0000000000000..0f1391be89806 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@services@{service_name}@annotation@search.yaml @@ -0,0 +1,57 @@ +get: + summary: Search for annotations + description: Search for annotations related to a specific service. + operationId: getAnnotation + tags: + - APM annotations + parameters: + - $ref: '../components/headers/elastic_api_version.yaml' + - name: serviceName + in: path + required: true + description: The name of the service + schema: + type: string + - name: environment + in: query + required: false + description: The environment to filter annotations by + schema: + type: string + - name: start + in: query + required: false + description: The start date for the search + schema: + type: string + - name: end + in: query + required: false + description: The end date for the search + schema: + type: string + responses: + "200": + description: Successful response + content: + application/json: + schema: + $ref: '../components/schemas/annotation_search_response.yaml' + '400': + description: Bad Request response + content: + application/json: + schema: + $ref: '../components/schemas/400_response.yaml' + '401': + description: Unauthorized response + content: + application/json: + schema: + $ref: '../components/schemas/401_response.yaml' + '500': + description: Internal Server Error response + content: + application/json: + schema: + $ref: '../components/schemas/500_response.yaml' diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@settings@agent_configuration.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@settings@agent_configuration.yaml new file mode 100644 index 0000000000000..f508e855a3883 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@settings@agent_configuration.yaml @@ -0,0 +1,128 @@ +get: + summary: Get a list of agent configurations + operationId: getAgentConfigurations + tags: + - APM agent configuration + parameters: + - $ref: '../components/headers/elastic_api_version.yaml' + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '../components/schemas/agent_configurations_response.yaml' + '400': + description: Bad Request response + content: + application/json: + schema: + $ref: '../components/schemas/400_response.yaml' + '401': + description: Unauthorized response + content: + application/json: + schema: + $ref: '../components/schemas/401_response.yaml' + '404': + description: Not found response + content: + application/json: + schema: + $ref: '../components/schemas/404_response.yaml' +delete: + summary: Delete agent configuration + operationId: deleteAgentConfiguration + tags: + - APM agent configuration + parameters: + - $ref: '../components/headers/elastic_api_version.yaml' + - $ref: '../components/headers/kbn_xsrf.yaml' + requestBody: + required: true + content: + application/json: + schema: + $ref: '../components/schemas/service_object.yaml' + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '../components/schemas/delete_agent_configurations_response.yaml' + '400': + description: Bad Request response + content: + application/json: + schema: + $ref: '../components/schemas/400_response.yaml' + '401': + description: Unauthorized response + content: + application/json: + schema: + $ref: '../components/schemas/401_response.yaml' + '403': + description: Forbidden response + content: + application/json: + schema: + $ref: '../components/schemas/403_response.yaml' + '404': + description: Not found response + content: + application/json: + schema: + $ref: '../components/schemas/404_response.yaml' +put: + summary: Create or update agent configuration + operationId: createUpdateAgentConfiguration + tags: + - APM agent configuration + parameters: + - $ref: '../components/headers/elastic_api_version.yaml' + - $ref: '../components/headers/kbn_xsrf.yaml' + - name: overwrite + in: query + description: If the config exists ?overwrite=true is required + schema: + type: boolean + requestBody: + required: true + content: + application/json: + schema: + $ref: '../components/schemas/agent_configuration_intake_object.yaml' + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + additionalProperties: false + '400': + description: Bad Request response + content: + application/json: + schema: + $ref: '../components/schemas/400_response.yaml' + '401': + description: Unauthorized response + content: + application/json: + schema: + $ref: '../components/schemas/401_response.yaml' + '403': + description: Forbidden response + content: + application/json: + schema: + $ref: '../components/schemas/403_response.yaml' + '404': + description: Not found response + content: + application/json: + schema: + $ref: '../components/schemas/404_response.yaml' diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@settings@agent_configuration@agent_name.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@settings@agent_configuration@agent_name.yaml new file mode 100644 index 0000000000000..4ad10fbb00833 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@settings@agent_configuration@agent_name.yaml @@ -0,0 +1,40 @@ +get: + summary: Get agent name for service + description: Retrieve `agentName` for a service. + operationId: getAgentNameForService + tags: + - APM agent configuration + parameters: + - $ref: '../components/headers/elastic_api_version.yaml' + - name: serviceName + in: query + description: The name of the service + required: true + schema: + type: string + example: node + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '../components/schemas/service_agent_name_response.yaml' + '400': + description: Bad Request response + content: + application/json: + schema: + $ref: '../components/schemas/400_response.yaml' + '401': + description: Unauthorized response + content: + application/json: + schema: + $ref: '../components/schemas/401_response.yaml' + '404': + description: Not found response + content: + application/json: + schema: + $ref: '../components/schemas/404_response.yaml' diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@settings@agent_configuration@environments.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@settings@agent_configuration@environments.yaml new file mode 100644 index 0000000000000..c0791b92ac148 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@settings@agent_configuration@environments.yaml @@ -0,0 +1,37 @@ +get: + summary: Get environments for service + operationId: getEnvironmentsForService + tags: + - APM agent configuration + parameters: + - $ref: '../components/headers/elastic_api_version.yaml' + - name: serviceName + in: query + description: The name of the service + schema: + type: string + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '../components/schemas/service_environments_response.yaml' + '400': + description: Bad Request response + content: + application/json: + schema: + $ref: '../components/schemas/400_response.yaml' + '401': + description: Unauthorized response + content: + application/json: + schema: + $ref: '../components/schemas/401_response.yaml' + '404': + description: Not found response + content: + application/json: + schema: + $ref: '../components/schemas/404_response.yaml' diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@settings@agent_configuration@search.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@settings@agent_configuration@search.yaml new file mode 100644 index 0000000000000..8ae4ce975fc08 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@settings@agent_configuration@search.yaml @@ -0,0 +1,41 @@ +post: + summary: Lookup single agent configuration + description: | + This endpoint allows to search for single agent configuration and update 'applied_by_agent' field. + operationId: searchSingleConfiguration + tags: + - APM agent configuration + parameters: + - $ref: '../components/headers/elastic_api_version.yaml' + - $ref: '../components/headers/kbn_xsrf.yaml' + requestBody: + required: true + content: + application/json: + schema: + $ref: '../components/schemas/search_agent_configuration_object.yaml' + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '../components/schemas/search_agent_configuration_response.yaml' + '400': + description: Bad Request response + content: + application/json: + schema: + $ref: '../components/schemas/400_response.yaml' + '401': + description: Unauthorized response + content: + application/json: + schema: + $ref: '../components/schemas/401_response.yaml' + '404': + description: Not found response + content: + application/json: + schema: + $ref: '../components/schemas/404_response.yaml' diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@settings@agent_configuration@view.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@settings@agent_configuration@view.yaml new file mode 100644 index 0000000000000..23e5cc2186d12 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@settings@agent_configuration@view.yaml @@ -0,0 +1,44 @@ +get: + summary: Get single agent configuration + operationId: getSingleAgentConfiguration + tags: + - APM agent configuration + parameters: + - $ref: '../components/headers/elastic_api_version.yaml' + - name: name + in: query + description: Service name + schema: + type: string + example: node + - name: environment + in: query + description: Service environment + schema: + type: string + example: prod + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '../components/schemas/single_agent_configuration_response.yaml' + '400': + description: Bad Request response + content: + application/json: + schema: + $ref: '../components/schemas/400_response.yaml' + '401': + description: Unauthorized response + content: + application/json: + schema: + $ref: '../components/schemas/401_response.yaml' + '404': + description: Not found response + content: + application/json: + schema: + $ref: '../components/schemas/404_response.yaml' diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@sourcemaps.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@sourcemaps.yaml new file mode 100644 index 0000000000000..142cd0d2673c1 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@sourcemaps.yaml @@ -0,0 +1,101 @@ +get: + summary: Get source maps + description: Returns an array of Fleet artifacts, including source map uploads. + operationId: getSourceMaps + tags: + - APM sourcemaps + parameters: + - $ref: '../components/headers/elastic_api_version.yaml' + - name: page + in: query + description: Page number + schema: + type: number + - name: perPage + in: query + description: Number of records per page + schema: + type: number + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '../components/schemas/source_maps_response.yaml' + '400': + description: Bad Request response + content: + application/json: + schema: + $ref: '../components/schemas/400_response.yaml' + '401': + description: Unauthorized response + content: + application/json: + schema: + $ref: '../components/schemas/401_response.yaml' + '500': + description: Internal Server Error response + content: + application/json: + schema: + $ref: '../components/schemas/500_response.yaml' + '501': + description: Not Implemented response + content: + application/json: + schema: + $ref: '../components/schemas/501_response.yaml' +post: + summary: Upload source map + description: Upload a source map for a specific service and version. + operationId: uploadSourceMap + tags: + - APM sourcemaps + parameters: + - $ref: '../components/headers/elastic_api_version.yaml' + - $ref: '../components/headers/kbn_xsrf.yaml' + requestBody: + required: true + content: + multipart/form-data: + schema: + $ref: '../components/schemas/upload_source_map_object.yaml' + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '../components/schemas/upload_source_maps_response.yaml' + '400': + description: Bad Request response + content: + application/json: + schema: + $ref: '../components/schemas/400_response.yaml' + '401': + description: Unauthorized response + content: + application/json: + schema: + $ref: '../components/schemas/401_response.yaml' + '403': + description: Forbidden response + content: + application/json: + schema: + $ref: '../components/schemas/403_response.yaml' + '500': + description: Internal Server Error response + content: + application/json: + schema: + $ref: '../components/schemas/500_response.yaml' + '501': + description: Not Implemented response + content: + application/json: + schema: + $ref: '../components/schemas/501_response.yaml' diff --git a/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@sourcemaps@{id}.yaml b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@sourcemaps@{id}.yaml new file mode 100644 index 0000000000000..3f165360bf60c --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/docs/openapi/apm/paths/api@apm@sourcemaps@{id}.yaml @@ -0,0 +1,53 @@ +delete: + summary: Delete source map + description: Delete a previously uploaded source map. + operationId: deleteSourceMap + tags: + - APM sourcemaps + parameters: + - $ref: '../components/headers/elastic_api_version.yaml' + - $ref: '../components/headers/kbn_xsrf.yaml' + - name: id + in: path + description: Source map identifier + required: true + schema: + type: string + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + additionalProperties: false + '400': + description: Bad Request response + content: + application/json: + schema: + $ref: '../components/schemas/400_response.yaml' + '401': + description: Unauthorized response + content: + application/json: + schema: + $ref: '../components/schemas/401_response.yaml' + '403': + description: Forbidden response + content: + application/json: + schema: + $ref: '../components/schemas/403_response.yaml' + '500': + description: Internal Server Error response + content: + application/json: + schema: + $ref: '../components/schemas/500_response.yaml' + '501': + description: Not Implemented response + content: + application/json: + schema: + $ref: '../components/schemas/501_response.yaml' diff --git a/x-pack/plugins/observability_solution/apm/ftr_e2e/cypress/e2e/integration_settings/integration_policy.cy.ts b/x-pack/plugins/observability_solution/apm/ftr_e2e/cypress/e2e/integration_settings/integration_policy.cy.ts index da9a08339a45c..753e6476be1ed 100644 --- a/x-pack/plugins/observability_solution/apm/ftr_e2e/cypress/e2e/integration_settings/integration_policy.cy.ts +++ b/x-pack/plugins/observability_solution/apm/ftr_e2e/cypress/e2e/integration_settings/integration_policy.cy.ts @@ -28,7 +28,7 @@ const policyFormFields = [ }, ]; -describe('when navigating to integration page', () => { +describe.skip('when navigating to integration page', () => { beforeEach(() => { const integrationsPath = '/app/integrations/browse'; diff --git a/x-pack/plugins/observability_solution/apm/ftr_e2e/cypress/e2e/transaction_details/transaction_details.cy.ts b/x-pack/plugins/observability_solution/apm/ftr_e2e/cypress/e2e/transaction_details/transaction_details.cy.ts index 38ced9a6587ee..3ae431f5d3299 100644 --- a/x-pack/plugins/observability_solution/apm/ftr_e2e/cypress/e2e/transaction_details/transaction_details.cy.ts +++ b/x-pack/plugins/observability_solution/apm/ftr_e2e/cypress/e2e/transaction_details/transaction_details.cy.ts @@ -15,8 +15,8 @@ const timeRange = { rangeFrom: start, rangeTo: end, }; - -describe('Transaction details', () => { +// flaky +describe.skip('Transaction details', () => { before(() => { synthtrace.index( opbeans({ diff --git a/x-pack/plugins/observability_solution/apm/ftr_e2e/cypress/e2e/tutorial/tutorial.cy.ts b/x-pack/plugins/observability_solution/apm/ftr_e2e/cypress/e2e/tutorial/tutorial.cy.ts deleted file mode 100644 index 9aa71604e6a80..0000000000000 --- a/x-pack/plugins/observability_solution/apm/ftr_e2e/cypress/e2e/tutorial/tutorial.cy.ts +++ /dev/null @@ -1,37 +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. - */ - -describe('APM tutorial', () => { - beforeEach(() => { - cy.loginAsViewerUser(); - cy.visitKibana('/app/home#/tutorial/apm'); - }); - - it('includes section for APM Server', () => { - cy.contains('APM Server'); - cy.contains('Linux DEB'); - cy.contains('Linux RPM'); - cy.contains('Other Linux'); - cy.contains('macOS'); - cy.contains('Windows'); - cy.contains('Fleet'); - }); - - it('includes section for APM Agents', () => { - cy.contains('APM agents'); - cy.contains('Java'); - cy.contains('RUM'); - cy.contains('Node.js'); - cy.contains('Django'); - cy.contains('Flask'); - cy.contains('Ruby on Rails'); - cy.contains('Rack'); - cy.contains('Go'); - cy.contains('.NET'); - cy.contains('PHP'); - }); -}); diff --git a/x-pack/plugins/observability_solution/apm/ftr_e2e/kibana.jsonc b/x-pack/plugins/observability_solution/apm/ftr_e2e/kibana.jsonc index e0bf29cc4757c..47319dbadc61c 100644 --- a/x-pack/plugins/observability_solution/apm/ftr_e2e/kibana.jsonc +++ b/x-pack/plugins/observability_solution/apm/ftr_e2e/kibana.jsonc @@ -2,5 +2,7 @@ "type": "test-helper", "id": "@kbn/apm-ftr-e2e", "owner": "@elastic/obs-ux-infra_services-team", + "group": "observability", + "visibility": "private", "devOnly": true } diff --git a/x-pack/plugins/observability_solution/apm/kibana.jsonc b/x-pack/plugins/observability_solution/apm/kibana.jsonc index e12b22a43d60a..656f898f24064 100644 --- a/x-pack/plugins/observability_solution/apm/kibana.jsonc +++ b/x-pack/plugins/observability_solution/apm/kibana.jsonc @@ -1,13 +1,20 @@ { "type": "plugin", "id": "@kbn/apm-plugin", - "owner": "@elastic/obs-ux-infra_services-team", + "owner": [ + "@elastic/obs-ux-infra_services-team" + ], + "group": "observability", + "visibility": "private", "description": "The user interface for Elastic APM", "plugin": { "id": "apm", - "server": true, "browser": true, - "configPath": ["xpack", "apm"], + "server": true, + "configPath": [ + "xpack", + "apm" + ], "requiredPlugins": [ "apmDataAccess", "data", @@ -47,12 +54,19 @@ "serverless", "taskManager", "usageCollection", - "customIntegrations", // Move this to requiredPlugins after completely migrating from the Tutorials Home App + "customIntegrations", "licenseManagement", "profilingDataAccess", "cases", "observabilityAIAssistant" ], - "requiredBundles": ["fleet", "kibanaReact", "kibanaUtils", "ml", "observability", "maps"] + "requiredBundles": [ + "fleet", + "kibanaReact", + "kibanaUtils", + "ml", + "observability", + "maps" + ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/dependency_operation_detail_view/maybe_redirect_to_available_span_sample.ts b/x-pack/plugins/observability_solution/apm/public/components/app/dependency_operation_detail_view/maybe_redirect_to_available_span_sample.ts index 1815897cb7c7a..d47cdbca64b92 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/app/dependency_operation_detail_view/maybe_redirect_to_available_span_sample.ts +++ b/x-pack/plugins/observability_solution/apm/public/components/app/dependency_operation_detail_view/maybe_redirect_to_available_span_sample.ts @@ -24,7 +24,7 @@ export function maybeRedirectToAvailableSpanSample({ page: number; replace: typeof urlHelpersReplace; history: History; - samples: Array<{ spanId: string; traceId: string; transactionId: string }>; + samples: Array<{ spanId: string; traceId: string; transactionId?: string }>; }) { if (spanFetchStatus !== FETCH_STATUS.SUCCESS) { // we're still loading, don't do anything diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/metrics/static_dashboard/dashboards/dashboard_catalog.ts b/x-pack/plugins/observability_solution/apm/public/components/app/metrics/static_dashboard/dashboards/dashboard_catalog.ts index 2d3ea5fded80b..6f81ef6db535b 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/app/metrics/static_dashboard/dashboards/dashboard_catalog.ts +++ b/x-pack/plugins/observability_solution/apm/public/components/app/metrics/static_dashboard/dashboards/dashboard_catalog.ts @@ -12,6 +12,9 @@ export const AGENT_NAME_DASHBOARD_FILE_MAPPING: Record = { 'opentelemetry/java': 'opentelemetry_java', 'opentelemetry/java/opentelemetry-java-instrumentation': 'opentelemetry_java', 'opentelemetry/java/elastic': 'opentelemetry_java', + 'opentelemetry/dotnet': 'opentelemetry_dotnet', + 'opentelemetry/dotnet/opentelemetry-dotnet-instrumentation': 'opentelemetry_dotnet', + 'opentelemetry/dotnet/elastic': 'opentelemetry_dotnet', }; /** @@ -44,6 +47,12 @@ export async function loadDashboardFile(filename: string): Promise { './opentelemetry_java.json' ); } + case 'opentelemetry_dotnet': { + return import( + /* webpackChunkName: "lazyOtelDotnetDashboard" */ + './opentelemetry_dotnet.json' + ); + } default: { break; } diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/metrics/static_dashboard/dashboards/opentelemetry_dotnet.json b/x-pack/plugins/observability_solution/apm/public/components/app/metrics/static_dashboard/dashboards/opentelemetry_dotnet.json new file mode 100644 index 0000000000000..2862bf0a586d7 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/public/components/app/metrics/static_dashboard/dashboards/opentelemetry_dotnet.json @@ -0,0 +1 @@ +{"attributes":{"controlGroupInput":{"chainingSystem":"HIERARCHICAL","controlStyle":"oneLine","ignoreParentSettingsJSON":"{\"ignoreFilters\":false,\"ignoreQuery\":false,\"ignoreTimerange\":false,\"ignoreValidations\":false}","panelsJSON":"{\"2be66584-9de4-4a36-ba54-bfdd1b4ccfb4\":{\"type\":\"optionsListControl\",\"order\":0,\"grow\":true,\"width\":\"medium\",\"explicitInput\":{\"id\":\"2be66584-9de4-4a36-ba54-bfdd1b4ccfb4\",\"fieldName\":\"service.node.name\",\"title\":\"Instance\",\"grow\":true,\"width\":\"medium\",\"searchTechnique\":\"prefix\",\"selectedOptions\":[],\"existsSelected\":true,\"enhancements\":{}}}}"},"description":"","kibanaSavedObjectMeta":{"searchSourceJSON":"{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}"},"optionsJSON":"{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}","panelsJSON":"[{\"type\":\"lens\",\"gridData\":{\"x\":0,\"y\":0,\"w\":48,\"h\":8,\"i\":\"ea9f86f0-ff73-4c92-9b93-41baebdcffab\"},\"panelIndex\":\"ea9f86f0-ff73-4c92-9b93-41baebdcffab\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsDatatable\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"APM_STATIC_DATA_VIEW_ID\",\"name\":\"indexpattern-datasource-layer-1ba88117-6e95-46e2-8667-e0bc15145182\"}],\"state\":{\"visualization\":{\"columns\":[{\"columnId\":\"d5553b2b-25e9-4b94-9697-f04885f4a067\",\"isTransposed\":false,\"isMetric\":false,\"alignment\":\"left\",\"summaryRow\":\"none\",\"width\":547.5714285714286},{\"columnId\":\"b3da070f-3463-4990-b257-40ac399bec87\",\"isTransposed\":false,\"isMetric\":true,\"colorMode\":\"none\",\"hidden\":false,\"alignment\":\"left\",\"summaryRow\":\"avg\",\"width\":135.73809523809527},{\"columnId\":\"a7f6a205-1e8f-4b64-b745-efe20c2c6545\",\"isTransposed\":false,\"isMetric\":true,\"alignment\":\"left\",\"summaryRow\":\"sum\",\"width\":143.93809523809523},{\"columnId\":\"2bbcd9e9-15ff-42c1-98a8-65a5b9b4e4c5\",\"isTransposed\":false,\"isMetric\":true,\"alignment\":\"left\",\"summaryRow\":\"sum\",\"width\":142.68809523809523},{\"columnId\":\"9f3d67e3-0513-468e-b9b2-6d8780dac3e0\",\"isTransposed\":false,\"isMetric\":true,\"alignment\":\"left\",\"summaryRow\":\"sum\",\"width\":163.18809523809523},{\"columnId\":\"21bdcb2d-cee4-4dd9-84cb-5031f073bf85\",\"isTransposed\":false,\"isMetric\":true,\"alignment\":\"left\",\"width\":183.68809523809523}],\"layerId\":\"1ba88117-6e95-46e2-8667-e0bc15145182\",\"layerType\":\"data\",\"paging\":{\"size\":10,\"enabled\":true}},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"1ba88117-6e95-46e2-8667-e0bc15145182\":{\"columns\":{\"d5553b2b-25e9-4b94-9697-f04885f4a067\":{\"label\":\"Host + Service instance\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"host.name\",\"isBucketed\":true,\"params\":{\"size\":25,\"orderBy\":{\"type\":\"alphabetical\",\"fallback\":false},\"orderDirection\":\"asc\",\"otherBucket\":true,\"missingBucket\":false,\"parentFormat\":{\"id\":\"multi_terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false,\"secondaryFields\":[\"service.node.name\"]},\"customLabel\":true},\"b3da070f-3463-4990-b257-40ac399bec87\":{\"label\":\"Memory usage\",\"dataType\":\"number\",\"operationType\":\"last_value\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"process.memory.usage\",\"filter\":{\"query\":\"\\\"process.memory.usage\\\": *\",\"language\":\"kuery\"},\"params\":{\"sortField\":\"@timestamp\",\"format\":{\"id\":\"bytes\",\"params\":{\"decimals\":0}}},\"customLabel\":true},\"a7f6a205-1e8f-4b64-b745-efe20c2c6545\":{\"label\":\"Gen 0 collections\",\"dataType\":\"number\",\"operationType\":\"last_value\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"process.runtime.dotnet.gc.collections.count\",\"filter\":{\"query\":\"\\\"process.runtime.dotnet.gc.collections.count\\\": * AND labels.generation : \\\"gen0\\\" \",\"language\":\"kuery\"},\"params\":{\"sortField\":\"@timestamp\"},\"customLabel\":true},\"2bbcd9e9-15ff-42c1-98a8-65a5b9b4e4c5\":{\"label\":\"Gen 1 collections\",\"dataType\":\"number\",\"operationType\":\"last_value\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"process.runtime.dotnet.gc.collections.count\",\"filter\":{\"query\":\"\\\"process.runtime.dotnet.gc.collections.count\\\": * AND labels.generation : \\\"gen1\\\" \",\"language\":\"kuery\"},\"params\":{\"sortField\":\"@timestamp\"},\"customLabel\":true},\"9f3d67e3-0513-468e-b9b2-6d8780dac3e0\":{\"label\":\"Gen 2 collections\",\"dataType\":\"number\",\"operationType\":\"last_value\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"process.runtime.dotnet.gc.collections.count\",\"filter\":{\"query\":\"\\\"process.runtime.dotnet.gc.collections.count\\\": * AND labels.generation : \\\"gen2\\\" \",\"language\":\"kuery\"},\"params\":{\"sortField\":\"@timestamp\"},\"customLabel\":true},\"21bdcb2d-cee4-4dd9-84cb-5031f073bf85\":{\"label\":\"Managed threads\",\"dataType\":\"number\",\"operationType\":\"last_value\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"process.runtime.dotnet.thread_pool.threads.count\",\"filter\":{\"query\":\"\\\"process.runtime.dotnet.thread_pool.threads.count\\\": *\",\"language\":\"kuery\"},\"params\":{\"sortField\":\"@timestamp\"},\"customLabel\":true}},\"columnOrder\":[\"d5553b2b-25e9-4b94-9697-f04885f4a067\",\"21bdcb2d-cee4-4dd9-84cb-5031f073bf85\",\"b3da070f-3463-4990-b257-40ac399bec87\",\"a7f6a205-1e8f-4b64-b745-efe20c2c6545\",\"2bbcd9e9-15ff-42c1-98a8-65a5b9b4e4c5\",\"9f3d67e3-0513-468e-b9b2-6d8780dac3e0\"],\"sampling\":1,\"ignoreGlobalFilters\":false,\"incompleteColumns\":{},\"indexPatternId\":\"apm_static_data_view_id_default\"}},\"currentIndexPatternId\":\"apm_static_data_view_id_default\"},\"indexpattern\":{\"layers\":{}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"hidePanelTitles\":true,\"enhancements\":{}},\"title\":\"\"},{\"type\":\"lens\",\"gridData\":{\"x\":0,\"y\":8,\"w\":25,\"h\":16,\"i\":\"d0991248-2fad-4f28-bedc-b8723bc45a81\"},\"panelIndex\":\"d0991248-2fad-4f28-bedc-b8723bc45a81\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"The amount of physical memory allocated for this process.\",\"visualizationType\":\"lnsXY\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"APM_STATIC_DATA_VIEW_ID\",\"name\":\"indexpattern-datasource-layer-961b1efd-6f0d-41e4-a72b-5d66237d212b\"}],\"state\":{\"visualization\":{\"legend\":{\"isVisible\":true,\"position\":\"bottom\",\"showSingleSeries\":true},\"valueLabels\":\"hide\",\"fittingFunction\":\"None\",\"yTitle\":\"Allocated physical memory\",\"axisTitlesVisibilitySettings\":{\"x\":false,\"yLeft\":false,\"yRight\":true},\"tickLabelsVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"labelsOrientation\":{\"x\":0,\"yLeft\":0,\"yRight\":0},\"gridlinesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"preferredSeriesType\":\"bar_stacked\",\"layers\":[{\"layerId\":\"961b1efd-6f0d-41e4-a72b-5d66237d212b\",\"accessors\":[\"81968f1a-1c2f-46cb-8276-3dca900342e9\",\"54c0cda3-76f3-4b75-9fe1-2265fa68993c\"],\"position\":\"top\",\"seriesType\":\"line\",\"showGridlines\":false,\"layerType\":\"data\",\"colorMapping\":{\"assignments\":[],\"specialAssignments\":[{\"rule\":{\"type\":\"other\"},\"color\":{\"type\":\"loop\"},\"touched\":false}],\"paletteId\":\"eui_amsterdam_color_blind\",\"colorMode\":{\"type\":\"categorical\"}},\"xAccessor\":\"2405efa8-e18d-426f-822f-3a4551bf97d2\",\"yConfig\":[]}]},\"query\":{\"query\":\"agent.name: \\\"opentelemetry/dotnet\\\" \",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"961b1efd-6f0d-41e4-a72b-5d66237d212b\":{\"columns\":{\"2405efa8-e18d-426f-822f-3a4551bf97d2\":{\"label\":\"@timestamp\",\"dataType\":\"date\",\"operationType\":\"date_histogram\",\"sourceField\":\"@timestamp\",\"isBucketed\":true,\"scale\":\"interval\",\"params\":{\"interval\":\"auto\",\"includeEmptyRows\":true,\"dropPartials\":false}},\"81968f1a-1c2f-46cb-8276-3dca900342e9\":{\"label\":\"Average\",\"dataType\":\"number\",\"operationType\":\"average\",\"sourceField\":\"process.memory.usage\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"format\":{\"id\":\"bytes\",\"params\":{\"decimals\":0}},\"emptyAsNull\":true},\"customLabel\":true},\"54c0cda3-76f3-4b75-9fe1-2265fa68993c\":{\"label\":\"Max\",\"dataType\":\"number\",\"operationType\":\"max\",\"sourceField\":\"process.memory.usage\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"emptyAsNull\":true,\"format\":{\"id\":\"bytes\",\"params\":{\"decimals\":0}}},\"customLabel\":true}},\"columnOrder\":[\"2405efa8-e18d-426f-822f-3a4551bf97d2\",\"81968f1a-1c2f-46cb-8276-3dca900342e9\",\"54c0cda3-76f3-4b75-9fe1-2265fa68993c\"],\"incompleteColumns\":{},\"sampling\":1,\"indexPatternId\":\"apm_static_data_view_id_default\"}},\"currentIndexPatternId\":\"apm_static_data_view_id_default\"},\"indexpattern\":{\"layers\":{}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"description\":\"The amount of physical memory allocated to the .NET process.\",\"enhancements\":{}},\"title\":\"Allocated physical memory\"},{\"type\":\"lens\",\"gridData\":{\"x\":25,\"y\":8,\"w\":23,\"h\":16,\"i\":\"0bf63f9e-8797-4249-85f7-9407c165f732\"},\"panelIndex\":\"0bf63f9e-8797-4249-85f7-9407c165f732\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsXY\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"APM_STATIC_DATA_VIEW_ID\",\"name\":\"indexpattern-datasource-layer-eb4e02de-8962-40fa-9e75-ff25862ca5f3\"}],\"state\":{\"visualization\":{\"legend\":{\"isVisible\":true,\"position\":\"right\",\"maxLines\":1},\"valueLabels\":\"hide\",\"fittingFunction\":\"None\",\"axisTitlesVisibilitySettings\":{\"x\":false,\"yLeft\":false,\"yRight\":true},\"tickLabelsVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"labelsOrientation\":{\"x\":0,\"yLeft\":0,\"yRight\":0},\"gridlinesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"preferredSeriesType\":\"line\",\"layers\":[{\"layerId\":\"eb4e02de-8962-40fa-9e75-ff25862ca5f3\",\"accessors\":[\"cced3ff5-cfa3-4804-93be-c8d893114e93\",\"211c2cbb-033a-454b-b379-186b8d7b247e\",\"5ac14ba1-f6d4-4015-96d1-aeaa3ed63aec\",\"938252b1-14ec-4a64-b3e5-108e096f116b\",\"1e106c40-c845-4368-baaa-4057e1d29d92\"],\"position\":\"top\",\"seriesType\":\"line\",\"showGridlines\":false,\"layerType\":\"data\",\"colorMapping\":{\"assignments\":[],\"specialAssignments\":[{\"rule\":{\"type\":\"other\"},\"color\":{\"type\":\"loop\"},\"touched\":false}],\"paletteId\":\"eui_amsterdam_color_blind\",\"colorMode\":{\"type\":\"categorical\"}},\"xAccessor\":\"cb7bae9c-fdc5-44a8-8ee8-c0762595511c\"}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"eb4e02de-8962-40fa-9e75-ff25862ca5f3\":{\"columns\":{\"cb7bae9c-fdc5-44a8-8ee8-c0762595511c\":{\"label\":\"@timestamp\",\"dataType\":\"date\",\"operationType\":\"date_histogram\",\"sourceField\":\"@timestamp\",\"isBucketed\":true,\"scale\":\"interval\",\"params\":{\"interval\":\"auto\",\"includeEmptyRows\":true,\"dropPartials\":false}},\"cced3ff5-cfa3-4804-93be-c8d893114e93\":{\"label\":\"Gen 0\",\"dataType\":\"number\",\"operationType\":\"average\",\"sourceField\":\"process.runtime.dotnet.gc.heap.size\",\"isBucketed\":false,\"scale\":\"ratio\",\"filter\":{\"query\":\"labels.generation : \\\"gen0\\\" \",\"language\":\"kuery\"},\"params\":{\"format\":{\"id\":\"bytes\",\"params\":{\"decimals\":2}},\"emptyAsNull\":true},\"customLabel\":true},\"211c2cbb-033a-454b-b379-186b8d7b247e\":{\"label\":\"Gen 1\",\"dataType\":\"number\",\"operationType\":\"average\",\"sourceField\":\"process.runtime.dotnet.gc.heap.size\",\"isBucketed\":false,\"scale\":\"ratio\",\"filter\":{\"query\":\"labels.generation : \\\"gen1\\\" \",\"language\":\"kuery\"},\"params\":{\"emptyAsNull\":true,\"format\":{\"id\":\"bytes\",\"params\":{\"decimals\":2}}},\"customLabel\":true},\"5ac14ba1-f6d4-4015-96d1-aeaa3ed63aec\":{\"label\":\"Gen 2\",\"dataType\":\"number\",\"operationType\":\"average\",\"sourceField\":\"process.runtime.dotnet.gc.heap.size\",\"isBucketed\":false,\"scale\":\"ratio\",\"filter\":{\"query\":\"labels.generation : \\\"gen2\\\" \",\"language\":\"kuery\"},\"params\":{\"emptyAsNull\":true,\"format\":{\"id\":\"bytes\",\"params\":{\"decimals\":2}}},\"customLabel\":true},\"938252b1-14ec-4a64-b3e5-108e096f116b\":{\"label\":\"LOH\",\"dataType\":\"number\",\"operationType\":\"average\",\"sourceField\":\"process.runtime.dotnet.gc.heap.size\",\"isBucketed\":false,\"scale\":\"ratio\",\"filter\":{\"query\":\"labels.generation:\\\"loh\\\" \",\"language\":\"kuery\"},\"params\":{\"emptyAsNull\":true,\"format\":{\"id\":\"bytes\",\"params\":{\"decimals\":2}}},\"customLabel\":true},\"1e106c40-c845-4368-baaa-4057e1d29d92\":{\"label\":\"POH\",\"dataType\":\"number\",\"operationType\":\"median\",\"sourceField\":\"process.runtime.dotnet.gc.heap.size\",\"isBucketed\":false,\"scale\":\"ratio\",\"filter\":{\"query\":\"labels.generation : \\\"poh\\\" \",\"language\":\"kuery\"},\"params\":{\"emptyAsNull\":true,\"format\":{\"id\":\"bytes\",\"params\":{\"decimals\":2}}},\"customLabel\":true}},\"columnOrder\":[\"cb7bae9c-fdc5-44a8-8ee8-c0762595511c\",\"cced3ff5-cfa3-4804-93be-c8d893114e93\",\"211c2cbb-033a-454b-b379-186b8d7b247e\",\"5ac14ba1-f6d4-4015-96d1-aeaa3ed63aec\",\"938252b1-14ec-4a64-b3e5-108e096f116b\",\"1e106c40-c845-4368-baaa-4057e1d29d92\"],\"incompleteColumns\":{},\"sampling\":1,\"indexPatternId\":\"apm_static_data_view_id_default\"}},\"currentIndexPatternId\":\"apm_static_data_view_id_default\"},\"indexpattern\":{\"layers\":{}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"enhancements\":{}},\"title\":\"Average GC heap size by generation\"}]","timeRestore":false,"title":".NET OpenTelemetry Runtime Metrics","version":1},"coreMigrationVersion":"8.8.0","created_at":"2024-05-17T13:46:01.942Z","id":"c65be603-2c73-4417-972c-033586a56102","managed":false,"references":[{"id":"apm_static_data_view_id_default","name":"ea9f86f0-ff73-4c92-9b93-41baebdcffab:indexpattern-datasource-layer-1ba88117-6e95-46e2-8667-e0bc15145182","type":"index-pattern"},{"id":"APM_STATIC_DATA_VIEW_ID","name":"d0991248-2fad-4f28-bedc-b8723bc45a81:indexpattern-datasource-layer-961b1efd-6f0d-41e4-a72b-5d66237d212b","type":"index-pattern"},{"id":"APM_STATIC_DATA_VIEW_ID","name":"0bf63f9e-8797-4249-85f7-9407c165f732:indexpattern-datasource-layer-eb4e02de-8962-40fa-9e75-ff25862ca5f3","type":"index-pattern"},{"id":"APM_STATIC_DATA_VIEW_ID","name":"controlGroup_2be66584-9de4-4a36-ba54-bfdd1b4ccfb4:optionsListDataView","type":"index-pattern"}],"type":"dashboard","typeMigrationVersion":"8.9.0","updated_at":"2024-05-17T13:46:01.942Z","version":"WzM0NTMsN10="} \ No newline at end of file diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/service_dashboards/actions/save_dashboard_modal.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/service_dashboards/actions/save_dashboard_modal.tsx index 739fe26342893..5f2f91df44231 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/app/service_dashboards/actions/save_dashboard_modal.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/app/service_dashboards/actions/save_dashboard_modal.tsx @@ -23,7 +23,6 @@ import { EuiButtonEmpty, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { DashboardItem } from '@kbn/dashboard-plugin/common/content_management'; import { callApmApi } from '../../../../services/rest/create_call_apm_api'; import { useDashboardFetcher } from '../../../../hooks/use_dashboards_fetcher'; import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; @@ -76,7 +75,7 @@ export function SaveDashboardModal({ const isEditMode = !!currentDashboard?.id; - const options = allAvailableDashboards?.map((dashboardItem: DashboardItem) => ({ + const options = allAvailableDashboards?.map((dashboardItem) => ({ label: dashboardItem.attributes.title, value: dashboardItem.id, disabled: diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/service_dashboards/empty_dashboards.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/service_dashboards/empty_dashboards.tsx index 9ce282d267f11..9b7ace008206b 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/app/service_dashboards/empty_dashboards.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/app/service_dashboards/empty_dashboards.tsx @@ -59,7 +59,7 @@ export function EmptyDashboards({ actions }: Props) {

{i18n.translate('xpack.apm.serviceDashboards.emptyBody.getStarted', { - defaultMessage: 'To get started, add your dashaboard', + defaultMessage: 'To get started, add your dashboard', })}

diff --git a/x-pack/plugins/observability_solution/apm/public/components/routing/templates/apm_service_template/index.tsx b/x-pack/plugins/observability_solution/apm/public/components/routing/templates/apm_service_template/index.tsx index 0e095694cd538..6c2fdaea96687 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/routing/templates/apm_service_template/index.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/routing/templates/apm_service_template/index.tsx @@ -8,7 +8,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingLogo, EuiSpacer, EuiTitle } from '@elastic/eui'; import React from 'react'; import { useHistory, useLocation } from 'react-router-dom'; -import { isLogsOnlySignal } from '../../../../utils/get_signal_type'; import { isMobileAgentName } from '../../../../../common/agent_name'; import { ApmServiceContextProvider } from '../../../../context/apm_service/apm_service_context'; import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; @@ -55,7 +54,7 @@ function TemplateWithContext({ title, children, selectedTab, searchBarOptions }: const tabs = useTabs({ selectedTab }); - const { agentName, serviceAgentStatus, serviceEntitySummary } = useApmServiceContext(); + const { agentName, serviceAgentStatus } = useApmServiceContext(); const isPendingServiceAgent = !agentName && isPending(serviceAgentStatus); @@ -76,9 +75,6 @@ function TemplateWithContext({ title, children, selectedTab, searchBarOptions }: }); } - const hasLogsOnlySignal = - serviceEntitySummary?.dataStreamTypes && isLogsOnlySignal(serviceEntitySummary.dataStreamTypes); - return ( ) : ( <> - {!hasLogsOnlySignal && } + {children} diff --git a/x-pack/plugins/observability_solution/apm/public/components/shared/transaction_action_menu/__snapshots__/transaction_action_menu.test.tsx.snap b/x-pack/plugins/observability_solution/apm/public/components/shared/transaction_action_menu/__snapshots__/transaction_action_menu.test.tsx.snap index 023caad499485..63f0a9ff2f3ce 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/shared/transaction_action_menu/__snapshots__/transaction_action_menu.test.tsx.snap +++ b/x-pack/plugins/observability_solution/apm/public/components/shared/transaction_action_menu/__snapshots__/transaction_action_menu.test.tsx.snap @@ -3,7 +3,7 @@ exports[`TransactionActionMenu matches the snapshot 1`] = `

@@ -94,23 +100,7 @@ export function KnowledgeBaseEditManualEntryFlyout({ - {!entry ? ( - - setNewEntryId(e.target.value)} - isInvalid={isEntryIdInvalid} - /> - - ) : ( + {entry ? ( @@ -136,7 +126,26 @@ export function KnowledgeBaseEditManualEntryFlyout({
- )} + ) : null} + + + + + setNewEntryTitle(e.target.value)} + isInvalid={isEntryTitleInvalid} + /> + + diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_edit_user_instruction_flyout.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_edit_user_instruction_flyout.tsx index e8152738e3807..1c05ca6d52b3f 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_edit_user_instruction_flyout.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_edit_user_instruction_flyout.tsx @@ -30,19 +30,19 @@ export function KnowledgeBaseEditUserInstructionFlyout({ onClose }: { onClose: ( const { userInstructions, isLoading: isFetching } = useGetUserInstructions(); const { mutateAsync: createEntry, isLoading: isSaving } = useCreateKnowledgeBaseUserInstruction(); const [newEntryText, setNewEntryText] = useState(''); - const [newEntryDocId, setNewEntryDocId] = useState(); + const [newEntryId, setNewEntryId] = useState(); const isSubmitDisabled = newEntryText.trim() === ''; useEffect(() => { const userInstruction = userInstructions?.find((entry) => !entry.public); - setNewEntryDocId(userInstruction?.doc_id); setNewEntryText(userInstruction?.text ?? ''); + setNewEntryId(userInstruction?.id); }, [userInstructions]); const handleSubmit = async () => { await createEntry({ entry: { - doc_id: newEntryDocId ?? uuidv4(), + id: newEntryId ?? uuidv4(), text: newEntryText, public: false, // limit user instructions to private (for now) }, diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_tab.test.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_tab.test.tsx index d8e2897c6878c..50d0cb8ba47c8 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_tab.test.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_tab.test.tsx @@ -81,7 +81,18 @@ describe('KnowledgeBaseTab', () => { getByTestId('knowledgeBaseEditManualEntryFlyoutSaveButton').click(); - expect(createMock).toHaveBeenCalledWith({ entry: { id: 'foo', public: false, text: 'bar' } }); + expect(createMock).toHaveBeenCalledWith({ + entry: { + id: expect.any(String), + title: 'foo', + public: false, + text: 'bar', + role: 'user_entry', + confidence: 'high', + is_correction: false, + labels: expect.any(Object), + }, + }); }); it('should require an id', () => { @@ -126,7 +137,7 @@ describe('KnowledgeBaseTab', () => { entries: [ { id: 'test', - doc_id: 'test', + title: 'test', text: 'test', '@timestamp': 1638340456, labels: {}, @@ -134,7 +145,7 @@ describe('KnowledgeBaseTab', () => { }, { id: 'test2', - doc_id: 'test2', + title: 'test2', text: 'test', '@timestamp': 1638340456, labels: { @@ -144,7 +155,7 @@ describe('KnowledgeBaseTab', () => { }, { id: 'test3', - doc_id: 'test3', + title: 'test3', text: 'test', '@timestamp': 1638340456, labels: { diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_tab.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_tab.tsx index 6ba09101b6227..23fbf796290c8 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_tab.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_tab.tsx @@ -57,10 +57,10 @@ export function KnowledgeBaseTab() { data-test-subj="pluginsColumnsButton" onClick={() => setSelectedCategory(category)} aria-label={ - category.categoryName === selectedCategory?.categoryName ? 'Collapse' : 'Expand' + category.categoryKey === selectedCategory?.categoryKey ? 'Collapse' : 'Expand' } iconType={ - category.categoryName === selectedCategory?.categoryName ? 'minimize' : 'expand' + category.categoryKey === selectedCategory?.categoryKey ? 'minimize' : 'expand' } /> ); @@ -85,7 +85,8 @@ export function KnowledgeBaseTab() { width: '40px', }, { - field: 'categoryName', + 'data-test-subj': 'knowledgeBaseTableTitleCell', + field: 'title', name: i18n.translate('xpack.observabilityAiAssistantManagement.kbTab.columns.name', { defaultMessage: 'Name', }), @@ -107,6 +108,7 @@ export function KnowledgeBaseTab() { }, }, { + 'data-test-subj': 'knowledgeBaseTableAuthorCell', name: i18n.translate('xpack.observabilityAiAssistantManagement.kbTab.columns.author', { defaultMessage: 'Author', }), @@ -183,7 +185,7 @@ export function KnowledgeBaseTab() { const [isNewEntryPopoverOpen, setIsNewEntryPopoverOpen] = useState(false); const [isEditUserInstructionFlyoutOpen, setIsEditUserInstructionFlyoutOpen] = useState(false); const [query, setQuery] = useState(''); - const [sortBy, setSortBy] = useState<'doc_id' | '@timestamp'>('doc_id'); + const [sortBy, setSortBy] = useState('title'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); const { @@ -193,17 +195,10 @@ export function KnowledgeBaseTab() { } = useGetKnowledgeBaseEntries({ query, sortBy, sortDirection }); const categorizedEntries = categorizeEntries({ entries }); - const handleChangeSort = ({ - sort, - }: Criteria) => { + const handleChangeSort = ({ sort }: Criteria) => { if (sort) { const { field, direction } = sort; - if (field === '@timestamp') { - setSortBy(field); - } - if (field === 'categoryName') { - setSortBy('doc_id'); - } + setSortBy(field); setSortDirection(direction); } }; @@ -329,7 +324,7 @@ export function KnowledgeBaseTab() { loading={isLoading} sorting={{ sort: { - field: sortBy === 'doc_id' ? 'categoryName' : sortBy, + field: sortBy, direction: sortDirection, }, }} diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_page.test.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_page.test.tsx index 9a543be1938ea..c4051b9665b57 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_page.test.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_page.test.tsx @@ -8,8 +8,24 @@ import React from 'react'; import { coreStartMock, render } from '../../helpers/test_helper'; import { SettingsPage } from './settings_page'; +import { useKnowledgeBase } from '@kbn/ai-assistant'; + +jest.mock('@kbn/ai-assistant'); + +const useKnowledgeBaseMock = useKnowledgeBase as jest.Mock; describe('Settings Page', () => { + const appContextValue = { + config: { spacesEnabled: true, visibilityEnabled: true, logSourcesEnabled: true }, + setBreadcrumbs: () => {}, + }; + useKnowledgeBaseMock.mockReturnValue({ + status: { + value: { + enabled: true, + }, + }, + }); it('should navigate to home when not authorized', () => { render(, { coreStart: { @@ -21,13 +37,16 @@ describe('Settings Page', () => { }, }, }, + appContextValue, }); expect(coreStartMock.application.navigateToApp).toBeCalledWith('home'); }); it('should render settings and knowledge base tabs', () => { - const { getByTestId } = render(); + const { getByTestId } = render(, { + appContextValue, + }); expect(getByTestId('settingsPageTab-settings')).toBeInTheDocument(); expect(getByTestId('settingsPageTab-knowledge_base')).toBeInTheDocument(); @@ -36,7 +55,7 @@ describe('Settings Page', () => { it('should set breadcrumbs', () => { const setBreadcrumbs = jest.fn(); render(, { - appContextValue: { setBreadcrumbs }, + appContextValue: { ...appContextValue, setBreadcrumbs }, }); expect(setBreadcrumbs).toHaveBeenCalledWith([ diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_page.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_page.tsx index 075aaeb0aeb75..57a167b1080fa 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_page.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_page.tsx @@ -8,6 +8,7 @@ import React, { useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiSpacer, EuiTab, EuiTabs, EuiTitle } from '@elastic/eui'; +import { useKnowledgeBase } from '@kbn/ai-assistant'; import { useAppContext } from '../../hooks/use_app_context'; import { SettingsTab } from './settings_tab/settings_tab'; import { KnowledgeBaseTab } from './knowledge_base_tab'; @@ -28,6 +29,7 @@ export function SettingsPage() { } = useKibana(); const router = useObservabilityAIAssistantManagementRouter(); + const knowledgeBase = useKnowledgeBase(); const { query: { tab }, @@ -85,6 +87,7 @@ export function SettingsPage() { } ), content: , + disabled: !knowledgeBase.status.value?.enabled, }, { id: 'search_connector', diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/settings_tab.test.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/settings_tab.test.tsx index 2bed5aed37160..02ee9ba06b1ee 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/settings_tab.test.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/settings_tab.test.tsx @@ -9,10 +9,14 @@ import React from 'react'; import { fireEvent } from '@testing-library/react'; import { render } from '../../../helpers/test_helper'; import { SettingsTab } from './settings_tab'; +import { useAppContext } from '../../../hooks/use_app_context'; jest.mock('../../../hooks/use_app_context'); +const useAppContextMock = useAppContext as jest.Mock; + describe('SettingsTab', () => { + useAppContextMock.mockReturnValue({ config: { spacesEnabled: true, visibilityEnabled: true } }); it('should offer a way to configure Observability AI Assistant visibility in apps', () => { const navigateToAppMock = jest.fn(() => Promise.resolve()); const { getByTestId } = render(, { diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/settings_tab.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/settings_tab.tsx index 71b758f27f580..831ba9ff58054 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/settings_tab.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/settings_tab.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { EuiButton, EuiDescribedFormGroup, EuiFormRow, EuiPanel } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { useAppContext } from '../../../hooks/use_app_context'; import { useKibana } from '../../../hooks/use_kibana'; import { UISettings } from './ui_settings'; @@ -15,6 +16,7 @@ export function SettingsTab() { const { application: { navigateToApp }, } = useKibana().services; + const { config } = useAppContext(); const handleNavigateToConnectors = () => { navigateToApp('management', { @@ -30,44 +32,46 @@ export function SettingsTab() { return ( - - {i18n.translate( - 'xpack.observabilityAiAssistantManagement.settingsPage.showAIAssistantButtonLabel', - { - defaultMessage: - 'Show AI Assistant button and Contextual Insights in Observability apps', - } - )} -

- } - description={ -

- {i18n.translate( - 'xpack.observabilityAiAssistantManagement.settingsPage.showAIAssistantDescriptionLabel', - { - defaultMessage: - 'Toggle the AI Assistant button and Contextual Insights on or off in Observability apps by checking or unchecking the AI Assistant feature in Spaces > > Features.', - ignoreTag: true, - } - )} -

- } - > - - - {i18n.translate( - 'xpack.observabilityAiAssistantManagement.settingsPage.goToFeatureControlsButtonLabel', - { defaultMessage: 'Go to Spaces' } - )} - - - + {config.spacesEnabled && ( + + {i18n.translate( + 'xpack.observabilityAiAssistantManagement.settingsPage.showAIAssistantButtonLabel', + { + defaultMessage: + 'Show AI Assistant button and Contextual Insights in Observability apps', + } + )} +

+ } + description={ +

+ {i18n.translate( + 'xpack.observabilityAiAssistantManagement.settingsPage.showAIAssistantDescriptionLabel', + { + defaultMessage: + 'Toggle the AI Assistant button and Contextual Insights on or off in Observability apps by checking or unchecking the AI Assistant feature in Spaces > > Features.', + ignoreTag: true, + } + )} +

+ } + > + + + {i18n.translate( + 'xpack.observabilityAiAssistantManagement.settingsPage.goToFeatureControlsButtonLabel', + { defaultMessage: 'Go to Spaces' } + )} + + + + )} ); })} - - + {config.logSourcesEnabled && ( + + )} {!isEmpty(unsavedChanges) && ( ; + +export const config: PluginConfigDescriptor = { + schema: configSchema, + exposeToBrowser: { logSourcesEnabled: true, spacesEnabled: true, visibilityEnabled: true }, +}; diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/server/index.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_management/server/index.ts index 55332dbba35c5..1592b6f4cd72e 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/server/index.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/server/index.ts @@ -5,6 +5,8 @@ * 2.0. */ +export { config } from './config'; + export const plugin = async () => { const { AiAssistantManagementPlugin } = await import('./plugin'); return new AiAssistantManagementPlugin(); diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/tsconfig.json b/x-pack/plugins/observability_solution/observability_ai_assistant_management/tsconfig.json index 12148ec014725..f0ad230f6f1b3 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/tsconfig.json +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/tsconfig.json @@ -3,7 +3,11 @@ "compilerOptions": { "outDir": "target/types" }, - "include": ["common/**/*", "public/**/*", "server/**/*"], + "include": [ + "common/**/*", + "public/**/*", + "server/**/*" + ], "kbn_references": [ "@kbn/core", "@kbn/home-plugin", @@ -15,13 +19,17 @@ "@kbn/core-chrome-browser", "@kbn/observability-ai-assistant-plugin", "@kbn/serverless", - "@kbn/translations-plugin", "@kbn/enterprise-search-plugin", "@kbn/management-settings-components-field-row", "@kbn/observability-shared-plugin", "@kbn/config-schema", "@kbn/core-ui-settings-common", "@kbn/logs-data-access-plugin", + "@kbn/core-plugins-browser", + "@kbn/ai-assistant", + "@kbn/core-plugins-server" ], - "exclude": ["target/**/*"] + "exclude": [ + "target/**/*" + ] } diff --git a/x-pack/plugins/observability_solution/observability_logs_explorer/kibana.jsonc b/x-pack/plugins/observability_solution/observability_logs_explorer/kibana.jsonc index 98073fc2b6f21..40f678ac0a15e 100644 --- a/x-pack/plugins/observability_solution/observability_logs_explorer/kibana.jsonc +++ b/x-pack/plugins/observability_solution/observability_logs_explorer/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/observability-logs-explorer-plugin", - "owner": "@elastic/obs-ux-logs-team", + "owner": [ + "@elastic/obs-ux-logs-team" + ], + "group": "observability", + "visibility": "private", "description": "This plugin exposes and registers observability log consumption features.", "plugin": { "id": "observabilityLogsExplorer", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "observabilityLogsExplorer" @@ -40,4 +44,4 @@ "common" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/observability_solution/observability_onboarding/e2e/kibana.jsonc b/x-pack/plugins/observability_solution/observability_onboarding/e2e/kibana.jsonc index 551d012935b44..655ccc396d3fe 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/e2e/kibana.jsonc +++ b/x-pack/plugins/observability_solution/observability_onboarding/e2e/kibana.jsonc @@ -5,5 +5,7 @@ "@elastic/obs-ux-logs-team", "@elastic/obs-ux-onboarding-team" ], + "group": "observability", + "visibility": "private", "devOnly": true } diff --git a/x-pack/plugins/observability_solution/observability_onboarding/kibana.jsonc b/x-pack/plugins/observability_solution/observability_onboarding/kibana.jsonc index 859f9539bd9fa..8c24f5376a4bb 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/kibana.jsonc +++ b/x-pack/plugins/observability_solution/observability_onboarding/kibana.jsonc @@ -1,12 +1,20 @@ { "type": "plugin", "id": "@kbn/observability-onboarding-plugin", - "owner": ["@elastic/obs-ux-logs-team", "@elastic/obs-ux-onboarding-team"], + "owner": [ + "@elastic/obs-ux-logs-team", + "@elastic/obs-ux-onboarding-team" + ], + "group": "observability", + "visibility": "private", "plugin": { "id": "observabilityOnboarding", - "server": true, "browser": true, - "configPath": ["xpack", "observability_onboarding"], + "server": true, + "configPath": [ + "xpack", + "observability_onboarding" + ], "requiredPlugins": [ "data", "observability", @@ -16,8 +24,15 @@ "fleet", "customIntegrations" ], - "optionalPlugins": ["cloud", "usageCollection"], - "requiredBundles": ["kibanaReact"], - "extraPublicDirs": ["common"] + "optionalPlugins": [ + "cloud", + "usageCollection" + ], + "requiredBundles": [ + "kibanaReact" + ], + "extraPublicDirs": [ + "common" + ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/application/onboarding_flow_form/use_custom_cards_for_category.tsx b/x-pack/plugins/observability_solution/observability_onboarding/public/application/onboarding_flow_form/use_custom_cards_for_category.tsx index eb359f6158030..d6a72e25a2a9d 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/public/application/onboarding_flow_form/use_custom_cards_for_category.tsx +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/application/onboarding_flow_form/use_custom_cards_for_category.tsx @@ -51,7 +51,7 @@ export function useCustomCardsForCategory( title: i18n.translate( 'xpack.observability_onboarding.useCustomCardsForCategory.autoDetectTitle', { - defaultMessage: 'Auto-detect Integrations with Elastic Agent', + defaultMessage: 'Elastic Agent: Logs & Metrics', } ), description: i18n.translate( @@ -79,7 +79,6 @@ export function useCustomCardsForCategory( version: '', integration: '', isQuickstart: true, - release: 'preview', }, { id: 'otel-logs', @@ -88,7 +87,7 @@ export function useCustomCardsForCategory( title: i18n.translate( 'xpack.observability_onboarding.useCustomCardsForCategory.logsOtelTitle', { - defaultMessage: 'Host monitoring with EDOT Collector', + defaultMessage: 'OpenTelemetry: Logs & Metrics', } ), description: i18n.translate( @@ -130,14 +129,13 @@ export function useCustomCardsForCategory( title: i18n.translate( 'xpack.observability_onboarding.useCustomCardsForCategory.kubernetesTitle', { - defaultMessage: 'Kubernetes monitoring with Elastic Agent', + defaultMessage: 'Elastic Agent: Logs & Metrics', } ), description: i18n.translate( 'xpack.observability_onboarding.useCustomCardsForCategory.kubernetesDescription', { - defaultMessage: - 'Monitor your Kubernetes cluster with Elastic Agent, collect container logs', + defaultMessage: 'Collect logs and metrics from Kubernetes using Elastic Agent', } ), extraLabelsBadges: [ @@ -156,7 +154,6 @@ export function useCustomCardsForCategory( version: '', integration: '', isQuickstart: true, - release: 'preview', }, { id: 'otel-kubernetes', @@ -165,14 +162,14 @@ export function useCustomCardsForCategory( title: i18n.translate( 'xpack.observability_onboarding.useCustomCardsForCategory.kubernetesOtelTitle', { - defaultMessage: 'Kubernetes monitoring with EDOT Collector', + defaultMessage: 'OpenTelemetry: Full Observability', } ), description: i18n.translate( 'xpack.observability_onboarding.useCustomCardsForCategory.kubernetesOtelDescription', { defaultMessage: - 'Unified Kubernetes observability with Elastic Distro for OTel Collector', + 'Collect logs, traces and metrics with the Elastic Distro for OTel Collector', } ), extraLabelsBadges: [ diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/application/pages/auto_detect.tsx b/x-pack/plugins/observability_solution/observability_onboarding/public/application/pages/auto_detect.tsx index 7dc3d0acb0a2e..585e1061291a5 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/public/application/pages/auto_detect.tsx +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/application/pages/auto_detect.tsx @@ -29,7 +29,6 @@ export const AutoDetectPage = () => ( 'This installation scans your host and auto-detects log and metric files.', } )} - isTechnicalPreview={true} /> } > diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/application/pages/kubernetes.tsx b/x-pack/plugins/observability_solution/observability_onboarding/public/application/pages/kubernetes.tsx index f92b1d9a83ac6..8e1af954736c1 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/public/application/pages/kubernetes.tsx +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/application/pages/kubernetes.tsx @@ -29,7 +29,6 @@ export const KubernetesPage = () => ( 'This installation is tailored for configuring and collecting metrics and logs by deploying a new Elastic Agent within your host.', } )} - isTechnicalPreview={true} /> } > diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/application/quickstart_flows/otel_kubernetes/otel_kubernetes_panel.tsx b/x-pack/plugins/observability_solution/observability_onboarding/public/application/quickstart_flows/otel_kubernetes/otel_kubernetes_panel.tsx index 6cbfe740fa784..942cecb13aeeb 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/public/application/quickstart_flows/otel_kubernetes/otel_kubernetes_panel.tsx +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/application/quickstart_flows/otel_kubernetes/otel_kubernetes_panel.tsx @@ -101,7 +101,7 @@ helm install opentelemetry-kube-stack open-telemetry/opentelemetry-kube-stack \\

), + doc: ( + + {i18n.translate( + 'xpack.observability_onboarding.otelKubernetesPanel.certmanagerDocsLinkLabel', + { defaultMessage: 'in our documentation' } + )} + + ), }} />{' '} @@ -213,17 +225,33 @@ helm install opentelemetry-kube-stack open-telemetry/opentelemetry-kube-stack \\ ]} /> - - {`apiVersion: v1 -kind: Pod + + {`# To annotate specific deployment Pods modify its manifest +apiVersion: apps/v1 +kind: Deployment metadata: - name: my-app - annotations: - instrumentation.opentelemetry.io/inject-${idSelected}: "${namespace}/elastic-instrumentation" + name: myapp spec: - containers: - - name: my-app - image: my-app:latest`} + ... + template: + metadata: + annotations: + instrumentation.opentelemetry.io/inject-${idSelected}: "${namespace}/elastic-instrumentation" + ... + spec: + containers: + - image: myapplication-image + name: app + ... + +# To annotate all resources in a namespace +kubectl annotate namespace my-namespace instrumentation.opentelemetry.io/inject-${idSelected}="${namespace}/elastic-instrumentation" + +# Restart your deployment +kubectl rollout restart deployment myapp -n my-namespace + +# Check annotations have been applied correctly and auto-instrumentation library is injected +kubectl describe pod -n my-namespace`} diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/assets/auto_detect.sh b/x-pack/plugins/observability_solution/observability_onboarding/public/assets/auto_detect.sh index ebdcdeb0d81dc..dd3077180d08e 100755 --- a/x-pack/plugins/observability_solution/observability_onboarding/public/assets/auto_detect.sh +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/assets/auto_detect.sh @@ -311,15 +311,32 @@ apply_elastic_agent_config() { read_open_log_file_list() { local exclude_patterns=( - "^\/Users\/.+?\/Library\/Application Support" - "^\/Users\/.+?\/Library\/Group Containers" - "^\/Users\/.+?\/Library\/Containers" - "^\/Users\/.+?\/Library\/Caches" - "^\/private" + "^\/Users\/.+?\/Library\/Application Support\/" + "^\/Users\/.+?\/Library\/Group Containers\/" + "^\/Users\/.+?\/Library\/Containers\/" + "^\/Users\/.+?\/Library\/Caches\/" + "^\/private\/" + + # Integrations only ingest a subset of application logs so there are scenarios where additional + # log files could be detected and displayed as a "custom log" alongside the detected integration + # they belong to. To avoid this UX issue we exclude all log files inside application directories + # from the custom log file detection + "^\/var\/log\/nginx\/" + "^\/var\/log\/apache2\/" + "^\/var\/log\/httpd\/" + "^\/var\/log\/mysql\/" + "^\/var\/log\/postgresql\/" + "^\/var\/log\/redis\/" + "^\/var\/log\/rabbitmq\/" + "^\/var\/log\/kafka\/" + "^\/var\/lib\/docker\/" + "^\/var\/log\/mongodb\/" + "^\/opt\/tomcat\/logs\/" + "^\/var\/log\/prometheus\/" # Exclude previous installation logs - "\/opt\/Elastic\/Agent\/" - "\/Library\/Elastic\/Agent\/" + "^\/opt\/Elastic\/Agent\/" + "^\/Library\/Elastic\/Agent\/" ) # Excluding all patterns that correspond to known integrations diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/assets/integrations.conf b/x-pack/plugins/observability_solution/observability_onboarding/public/assets/integrations.conf index e6455a9170c86..e9afdc3cdfb2e 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/public/assets/integrations.conf +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/assets/integrations.conf @@ -34,9 +34,9 @@ patterns= title=PostgreSQL Logs patterns= /var/log/postgresql/postgresql-*-*.log* + /var/log/postgresql/postgresql-*-*.csv* /*/postgresql-logs/*.log /etc/postgresql/*/main/postgresql.conf - /var/log/postgresql/postgresql-*-*.csv* [redis] title=Redis Logs diff --git a/x-pack/plugins/observability_solution/observability_shared/common/entity/entity_types.ts b/x-pack/plugins/observability_solution/observability_shared/common/entity/entity_types.ts index b905f542d3473..4d8be9efc59c6 100644 --- a/x-pack/plugins/observability_solution/observability_shared/common/entity/entity_types.ts +++ b/x-pack/plugins/observability_solution/observability_shared/common/entity/entity_types.ts @@ -5,7 +5,25 @@ * 2.0. */ -export enum EntityType { - HOST = 'host', - CONTAINER = 'container', -} +const createKubernetesEntity = (base: T) => ({ + ecs: `kubernetes_${base}_ecs` as const, + semconv: `kubernetes_${base}_semconv` as const, +}); + +export const ENTITY_TYPES = { + HOST: 'host', + CONTAINER: 'container', + SERVICE: 'service', + KUBERNETES: { + CLUSTER: createKubernetesEntity('cluster'), + CONTAINER: createKubernetesEntity('container'), + CRONJOB: createKubernetesEntity('cron_job'), + DAEMONSET: createKubernetesEntity('daemon_set'), + DEPLOYMENT: createKubernetesEntity('deployment'), + JOB: createKubernetesEntity('job'), + NAMESPACE: createKubernetesEntity('namespace'), + NODE: createKubernetesEntity('node'), + POD: createKubernetesEntity('pod'), + STATEFULSET: createKubernetesEntity('stateful_set'), + }, +} as const; diff --git a/x-pack/plugins/observability_solution/observability_shared/common/entity/index.ts b/x-pack/plugins/observability_solution/observability_shared/common/entity/index.ts index 27bef43d5ff7a..adc07a2931b60 100644 --- a/x-pack/plugins/observability_solution/observability_shared/common/entity/index.ts +++ b/x-pack/plugins/observability_solution/observability_shared/common/entity/index.ts @@ -5,5 +5,5 @@ * 2.0. */ -export { EntityType } from './entity_types'; +export { ENTITY_TYPES } from './entity_types'; export { EntityDataStreamType } from './entity_data_stream_types'; diff --git a/x-pack/plugins/observability_solution/observability_shared/common/index.ts b/x-pack/plugins/observability_solution/observability_shared/common/index.ts index e9be61e8fde34..b4b7731d166b7 100644 --- a/x-pack/plugins/observability_solution/observability_shared/common/index.ts +++ b/x-pack/plugins/observability_solution/observability_shared/common/index.ts @@ -193,6 +193,7 @@ export type { export { ServiceOverviewLocatorDefinition, + SERVICE_OVERVIEW_LOCATOR_ID, TransactionDetailsByNameLocatorDefinition, ASSET_DETAILS_FLYOUT_LOCATOR_ID, AssetDetailsFlyoutLocatorDefinition, @@ -218,4 +219,4 @@ export { export { COMMON_OBSERVABILITY_GROUPING } from './embeddable_grouping'; -export { EntityType, EntityDataStreamType } from './entity'; +export { ENTITY_TYPES, EntityDataStreamType } from './entity'; diff --git a/x-pack/plugins/observability_solution/observability_shared/common/locators/apm/service_overview_locator.ts b/x-pack/plugins/observability_solution/observability_shared/common/locators/apm/service_overview_locator.ts index 2a4e8aac330ec..e216640f31b4f 100644 --- a/x-pack/plugins/observability_solution/observability_shared/common/locators/apm/service_overview_locator.ts +++ b/x-pack/plugins/observability_solution/observability_shared/common/locators/apm/service_overview_locator.ts @@ -16,9 +16,10 @@ export interface ServiceOverviewParams extends SerializableRecord { } export type ServiceOverviewLocator = LocatorPublic; +export const SERVICE_OVERVIEW_LOCATOR_ID = 'serviceOverviewLocator'; export class ServiceOverviewLocatorDefinition implements LocatorDefinition { - public readonly id = 'serviceOverviewLocator'; + public readonly id = SERVICE_OVERVIEW_LOCATOR_ID; public readonly getLocation = async ({ rangeFrom, diff --git a/x-pack/plugins/observability_solution/observability_shared/kibana.jsonc b/x-pack/plugins/observability_solution/observability_shared/kibana.jsonc index 3409c8c11525f..a5cde081c7c54 100644 --- a/x-pack/plugins/observability_solution/observability_shared/kibana.jsonc +++ b/x-pack/plugins/observability_solution/observability_shared/kibana.jsonc @@ -1,15 +1,36 @@ { "type": "plugin", "id": "@kbn/observability-shared-plugin", - "owner": "@elastic/observability-ui", + "owner": [ + "@elastic/observability-ui" + ], + "group": "observability", + "visibility": "private", "plugin": { "id": "observabilityShared", - "server": false, "browser": true, - "configPath": ["xpack", "observability_shared"], - "requiredPlugins": ["cases", "uiActions", "embeddable", "share"], - "optionalPlugins": ["guidedOnboarding"], - "requiredBundles": ["data", "inspector", "kibanaReact", "kibanaUtils"], - "extraPublicDirs": ["common"] + "server": false, + "configPath": [ + "xpack", + "observability_shared" + ], + "requiredPlugins": [ + "cases", + "uiActions", + "embeddable", + "share" + ], + "optionalPlugins": [ + "guidedOnboarding" + ], + "requiredBundles": [ + "data", + "inspector", + "kibanaReact", + "kibanaUtils" + ], + "extraPublicDirs": [ + "common" + ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/observability_solution/profiling/kibana.jsonc b/x-pack/plugins/observability_solution/profiling/kibana.jsonc index 329da8be36f1a..e304f0c77e548 100644 --- a/x-pack/plugins/observability_solution/profiling/kibana.jsonc +++ b/x-pack/plugins/observability_solution/profiling/kibana.jsonc @@ -1,20 +1,18 @@ { "type": "plugin", "id": "@kbn/profiling-plugin", - "owner": "@elastic/obs-ux-infra_services-team", + "owner": [ + "@elastic/obs-ux-infra_services-team" + ], + "group": "observability", + "visibility": "private", "plugin": { "id": "profiling", - "server": true, "browser": true, - "configPath": ["xpack", "profiling"], - "optionalPlugins": [ - "spaces", - "usageCollection", - "security", - "cloud", - "fleet", - "observabilityAIAssistant", - "apmDataAccess", + "server": true, + "configPath": [ + "xpack", + "profiling" ], "requiredPlugins": [ "charts", @@ -28,9 +26,18 @@ "share", "profilingDataAccess" ], + "optionalPlugins": [ + "spaces", + "usageCollection", + "security", + "cloud", + "fleet", + "observabilityAIAssistant", + "apmDataAccess" + ], "requiredBundles": [ "kibanaReact", "kibanaUtils" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/observability_solution/profiling_data_access/kibana.jsonc b/x-pack/plugins/observability_solution/profiling_data_access/kibana.jsonc index a2c3fb4cb267b..47a11e19dad3d 100644 --- a/x-pack/plugins/observability_solution/profiling_data_access/kibana.jsonc +++ b/x-pack/plugins/observability_solution/profiling_data_access/kibana.jsonc @@ -1,16 +1,26 @@ { "type": "plugin", "id": "@kbn/profiling-data-access-plugin", - "owner": "@elastic/obs-ux-infra_services-team", + "owner": [ + "@elastic/obs-ux-infra_services-team" + ], + "group": "observability", + "visibility": "private", "plugin": { "id": "profilingDataAccess", - "server": true, "browser": false, - "configPath": ["xpack", "profiling"], + "server": true, + "configPath": [ + "xpack", + "profiling" + ], "requiredPlugins": [ - "data", + "data" + ], + "optionalPlugins": [ + "cloud", + "fleet" ], - "optionalPlugins": ["cloud", "fleet"], "requiredBundles": [] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/observability_solution/slo/kibana.jsonc b/x-pack/plugins/observability_solution/slo/kibana.jsonc index c00145f96362e..c1054089c508a 100644 --- a/x-pack/plugins/observability_solution/slo/kibana.jsonc +++ b/x-pack/plugins/observability_solution/slo/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/slo-plugin", - "owner": "@elastic/obs-ux-management-team", + "owner": [ + "@elastic/obs-ux-management-team" + ], + "group": "observability", + "visibility": "private", "plugin": { "id": "slo", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "slo" @@ -52,4 +56,4 @@ "ingestPipelines" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/observability_solution/slo/public/embeddable/slo/overview/slo_overview_grid.tsx b/x-pack/plugins/observability_solution/slo/public/embeddable/slo/overview/slo_overview_grid.tsx index 1ca47e02f4df3..f452f77cb1da3 100644 --- a/x-pack/plugins/observability_solution/slo/public/embeddable/slo/overview/slo_overview_grid.tsx +++ b/x-pack/plugins/observability_solution/slo/public/embeddable/slo/overview/slo_overview_grid.tsx @@ -9,7 +9,6 @@ import React from 'react'; import { ALL_VALUE, HistoricalSummaryResponse, SLOWithSummaryResponse } from '@kbn/slo-schema'; import { Chart, - DARK_THEME, isMetricElementEvent, Metric, MetricTrendShape, @@ -73,12 +72,18 @@ const getSloChartData = ({ }; }; +const ROW_HEIGHT = 220; +const ITEMS_PER_ROW = 4; + export function SloCardChartList({ sloId }: { sloId: string }) { const { http: { basePath }, uiSettings, + charts, } = useKibana().services; + const baseTheme = charts.theme.useChartsBaseTheme(); + const [selectedSlo, setSelectedSlo] = React.useState(null); const kqlQuery = `slo.id:"${sloId}"`; @@ -89,6 +94,7 @@ export function SloCardChartList({ sloId }: { sloId: string }) { const { data: activeAlertsBySlo } = useFetchActiveAlerts({ sloIdsAndInstanceIds: [[sloId, ALL_VALUE]], + rangeFrom: 'now-24h', }); const { data: rulesBySlo } = useFetchRulesForSlo({ @@ -151,16 +157,24 @@ export function SloCardChartList({ sloId }: { sloId: string }) { ); } + const height = sloList?.results + ? ROW_HEIGHT * Math.ceil(sloList.results.length / ITEMS_PER_ROW) + : ROW_HEIGHT; + return ( <> -

- +
+ { if (isMetricElementEvent(d)) { const { columnIndex, rowIndex } = d; - const slo = sloList?.results[rowIndex * 4 + columnIndex]; + const slo = sloList?.results[rowIndex * ITEMS_PER_ROW + columnIndex]; setSelectedSlo(slo ?? null); } }} diff --git a/x-pack/plugins/observability_solution/slo/public/hooks/use_fetch_active_alerts.ts b/x-pack/plugins/observability_solution/slo/public/hooks/use_fetch_active_alerts.ts index 1f353e6a38558..6ad34d8c4dc86 100644 --- a/x-pack/plugins/observability_solution/slo/public/hooks/use_fetch_active_alerts.ts +++ b/x-pack/plugins/observability_solution/slo/public/hooks/use_fetch_active_alerts.ts @@ -20,6 +20,7 @@ type SloIdAndInstanceId = [string, string]; interface Params { sloIdsAndInstanceIds: SloIdAndInstanceId[]; shouldRefetch?: boolean; + rangeFrom?: string; } export interface UseFetchActiveAlerts { @@ -46,6 +47,7 @@ const EMPTY_ACTIVE_ALERTS_MAP = new ActiveAlerts(); export function useFetchActiveAlerts({ sloIdsAndInstanceIds = [], shouldRefetch = false, + rangeFrom = 'now-5m/m', }: Params): UseFetchActiveAlerts { const { http } = useKibana().services; @@ -63,7 +65,7 @@ export function useFetchActiveAlerts({ { range: { '@timestamp': { - gte: 'now-5m/m', + gte: rangeFrom, }, }, }, diff --git a/x-pack/plugins/observability_solution/slo/public/pages/slo_edit/components/common/query_search_bar.tsx b/x-pack/plugins/observability_solution/slo/public/pages/slo_edit/components/common/query_search_bar.tsx index d238aacf1df60..394d8c303e953 100644 --- a/x-pack/plugins/observability_solution/slo/public/pages/slo_edit/components/common/query_search_bar.tsx +++ b/x-pack/plugins/observability_solution/slo/public/pages/slo_edit/components/common/query_search_bar.tsx @@ -69,7 +69,7 @@ export const QuerySearchBar = memo( field.onChange(String(value?.query)); } else { field.onChange({ - ...(field.value ?? {}), + filters: field.value?.filters ?? [], kqlQuery: String(value?.query), }); } @@ -111,15 +111,27 @@ export const QuerySearchBar = memo( } onQuerySubmit={(value) => handleQueryChange(value.query, value.dateRange)} onFiltersUpdated={(filters) => { + const updatedFilters = filters.map((filter) => { + const { $state, meta, ...rest } = filter; + const query = filter?.query ? { ...filter.query } : { ...rest }; + return { + meta: { + ...meta, + alias: meta?.alias ?? JSON.stringify(query), + }, + query, + }; + }); + if (kqlQuerySchema.is(field.value)) { field.onChange({ - filters, + filters: updatedFilters, kqlQuery: field.value, }); } else { field.onChange({ - ...(field.value ?? {}), - filters, + kqlQuery: field.value?.kqlQuery ?? '', + filters: updatedFilters, }); } }} diff --git a/x-pack/plugins/observability_solution/slo/public/pages/slos/components/card_view/slo_card_item_badges.tsx b/x-pack/plugins/observability_solution/slo/public/pages/slos/components/card_view/slo_card_item_badges.tsx index ee441468fab2c..5166baaf7d311 100644 --- a/x-pack/plugins/observability_solution/slo/public/pages/slos/components/card_view/slo_card_item_badges.tsx +++ b/x-pack/plugins/observability_solution/slo/public/pages/slos/components/card_view/slo_card_item_badges.tsx @@ -10,6 +10,7 @@ import { SLOWithSummaryResponse } from '@kbn/slo-schema'; import { Rule } from '@kbn/triggers-actions-ui-plugin/public'; import React, { useCallback } from 'react'; import styled from 'styled-components'; +import { SloIndicatorTypeBadge } from '../badges/slo_indicator_type_badge'; import { SloActiveAlertsBadge } from '../../../../components/slo/slo_status_badge/slo_active_alerts_badge'; import { BurnRateRuleParams } from '../../../../typings'; import { useUrlSearchState } from '../../hooks/use_url_search_state'; @@ -45,6 +46,11 @@ export function SloCardItemBadges({ slo, activeAlerts, rules, handleCreateRule } [onStateChange] ); + const isRemote = !!slo.remote; + + // in this case, there is more space to display tags + const numberOfTagsToDisplay = !isRemote || (rules ?? []).length > 0 ? 2 : 1; + return ( { @@ -57,13 +63,14 @@ export function SloCardItemBadges({ slo, activeAlerts, rules, handleCreateRule } ) : ( <> + { return testLibRender( // @ts-ignore - + ({ id: getSLOPipelineId(slo.id, slo.revision), description: `Ingest pipeline for SLO rollup data [id: ${slo.id}, revision: ${slo.revision}]`, processors: [ + { + set: { + field: '_id', + value: `{{{_id}}}-${slo.id}-${slo.revision}`, + }, + }, { set: { field: 'event.ingested', diff --git a/x-pack/plugins/observability_solution/slo/server/services/__snapshots__/create_slo.test.ts.snap b/x-pack/plugins/observability_solution/slo/server/services/__snapshots__/create_slo.test.ts.snap index d747d5083cd28..46acf96dde5c3 100644 --- a/x-pack/plugins/observability_solution/slo/server/services/__snapshots__/create_slo.test.ts.snap +++ b/x-pack/plugins/observability_solution/slo/server/services/__snapshots__/create_slo.test.ts.snap @@ -12,6 +12,12 @@ Array [ "description": "Ingest pipeline for SLO rollup data [id: unique-id, revision: 1]", "id": ".slo-observability.sli.pipeline-unique-id-1", "processors": Array [ + Object { + "set": Object { + "field": "_id", + "value": "{{{_id}}}-unique-id-1", + }, + }, Object { "set": Object { "field": "event.ingested", diff --git a/x-pack/plugins/observability_solution/slo/server/services/__snapshots__/reset_slo.test.ts.snap b/x-pack/plugins/observability_solution/slo/server/services/__snapshots__/reset_slo.test.ts.snap index 00dc9bb4654ae..90690a4989586 100644 --- a/x-pack/plugins/observability_solution/slo/server/services/__snapshots__/reset_slo.test.ts.snap +++ b/x-pack/plugins/observability_solution/slo/server/services/__snapshots__/reset_slo.test.ts.snap @@ -208,6 +208,12 @@ exports[`ResetSLO resets all associated resources 8`] = ` "description": "Ingest pipeline for SLO rollup data [id: irrelevant, revision: 1]", "id": ".slo-observability.sli.pipeline-irrelevant-1", "processors": Array [ + Object { + "set": Object { + "field": "_id", + "value": "{{{_id}}}-irrelevant-1", + }, + }, Object { "set": Object { "field": "event.ingested", diff --git a/x-pack/plugins/observability_solution/slo/server/services/transform_generators/__snapshots__/synthetics_availability.test.ts.snap b/x-pack/plugins/observability_solution/slo/server/services/transform_generators/__snapshots__/synthetics_availability.test.ts.snap new file mode 100644 index 0000000000000..3c71844678885 --- /dev/null +++ b/x-pack/plugins/observability_solution/slo/server/services/transform_generators/__snapshots__/synthetics_availability.test.ts.snap @@ -0,0 +1,117 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Synthetics Availability Transform Generator returns the expected transform params 1`] = ` +Object { + "_meta": Object { + "managed": true, + "managed_by": "observability", + "version": 3.3, + }, + "defer_validation": true, + "description": "Rolled-up SLI data for SLO: irrelevant [id: irrelevant, revision: 1]", + "dest": Object { + "index": ".slo-observability.sli-v3.3", + "pipeline": ".slo-observability.sli.pipeline-irrelevant-1", + }, + "frequency": "1m", + "pivot": Object { + "aggregations": Object { + "slo.denominator": Object { + "filter": Object { + "term": Object { + "summary.final_attempt": true, + }, + }, + }, + "slo.numerator": Object { + "filter": Object { + "term": Object { + "monitor.status": "up", + }, + }, + }, + }, + "group_by": Object { + "@timestamp": Object { + "date_histogram": Object { + "field": "@timestamp", + "fixed_interval": "1m", + }, + }, + "monitor.config_id": Object { + "terms": Object { + "field": "config_id", + }, + }, + "monitor.name": Object { + "terms": Object { + "field": "monitor.name", + }, + }, + "observer.geo.name": Object { + "terms": Object { + "field": "observer.geo.name", + }, + }, + "observer.name": Object { + "terms": Object { + "field": "observer.name", + }, + }, + "slo.groupings.monitor.id": Object { + "terms": Object { + "field": "monitor.id", + }, + }, + "slo.groupings.monitor.name": Object { + "terms": Object { + "field": "monitor.name", + }, + }, + "slo.groupings.observer.geo.name": Object { + "terms": Object { + "field": "observer.geo.name", + }, + }, + }, + }, + "settings": Object { + "deduce_mappings": false, + "unattended": true, + }, + "source": Object { + "index": "synthetics-*", + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "summary.final_attempt": true, + }, + }, + Object { + "term": Object { + "meta.space_id": "custom-space", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "gte": "now-7d/d", + }, + }, + }, + ], + }, + }, + "runtime_mappings": Object {}, + }, + "sync": Object { + "time": Object { + "delay": "1m", + "field": "event.ingested", + }, + }, + "transform_id": "slo-irrelevant-1", +} +`; diff --git a/x-pack/plugins/observability_solution/slo/server/services/transform_generators/__snapshots__/transform_generator.test.ts.snap b/x-pack/plugins/observability_solution/slo/server/services/transform_generators/__snapshots__/transform_generator.test.ts.snap new file mode 100644 index 0000000000000..144a4fa35eda5 --- /dev/null +++ b/x-pack/plugins/observability_solution/slo/server/services/transform_generators/__snapshots__/transform_generator.test.ts.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Transform Generator builds common runtime mappings and group by with single group by 1`] = `Object {}`; + +exports[`Transform Generator builds common runtime mappings and group by with single group by 2`] = ` +Object { + "@timestamp": Object { + "date_histogram": Object { + "field": "@timestamp", + "fixed_interval": "1m", + }, + }, + "slo.groupings.example": Object { + "terms": Object { + "field": "example", + }, + }, +} +`; + +exports[`Transform Generator builds common runtime mappings and group by with single group by 3`] = `Object {}`; + +exports[`Transform Generator builds common runtime mappings and group by with single group by 4`] = ` +Object { + "@timestamp": Object { + "date_histogram": Object { + "field": "@timestamp", + "fixed_interval": "1m", + }, + }, + "slo.groupings.example": Object { + "terms": Object { + "field": "example", + }, + }, +} +`; + +exports[`Transform Generator builds common runtime mappings without multi group by 1`] = `Object {}`; + +exports[`Transform Generator builds common runtime mappings without multi group by 2`] = ` +Object { + "@timestamp": Object { + "date_histogram": Object { + "field": "@timestamp", + "fixed_interval": "1m", + }, + }, + "slo.groupings.example1": Object { + "terms": Object { + "field": "example1", + }, + }, + "slo.groupings.example2": Object { + "terms": Object { + "field": "example2", + }, + }, +} +`; + +exports[`Transform Generator builds empty runtime mappings without group by 1`] = `Object {}`; + +exports[`Transform Generator builds empty runtime mappings without group by 2`] = ` +Object { + "@timestamp": Object { + "date_histogram": Object { + "field": "@timestamp", + "fixed_interval": "1m", + }, + }, +} +`; diff --git a/x-pack/plugins/observability_solution/slo/server/services/transform_generators/synthetics_availability.test.ts b/x-pack/plugins/observability_solution/slo/server/services/transform_generators/synthetics_availability.test.ts index f9caeb9f57c31..565a0d56d1ff4 100644 --- a/x-pack/plugins/observability_solution/slo/server/services/transform_generators/synthetics_availability.test.ts +++ b/x-pack/plugins/observability_solution/slo/server/services/transform_generators/synthetics_availability.test.ts @@ -5,13 +5,12 @@ * 2.0. */ -import { ALL_VALUE } from '@kbn/slo-schema'; import { dataViewsService } from '@kbn/data-views-plugin/server/mocks'; +import { ALL_VALUE } from '@kbn/slo-schema'; import { SLODefinition } from '../../domain/models'; +import { twoMinute } from '../fixtures/duration'; import { createSLO, createSyntheticsAvailabilityIndicator } from '../fixtures/slo'; import { SyntheticsAvailabilityTransformGenerator } from './synthetics_availability'; -import { SYNTHETICS_INDEX_PATTERN } from '../../../common/constants'; -import { twoMinute } from '../fixtures/duration'; const generator = new SyntheticsAvailabilityTransformGenerator(); @@ -22,119 +21,7 @@ describe('Synthetics Availability Transform Generator', () => { const slo = createSLO({ id: 'irrelevant', indicator: createSyntheticsAvailabilityIndicator() }); const transform = await generator.getTransformParams(slo, spaceId, dataViewsService); - expect(transform).toEqual({ - _meta: { - managed: true, - managed_by: 'observability', - version: 3.3, - }, - defer_validation: true, - description: 'Rolled-up SLI data for SLO: irrelevant [id: irrelevant, revision: 1]', - dest: { - index: '.slo-observability.sli-v3.3', - pipeline: '.slo-observability.sli.pipeline-irrelevant-1', - }, - frequency: '1m', - pivot: { - aggregations: { - 'slo.denominator': { - filter: { - term: { - 'summary.final_attempt': true, - }, - }, - }, - 'slo.numerator': { - filter: { - term: { - 'monitor.status': 'up', - }, - }, - }, - }, - group_by: { - '@timestamp': { - date_histogram: { - field: '@timestamp', - fixed_interval: '1m', - }, - }, - 'monitor.config_id': { - terms: { - field: 'config_id', - }, - }, - 'monitor.name': { - terms: { - field: 'monitor.name', - }, - }, - 'observer.name': { - terms: { - field: 'observer.name', - }, - }, - 'observer.geo.name': { - terms: { - field: 'observer.geo.name', - }, - }, - 'slo.groupings.monitor.name': { - terms: { - field: 'monitor.name', - }, - }, - 'slo.groupings.observer.geo.name': { - terms: { - field: 'observer.geo.name', - }, - }, - 'slo.groupings.monitor.id': { - terms: { - field: 'monitor.id', - }, - }, - }, - }, - settings: { - deduce_mappings: false, - unattended: true, - }, - source: { - index: SYNTHETICS_INDEX_PATTERN, - query: { - bool: { - filter: [ - { - term: { - 'summary.final_attempt': true, - }, - }, - { - term: { - 'meta.space_id': 'custom-space', - }, - }, - { - range: { - '@timestamp': { - gte: 'now-7d/d', - }, - }, - }, - ], - }, - }, - runtime_mappings: {}, - }, - sync: { - time: { - delay: '1m', - field: 'event.ingested', - }, - }, - transform_id: 'slo-irrelevant-1', - }); + expect(transform).toMatchSnapshot(); expect(transform.source.query?.bool?.filter).toContainEqual({ term: { 'summary.final_attempt': true, diff --git a/x-pack/plugins/observability_solution/slo/server/services/transform_generators/transform_generator.test.ts b/x-pack/plugins/observability_solution/slo/server/services/transform_generators/transform_generator.test.ts index ffb165fdb4326..9f07c6cfb5afa 100644 --- a/x-pack/plugins/observability_solution/slo/server/services/transform_generators/transform_generator.test.ts +++ b/x-pack/plugins/observability_solution/slo/server/services/transform_generators/transform_generator.test.ts @@ -17,19 +17,10 @@ describe('Transform Generator', () => { indicator: createAPMTransactionErrorRateIndicator(), }); const commonRuntime = generator.buildCommonRuntimeMappings(slo); - - expect(commonRuntime).toEqual({}); + expect(commonRuntime).toMatchSnapshot(); const commonGroupBy = generator.buildCommonGroupBy(slo); - - expect(commonGroupBy).toEqual({ - '@timestamp': { - date_histogram: { - field: '@timestamp', - fixed_interval: '1m', - }, - }, - }); + expect(commonGroupBy).toMatchSnapshot(); }); it.each(['example', ['example']])( @@ -42,24 +33,10 @@ describe('Transform Generator', () => { indicator, }); const commonRuntime = generator.buildCommonRuntimeMappings(slo); - - expect(commonRuntime).toEqual({}); + expect(commonRuntime).toMatchSnapshot(); const commonGroupBy = generator.buildCommonGroupBy(slo); - - expect(commonGroupBy).toEqual({ - '@timestamp': { - date_histogram: { - field: '@timestamp', - fixed_interval: '1m', - }, - }, - 'slo.groupings.example': { - terms: { - field: 'example', - }, - }, - }); + expect(commonGroupBy).toMatchSnapshot(); } ); @@ -71,28 +48,9 @@ describe('Transform Generator', () => { indicator, }); const commonRuntime = generator.buildCommonRuntimeMappings(slo); - - expect(commonRuntime).toEqual({}); + expect(commonRuntime).toMatchSnapshot(); const commonGroupBy = generator.buildCommonGroupBy(slo); - - expect(commonGroupBy).toEqual({ - '@timestamp': { - date_histogram: { - field: '@timestamp', - fixed_interval: '1m', - }, - }, - 'slo.groupings.example1': { - terms: { - field: 'example1', - }, - }, - 'slo.groupings.example2': { - terms: { - field: 'example2', - }, - }, - }); + expect(commonGroupBy).toMatchSnapshot(); }); }); diff --git a/x-pack/plugins/observability_solution/slo/tsconfig.json b/x-pack/plugins/observability_solution/slo/tsconfig.json index 3d968c00e1e1f..2bf0737b5436c 100644 --- a/x-pack/plugins/observability_solution/slo/tsconfig.json +++ b/x-pack/plugins/observability_solution/slo/tsconfig.json @@ -17,7 +17,6 @@ "@kbn/i18n-react", "@kbn/shared-ux-router", "@kbn/core", - "@kbn/translations-plugin", "@kbn/rule-data-utils", "@kbn/triggers-actions-ui-plugin", "@kbn/observability-plugin", diff --git a/x-pack/plugins/observability_solution/synthetics/common/field_names.ts b/x-pack/plugins/observability_solution/synthetics/common/field_names.ts index e7f8e83d73b4b..45be741982b01 100644 --- a/x-pack/plugins/observability_solution/synthetics/common/field_names.ts +++ b/x-pack/plugins/observability_solution/synthetics/common/field_names.ts @@ -15,6 +15,7 @@ export const OBSERVER_NAME = 'observer.name'; export const SERVICE_NAME = 'service.name'; export const OBSERVER_GEO_NAME = 'observer.geo.name'; export const ERROR_MESSAGE = 'error.message'; +export const ERROR_STACK_TRACE = 'error.stack_trace'; export const STATE_ID = 'monitor.state.id'; export const CERT_COMMON_NAME = 'tls.server.x509.subject.common_name'; diff --git a/x-pack/plugins/observability_solution/synthetics/common/requests/get_certs_request_body.ts b/x-pack/plugins/observability_solution/synthetics/common/requests/get_certs_request_body.ts index f151192a730ed..31f389a909004 100644 --- a/x-pack/plugins/observability_solution/synthetics/common/requests/get_certs_request_body.ts +++ b/x-pack/plugins/observability_solution/synthetics/common/requests/get_certs_request_body.ts @@ -150,7 +150,7 @@ export const getCertsRequestBody = ({ 'service', 'labels', 'tags', - 'error.message', + 'error', ], collapse: { field: 'tls.server.hash.sha256', @@ -222,6 +222,7 @@ export const processCertsResult = (result: CertificatesResults): CertResult => { locationId: ping?.observer?.name, locationName: ping?.observer?.geo?.name, errorMessage: ping?.error?.message, + errorStackTrace: ping?.error?.stack_trace, }; }); const total = result.aggregations?.total?.value ?? 0; diff --git a/x-pack/plugins/observability_solution/synthetics/common/rules/synthetics_rule_field_map.ts b/x-pack/plugins/observability_solution/synthetics/common/rules/synthetics_rule_field_map.ts index f82f44ba2d24d..390916026668c 100644 --- a/x-pack/plugins/observability_solution/synthetics/common/rules/synthetics_rule_field_map.ts +++ b/x-pack/plugins/observability_solution/synthetics/common/rules/synthetics_rule_field_map.ts @@ -32,6 +32,10 @@ export const syntheticsRuleFieldMap: FieldMap = { type: 'text', required: false, }, + 'error.stack_trace': { + type: 'wildcard', + required: false, + }, 'agent.name': { type: 'keyword', required: false, diff --git a/x-pack/plugins/observability_solution/synthetics/common/runtime_types/certs.ts b/x-pack/plugins/observability_solution/synthetics/common/runtime_types/certs.ts index 4fe14a54c0d66..49ac5573294e1 100644 --- a/x-pack/plugins/observability_solution/synthetics/common/runtime_types/certs.ts +++ b/x-pack/plugins/observability_solution/synthetics/common/runtime_types/certs.ts @@ -51,6 +51,7 @@ export const CertType = t.intersection([ '@timestamp': t.string, serviceName: t.string, errorMessage: t.string, + errorStackTrace: t.union([t.string, t.null]), labels: t.record(t.string, t.string), tags: t.array(t.string), }), diff --git a/x-pack/plugins/observability_solution/synthetics/common/runtime_types/monitor_management/synthetics_params.ts b/x-pack/plugins/observability_solution/synthetics/common/runtime_types/monitor_management/synthetics_params.ts index 5393fed135b7d..4107bc1efbb2a 100644 --- a/x-pack/plugins/observability_solution/synthetics/common/runtime_types/monitor_management/synthetics_params.ts +++ b/x-pack/plugins/observability_solution/synthetics/common/runtime_types/monitor_management/synthetics_params.ts @@ -19,6 +19,8 @@ export const SyntheticsParamsReadonlyCodec = t.intersection([ }), ]); +export const SyntheticsParamsReadonlyCodecList = t.array(SyntheticsParamsReadonlyCodec); + export type SyntheticsParamsReadonly = t.TypeOf; export const SyntheticsParamsCodec = t.intersection([ diff --git a/x-pack/plugins/observability_solution/synthetics/common/saved_objects/private_locations.ts b/x-pack/plugins/observability_solution/synthetics/common/saved_objects/private_locations.ts index bb3639e816059..1b5bb92dd7d88 100644 --- a/x-pack/plugins/observability_solution/synthetics/common/saved_objects/private_locations.ts +++ b/x-pack/plugins/observability_solution/synthetics/common/saved_objects/private_locations.ts @@ -5,5 +5,7 @@ * 2.0. */ -export const privateLocationsSavedObjectId = 'synthetics-privates-locations-singleton'; -export const privateLocationsSavedObjectName = 'synthetics-privates-locations'; +export const legacyPrivateLocationsSavedObjectId = 'synthetics-privates-locations-singleton'; +export const legacyPrivateLocationsSavedObjectName = 'synthetics-privates-locations'; + +export const privateLocationSavedObjectName = 'synthetics-private-location'; diff --git a/x-pack/plugins/observability_solution/synthetics/e2e/kibana.jsonc b/x-pack/plugins/observability_solution/synthetics/e2e/kibana.jsonc index 178c7b2e8e84f..ab72e2d66425e 100644 --- a/x-pack/plugins/observability_solution/synthetics/e2e/kibana.jsonc +++ b/x-pack/plugins/observability_solution/synthetics/e2e/kibana.jsonc @@ -2,5 +2,7 @@ "type": "test-helper", "id": "@kbn/synthetics-e2e", "owner": "@elastic/obs-ux-management-team", + "group": "observability", + "visibility": "private", "devOnly": true } diff --git a/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/private_locations.journey.ts b/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/private_locations.journey.ts index 9e6bb8352c35f..cdc5961991579 100644 --- a/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/private_locations.journey.ts +++ b/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/private_locations.journey.ts @@ -7,17 +7,14 @@ import { journey, step, before, after, expect } from '@elastic/synthetics'; import { waitForLoadingToFinish } from '@kbn/ux-plugin/e2e/journeys/utils'; +import { SyntheticsServices } from './services/synthetics_services'; import { byTestId } from '../../helpers/utils'; -import { - addTestMonitor, - cleanPrivateLocations, - cleanTestMonitors, - getPrivateLocations, -} from './services/add_monitor'; +import { addTestMonitor, cleanPrivateLocations, cleanTestMonitors } from './services/add_monitor'; import { syntheticsAppPageProvider } from '../page_objects/synthetics_app'; journey(`PrivateLocationsSettings`, async ({ page, params }) => { const syntheticsApp = syntheticsAppPageProvider({ page, kibanaUrl: params.kibanaUrl, params }); + const services = new SyntheticsServices(params); page.setDefaultTimeout(2 * 30000); @@ -78,16 +75,14 @@ journey(`PrivateLocationsSettings`, async ({ page, params }) => { await page.click('text=Private Locations'); await page.click('h1:has-text("Settings")'); - const privateLocations = await getPrivateLocations(params); + const privateLocations = await services.getPrivateLocations(); - const locations = privateLocations.attributes.locations; + expect(privateLocations.length).toBe(1); - expect(locations.length).toBe(1); - - locationId = locations[0].id; + locationId = privateLocations[0].id; await addTestMonitor(params.kibanaUrl, 'test-monitor', { - locations: [locations[0]], + locations: [privateLocations[0]], type: 'browser', }); }); diff --git a/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/services/add_monitor.ts b/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/services/add_monitor.ts index 6384179a71bb9..6a527da275eb3 100644 --- a/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/services/add_monitor.ts +++ b/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/services/add_monitor.ts @@ -7,10 +7,7 @@ import axios from 'axios'; import { SYNTHETICS_API_URLS } from '@kbn/synthetics-plugin/common/constants'; -import { - privateLocationsSavedObjectId, - privateLocationsSavedObjectName, -} from '@kbn/synthetics-plugin/common/saved_objects/private_locations'; +import { legacyPrivateLocationsSavedObjectName } from '@kbn/synthetics-plugin/common/saved_objects/private_locations'; export const enableMonitorManagedViaApi = async (kibanaUrl: string) => { try { @@ -46,21 +43,6 @@ export const addTestMonitor = async ( } }; -export const getPrivateLocations = async (params: Record) => { - const getService = params.getService; - const server = getService('kibanaServer'); - - try { - return await server.savedObjects.get({ - id: privateLocationsSavedObjectId, - type: privateLocationsSavedObjectName, - }); - } catch (e) { - // eslint-disable-next-line no-console - console.log(e); - } -}; - export const cleanTestMonitors = async (params: Record) => { const getService = params.getService; const server = getService('kibanaServer'); @@ -79,7 +61,11 @@ export const cleanPrivateLocations = async (params: Record) => { try { await server.savedObjects.clean({ - types: [privateLocationsSavedObjectName, 'ingest-agent-policies', 'ingest-package-policies'], + types: [ + legacyPrivateLocationsSavedObjectName, + 'ingest-agent-policies', + 'ingest-package-policies', + ], }); } catch (e) { // eslint-disable-next-line no-console diff --git a/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/services/synthetics_services.ts b/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/services/synthetics_services.ts index 5c356492f1c24..507efe52c453f 100644 --- a/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/services/synthetics_services.ts +++ b/x-pack/plugins/observability_solution/synthetics/e2e/synthetics/journeys/services/synthetics_services.ts @@ -10,7 +10,10 @@ import type { Client } from '@elastic/elasticsearch'; import { KbnClient } from '@kbn/test'; import pMap from 'p-map'; import { makeDownSummary, makeUpSummary } from '@kbn/observability-synthetics-test-data'; -import { SyntheticsMonitor } from '@kbn/synthetics-plugin/common/runtime_types'; +import { + SyntheticsMonitor, + SyntheticsPrivateLocations, +} from '@kbn/synthetics-plugin/common/runtime_types'; import { SYNTHETICS_API_URLS } from '@kbn/synthetics-plugin/common/constants'; import { journeyStart, journeySummary, step1, step2 } from './data/browser_docs'; @@ -251,4 +254,12 @@ export class SyntheticsServices { }); return connector.data; } + + async getPrivateLocations(): Promise { + const response = await this.requester.request({ + path: SYNTHETICS_API_URLS.PRIVATE_LOCATIONS, + method: 'GET', + }); + return response.data as SyntheticsPrivateLocations; + } } diff --git a/x-pack/plugins/observability_solution/synthetics/kibana.jsonc b/x-pack/plugins/observability_solution/synthetics/kibana.jsonc index 30f267fc72573..89870a9e6c881 100644 --- a/x-pack/plugins/observability_solution/synthetics/kibana.jsonc +++ b/x-pack/plugins/observability_solution/synthetics/kibana.jsonc @@ -1,13 +1,20 @@ { "type": "plugin", "id": "@kbn/synthetics-plugin", - "owner": "@elastic/obs-ux-management-team", + "owner": [ + "@elastic/obs-ux-management-team" + ], + "group": "observability", + "visibility": "private", "description": "This plugin visualizes data from Synthetics and Heartbeat, and integrates with other Observability solutions.", "plugin": { "id": "synthetics", - "server": true, "browser": true, - "configPath": ["xpack", "uptime"], + "server": true, + "configPath": [ + "xpack", + "uptime" + ], "requiredPlugins": [ "actions", "alerting", @@ -57,7 +64,7 @@ "observability", "spaces", "indexLifecycleManagement", - "unifiedDocViewer", + "unifiedDocViewer" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/settings/global_params/delete_param.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/settings/global_params/delete_param.tsx index 814fb13a99ba9..2615a65ef289c 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/settings/global_params/delete_param.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/settings/global_params/delete_param.tsx @@ -5,16 +5,17 @@ * 2.0. */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import { EuiConfirmModal } from '@elastic/eui'; -import { FETCH_STATUS, useFetcher } from '@kbn/observability-shared-plugin/public'; -import { toMountPoint } from '@kbn/react-kibana-mount'; import { i18n } from '@kbn/i18n'; -import { useDispatch } from 'react-redux'; -import { getGlobalParamAction, deleteGlobalParams } from '../../../state/global_params'; +import { useDispatch, useSelector } from 'react-redux'; +import { + getGlobalParamAction, + deleteGlobalParamsAction, + selectGlobalParamState, +} from '../../../state/global_params'; import { syncGlobalParamsAction } from '../../../state/settings'; -import { kibanaService } from '../../../../../utils/kibana_service'; import { NO_LABEL, YES_LABEL } from '../../monitors_page/management/monitor_list_table/labels'; import { ListParamItem } from './params_list'; @@ -25,19 +26,8 @@ export const DeleteParam = ({ items: ListParamItem[]; setIsDeleteModalVisible: React.Dispatch>; }) => { - const [isDeleting, setIsDeleting] = useState(false); - const dispatch = useDispatch(); - - const handleConfirmDelete = () => { - setIsDeleting(true); - }; - - const { status } = useFetcher(() => { - if (isDeleting) { - return deleteGlobalParams(items.map(({ id }) => id)); - } - }, [items, isDeleting]); + const { isDeleting, listOfParams } = useSelector(selectGlobalParamState); const name = items .map(({ key }) => key) @@ -45,51 +35,12 @@ export const DeleteParam = ({ .slice(0, 50); useEffect(() => { - if (!isDeleting) { - return; - } - const { coreStart, toasts } = kibanaService; - - if (status === FETCH_STATUS.FAILURE) { - toasts.addDanger( - { - title: toMountPoint( -

- {' '} - {i18n.translate('xpack.synthetics.paramManagement.paramDeleteFailuresMessage.name', { - defaultMessage: 'Param {name} failed to delete.', - values: { name }, - })} -

, - coreStart - ), - }, - { toastLifeTimeMs: 3000 } - ); - } else if (status === FETCH_STATUS.SUCCESS) { - toasts.addSuccess( - { - title: toMountPoint( -

- {i18n.translate('xpack.synthetics.paramManagement.paramDeleteSuccessMessage.name', { - defaultMessage: 'Param {name} deleted successfully.', - values: { name }, - })} -

, - coreStart - ), - }, - { toastLifeTimeMs: 3000 } - ); - dispatch(syncGlobalParamsAction.get()); - } - if (status === FETCH_STATUS.SUCCESS || status === FETCH_STATUS.FAILURE) { - setIsDeleting(false); + if (!isDeleting && (listOfParams ?? []).length === 0) { setIsDeleteModalVisible(false); dispatch(getGlobalParamAction.get()); dispatch(syncGlobalParamsAction.get()); } - }, [setIsDeleting, isDeleting, status, setIsDeleteModalVisible, name, dispatch]); + }, [isDeleting, setIsDeleteModalVisible, name, dispatch, listOfParams]); return ( setIsDeleteModalVisible(false)} - onConfirm={handleConfirmDelete} + onConfirm={() => { + dispatch(deleteGlobalParamsAction.get(items.map(({ id }) => id))); + }} cancelButtonText={NO_LABEL} confirmButtonText={YES_LABEL} buttonColor="danger" diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/settings/global_params/params_list.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/settings/global_params/params_list.tsx index 2ff3ea547ae9f..b16dbcd686d91 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/settings/global_params/params_list.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/settings/global_params/params_list.tsx @@ -83,7 +83,11 @@ export const ParamsList = () => { render: (val: string[]) => { const tags = val ?? []; if (tags.length === 0) { - return --; + return ( + + {i18n.translate('xpack.synthetics.columns.TextLabel', { defaultMessage: '--' })} + + ); } return ( @@ -105,7 +109,11 @@ export const ParamsList = () => { render: (val: string[]) => { const namespaces = val ?? []; if (namespaces.length === 0) { - return --; + return ( + + {i18n.translate('xpack.synthetics.columns.TextLabel', { defaultMessage: '--' })} + + ); } return ( @@ -184,6 +192,7 @@ export const ParamsList = () => { isEditingItem={isEditingItem} setIsEditingItem={setIsEditingItem} items={items} + key="add-param-flyout" />, ]; }; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/settings/hooks/use_get_data_stream_statuses.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/settings/hooks/use_get_data_stream_statuses.ts index f3b3136200bd7..00d301e9eb706 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/settings/hooks/use_get_data_stream_statuses.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/settings/hooks/use_get_data_stream_statuses.ts @@ -94,6 +94,7 @@ function toMissingDataStream({ privileges: { delete_index: true, manage_data_stream_lifecycle: true }, hidden: false, nextGenerationManagedBy: 'Data stream lifecycle', + indexMode: 'standard', }; } diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/global_params/actions.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/global_params/actions.ts index b1388bc2674b9..0faef0079657a 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/global_params/actions.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/global_params/actions.ts @@ -23,3 +23,8 @@ export const editGlobalParamAction = createAsyncAction< }, SyntheticsParams >('EDIT GLOBAL PARAM'); + +export const deleteGlobalParamsAction = createAsyncAction< + string[], + Array<{ id: string; deleted: boolean }> +>('DELETE GLOBAL PARAMS'); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/global_params/api.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/global_params/api.ts index 33eb4622bf6c5..1badb74dff26f 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/global_params/api.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/global_params/api.ts @@ -13,6 +13,7 @@ import { SyntheticsParams, SyntheticsParamsCodec, SyntheticsParamsReadonlyCodec, + SyntheticsParamsReadonlyCodecList, } from '../../../../../common/runtime_types'; import { apiService } from '../../../../utils/api_service/api_service'; @@ -20,14 +21,14 @@ export const getGlobalParams = async (): Promise => { return apiService.get( SYNTHETICS_API_URLS.PARAMS, { version: INITIAL_REST_VERSION }, - SyntheticsParamsReadonlyCodec + SyntheticsParamsReadonlyCodecList ); }; export const addGlobalParam = async ( paramRequest: SyntheticsParamRequest ): Promise => - apiService.post(SYNTHETICS_API_URLS.PARAMS, paramRequest, SyntheticsParamsCodec, { + apiService.post(SYNTHETICS_API_URLS.PARAMS, paramRequest, SyntheticsParamsReadonlyCodec, { version: INITIAL_REST_VERSION, }); @@ -53,11 +54,13 @@ export const editGlobalParam = async ({ ); }; -export const deleteGlobalParams = async (ids: string[]): Promise => - apiService.delete( - SYNTHETICS_API_URLS.PARAMS, - { version: INITIAL_REST_VERSION }, +export const deleteGlobalParams = async (ids: string[]): Promise => { + return await apiService.post( + SYNTHETICS_API_URLS.PARAMS + '/_bulk_delete', { ids, - } + }, + null, + { version: INITIAL_REST_VERSION } ); +}; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/global_params/effects.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/global_params/effects.ts index d5249fcfc4519..f5f0c6e4ee951 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/global_params/effects.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/global_params/effects.ts @@ -8,8 +8,13 @@ import { takeLeading } from 'redux-saga/effects'; import { i18n } from '@kbn/i18n'; import { fetchEffectFactory } from '../utils/fetch_effect'; -import { addGlobalParam, editGlobalParam, getGlobalParams } from './api'; -import { addNewGlobalParamAction, editGlobalParamAction, getGlobalParamAction } from './actions'; +import { addGlobalParam, deleteGlobalParams, editGlobalParam, getGlobalParams } from './api'; +import { + addNewGlobalParamAction, + deleteGlobalParamsAction, + editGlobalParamAction, + getGlobalParamAction, +} from './actions'; export function* getGlobalParamEffect() { yield takeLeading( @@ -69,3 +74,26 @@ const editSuccessMessage = i18n.translate('xpack.synthetics.settings.editParams. const editFailureMessage = i18n.translate('xpack.synthetics.settings.editParams.fail', { defaultMessage: 'Failed to edit global parameter.', }); + +// deleteGlobalParams + +export function* deleteGlobalParamsEffect() { + yield takeLeading( + deleteGlobalParamsAction.get, + fetchEffectFactory( + deleteGlobalParams, + deleteGlobalParamsAction.success, + deleteGlobalParamsAction.fail, + deleteSuccessMessage, + deleteFailureMessage + ) + ); +} + +const deleteSuccessMessage = i18n.translate('xpack.synthetics.settings.deleteParams.success', { + defaultMessage: 'Successfully deleted global parameters.', +}); + +const deleteFailureMessage = i18n.translate('xpack.synthetics.settings.deleteParams.fail', { + defaultMessage: 'Failed to delete global parameters.', +}); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/global_params/index.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/global_params/index.ts index 89b3a0b7e1904..a1e2e07ff955f 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/global_params/index.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/global_params/index.ts @@ -8,7 +8,12 @@ import { createReducer } from '@reduxjs/toolkit'; import { SyntheticsParams } from '../../../../../common/runtime_types'; import { IHttpSerializedFetchError } from '..'; -import { addNewGlobalParamAction, editGlobalParamAction, getGlobalParamAction } from './actions'; +import { + addNewGlobalParamAction, + deleteGlobalParamsAction, + editGlobalParamAction, + getGlobalParamAction, +} from './actions'; export interface GlobalParamsState { isLoading?: boolean; @@ -16,6 +21,7 @@ export interface GlobalParamsState { addError: IHttpSerializedFetchError | null; editError: IHttpSerializedFetchError | null; isSaving?: boolean; + isDeleting?: boolean; savedData?: SyntheticsParams; } @@ -23,6 +29,7 @@ const initialState: GlobalParamsState = { isLoading: false, addError: null, isSaving: false, + isDeleting: false, editError: null, listOfParams: [], }; @@ -62,6 +69,16 @@ export const globalParamsReducer = createReducer(initialState, (builder) => { .addCase(editGlobalParamAction.fail, (state, action) => { state.isSaving = false; state.editError = action.payload; + }) + .addCase(deleteGlobalParamsAction.get, (state) => { + state.isDeleting = true; + }) + .addCase(deleteGlobalParamsAction.success, (state) => { + state.isDeleting = false; + state.listOfParams = []; + }) + .addCase(deleteGlobalParamsAction.fail, (state) => { + state.isDeleting = false; }); }); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/monitor_list/api.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/monitor_list/api.ts index 344897dd0eb1d..bef569bf0da39 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/monitor_list/api.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/monitor_list/api.ts @@ -60,12 +60,13 @@ export const fetchDeleteMonitor = async ({ }): Promise => { const baseUrl = SYNTHETICS_API_URLS.SYNTHETICS_MONITORS; - return await apiService.delete( - baseUrl, - { version: INITIAL_REST_VERSION, spaceId }, + return await apiService.post( + baseUrl + '/_bulk_delete', { ids: configIds, - } + }, + undefined, + { version: INITIAL_REST_VERSION, spaceId } ); }; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/root_effect.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/root_effect.ts index 1a565fe772aa6..e38a1b5ad918f 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/root_effect.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/root_effect.ts @@ -7,7 +7,12 @@ import { all, fork } from 'redux-saga/effects'; import { getCertsListEffect } from './certs'; -import { addGlobalParamEffect, editGlobalParamEffect, getGlobalParamEffect } from './global_params'; +import { + addGlobalParamEffect, + deleteGlobalParamsEffect, + editGlobalParamEffect, + getGlobalParamEffect, +} from './global_params'; import { fetchManualTestRunsEffect } from './manual_test_runs/effects'; import { enableDefaultAlertingEffect, @@ -66,6 +71,7 @@ export const rootEffect = function* root(): Generator { fork(fetchManualTestRunsEffect), fork(addGlobalParamEffect), fork(editGlobalParamEffect), + fork(deleteGlobalParamsEffect), fork(getGlobalParamEffect), fork(getCertsListEffect), fork(getDefaultAlertingEffect), diff --git a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/message_utils.ts b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/message_utils.ts index 812a900667cf7..6f01e9b234bf6 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/message_utils.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/message_utils.ts @@ -24,6 +24,7 @@ import { AGENT_NAME, STATE_ID, SERVICE_NAME, + ERROR_STACK_TRACE, } from '../../../common/field_names'; import { OverviewPing } from '../../../common/runtime_types'; import { UNNAMED_LOCATION } from '../../../common/constants'; @@ -42,6 +43,8 @@ export const getMonitorAlertDocument = ( [OBSERVER_GEO_NAME]: locationNames, [OBSERVER_NAME]: locationIds, [ERROR_MESSAGE]: monitorSummary.lastErrorMessage, + // done to avoid assigning null to the field + [ERROR_STACK_TRACE]: monitorSummary.lastErrorStack ? monitorSummary.lastErrorStack : undefined, [AGENT_NAME]: monitorSummary.hostName, [ALERT_REASON]: monitorSummary.reason, [STATE_ID]: monitorSummary.stateId, @@ -114,7 +117,9 @@ export const getMonitorSummary = ({ monitorId: monitorInfo.monitor?.id, monitorName, monitorType: typeToLabelMap[monitorInfo.monitor?.type] || monitorInfo.monitor?.type, - lastErrorMessage: monitorInfo.error?.message!, + lastErrorMessage: monitorInfo.error?.message, + // done to avoid assigning null to the field + lastErrorStack: monitorInfo.error?.stack_trace ? monitorInfo.error?.stack_trace : undefined, serviceName: monitorInfo.service?.name, labels: monitorInfo.labels, locationName: formattedLocationName, diff --git a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/types.ts b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/types.ts index 82294e55c08fc..85ae989876107 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/types.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/status_rule/types.ts @@ -69,6 +69,7 @@ export interface MonitorSummaryStatusRule { }; stateId?: string; lastErrorMessage?: string; + lastErrorStack?: string | null; timestamp: string; labels?: Record; } diff --git a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/tls_rule/message_utils.ts b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/tls_rule/message_utils.ts index 15a6f093becd9..a6a7d82fb3335 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/tls_rule/message_utils.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/tls_rule/message_utils.ts @@ -29,6 +29,7 @@ import { CERT_VALID_NOT_AFTER, CERT_VALID_NOT_BEFORE, ERROR_MESSAGE, + ERROR_STACK_TRACE, MONITOR_ID, MONITOR_NAME, MONITOR_TYPE, @@ -103,6 +104,7 @@ export const getCertSummary = (cert: Cert, expirationThreshold: number, ageThres configId: cert.configId, monitorTags: cert.tags, errorMessage: cert.errorMessage, + errorStackTrace: cert.errorStackTrace, labels: cert.labels, }; }; @@ -123,6 +125,8 @@ export const getTLSAlertDocument = (cert: Cert, monitorSummary: CertSummary, uui [OBSERVER_GEO_NAME]: monitorSummary.locationName ? [monitorSummary.locationName] : [], [OBSERVER_NAME]: monitorSummary.locationId ? [monitorSummary.locationId] : [], [ERROR_MESSAGE]: monitorSummary.errorMessage, + // done to avoid assigning null to the field + [ERROR_STACK_TRACE]: monitorSummary.errorStackTrace ? monitorSummary.errorStackTrace : undefined, 'location.id': monitorSummary.locationId ? [monitorSummary.locationId] : [], 'location.name': monitorSummary.locationName ? [monitorSummary.locationName] : [], labels: cert.labels, diff --git a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/translations.ts b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/translations.ts index 40017b00646f1..03063f92ee56c 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/alert_rules/translations.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/alert_rules/translations.ts @@ -79,6 +79,15 @@ export const commonMonitorStateI18: Array<{ } ), }, + { + name: 'lastErrorStack', + description: i18n.translate( + 'xpack.synthetics.alertRules.monitorStatus.actionVariables.state.lastErrorStack', + { + defaultMessage: 'Monitor last error stack trace.', + } + ), + }, { name: 'locationName', description: i18n.translate( diff --git a/x-pack/plugins/observability_solution/synthetics/server/feature.ts b/x-pack/plugins/observability_solution/synthetics/server/feature.ts index c8b4b721a9ce1..bf86ac7b0c890 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/feature.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/feature.ts @@ -14,7 +14,10 @@ import { import { KibanaFeatureScope } from '@kbn/features-plugin/common'; import { syntheticsMonitorType, syntheticsParamType } from '../common/types/saved_objects'; import { SYNTHETICS_RULE_TYPES } from '../common/constants/synthetics_alerts'; -import { privateLocationsSavedObjectName } from '../common/saved_objects/private_locations'; +import { + legacyPrivateLocationsSavedObjectName, + privateLocationSavedObjectName, +} from '../common/saved_objects/private_locations'; import { PLUGIN } from '../common/constants/plugin'; import { syntheticsSettingsObjectType, @@ -71,7 +74,8 @@ export const syntheticsFeature = { syntheticsSettingsObjectType, syntheticsMonitorType, syntheticsApiKeyObjectType, - privateLocationsSavedObjectName, + privateLocationSavedObjectName, + legacyPrivateLocationsSavedObjectName, syntheticsParamType, // uptime settings object is also registered here since feature is shared between synthetics and uptime uptimeSettingsObjectType, @@ -102,7 +106,7 @@ export const syntheticsFeature = { syntheticsSettingsObjectType, syntheticsMonitorType, syntheticsApiKeyObjectType, - privateLocationsSavedObjectName, + legacyPrivateLocationsSavedObjectName, // uptime settings object is also registered here since feature is shared between synthetics and uptime uptimeSettingsObjectType, ], diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/index.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/index.ts index 48e0de1c7fba4..f9d178befeb46 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/index.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/index.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { deleteSyntheticsParamsBulkRoute } from './settings/params/delete_params_bulk'; +import { deleteSyntheticsMonitorBulkRoute } from './monitor_cruds/bulk_cruds/delete_monitor_bulk'; import { createGetDynamicSettingsRoute, createPostDynamicSettingsRoute, @@ -113,4 +115,6 @@ export const syntheticsAppPublicRestApiRoutes: SyntheticsRestApiRouteFactory[] = addSyntheticsMonitorRoute, editSyntheticsMonitorRoute, deleteSyntheticsMonitorRoute, + deleteSyntheticsMonitorBulkRoute, + deleteSyntheticsParamsBulkRoute, ]; diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor/utils.test.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor/utils.test.ts index c6bd1d330bc86..d9d1a23196055 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor/utils.test.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor/utils.test.ts @@ -33,6 +33,11 @@ describe('parseMonitorLocations', () => { id: 'local', isServiceManaged: true, }; + const localLoc2 = { + label: 'Local 2', + id: 'local2', + isServiceManaged: true, + }; it('should return expected', function () { const result = parseMonitorLocations({ @@ -81,6 +86,20 @@ describe('parseMonitorLocations', () => { }); }); + it('should handle editing location', function () { + const result = parseMonitorLocations( + { + locations: ['local'], + } as any, + [localLoc, localLoc2] + ); + + expect(result).toEqual({ + locations: ['local'], + privateLocations: [], + }); + }); + it('should add private locations to existing', function () { const result = parseMonitorLocations( { @@ -91,7 +110,7 @@ describe('parseMonitorLocations', () => { expect(result).toEqual({ locations: ['local'], - privateLocations: ['test-private-location-2', 'test-private-location'], + privateLocations: ['test-private-location-2'], }); }); @@ -185,4 +204,19 @@ describe('parseMonitorLocations', () => { privateLocations: [], }); }); + + it('should handle private location objects', function () { + const result = parseMonitorLocations( + { + locations: [pvtLoc1], + } as any, + [localLoc, pvtLoc1], + true + ); + + expect(result).toEqual({ + locations: [], + privateLocations: ['test-private-location'], + }); + }); }); diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor/utils.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor/utils.ts index 0d096ee21745f..2c58e868d827f 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor/utils.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor/utils.ts @@ -25,7 +25,7 @@ export const getPrivateLocationsForMonitor = async ( export const parseMonitorLocations = ( monitorPayload: CreateMonitorPayLoad, prevLocations?: MonitorFields['locations'], - internal: boolean = false + internal = false ) => { const { locations, private_locations: privateLocations } = monitorPayload; @@ -42,41 +42,33 @@ export const parseMonitorLocations = ( pvtLocs = prevLocations.filter((loc) => !loc.isServiceManaged).map((loc) => loc.id); } else { if (prevLocations && !internal) { + const prevPublicLocs = prevLocations + .filter((loc) => loc.isServiceManaged) + .map((loc) => loc.id); + const prevPrivateLocs = prevLocations + .filter((loc) => !loc.isServiceManaged) + .map((loc) => loc.id); + if (!locations && !privateLocations) { - locs = prevLocations.filter((loc) => loc.isServiceManaged).map((loc) => loc.id); - pvtLocs = prevLocations.filter((loc) => !loc.isServiceManaged).map((loc) => loc.id); + locs = prevPublicLocs; + pvtLocs = prevPrivateLocs; } else { if (!privateLocations) { - pvtLocs = [ - ...(pvtLocs ?? []), - ...prevLocations.filter((loc) => !loc.isServiceManaged).map((loc) => loc.id), - ]; + pvtLocs = [...(pvtLocs ?? []), ...prevPrivateLocs]; if (locations?.length === 0) { locs = []; - } else { - locs = [ - ...(locs ?? []), - ...prevLocations.filter((loc) => loc.isServiceManaged).map((loc) => loc.id), - ]; } } if (!locations) { - locs = [ - ...(locs ?? []), - ...prevLocations.filter((loc) => loc.isServiceManaged).map((loc) => loc.id), - ]; + locs = [...(locs ?? []), ...prevPublicLocs]; if (privateLocations?.length === 0) { pvtLocs = []; - } else { - pvtLocs = [ - ...(pvtLocs ?? []), - ...prevLocations.filter((loc) => !loc.isServiceManaged).map((loc) => loc.id), - ]; } } } } } + return { locations: Array.from(new Set(locs)), privateLocations: Array.from(new Set(pvtLocs)), diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/bulk_cruds/add_monitor_bulk.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/bulk_cruds/add_monitor_bulk.ts index 2ecbbf83d471c..03c7ede49ceba 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/bulk_cruds/add_monitor_bulk.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/bulk_cruds/add_monitor_bulk.ts @@ -10,7 +10,6 @@ import { SavedObjectsBulkResponse } from '@kbn/core-saved-objects-api-server'; import { v4 as uuidV4 } from 'uuid'; import { NewPackagePolicy } from '@kbn/fleet-plugin/common'; import { SavedObjectError } from '@kbn/core-saved-objects-common'; -import { deleteMonitorBulk } from './delete_monitor_bulk'; import { SyntheticsServerSetup } from '../../../types'; import { RouteContext } from '../../types'; import { formatTelemetryEvent, sendTelemetryEvents } from '../../telemetry/monitor_upgrade_sender'; @@ -190,9 +189,10 @@ export const deleteMonitorIfCreated = async ({ newMonitorId ); if (encryptedMonitor) { - await deleteMonitorBulk({ + const deleteMonitorAPI = new DeleteMonitorAPI(routeContext); + + await deleteMonitorAPI.deleteMonitorBulk({ monitors: [encryptedMonitor], - routeContext, }); } } catch (e) { diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/bulk_cruds/delete_monitor_bulk.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/bulk_cruds/delete_monitor_bulk.ts index 9a031b3e7111a..ba6426de740d3 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/bulk_cruds/delete_monitor_bulk.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/bulk_cruds/delete_monitor_bulk.ts @@ -4,63 +4,40 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { SavedObject } from '@kbn/core-saved-objects-server'; -import { - formatTelemetryDeleteEvent, - sendTelemetryEvents, -} from '../../telemetry/monitor_upgrade_sender'; -import { - ConfigKey, - MonitorFields, - SyntheticsMonitor, - EncryptedSyntheticsMonitorAttributes, - SyntheticsMonitorWithId, -} from '../../../../common/runtime_types'; -import { syntheticsMonitorType } from '../../../../common/types/saved_objects'; -import { RouteContext } from '../../types'; -export const deleteMonitorBulk = async ({ - monitors, - routeContext, -}: { - monitors: Array>; - routeContext: RouteContext; -}) => { - const { savedObjectsClient, server, spaceId, syntheticsMonitorClient } = routeContext; - const { logger, telemetry, stackVersion } = server; +import { schema } from '@kbn/config-schema'; +import { DeleteMonitorAPI } from '../services/delete_monitor_api'; +import { SYNTHETICS_API_URLS } from '../../../../common/constants'; +import { SyntheticsRestApiRouteFactory } from '../../types'; - try { - const deleteSyncPromise = syntheticsMonitorClient.deleteMonitors( - monitors.map((normalizedMonitor) => ({ - ...normalizedMonitor.attributes, - id: normalizedMonitor.attributes[ConfigKey.MONITOR_QUERY_ID], - })) as SyntheticsMonitorWithId[], - savedObjectsClient, - spaceId - ); +export const deleteSyntheticsMonitorBulkRoute: SyntheticsRestApiRouteFactory< + Array<{ id: string; deleted: boolean }>, + Record, + Record, + { ids: string[] } +> = () => ({ + method: 'POST', + path: SYNTHETICS_API_URLS.SYNTHETICS_MONITORS + '/_bulk_delete', + validate: {}, + validation: { + request: { + body: schema.object({ + ids: schema.arrayOf(schema.string(), { + minSize: 1, + }), + }), + }, + }, + handler: async (routeContext): Promise => { + const { request } = routeContext; - const deletePromises = savedObjectsClient.bulkDelete( - monitors.map((monitor) => ({ type: syntheticsMonitorType, id: monitor.id })) - ); + const { ids: idsToDelete } = request.body || {}; + const deleteMonitorAPI = new DeleteMonitorAPI(routeContext); - const [errors, result] = await Promise.all([deleteSyncPromise, deletePromises]); - - monitors.forEach((monitor) => { - sendTelemetryEvents( - logger, - telemetry, - formatTelemetryDeleteEvent( - monitor, - stackVersion, - new Date().toISOString(), - Boolean((monitor.attributes as MonitorFields)[ConfigKey.SOURCE_INLINE]), - errors - ) - ); + const { errors, result } = await deleteMonitorAPI.execute({ + monitorIds: idsToDelete, }); - return { errors, result }; - } catch (e) { - throw e; - } -}; + return { result, errors }; + }, +}); diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/delete_monitor.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/delete_monitor.ts index f40f06f66b1ff..b989d16e4f194 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/delete_monitor.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/delete_monitor.ts @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { i18n } from '@kbn/i18n'; import { schema } from '@kbn/config-schema'; import { DeleteMonitorAPI } from './services/delete_monitor_api'; import { SyntheticsRestApiRouteFactory } from '../types'; @@ -41,30 +42,39 @@ export const deleteSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory< if (ids && queryId) { return response.badRequest({ - body: { message: 'id must be provided either via param or body.' }, + body: { + message: i18n.translate('xpack.synthetics.deleteMonitor.errorMultipleIdsProvided', { + defaultMessage: 'id must be provided either via param or body.', + }), + }, }); } const idsToDelete = [...(ids ?? []), ...(queryId ? [queryId] : [])]; if (idsToDelete.length === 0) { return response.badRequest({ - body: { message: 'id must be provided via param or body.' }, + body: { + message: i18n.translate('xpack.synthetics.deleteMonitor.errorMultipleIdsProvided', { + defaultMessage: 'id must be provided either via param or body.', + }), + }, }); } const deleteMonitorAPI = new DeleteMonitorAPI(routeContext); - try { - const { errors } = await deleteMonitorAPI.execute({ - monitorIds: idsToDelete, - }); + const { errors } = await deleteMonitorAPI.execute({ + monitorIds: idsToDelete, + }); - if (errors && errors.length > 0) { - return response.ok({ - body: { message: 'error pushing monitor to the service', attributes: { errors } }, - }); - } - } catch (getErr) { - throw getErr; + if (errors && errors.length > 0) { + return response.ok({ + body: { + message: i18n.translate('xpack.synthetics.deleteMonitor.errorPushingMonitorToService', { + defaultMessage: 'Error pushing monitor to the service', + }), + attributes: { errors }, + }, + }); } return deleteMonitorAPI.result; diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/delete_monitor_project.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/delete_monitor_project.ts index 7b36780937694..a56f66842a703 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/delete_monitor_project.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/delete_monitor_project.ts @@ -6,12 +6,12 @@ */ import { schema } from '@kbn/config-schema'; import { i18n } from '@kbn/i18n'; +import { DeleteMonitorAPI } from './services/delete_monitor_api'; import { SyntheticsRestApiRouteFactory } from '../types'; import { syntheticsMonitorType } from '../../../common/types/saved_objects'; import { ConfigKey } from '../../../common/runtime_types'; import { SYNTHETICS_API_URLS } from '../../../common/constants'; import { getMonitors, getSavedObjectKqlFilter } from '../common'; -import { deleteMonitorBulk } from './bulk_cruds/delete_monitor_bulk'; import { validateSpaceId } from './services/validate_space_id'; export const deleteSyntheticsMonitorProjectRoute: SyntheticsRestApiRouteFactory = () => ({ @@ -58,9 +58,10 @@ export const deleteSyntheticsMonitorProjectRoute: SyntheticsRestApiRouteFactory { fields: [] } ); - await deleteMonitorBulk({ + const deleteMonitorAPI = new DeleteMonitorAPI(routeContext); + + await deleteMonitorAPI.deleteMonitorBulk({ monitors, - routeContext, }); return { diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/edit_monitor.test.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/edit_monitor.test.ts index 637c0fc5c6193..cb50708c04eca 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/edit_monitor.test.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/edit_monitor.test.ts @@ -33,6 +33,16 @@ describe('syncEditedMonitor', () => { bulkUpdate: jest.fn(), get: jest.fn(), update: jest.fn(), + createPointInTimeFinder: jest.fn().mockImplementation(({ perPage, type: soType }) => ({ + close: jest.fn(async () => {}), + find: jest.fn().mockReturnValue({ + async *[Symbol.asyncIterator]() { + yield { + saved_objects: [], + }; + }, + }), + })), }, logger, config: { diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/formatters/saved_object_to_monitor.test.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/formatters/saved_object_to_monitor.test.ts index 40aec3b468a37..18c4bf71cdfec 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/formatters/saved_object_to_monitor.test.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/formatters/saved_object_to_monitor.test.ts @@ -70,6 +70,9 @@ describe('mergeSourceMonitor', () => { const result = mapSavedObjectToMonitor({ monitor: { attributes: testMonitor } } as any); expect(result).toEqual({ + __ui: { + is_tls_enabled: false, + }, alert: { status: { enabled: true, @@ -78,53 +81,48 @@ describe('mergeSourceMonitor', () => { enabled: true, }, }, + 'check.request.method': 'GET', + 'check.response.status': ['404'], config_id: 'ae88f0aa-9c7d-4a5f-96dc-89d65a0ca947', custom_heartbeat_id: 'todos-lightweight-test-projects-default', enabled: true, id: 'todos-lightweight-test-projects-default', ipv4: true, ipv6: true, - locations: ['us_central', 'us_east'], - private_locations: ['pvt_us_east'], - max_redirects: 0, + locations: [ + { + geo: { + lat: 41.25, + lon: -95.86, + }, + id: 'us_central', + isServiceManaged: true, + label: 'North America - US Central', + }, + ], + max_attempts: 2, + max_redirects: '0', mode: 'any', name: 'Todos Lightweight', namespace: 'default', + origin: 'project', original_space: 'default', - proxy_url: '', + project_id: 'test-projects', + 'response.include_body': 'on_error', + 'response.include_body_max_bytes': '1024', + 'response.include_headers': true, retest_on_failure: true, revision: 21, schedule: { number: '3', unit: 'm', }, - 'service.name': '', - tags: [], + 'ssl.key': 'test-key', + 'ssl.supported_protocols': ['TLSv1.1', 'TLSv1.2', 'TLSv1.3'], + 'ssl.verification_mode': 'full', timeout: '16', type: 'http', url: '${devUrl}', - 'url.port': null, - ssl: { - certificate: '', - certificate_authorities: '', - supported_protocols: ['TLSv1.1', 'TLSv1.2', 'TLSv1.3'], - verification_mode: 'full', - key: 'test-key', - }, - response: { - include_body: 'on_error', - include_body_max_bytes: '1024', - include_headers: true, - }, - check: { - request: { - method: 'GET', - }, - response: { - status: ['404'], - }, - }, - params: {}, }); }); @@ -157,30 +155,12 @@ describe('mergeSourceMonitor', () => { locations: [ { geo: { - lon: -95.86, lat: 41.25, - }, - isServiceManaged: true, - id: 'us_central', - label: 'North America - US Central', - }, - { - geo: { lon: -95.86, - lat: 41.25, }, + id: 'us_central', isServiceManaged: true, - id: 'us-east4-a', - label: 'US East', - }, - { - geo: { - lon: -95.86, - lat: 41.25, - }, - isServiceManaged: false, - id: 'pvt_us_east', - label: 'US East (Private)', + label: 'North America - US Central', }, ], max_redirects: '0', @@ -249,24 +229,6 @@ const testMonitor = { id: 'us_central', label: 'North America - US Central', }, - { - geo: { - lon: -95.86, - lat: 41.25, - }, - isServiceManaged: true, - id: 'us-east4-a', - label: 'US East', - }, - { - geo: { - lon: -95.86, - lat: 41.25, - }, - isServiceManaged: false, - id: 'pvt_us_east', - label: 'US East (Private)', - }, ], namespace: 'default', origin: 'project', diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/formatters/saved_object_to_monitor.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/formatters/saved_object_to_monitor.ts index 06746fc235769..4156620abdd78 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/formatters/saved_object_to_monitor.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/formatters/saved_object_to_monitor.ts @@ -7,7 +7,6 @@ import { SavedObject } from '@kbn/core/server'; import { mergeWith, omit, omitBy } from 'lodash'; -import { LocationsMap } from '../../../synthetics_service/project_monitor/normalizers/common_fields'; import { ConfigKey, EncryptedSyntheticsMonitor, @@ -22,14 +21,6 @@ const keysToOmit = [ ConfigKey.CONFIG_HASH, ConfigKey.JOURNEY_ID, ConfigKey.FORM_MONITOR_TYPE, - ConfigKey.MAX_ATTEMPTS, - ConfigKey.MONITOR_SOURCE_TYPE, - ConfigKey.METADATA, - ConfigKey.SOURCE_PROJECT_CONTENT, - ConfigKey.PROJECT_ID, - ConfigKey.JOURNEY_FILTERS_MATCH, - ConfigKey.JOURNEY_FILTERS_TAGS, - ConfigKey.MONITOR_SOURCE_TYPE, ]; type Result = MonitorFieldsResult & { @@ -39,14 +30,25 @@ type Result = MonitorFieldsResult & { ssl: Record; response: Record; check: Record; - locations: string[]; - private_locations: string[]; }; export const transformPublicKeys = (result: Result) => { + if (result[ConfigKey.SOURCE_INLINE]) { + result.inline_script = result[ConfigKey.SOURCE_INLINE]; + } + if (result[ConfigKey.HOSTS]) { + result.host = result[ConfigKey.HOSTS]; + } + if (result[ConfigKey.PARAMS]) { + try { + result[ConfigKey.PARAMS] = JSON.parse(result[ConfigKey.PARAMS] ?? '{}'); + } catch (e) { + // ignore + } + } + let formattedResult = { ...result, - ...formatLocations(result), [ConfigKey.PARAMS]: formatParams(result), retest_on_failure: (result[ConfigKey.MAX_ATTEMPTS] ?? 1) > 1, ...(result[ConfigKey.HOSTS] && { host: result[ConfigKey.HOSTS] }), @@ -58,20 +60,8 @@ export const transformPublicKeys = (result: Result) => { ...(result[ConfigKey.SOURCE_INLINE] && { inline_script: result[ConfigKey.SOURCE_INLINE] }), [ConfigKey.PLAYWRIGHT_OPTIONS]: formatPWOptions(result), }; - } else { - formattedResult.ssl = formatNestedFields(formattedResult, 'ssl'); - formattedResult.response = formatNestedFields(formattedResult, 'response'); - formattedResult.check = formatNestedFields(formattedResult, 'check'); - if (formattedResult[ConfigKey.MAX_REDIRECTS]) { - formattedResult[ConfigKey.MAX_REDIRECTS] = Number(formattedResult[ConfigKey.MAX_REDIRECTS]); - } } - const res = omit(formattedResult, keysToOmit) as Result; - - return omitBy( - res, - (_, key) => key.startsWith('response.') || key.startsWith('ssl.') || key.startsWith('check.') - ); + return omit(formattedResult, keysToOmit) as Result; }; export function mapSavedObjectToMonitor({ @@ -81,7 +71,7 @@ export function mapSavedObjectToMonitor({ monitor: SavedObject; internal?: boolean; }) { - const result = { + let result = { ...monitor.attributes, created_at: monitor.created_at, updated_at: monitor.updated_at, @@ -89,7 +79,9 @@ export function mapSavedObjectToMonitor({ if (internal) { return result; } - return transformPublicKeys(result); + result = transformPublicKeys(result); + // omit undefined value or null value + return omitBy(result, removeMonitorEmptyValues); } export function mergeSourceMonitor( normalizedPreviousMonitor: EncryptedSyntheticsMonitor, @@ -108,24 +100,6 @@ const customizer = (destVal: any, srcValue: any, key: string) => { } }; -const formatLocations = (config: MonitorFields) => { - const locMap = Object.entries(LocationsMap); - const locations = config[ConfigKey.LOCATIONS] - ?.filter((location) => location.isServiceManaged) - .map((location) => { - return locMap.find(([_key, value]) => value === location.id)?.[0] ?? location.id; - }); - - const privateLocations = config[ConfigKey.LOCATIONS] - ?.filter((location) => !location.isServiceManaged) - .map((location) => location.id); - - return { - ...(locations && { locations }), - ...(privateLocations && { private_locations: privateLocations }), - }; -}; - const formatParams = (config: MonitorFields) => { if (config[ConfigKey.PARAMS]) { try { @@ -177,3 +151,17 @@ const formatNestedFields = ( return obj; }; + +export const removeMonitorEmptyValues = (v: any) => { + // value is falsy + return ( + v === undefined || + v === null || + // value is empty string + (typeof v === 'string' && v.trim() === '') || + // is empty array + (Array.isArray(v) && v.length === 0) || + // object is has no values + (typeof v === 'object' && Object.keys(v).length === 0) + ); +}; diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/services/delete_monitor_api.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/services/delete_monitor_api.ts index bd162fc043592..4fc527f930832 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/services/delete_monitor_api.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/services/delete_monitor_api.ts @@ -7,16 +7,22 @@ import pMap from 'p-map'; import { SavedObject, SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; -import { deleteMonitorBulk } from '../bulk_cruds/delete_monitor_bulk'; import { validatePermissions } from '../edit_monitor'; import { + ConfigKey, EncryptedSyntheticsMonitorAttributes, + MonitorFields, SyntheticsMonitor, + SyntheticsMonitorWithId, SyntheticsMonitorWithSecretsAttributes, } from '../../../../common/runtime_types'; import { syntheticsMonitorType } from '../../../../common/types/saved_objects'; import { normalizeSecrets } from '../../../synthetics_service/utils'; -import { sendErrorTelemetryEvents } from '../../telemetry/monitor_upgrade_sender'; +import { + formatTelemetryDeleteEvent, + sendErrorTelemetryEvents, + sendTelemetryEvents, +} from '../../telemetry/monitor_upgrade_sender'; import { RouteContext } from '../../types'; export class DeleteMonitorAPI { @@ -100,9 +106,8 @@ export class DeleteMonitorAPI { } try { - const { errors, result } = await deleteMonitorBulk({ + const { errors, result } = await this.deleteMonitorBulk({ monitors, - routeContext: this.routeContext, }); result.statuses?.forEach((res) => { @@ -112,11 +117,55 @@ export class DeleteMonitorAPI { }); }); - return { errors }; + return { errors, result: this.result }; } catch (e) { server.logger.error(`Unable to delete Synthetics monitor with error ${e.message}`); server.logger.error(e); throw e; } } + + async deleteMonitorBulk({ + monitors, + }: { + monitors: Array>; + }) { + const { savedObjectsClient, server, spaceId, syntheticsMonitorClient } = this.routeContext; + const { logger, telemetry, stackVersion } = server; + + try { + const deleteSyncPromise = syntheticsMonitorClient.deleteMonitors( + monitors.map((normalizedMonitor) => ({ + ...normalizedMonitor.attributes, + id: normalizedMonitor.attributes[ConfigKey.MONITOR_QUERY_ID], + })) as SyntheticsMonitorWithId[], + savedObjectsClient, + spaceId + ); + + const deletePromises = savedObjectsClient.bulkDelete( + monitors.map((monitor) => ({ type: syntheticsMonitorType, id: monitor.id })) + ); + + const [errors, result] = await Promise.all([deleteSyncPromise, deletePromises]); + + monitors.forEach((monitor) => { + sendTelemetryEvents( + logger, + telemetry, + formatTelemetryDeleteEvent( + monitor, + stackVersion, + new Date().toISOString(), + Boolean((monitor.attributes as MonitorFields)[ConfigKey.SOURCE_INLINE]), + errors + ) + ); + }); + + return { errors, result }; + } catch (e) { + throw e; + } + } } diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/settings/params/delete_param.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/settings/params/delete_param.ts index 78d24d9452ae9..1a504b263861b 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/settings/params/delete_param.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/settings/params/delete_param.ts @@ -6,6 +6,7 @@ */ import { schema } from '@kbn/config-schema'; +import { i18n } from '@kbn/i18n'; import { SyntheticsRestApiRouteFactory } from '../../types'; import { syntheticsParamType } from '../../../../common/types/saved_objects'; import { SYNTHETICS_API_URLS } from '../../../../common/constants'; @@ -13,25 +14,51 @@ import { DeleteParamsResponse } from '../../../../common/runtime_types'; export const deleteSyntheticsParamsRoute: SyntheticsRestApiRouteFactory< DeleteParamsResponse[], - unknown, + { id?: string }, unknown, { ids: string[] } > = () => ({ method: 'DELETE', - path: SYNTHETICS_API_URLS.PARAMS, + path: SYNTHETICS_API_URLS.PARAMS + '/{id?}', validate: {}, validation: { request: { - body: schema.object({ - ids: schema.arrayOf(schema.string()), + body: schema.nullable( + schema.object({ + ids: schema.arrayOf(schema.string(), { + minSize: 1, + }), + }) + ), + params: schema.object({ + id: schema.maybe(schema.string()), }), }, }, - handler: async ({ savedObjectsClient, request }) => { - const { ids } = request.body; + handler: async ({ savedObjectsClient, request, response }) => { + const { ids } = request.body ?? {}; + const { id: paramId } = request.params ?? {}; + + if (ids && paramId) { + return response.badRequest({ + body: i18n.translate('xpack.synthetics.deleteParam.errorMultipleIdsProvided', { + defaultMessage: `Both param id and body parameters cannot be provided`, + }), + }); + } + + const idsToDelete = ids ?? [paramId]; + + if (idsToDelete.length === 0) { + return response.badRequest({ + body: i18n.translate('xpack.synthetics.deleteParam.errorNoIdsProvided', { + defaultMessage: `No param ids provided`, + }), + }); + } const result = await savedObjectsClient.bulkDelete( - ids.map((id) => ({ type: syntheticsParamType, id })), + idsToDelete.map((id) => ({ type: syntheticsParamType, id })), { force: true } ); return result.statuses.map(({ id, success }) => ({ id, deleted: success })); diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/settings/params/delete_params_bulk.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/settings/params/delete_params_bulk.ts new file mode 100644 index 0000000000000..2cafaf0a1af99 --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/settings/params/delete_params_bulk.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { SyntheticsRestApiRouteFactory } from '../../types'; +import { syntheticsParamType } from '../../../../common/types/saved_objects'; +import { SYNTHETICS_API_URLS } from '../../../../common/constants'; +import { DeleteParamsResponse } from '../../../../common/runtime_types'; + +export const deleteSyntheticsParamsBulkRoute: SyntheticsRestApiRouteFactory< + DeleteParamsResponse[], + unknown, + unknown, + { ids: string[] } +> = () => ({ + method: 'POST', + path: SYNTHETICS_API_URLS.PARAMS + '/_bulk_delete', + validate: {}, + validation: { + request: { + body: schema.object({ + ids: schema.arrayOf(schema.string()), + }), + }, + }, + handler: async ({ savedObjectsClient, request }) => { + const { ids } = request.body; + + const result = await savedObjectsClient.bulkDelete( + ids.map((id) => ({ type: syntheticsParamType, id })), + { force: true } + ); + return result.statuses.map(({ id, success }) => ({ id, deleted: success })); + }, +}); diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/settings/private_locations/add_private_location.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/settings/private_locations/add_private_location.ts index ac6eff7dea90d..1feb120b2ea14 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/settings/private_locations/add_private_location.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/settings/private_locations/add_private_location.ts @@ -6,14 +6,12 @@ */ import { schema, TypeOf } from '@kbn/config-schema'; +import { migrateLegacyPrivateLocations } from './migrate_legacy_private_locations'; import { SyntheticsRestApiRouteFactory } from '../../types'; import { getPrivateLocationsAndAgentPolicies } from './get_private_locations'; -import { - privateLocationsSavedObjectId, - privateLocationsSavedObjectName, -} from '../../../../common/saved_objects/private_locations'; +import { privateLocationSavedObjectName } from '../../../../common/saved_objects/private_locations'; import { SYNTHETICS_API_URLS } from '../../../../common/constants'; -import type { SyntheticsPrivateLocationsAttributes } from '../../../runtime_types/private_locations'; +import { PrivateLocationAttributes } from '../../../runtime_types/private_locations'; import { toClientContract, toSavedObjectContract } from './helpers'; import { PrivateLocation } from '../../../../common/runtime_types'; @@ -40,7 +38,11 @@ export const addPrivateLocationRoute: SyntheticsRestApiRouteFactory { + handler: async (routeContext) => { + await migrateLegacyPrivateLocations(routeContext); + + const { response, request, savedObjectsClient, syntheticsMonitorClient } = routeContext; + const location = request.body as PrivateLocationObject; const { locations, agentPolicies } = await getPrivateLocationsAndAgentPolicies( @@ -65,7 +67,6 @@ export const addPrivateLocationRoute: SyntheticsRestApiRouteFactory loc.id !== location.agentPolicyId); const formattedLocation = toSavedObjectContract({ ...location, id: location.agentPolicyId, @@ -80,17 +81,17 @@ export const addPrivateLocationRoute: SyntheticsRestApiRouteFactory( - privateLocationsSavedObjectName, - { locations: [...existingLocations, formattedLocation] }, + const soClient = routeContext.server.coreStart.savedObjects.createInternalRepository(); + + const result = await soClient.create( + privateLocationSavedObjectName, + formattedLocation, { - id: privateLocationsSavedObjectId, - overwrite: true, + id: location.agentPolicyId, + initialNamespaces: ['*'], } ); - const allLocations = toClientContract(result.attributes, agentPolicies); - - return allLocations.find((loc) => loc.id === location.agentPolicyId)!; + return toClientContract(result.attributes, agentPolicies); }, }); diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/settings/private_locations/delete_private_location.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/settings/private_locations/delete_private_location.ts index 1c6ede5a2ad00..bac3907eac871 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/settings/private_locations/delete_private_location.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/settings/private_locations/delete_private_location.ts @@ -7,15 +7,12 @@ import { schema } from '@kbn/config-schema'; import { isEmpty } from 'lodash'; +import { migrateLegacyPrivateLocations } from './migrate_legacy_private_locations'; import { getMonitorsByLocation } from './get_location_monitors'; import { getPrivateLocationsAndAgentPolicies } from './get_private_locations'; import { SyntheticsRestApiRouteFactory } from '../../types'; import { SYNTHETICS_API_URLS } from '../../../../common/constants'; -import { - privateLocationsSavedObjectId, - privateLocationsSavedObjectName, -} from '../../../../common/saved_objects/private_locations'; -import type { SyntheticsPrivateLocationsAttributes } from '../../../runtime_types/private_locations'; +import { privateLocationSavedObjectName } from '../../../../common/saved_objects/private_locations'; export const deletePrivateLocationRoute: SyntheticsRestApiRouteFactory = () => ({ method: 'DELETE', @@ -28,12 +25,16 @@ export const deletePrivateLocationRoute: SyntheticsRestApiRouteFactory { + handler: async (routeContext) => { + await migrateLegacyPrivateLocations(routeContext); + + const { savedObjectsClient, syntheticsMonitorClient, request, response, server } = routeContext; const { locationId } = request.params as { locationId: string }; const { locations } = await getPrivateLocationsAndAgentPolicies( savedObjectsClient, - syntheticsMonitorClient + syntheticsMonitorClient, + true ); if (!locations.find((loc) => loc.id === locationId)) { @@ -55,17 +56,8 @@ export const deletePrivateLocationRoute: SyntheticsRestApiRouteFactory loc.id !== locationId); - - await savedObjectsClient.create( - privateLocationsSavedObjectName, - { locations: remainingLocations }, - { - id: privateLocationsSavedObjectId, - overwrite: true, - } - ); - - return; + await savedObjectsClient.delete(privateLocationSavedObjectName, locationId, { + force: true, + }); }, }); diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/settings/private_locations/get_private_locations.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/settings/private_locations/get_private_locations.ts index f7adc1e7ac16e..d884bba5c2b0a 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/settings/private_locations/get_private_locations.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/settings/private_locations/get_private_locations.ts @@ -7,6 +7,7 @@ import { SavedObjectsErrorHelpers } from '@kbn/core/server'; import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; import { schema } from '@kbn/config-schema'; +import { migrateLegacyPrivateLocations } from './migrate_legacy_private_locations'; import { AgentPolicyInfo } from '../../../../common/types'; import { SyntheticsRestApiRouteFactory } from '../../types'; import { PrivateLocation, SyntheticsPrivateLocations } from '../../../../common/runtime_types'; @@ -14,7 +15,7 @@ import { SYNTHETICS_API_URLS } from '../../../../common/constants'; import { getPrivateLocations } from '../../../synthetics_service/get_private_locations'; import type { SyntheticsPrivateLocationsAttributes } from '../../../runtime_types/private_locations'; import { SyntheticsMonitorClient } from '../../../synthetics_service/synthetics_monitor/synthetics_monitor_client'; -import { toClientContract } from './helpers'; +import { allLocationsToClientContract } from './helpers'; export const getPrivateLocationsRoute: SyntheticsRestApiRouteFactory< SyntheticsPrivateLocations | PrivateLocation @@ -29,14 +30,17 @@ export const getPrivateLocationsRoute: SyntheticsRestApiRouteFactory< }), }, }, - handler: async ({ savedObjectsClient, syntheticsMonitorClient, request, response }) => { + handler: async (routeContext) => { + await migrateLegacyPrivateLocations(routeContext); + + const { savedObjectsClient, syntheticsMonitorClient, request, response } = routeContext; const { id } = request.params as { id?: string }; const { locations, agentPolicies } = await getPrivateLocationsAndAgentPolicies( savedObjectsClient, syntheticsMonitorClient ); - const list = toClientContract({ locations }, agentPolicies); + const list = allLocationsToClientContract({ locations }, agentPolicies); if (!id) return list; const location = list.find((loc) => loc.id === id || loc.label === id); if (!location) { @@ -53,7 +57,7 @@ export const getPrivateLocationsRoute: SyntheticsRestApiRouteFactory< export const getPrivateLocationsAndAgentPolicies = async ( savedObjectsClient: SavedObjectsClientContract, syntheticsMonitorClient: SyntheticsMonitorClient, - excludeAgentPolicies: boolean = false + excludeAgentPolicies = false ): Promise => { try { const [privateLocations, agentPolicies] = await Promise.all([ diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/settings/private_locations/helpers.test.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/settings/private_locations/helpers.test.ts index 6055b217f8794..84c531cb9ce70 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/settings/private_locations/helpers.test.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/settings/private_locations/helpers.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { toClientContract } from './helpers'; +import { allLocationsToClientContract } from './helpers'; const testLocations = { locations: [ @@ -56,7 +56,7 @@ const testLocations2 = { describe('toClientContract', () => { it('formats SO attributes to client contract with falsy geo location', () => { // @ts-ignore fixtures are purposely wrong types for testing - expect(toClientContract(testLocations)).toEqual([ + expect(allLocationsToClientContract(testLocations)).toEqual([ { agentPolicyId: 'e3134290-0f73-11ee-ba15-159f4f728deb', geo: { @@ -86,7 +86,7 @@ describe('toClientContract', () => { it('formats SO attributes to client contract with truthy geo location', () => { // @ts-ignore fixtures are purposely wrong types for testing - expect(toClientContract(testLocations2)).toEqual([ + expect(allLocationsToClientContract(testLocations2)).toEqual([ { agentPolicyId: 'e3134290-0f73-11ee-ba15-159f4f728deb', geo: { diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/settings/private_locations/helpers.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/settings/private_locations/helpers.ts index 1c6c03067a817..8df065ad3e48d 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/settings/private_locations/helpers.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/settings/private_locations/helpers.ts @@ -13,6 +13,22 @@ import type { import { PrivateLocation } from '../../../../common/runtime_types'; export const toClientContract = ( + location: PrivateLocationAttributes, + agentPolicies?: AgentPolicyInfo[] +): PrivateLocation => { + const agPolicy = agentPolicies?.find((policy) => policy.id === location.agentPolicyId); + return { + label: location.label, + id: location.id, + agentPolicyId: location.agentPolicyId, + isServiceManaged: false, + isInvalid: !Boolean(agPolicy), + tags: location.tags, + geo: location.geo, + }; +}; + +export const allLocationsToClientContract = ( attributes: SyntheticsPrivateLocationsAttributes, agentPolicies?: AgentPolicyInfo[] ): SyntheticsPrivateLocations => { diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/settings/private_locations/migrate_legacy_private_locations.test.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/settings/private_locations/migrate_legacy_private_locations.test.ts new file mode 100644 index 0000000000000..2305853aab3f1 --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/settings/private_locations/migrate_legacy_private_locations.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { migrateLegacyPrivateLocations } from './migrate_legacy_private_locations'; +import { SyntheticsServerSetup } from '../../../types'; +import { coreMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { loggerMock } from '@kbn/logging-mocks'; +import { + type ISavedObjectsRepository, + SavedObjectsClientContract, +} from '@kbn/core-saved-objects-api-server'; + +describe('migrateLegacyPrivateLocations', () => { + let serverMock: SyntheticsServerSetup; + let savedObjectsClient: jest.Mocked; + let repositoryMock: ISavedObjectsRepository; + beforeEach(() => { + const coreStartMock = coreMock.createStart(); + serverMock = { + coreStart: coreStartMock, + logger: loggerMock.create(), + } as any; + savedObjectsClient = savedObjectsClientMock.create(); + repositoryMock = coreMock.createStart().savedObjects.createInternalRepository(); + + coreStartMock.savedObjects.createInternalRepository.mockReturnValue(repositoryMock); + }); + + it('should get the legacy private locations', async () => { + savedObjectsClient.get.mockResolvedValueOnce({ + attributes: { locations: [{ id: '1', label: 'Location 1' }] }, + } as any); + savedObjectsClient.find.mockResolvedValueOnce({ total: 1 } as any); + + await migrateLegacyPrivateLocations({ + server: serverMock, + savedObjectsClient, + } as any); + + expect(savedObjectsClient.get).toHaveBeenCalledWith( + 'synthetics-privates-locations', + 'synthetics-privates-locations-singleton' + ); + }); + + it('should log and return if an error occurs while getting legacy private locations', async () => { + const error = new Error('Get error'); + savedObjectsClient.get.mockRejectedValueOnce(error); + + await migrateLegacyPrivateLocations({ + server: serverMock, + savedObjectsClient, + } as any); + + expect(serverMock.logger.error).toHaveBeenCalledWith( + `Error getting legacy private locations: ${error}` + ); + expect(repositoryMock.bulkCreate).not.toHaveBeenCalled(); + }); + + it('should return if there are no legacy locations', async () => { + savedObjectsClient.get.mockResolvedValueOnce({ + attributes: { locations: [] }, + } as any); + + await migrateLegacyPrivateLocations({ + server: serverMock, + savedObjectsClient: savedObjectsClientMock, + } as any); + + expect(repositoryMock.bulkCreate).not.toHaveBeenCalled(); + }); + + it('should bulk create new private locations if there are legacy locations', async () => { + const legacyLocations = [{ id: '1', label: 'Location 1' }]; + savedObjectsClient.get.mockResolvedValueOnce({ + attributes: { locations: legacyLocations }, + } as any); + savedObjectsClient.find.mockResolvedValueOnce({ total: 1 } as any); + + await migrateLegacyPrivateLocations({ + server: serverMock, + savedObjectsClient, + } as any); + + expect(repositoryMock.bulkCreate).toHaveBeenCalledWith( + legacyLocations.map((location) => ({ + id: location.id, + attributes: location, + type: 'synthetics-private-location', + initialNamespaces: ['*'], + })), + { overwrite: true } + ); + }); + + it('should delete legacy private locations if bulk create count matches', async () => { + const legacyLocations = [{ id: '1', label: 'Location 1' }]; + savedObjectsClient.get.mockResolvedValueOnce({ + attributes: { locations: legacyLocations }, + } as any); + savedObjectsClient.find.mockResolvedValueOnce({ total: 1 } as any); + + await migrateLegacyPrivateLocations({ + server: serverMock, + savedObjectsClient, + } as any); + + expect(savedObjectsClient.delete).toHaveBeenCalledWith( + 'synthetics-privates-locations', + 'synthetics-privates-locations-singleton', + {} + ); + }); +}); diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/settings/private_locations/migrate_legacy_private_locations.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/settings/private_locations/migrate_legacy_private_locations.ts new file mode 100644 index 0000000000000..cd73e27b950e3 --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/settings/private_locations/migrate_legacy_private_locations.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 { SavedObject } from '@kbn/core-saved-objects-server'; +import { + type PrivateLocationAttributes, + SyntheticsPrivateLocationsAttributes, +} from '../../../runtime_types/private_locations'; +import { + legacyPrivateLocationsSavedObjectId, + legacyPrivateLocationsSavedObjectName, + privateLocationSavedObjectName, +} from '../../../../common/saved_objects/private_locations'; +import { RouteContext } from '../../types'; + +export const migrateLegacyPrivateLocations = async ({ + server, + savedObjectsClient, +}: RouteContext) => { + try { + let obj: SavedObject | undefined; + try { + obj = await savedObjectsClient.get( + legacyPrivateLocationsSavedObjectName, + legacyPrivateLocationsSavedObjectId + ); + } catch (e) { + server.logger.error(`Error getting legacy private locations: ${e}`); + return; + } + const legacyLocations = obj?.attributes.locations ?? []; + if (legacyLocations.length === 0) { + return; + } + + const soClient = server.coreStart.savedObjects.createInternalRepository(); + + await soClient.bulkCreate( + legacyLocations.map((location) => ({ + id: location.id, + attributes: location, + type: privateLocationSavedObjectName, + initialNamespaces: ['*'], + })), + { + overwrite: true, + } + ); + + const { total } = await savedObjectsClient.find({ + type: privateLocationSavedObjectName, + fields: [], + perPage: 0, + }); + + if (total === legacyLocations.length) { + await savedObjectsClient.delete( + legacyPrivateLocationsSavedObjectName, + legacyPrivateLocationsSavedObjectId, + {} + ); + } + } catch (e) { + server.logger.error(`Error migrating legacy private locations: ${e}`); + } +}; diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/synthetics_service/get_service_locations.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/synthetics_service/get_service_locations.ts index a9142170c9e26..ca704cdff1b28 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/synthetics_service/get_service_locations.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/synthetics_service/get_service_locations.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { toClientContract } from '../settings/private_locations/helpers'; +import { allLocationsToClientContract } from '../settings/private_locations/helpers'; import { getPrivateLocationsAndAgentPolicies } from '../settings/private_locations/get_private_locations'; import { SyntheticsRestApiRouteFactory } from '../types'; import { getAllLocations } from '../../synthetics_service/get_all_locations'; @@ -45,7 +45,7 @@ export const getServiceLocationsRoute: SyntheticsRestApiRouteFactory = () => ({ const { locations: privateLocations, agentPolicies } = await getPrivateLocationsAndAgentPolicies(savedObjectsClient, syntheticsMonitorClient); - const result = toClientContract({ locations: privateLocations }, agentPolicies); + const result = allLocationsToClientContract({ locations: privateLocations }, agentPolicies); return { locations: result, }; diff --git a/x-pack/plugins/observability_solution/synthetics/server/saved_objects/migrations/private_locations/model_version_1.test.ts b/x-pack/plugins/observability_solution/synthetics/server/saved_objects/migrations/private_locations/model_version_1.test.ts index 63a9f940143a4..dbcdea546a9f8 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/saved_objects/migrations/private_locations/model_version_1.test.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/saved_objects/migrations/private_locations/model_version_1.test.ts @@ -5,7 +5,7 @@ * 2.0. */ import { transformGeoProperty } from './model_version_1'; -import { privateLocationsSavedObjectName } from '../../../../common/saved_objects/private_locations'; +import { legacyPrivateLocationsSavedObjectName } from '../../../../common/saved_objects/private_locations'; describe('model version 1 migration', () => { const testLocation = { @@ -19,7 +19,7 @@ describe('model version 1 migration', () => { concurrentMonitors: 1, }; const testObject = { - type: privateLocationsSavedObjectName, + type: legacyPrivateLocationsSavedObjectName, id: 'synthetics-privates-locations-singleton', attributes: { locations: [testLocation], diff --git a/x-pack/plugins/observability_solution/synthetics/server/saved_objects/private_locations.ts b/x-pack/plugins/observability_solution/synthetics/server/saved_objects/private_locations.ts index ee7426ead23af..370c8d203dff6 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/saved_objects/private_locations.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/saved_objects/private_locations.ts @@ -7,11 +7,33 @@ import { SavedObjectsType } from '@kbn/core/server'; import { modelVersion1 } from './migrations/private_locations/model_version_1'; -import { privateLocationsSavedObjectName } from '../../common/saved_objects/private_locations'; -export const privateLocationsSavedObjectId = 'synthetics-privates-locations-singleton'; +import { + legacyPrivateLocationsSavedObjectName, + privateLocationSavedObjectName, +} from '../../common/saved_objects/private_locations'; -export const PRIVATE_LOCATIONS_SAVED_OBJECT_TYPE: SavedObjectsType = { - name: privateLocationsSavedObjectName, +export const PRIVATE_LOCATION_SAVED_OBJECT_TYPE: SavedObjectsType = { + name: privateLocationSavedObjectName, + hidden: false, + namespaceType: 'multiple', + mappings: { + dynamic: false, + properties: { + /* Leaving these commented to make it clear that these fields exist, even though we don't want them indexed. + When adding new fields please add them here. If they need to be searchable put them in the uncommented + part of properties. + */ + }, + }, + management: { + importableAndExportable: true, + }, +}; + +export const legacyPrivateLocationsSavedObjectId = 'synthetics-privates-locations-singleton'; + +export const LEGACY_PRIVATE_LOCATIONS_SAVED_OBJECT_TYPE: SavedObjectsType = { + name: legacyPrivateLocationsSavedObjectName, hidden: false, namespaceType: 'agnostic', mappings: { diff --git a/x-pack/plugins/observability_solution/synthetics/server/saved_objects/saved_objects.ts b/x-pack/plugins/observability_solution/synthetics/server/saved_objects/saved_objects.ts index 9b4a365941a7d..d59ecb507166b 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/saved_objects/saved_objects.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/saved_objects/saved_objects.ts @@ -24,7 +24,10 @@ import { SYNTHETICS_SECRET_ENCRYPTED_TYPE, syntheticsParamSavedObjectType, } from './synthetics_param'; -import { PRIVATE_LOCATIONS_SAVED_OBJECT_TYPE } from './private_locations'; +import { + LEGACY_PRIVATE_LOCATIONS_SAVED_OBJECT_TYPE, + PRIVATE_LOCATION_SAVED_OBJECT_TYPE, +} from './private_locations'; import { DYNAMIC_SETTINGS_DEFAULT_ATTRIBUTES } from '../constants/settings'; import { DynamicSettingsAttributes } from '../runtime_types/settings'; import { @@ -37,7 +40,8 @@ export const registerSyntheticsSavedObjects = ( savedObjectsService: SavedObjectsServiceSetup, encryptedSavedObjects: EncryptedSavedObjectsPluginSetup ) => { - savedObjectsService.registerType(PRIVATE_LOCATIONS_SAVED_OBJECT_TYPE); + savedObjectsService.registerType(LEGACY_PRIVATE_LOCATIONS_SAVED_OBJECT_TYPE); + savedObjectsService.registerType(PRIVATE_LOCATION_SAVED_OBJECT_TYPE); savedObjectsService.registerType(getSyntheticsMonitorSavedObjectType(encryptedSavedObjects)); savedObjectsService.registerType(syntheticsServiceApiKey); diff --git a/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/get_all_locations.ts b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/get_all_locations.ts index c24b28c00ca99..0d8355cebc1f6 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/get_all_locations.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/get_all_locations.ts @@ -5,7 +5,7 @@ * 2.0. */ import { SavedObjectsClientContract } from '@kbn/core/server'; -import { toClientContract } from '../routes/settings/private_locations/helpers'; +import { allLocationsToClientContract } from '../routes/settings/private_locations/helpers'; import { getPrivateLocationsAndAgentPolicies } from '../routes/settings/private_locations/get_private_locations'; import { SyntheticsServerSetup } from '../types'; import { getServiceLocations } from './get_service_locations'; @@ -34,7 +34,10 @@ export async function getAllLocations({ ), getServicePublicLocations(server, syntheticsMonitorClient), ]); - const pvtLocations = toClientContract({ locations: privateLocations }, agentPolicies); + const pvtLocations = allLocationsToClientContract( + { locations: privateLocations }, + agentPolicies + ); return { publicLocations, privateLocations: pvtLocations, diff --git a/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/get_private_locations.ts b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/get_private_locations.ts index a850cbf081e68..a476df9dfe038 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/get_private_locations.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/get_private_locations.ts @@ -5,22 +5,42 @@ * 2.0. */ -import { SavedObjectsClientContract, SavedObjectsErrorHelpers } from '@kbn/core/server'; import { - privateLocationsSavedObjectId, - privateLocationsSavedObjectName, + SavedObject, + SavedObjectsClientContract, + SavedObjectsErrorHelpers, +} from '@kbn/core/server'; +import { uniqBy } from 'lodash'; +import { + legacyPrivateLocationsSavedObjectId, + legacyPrivateLocationsSavedObjectName, + privateLocationSavedObjectName, } from '../../common/saved_objects/private_locations'; -import type { SyntheticsPrivateLocationsAttributes } from '../runtime_types/private_locations'; +import { + PrivateLocationAttributes, + SyntheticsPrivateLocationsAttributes, +} from '../runtime_types/private_locations'; export const getPrivateLocations = async ( client: SavedObjectsClientContract ): Promise => { try { - const obj = await client.get( - privateLocationsSavedObjectName, - privateLocationsSavedObjectId - ); - return obj?.attributes.locations ?? []; + const finder = client.createPointInTimeFinder({ + type: privateLocationSavedObjectName, + perPage: 1000, + }); + + const results: Array> = []; + + for await (const response of finder.find()) { + results.push(...response.saved_objects); + } + + finder.close().catch((e) => {}); + + const legacyLocations = await getLegacyPrivateLocations(client); + + return uniqBy([...results.map((r) => r.attributes), ...legacyLocations], 'id'); } catch (getErr) { if (SavedObjectsErrorHelpers.isNotFoundError(getErr)) { return []; @@ -28,3 +48,15 @@ export const getPrivateLocations = async ( throw getErr; } }; + +const getLegacyPrivateLocations = async (client: SavedObjectsClientContract) => { + try { + const obj = await client.get( + legacyPrivateLocationsSavedObjectName, + legacyPrivateLocationsSavedObjectId + ); + return obj?.attributes.locations ?? []; + } catch (getErr) { + return []; + } +}; diff --git a/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/private_location/synthetics_private_location.ts b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/private_location/synthetics_private_location.ts index fe5f74529121e..9ed34399e74f2 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/private_location/synthetics_private_location.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/private_location/synthetics_private_location.ts @@ -369,7 +369,10 @@ export class SyntheticsPrivateLocation { return await this.server.fleet.packagePolicyService.bulkCreate( soClient, esClient, - newPolicies + newPolicies, + { + asyncDeploy: true, + } ); } } @@ -384,6 +387,7 @@ export class SyntheticsPrivateLocation { policiesToUpdate, { force: true, + asyncDeploy: true, } ); return failedPolicies; @@ -401,6 +405,7 @@ export class SyntheticsPrivateLocation { policyIdsToDelete, { force: true, + asyncDeploy: true, } ); } catch (e) { @@ -430,6 +435,7 @@ export class SyntheticsPrivateLocation { policyIdsToDelete, { force: true, + asyncDeploy: true, } ); const failedPolicies = result?.filter((policy) => { diff --git a/x-pack/plugins/observability_solution/uptime/common/rules/uptime_rule_field_map.ts b/x-pack/plugins/observability_solution/uptime/common/rules/uptime_rule_field_map.ts index 6e0f73e183462..c157177b585ba 100644 --- a/x-pack/plugins/observability_solution/uptime/common/rules/uptime_rule_field_map.ts +++ b/x-pack/plugins/observability_solution/uptime/common/rules/uptime_rule_field_map.ts @@ -32,6 +32,10 @@ export const uptimeRuleFieldMap: FieldMap = { type: 'text', required: false, }, + 'error.stack_trace': { + type: 'wildcard', + required: false, + }, 'agent.name': { type: 'keyword', required: false, diff --git a/x-pack/plugins/observability_solution/uptime/kibana.jsonc b/x-pack/plugins/observability_solution/uptime/kibana.jsonc index b45d8b78bc9cc..c4c8b8b9d76de 100644 --- a/x-pack/plugins/observability_solution/uptime/kibana.jsonc +++ b/x-pack/plugins/observability_solution/uptime/kibana.jsonc @@ -1,13 +1,20 @@ { "type": "plugin", "id": "@kbn/uptime-plugin", - "owner": "@elastic/obs-ux-management-team", + "owner": [ + "@elastic/obs-ux-management-team" + ], + "group": "observability", + "visibility": "private", "description": "This plugin visualizes data from Heartbeat, and integrates with other Observability solutions.", "plugin": { "id": "uptime", - "server": true, "browser": true, - "configPath": ["xpack", "legacy_uptime"], + "server": true, + "configPath": [ + "xpack", + "legacy_uptime" + ], "requiredPlugins": [ "actions", "alerting", @@ -33,12 +40,21 @@ "unifiedSearch", "bfetch" ], - "optionalPlugins": ["cloud", "data", "fleet", "home", "ml", "spaces", "telemetry", "observabilityAIAssistant"], + "optionalPlugins": [ + "cloud", + "data", + "fleet", + "home", + "ml", + "spaces", + "telemetry", + "observabilityAIAssistant" + ], "requiredBundles": [ "fleet", "kibanaReact", "kibanaUtils", - "observability", + "observability" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/observability_solution/ux/kibana.jsonc b/x-pack/plugins/observability_solution/ux/kibana.jsonc index 3e09a387f91b3..f2770a896d89b 100644 --- a/x-pack/plugins/observability_solution/ux/kibana.jsonc +++ b/x-pack/plugins/observability_solution/ux/kibana.jsonc @@ -1,12 +1,19 @@ { "type": "plugin", "id": "@kbn/ux-plugin", - "owner": "@elastic/obs-ux-infra_services-team", + "owner": [ + "@elastic/obs-ux-infra_services-team" + ], + "group": "observability", + "visibility": "private", "plugin": { "id": "ux", - "server": true, "browser": true, - "configPath": ["xpack", "ux"], + "server": true, + "configPath": [ + "xpack", + "ux" + ], "requiredPlugins": [ "features", "data", @@ -39,4 +46,4 @@ "maps" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/osquery/cypress/cypress_base.config.ts b/x-pack/plugins/osquery/cypress/cypress_base.config.ts index 820df131c700f..d37ebf246576e 100644 --- a/x-pack/plugins/osquery/cypress/cypress_base.config.ts +++ b/x-pack/plugins/osquery/cypress/cypress_base.config.ts @@ -10,8 +10,8 @@ import path from 'path'; import { safeLoad as loadYaml } from 'js-yaml'; import { readFileSync } from 'fs'; import type { YamlRoleDefinitions } from '@kbn/test-suites-serverless/shared/lib'; -import { setupUserDataLoader } from '@kbn/test-suites-serverless/functional/test_suites/security/cypress/support/setup_data_loader_tasks'; import { samlAuthentication } from '@kbn/security-solution-plugin/public/management/cypress/support/saml_authentication'; +import { setupUserDataLoader } from './support/setup_data_loader_tasks'; import { getFailedSpecVideos } from './support/filter_videos'; const ROLES_YAML_FILE_PATH = path.join( diff --git a/x-pack/plugins/osquery/cypress/e2e/all/alerts_automated_action_results.cy.ts b/x-pack/plugins/osquery/cypress/e2e/all/alerts_automated_action_results.cy.ts index 4c7c9663b2d40..6afbecdaba689 100644 --- a/x-pack/plugins/osquery/cypress/e2e/all/alerts_automated_action_results.cy.ts +++ b/x-pack/plugins/osquery/cypress/e2e/all/alerts_automated_action_results.cy.ts @@ -11,7 +11,10 @@ import { checkActionItemsInResults, loadRuleAlerts } from '../../tasks/live_quer const UUID_REGEX = '[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}'; -describe('Alert Flyout Automated Action Results', () => { +// Failing: See https://github.com/elastic/kibana/issues/197335 +// Failing: See https://github.com/elastic/kibana/issues/197328 +// Failing: See https://github.com/elastic/kibana/issues/178404 +describe.skip('Alert Flyout Automated Action Results', () => { let ruleId: string; before(() => { diff --git a/x-pack/plugins/osquery/cypress/e2e/all/alerts_linked_apps.cy.ts b/x-pack/plugins/osquery/cypress/e2e/all/alerts_linked_apps.cy.ts index 2b04a99bd4f9c..6ea1acfebabce 100644 --- a/x-pack/plugins/osquery/cypress/e2e/all/alerts_linked_apps.cy.ts +++ b/x-pack/plugins/osquery/cypress/e2e/all/alerts_linked_apps.cy.ts @@ -18,7 +18,8 @@ import { import { closeModalIfVisible, closeToastIfVisible } from '../../tasks/integrations'; import { RESULTS_TABLE, RESULTS_TABLE_BUTTON } from '../../screens/live_query'; -describe( +// Failing: See https://github.com/elastic/kibana/issues/181889 +describe.skip( 'Alert Event Details', { tags: ['@ess', '@serverless', '@skipInServerlessMKI'], diff --git a/x-pack/plugins/osquery/cypress/e2e/all/ecs_mappings.cy.ts b/x-pack/plugins/osquery/cypress/e2e/all/ecs_mappings.cy.ts index 5330b7869e6f4..d6f9f1a9fe4fd 100644 --- a/x-pack/plugins/osquery/cypress/e2e/all/ecs_mappings.cy.ts +++ b/x-pack/plugins/osquery/cypress/e2e/all/ecs_mappings.cy.ts @@ -18,7 +18,8 @@ import { typeInOsqueryFieldInput, } from '../../tasks/live_query'; -describe('EcsMapping', { tags: ['@ess', '@serverless'] }, () => { +// Failing: See https://github.com/elastic/kibana/issues/192128 +describe.skip('EcsMapping', { tags: ['@ess', '@serverless', '@skipInServerlessMKI'] }, () => { beforeEach(() => { initializeDataViews(); }); diff --git a/x-pack/plugins/osquery/cypress/e2e/roles/alert_test.cy.ts b/x-pack/plugins/osquery/cypress/e2e/roles/alert_test.cy.ts index b332951b1a444..2eaf015f23220 100644 --- a/x-pack/plugins/osquery/cypress/e2e/roles/alert_test.cy.ts +++ b/x-pack/plugins/osquery/cypress/e2e/roles/alert_test.cy.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { waitForAlertsToPopulate } from '@kbn/test-suites-xpack/security_solution_cypress/cypress/tasks/create_new_rule'; import { disableNewFeaturesTours } from '../../tasks/navigation'; import { initializeDataViews } from '../../tasks/login'; import { checkResults, clickRuleName, submitQuery } from '../../tasks/live_query'; @@ -31,9 +32,8 @@ describe('Alert Test', { tags: ['@ess'] }, () => { onBeforeLoad: (win) => disableNewFeaturesTours(win), }); clickRuleName(ruleName); - cy.getBySel('expand-event').first().click({ force: true }); - - cy.wait(500); + waitForAlertsToPopulate(); + cy.getBySel('expand-event').first().click(); cy.getBySel('securitySolutionFlyoutInvestigationGuideButton').click(); cy.contains('Get processes').click(); }); diff --git a/x-pack/plugins/osquery/cypress/support/e2e.ts b/x-pack/plugins/osquery/cypress/support/e2e.ts index 3a989aa235575..7426498cd2832 100644 --- a/x-pack/plugins/osquery/cypress/support/e2e.ts +++ b/x-pack/plugins/osquery/cypress/support/e2e.ts @@ -34,11 +34,16 @@ registerCypressGrep(); import type { SecuritySolutionDescribeBlockFtrConfig } from '@kbn/security-solution-plugin/scripts/run_cypress/utils'; import { login } from '@kbn/security-solution-plugin/public/management/cypress/tasks/login'; +import type { LoadedRoleAndUser } from '@kbn/test-suites-serverless/shared/lib'; import type { ServerlessRoleName } from './roles'; import { waitUntil } from '../tasks/wait_until'; import { isCloudServerless, isServerless } from '../tasks/serverless'; +export interface LoadUserAndRoleCyTaskOptions { + name: ServerlessRoleName; +} + declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace Cypress { @@ -49,6 +54,12 @@ declare global { } interface Chainable { + task( + name: 'loadUserAndRole', + arg: LoadUserAndRoleCyTaskOptions, + options?: Partial + ): Chainable; + getBySel(...args: Parameters): Chainable>; getBySelContains( diff --git a/x-pack/test_serverless/functional/test_suites/security/cypress/support/setup_data_loader_tasks.ts b/x-pack/plugins/osquery/cypress/support/setup_data_loader_tasks.ts similarity index 77% rename from x-pack/test_serverless/functional/test_suites/security/cypress/support/setup_data_loader_tasks.ts rename to x-pack/plugins/osquery/cypress/support/setup_data_loader_tasks.ts index 65cbcf5aac212..938fa67585f88 100644 --- a/x-pack/test_serverless/functional/test_suites/security/cypress/support/setup_data_loader_tasks.ts +++ b/x-pack/plugins/osquery/cypress/support/setup_data_loader_tasks.ts @@ -6,12 +6,12 @@ */ import { createRuntimeServices } from '@kbn/security-solution-plugin/scripts/endpoint/common/stack_services'; -import { LoadUserAndRoleCyTaskOptions } from '../cypress'; -import { +import { SecurityRoleAndUserLoader } from '@kbn/test-suites-serverless/shared/lib'; +import type { LoadedRoleAndUser, - SecurityRoleAndUserLoader, YamlRoleDefinitions, -} from '../../../../../shared/lib'; +} from '@kbn/test-suites-serverless/shared/lib'; +import type { LoadUserAndRoleCyTaskOptions } from './e2e'; interface AdditionalDefinitions { roleDefinitions?: YamlRoleDefinitions; @@ -33,9 +33,7 @@ export const setupUserDataLoader = ( }); const roleAndUserLoaderPromise: Promise = stackServicesPromise.then( - ({ kbnClient, log }) => { - return new SecurityRoleAndUserLoader(kbnClient, log, roleDefinitions); - } + ({ kbnClient, log }) => new SecurityRoleAndUserLoader(kbnClient, log, roleDefinitions) ); on('task', { @@ -43,8 +41,7 @@ export const setupUserDataLoader = ( * Loads a user/role into Kibana. Used from `login()` task. * @param name */ - loadUserAndRole: async ({ name }: LoadUserAndRoleCyTaskOptions): Promise => { - return (await roleAndUserLoaderPromise).load(name, additionalRoleName); - }, + loadUserAndRole: async ({ name }: LoadUserAndRoleCyTaskOptions): Promise => + (await roleAndUserLoaderPromise).load(name, additionalRoleName), }); }; diff --git a/x-pack/plugins/osquery/cypress/tasks/navigation.ts b/x-pack/plugins/osquery/cypress/tasks/navigation.ts index 72107207d7a8c..a0747706ffc15 100644 --- a/x-pack/plugins/osquery/cypress/tasks/navigation.ts +++ b/x-pack/plugins/osquery/cypress/tasks/navigation.ts @@ -45,8 +45,6 @@ export enum NAV_SEARCH_INPUT_OSQUERY_RESULTS { export const NEW_FEATURES_TOUR_STORAGE_KEYS = { RULE_MANAGEMENT_PAGE: 'securitySolution.rulesManagementPage.newFeaturesTour.v8.13', TIMELINES: 'securitySolution.security.timelineFlyoutHeader.saveTimelineTour', - TIMELINE: 'securitySolution.timeline.newFeaturesTour.v8.12', - FLYOUT: 'securitySolution.documentDetails.newFeaturesTour.v8.14', KNOWLEDGE_BASE: 'elasticAssistant.knowledgeBase.newFeaturesTour.v8.16', }; diff --git a/x-pack/plugins/painless_lab/kibana.jsonc b/x-pack/plugins/painless_lab/kibana.jsonc index e65ff13f6a8d0..adfc4db52f576 100644 --- a/x-pack/plugins/painless_lab/kibana.jsonc +++ b/x-pack/plugins/painless_lab/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/painless-lab-plugin", - "owner": "@elastic/kibana-management", + "owner": [ + "@elastic/kibana-management" + ], + "group": "platform", + "visibility": "private", "plugin": { "id": "painlessLab", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "painless_lab" @@ -19,4 +23,4 @@ "kibanaReact" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/remote_clusters/kibana.jsonc b/x-pack/plugins/remote_clusters/kibana.jsonc index 305ee26caebae..3c57477b17f5f 100644 --- a/x-pack/plugins/remote_clusters/kibana.jsonc +++ b/x-pack/plugins/remote_clusters/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/remote-clusters-plugin", - "owner": "@elastic/kibana-management", + "owner": [ + "@elastic/kibana-management" + ], + "group": "platform", + "visibility": "private", "plugin": { "id": "remoteClusters", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "remote_clusters" @@ -26,4 +30,4 @@ "esUiShared" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/reporting/kibana.jsonc b/x-pack/plugins/reporting/kibana.jsonc index 8c9e97a1f6291..4273ad8ae6dab 100644 --- a/x-pack/plugins/reporting/kibana.jsonc +++ b/x-pack/plugins/reporting/kibana.jsonc @@ -1,13 +1,20 @@ { "type": "plugin", "id": "@kbn/reporting-plugin", - "owner": "@elastic/appex-sharedux", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "private", "description": "Reporting Services enables applications to feature reports that the user can automate with Watcher and download later.", "plugin": { "id": "reporting", - "server": true, "browser": true, - "configPath": ["xpack", "reporting"], + "server": true, + "configPath": [ + "xpack", + "reporting" + ], "requiredPlugins": [ "data", "discover", @@ -21,7 +28,16 @@ "share", "features" ], - "optionalPlugins": ["security", "spaces", "usageCollection", "screenshotting"], - "requiredBundles": ["embeddable", "esUiShared", "kibanaReact"] + "optionalPlugins": [ + "security", + "spaces", + "usageCollection", + "screenshotting" + ], + "requiredBundles": [ + "embeddable", + "esUiShared", + "kibanaReact" + ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/rollup/kibana.jsonc b/x-pack/plugins/rollup/kibana.jsonc index 62f2daa8a1704..95e1e21b84956 100644 --- a/x-pack/plugins/rollup/kibana.jsonc +++ b/x-pack/plugins/rollup/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/rollup-plugin", - "owner": "@elastic/kibana-management", + "owner": [ + "@elastic/kibana-management" + ], + "group": "platform", + "visibility": "private", "plugin": { "id": "rollup", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "rollup" @@ -29,4 +33,4 @@ "data" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/rule_registry/kibana.jsonc b/x-pack/plugins/rule_registry/kibana.jsonc index 28612bff2b9cc..d663b254a3a32 100644 --- a/x-pack/plugins/rule_registry/kibana.jsonc +++ b/x-pack/plugins/rule_registry/kibana.jsonc @@ -5,10 +5,12 @@ "@elastic/response-ops", "@elastic/obs-ux-management-team" ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "ruleRegistry", - "server": true, "browser": false, + "server": true, "configPath": [ "xpack", "ruleRegistry" @@ -23,4 +25,4 @@ "spaces" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/rule_registry/server/index.ts b/x-pack/plugins/rule_registry/server/index.ts index 826d0d6f23bab..de0685b8c9617 100644 --- a/x-pack/plugins/rule_registry/server/index.ts +++ b/x-pack/plugins/rule_registry/server/index.ts @@ -25,13 +25,6 @@ export * from './rule_data_plugin_service'; export * from './rule_data_client'; export * from './alert_data_client/audit_events'; -export { createLifecycleRuleTypeFactory } from './utils/create_lifecycle_rule_type_factory'; -export type { - LifecycleRuleExecutor, - LifecycleAlertService, - LifecycleAlertServices, -} from './utils/create_lifecycle_executor'; -export { createLifecycleExecutor } from './utils/create_lifecycle_executor'; export { createPersistenceRuleTypeWrapper } from './utils/create_persistence_rule_type_wrapper'; export * from './utils/persistence_types'; export type { AlertsClient } from './alert_data_client/alerts_client'; diff --git a/x-pack/plugins/rule_registry/server/mocks.ts b/x-pack/plugins/rule_registry/server/mocks.ts index 7ab1391ca1dec..ef5ae00ca0c56 100644 --- a/x-pack/plugins/rule_registry/server/mocks.ts +++ b/x-pack/plugins/rule_registry/server/mocks.ts @@ -11,10 +11,8 @@ import { ruleDataServiceMock, RuleDataServiceMock, } from './rule_data_plugin_service/rule_data_plugin_service.mock'; -import { createLifecycleAlertServicesMock } from './utils/lifecycle_alert_services.mock'; export const ruleRegistryMocks = { - createLifecycleAlertServices: createLifecycleAlertServicesMock, createRuleDataService: ruleDataServiceMock.create, createRuleDataClient: createRuleDataClientMock, createAlertsClientMock: alertsClientMock, diff --git a/x-pack/plugins/rule_registry/server/plugin.ts b/x-pack/plugins/rule_registry/server/plugin.ts index 7f6b6e0bf6002..60ee2256ae377 100644 --- a/x-pack/plugins/rule_registry/server/plugin.ts +++ b/x-pack/plugins/rule_registry/server/plugin.ts @@ -29,7 +29,6 @@ import type { PluginSetup as DataPluginSetup, } from '@kbn/data-plugin/server'; -import { createLifecycleRuleTypeFactory } from './utils/create_lifecycle_rule_type_factory'; import type { RuleRegistryPluginConfig } from './config'; import { type IRuleDataService, RuleDataService, Dataset } from './rule_data_plugin_service'; import { AlertsClientFactory } from './alert_data_client/alerts_client_factory'; @@ -52,7 +51,6 @@ export interface RuleRegistryPluginStartDependencies { export interface RuleRegistryPluginSetupContract { ruleDataService: IRuleDataService; - createLifecycleRuleTypeFactory: typeof createLifecycleRuleTypeFactory; dataset: typeof Dataset; } @@ -153,7 +151,6 @@ export class RuleRegistryPlugin return { ruleDataService: this.ruleDataService, - createLifecycleRuleTypeFactory, dataset: Dataset, }; } diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.test.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.test.ts deleted file mode 100644 index b895c49c14a5f..0000000000000 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.test.ts +++ /dev/null @@ -1,2408 +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 { loggerMock } from '@kbn/logging-mocks'; -import { pick } from 'lodash'; -import { - ALERT_INSTANCE_ID, - ALERT_MAINTENANCE_WINDOW_IDS, - ALERT_RULE_CATEGORY, - ALERT_RULE_CONSUMER, - ALERT_RULE_NAME, - ALERT_RULE_PRODUCER, - ALERT_RULE_TYPE_ID, - ALERT_RULE_UUID, - ALERT_STATUS, - ALERT_STATUS_ACTIVE, - ALERT_STATUS_RECOVERED, - ALERT_WORKFLOW_STATUS, - ALERT_UUID, - EVENT_ACTION, - EVENT_KIND, - SPACE_IDS, - ALERT_FLAPPING, - TAGS, - ALERT_CONSECUTIVE_MATCHES, -} from '../../common/technical_rule_data_field_names'; -import { createRuleDataClientMock } from '../rule_data_client/rule_data_client.mock'; -import { createLifecycleExecutor } from './create_lifecycle_executor'; -import { createDefaultAlertExecutorOptions } from './rule_executor.test_helpers'; - -describe('createLifecycleExecutor', () => { - it('wraps and unwraps the original executor state', async () => { - const logger = loggerMock.create(); - const ruleDataClientMock = createRuleDataClientMock(); - // @ts-ignore 4.3.5 upgrade - Expression produces a union type that is too complex to represent.ts(2590) - const executor = createLifecycleExecutor( - logger, - ruleDataClientMock - )<{}, TestRuleState, never, never, never>(async (options) => { - expect(options.state).toEqual(initialRuleState); - - const nextRuleState: TestRuleState = { - aRuleStateKey: 'NEXT_RULE_STATE_VALUE', - }; - - return { state: nextRuleState }; - }); - - const newExecutorResult = await executor( - createDefaultAlertExecutorOptions({ - params: {}, - state: { wrapped: initialRuleState, trackedAlerts: {}, trackedAlertsRecovered: {} }, - logger, - }) - ); - - expect(newExecutorResult.state).toEqual({ - wrapped: { - aRuleStateKey: 'NEXT_RULE_STATE_VALUE', - }, - trackedAlerts: {}, - trackedAlertsRecovered: {}, - }); - }); - - it('writes initial documents for newly firing alerts', async () => { - const logger = loggerMock.create(); - const ruleDataClientMock = createRuleDataClientMock(); - const executor = createLifecycleExecutor( - logger, - ruleDataClientMock - )<{}, TestRuleState, never, never, never>(async ({ services, state }) => { - services.alertWithLifecycle({ - id: 'TEST_ALERT_0', - fields: { [TAGS]: ['source-tag1', 'source-tag2'] }, - }); - services.alertWithLifecycle({ - id: 'TEST_ALERT_1', - fields: { [TAGS]: ['source-tag3', 'source-tag4'] }, - }); - - return { state }; - }); - - await executor( - createDefaultAlertExecutorOptions({ - params: {}, - state: { wrapped: initialRuleState, trackedAlerts: {}, trackedAlertsRecovered: {} }, - logger, - }) - ); - - expect((await ruleDataClientMock.getWriter()).bulk).toHaveBeenCalledWith( - expect.objectContaining({ - body: [ - // alert documents - { create: { _id: expect.any(String) } }, - expect.objectContaining({ - [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [EVENT_ACTION]: 'open', - [EVENT_KIND]: 'signal', - [TAGS]: ['source-tag1', 'source-tag2', 'rule-tag1', 'rule-tag2'], - }), - { create: { _id: expect.any(String) } }, - expect.objectContaining({ - [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [EVENT_ACTION]: 'open', - [EVENT_KIND]: 'signal', - [TAGS]: ['source-tag3', 'source-tag4', 'rule-tag1', 'rule-tag2'], - }), - ], - }) - ); - expect((await ruleDataClientMock.getWriter()).bulk).not.toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.arrayContaining([ - // evaluation documents - { create: {} }, - expect.objectContaining({ - [EVENT_KIND]: 'event', - }), - ]), - }) - ); - }); - - it('updates existing documents for repeatedly firing alerts', async () => { - const logger = loggerMock.create(); - const ruleDataClientMock = createRuleDataClientMock(); - ruleDataClientMock.getReader().search.mockResolvedValue({ - hits: { - hits: [ - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', - [ALERT_UUID]: 'ALERT_0_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [ALERT_WORKFLOW_STATUS]: 'closed', - [SPACE_IDS]: ['fake-space-id'], - labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must show up in the written doc - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', - [ALERT_UUID]: 'ALERT_1_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [ALERT_WORKFLOW_STATUS]: 'open', - [SPACE_IDS]: ['fake-space-id'], - labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must not show up in the written doc - }, - _index: '.alerts-index-name', - _seq_no: 1, - _primary_term: 3, - }, - ], - }, - } as any); - const executor = createLifecycleExecutor( - logger, - ruleDataClientMock - )<{}, TestRuleState, never, never, never>(async ({ services, state }) => { - services.alertWithLifecycle({ - id: 'TEST_ALERT_0', - fields: {}, - }); - services.alertWithLifecycle({ - id: 'TEST_ALERT_1', - fields: {}, - }); - - return { state }; - }); - - await executor( - createDefaultAlertExecutorOptions({ - alertId: 'TEST_ALERT_0', - params: {}, - state: { - wrapped: initialRuleState, - trackedAlerts: { - TEST_ALERT_0: { - alertId: 'TEST_ALERT_0', - alertUuid: 'TEST_ALERT_0_UUID', - started: '2020-01-01T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - TEST_ALERT_1: { - alertId: 'TEST_ALERT_1', - alertUuid: 'TEST_ALERT_1_UUID', - started: '2020-01-02T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - }, - trackedAlertsRecovered: {}, - }, - logger, - }) - ); - - expect((await ruleDataClientMock.getWriter()).bulk).toHaveBeenCalledWith( - expect.objectContaining({ - body: [ - // alert document - { - index: { - _id: 'TEST_ALERT_0_UUID', - _index: '.alerts-index-name', - if_primary_term: 2, - if_seq_no: 4, - require_alias: false, - }, - }, - expect.objectContaining({ - [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', - [ALERT_WORKFLOW_STATUS]: 'closed', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, - - [EVENT_ACTION]: 'active', - [EVENT_KIND]: 'signal', - }), - { - index: { - _id: 'TEST_ALERT_1_UUID', - _index: '.alerts-index-name', - if_primary_term: 3, - if_seq_no: 1, - require_alias: false, - }, - }, - expect.objectContaining({ - [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', - [ALERT_WORKFLOW_STATUS]: 'open', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - - [EVENT_ACTION]: 'active', - [EVENT_KIND]: 'signal', - }), - ], - }) - ); - expect((await ruleDataClientMock.getWriter()).bulk).not.toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.arrayContaining([ - // evaluation documents - { index: {} }, - expect.objectContaining({ - [EVENT_KIND]: 'event', - }), - ]), - }) - ); - }); - - it('logs warning if existing documents are in unexpected index', async () => { - const logger = loggerMock.create(); - const ruleDataClientMock = createRuleDataClientMock(); - ruleDataClientMock.getReader().search.mockResolvedValue({ - hits: { - hits: [ - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', - [ALERT_UUID]: 'ALERT_0_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [ALERT_WORKFLOW_STATUS]: 'closed', - [SPACE_IDS]: ['fake-space-id'], - labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must show up in the written doc - }, - _index: 'partial-.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', - [ALERT_UUID]: 'ALERT_1_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [ALERT_WORKFLOW_STATUS]: 'open', - [SPACE_IDS]: ['fake-space-id'], - labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must not show up in the written doc - }, - _index: '.alerts-index-name', - _seq_no: 1, - _primary_term: 3, - }, - ], - }, - } as any); - const executor = createLifecycleExecutor( - logger, - ruleDataClientMock - )<{}, TestRuleState, never, never, never>(async ({ services, state }) => { - services.alertWithLifecycle({ - id: 'TEST_ALERT_0', - fields: {}, - }); - services.alertWithLifecycle({ - id: 'TEST_ALERT_1', - fields: {}, - }); - - return { state }; - }); - - await executor( - createDefaultAlertExecutorOptions({ - alertId: 'TEST_ALERT_0', - params: {}, - state: { - wrapped: initialRuleState, - trackedAlerts: { - TEST_ALERT_0: { - alertId: 'TEST_ALERT_0', - alertUuid: 'TEST_ALERT_0_UUID', - started: '2020-01-01T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - TEST_ALERT_1: { - alertId: 'TEST_ALERT_1', - alertUuid: 'TEST_ALERT_1_UUID', - started: '2020-01-02T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - }, - trackedAlertsRecovered: {}, - }, - logger, - }) - ); - - expect((await ruleDataClientMock.getWriter()).bulk).toHaveBeenCalledWith( - expect.objectContaining({ - body: [ - // alert document - { - index: { - _id: 'TEST_ALERT_1_UUID', - _index: '.alerts-index-name', - if_primary_term: 3, - if_seq_no: 1, - require_alias: false, - }, - }, - expect.objectContaining({ - [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', - [ALERT_WORKFLOW_STATUS]: 'open', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - - [EVENT_ACTION]: 'active', - [EVENT_KIND]: 'signal', - }), - ], - }) - ); - expect((await ruleDataClientMock.getWriter()).bulk).not.toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.arrayContaining([ - // evaluation documents - { index: {} }, - expect.objectContaining({ - [EVENT_KIND]: 'event', - }), - ]), - }) - ); - expect(logger.warn).toHaveBeenCalledWith( - `Could not update alert TEST_ALERT_0 in partial-.alerts-index-name. Partial and restored alert indices are not supported.` - ); - }); - - it('updates existing documents for recovered alerts', async () => { - const logger = loggerMock.create(); - const ruleDataClientMock = createRuleDataClientMock(); - ruleDataClientMock.getReader().search.mockResolvedValue({ - hits: { - hits: [ - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', - [ALERT_UUID]: 'ALERT_0_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [SPACE_IDS]: ['fake-space-id'], - labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must show up in the written doc - [TAGS]: ['source-tag1', 'source-tag2'], - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', - [ALERT_UUID]: 'ALERT_1_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [SPACE_IDS]: ['fake-space-id'], - labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must not show up in the written doc - [TAGS]: ['source-tag3', 'source-tag4'], - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - ], - }, - } as any); - const executor = createLifecycleExecutor( - logger, - ruleDataClientMock - )<{}, TestRuleState, never, never, never>(async ({ services, state }) => { - // TEST_ALERT_0 has recovered - services.alertWithLifecycle({ - id: 'TEST_ALERT_1', - fields: {}, - }); - - return { state }; - }); - - await executor( - createDefaultAlertExecutorOptions({ - alertId: 'TEST_ALERT_0', - params: {}, - state: { - wrapped: initialRuleState, - trackedAlerts: { - TEST_ALERT_0: { - alertId: 'TEST_ALERT_0', - alertUuid: 'TEST_ALERT_0_UUID', - started: '2020-01-01T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - TEST_ALERT_1: { - alertId: 'TEST_ALERT_1', - alertUuid: 'TEST_ALERT_1_UUID', - started: '2020-01-02T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - }, - trackedAlertsRecovered: {}, - }, - logger, - }) - ); - - expect((await ruleDataClientMock.getWriter()).bulk).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.arrayContaining([ - // alert document - { index: expect.objectContaining({ _id: 'TEST_ALERT_0_UUID' }) }, - expect.objectContaining({ - [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', - [ALERT_STATUS]: ALERT_STATUS_RECOVERED, - labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, - [TAGS]: ['source-tag1', 'source-tag2', 'rule-tag1', 'rule-tag2'], - [EVENT_ACTION]: 'close', - [EVENT_KIND]: 'signal', - }), - { index: expect.objectContaining({ _id: 'TEST_ALERT_1_UUID' }) }, - expect.objectContaining({ - [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [EVENT_ACTION]: 'active', - [EVENT_KIND]: 'signal', - [TAGS]: ['source-tag3', 'source-tag4', 'rule-tag1', 'rule-tag2'], - }), - ]), - }) - ); - expect((await ruleDataClientMock.getWriter()).bulk).not.toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.arrayContaining([ - // evaluation documents - { index: {} }, - expect.objectContaining({ - [EVENT_KIND]: 'event', - }), - ]), - }) - ); - }); - - it('does not write alert documents when rule execution is cancelled and feature flags indicate to skip', async () => { - const logger = loggerMock.create(); - const ruleDataClientMock = createRuleDataClientMock(); - const executor = createLifecycleExecutor( - logger, - ruleDataClientMock - )<{}, TestRuleState, never, never, never>(async (options) => { - expect(options.state).toEqual(initialRuleState); - - const nextRuleState: TestRuleState = { - aRuleStateKey: 'NEXT_RULE_STATE_VALUE', - }; - - return { state: nextRuleState }; - }); - - await executor( - createDefaultAlertExecutorOptions({ - params: {}, - state: { wrapped: initialRuleState, trackedAlerts: {}, trackedAlertsRecovered: {} }, - shouldWriteAlerts: false, - logger, - }) - ); - - expect((await ruleDataClientMock.getWriter()).bulk).not.toHaveBeenCalled(); - }); - - it('throws error when writer initialization fails', async () => { - const logger = loggerMock.create(); - const ruleDataClientMock = createRuleDataClientMock(); - ruleDataClientMock.getWriter = jest - .fn() - .mockRejectedValueOnce(new Error('error initializing!')); - const executor = createLifecycleExecutor( - logger, - ruleDataClientMock - )<{}, TestRuleState, never, never, never>(async (options) => { - const nextRuleState: TestRuleState = { - aRuleStateKey: 'NEXT_RULE_STATE_VALUE', - }; - - return { state: nextRuleState }; - }); - - await expect(() => - executor( - createDefaultAlertExecutorOptions({ - params: {}, - state: { wrapped: initialRuleState, trackedAlerts: {}, trackedAlertsRecovered: {} }, - shouldWriteAlerts: false, - logger, - }) - ) - ).rejects.toThrowErrorMatchingInlineSnapshot(`"error initializing!"`); - }); - - describe('updating flappingHistory', () => { - it('sets flapping state to true on a new alert', async () => { - const logger = loggerMock.create(); - const ruleDataClientMock = createRuleDataClientMock(); - const executor = createLifecycleExecutor( - logger, - ruleDataClientMock - )<{}, TestRuleState, never, never, never>(async ({ services, state }) => { - services.alertWithLifecycle({ - id: 'TEST_ALERT_0', - fields: {}, - }); - services.alertWithLifecycle({ - id: 'TEST_ALERT_1', - fields: {}, - }); - - return { state }; - }); - - const { - state: { trackedAlerts, trackedAlertsRecovered }, - } = await executor( - createDefaultAlertExecutorOptions({ - params: {}, - state: { wrapped: initialRuleState, trackedAlerts: {}, trackedAlertsRecovered: {} }, - logger, - }) - ); - - const alerts = pick(trackedAlerts, [ - 'TEST_ALERT_0.flappingHistory', - 'TEST_ALERT_1.flappingHistory', - ]); - expect(alerts).toMatchInlineSnapshot(` - Object { - "TEST_ALERT_0": Object { - "flappingHistory": Array [ - true, - ], - }, - "TEST_ALERT_1": Object { - "flappingHistory": Array [ - true, - ], - }, - } - `); - expect(trackedAlertsRecovered).toMatchInlineSnapshot(`Object {}`); - }); - - it('sets flapping state to false on an alert that is still active', async () => { - const logger = loggerMock.create(); - const ruleDataClientMock = createRuleDataClientMock(); - ruleDataClientMock.getReader().search.mockResolvedValue({ - hits: { - hits: [ - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', - [ALERT_UUID]: 'ALERT_0_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [ALERT_WORKFLOW_STATUS]: 'closed', - [SPACE_IDS]: ['fake-space-id'], - labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must show up in the written doc - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', - [ALERT_UUID]: 'ALERT_1_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [ALERT_WORKFLOW_STATUS]: 'open', - [SPACE_IDS]: ['fake-space-id'], - labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must not show up in the written doc - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - ], - }, - } as any); - const executor = createLifecycleExecutor( - logger, - ruleDataClientMock - )<{}, TestRuleState, never, never, never>(async ({ services, state }) => { - services.alertWithLifecycle({ - id: 'TEST_ALERT_0', - fields: {}, - }); - services.alertWithLifecycle({ - id: 'TEST_ALERT_1', - fields: {}, - }); - - return { state }; - }); - - const { - state: { trackedAlerts, trackedAlertsRecovered }, - } = await executor( - createDefaultAlertExecutorOptions({ - alertId: 'TEST_ALERT_0', - params: {}, - state: { - wrapped: initialRuleState, - trackedAlerts: { - TEST_ALERT_0: { - alertId: 'TEST_ALERT_0', - alertUuid: 'TEST_ALERT_0_UUID', - started: '2020-01-01T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - TEST_ALERT_1: { - alertId: 'TEST_ALERT_1', - alertUuid: 'TEST_ALERT_1_UUID', - started: '2020-01-02T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - }, - trackedAlertsRecovered: {}, - }, - logger, - }) - ); - - const alerts = pick(trackedAlerts, [ - 'TEST_ALERT_0.flappingHistory', - 'TEST_ALERT_1.flappingHistory', - ]); - expect(alerts).toMatchInlineSnapshot(` - Object { - "TEST_ALERT_0": Object { - "flappingHistory": Array [ - false, - ], - }, - "TEST_ALERT_1": Object { - "flappingHistory": Array [ - false, - ], - }, - } - `); - expect(trackedAlertsRecovered).toMatchInlineSnapshot(`Object {}`); - }); - - it('sets flapping state to true on an alert that is active and previously recovered', async () => { - const logger = loggerMock.create(); - const ruleDataClientMock = createRuleDataClientMock(); - ruleDataClientMock.getReader().search.mockResolvedValue({ - hits: { - hits: [ - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', - [ALERT_UUID]: 'ALERT_0_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [ALERT_WORKFLOW_STATUS]: 'closed', - [SPACE_IDS]: ['fake-space-id'], - labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must show up in the written doc - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', - [ALERT_UUID]: 'ALERT_1_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [ALERT_WORKFLOW_STATUS]: 'open', - [SPACE_IDS]: ['fake-space-id'], - labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must not show up in the written doc - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - ], - }, - } as any); - const executor = createLifecycleExecutor( - logger, - ruleDataClientMock - )<{}, TestRuleState, never, never, never>(async ({ services, state }) => { - services.alertWithLifecycle({ - id: 'TEST_ALERT_0', - fields: {}, - }); - services.alertWithLifecycle({ - id: 'TEST_ALERT_1', - fields: {}, - }); - - return { state }; - }); - - const { - state: { trackedAlerts, trackedAlertsRecovered }, - } = await executor( - createDefaultAlertExecutorOptions({ - alertId: 'TEST_ALERT_0', - params: {}, - state: { - wrapped: initialRuleState, - trackedAlertsRecovered: { - TEST_ALERT_0: { - alertId: 'TEST_ALERT_0', - alertUuid: 'TEST_ALERT_0_UUID', - started: '2020-01-01T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - TEST_ALERT_1: { - alertId: 'TEST_ALERT_1', - alertUuid: 'TEST_ALERT_1_UUID', - started: '2020-01-02T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - }, - trackedAlerts: {}, - }, - logger, - }) - ); - - const alerts = pick(trackedAlerts, [ - 'TEST_ALERT_0.flappingHistory', - 'TEST_ALERT_1.flappingHistory', - ]); - expect(alerts).toMatchInlineSnapshot(` - Object { - "TEST_ALERT_0": Object { - "flappingHistory": Array [ - true, - ], - }, - "TEST_ALERT_1": Object { - "flappingHistory": Array [ - true, - ], - }, - } - `); - expect(trackedAlertsRecovered).toMatchInlineSnapshot(`Object {}`); - }); - - it('sets flapping state to true on an alert that is recovered and previously active', async () => { - const logger = loggerMock.create(); - const ruleDataClientMock = createRuleDataClientMock(); - ruleDataClientMock.getReader().search.mockResolvedValue({ - hits: { - hits: [ - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', - [ALERT_UUID]: 'ALERT_0_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [SPACE_IDS]: ['fake-space-id'], - labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must show up in the written doc - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', - [ALERT_UUID]: 'ALERT_1_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [SPACE_IDS]: ['fake-space-id'], - labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must not show up in the written doc - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - ], - }, - } as any); - const executor = createLifecycleExecutor( - logger, - ruleDataClientMock - )<{}, TestRuleState, never, never, never>(async ({ services, state }) => { - // TEST_ALERT_0 has recovered - services.alertWithLifecycle({ - id: 'TEST_ALERT_1', - fields: {}, - }); - - return { state }; - }); - - const { - state: { trackedAlerts, trackedAlertsRecovered }, - } = await executor( - createDefaultAlertExecutorOptions({ - alertId: 'TEST_ALERT_0', - params: {}, - state: { - wrapped: initialRuleState, - trackedAlerts: { - TEST_ALERT_0: { - alertId: 'TEST_ALERT_0', - alertUuid: 'TEST_ALERT_0_UUID', - started: '2020-01-01T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - TEST_ALERT_1: { - alertId: 'TEST_ALERT_1', - alertUuid: 'TEST_ALERT_1_UUID', - started: '2020-01-02T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - }, - trackedAlertsRecovered: {}, - }, - logger, - }) - ); - - const recovered = pick(trackedAlertsRecovered, ['TEST_ALERT_0.flappingHistory']); - expect(recovered).toMatchInlineSnapshot(` - Object { - "TEST_ALERT_0": Object { - "flappingHistory": Array [ - true, - ], - }, - } - `); - const active = pick(trackedAlerts, ['TEST_ALERT_1.flappingHistory']); - expect(active).toMatchInlineSnapshot(` - Object { - "TEST_ALERT_1": Object { - "flappingHistory": Array [ - false, - ], - }, - } - `); - }); - - it('sets flapping state to false on an alert that is still recovered', async () => { - const logger = loggerMock.create(); - const ruleDataClientMock = createRuleDataClientMock(); - ruleDataClientMock.getReader().search.mockResolvedValue({ - hits: { - hits: [ - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', - [ALERT_UUID]: 'ALERT_0_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [SPACE_IDS]: ['fake-space-id'], - labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must show up in the written doc - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', - [ALERT_UUID]: 'ALERT_1_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [SPACE_IDS]: ['fake-space-id'], - labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must not show up in the written doc - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - ], - }, - } as any); - const executor = createLifecycleExecutor( - logger, - ruleDataClientMock - )<{}, TestRuleState, never, never, never>(async ({ services, state }) => { - // TEST_ALERT_0 has recovered - services.alertWithLifecycle({ - id: 'TEST_ALERT_1', - fields: {}, - }); - - return { state }; - }); - - const { - state: { trackedAlerts, trackedAlertsRecovered }, - } = await executor( - createDefaultAlertExecutorOptions({ - alertId: 'TEST_ALERT_0', - params: {}, - state: { - wrapped: initialRuleState, - trackedAlerts: { - TEST_ALERT_1: { - alertId: 'TEST_ALERT_1', - alertUuid: 'TEST_ALERT_1_UUID', - started: '2020-01-02T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - }, - trackedAlertsRecovered: { - TEST_ALERT_0: { - alertId: 'TEST_ALERT_0', - alertUuid: 'TEST_ALERT_0_UUID', - started: '2020-01-01T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - }, - }, - logger, - }) - ); - - const recovered = pick(trackedAlertsRecovered, ['TEST_ALERT_0.flappingHistory']); - expect(recovered).toMatchInlineSnapshot(`Object {}`); - const active = pick(trackedAlerts, ['TEST_ALERT_1.flappingHistory']); - expect(active).toMatchInlineSnapshot(` - Object { - "TEST_ALERT_1": Object { - "flappingHistory": Array [ - false, - ], - }, - } - `); - }); - }); - - describe('set maintenance window ids on the document', () => { - const maintenanceWindowIds = ['test-id-1', 'test-id-2']; - - it('updates documents with maintenance window ids for newly firing alerts', async () => { - const logger = loggerMock.create(); - const ruleDataClientMock = createRuleDataClientMock(); - - const executor = createLifecycleExecutor( - logger, - ruleDataClientMock - )<{}, TestRuleState, never, never, never>(async ({ services, state }) => { - services.alertWithLifecycle({ - id: 'TEST_ALERT_0', - fields: { [TAGS]: ['source-tag1', 'source-tag2'] }, - }); - services.alertWithLifecycle({ - id: 'TEST_ALERT_1', - fields: { [TAGS]: ['source-tag3', 'source-tag4'] }, - }); - - return { state }; - }); - - await executor( - createDefaultAlertExecutorOptions({ - params: {}, - state: { wrapped: initialRuleState, trackedAlerts: {}, trackedAlertsRecovered: {} }, - logger, - }) - ); - - expect((await ruleDataClientMock.getWriter()).bulk).toHaveBeenCalledWith( - expect.objectContaining({ - body: [ - // alert documents - { create: { _id: expect.any(String) } }, - expect.objectContaining({ - [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [EVENT_ACTION]: 'open', - [EVENT_KIND]: 'signal', - [TAGS]: ['source-tag1', 'source-tag2', 'rule-tag1', 'rule-tag2'], - [ALERT_MAINTENANCE_WINDOW_IDS]: maintenanceWindowIds, - }), - { create: { _id: expect.any(String) } }, - expect.objectContaining({ - [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [EVENT_ACTION]: 'open', - [EVENT_KIND]: 'signal', - [TAGS]: ['source-tag3', 'source-tag4', 'rule-tag1', 'rule-tag2'], - [ALERT_MAINTENANCE_WINDOW_IDS]: maintenanceWindowIds, - }), - ], - }) - ); - expect((await ruleDataClientMock.getWriter()).bulk).not.toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.arrayContaining([ - // evaluation documents - { index: {} }, - expect.objectContaining({ - [EVENT_KIND]: 'event', - }), - ]), - }) - ); - }); - - it('does not update documents with maintenance window ids for repeatedly firing alerts', async () => { - const logger = loggerMock.create(); - const ruleDataClientMock = createRuleDataClientMock(); - ruleDataClientMock.getReader().search.mockResolvedValue({ - hits: { - hits: [ - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', - [ALERT_UUID]: 'ALERT_0_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [ALERT_WORKFLOW_STATUS]: 'closed', - [SPACE_IDS]: ['fake-space-id'], - labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must show up in the written doc - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', - [ALERT_UUID]: 'ALERT_1_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [ALERT_WORKFLOW_STATUS]: 'open', - [SPACE_IDS]: ['fake-space-id'], - labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must not show up in the written doc - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - ], - }, - } as any); - - const executor = createLifecycleExecutor( - logger, - ruleDataClientMock - )<{}, TestRuleState, never, never, never>(async ({ services, state }) => { - services.alertWithLifecycle({ - id: 'TEST_ALERT_0', - fields: {}, - }); - services.alertWithLifecycle({ - id: 'TEST_ALERT_1', - fields: {}, - }); - - return { state }; - }); - - await executor( - createDefaultAlertExecutorOptions({ - alertId: 'TEST_ALERT_0', - params: {}, - state: { - wrapped: initialRuleState, - trackedAlerts: { - TEST_ALERT_0: { - alertId: 'TEST_ALERT_0', - alertUuid: 'TEST_ALERT_0_UUID', - started: '2020-01-01T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - TEST_ALERT_1: { - alertId: 'TEST_ALERT_1', - alertUuid: 'TEST_ALERT_1_UUID', - started: '2020-01-02T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - }, - trackedAlertsRecovered: {}, - }, - logger, - }) - ); - - expect((await ruleDataClientMock.getWriter()).bulk).toHaveBeenCalledWith( - expect.objectContaining({ - body: [ - // alert document - { index: expect.objectContaining({ _id: 'TEST_ALERT_0_UUID' }) }, - expect.objectContaining({ - [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', - [ALERT_WORKFLOW_STATUS]: 'closed', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, - [EVENT_ACTION]: 'active', - [EVENT_KIND]: 'signal', - }), - { index: expect.objectContaining({ _id: 'TEST_ALERT_1_UUID' }) }, - expect.objectContaining({ - [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', - [ALERT_WORKFLOW_STATUS]: 'open', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [EVENT_ACTION]: 'active', - [EVENT_KIND]: 'signal', - }), - ], - }) - ); - expect((await ruleDataClientMock.getWriter()).bulk).not.toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.arrayContaining([ - // evaluation documents - { index: {} }, - expect.objectContaining({ - [EVENT_KIND]: 'event', - }), - ]), - }) - ); - }); - - it('does not update documents with maintenance window ids for recovered alerts', async () => { - const logger = loggerMock.create(); - const ruleDataClientMock = createRuleDataClientMock(); - ruleDataClientMock.getReader().search.mockResolvedValue({ - hits: { - hits: [ - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', - [ALERT_UUID]: 'ALERT_0_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [SPACE_IDS]: ['fake-space-id'], - labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must show up in the written doc - [TAGS]: ['source-tag1', 'source-tag2'], - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', - [ALERT_UUID]: 'ALERT_1_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [SPACE_IDS]: ['fake-space-id'], - labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must not show up in the written doc - [TAGS]: ['source-tag3', 'source-tag4'], - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - ], - }, - } as any); - const executor = createLifecycleExecutor( - logger, - ruleDataClientMock - )<{}, TestRuleState, never, never, never>(async ({ services, state }) => { - // TEST_ALERT_0 has recovered - services.alertWithLifecycle({ - id: 'TEST_ALERT_1', - fields: {}, - }); - - return { state }; - }); - - await executor( - createDefaultAlertExecutorOptions({ - alertId: 'TEST_ALERT_0', - params: {}, - state: { - wrapped: initialRuleState, - trackedAlerts: { - TEST_ALERT_0: { - alertId: 'TEST_ALERT_0', - alertUuid: 'TEST_ALERT_0_UUID', - started: '2020-01-01T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - TEST_ALERT_1: { - alertId: 'TEST_ALERT_1', - alertUuid: 'TEST_ALERT_1_UUID', - started: '2020-01-02T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - }, - trackedAlertsRecovered: {}, - }, - logger, - }) - ); - - expect((await ruleDataClientMock.getWriter()).bulk).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.arrayContaining([ - // alert document - { index: expect.objectContaining({ _id: 'TEST_ALERT_0_UUID' }) }, - expect.objectContaining({ - [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', - [ALERT_STATUS]: ALERT_STATUS_RECOVERED, - labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, - [TAGS]: ['source-tag1', 'source-tag2', 'rule-tag1', 'rule-tag2'], - [EVENT_ACTION]: 'close', - [EVENT_KIND]: 'signal', - }), - { index: expect.objectContaining({ _id: 'TEST_ALERT_1_UUID' }) }, - expect.objectContaining({ - [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [EVENT_ACTION]: 'active', - [EVENT_KIND]: 'signal', - [TAGS]: ['source-tag3', 'source-tag4', 'rule-tag1', 'rule-tag2'], - }), - ]), - }) - ); - expect((await ruleDataClientMock.getWriter()).bulk).not.toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.arrayContaining([ - // evaluation documents - { index: {} }, - expect.objectContaining({ - [EVENT_KIND]: 'event', - }), - ]), - }) - ); - }); - }); - - describe('set flapping on the document', () => { - const flapping = new Array(16).fill(false).concat([true, true, true, true]); - const notFlapping = new Array(20).fill(false); - - it('updates documents with flapping for active alerts', async () => { - const logger = loggerMock.create(); - const ruleDataClientMock = createRuleDataClientMock(); - ruleDataClientMock.getReader().search.mockResolvedValue({ - hits: { - hits: [ - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', - [ALERT_UUID]: 'ALERT_0_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [ALERT_WORKFLOW_STATUS]: 'closed', - [SPACE_IDS]: ['fake-space-id'], - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', - [ALERT_UUID]: 'ALERT_1_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [ALERT_WORKFLOW_STATUS]: 'open', - [SPACE_IDS]: ['fake-space-id'], - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_2', - [ALERT_UUID]: 'ALERT_2_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [ALERT_WORKFLOW_STATUS]: 'open', - [SPACE_IDS]: ['fake-space-id'], - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_3', - [ALERT_UUID]: 'ALERT_3_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [ALERT_WORKFLOW_STATUS]: 'open', - [SPACE_IDS]: ['fake-space-id'], - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - ], - }, - } as any); - const executor = createLifecycleExecutor( - logger, - ruleDataClientMock - )<{}, TestRuleState, never, never, never>(async ({ services, state }) => { - services.alertWithLifecycle({ - id: 'TEST_ALERT_0', - fields: {}, - }); - services.alertWithLifecycle({ - id: 'TEST_ALERT_1', - fields: {}, - }); - services.alertWithLifecycle({ - id: 'TEST_ALERT_2', - fields: {}, - }); - services.alertWithLifecycle({ - id: 'TEST_ALERT_3', - fields: {}, - }); - - return { state }; - }); - - const serializedAlerts = await executor( - createDefaultAlertExecutorOptions({ - alertId: 'TEST_ALERT_0', - params: {}, - state: { - wrapped: initialRuleState, - trackedAlerts: { - TEST_ALERT_0: { - alertId: 'TEST_ALERT_0', - alertUuid: 'TEST_ALERT_0_UUID', - started: '2020-01-01T12:00:00.000Z', - flappingHistory: flapping, - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - TEST_ALERT_1: { - alertId: 'TEST_ALERT_1', - alertUuid: 'TEST_ALERT_1_UUID', - started: '2020-01-02T12:00:00.000Z', - flappingHistory: [false, false], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - TEST_ALERT_2: { - alertId: 'TEST_ALERT_2', - alertUuid: 'TEST_ALERT_2_UUID', - started: '2020-01-01T12:00:00.000Z', - flappingHistory: flapping, - flapping: true, - pendingRecoveredCount: 0, - activeCount: 0, - }, - TEST_ALERT_3: { - alertId: 'TEST_ALERT_3', - alertUuid: 'TEST_ALERT_3_UUID', - started: '2020-01-02T12:00:00.000Z', - flappingHistory: [false, false], - flapping: true, - pendingRecoveredCount: 0, - activeCount: 0, - }, - }, - trackedAlertsRecovered: {}, - }, - logger, - }) - ); - - expect(serializedAlerts.state.trackedAlerts).toEqual({ - TEST_ALERT_0: { - activeCount: 1, - alertId: 'TEST_ALERT_0', - alertUuid: 'TEST_ALERT_0_UUID', - flapping: true, - flappingHistory: flapping.slice(1).concat([false]), - pendingRecoveredCount: 0, - started: '2020-01-01T12:00:00.000Z', - }, - TEST_ALERT_1: { - activeCount: 1, - alertId: 'TEST_ALERT_1', - alertUuid: 'TEST_ALERT_1_UUID', - flapping: false, - flappingHistory: [false, false, false], - pendingRecoveredCount: 0, - started: '2020-01-02T12:00:00.000Z', - }, - TEST_ALERT_2: { - activeCount: 1, - alertId: 'TEST_ALERT_2', - alertUuid: 'TEST_ALERT_2_UUID', - flapping: true, - flappingHistory: flapping.slice(1).concat([false]), - pendingRecoveredCount: 0, - started: '2020-01-01T12:00:00.000Z', - }, - TEST_ALERT_3: { - activeCount: 1, - alertId: 'TEST_ALERT_3', - alertUuid: 'TEST_ALERT_3_UUID', - flapping: true, - flappingHistory: [false, false, false], - pendingRecoveredCount: 0, - started: '2020-01-02T12:00:00.000Z', - }, - }); - - expect(serializedAlerts.state.trackedAlertsRecovered).toEqual({}); - - expect((await ruleDataClientMock.getWriter()).bulk).toHaveBeenCalledWith( - expect.objectContaining({ - body: [ - // alert document - { index: expect.objectContaining({ _id: 'TEST_ALERT_0_UUID' }) }, - expect.objectContaining({ - [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', - [ALERT_WORKFLOW_STATUS]: 'closed', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [ALERT_FLAPPING]: false, - [EVENT_ACTION]: 'active', - [EVENT_KIND]: 'signal', - }), - { index: expect.objectContaining({ _id: 'TEST_ALERT_1_UUID' }) }, - expect.objectContaining({ - [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', - [ALERT_WORKFLOW_STATUS]: 'open', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [EVENT_ACTION]: 'active', - [EVENT_KIND]: 'signal', - [ALERT_FLAPPING]: false, - }), - { index: expect.objectContaining({ _id: 'TEST_ALERT_2_UUID' }) }, - expect.objectContaining({ - [ALERT_INSTANCE_ID]: 'TEST_ALERT_2', - [ALERT_WORKFLOW_STATUS]: 'open', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [EVENT_ACTION]: 'active', - [EVENT_KIND]: 'signal', - [ALERT_FLAPPING]: true, - }), - { index: expect.objectContaining({ _id: 'TEST_ALERT_3_UUID' }) }, - expect.objectContaining({ - [ALERT_INSTANCE_ID]: 'TEST_ALERT_3', - [ALERT_WORKFLOW_STATUS]: 'open', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [EVENT_ACTION]: 'active', - [EVENT_KIND]: 'signal', - [ALERT_FLAPPING]: true, - }), - ], - }) - ); - }); - - it('updates existing documents for recovered alerts', async () => { - const logger = loggerMock.create(); - const ruleDataClientMock = createRuleDataClientMock(); - ruleDataClientMock.getReader().search.mockResolvedValue({ - hits: { - hits: [ - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', - [ALERT_UUID]: 'ALERT_0_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [SPACE_IDS]: ['fake-space-id'], - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', - [ALERT_UUID]: 'ALERT_1_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [SPACE_IDS]: ['fake-space-id'], - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_2', - [ALERT_UUID]: 'ALERT_2_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [SPACE_IDS]: ['fake-space-id'], - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_3', - [ALERT_UUID]: 'ALERT_3_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [SPACE_IDS]: ['fake-space-id'], - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - ], - }, - } as any); - const executor = createLifecycleExecutor( - logger, - ruleDataClientMock - )<{}, TestRuleState, never, never, never>(async ({ services, state }) => { - return { state }; - }); - - const serializedAlerts = await executor( - createDefaultAlertExecutorOptions({ - alertId: 'TEST_ALERT_0', - params: {}, - state: { - wrapped: initialRuleState, - trackedAlerts: { - TEST_ALERT_0: { - alertId: 'TEST_ALERT_0', - alertUuid: 'TEST_ALERT_0_UUID', - started: '2020-01-01T12:00:00.000Z', - flappingHistory: [true, true, true, true], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - TEST_ALERT_1: { - alertId: 'TEST_ALERT_1', - alertUuid: 'TEST_ALERT_1_UUID', - started: '2020-01-02T12:00:00.000Z', - flappingHistory: notFlapping, - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - TEST_ALERT_2: { - alertId: 'TEST_ALERT_2', - alertUuid: 'TEST_ALERT_2_UUID', - started: '2020-01-02T12:00:00.000Z', - flappingHistory: [true, true], - flapping: true, - pendingRecoveredCount: 0, - activeCount: 0, - }, - TEST_ALERT_3: { - alertId: 'TEST_ALERT_3', - alertUuid: 'TEST_ALERT_3_UUID', - started: '2020-01-02T12:00:00.000Z', - flappingHistory: notFlapping, - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - }, - trackedAlertsRecovered: {}, - }, - logger, - }) - ); - - expect(serializedAlerts.state.trackedAlerts).toEqual({ - TEST_ALERT_2: { - activeCount: 0, - alertId: 'TEST_ALERT_2', - alertUuid: 'TEST_ALERT_2_UUID', - flapping: true, - flappingHistory: [true, true, true], - pendingRecoveredCount: 1, - started: '2020-01-02T12:00:00.000Z', - }, - }); - - expect(serializedAlerts.state.trackedAlertsRecovered).toEqual({ - TEST_ALERT_0: { - activeCount: 0, - alertId: 'TEST_ALERT_0', - alertUuid: 'TEST_ALERT_0_UUID', - flapping: true, - flappingHistory: [true, true, true, true, true], - pendingRecoveredCount: 0, - started: '2020-01-01T12:00:00.000Z', - }, - TEST_ALERT_1: { - activeCount: 0, - alertId: 'TEST_ALERT_1', - alertUuid: 'TEST_ALERT_1_UUID', - flapping: false, - flappingHistory: notFlapping.slice(0, notFlapping.length - 1).concat([true]), - pendingRecoveredCount: 0, - started: '2020-01-02T12:00:00.000Z', - }, - TEST_ALERT_3: { - activeCount: 0, - alertId: 'TEST_ALERT_3', - alertUuid: 'TEST_ALERT_3_UUID', - flapping: false, - flappingHistory: notFlapping.slice(0, notFlapping.length - 1).concat([true]), - pendingRecoveredCount: 0, - started: '2020-01-02T12:00:00.000Z', - }, - }); - - expect((await ruleDataClientMock.getWriter()).bulk).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.arrayContaining([ - // alert document - { index: expect.objectContaining({ _id: 'TEST_ALERT_0_UUID' }) }, - expect.objectContaining({ - [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', - [ALERT_STATUS]: ALERT_STATUS_RECOVERED, - [EVENT_ACTION]: 'close', - [EVENT_KIND]: 'signal', - [ALERT_FLAPPING]: false, - }), - { index: expect.objectContaining({ _id: 'TEST_ALERT_1_UUID' }) }, - expect.objectContaining({ - [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', - [ALERT_STATUS]: ALERT_STATUS_RECOVERED, - [EVENT_ACTION]: 'close', - [EVENT_KIND]: 'signal', - [ALERT_FLAPPING]: false, - }), - { index: expect.objectContaining({ _id: 'TEST_ALERT_2_UUID' }) }, - expect.objectContaining({ - [ALERT_INSTANCE_ID]: 'TEST_ALERT_2', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [EVENT_ACTION]: 'active', - [EVENT_KIND]: 'signal', - [ALERT_FLAPPING]: true, - }), - { index: expect.objectContaining({ _id: 'TEST_ALERT_3_UUID' }) }, - expect.objectContaining({ - [ALERT_INSTANCE_ID]: 'TEST_ALERT_3', - [ALERT_STATUS]: ALERT_STATUS_RECOVERED, - [EVENT_ACTION]: 'close', - [EVENT_KIND]: 'signal', - [ALERT_FLAPPING]: false, - }), - ]), - }) - ); - }); - }); - - describe('set consecutive matches on the document', () => { - it('updates documents with consecutive matches for active alerts', async () => { - const logger = loggerMock.create(); - const ruleDataClientMock = createRuleDataClientMock(); - ruleDataClientMock.getReader().search.mockResolvedValue({ - hits: { - hits: [ - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', - [ALERT_UUID]: 'ALERT_0_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [ALERT_WORKFLOW_STATUS]: 'closed', - [SPACE_IDS]: ['fake-space-id'], - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', - [ALERT_UUID]: 'ALERT_1_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [ALERT_WORKFLOW_STATUS]: 'open', - [SPACE_IDS]: ['fake-space-id'], - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_2', - [ALERT_UUID]: 'ALERT_2_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [ALERT_WORKFLOW_STATUS]: 'open', - [SPACE_IDS]: ['fake-space-id'], - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_3', - [ALERT_UUID]: 'ALERT_3_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [ALERT_WORKFLOW_STATUS]: 'open', - [SPACE_IDS]: ['fake-space-id'], - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - ], - }, - } as any); - const executor = createLifecycleExecutor( - logger, - ruleDataClientMock - )<{}, TestRuleState, never, never, never>(async ({ services, state }) => { - services.alertWithLifecycle({ - id: 'TEST_ALERT_0', - fields: {}, - }); - services.alertWithLifecycle({ - id: 'TEST_ALERT_1', - fields: {}, - }); - services.alertWithLifecycle({ - id: 'TEST_ALERT_2', - fields: {}, - }); - services.alertWithLifecycle({ - id: 'TEST_ALERT_3', - fields: {}, - }); - - return { state }; - }); - - const serializedAlerts = await executor( - createDefaultAlertExecutorOptions({ - alertId: 'TEST_ALERT_0', - params: {}, - state: { - wrapped: initialRuleState, - trackedAlerts: { - TEST_ALERT_0: { - alertId: 'TEST_ALERT_0', - alertUuid: 'TEST_ALERT_0_UUID', - started: '2020-01-01T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - TEST_ALERT_1: { - alertId: 'TEST_ALERT_1', - alertUuid: 'TEST_ALERT_1_UUID', - started: '2020-01-02T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - TEST_ALERT_2: { - alertId: 'TEST_ALERT_2', - alertUuid: 'TEST_ALERT_2_UUID', - started: '2020-01-01T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - TEST_ALERT_3: { - alertId: 'TEST_ALERT_3', - alertUuid: 'TEST_ALERT_3_UUID', - started: '2020-01-02T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - }, - trackedAlertsRecovered: {}, - }, - logger, - }) - ); - - expect(serializedAlerts.state.trackedAlerts).toEqual({ - TEST_ALERT_0: { - activeCount: 1, - alertId: 'TEST_ALERT_0', - alertUuid: 'TEST_ALERT_0_UUID', - flapping: false, - flappingHistory: [false], - pendingRecoveredCount: 0, - started: '2020-01-01T12:00:00.000Z', - }, - TEST_ALERT_1: { - activeCount: 1, - alertId: 'TEST_ALERT_1', - alertUuid: 'TEST_ALERT_1_UUID', - flapping: false, - flappingHistory: [false], - pendingRecoveredCount: 0, - started: '2020-01-02T12:00:00.000Z', - }, - TEST_ALERT_2: { - activeCount: 1, - alertId: 'TEST_ALERT_2', - alertUuid: 'TEST_ALERT_2_UUID', - flapping: false, - flappingHistory: [false], - pendingRecoveredCount: 0, - started: '2020-01-01T12:00:00.000Z', - }, - TEST_ALERT_3: { - activeCount: 1, - alertId: 'TEST_ALERT_3', - alertUuid: 'TEST_ALERT_3_UUID', - flapping: false, - flappingHistory: [false], - pendingRecoveredCount: 0, - started: '2020-01-02T12:00:00.000Z', - }, - }); - - expect(serializedAlerts.state.trackedAlertsRecovered).toEqual({}); - - expect((await ruleDataClientMock.getWriter()).bulk).toHaveBeenCalledWith( - expect.objectContaining({ - body: [ - // alert document - { index: expect.objectContaining({ _id: 'TEST_ALERT_0_UUID' }) }, - expect.objectContaining({ - [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', - [ALERT_WORKFLOW_STATUS]: 'closed', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [ALERT_CONSECUTIVE_MATCHES]: 1, - [EVENT_ACTION]: 'active', - [EVENT_KIND]: 'signal', - }), - { index: expect.objectContaining({ _id: 'TEST_ALERT_1_UUID' }) }, - expect.objectContaining({ - [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', - [ALERT_WORKFLOW_STATUS]: 'open', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [EVENT_ACTION]: 'active', - [EVENT_KIND]: 'signal', - [ALERT_CONSECUTIVE_MATCHES]: 1, - }), - { index: expect.objectContaining({ _id: 'TEST_ALERT_2_UUID' }) }, - expect.objectContaining({ - [ALERT_INSTANCE_ID]: 'TEST_ALERT_2', - [ALERT_WORKFLOW_STATUS]: 'open', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [EVENT_ACTION]: 'active', - [EVENT_KIND]: 'signal', - [ALERT_CONSECUTIVE_MATCHES]: 1, - }), - { index: expect.objectContaining({ _id: 'TEST_ALERT_3_UUID' }) }, - expect.objectContaining({ - [ALERT_INSTANCE_ID]: 'TEST_ALERT_3', - [ALERT_WORKFLOW_STATUS]: 'open', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [EVENT_ACTION]: 'active', - [EVENT_KIND]: 'signal', - [ALERT_CONSECUTIVE_MATCHES]: 1, - }), - ], - }) - ); - }); - - it('updates existing documents for recovered alerts', async () => { - const logger = loggerMock.create(); - const ruleDataClientMock = createRuleDataClientMock(); - ruleDataClientMock.getReader().search.mockResolvedValue({ - hits: { - hits: [ - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', - [ALERT_UUID]: 'ALERT_0_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [SPACE_IDS]: ['fake-space-id'], - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', - [ALERT_UUID]: 'ALERT_1_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [SPACE_IDS]: ['fake-space-id'], - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_2', - [ALERT_UUID]: 'ALERT_2_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [SPACE_IDS]: ['fake-space-id'], - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - { - _source: { - '@timestamp': '', - [ALERT_INSTANCE_ID]: 'TEST_ALERT_3', - [ALERT_UUID]: 'ALERT_3_UUID', - [ALERT_RULE_CATEGORY]: 'RULE_TYPE_NAME', - [ALERT_RULE_CONSUMER]: 'CONSUMER', - [ALERT_RULE_NAME]: 'NAME', - [ALERT_RULE_PRODUCER]: 'PRODUCER', - [ALERT_RULE_TYPE_ID]: 'RULE_TYPE_ID', - [ALERT_RULE_UUID]: 'RULE_UUID', - [ALERT_STATUS]: ALERT_STATUS_ACTIVE, - [SPACE_IDS]: ['fake-space-id'], - }, - _index: '.alerts-index-name', - _seq_no: 4, - _primary_term: 2, - }, - ], - }, - } as any); - const executor = createLifecycleExecutor( - logger, - ruleDataClientMock - )<{}, TestRuleState, never, never, never>(async ({ services, state }) => { - return { state }; - }); - - const serializedAlerts = await executor( - createDefaultAlertExecutorOptions({ - alertId: 'TEST_ALERT_0', - params: {}, - state: { - wrapped: initialRuleState, - trackedAlerts: { - TEST_ALERT_0: { - alertId: 'TEST_ALERT_0', - alertUuid: 'TEST_ALERT_0_UUID', - started: '2020-01-01T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - TEST_ALERT_1: { - alertId: 'TEST_ALERT_1', - alertUuid: 'TEST_ALERT_1_UUID', - started: '2020-01-02T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - TEST_ALERT_2: { - alertId: 'TEST_ALERT_2', - alertUuid: 'TEST_ALERT_2_UUID', - started: '2020-01-02T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - TEST_ALERT_3: { - alertId: 'TEST_ALERT_3', - alertUuid: 'TEST_ALERT_3_UUID', - started: '2020-01-02T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - }, - trackedAlertsRecovered: {}, - }, - logger, - }) - ); - - expect(serializedAlerts.state.trackedAlerts).toEqual({}); - - expect(serializedAlerts.state.trackedAlertsRecovered).toEqual({ - TEST_ALERT_0: { - activeCount: 0, - alertId: 'TEST_ALERT_0', - alertUuid: 'TEST_ALERT_0_UUID', - flapping: false, - flappingHistory: [true], - pendingRecoveredCount: 0, - started: '2020-01-01T12:00:00.000Z', - }, - TEST_ALERT_1: { - activeCount: 0, - alertId: 'TEST_ALERT_1', - alertUuid: 'TEST_ALERT_1_UUID', - flapping: false, - flappingHistory: [true], - pendingRecoveredCount: 0, - started: '2020-01-02T12:00:00.000Z', - }, - TEST_ALERT_2: { - activeCount: 0, - alertId: 'TEST_ALERT_2', - alertUuid: 'TEST_ALERT_2_UUID', - flapping: false, - flappingHistory: [true], - pendingRecoveredCount: 0, - started: '2020-01-02T12:00:00.000Z', - }, - TEST_ALERT_3: { - activeCount: 0, - alertId: 'TEST_ALERT_3', - alertUuid: 'TEST_ALERT_3_UUID', - flapping: false, - flappingHistory: [true], - pendingRecoveredCount: 0, - started: '2020-01-02T12:00:00.000Z', - }, - }); - - expect((await ruleDataClientMock.getWriter()).bulk).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.arrayContaining([ - // alert document - { index: expect.objectContaining({ _id: 'TEST_ALERT_0_UUID' }) }, - expect.objectContaining({ - [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', - [ALERT_STATUS]: ALERT_STATUS_RECOVERED, - [EVENT_ACTION]: 'close', - [EVENT_KIND]: 'signal', - [ALERT_CONSECUTIVE_MATCHES]: 0, - }), - { index: expect.objectContaining({ _id: 'TEST_ALERT_1_UUID' }) }, - expect.objectContaining({ - [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', - [ALERT_STATUS]: ALERT_STATUS_RECOVERED, - [EVENT_ACTION]: 'close', - [EVENT_KIND]: 'signal', - [ALERT_CONSECUTIVE_MATCHES]: 0, - }), - { index: expect.objectContaining({ _id: 'TEST_ALERT_2_UUID' }) }, - expect.objectContaining({ - [ALERT_INSTANCE_ID]: 'TEST_ALERT_2', - [ALERT_STATUS]: ALERT_STATUS_RECOVERED, - [EVENT_ACTION]: 'close', - [EVENT_KIND]: 'signal', - [ALERT_CONSECUTIVE_MATCHES]: 0, - }), - { index: expect.objectContaining({ _id: 'TEST_ALERT_3_UUID' }) }, - expect.objectContaining({ - [ALERT_INSTANCE_ID]: 'TEST_ALERT_3', - [ALERT_STATUS]: ALERT_STATUS_RECOVERED, - [EVENT_ACTION]: 'close', - [EVENT_KIND]: 'signal', - [ALERT_CONSECUTIVE_MATCHES]: 0, - }), - ]), - }) - ); - }); - }); -}); - -type TestRuleState = Record & { - aRuleStateKey: string; -}; - -const initialRuleState: TestRuleState = { - aRuleStateKey: 'INITIAL_RULE_STATE_VALUE', -}; diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts deleted file mode 100644 index cdbdf56fabc51..0000000000000 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts +++ /dev/null @@ -1,479 +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 { Logger } from '@kbn/logging'; -import type { PublicContract } from '@kbn/utility-types'; -import { getOrElse } from 'fp-ts/lib/Either'; -import { v4 } from 'uuid'; -import { difference } from 'lodash'; -import { - RuleExecutorOptions, - Alert, - AlertInstanceContext, - AlertInstanceState, - RuleTypeParams, - RuleTypeState, - isValidAlertIndexName, -} from '@kbn/alerting-plugin/server'; -import { isFlapping } from '@kbn/alerting-plugin/server/lib'; -import { wrappedStateRt, WrappedLifecycleRuleState } from '@kbn/alerting-state-types'; -export type { - TrackedLifecycleAlertState, - WrappedLifecycleRuleState, -} from '@kbn/alerting-state-types'; -import { ParsedExperimentalFields } from '../../common/parse_experimental_fields'; -import { ParsedTechnicalFields } from '../../common/parse_technical_fields'; -import { - ALERT_TIME_RANGE, - ALERT_DURATION, - ALERT_END, - ALERT_INSTANCE_ID, - ALERT_START, - ALERT_STATUS, - ALERT_STATUS_ACTIVE, - ALERT_STATUS_RECOVERED, - ALERT_UUID, - ALERT_WORKFLOW_STATUS, - EVENT_ACTION, - EVENT_KIND, - TAGS, - TIMESTAMP, - VERSION, - ALERT_FLAPPING, - ALERT_MAINTENANCE_WINDOW_IDS, -} from '../../common/technical_rule_data_field_names'; -import { CommonAlertFieldNameLatest, CommonAlertIdFieldNameLatest } from '../../common/schemas'; -import { IRuleDataClient } from '../rule_data_client'; -import { AlertExecutorOptionsWithExtraServices } from '../types'; -import { fetchExistingAlerts } from './fetch_existing_alerts'; -import { getCommonAlertFields } from './get_common_alert_fields'; -import { getUpdatedFlappingHistory } from './get_updated_flapping_history'; -import { fetchAlertByAlertUUID } from './fetch_alert_by_uuid'; -import { getAlertsForNotification } from './get_alerts_for_notification'; - -type ImplicitTechnicalFieldName = CommonAlertFieldNameLatest | CommonAlertIdFieldNameLatest; - -type ExplicitTechnicalAlertFields = Partial< - Omit ->; - -type ExplicitAlertFields = Record & // every field can have values of arbitrary types - ExplicitTechnicalAlertFields; // but technical fields must obey their respective type - -export type LifecycleAlertService< - InstanceState extends AlertInstanceState = never, - InstanceContext extends AlertInstanceContext = never, - ActionGroupIds extends string = never -> = (alert: { - id: string; - fields: ExplicitAlertFields; -}) => Alert; - -export interface LifecycleAlertServices< - InstanceState extends AlertInstanceState = never, - InstanceContext extends AlertInstanceContext = never, - ActionGroupIds extends string = never -> { - alertWithLifecycle: LifecycleAlertService; - getAlertStartedDate: (alertInstanceId: string) => string | null; - getAlertUuid: (alertInstanceId: string) => string; - getAlertByAlertUuid: ( - alertUuid: string - ) => Promise | null> | null; -} - -export type LifecycleRuleExecutor< - Params extends RuleTypeParams = never, - State extends RuleTypeState = never, - InstanceState extends AlertInstanceState = never, - InstanceContext extends AlertInstanceContext = never, - ActionGroupIds extends string = never -> = ( - options: AlertExecutorOptionsWithExtraServices< - Params, - State, - InstanceState, - InstanceContext, - ActionGroupIds, - LifecycleAlertServices - > -) => Promise<{ state: State }>; - -export const createLifecycleExecutor = - (logger: Logger, ruleDataClient: PublicContract) => - < - Params extends RuleTypeParams = never, - State extends RuleTypeState = never, - InstanceState extends AlertInstanceState = never, - InstanceContext extends AlertInstanceContext = never, - ActionGroupIds extends string = never - >( - wrappedExecutor: LifecycleRuleExecutor< - Params, - State, - InstanceState, - InstanceContext, - ActionGroupIds - > - ) => - async ( - options: RuleExecutorOptions< - Params, - WrappedLifecycleRuleState, - InstanceState, - InstanceContext, - ActionGroupIds - > - ): Promise<{ state: WrappedLifecycleRuleState }> => { - const { - services: { alertFactory, getMaintenanceWindowIds, shouldWriteAlerts }, - state: previousState, - flappingSettings, - rule, - } = options; - - const ruleDataClientWriter = await ruleDataClient.getWriter(); - - const state = getOrElse( - (): WrappedLifecycleRuleState => ({ - wrapped: previousState as State, - trackedAlerts: {}, - trackedAlertsRecovered: {}, - }) - )(wrappedStateRt().decode(previousState)); - - const commonRuleFields = getCommonAlertFields(options); - - const currentAlerts: Record = {}; - const alertUuidMap: Map = new Map(); - - const lifecycleAlertServices: LifecycleAlertServices< - InstanceState, - InstanceContext, - ActionGroupIds - > = { - alertWithLifecycle: ({ id, fields }) => { - currentAlerts[id] = fields; - const alert = alertFactory.create(id); - const uuid = alert.getUuid(); - alertUuidMap.set(id, uuid); - return alert; - }, - getAlertStartedDate: (alertId: string) => state.trackedAlerts[alertId]?.started ?? null, - getAlertUuid: (alertId: string) => { - const uuid = alertUuidMap.get(alertId); - if (uuid) { - return uuid; - } - - const trackedAlert = state.trackedAlerts[alertId]; - if (trackedAlert) { - return trackedAlert.alertUuid; - } - - const trackedRecoveredAlert = state.trackedAlertsRecovered[alertId]; - if (trackedRecoveredAlert) { - return trackedRecoveredAlert.alertUuid; - } - - const alertInfo = `alert ${alertId} of rule ${rule.ruleTypeId}:${rule.id}`; - logger.warn( - `[Rule Registry] requesting uuid for ${alertInfo} which is not tracked, generating dynamically` - ); - return v4(); - }, - getAlertByAlertUuid: async (alertUuid: string) => { - try { - return await fetchAlertByAlertUUID(ruleDataClient, alertUuid); - } catch (err) { - return null; - } - }, - }; - - const wrappedExecutorResult = await wrappedExecutor({ - ...options, - state: state.wrapped != null ? state.wrapped : ({} as State), - services: { - ...options.services, - ...lifecycleAlertServices, - }, - }); - - const currentAlertIds = Object.keys(currentAlerts); - const trackedAlertIds = Object.keys(state.trackedAlerts); - const trackedAlertRecoveredIds = Object.keys(state.trackedAlertsRecovered); - const newAlertIds = difference(currentAlertIds, trackedAlertIds); - const allAlertIds = [...new Set(currentAlertIds.concat(trackedAlertIds))]; - - const trackedAlertStates = Object.values(state.trackedAlerts); - - logger.debug( - `[Rule Registry] Tracking ${allAlertIds.length} alerts (${newAlertIds.length} new, ${trackedAlertStates.length} previous)` - ); - - // load maintenance window ids if there are new alerts - const maintenanceWindowIds: string[] = allAlertIds.length - ? await getMaintenanceWindowIds() - : []; - - interface TrackedAlertData { - indexName: string; - fields: Partial; - seqNo: number | undefined; - primaryTerm: number | undefined; - } - - const trackedAlertsDataMap: Record = {}; - - if (trackedAlertStates.length) { - const result = await fetchExistingAlerts( - ruleDataClient, - trackedAlertStates, - commonRuleFields - ); - result.forEach((hit) => { - const alertInstanceId = hit._source ? hit._source[ALERT_INSTANCE_ID] : void 0; - if (alertInstanceId && hit._source) { - const alertLabel = `${rule.ruleTypeId}:${rule.id} ${alertInstanceId}`; - if (hit._seq_no == null) { - logger.error(`missing _seq_no on alert instance ${alertLabel}`); - } else if (hit._primary_term == null) { - logger.error(`missing _primary_term on alert instance ${alertLabel}`); - } else { - trackedAlertsDataMap[alertInstanceId] = { - indexName: hit._index, - fields: hit._source, - seqNo: hit._seq_no, - primaryTerm: hit._primary_term, - }; - } - } - }); - } - - const makeEventsDataMapFor = (alertIds: string[]) => - alertIds - .filter((alertId) => { - const alertData = trackedAlertsDataMap[alertId]; - const alertIndex = alertData?.indexName; - if (!alertIndex) { - return true; - } else if (!isValidAlertIndexName(alertIndex)) { - logger.warn( - `Could not update alert ${alertId} in ${alertIndex}. Partial and restored alert indices are not supported.` - ); - return false; - } - return true; - }) - .map((alertId) => { - const alertData = trackedAlertsDataMap[alertId]; - const currentAlertData = currentAlerts[alertId]; - const trackedAlert = state.trackedAlerts[alertId]; - - if (!alertData) { - logger.debug(`[Rule Registry] Could not find alert data for ${alertId}`); - } - - const isNew = !trackedAlert; - const isRecovered = !currentAlertData; - const isActive = !isRecovered; - - const flappingHistory = getUpdatedFlappingHistory( - flappingSettings, - alertId, - state, - isNew, - isRecovered, - isActive, - trackedAlertRecoveredIds - ); - - const { alertUuid, started, flapping, pendingRecoveredCount, activeCount } = !isNew - ? state.trackedAlerts[alertId] - : { - alertUuid: lifecycleAlertServices.getAlertUuid(alertId), - started: commonRuleFields[TIMESTAMP], - flapping: state.trackedAlertsRecovered[alertId] - ? state.trackedAlertsRecovered[alertId].flapping - : false, - pendingRecoveredCount: 0, - activeCount: 0, - }; - - const event: ParsedTechnicalFields & ParsedExperimentalFields = { - ...alertData?.fields, - ...commonRuleFields, - ...currentAlertData, - [ALERT_DURATION]: (options.startedAt.getTime() - new Date(started).getTime()) * 1000, - [ALERT_TIME_RANGE]: isRecovered - ? { - gte: started, - lte: commonRuleFields[TIMESTAMP], - } - : { gte: started }, - [ALERT_INSTANCE_ID]: alertId, - [ALERT_START]: started, - [ALERT_UUID]: alertUuid, - [ALERT_STATUS]: isRecovered ? ALERT_STATUS_RECOVERED : ALERT_STATUS_ACTIVE, - [ALERT_WORKFLOW_STATUS]: alertData?.fields[ALERT_WORKFLOW_STATUS] ?? 'open', - [EVENT_KIND]: 'signal', - [EVENT_ACTION]: isNew ? 'open' : isActive ? 'active' : 'close', - [TAGS]: Array.from( - new Set([ - ...(currentAlertData?.tags ?? []), - ...(alertData?.fields[TAGS] ?? []), - ...(options.rule.tags ?? []), - ]) - ), - [VERSION]: ruleDataClient.kibanaVersion, - [ALERT_FLAPPING]: flapping, - ...(isRecovered ? { [ALERT_END]: commonRuleFields[TIMESTAMP] } : {}), - ...(isNew && maintenanceWindowIds?.length - ? { [ALERT_MAINTENANCE_WINDOW_IDS]: maintenanceWindowIds } - : {}), - }; - - return { - indexName: alertData?.indexName, - seqNo: alertData?.seqNo, - primaryTerm: alertData?.primaryTerm, - event, - flappingHistory, - flapping, - pendingRecoveredCount, - activeCount, - }; - }); - - const trackedEventsToIndex = makeEventsDataMapFor(trackedAlertIds); - const newEventsToIndex = makeEventsDataMapFor(newAlertIds); - const trackedRecoveredEventsToIndex = makeEventsDataMapFor(trackedAlertRecoveredIds); - const allEventsToIndex = getAlertsForNotification( - flappingSettings, - rule.alertDelay?.active ?? 0, - trackedEventsToIndex, - newEventsToIndex, - { maintenanceWindowIds, timestamp: commonRuleFields[TIMESTAMP] } - ); - - // Only write alerts if: - // - writing is enabled - // AND - // - rule execution has not been cancelled due to timeout - // OR - // - if execution has been cancelled due to timeout, if feature flags are configured to write alerts anyway - const writeAlerts = ruleDataClient.isWriteEnabled() && shouldWriteAlerts(); - - if (allEventsToIndex.length > 0 && writeAlerts) { - logger.debug(`[Rule Registry] Preparing to index ${allEventsToIndex.length} alerts.`); - - await ruleDataClientWriter.bulk({ - body: allEventsToIndex.flatMap(({ event, indexName, seqNo, primaryTerm }) => [ - indexName - ? { - index: { - _id: event[ALERT_UUID]!, - _index: indexName, - if_seq_no: seqNo, - if_primary_term: primaryTerm, - require_alias: false, - }, - } - : { - create: { - _id: event[ALERT_UUID]!, - }, - }, - event, - ]), - refresh: true, - }); - } else { - logger.debug( - `[Rule Registry] Not indexing ${allEventsToIndex.length} alerts because writing has been disabled.` - ); - } - - const nextTrackedAlerts = Object.fromEntries( - [...newEventsToIndex, ...trackedEventsToIndex] - .filter(({ event }) => event[ALERT_STATUS] !== ALERT_STATUS_RECOVERED) - .map( - ({ - event, - flappingHistory, - flapping: isCurrentlyFlapping, - pendingRecoveredCount, - activeCount, - }) => { - const alertId = event[ALERT_INSTANCE_ID]!; - const alertUuid = event[ALERT_UUID]!; - const started = new Date(event[ALERT_START]!).toISOString(); - const flapping = isFlapping(flappingSettings, flappingHistory, isCurrentlyFlapping); - return [ - alertId, - { - alertId, - alertUuid, - started, - flappingHistory, - flapping, - pendingRecoveredCount, - activeCount, - }, - ]; - } - ) - ); - - const nextTrackedAlertsRecovered = Object.fromEntries( - [...allEventsToIndex, ...trackedRecoveredEventsToIndex] - .filter( - ({ event, flappingHistory, flapping }) => - // return recovered alerts if they are flapping or if the flapping array is not at capacity - // this is a space saving effort that will stop tracking a recovered alert if it wasn't flapping and doesn't have state changes - // in the last max capcity number of executions - event[ALERT_STATUS] === ALERT_STATUS_RECOVERED && - (flapping || flappingHistory.filter((f: boolean) => f).length > 0) - ) - .map( - ({ - event, - flappingHistory, - flapping: isCurrentlyFlapping, - pendingRecoveredCount, - activeCount, - }) => { - const alertId = event[ALERT_INSTANCE_ID]!; - const alertUuid = event[ALERT_UUID]!; - const started = new Date(event[ALERT_START]!).toISOString(); - const flapping = isFlapping(flappingSettings, flappingHistory, isCurrentlyFlapping); - return [ - alertId, - { - alertId, - alertUuid, - started, - flappingHistory, - flapping, - pendingRecoveredCount, - activeCount, - }, - ]; - } - ) - ); - - return { - state: { - wrapped: wrappedExecutorResult?.state ?? ({} as State), - trackedAlerts: writeAlerts ? nextTrackedAlerts : {}, - trackedAlertsRecovered: writeAlerts ? nextTrackedAlertsRecovered : {}, - }, - }; - }; diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_executor_mock.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_executor_mock.ts deleted file mode 100644 index bf0d98d5156af..0000000000000 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_executor_mock.ts +++ /dev/null @@ -1,38 +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 { - RuleTypeParams, - RuleTypeState, - AlertInstanceState, - AlertInstanceContext, -} from '@kbn/alerting-plugin/server'; -import { AlertExecutorOptionsWithExtraServices } from '../types'; - -import { LifecycleAlertServices, LifecycleRuleExecutor } from './create_lifecycle_executor'; - -export const createLifecycleRuleExecutorMock = - < - Params extends RuleTypeParams = never, - State extends RuleTypeState = never, - InstanceState extends AlertInstanceState = never, - InstanceContext extends AlertInstanceContext = never, - ActionGroupIds extends string = never - >( - executor: LifecycleRuleExecutor - ) => - async ( - options: AlertExecutorOptionsWithExtraServices< - Params, - State, - InstanceState, - InstanceContext, - ActionGroupIds, - LifecycleAlertServices - > - ) => - await executor(options); diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts deleted file mode 100644 index 6dbc33b666497..0000000000000 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts +++ /dev/null @@ -1,512 +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 { schema } from '@kbn/config-schema'; -import { - ALERT_DURATION, - ALERT_STATUS, - ALERT_STATUS_ACTIVE, - ALERT_STATUS_RECOVERED, - ALERT_UUID, - ALERT_TIME_RANGE, -} from '@kbn/rule-data-utils'; -import { loggerMock } from '@kbn/logging-mocks'; -import { castArray, omit } from 'lodash'; -import { createRuleDataClientMock } from '../rule_data_client/rule_data_client.mock'; -import { createLifecycleRuleTypeFactory } from './create_lifecycle_rule_type_factory'; -import { ISearchStartSearchSource } from '@kbn/data-plugin/common'; -import { SharePluginStart } from '@kbn/share-plugin/server'; -import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; -import { DEFAULT_FLAPPING_SETTINGS } from '@kbn/alerting-plugin/common/rules_settings'; - -type RuleTestHelpers = ReturnType; - -function createRule(shouldWriteAlerts: boolean = true) { - const ruleDataClientMock = createRuleDataClientMock(); - - const factory = createLifecycleRuleTypeFactory({ - ruleDataClient: ruleDataClientMock, - logger: loggerMock.create(), - }); - - let nextAlerts: Array<{ id: string; fields: Record }> = []; - - const type = factory({ - actionGroups: [ - { - id: 'warning', - name: 'warning', - }, - ], - actionVariables: { - context: [], - params: [], - state: [], - }, - defaultActionGroupId: 'warning', - executor: async ({ services }) => { - nextAlerts.forEach((alert) => { - services.alertWithLifecycle(alert); - }); - nextAlerts = []; - return { state: {} }; - }, - id: 'ruleTypeId', - isExportable: true, - minimumLicenseRequired: 'basic', - name: 'ruleTypeName', - category: 'test', - producer: 'producer', - validate: { - params: schema.object( - {}, - { - unknowns: 'allow', - } - ), - }, - }); - - let state: Record = {}; - let previousStartedAt: Date | null; - const createdAt = new Date('2021-06-16T09:00:00.000Z'); - - const scheduleActions = jest.fn(); - - let uuidCounter = 1; - const getUuid = jest.fn(() => `uuid-${uuidCounter++}`); - - const alertFactory = { - create: () => { - return { - scheduleActions, - getUuid, - } as any; - }, - alertLimit: { - getValue: () => 1000, - setLimitReached: () => {}, - }, - done: () => ({ getRecoveredAlerts: () => [] }), - }; - - return { - alertWithLifecycle: async (alerts: Array<{ id: string; fields: Record }>) => { - nextAlerts = alerts; - - const startedAt = new Date((previousStartedAt ?? createdAt).getTime() + 60000); - - scheduleActions.mockClear(); - - ({ state } = ((await type.executor({ - executionId: 'b33f65d7-6e8b-4aae-8d20-c93613dec9f9', - logger: loggerMock.create(), - namespace: 'namespace', - params: { threshold: 1, operator: '>' }, - previousStartedAt, - rule: { - id: 'alertId', - actions: [], - consumer: 'consumer', - createdAt, - createdBy: 'createdBy', - enabled: true, - muteAll: false, - name: 'name', - notifyWhen: 'onActionGroupChange', - producer: 'producer', - revision: 0, - ruleTypeId: 'ruleTypeId', - ruleTypeName: 'ruleTypeName', - schedule: { - interval: '1m', - }, - snoozeSchedule: [], - tags: ['tags'], - throttle: null, - updatedAt: createdAt, - updatedBy: 'updatedBy', - }, - services: { - alertsClient: null, - alertFactory, - savedObjectsClient: {} as any, - scopedClusterClient: {} as any, - search: {} as any, - getMaintenanceWindowIds: async () => [], - getSearchSourceClient: async () => ({} as ISearchStartSearchSource), - shouldStopExecution: () => false, - shouldWriteAlerts: () => shouldWriteAlerts, - uiSettingsClient: {} as any, - share: {} as SharePluginStart, - getDataViews: async () => dataViewPluginMocks.createStartContract(), - }, - spaceId: 'spaceId', - startedAt, - startedAtOverridden: false, - state, - flappingSettings: DEFAULT_FLAPPING_SETTINGS, - getTimeRange: () => { - const date = new Date(Date.now()).toISOString(); - return { dateStart: date, dateEnd: date }; - }, - })) ?? {}) as Record); - - previousStartedAt = startedAt; - }, - scheduleActions, - ruleDataClientMock, - }; -} - -describe('createLifecycleRuleTypeFactory', () => { - describe('with a new rule', () => { - let helpers: RuleTestHelpers; - - beforeEach(() => { - helpers = createRule(); - }); - - describe('when writing is disabled', () => { - beforeEach(() => { - helpers.ruleDataClientMock.isWriteEnabled.mockReturnValue(false); - }); - - it("doesn't persist anything", async () => { - await helpers.alertWithLifecycle([ - { - id: 'opbeans-java', - fields: { - 'service.name': 'opbeans-java', - }, - }, - ]); - - expect((await helpers.ruleDataClientMock.getWriter()).bulk).toHaveBeenCalledTimes(0); - }); - }); - - describe('when rule is cancelled due to timeout and config flags indicate to skip actions', () => { - beforeEach(() => { - helpers = createRule(false); - helpers.ruleDataClientMock.isWriteEnabled.mockReturnValue(true); - }); - - it("doesn't persist anything", async () => { - await helpers.alertWithLifecycle([ - { - id: 'opbeans-java', - fields: { - 'service.name': 'opbeans-java', - }, - }, - ]); - - expect((await helpers.ruleDataClientMock.getWriter()).bulk).toHaveBeenCalledTimes(0); - }); - }); - - describe('when alerts are new', () => { - beforeEach(async () => { - await helpers.alertWithLifecycle([ - { - id: 'opbeans-java', - fields: { - 'service.name': 'opbeans-java', - }, - }, - { - id: 'opbeans-node', - fields: { - 'service.name': 'opbeans-node', - }, - }, - ]); - }); - - it('writes the correct alerts', async () => { - expect((await helpers.ruleDataClientMock.getWriter()).bulk).toHaveBeenCalledTimes(1); - - const body = (await helpers.ruleDataClientMock.getWriter()).bulk.mock.calls[0][0].body!; - - const documents: any[] = body.filter((op: any) => !isOpDoc(op)); - - const evaluationDocuments = documents.filter((doc) => doc['event.kind'] === 'event'); - const alertDocuments = documents.filter((doc) => doc['event.kind'] === 'signal'); - - expect(evaluationDocuments.length).toBe(0); - expect(alertDocuments.length).toBe(2); - - expect( - alertDocuments.every((doc) => doc[ALERT_STATUS] === ALERT_STATUS_ACTIVE) - ).toBeTruthy(); - - expect(alertDocuments.every((doc) => doc[ALERT_DURATION] === 0)).toBeTruthy(); - - expect(alertDocuments.every((doc) => doc['event.action'] === 'open')).toBeTruthy(); - - expect(documents.map((doc) => omit(doc, ALERT_UUID))).toMatchInlineSnapshot(` - Array [ - Object { - "@timestamp": "2021-06-16T09:01:00.000Z", - "event.action": "open", - "event.kind": "signal", - "kibana.alert.consecutive_matches": 1, - "kibana.alert.duration.us": 0, - "kibana.alert.flapping": false, - "kibana.alert.instance.id": "opbeans-java", - "kibana.alert.rule.category": "ruleTypeName", - "kibana.alert.rule.consumer": "consumer", - "kibana.alert.rule.execution.uuid": "b33f65d7-6e8b-4aae-8d20-c93613dec9f9", - "kibana.alert.rule.name": "name", - "kibana.alert.rule.parameters": Object { - "operator": ">", - "threshold": 1, - }, - "kibana.alert.rule.producer": "producer", - "kibana.alert.rule.revision": 0, - "kibana.alert.rule.rule_type_id": "ruleTypeId", - "kibana.alert.rule.tags": Array [ - "tags", - ], - "kibana.alert.rule.uuid": "alertId", - "kibana.alert.start": "2021-06-16T09:01:00.000Z", - "kibana.alert.status": "active", - "kibana.alert.time_range": Object { - "gte": "2021-06-16T09:01:00.000Z", - }, - "kibana.alert.workflow_status": "open", - "kibana.space_ids": Array [ - "spaceId", - ], - "kibana.version": "7.16.0", - "service.name": "opbeans-java", - "tags": Array [ - "tags", - ], - }, - Object { - "@timestamp": "2021-06-16T09:01:00.000Z", - "event.action": "open", - "event.kind": "signal", - "kibana.alert.consecutive_matches": 1, - "kibana.alert.duration.us": 0, - "kibana.alert.flapping": false, - "kibana.alert.instance.id": "opbeans-node", - "kibana.alert.rule.category": "ruleTypeName", - "kibana.alert.rule.consumer": "consumer", - "kibana.alert.rule.execution.uuid": "b33f65d7-6e8b-4aae-8d20-c93613dec9f9", - "kibana.alert.rule.name": "name", - "kibana.alert.rule.parameters": Object { - "operator": ">", - "threshold": 1, - }, - "kibana.alert.rule.producer": "producer", - "kibana.alert.rule.revision": 0, - "kibana.alert.rule.rule_type_id": "ruleTypeId", - "kibana.alert.rule.tags": Array [ - "tags", - ], - "kibana.alert.rule.uuid": "alertId", - "kibana.alert.start": "2021-06-16T09:01:00.000Z", - "kibana.alert.status": "active", - "kibana.alert.time_range": Object { - "gte": "2021-06-16T09:01:00.000Z", - }, - "kibana.alert.workflow_status": "open", - "kibana.space_ids": Array [ - "spaceId", - ], - "kibana.version": "7.16.0", - "service.name": "opbeans-node", - "tags": Array [ - "tags", - ], - }, - ] - `); - }); - }); - - describe('when alerts are active', () => { - beforeEach(async () => { - await helpers.alertWithLifecycle([ - { - id: 'opbeans-java', - fields: { - 'service.name': 'opbeans-java', - }, - }, - { - id: 'opbeans-node', - fields: { - 'service.name': 'opbeans-node', - }, - }, - ]); - - // TODO mock the resolved value before calling alertWithLifecycle again - const lastOpbeansNodeDoc = ( - await helpers.ruleDataClientMock.getWriter() - ).bulk.mock.calls[0][0].body - ?.concat() - .reverse() - .find((doc: any) => !isOpDoc(doc) && doc['service.name'] === 'opbeans-node') as Record< - string, - any - >; - - // @ts-ignore 4.3.5 upgrade - helpers.ruleDataClientMock.getReader().search.mockResolvedValueOnce({ - hits: { - hits: [{ _source: lastOpbeansNodeDoc } as any], - total: { - value: 1, - relation: 'eq', - }, - }, - took: 0, - timed_out: false, - _shards: { - failed: 0, - successful: 1, - total: 1, - }, - }); - - await helpers.alertWithLifecycle([ - { - id: 'opbeans-java', - fields: { - 'service.name': 'opbeans-java', - }, - }, - { - id: 'opbeans-node', - fields: { - 'service.name': 'opbeans-node', - 'kibana.alert.workflow_status': 'closed', - }, - }, - ]); - }); - - it('writes the correct alerts', async () => { - expect((await helpers.ruleDataClientMock.getWriter()).bulk).toHaveBeenCalledTimes(2); - const body = (await helpers.ruleDataClientMock.getWriter()).bulk.mock.calls[1][0].body!; - - const documents: any[] = body.filter((op: any) => !isOpDoc(op)); - - const evaluationDocuments = documents.filter((doc) => doc['event.kind'] === 'event'); - const alertDocuments = documents.filter((doc) => doc['event.kind'] === 'signal'); - - expect(evaluationDocuments.length).toBe(0); - expect(alertDocuments.length).toBe(2); - - expect( - alertDocuments.every((doc) => doc[ALERT_STATUS] === ALERT_STATUS_ACTIVE) - ).toBeTruthy(); - expect(alertDocuments.every((doc) => doc['event.action'] === 'active')).toBeTruthy(); - - expect(alertDocuments.every((doc) => doc[ALERT_DURATION] > 0)).toBeTruthy(); - }); - }); - - describe('when alerts recover', () => { - beforeEach(async () => { - await helpers.alertWithLifecycle([ - { - id: 'opbeans-java', - fields: { - 'service.name': 'opbeans-java', - }, - }, - { - id: 'opbeans-node', - fields: { - 'service.name': 'opbeans-node', - }, - }, - ]); - - const lastOpbeansNodeDoc = ( - await helpers.ruleDataClientMock.getWriter() - ).bulk.mock.calls[0][0].body - ?.concat() - .reverse() - .find((doc: any) => !isOpDoc(doc) && doc['service.name'] === 'opbeans-node') as Record< - string, - any - >; - - helpers.ruleDataClientMock.getReader().search.mockResolvedValueOnce({ - hits: { - hits: [ - { - _source: lastOpbeansNodeDoc, - _index: '.alerts-a', - _primary_term: 4, - _seq_no: 2, - } as any, - ], - total: { - value: 1, - relation: 'eq', - }, - }, - took: 0, - timed_out: false, - _shards: { - failed: 0, - successful: 1, - total: 1, - }, - }); - - await helpers.alertWithLifecycle([ - { - id: 'opbeans-java', - fields: { - 'service.name': 'opbeans-java', - }, - }, - ]); - }); - - it('writes the correct alerts', async () => { - expect((await helpers.ruleDataClientMock.getWriter()).bulk).toHaveBeenCalledTimes(2); - - const body = (await helpers.ruleDataClientMock.getWriter()).bulk.mock.calls[1][0].body!; - - const documents: any[] = body.filter((op: any) => !isOpDoc(op)); - - const opbeansJavaAlertDoc = documents.find( - (doc) => castArray(doc['service.name'])[0] === 'opbeans-java' - ); - const opbeansNodeAlertDoc = documents.find( - (doc) => castArray(doc['service.name'])[0] === 'opbeans-node' - ); - - expect(opbeansJavaAlertDoc['event.action']).toBe('active'); - expect(opbeansJavaAlertDoc[ALERT_STATUS]).toBe(ALERT_STATUS_ACTIVE); - - expect(opbeansNodeAlertDoc['event.action']).toBe('close'); - expect(opbeansNodeAlertDoc[ALERT_STATUS]).toBe(ALERT_STATUS_RECOVERED); - expect(opbeansNodeAlertDoc[ALERT_TIME_RANGE]).toEqual({ - gte: '2021-06-16T09:01:00.000Z', - lte: '2021-06-16T09:02:00.000Z', - }); - }); - }); - }); -}); - -function isOpDoc(doc: any) { - if (doc?.index?._id) return true; - if (doc?.create?._id) return true; - return false; -} diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts deleted file mode 100644 index 7f1be5ff54f83..0000000000000 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts +++ /dev/null @@ -1,45 +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 { Logger } from '@kbn/logging'; -import { - AlertInstanceContext, - AlertInstanceState, - RuleTypeParams, - RuleTypeState, -} from '@kbn/alerting-plugin/common'; -import { IRuleDataClient } from '../rule_data_client'; -import { AlertTypeWithExecutor } from '../types'; -import { createLifecycleExecutor, LifecycleAlertServices } from './create_lifecycle_executor'; - -export const createLifecycleRuleTypeFactory = - ({ logger, ruleDataClient }: { logger: Logger; ruleDataClient: IRuleDataClient }) => - < - TParams extends RuleTypeParams, - TAlertInstanceState extends AlertInstanceState, - TAlertInstanceContext extends AlertInstanceContext, - TActionGroupIds extends string, - TServices extends LifecycleAlertServices< - TAlertInstanceState, - TAlertInstanceContext, - TActionGroupIds - > - >( - type: AlertTypeWithExecutor - ): AlertTypeWithExecutor => { - const createBoundLifecycleExecutor = createLifecycleExecutor(logger, ruleDataClient); - const executor = createBoundLifecycleExecutor< - TParams, - RuleTypeState, - AlertInstanceState, - TAlertInstanceContext, - string - >(type.executor as any); - return { - ...type, - executor: executor as any, - }; - }; diff --git a/x-pack/plugins/rule_registry/server/utils/get_updated_flapping_history.test.ts b/x-pack/plugins/rule_registry/server/utils/get_updated_flapping_history.test.ts deleted file mode 100644 index 84685779186d9..0000000000000 --- a/x-pack/plugins/rule_registry/server/utils/get_updated_flapping_history.test.ts +++ /dev/null @@ -1,207 +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 { - DEFAULT_FLAPPING_SETTINGS, - DISABLE_FLAPPING_SETTINGS, -} from '@kbn/alerting-plugin/common/rules_settings'; -import { getUpdatedFlappingHistory } from './get_updated_flapping_history'; - -describe('getUpdatedFlappingHistory', () => { - type TestRuleState = Record & { - aRuleStateKey: string; - }; - const initialRuleState: TestRuleState = { - aRuleStateKey: 'INITIAL_RULE_STATE_VALUE', - }; - - test('sets flapping state to true if the alert is new', () => { - const state = { wrapped: initialRuleState, trackedAlerts: {}, trackedAlertsRecovered: {} }; - expect( - getUpdatedFlappingHistory( - DEFAULT_FLAPPING_SETTINGS, - 'TEST_ALERT_0', - state, - true, - false, - false, - [] - ) - ).toMatchInlineSnapshot(` - Array [ - true, - ] - `); - }); - - test('sets flapping state to false on an alert that is still active', () => { - const state = { - wrapped: initialRuleState, - trackedAlerts: { - TEST_ALERT_0: { - alertId: 'TEST_ALERT_0', - alertUuid: 'TEST_ALERT_0_UUID', - started: '2020-01-01T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - }, - trackedAlertsRecovered: {}, - }; - expect( - getUpdatedFlappingHistory( - DEFAULT_FLAPPING_SETTINGS, - 'TEST_ALERT_0', - state, - false, - false, - true, - [] - ) - ).toMatchInlineSnapshot(` - Array [ - false, - ] - `); - }); - - test('sets flapping state to true on an alert that is active and previously recovered', () => { - const state = { - wrapped: initialRuleState, - trackedAlertsRecovered: { - TEST_ALERT_0: { - alertId: 'TEST_ALERT_0', - alertUuid: 'TEST_ALERT_0_UUID', - started: '2020-01-01T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - }, - trackedAlerts: {}, - }; - const recoveredIds = ['TEST_ALERT_0']; - expect( - getUpdatedFlappingHistory( - DEFAULT_FLAPPING_SETTINGS, - 'TEST_ALERT_0', - state, - true, - false, - true, - recoveredIds - ) - ).toMatchInlineSnapshot(` - Array [ - true, - ] - `); - expect(recoveredIds).toEqual([]); - }); - - test('sets flapping state to true on an alert that is recovered and previously active', () => { - const state = { - wrapped: initialRuleState, - trackedAlerts: { - TEST_ALERT_0: { - alertId: 'TEST_ALERT_0', - alertUuid: 'TEST_ALERT_0_UUID', - started: '2020-01-01T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - }, - trackedAlertsRecovered: {}, - }; - const recoveredIds = ['TEST_ALERT_0']; - expect( - getUpdatedFlappingHistory( - DEFAULT_FLAPPING_SETTINGS, - 'TEST_ALERT_0', - state, - false, - true, - false, - recoveredIds - ) - ).toMatchInlineSnapshot(` - Array [ - true, - ] - `); - expect(recoveredIds).toEqual(['TEST_ALERT_0']); - }); - - test('sets flapping state to false on an alert that is still recovered', () => { - const state = { - wrapped: initialRuleState, - trackedAlerts: {}, - trackedAlertsRecovered: { - TEST_ALERT_0: { - alertId: 'TEST_ALERT_0', - alertUuid: 'TEST_ALERT_0_UUID', - started: '2020-01-01T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - }, - }; - const recoveredIds = ['TEST_ALERT_0']; - expect( - getUpdatedFlappingHistory( - DEFAULT_FLAPPING_SETTINGS, - 'TEST_ALERT_0', - state, - false, - true, - false, - recoveredIds - ) - ).toMatchInlineSnapshot(` - Array [ - false, - ] - `); - expect(recoveredIds).toEqual(['TEST_ALERT_0']); - }); - - test('does not set flapping state if flapping is not enabled', () => { - const state = { - wrapped: initialRuleState, - trackedAlerts: {}, - trackedAlertsRecovered: { - TEST_ALERT_0: { - alertId: 'TEST_ALERT_0', - alertUuid: 'TEST_ALERT_0_UUID', - started: '2020-01-01T12:00:00.000Z', - flappingHistory: [], - flapping: false, - pendingRecoveredCount: 0, - activeCount: 0, - }, - }, - }; - expect( - getUpdatedFlappingHistory( - DISABLE_FLAPPING_SETTINGS, - 'TEST_ALERT_0', - state, - false, - true, - false, - ['TEST_ALERT_0'] - ) - ).toMatchInlineSnapshot(`Array []`); - }); -}); diff --git a/x-pack/plugins/rule_registry/server/utils/get_updated_flapping_history.ts b/x-pack/plugins/rule_registry/server/utils/get_updated_flapping_history.ts deleted file mode 100644 index 854f919722330..0000000000000 --- a/x-pack/plugins/rule_registry/server/utils/get_updated_flapping_history.ts +++ /dev/null @@ -1,64 +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 { RuleTypeState } from '@kbn/alerting-plugin/common'; -import { RulesSettingsFlappingProperties } from '@kbn/alerting-plugin/common/rules_settings'; -import { updateFlappingHistory } from '@kbn/alerting-plugin/server/lib'; -import { remove } from 'lodash'; -import { WrappedLifecycleRuleState } from './create_lifecycle_executor'; - -export function getUpdatedFlappingHistory( - flappingSettings: RulesSettingsFlappingProperties, - alertId: string, - state: WrappedLifecycleRuleState, - isNew: boolean, - isRecovered: boolean, - isActive: boolean, - recoveredIds: string[] -) { - // duplicating this logic to determine flapping at this level - let flappingHistory: boolean[] = []; - if (flappingSettings.enabled) { - if (isRecovered) { - if (state.trackedAlerts[alertId]) { - // this alert has flapped from active to recovered - flappingHistory = updateFlappingHistory( - flappingSettings, - state.trackedAlerts[alertId].flappingHistory, - true - ); - } else if (state.trackedAlertsRecovered[alertId]) { - // this alert is still recovered - flappingHistory = updateFlappingHistory( - flappingSettings, - state.trackedAlertsRecovered[alertId].flappingHistory, - false - ); - } - } else if (isNew) { - if (state.trackedAlertsRecovered[alertId]) { - // this alert has flapped from recovered to active - flappingHistory = updateFlappingHistory( - flappingSettings, - state.trackedAlertsRecovered[alertId].flappingHistory, - true - ); - remove(recoveredIds, (id) => id === alertId); - } else { - flappingHistory = updateFlappingHistory(flappingSettings, [], true); - } - } else if (isActive) { - // this alert is still active - flappingHistory = updateFlappingHistory( - flappingSettings, - state.trackedAlerts[alertId].flappingHistory, - false - ); - } - } - return flappingHistory; -} diff --git a/x-pack/plugins/rule_registry/server/utils/lifecycle_alert_services.mock.ts b/x-pack/plugins/rule_registry/server/utils/lifecycle_alert_services.mock.ts deleted file mode 100644 index 9324bcfd76cb4..0000000000000 --- a/x-pack/plugins/rule_registry/server/utils/lifecycle_alert_services.mock.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { AlertInstanceContext, AlertInstanceState } from '@kbn/alerting-plugin/server'; -import { alertsMock } from '@kbn/alerting-plugin/server/mocks'; -import { LifecycleAlertServices } from './create_lifecycle_executor'; - -/** - * This wraps the alerts to enable the preservation of the generic type - * arguments of the factory function. - **/ -class AlertsMockWrapper< - InstanceState extends AlertInstanceState = AlertInstanceState, - InstanceContext extends AlertInstanceContext = AlertInstanceContext -> { - createAlertServices() { - return alertsMock.createRuleExecutorServices(); - } -} - -type AlertServices< - InstanceState extends AlertInstanceState = AlertInstanceState, - InstanceContext extends AlertInstanceContext = AlertInstanceContext -> = ReturnType['createAlertServices']>; - -export const createLifecycleAlertServicesMock = < - InstanceState extends AlertInstanceState = never, - InstanceContext extends AlertInstanceContext = never, - ActionGroupIds extends string = never ->( - alertServices: AlertServices -): LifecycleAlertServices => ({ - alertWithLifecycle: ({ id }) => alertServices.alertFactory.create(id), - getAlertStartedDate: jest.fn((id: string) => null), - getAlertUuid: jest.fn((id: string) => 'mock-alert-uuid'), - getAlertByAlertUuid: jest.fn((id: string) => Promise.resolve(null)), -}); diff --git a/x-pack/plugins/rule_registry/tsconfig.json b/x-pack/plugins/rule_registry/tsconfig.json index 71f1e13a199b5..8c244ed95e014 100644 --- a/x-pack/plugins/rule_registry/tsconfig.json +++ b/x-pack/plugins/rule_registry/tsconfig.json @@ -34,7 +34,6 @@ "@kbn/alerts-as-data-utils", "@kbn/core-http-router-server-mocks", "@kbn/core-http-server", - "@kbn/alerting-state-types", "@kbn/alerting-types" ], "exclude": [ diff --git a/x-pack/plugins/runtime_fields/kibana.jsonc b/x-pack/plugins/runtime_fields/kibana.jsonc index 54f222d0fdf23..95d7815414dbf 100644 --- a/x-pack/plugins/runtime_fields/kibana.jsonc +++ b/x-pack/plugins/runtime_fields/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/runtime-fields-plugin", - "owner": "@elastic/kibana-management", + "owner": [ + "@elastic/kibana-management" + ], + "group": "platform", + "visibility": "private", "plugin": { "id": "runtimeFields", - "server": false, "browser": true, + "server": false, "configPath": [ "xpack", "runtime_fields" @@ -15,4 +19,4 @@ "esUiShared" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/saved_objects_tagging/kibana.jsonc b/x-pack/plugins/saved_objects_tagging/kibana.jsonc index a3c5609148d99..3a2cdef308de0 100644 --- a/x-pack/plugins/saved_objects_tagging/kibana.jsonc +++ b/x-pack/plugins/saved_objects_tagging/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/saved-objects-tagging-plugin", - "owner": "@elastic/appex-sharedux", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "private", "plugin": { "id": "savedObjectsTagging", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "saved_object_tagging" @@ -21,4 +25,4 @@ ], "requiredBundles": [] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/screenshotting/kibana.jsonc b/x-pack/plugins/screenshotting/kibana.jsonc index 426df4176e750..855f041d13ee3 100644 --- a/x-pack/plugins/screenshotting/kibana.jsonc +++ b/x-pack/plugins/screenshotting/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/screenshotting-plugin", - "owner": "@elastic/kibana-reporting-services", + "owner": [ + "@elastic/kibana-reporting-services" + ], + "group": "platform", + "visibility": "shared", "description": "Kibana Screenshotting Plugin", "plugin": { "id": "screenshotting", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "screenshotting" @@ -19,4 +23,4 @@ "cloud" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/search_assistant/kibana.jsonc b/x-pack/plugins/search_assistant/kibana.jsonc index 53af40cee6cc6..d84d928b7a8b7 100644 --- a/x-pack/plugins/search_assistant/kibana.jsonc +++ b/x-pack/plugins/search_assistant/kibana.jsonc @@ -2,6 +2,8 @@ "type": "plugin", "id": "@kbn/search-assistant", "owner": "@elastic/search-kibana", + "group": "search", + "visibility": "private", "description": "AI Assistant for Search", "plugin": { "id": "searchAssistant", @@ -15,7 +17,6 @@ "actions", "licensing", "observabilityAIAssistant", - "observabilityAIAssistantApp", "triggersActionsUi", "share" ], diff --git a/x-pack/plugins/search_assistant/public/application.tsx b/x-pack/plugins/search_assistant/public/application.tsx deleted file mode 100644 index 1bbf7063ec373..0000000000000 --- a/x-pack/plugins/search_assistant/public/application.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import ReactDOM from 'react-dom'; -import type { AppMountParameters, CoreStart } from '@kbn/core/public'; -import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; -import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; -import { I18nProvider } from '@kbn/i18n-react'; -import type { SearchAssistantPluginStartDependencies } from './types'; -import { SearchAssistantRouter } from './components/routes/router'; - -export const renderApp = ( - core: CoreStart, - services: SearchAssistantPluginStartDependencies, - appMountParameters: AppMountParameters -) => { - ReactDOM.render( - - - - - - - , - appMountParameters.element - ); - - return () => ReactDOM.unmountComponentAtNode(appMountParameters.element); -}; diff --git a/x-pack/plugins/search_assistant/public/components/app.tsx b/x-pack/plugins/search_assistant/public/components/app.tsx deleted file mode 100644 index 7d9497c0e1457..0000000000000 --- a/x-pack/plugins/search_assistant/public/components/app.tsx +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import React from 'react'; -import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; -export const App: React.FC = () => { - return ( - -
- - ); -}; diff --git a/x-pack/plugins/search_assistant/public/components/nav_control/index.tsx b/x-pack/plugins/search_assistant/public/components/nav_control/index.tsx new file mode 100644 index 0000000000000..a341fdbe81412 --- /dev/null +++ b/x-pack/plugins/search_assistant/public/components/nav_control/index.tsx @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useEffect, useRef, useState } from 'react'; +import { AssistantAvatar, useAbortableAsync } from '@kbn/observability-ai-assistant-plugin/public'; +import { EuiButton, EuiLoadingSpinner, EuiToolTip, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { v4 } from 'uuid'; +import useObservable from 'react-use/lib/useObservable'; +import { i18n } from '@kbn/i18n'; +import { useAIAssistantAppService, ChatFlyout } from '@kbn/ai-assistant'; +import { useKibana } from '@kbn/ai-assistant/src/hooks/use_kibana'; +import { AIAssistantPluginStartDependencies } from '@kbn/ai-assistant/src/types'; +import { EuiErrorBoundary } from '@elastic/eui'; +import type { CoreStart } from '@kbn/core/public'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme'; +import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; + +interface NavControlWithProviderDeps { + coreStart: CoreStart; + pluginsStart: AIAssistantPluginStartDependencies; +} + +export const NavControlWithProvider = ({ coreStart, pluginsStart }: NavControlWithProviderDeps) => { + return ( + + + + + + + + + + + + ); +}; + +export function NavControl() { + const service = useAIAssistantAppService(); + + const { + services: { notifications, observabilityAIAssistant }, + } = useKibana(); + + const [hasBeenOpened, setHasBeenOpened] = useState(false); + + const chatService = useAbortableAsync( + ({ signal }) => { + return hasBeenOpened + ? service.start({ signal }).catch((error) => { + notifications?.toasts.addError(error, { + title: i18n.translate('xpack.searchAssistant.navControl.initFailureErrorTitle', { + defaultMessage: 'Failed to initialize AI Assistant', + }), + }); + + setHasBeenOpened(false); + setIsOpen(false); + + throw error; + }) + : undefined; + }, + [service, hasBeenOpened, notifications?.toasts] + ); + + const [isOpen, setIsOpen] = useState(false); + + const keyRef = useRef(v4()); + + useEffect(() => { + const conversationSubscription = service.conversations.predefinedConversation$.subscribe(() => { + keyRef.current = v4(); + setHasBeenOpened(true); + setIsOpen(true); + }); + + return () => { + conversationSubscription.unsubscribe(); + }; + }, [service.conversations.predefinedConversation$]); + + const { messages, title } = useObservable(service.conversations.predefinedConversation$) ?? { + messages: [], + title: undefined, + }; + + const theme = useEuiTheme().euiTheme; + + const buttonCss = css` + padding: 0px 8px; + + svg path { + fill: ${theme.colors.darkestShade}; + } + `; + + return ( + <> + + { + service.conversations.openNewConversation({ + messages: [], + }); + }} + color="primary" + size="s" + fullWidth={false} + minWidth={0} + > + {chatService.loading ? : } + + + {chatService.value && + Boolean(observabilityAIAssistant?.ObservabilityAIAssistantChatServiceContext) ? ( + + { + setIsOpen(false); + }} + /> + + ) : undefined} + + ); +} + +const buttonLabel = i18n.translate( + 'xpack.searchAssistant.navControl.openTheAIAssistantPopoverLabel', + { defaultMessage: 'Open the AI Assistant' } +); diff --git a/x-pack/plugins/search_assistant/public/components/nav_control/lazy_nav_control.tsx b/x-pack/plugins/search_assistant/public/components/nav_control/lazy_nav_control.tsx new file mode 100644 index 0000000000000..d37eea2fae9f4 --- /dev/null +++ b/x-pack/plugins/search_assistant/public/components/nav_control/lazy_nav_control.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 { dynamic } from '@kbn/shared-ux-utility'; +import React from 'react'; +import { CoreStart } from '@kbn/core-lifecycle-browser'; +import { AIAssistantAppService } from '@kbn/ai-assistant'; +import { AIAssistantPluginStartDependencies } from '@kbn/ai-assistant/src/types'; + +const LazyNavControlWithProvider = dynamic(() => + import('.').then((m) => ({ default: m.NavControlWithProvider })) +); + +interface NavControlInitiatorProps { + appService: AIAssistantAppService; + coreStart: CoreStart; + pluginsStart: AIAssistantPluginStartDependencies; +} + +export const NavControlInitiator = ({ coreStart, pluginsStart }: NavControlInitiatorProps) => { + return ; +}; diff --git a/x-pack/plugins/search_assistant/public/components/routes/conversations/conversation_view_with_props.tsx b/x-pack/plugins/search_assistant/public/components/routes/conversations/conversation_view_with_props.tsx deleted file mode 100644 index 28ed6d00863f3..0000000000000 --- a/x-pack/plugins/search_assistant/public/components/routes/conversations/conversation_view_with_props.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { ConversationView } from '@kbn/ai-assistant'; -import { useParams } from 'react-router-dom'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; - -export function ConversationViewWithProps() { - const { conversationId } = useParams<{ conversationId?: string }>(); - const { - services: { application, http }, - } = useKibana(); - function navigateToConversation(nextConversationId?: string) { - application?.navigateToUrl( - http?.basePath.prepend(`/app/searchAssistant/conversations/${nextConversationId || ''}`) || '' - ); - } - return ( - - http?.basePath.prepend(`/app/searchAssistant/conversations/${id || ''}`) || '' - } - scopes={['search']} - /> - ); -} diff --git a/x-pack/plugins/search_assistant/public/components/routes/router.tsx b/x-pack/plugins/search_assistant/public/components/routes/router.tsx deleted file mode 100644 index 154bc2ab46a3e..0000000000000 --- a/x-pack/plugins/search_assistant/public/components/routes/router.tsx +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { History } from 'history'; -import { Route, Router, Routes } from '@kbn/shared-ux-router'; -import { Redirect } from 'react-router-dom'; -import { SearchAIAssistantPageTemplate } from '../page_template'; -import { ConversationViewWithProps } from './conversations/conversation_view_with_props'; - -export const SearchAssistantRouter: React.FC<{ history: History }> = ({ history }) => { - return ( - - - - - - - - - - - - - - - ); -}; diff --git a/x-pack/plugins/search_assistant/public/plugin.ts b/x-pack/plugins/search_assistant/public/plugin.tsx similarity index 51% rename from x-pack/plugins/search_assistant/public/plugin.ts rename to x-pack/plugins/search_assistant/public/plugin.tsx index 1c09502c154ad..15c1443045cdc 100644 --- a/x-pack/plugins/search_assistant/public/plugin.ts +++ b/x-pack/plugins/search_assistant/public/plugin.tsx @@ -5,20 +5,16 @@ * 2.0. */ -import { - DEFAULT_APP_CATEGORIES, - type CoreSetup, - type Plugin, - CoreStart, - AppMountParameters, - PluginInitializerContext, -} from '@kbn/core/public'; -import { i18n } from '@kbn/i18n'; +import { type CoreSetup, type Plugin, CoreStart, PluginInitializerContext } from '@kbn/core/public'; +import { createAppService } from '@kbn/ai-assistant'; +import ReactDOM from 'react-dom'; +import React from 'react'; import type { SearchAssistantPluginSetup, SearchAssistantPluginStart, SearchAssistantPluginStartDependencies, } from './types'; +import { NavControlInitiator } from './components/nav_control/lazy_nav_control'; export interface PublicConfigType { ui: { @@ -44,36 +40,43 @@ export class SearchAssistantPlugin public setup( core: CoreSetup ): SearchAssistantPluginSetup { + return {}; + } + + public start( + coreStart: CoreStart, + pluginsStart: SearchAssistantPluginStartDependencies + ): SearchAssistantPluginStart { if (!this.config.ui.enabled) { return {}; } + const appService = createAppService({ + pluginsStart, + }); + const isEnabled = appService.isEnabled(); - core.application.register({ - id: 'searchAssistant', - title: i18n.translate('xpack.searchAssistant.appTitle', { - defaultMessage: 'Search Assistant', - }), - euiIconType: 'logoEnterpriseSearch', - appRoute: '/app/searchAssistant', - category: DEFAULT_APP_CATEGORIES.search, - visibleIn: [], - deepLinks: [], - mount: async (appMountParameters: AppMountParameters) => { - // Load application bundle and Get start services - const [{ renderApp }, [coreStart, pluginsStart]] = await Promise.all([ - import('./application'), - core.getStartServices() as Promise< - [CoreStart, SearchAssistantPluginStartDependencies, unknown] - >, - ]); + if (!isEnabled) { + return {}; + } + + coreStart.chrome.navControls.registerRight({ + mount: (element) => { + ReactDOM.render( + , + element, + () => {} + ); - return renderApp(coreStart, pluginsStart, appMountParameters); + return () => {}; }, + // right before the user profile + order: 1001, }); - return {}; - } - public start(): SearchAssistantPluginStart { return {}; } diff --git a/x-pack/plugins/search_assistant/public/types.ts b/x-pack/plugins/search_assistant/public/types.ts index b1a5d6164b1f1..5b70941d2bf0c 100644 --- a/x-pack/plugins/search_assistant/public/types.ts +++ b/x-pack/plugins/search_assistant/public/types.ts @@ -7,6 +7,10 @@ import { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; import { ObservabilityAIAssistantPublicStart } from '@kbn/observability-ai-assistant-plugin/public'; +import { LicensingPluginStart } from '@kbn/licensing-plugin/public'; +import { MlPluginStart } from '@kbn/ml-plugin/public'; +import { SharePluginStart } from '@kbn/share-plugin/public'; +import { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface SearchAssistantPluginSetup {} @@ -15,6 +19,10 @@ export interface SearchAssistantPluginSetup {} export interface SearchAssistantPluginStart {} export interface SearchAssistantPluginStartDependencies { + licensing: LicensingPluginStart; + ml: MlPluginStart; observabilityAIAssistant: ObservabilityAIAssistantPublicStart; + share: SharePluginStart; + triggersActionsUi: TriggersAndActionsUIPublicPluginStart; usageCollection?: UsageCollectionStart; } diff --git a/x-pack/plugins/search_assistant/tsconfig.json b/x-pack/plugins/search_assistant/tsconfig.json index b95020aca1dfc..30002038bbc2d 100644 --- a/x-pack/plugins/search_assistant/tsconfig.json +++ b/x-pack/plugins/search_assistant/tsconfig.json @@ -13,17 +13,22 @@ ], "kbn_references": [ "@kbn/core", - "@kbn/react-kibana-context-render", "@kbn/kibana-react-plugin", - "@kbn/i18n-react", "@kbn/shared-ux-page-kibana-template", "@kbn/usage-collection-plugin", "@kbn/observability-ai-assistant-plugin", "@kbn/config-schema", "@kbn/ai-assistant", "@kbn/i18n", - "@kbn/shared-ux-router", - "@kbn/serverless" + "@kbn/serverless", + "@kbn/react-kibana-context-theme", + "@kbn/shared-ux-link-redirect-app", + "@kbn/shared-ux-utility", + "@kbn/core-lifecycle-browser", + "@kbn/licensing-plugin", + "@kbn/ml-plugin", + "@kbn/share-plugin", + "@kbn/triggers-actions-ui-plugin" ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/search_connectors/common/constants.ts b/x-pack/plugins/search_connectors/common/constants.ts deleted file mode 100644 index ce2ac89a40d33..0000000000000 --- a/x-pack/plugins/search_connectors/common/constants.ts +++ /dev/null @@ -1,182 +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 { ConnectorClientSideDefinition } from './types'; -import { docLinks } from './doc_links'; - -// needs to be a function because, docLinks are only populated with actual -// documentation links in browser after SearchConnectorsPlugin starts -export const getConnectorsDict = (): Record => ({ - azure_blob_storage: { - docsUrl: docLinks.connectorsAzureBlobStorage, - externalAuthDocsUrl: 'https://learn.microsoft.com/azure/storage/common/authorize-data-access', - externalDocsUrl: 'https://learn.microsoft.com/azure/storage/blobs/', - platinumOnly: true, - }, - box: { - docsUrl: docLinks.connectorsBox, - externalAuthDocsUrl: '', - externalDocsUrl: '', - platinumOnly: true, - }, - confluence: { - docsUrl: docLinks.connectorsConfluence, - externalAuthDocsUrl: '', - externalDocsUrl: '', - platinumOnly: true, - }, - custom: { - docsUrl: docLinks.connectors, - externalAuthDocsUrl: '', - externalDocsUrl: '', - }, - dropbox: { - docsUrl: docLinks.connectorsDropbox, - externalAuthDocsUrl: '', - externalDocsUrl: '', - platinumOnly: true, - }, - github: { - docsUrl: docLinks.connectorsGithub, - externalAuthDocsUrl: '', - externalDocsUrl: '', - platinumOnly: true, - }, - gmail: { - docsUrl: docLinks.connectorsGmail, - externalAuthDocsUrl: '', - externalDocsUrl: '', - platinumOnly: true, - }, - google_cloud_storage: { - docsUrl: docLinks.connectorsGoogleCloudStorage, - externalAuthDocsUrl: 'https://cloud.google.com/storage/docs/authentication', - externalDocsUrl: 'https://cloud.google.com/storage/docs', - platinumOnly: true, - }, - google_drive: { - docsUrl: docLinks.connectorsGoogleDrive, - externalAuthDocsUrl: 'https://cloud.google.com/iam/docs/service-account-overview', - externalDocsUrl: 'https://developers.google.com/drive', - platinumOnly: true, - }, - jira: { - docsUrl: docLinks.connectorsJira, - externalAuthDocsUrl: '', - externalDocsUrl: '', - platinumOnly: true, - }, - microsoft_teams: { - docsUrl: docLinks.connectorsTeams, - externalAuthDocsUrl: '', - externalDocsUrl: '', - platinumOnly: true, - }, - mongodb: { - docsUrl: docLinks.connectorsMongoDB, - externalAuthDocsUrl: 'https://www.mongodb.com/docs/atlas/app-services/authentication/', - externalDocsUrl: 'https://www.mongodb.com/docs/', - platinumOnly: true, - }, - mssql: { - docsUrl: docLinks.connectorsMicrosoftSQL, - externalAuthDocsUrl: - 'https://learn.microsoft.com/sql/relational-databases/security/authentication-access/getting-started-with-database-engine-permissions', - externalDocsUrl: 'https://learn.microsoft.com/sql/', - platinumOnly: true, - }, - mysql: { - docsUrl: docLinks.connectorsMySQL, - externalDocsUrl: 'https://dev.mysql.com/doc/', - platinumOnly: true, - }, - network_drive: { - docsUrl: docLinks.connectorsNetworkDrive, - externalAuthDocsUrl: '', - externalDocsUrl: '', - platinumOnly: true, - }, - notion: { - docsUrl: docLinks.connectorsNotion, - externalAuthDocsUrl: '', - externalDocsUrl: '', - platinumOnly: true, - }, - onedrive: { - docsUrl: docLinks.connectorsOneDrive, - externalAuthDocsUrl: '', - externalDocsUrl: '', - platinumOnly: true, - }, - oracle: { - docsUrl: docLinks.connectorsOracle, - externalAuthDocsUrl: - 'https://docs.oracle.com/en/database/oracle/oracle-database/19/dbseg/index.html', - externalDocsUrl: 'https://docs.oracle.com/database/oracle/oracle-database/', - platinumOnly: true, - }, - outlook: { - docsUrl: docLinks.connectorsOutlook, - externalAuthDocsUrl: '', - externalDocsUrl: '', - platinumOnly: true, - }, - postgresql: { - docsUrl: docLinks.connectorsPostgreSQL, - externalAuthDocsUrl: 'https://www.postgresql.org/docs/15/auth-methods.html', - externalDocsUrl: 'https://www.postgresql.org/docs/', - platinumOnly: true, - }, - redis: { - docsUrl: docLinks.connectorsRedis, - externalAuthDocsUrl: '', - externalDocsUrl: '', - platinumOnly: true, - }, - s3: { - docsUrl: docLinks.connectorsS3, - externalAuthDocsUrl: 'https://docs.aws.amazon.com/s3/index.html', - externalDocsUrl: '', - platinumOnly: true, - }, - salesforce: { - docsUrl: docLinks.connectorsSalesforce, - externalAuthDocsUrl: '', - externalDocsUrl: '', - platinumOnly: true, - }, - servicenow: { - docsUrl: docLinks.connectorsServiceNow, - externalAuthDocsUrl: '', - externalDocsUrl: '', - platinumOnly: true, - }, - sharepoint_online: { - docsUrl: docLinks.connectorsSharepointOnline, - externalAuthDocsUrl: '', - externalDocsUrl: '', - platinumOnly: true, - }, - sharepoint_server: { - docsUrl: docLinks.connectorsSharepoint, - externalAuthDocsUrl: '', - externalDocsUrl: '', - platinumOnly: true, - }, - slack: { - docsUrl: docLinks.connectorsSlack, - externalAuthDocsUrl: '', - externalDocsUrl: '', - platinumOnly: true, - }, - zoom: { - docsUrl: docLinks.connectorsZoom, - externalAuthDocsUrl: '', - externalDocsUrl: '', - platinumOnly: true, - }, -}); diff --git a/x-pack/plugins/search_connectors/common/lib/connector_types.ts b/x-pack/plugins/search_connectors/common/lib/connector_types.ts index 387b405b45774..32eff7d676a1d 100644 --- a/x-pack/plugins/search_connectors/common/lib/connector_types.ts +++ b/x-pack/plugins/search_connectors/common/lib/connector_types.ts @@ -6,10 +6,12 @@ */ import type { IStaticAssets } from '@kbn/core-http-browser'; -import { ConnectorServerSideDefinition, CONNECTOR_DEFINITIONS } from '../connectors'; -import { getConnectorsDict } from '../constants'; - -import { ConnectorDefinition } from '../types'; +import { + CONNECTOR_DEFINITIONS, + ConnectorDefinition, + ConnectorServerSideDefinition, + getConnectorsDict, +} from '@kbn/search-connectors'; // used on server and in browser before plugin start when we don't have docLinks yet export function getConnectorTypes(staticAssets: IStaticAssets): ConnectorServerSideDefinition[] { diff --git a/x-pack/plugins/search_connectors/common/types.ts b/x-pack/plugins/search_connectors/common/types.ts deleted file mode 100644 index 9d5049895b963..0000000000000 --- a/x-pack/plugins/search_connectors/common/types.ts +++ /dev/null @@ -1,17 +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 { ConnectorServerSideDefinition } from './connectors'; - -export interface ConnectorClientSideDefinition { - docsUrl?: string; - externalAuthDocsUrl?: string; - externalDocsUrl: string; - platinumOnly?: boolean; -} - -export type ConnectorDefinition = ConnectorClientSideDefinition & ConnectorServerSideDefinition; diff --git a/x-pack/plugins/search_connectors/kibana.jsonc b/x-pack/plugins/search_connectors/kibana.jsonc index e5aac900e89c0..83d052ac232ad 100644 --- a/x-pack/plugins/search_connectors/kibana.jsonc +++ b/x-pack/plugins/search_connectors/kibana.jsonc @@ -2,6 +2,8 @@ "type": "plugin", "id": "@kbn/search-connectors-plugin", "owner": "@elastic/search-kibana", + "group": "platform", + "visibility": "shared", "description": "Plugin hosting shared features for connectors", "plugin": { "id": "searchConnectors", diff --git a/x-pack/plugins/search_connectors/public/index.ts b/x-pack/plugins/search_connectors/public/index.ts index 77afb7b9f82d8..6a59a98b068f4 100644 --- a/x-pack/plugins/search_connectors/public/index.ts +++ b/x-pack/plugins/search_connectors/public/index.ts @@ -13,4 +13,3 @@ export function plugin() { } export type { SearchConnectorsPluginSetup, SearchConnectorsPluginStart } from './types'; -export type { ConnectorDefinition } from '../common/types'; diff --git a/x-pack/plugins/search_connectors/public/plugin.ts b/x-pack/plugins/search_connectors/public/plugin.ts index 830d9d3e94c1e..cc86709121ab0 100644 --- a/x-pack/plugins/search_connectors/public/plugin.ts +++ b/x-pack/plugins/search_connectors/public/plugin.ts @@ -6,7 +6,7 @@ */ import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; -import { docLinks } from '../common/doc_links'; +import { docLinks } from '@kbn/search-connectors'; import { getConnectorFullTypes, getConnectorTypes } from '../common/lib/connector_types'; import { SearchConnectorsPluginSetup, diff --git a/x-pack/plugins/search_connectors/public/types.ts b/x-pack/plugins/search_connectors/public/types.ts index a86ca30170fce..ec77fb403af78 100644 --- a/x-pack/plugins/search_connectors/public/types.ts +++ b/x-pack/plugins/search_connectors/public/types.ts @@ -5,8 +5,7 @@ * 2.0. */ -import { ConnectorServerSideDefinition } from '../common/connectors'; -import { ConnectorDefinition } from '../common/types'; +import { ConnectorDefinition, ConnectorServerSideDefinition } from '@kbn/search-connectors'; /* eslint-disable @typescript-eslint/no-empty-interface */ diff --git a/x-pack/plugins/search_connectors/server/index.ts b/x-pack/plugins/search_connectors/server/index.ts index 304eb460ca7d6..9a52237740d16 100644 --- a/x-pack/plugins/search_connectors/server/index.ts +++ b/x-pack/plugins/search_connectors/server/index.ts @@ -18,4 +18,3 @@ export function plugin(initializerContext: PluginInitializerContext) { } export type { SearchConnectorsPluginSetup, SearchConnectorsPluginStart } from './types'; -export type { CONNECTOR_DEFINITIONS, ConnectorServerSideDefinition } from '../common/connectors'; diff --git a/x-pack/plugins/search_connectors/server/plugin.ts b/x-pack/plugins/search_connectors/server/plugin.ts index 56f78638da2d7..fe73afae20b9a 100644 --- a/x-pack/plugins/search_connectors/server/plugin.ts +++ b/x-pack/plugins/search_connectors/server/plugin.ts @@ -6,7 +6,7 @@ */ import type { PluginInitializerContext, Plugin, CoreSetup } from '@kbn/core/server'; -import { ConnectorServerSideDefinition } from '../common/connectors'; +import { ConnectorServerSideDefinition } from '@kbn/search-connectors'; import { getConnectorTypes } from '../common/lib/connector_types'; import type { SearchConnectorsPluginSetup as SearchConnectorsPluginSetup, diff --git a/x-pack/plugins/search_connectors/server/types.ts b/x-pack/plugins/search_connectors/server/types.ts index c08b562ec8a5f..36b5aa877fd1e 100644 --- a/x-pack/plugins/search_connectors/server/types.ts +++ b/x-pack/plugins/search_connectors/server/types.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ConnectorServerSideDefinition } from '../common/connectors'; +import { ConnectorServerSideDefinition } from '@kbn/search-connectors'; /* eslint-disable @typescript-eslint/no-empty-interface */ diff --git a/x-pack/plugins/search_connectors/tsconfig.json b/x-pack/plugins/search_connectors/tsconfig.json index 9da933a758aec..040c873fe0353 100644 --- a/x-pack/plugins/search_connectors/tsconfig.json +++ b/x-pack/plugins/search_connectors/tsconfig.json @@ -17,8 +17,7 @@ "kbn_references": [ "@kbn/core", "@kbn/config-schema", - "@kbn/doc-links", "@kbn/core-http-browser", - "@kbn/i18n", + "@kbn/search-connectors", ] } diff --git a/x-pack/plugins/search_homepage/kibana.jsonc b/x-pack/plugins/search_homepage/kibana.jsonc index 0e345ab0d330a..7120432e1ef7f 100644 --- a/x-pack/plugins/search_homepage/kibana.jsonc +++ b/x-pack/plugins/search_homepage/kibana.jsonc @@ -2,6 +2,8 @@ "type": "plugin", "id": "@kbn/search-homepage", "owner": "@elastic/search-kibana", + "group": "search", + "visibility": "private", "plugin": { "id": "searchHomepage", "server": true, diff --git a/x-pack/plugins/search_indices/kibana.jsonc b/x-pack/plugins/search_indices/kibana.jsonc index dee69b2b4e109..50778c3fbb4e4 100644 --- a/x-pack/plugins/search_indices/kibana.jsonc +++ b/x-pack/plugins/search_indices/kibana.jsonc @@ -2,6 +2,8 @@ "type": "plugin", "id": "@kbn/search-indices", "owner": "@elastic/search-kibana", + "group": "search", + "visibility": "private", "plugin": { "id": "searchIndices", "server": true, @@ -25,4 +27,4 @@ "esUiShared" ] } -} \ No newline at end of file +} diff --git a/x-pack/plugins/search_indices/public/code_examples/sense.ts b/x-pack/plugins/search_indices/public/code_examples/sense.ts index 34e4e81802762..f54071003df64 100644 --- a/x-pack/plugins/search_indices/public/code_examples/sense.ts +++ b/x-pack/plugins/search_indices/public/code_examples/sense.ts @@ -36,7 +36,7 @@ export const ConsoleVectorsIngestDataExample: IngestDataCodeDefinition = { let result = 'POST /_bulk?pretty\n'; sampleDocuments.forEach((document) => { result += `{ "index": { "_index": "${indexName}" } } -${JSON.stringify(document)}`; +${JSON.stringify(document)}\n`; }); result += '\n'; return result; diff --git a/x-pack/plugins/search_indices/public/components/quick_stats/quick_stat.tsx b/x-pack/plugins/search_indices/public/components/quick_stats/quick_stat.tsx index 006906f8dd295..cdd5f12408480 100644 --- a/x-pack/plugins/search_indices/public/components/quick_stats/quick_stat.tsx +++ b/x-pack/plugins/search_indices/public/components/quick_stats/quick_stat.tsx @@ -70,7 +70,7 @@ export const QuickStat: React.FC = ({ marginRight: euiTheme.size.s, }, '.euiAccordion__triggerWrapper': { - background: euiTheme.colors.ghost, + background: euiTheme.colors.emptyShade, }, '.euiAccordion__children': { borderTop: euiTheme.border.thin, diff --git a/x-pack/plugins/search_indices/public/components/quick_stats/quick_stats.tsx b/x-pack/plugins/search_indices/public/components/quick_stats/quick_stats.tsx index 32590cf3efa47..e051fee17d2e2 100644 --- a/x-pack/plugins/search_indices/public/components/quick_stats/quick_stats.tsx +++ b/x-pack/plugins/search_indices/public/components/quick_stats/quick_stats.tsx @@ -87,7 +87,7 @@ export const QuickStats: React.FC = ({ index, mappings, indexDo open={open} setOpen={setOpen} icon="documents" - iconColor="black" + iconColor={euiTheme.colors.fullShade} title={i18n.translate('xpack.searchIndices.quickStats.document_count_heading', { defaultMessage: 'Document count', })} @@ -115,7 +115,7 @@ export const QuickStats: React.FC = ({ index, mappings, indexDo open={open} setOpen={setOpen} icon="sparkles" - iconColor="black" + iconColor={euiTheme.colors.fullShade} title={i18n.translate('xpack.searchIndices.quickStats.ai_search_heading', { defaultMessage: 'AI Search', })} diff --git a/x-pack/plugins/search_inference_endpoints/kibana.jsonc b/x-pack/plugins/search_inference_endpoints/kibana.jsonc index e7ba67795f7bf..7531316cd12e6 100644 --- a/x-pack/plugins/search_inference_endpoints/kibana.jsonc +++ b/x-pack/plugins/search_inference_endpoints/kibana.jsonc @@ -2,6 +2,8 @@ "type": "plugin", "id": "@kbn/search-inference-endpoints", "owner": "@elastic/search-kibana", + "group": "platform", + "visibility": "shared", "plugin": { "id": "searchInferenceEndpoints", "server": true, @@ -13,12 +15,12 @@ "requiredPlugins": [ "actions", "features", + "ml", "share", ], "optionalPlugins": [ "cloud", "console", - "ml" ], "requiredBundles": [ "kibanaReact" diff --git a/x-pack/plugins/search_notebooks/kibana.jsonc b/x-pack/plugins/search_notebooks/kibana.jsonc index e9432a5559ce9..2944f22f83382 100644 --- a/x-pack/plugins/search_notebooks/kibana.jsonc +++ b/x-pack/plugins/search_notebooks/kibana.jsonc @@ -2,6 +2,8 @@ "type": "plugin", "id": "@kbn/search-notebooks", "owner": "@elastic/search-kibana", + "group": "search", + "visibility": "private", "description": "Plugin to provide access to and rendering of python notebooks for use in the persistent developer console.", "plugin": { "id": "searchNotebooks", diff --git a/x-pack/plugins/search_playground/common/models.ts b/x-pack/plugins/search_playground/common/models.ts index ca27c29e20533..85bf5ddfb0970 100644 --- a/x-pack/plugins/search_playground/common/models.ts +++ b/x-pack/plugins/search_playground/common/models.ts @@ -8,12 +8,6 @@ import { ModelProvider, LLMs } from './types'; export const MODELS: ModelProvider[] = [ - { - name: 'OpenAI GPT-3.5 Turbo', - model: 'gpt-3.5-turbo', - promptTokenLimit: 16385, - provider: LLMs.openai, - }, { name: 'OpenAI GPT-4o', model: 'gpt-4o', @@ -26,6 +20,12 @@ export const MODELS: ModelProvider[] = [ promptTokenLimit: 128000, provider: LLMs.openai, }, + { + name: 'OpenAI GPT-3.5 Turbo', + model: 'gpt-3.5-turbo', + promptTokenLimit: 16385, + provider: LLMs.openai, + }, { name: 'Anthropic Claude 3 Haiku', model: 'anthropic.claude-3-haiku-20240307-v1:0', @@ -40,13 +40,13 @@ export const MODELS: ModelProvider[] = [ }, { name: 'Google Gemini 1.5 Pro', - model: 'gemini-1.5-pro-001', + model: 'gemini-1.5-pro-002', promptTokenLimit: 2097152, provider: LLMs.gemini, }, { name: 'Google Gemini 1.5 Flash', - model: 'gemini-1.5-flash-001', + model: 'gemini-1.5-flash-002', promptTokenLimit: 2097152, provider: LLMs.gemini, }, diff --git a/x-pack/plugins/search_playground/kibana.jsonc b/x-pack/plugins/search_playground/kibana.jsonc index e9dedf0fe716f..8b99add8587fa 100644 --- a/x-pack/plugins/search_playground/kibana.jsonc +++ b/x-pack/plugins/search_playground/kibana.jsonc @@ -2,6 +2,9 @@ "type": "plugin", "id": "@kbn/search-playground", "owner": "@elastic/search-kibana", + // @kbn/enterprise-search-plugin (platform) and @kbn/serverless-search (search) depend on it + "group": "platform", + "visibility": "shared", "plugin": { "id": "searchPlayground", "server": true, diff --git a/x-pack/plugins/search_playground/public/components/app.tsx b/x-pack/plugins/search_playground/public/components/app.tsx index 4f371ea5d15bb..914a429845e90 100644 --- a/x-pack/plugins/search_playground/public/components/app.tsx +++ b/x-pack/plugins/search_playground/public/components/app.tsx @@ -18,10 +18,11 @@ import { Chat } from './chat'; import { SearchMode } from './search_mode/search_mode'; import { SearchPlaygroundSetupPage } from './setup_page/search_playground_setup_page'; import { usePageMode } from '../hooks/use_page_mode'; +import { useKibana } from '../hooks/use_kibana'; export interface AppProps { showDocs?: boolean; - pageMode?: PlaygroundPageMode; + pageMode?: 'chat' | 'search'; } export enum ViewMode { @@ -33,6 +34,7 @@ export const App: React.FC = ({ showDocs = false, pageMode = PlaygroundPageMode.chat, }) => { + const { services } = useKibana(); const [selectedMode, setSelectedMode] = useState(ViewMode.chat); const { data: connectors } = useLoadConnectors(); const hasSelectedIndices = Boolean( @@ -41,7 +43,10 @@ export const App: React.FC = ({ }).length ); const handleModeChange = (id: ViewMode) => setSelectedMode(id); - const handlePageModeChange = (mode: PlaygroundPageMode) => setSelectedPageMode(mode); + const handlePageModeChange = (mode: PlaygroundPageMode) => { + services.application?.navigateToUrl(`./${mode}`); + setSelectedPageMode(mode); + }; const { showSetupPage, pageMode: selectedPageMode, @@ -49,7 +54,7 @@ export const App: React.FC = ({ } = usePageMode({ hasSelectedIndices, hasConnectors: Boolean(connectors?.length), - initialPageMode: pageMode, + initialPageMode: pageMode === 'chat' ? PlaygroundPageMode.chat : PlaygroundPageMode.search, }); const restrictedWidth = selectedPageMode === PlaygroundPageMode.search && selectedMode === 'chat'; diff --git a/x-pack/plugins/search_playground/public/components/search_mode/search_mode.tsx b/x-pack/plugins/search_playground/public/components/search_mode/search_mode.tsx index 94e6337f1a03c..8a27f3ac3d83e 100644 --- a/x-pack/plugins/search_playground/public/components/search_mode/search_mode.tsx +++ b/x-pack/plugins/search_playground/public/components/search_mode/search_mode.tsx @@ -69,6 +69,7 @@ export const SearchMode: React.FC = () => { name={ChatFormFields.searchQuery} render={({ field }) => ( = ({ useEffect(() => { usageTracker?.click( - `${AnalyticsEvents.modelSelected}_${selectedModel!.value || selectedModel!.connectorType}` + `${AnalyticsEvents.modelSelected}_${ + selectedModel?.value || selectedModel?.connectorType || 'unknown' + }` ); }, [usageTracker, selectedModel]); diff --git a/x-pack/plugins/search_playground/public/hooks/use_llms_models.test.ts b/x-pack/plugins/search_playground/public/hooks/use_llms_models.test.ts index ebce3883a471b..c529a9d4b9aa6 100644 --- a/x-pack/plugins/search_playground/public/hooks/use_llms_models.test.ts +++ b/x-pack/plugins/search_playground/public/hooks/use_llms_models.test.ts @@ -41,11 +41,11 @@ describe('useLLMsModels Hook', () => { connectorType: LLMs.openai, disabled: false, icon: expect.any(Function), - id: 'connectorId1OpenAI GPT-3.5 Turbo ', - name: 'OpenAI GPT-3.5 Turbo ', + id: 'connectorId1OpenAI GPT-4o ', + name: 'OpenAI GPT-4o ', showConnectorName: false, - value: 'gpt-3.5-turbo', - promptTokenLimit: 16385, + value: 'gpt-4o', + promptTokenLimit: 128000, }, { connectorId: 'connectorId1', @@ -53,10 +53,10 @@ describe('useLLMsModels Hook', () => { connectorType: LLMs.openai, disabled: false, icon: expect.any(Function), - id: 'connectorId1OpenAI GPT-4o ', - name: 'OpenAI GPT-4o ', + id: 'connectorId1OpenAI GPT-4 Turbo ', + name: 'OpenAI GPT-4 Turbo ', showConnectorName: false, - value: 'gpt-4o', + value: 'gpt-4-turbo', promptTokenLimit: 128000, }, { @@ -65,11 +65,11 @@ describe('useLLMsModels Hook', () => { connectorType: LLMs.openai, disabled: false, icon: expect.any(Function), - id: 'connectorId1OpenAI GPT-4 Turbo ', - name: 'OpenAI GPT-4 Turbo ', + id: 'connectorId1OpenAI GPT-3.5 Turbo ', + name: 'OpenAI GPT-3.5 Turbo ', showConnectorName: false, - value: 'gpt-4-turbo', - promptTokenLimit: 128000, + value: 'gpt-3.5-turbo', + promptTokenLimit: 16385, }, { connectorId: 'connectorId2', diff --git a/x-pack/plugins/search_playground/public/hooks/use_source_indices_fields.test.tsx b/x-pack/plugins/search_playground/public/hooks/use_source_indices_fields.test.tsx index b31512177d3cb..4adf3d18ea92b 100644 --- a/x-pack/plugins/search_playground/public/hooks/use_source_indices_fields.test.tsx +++ b/x-pack/plugins/search_playground/public/hooks/use_source_indices_fields.test.tsx @@ -6,144 +6,82 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; -import { useKibana } from './use_kibana'; -import { PlaygroundProvider } from '../providers/playground_provider'; -import React from 'react'; -import * as ReactHookForm from 'react-hook-form'; - -jest.mock('./use_kibana', () => ({ - useKibana: jest.fn(), -})); -jest.mock('react-router-dom-v5-compat', () => ({ - useSearchParams: jest.fn(() => [{ get: jest.fn() }]), -})); - -let formHookSpy: jest.SpyInstance; - +import { useUsageTracker } from './use_usage_tracker'; +import { useController } from 'react-hook-form'; +import { useIndicesFields } from './use_indices_fields'; +import { AnalyticsEvents } from '../analytics/constants'; import { useSourceIndicesFields } from './use_source_indices_field'; -import { IndicesQuerySourceFields } from '../types'; -// Failing: See https://github.com/elastic/kibana/issues/188840 -describe.skip('useSourceIndicesFields Hook', () => { - let postMock: jest.Mock; - - beforeEach(() => { - // Playground Provider has the formProvider which - // persists the form state into local storage - // We need to clear the local storage before each test - localStorage.clear(); - }); +jest.mock('./use_usage_tracker'); +jest.mock('react-hook-form'); +jest.mock('./use_indices_fields'); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); +describe('useSourceIndicesFields', () => { + const mockUsageTracker = { + count: jest.fn(), + }; + const mockOnChange = jest.fn(); + const mockFields = ['field1', 'field2']; + const mockSelectedIndices = ['index1', 'index2']; beforeEach(() => { - formHookSpy = jest.spyOn(ReactHookForm, 'useForm'); - const querySourceFields: IndicesQuerySourceFields = { - newIndex: { - elser_query_fields: [ - { - field: 'field1', - model_id: 'model1', - indices: ['newIndex'], - sparse_vector: true, - }, - ], - dense_vector_query_fields: [], - bm25_query_fields: [], - source_fields: ['field1'], - skipped_fields: 0, - semantic_fields: [], - }, - }; - - postMock = jest.fn().mockResolvedValue(querySourceFields); - (useKibana as jest.Mock).mockImplementation(() => ({ - services: { - http: { - post: postMock, - get: jest.fn(() => { - return []; - }), - }, - }, - })); - }); - - afterEach(() => { jest.clearAllMocks(); + (useUsageTracker as jest.Mock).mockReturnValue(mockUsageTracker); + (useController as jest.Mock).mockReturnValue({ + field: { value: mockSelectedIndices, onChange: mockOnChange }, + }); + (useIndicesFields as jest.Mock).mockReturnValue({ + fields: mockFields, + isLoading: false, + }); }); - it('should handle addIndex correctly changing indices', async () => { - const { result, waitForNextUpdate } = renderHook(() => useSourceIndicesFields(), { wrapper }); - const { getValues } = formHookSpy.mock.results[0].value; + it('should initialize correctly', () => { + const { result } = renderHook(() => useSourceIndicesFields()); + + expect(result.current.indices).toEqual(mockSelectedIndices); + expect(result.current.fields).toEqual(mockFields); + expect(result.current.isFieldsLoading).toBe(false); + }); + it('should add an index', () => { + const { result } = renderHook(() => useSourceIndicesFields()); act(() => { - expect(result.current.indices).toEqual([]); - expect(getValues()).toMatchInlineSnapshot(` - Object { - "doc_size": 3, - "elasticsearch_query": Object { - "retriever": Object { - "standard": Object { - "query": Object { - "match_all": Object {}, - }, - }, - }, - }, - "indices": Array [], - "prompt": "You are an assistant for question-answering tasks.", - "query_fields": Object {}, - "source_fields": Object {}, - "summarization_model": undefined, - } - `); result.current.addIndex('newIndex'); }); - await act(async () => { - await waitForNextUpdate(); - expect(result.current.indices).toEqual(['newIndex']); + expect(mockOnChange).toHaveBeenCalledWith([...mockSelectedIndices, 'newIndex']); + expect(mockUsageTracker.count).toHaveBeenCalledWith( + AnalyticsEvents.sourceIndexUpdated, + mockSelectedIndices.length + 1 + ); + }); + + it('should remove an index', () => { + const { result } = renderHook(() => useSourceIndicesFields()); + act(() => { + result.current.removeIndex('index1'); }); - expect(postMock).toHaveBeenCalled(); + expect(mockOnChange).toHaveBeenCalledWith(['index2']); + expect(mockUsageTracker.count).toHaveBeenCalledWith( + AnalyticsEvents.sourceIndexUpdated, + mockSelectedIndices.length - 1 + ); + }); + + it('should set indices', () => { + const { result } = renderHook(() => useSourceIndicesFields()); + const newIndices = ['index3', 'index4']; - await act(async () => { - expect(getValues()).toMatchInlineSnapshot(` - Object { - "doc_size": 3, - "elasticsearch_query": Object { - "retriever": Object { - "standard": Object { - "query": Object { - "sparse_vector": Object { - "field": "field1", - "inference_id": "model1", - "query": "{query}", - }, - }, - }, - }, - }, - "indices": Array [ - "newIndex", - ], - "prompt": "You are an assistant for question-answering tasks.", - "query_fields": Object { - "newIndex": Array [ - "field1", - ], - }, - "source_fields": Object { - "newIndex": Array [ - "field1", - ], - }, - "summarization_model": undefined, - } - `); + act(() => { + result.current.setIndices(newIndices); }); + + expect(mockOnChange).toHaveBeenCalledWith(newIndices); + expect(mockUsageTracker.count).toHaveBeenCalledWith( + AnalyticsEvents.sourceIndexUpdated, + newIndices.length + ); }); }); diff --git a/x-pack/plugins/search_playground/public/index.ts b/x-pack/plugins/search_playground/public/index.ts index c6d31b75b07fe..6a0bdff7784b7 100644 --- a/x-pack/plugins/search_playground/public/index.ts +++ b/x-pack/plugins/search_playground/public/index.ts @@ -13,4 +13,8 @@ export function plugin(initializerContext: PluginInitializerContext) { return new SearchPlaygroundPlugin(initializerContext); } -export type { SearchPlaygroundPluginSetup, SearchPlaygroundPluginStart } from './types'; +export type { + SearchPlaygroundPluginSetup, + SearchPlaygroundPluginStart, + PlaygroundPageMode, +} from './types'; diff --git a/x-pack/plugins/search_playground/server/lib/conversational_chain.test.ts b/x-pack/plugins/search_playground/server/lib/conversational_chain.test.ts index 13959e4455c29..5a56598e7387b 100644 --- a/x-pack/plugins/search_playground/server/lib/conversational_chain.test.ts +++ b/x-pack/plugins/search_playground/server/lib/conversational_chain.test.ts @@ -9,9 +9,8 @@ import type { Client } from '@elastic/elasticsearch'; import { BaseChatModel } from '@langchain/core/language_models/chat_models'; import { ChatPromptTemplate } from '@langchain/core/prompts'; import { FakeListChatModel, FakeStreamingLLM } from '@langchain/core/utils/testing'; -import { experimental_StreamData } from 'ai'; import { createAssist as Assist } from '../utils/assist'; -import { ConversationalChain, clipContext } from './conversational_chain'; +import { ConversationalChain, contextLimitCheck } from './conversational_chain'; import { ChatMessage, MessageRole } from '../types'; describe('conversational chain', () => { @@ -30,16 +29,20 @@ describe('conversational chain', () => { }: { responses: string[]; chat: ChatMessage[]; - expectedFinalAnswer: string; - expectedDocs: any; - expectedTokens: any; - expectedSearchRequest: any; + expectedFinalAnswer?: string; + expectedDocs?: any; + expectedTokens?: any; + expectedSearchRequest?: any; contentField?: Record; isChatModel?: boolean; docs?: any; expectedHasClipped?: boolean; modelLimit?: number; }) => { + if (expectedHasClipped) { + expect.assertions(1); + } + const searchMock = jest.fn().mockImplementation(() => { return { hits: { @@ -101,44 +104,52 @@ describe('conversational chain', () => { questionRewritePrompt: 'rewrite question {question} using {context}"', }); - const stream = await conversationalChain.stream(aiClient, chat); + try { + const stream = await conversationalChain.stream(aiClient, chat); - const streamToValue: string[] = await new Promise((resolve, reject) => { - const reader = stream.getReader(); - const textDecoder = new TextDecoder(); - const chunks: string[] = []; + const streamToValue: string[] = await new Promise((resolve, reject) => { + const reader = stream.getReader(); + const textDecoder = new TextDecoder(); + const chunks: string[] = []; - const read = () => { - reader.read().then(({ done, value }) => { - if (done) { - resolve(chunks); - } else { - chunks.push(textDecoder.decode(value)); - read(); - } - }, reject); - }; - read(); - }); + const read = () => { + reader.read().then(({ done, value }) => { + if (done) { + resolve(chunks); + } else { + chunks.push(textDecoder.decode(value)); + read(); + } + }, reject); + }; + read(); + }); - const textValue = streamToValue - .filter((v) => v[0] === '0') - .reduce((acc, v) => acc + v.replace(/0:"(.*)"\n/, '$1'), ''); - expect(textValue).toEqual(expectedFinalAnswer); + const textValue = streamToValue + .filter((v) => v[0] === '0') + .reduce((acc, v) => acc + v.replace(/0:"(.*)"\n/, '$1'), ''); + expect(textValue).toEqual(expectedFinalAnswer); - const annotations = streamToValue - .filter((v) => v[0] === '8') - .map((entry) => entry.replace(/8:(.*)\n/, '$1'), '') - .map((entry) => JSON.parse(entry)) - .reduce((acc, v) => acc.concat(v), []); + const annotations = streamToValue + .filter((v) => v[0] === '8') + .map((entry) => entry.replace(/8:(.*)\n/, '$1'), '') + .map((entry) => JSON.parse(entry)) + .reduce((acc, v) => acc.concat(v), []); - const docValues = annotations.filter((v: { type: string }) => v.type === 'retrieved_docs'); - const tokens = annotations.filter((v: { type: string }) => v.type.endsWith('_token_count')); - const hasClipped = !!annotations.some((v: { type: string }) => v.type === 'context_clipped'); - expect(docValues).toEqual(expectedDocs); - expect(tokens).toEqual(expectedTokens); - expect(hasClipped).toEqual(expectedHasClipped); - expect(searchMock.mock.calls[0]).toEqual(expectedSearchRequest); + const docValues = annotations.filter((v: { type: string }) => v.type === 'retrieved_docs'); + const tokens = annotations.filter((v: { type: string }) => v.type.endsWith('_token_count')); + const hasClipped = !!annotations.some((v: { type: string }) => v.type === 'context_clipped'); + expect(docValues).toEqual(expectedDocs); + expect(tokens).toEqual(expectedTokens); + expect(hasClipped).toEqual(expectedHasClipped); + expect(searchMock.mock.calls[0]).toEqual(expectedSearchRequest); + } catch (error) { + if (expectedHasClipped) { + expect(error).toMatchInlineSnapshot(`[ContextLimitError: Context exceeds the model limit]`); + } else { + throw error; + } + } }; it('should be able to create a conversational chain', async () => { @@ -470,102 +481,56 @@ describe('conversational chain', () => { }, ], modelLimit: 100, - expectedFinalAnswer: 'the final answer', - expectedDocs: [ - { - documents: [ - { - metadata: { _id: '1', _index: 'index' }, - pageContent: expect.any(String), - }, - { - metadata: { _id: '1', _index: 'website' }, - pageContent: expect.any(String), - }, - ], - type: 'retrieved_docs', - }, - ], - // Even with body_content of 1000, the token count should be below or equal to model limit of 100 - expectedTokens: [ - { type: 'context_token_count', count: 63 }, - { type: 'prompt_token_count', count: 97 }, - ], expectedHasClipped: true, - expectedSearchRequest: [ - { - method: 'POST', - path: '/index,website/_search', - body: { query: { match: { field: 'rewrite "the" question' } }, size: 3 }, - }, - ], isChatModel: false, }); }, 10000); - describe('clipContext', () => { + describe('contextLimitCheck', () => { const prompt = ChatPromptTemplate.fromTemplate( 'you are a QA bot {question} {chat_history} {context}' ); + afterEach(() => { + jest.clearAllMocks(); + }); + it('should return the input as is if modelLimit is undefined', async () => { const input = { context: 'This is a test context.', question: 'This is a test question.', chat_history: 'This is a test chat history.', }; + jest.spyOn(prompt, 'format'); + const result = await contextLimitCheck(undefined, prompt)(input); - const data = new experimental_StreamData(); - const appendMessageAnnotationSpy = jest.spyOn(data, 'appendMessageAnnotation'); - - const result = await clipContext(undefined, prompt, data)(input); - expect(result).toEqual(input); - expect(appendMessageAnnotationSpy).not.toHaveBeenCalled(); + expect(result).toBe(input); + expect(prompt.format).not.toHaveBeenCalled(); }); - it('should not clip context if within modelLimit', async () => { + it('should return the input if within modelLimit', async () => { const input = { context: 'This is a test context.', question: 'This is a test question.', chat_history: 'This is a test chat history.', }; - const data = new experimental_StreamData(); - const appendMessageAnnotationSpy = jest.spyOn(data, 'appendMessageAnnotation'); - const result = await clipContext(10000, prompt, data)(input); + jest.spyOn(prompt, 'format'); + const result = await contextLimitCheck(10000, prompt)(input); expect(result).toEqual(input); - expect(appendMessageAnnotationSpy).not.toHaveBeenCalled(); + expect(prompt.format).toHaveBeenCalledWith(input); }); it('should clip context if exceeds modelLimit', async () => { + expect.assertions(1); const input = { context: 'This is a test context.\nThis is another line.\nAnd another one.', question: 'This is a test question.', chat_history: 'This is a test chat history.', }; - const data = new experimental_StreamData(); - const appendMessageAnnotationSpy = jest.spyOn(data, 'appendMessageAnnotation'); - const result = await clipContext(33, prompt, data)(input); - expect(result.context).toBe('This is a test context.\nThis is another line.'); - expect(appendMessageAnnotationSpy).toHaveBeenCalledWith({ - type: 'context_clipped', - count: 4, - }); - }); - it('exit when context becomes empty', async () => { - const input = { - context: 'This is a test context.\nThis is another line.\nAnd another one.', - question: 'This is a test question.', - chat_history: 'This is a test chat history.', - }; - const data = new experimental_StreamData(); - const appendMessageAnnotationSpy = jest.spyOn(data, 'appendMessageAnnotation'); - const result = await clipContext(1, prompt, data)(input); - expect(result.context).toBe(''); - expect(appendMessageAnnotationSpy).toHaveBeenCalledWith({ - type: 'context_clipped', - count: 15, - }); + await expect(contextLimitCheck(33, prompt)(input)).rejects.toMatchInlineSnapshot( + `[ContextLimitError: Context exceeds the model limit]` + ); }); }); }); diff --git a/x-pack/plugins/search_playground/server/lib/conversational_chain.ts b/x-pack/plugins/search_playground/server/lib/conversational_chain.ts index 922f672bda5c6..dcd1f4189bc75 100644 --- a/x-pack/plugins/search_playground/server/lib/conversational_chain.ts +++ b/x-pack/plugins/search_playground/server/lib/conversational_chain.ts @@ -25,6 +25,7 @@ import { renderTemplate } from '../utils/render_template'; import { AssistClient } from '../utils/assist'; import { getCitations } from '../utils/get_citations'; import { getTokenEstimate, getTokenEstimateFromMessages } from './token_tracking'; +import { ContextLimitError } from './errors'; interface RAGOptions { index: string; @@ -88,37 +89,26 @@ position: ${i + 1} return serializedDocs.join('\n'); }; -export function clipContext( +export function contextLimitCheck( modelLimit: number | undefined, - prompt: ChatPromptTemplate, - data: experimental_StreamData + prompt: ChatPromptTemplate ): (input: ContextInputs) => Promise { return async (input) => { if (!modelLimit) return input; - let context = input.context; - const clippedContext = []; - while ( - getTokenEstimate(await prompt.format({ ...input, context })) > modelLimit && - context.length > 0 - ) { - // remove the last paragraph - const lines = context.split('\n'); - clippedContext.push(lines.pop()); - context = lines.join('\n'); - } + const stringPrompt = await prompt.format(input); + const approxPromptTokens = getTokenEstimate(stringPrompt); + const aboveContextLimit = approxPromptTokens > modelLimit; - if (clippedContext.length > 0) { - data.appendMessageAnnotation({ - type: 'context_clipped', - count: getTokenEstimate(clippedContext.join('\n')), - }); + if (aboveContextLimit) { + throw new ContextLimitError( + 'Context exceeds the model limit', + modelLimit, + approxPromptTokens + ); } - return { - ...input, - context, - }; + return input; }; } @@ -205,7 +195,7 @@ class ConversationalChainFn { }); return inputs; }), - RunnableLambda.from(clipContext(this.options?.rag?.inputTokensLimit, prompt, data)), + RunnableLambda.from(contextLimitCheck(this.options?.rag?.inputTokensLimit, prompt)), RunnableLambda.from(registerContextTokenCounts(data)), prompt, this.options.model.withConfig({ metadata: { type: 'question_answer_qa' } }), diff --git a/x-pack/plugins/search_playground/server/lib/errors.ts b/x-pack/plugins/search_playground/server/lib/errors.ts new file mode 100644 index 0000000000000..38441b607a64a --- /dev/null +++ b/x-pack/plugins/search_playground/server/lib/errors.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export class ContextLimitError extends Error { + public modelLimit: number; + public currentTokens: number; + + constructor(message: string, modelLimit: number, currentTokens: number) { + super(message); + this.name = 'ContextLimitError'; + this.modelLimit = modelLimit; + this.currentTokens = currentTokens; + } +} diff --git a/x-pack/plugins/search_playground/server/routes.test.ts b/x-pack/plugins/search_playground/server/routes.test.ts index 018b1420a46cf..fca1adab5862b 100644 --- a/x-pack/plugins/search_playground/server/routes.test.ts +++ b/x-pack/plugins/search_playground/server/routes.test.ts @@ -12,6 +12,7 @@ import { MockRouter } from '../__mocks__/router.mock'; import { ConversationalChain } from './lib/conversational_chain'; import { getChatParams } from './lib/get_chat_params'; import { createRetriever, defineRoutes } from './routes'; +import { ContextLimitError } from './lib/errors'; jest.mock('./lib/get_chat_params', () => ({ getChatParams: jest.fn(), @@ -100,5 +101,29 @@ describe('Search Playground routes', () => { }, }); }); + + it('responds with context error message if there is ContextLimitError', async () => { + (getChatParams as jest.Mock).mockResolvedValue({ model: 'open-ai' }); + (ConversationalChain as jest.Mock).mockImplementation(() => { + return { + stream: jest + .fn() + .mockRejectedValue( + new ContextLimitError('Context exceeds the model limit', 16385, 24000) + ), + }; + }); + + await mockRouter.callRoute({ + body: mockRequestBody, + }); + + expect(mockRouter.response.badRequest).toHaveBeenCalledWith({ + body: { + message: + 'Your request uses 24000 input tokens. This exceeds the model token limit of 16385 tokens. Please try using a different model thats capable of accepting larger prompts or reducing the prompt by decreasing the size of the context documents. If you are unsure, please see our documentation.', + }, + }); + }); }); }); diff --git a/x-pack/plugins/search_playground/server/routes.ts b/x-pack/plugins/search_playground/server/routes.ts index c26a342aace49..3cdebe11c02c2 100644 --- a/x-pack/plugins/search_playground/server/routes.ts +++ b/x-pack/plugins/search_playground/server/routes.ts @@ -8,6 +8,7 @@ import { schema } from '@kbn/config-schema'; import type { Logger } from '@kbn/logging'; import { IRouter, StartServicesAccessor } from '@kbn/core/server'; +import { i18n } from '@kbn/i18n'; import { sendMessageEvent, SendMessageEventData } from './analytics/events'; import { fetchFields } from './lib/fetch_query_source_fields'; import { AssistClientOptionsWithClient, createAssist as Assist } from './utils/assist'; @@ -23,6 +24,7 @@ import { getChatParams } from './lib/get_chat_params'; import { fetchIndices } from './lib/fetch_indices'; import { isNotNullish } from '../common/is_not_nullish'; import { MODELS } from '../common/models'; +import { ContextLimitError } from './lib/errors'; export function createRetriever(esQuery: string) { return (question: string) => { @@ -157,6 +159,21 @@ export function defineRoutes({ isCloud: cloud?.isCloudEnabled ?? false, }); } catch (e) { + if (e instanceof ContextLimitError) { + return response.badRequest({ + body: { + message: i18n.translate( + 'xpack.searchPlayground.serverErrors.exceedsModelTokenLimit', + { + defaultMessage: + 'Your request uses {approxPromptTokens} input tokens. This exceeds the model token limit of {modelLimit} tokens. Please try using a different model thats capable of accepting larger prompts or reducing the prompt by decreasing the size of the context documents. If you are unsure, please see our documentation.', + values: { modelLimit: e.modelLimit, approxPromptTokens: e.currentTokens }, + } + ), + }, + }); + } + logger.error('Failed to create the chat stream', e); if (typeof e === 'object') { diff --git a/x-pack/plugins/search_playground/tsconfig.json b/x-pack/plugins/search_playground/tsconfig.json index 29c144ff4bac8..73204bade51c3 100644 --- a/x-pack/plugins/search_playground/tsconfig.json +++ b/x-pack/plugins/search_playground/tsconfig.json @@ -45,7 +45,7 @@ "@kbn/data-views-plugin", "@kbn/discover-utils", "@kbn/data-plugin", - "@kbn/search-index-documents" + "@kbn/search-index-documents", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/searchprofiler/kibana.jsonc b/x-pack/plugins/searchprofiler/kibana.jsonc index 3c2b0909cef2b..165066ef0dda6 100644 --- a/x-pack/plugins/searchprofiler/kibana.jsonc +++ b/x-pack/plugins/searchprofiler/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/searchprofiler-plugin", - "owner": "@elastic/kibana-management", + "owner": [ + "@elastic/kibana-management" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "searchprofiler", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "searchprofiler" @@ -20,4 +24,4 @@ "esUiShared" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/searchprofiler/public/application/components/highlight_details_flyout/highlight_details_table.tsx b/x-pack/plugins/searchprofiler/public/application/components/highlight_details_flyout/highlight_details_table.tsx index 7fe8c167ed5dc..14ba16fbacbd8 100644 --- a/x-pack/plugins/searchprofiler/public/application/components/highlight_details_flyout/highlight_details_table.tsx +++ b/x-pack/plugins/searchprofiler/public/application/components/highlight_details_flyout/highlight_details_table.tsx @@ -30,7 +30,7 @@ export const HighlightDetailsTable = ({ breakdown }: Props) => { name: 'Time', render: (item: BreakdownItem) => ( - {nsToPretty(item.time, 1)} + {item.key.endsWith('_count') ? item.time : nsToPretty(item.time, 1)} ), }, diff --git a/x-pack/plugins/security/kibana.jsonc b/x-pack/plugins/security/kibana.jsonc index 01d089a90bea9..f4fe8bceb2bbc 100644 --- a/x-pack/plugins/security/kibana.jsonc +++ b/x-pack/plugins/security/kibana.jsonc @@ -1,17 +1,20 @@ { "type": "plugin", "id": "@kbn/security-plugin", - "owner": "@elastic/kibana-security", + "owner": [ + "@elastic/kibana-security" + ], + "group": "platform", + "visibility": "shared", "description": "This plugin provides authentication and authorization features, and exposes functionality to understand the capabilities of the currently authenticated user.", "plugin": { "id": "security", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "security" ], - "enabledOnAnonymousPages": true, "requiredPlugins": [ "features", "licensing", @@ -31,6 +34,7 @@ "spaces", "esUiShared", "remoteClusters" - ] + ], + "enabledOnAnonymousPages": true } -} +} \ No newline at end of file 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 406e102b4529d..fb472191b561d 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 @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiButtonGroup } from '@elastic/eui'; +import { EuiButtonGroup, EuiThemeProvider } from '@elastic/eui'; import React from 'react'; import { @@ -48,21 +48,28 @@ const displaySpaces: Space[] = [ }, ]; +const renderComponent = (props: React.ComponentProps) => { + return mountWithIntl( + + + + ); +}; + describe('PrivilegeSpaceForm', () => { it('renders an empty form when the role contains no Kibana privileges', () => { const role = createRole(); const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); - const wrapper = mountWithIntl( - - ); + const wrapper = renderComponent({ + role, + spaces: displaySpaces, + kibanaPrivileges, + privilegeIndex: -1, + canCustomizeSubFeaturePrivileges: true, + onChange: jest.fn(), + onCancel: jest.fn(), + }); expect( wrapper.find(EuiButtonGroup).filter('[name="basePrivilegeButtonGroup"]').props().idSelected @@ -110,17 +117,15 @@ describe('PrivilegeSpaceForm', () => { ]); const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); - const wrapper = mountWithIntl( - - ); + const wrapper = renderComponent({ + role, + spaces: displaySpaces, + kibanaPrivileges, + canCustomizeSubFeaturePrivileges: true, + privilegeIndex: 0, + onChange: jest.fn(), + onCancel: jest.fn(), + }); expect( wrapper.find(EuiButtonGroup).filter('[name="basePrivilegeButtonGroup"]').props().idSelected @@ -178,17 +183,15 @@ describe('PrivilegeSpaceForm', () => { ]); const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); - const wrapper = mountWithIntl( - - ); + const wrapper = renderComponent({ + role, + spaces: displaySpaces, + kibanaPrivileges, + canCustomizeSubFeaturePrivileges: true, + privilegeIndex: 0, + onChange: jest.fn(), + onCancel: jest.fn(), + }); expect( wrapper.find(EuiButtonGroup).filter('[name="basePrivilegeButtonGroup"]').props().idSelected @@ -249,16 +252,15 @@ describe('PrivilegeSpaceForm', () => { const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); - const wrapper = mountWithIntl( - - ); + const wrapper = renderComponent({ + role, + spaces: displaySpaces, + kibanaPrivileges, + privilegeIndex: -1, + canCustomizeSubFeaturePrivileges: true, + onChange: jest.fn(), + onCancel: jest.fn(), + }); wrapper.find(SpaceSelector).props().onChange(['*']); @@ -286,17 +288,15 @@ describe('PrivilegeSpaceForm', () => { ]); const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); - const wrapper = mountWithIntl( - - ); + const wrapper = renderComponent({ + role, + spaces: displaySpaces, + kibanaPrivileges, + canCustomizeSubFeaturePrivileges: true, + privilegeIndex: 0, + onChange: jest.fn(), + onCancel: jest.fn(), + }); expect( wrapper.find(EuiButtonGroup).filter('[name="basePrivilegeButtonGroup"]').props().idSelected @@ -360,17 +360,15 @@ describe('PrivilegeSpaceForm', () => { const onChange = jest.fn(); - const wrapper = mountWithIntl( - - ); + const wrapper = renderComponent({ + role, + spaces: displaySpaces, + kibanaPrivileges, + canCustomizeSubFeaturePrivileges: true, + privilegeIndex: 0, + onChange, + onCancel: jest.fn(), + }); findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click'); findTestSubject(wrapper, 'changeAllPrivileges-read').simulate('click'); @@ -418,17 +416,15 @@ describe('PrivilegeSpaceForm', () => { const canCustomize = Symbol('can customize') as unknown as boolean; - const wrapper = mountWithIntl( - - ); + const wrapper = renderComponent({ + role, + spaces: displaySpaces, + kibanaPrivileges, + canCustomizeSubFeaturePrivileges: canCustomize, + privilegeIndex: 0, + onChange, + onCancel: jest.fn(), + }); expect(wrapper.find(FeatureTable).props().canCustomizeSubFeaturePrivileges).toBe(canCustomize); }); @@ -464,17 +460,15 @@ describe('PrivilegeSpaceForm', () => { onChange.mockReset(); }); it('still allow other features privileges to be changed via "change read"', () => { - const wrapper = mountWithIntl( - - ); + const wrapper = renderComponent({ + role, + spaces: displaySpaces, + kibanaPrivileges, + canCustomizeSubFeaturePrivileges: true, + privilegeIndex: 0, + onChange, + onCancel: jest.fn(), + }); findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click'); findTestSubject(wrapper, 'changeAllPrivileges-read').simulate('click'); @@ -510,17 +504,15 @@ describe('PrivilegeSpaceForm', () => { }); it('still allow all privileges to be changed via "change all"', () => { - const wrapper = mountWithIntl( - - ); + const wrapper = renderComponent({ + role, + spaces: displaySpaces, + kibanaPrivileges, + canCustomizeSubFeaturePrivileges: true, + privilegeIndex: 0, + onChange, + onCancel: jest.fn(), + }); findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click'); findTestSubject(wrapper, 'changeAllPrivileges-all').simulate('click'); @@ -587,17 +579,15 @@ describe('PrivilegeSpaceForm', () => { }); it('still allow all features privileges to be changed via "change read" in foo space', () => { - const wrapper = mountWithIntl( - - ); + const wrapper = renderComponent({ + role, + spaces: displaySpaces, + kibanaPrivileges, + canCustomizeSubFeaturePrivileges: true, + privilegeIndex: 0, + onChange, + onCancel: jest.fn(), + }); findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click'); findTestSubject(wrapper, 'changeAllPrivileges-read').simulate('click'); @@ -631,17 +621,15 @@ describe('PrivilegeSpaceForm', () => { }); it('still allow other features privileges to be changed via "change all" in foo space', () => { - const wrapper = mountWithIntl( - - ); + const wrapper = renderComponent({ + role, + spaces: displaySpaces, + kibanaPrivileges, + canCustomizeSubFeaturePrivileges: true, + privilegeIndex: 0, + onChange, + onCancel: jest.fn(), + }); findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click'); findTestSubject(wrapper, 'changeAllPrivileges-all').simulate('click'); @@ -692,17 +680,15 @@ describe('PrivilegeSpaceForm', () => { spaces: ['bar'], }, ]); - const wrapper = mountWithIntl( - - ); + const wrapper = renderComponent({ + role: roleAllSpace, + spaces: displaySpaces, + kibanaPrivileges, + canCustomizeSubFeaturePrivileges: true, + privilegeIndex: 0, + onChange, + onCancel: jest.fn(), + }); findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click'); findTestSubject(wrapper, 'changeAllPrivileges-all').simulate('click'); diff --git a/x-pack/plugins/security/public/session/session_expiration_toast.test.tsx b/x-pack/plugins/security/public/session/session_expiration_toast.test.tsx index f2f1f6ff92f79..46b733c535ec4 100644 --- a/x-pack/plugins/security/public/session/session_expiration_toast.test.tsx +++ b/x-pack/plugins/security/public/session/session_expiration_toast.test.tsx @@ -40,10 +40,25 @@ describe('createSessionExpirationToast', () => { }); describe('SessionExpirationToast', () => { - it('renders session expiration time', () => { + it('renders session expiration time in minutes when >= 60s remaining', () => { const sessionState$ = of({ lastExtensionTime: Date.now(), - expiresInMs: 60 * 1000, + expiresInMs: 60 * 2000, + canBeExtended: true, + }); + + const { getByText } = render( + + + + ); + getByText(/You will be logged out in [0-9]+ minutes/); + }); + + it('renders session expiration time in seconds when < 60s remaining', () => { + const sessionState$ = of({ + lastExtensionTime: Date.now(), + expiresInMs: 60 * 900, canBeExtended: true, }); diff --git a/x-pack/plugins/security/public/session/session_expiration_toast.tsx b/x-pack/plugins/security/public/session/session_expiration_toast.tsx index de0c460f0f3e1..f38638a77bc33 100644 --- a/x-pack/plugins/security/public/session/session_expiration_toast.tsx +++ b/x-pack/plugins/security/public/session/session_expiration_toast.tsx @@ -44,7 +44,7 @@ export const SessionExpirationToast: FunctionComponent, + timeout: , }} /> ); diff --git a/x-pack/plugins/security/server/authorization/authorization_service.test.ts b/x-pack/plugins/security/server/authorization/authorization_service.test.ts index 275a6d2643f24..de3646166d8f9 100644 --- a/x-pack/plugins/security/server/authorization/authorization_service.test.ts +++ b/x-pack/plugins/security/server/authorization/authorization_service.test.ts @@ -145,12 +145,9 @@ describe('#start', () => { customBranding: mockCoreSetup.customBranding, }); - const featuresStart = featuresPluginMock.createStart(); - featuresStart.getKibanaFeatures.mockReturnValue([]); - authorizationService.start({ clusterClient: mockClusterClient, - features: featuresStart, + features: featuresPluginMock.createStart(), online$: statusSubject.asObservable(), }); @@ -217,12 +214,9 @@ it('#stop unsubscribes from license and ES updates.', async () => { customBranding: mockCoreSetup.customBranding, }); - const featuresStart = featuresPluginMock.createStart(); - featuresStart.getKibanaFeatures.mockReturnValue([]); - authorizationService.start({ clusterClient: mockClusterClient, - features: featuresStart, + features: featuresPluginMock.createStart(), online$: statusSubject.asObservable(), }); diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index 37c6e22a07fab..4b9479f51a0f3 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -64,10 +64,8 @@ describe('Security Plugin', () => { mockCoreStart = coreMock.createStart(); - const mockFeaturesStart = featuresPluginMock.createStart(); - mockFeaturesStart.getKibanaFeatures.mockReturnValue([]); mockStartDependencies = { - features: mockFeaturesStart, + features: featuresPluginMock.createStart(), licensing: licensingMock.createStart(), taskManager: taskManagerMock.createStart(), }; diff --git a/x-pack/plugins/security/server/routes/analytics/authentication_type.ts b/x-pack/plugins/security/server/routes/analytics/authentication_type.ts index f2bf76c71b1ab..92094a65da7bb 100644 --- a/x-pack/plugins/security/server/routes/analytics/authentication_type.ts +++ b/x-pack/plugins/security/server/routes/analytics/authentication_type.ts @@ -31,6 +31,13 @@ export function defineRecordAnalyticsOnAuthTypeRoutes({ router.post( { path: '/internal/security/analytics/_record_auth_type', + security: { + authz: { + enabled: false, + reason: + 'This route delegates authorization to the scoped ES cluster client of the internal authentication service', + }, + }, validate: { body: schema.nullable( schema.object({ signature: schema.string(), timestamp: schema.number() }) diff --git a/x-pack/plugins/security/server/routes/analytics/record_violations.ts b/x-pack/plugins/security/server/routes/analytics/record_violations.ts index 826a304f1656e..bec224a6d3eeb 100644 --- a/x-pack/plugins/security/server/routes/analytics/record_violations.ts +++ b/x-pack/plugins/security/server/routes/analytics/record_violations.ts @@ -135,6 +135,13 @@ export function defineRecordViolations({ router, analyticsService }: RouteDefini router.post( { path: '/internal/security/analytics/_record_violations', + security: { + authz: { + enabled: false, + reason: + 'This route is used by browsers to report CSP and Permission Policy violations. These requests are sent without authentication per the browser spec.', + }, + }, validate: { /** * Chrome supports CSP3 spec and sends an array of reports. Safari only sends a single diff --git a/x-pack/plugins/security/server/routes/anonymous_access/get_capabilities.ts b/x-pack/plugins/security/server/routes/anonymous_access/get_capabilities.ts index 220fb1515df46..84c8ed17e5963 100644 --- a/x-pack/plugins/security/server/routes/anonymous_access/get_capabilities.ts +++ b/x-pack/plugins/security/server/routes/anonymous_access/get_capabilities.ts @@ -15,7 +15,17 @@ export function defineAnonymousAccessGetCapabilitiesRoutes({ getAnonymousAccessService, }: RouteDefinitionParams) { router.get( - { path: '/internal/security/anonymous_access/capabilities', validate: false }, + { + path: '/internal/security/anonymous_access/capabilities', + security: { + authz: { + enabled: false, + reason: + 'This route delegates authorization to the scoped ES cluster client of the anonymous access service', + }, + }, + validate: false, + }, async (_context, request, response) => { const anonymousAccessService = getAnonymousAccessService(); return response.ok({ body: await anonymousAccessService.getCapabilities(request) }); diff --git a/x-pack/plugins/security/server/routes/anonymous_access/get_state.ts b/x-pack/plugins/security/server/routes/anonymous_access/get_state.ts index 28745c80a5f44..8911588b72109 100644 --- a/x-pack/plugins/security/server/routes/anonymous_access/get_state.ts +++ b/x-pack/plugins/security/server/routes/anonymous_access/get_state.ts @@ -18,7 +18,16 @@ export function defineAnonymousAccessGetStateRoutes({ getAnonymousAccessService, }: RouteDefinitionParams) { router.get( - { path: '/internal/security/anonymous_access/state', validate: false }, + { + path: '/internal/security/anonymous_access/state', + security: { + authz: { + enabled: false, + reason: 'This route is used for anonymous access', + }, + }, + validate: false, + }, async (_context, _request, response) => { const anonymousAccessService = getAnonymousAccessService(); const accessURLParameters = anonymousAccessService.accessURLParameters diff --git a/x-pack/plugins/security/server/routes/api_keys/create.ts b/x-pack/plugins/security/server/routes/api_keys/create.ts index 59d743e3726aa..963e6c7ced35b 100644 --- a/x-pack/plugins/security/server/routes/api_keys/create.ts +++ b/x-pack/plugins/security/server/routes/api_keys/create.ts @@ -32,6 +32,13 @@ export function defineCreateApiKeyRoutes({ router.post( { path: '/internal/security/api_key', + security: { + authz: { + enabled: false, + reason: + 'This route delegates authorization to the scoped ES cluster client of the internal authentication service', + }, + }, validate: { body: schema.oneOf([ restApiKeySchema, diff --git a/x-pack/plugins/security/server/routes/api_keys/enabled.ts b/x-pack/plugins/security/server/routes/api_keys/enabled.ts index c94c8af61e24f..dd06c93a71e88 100644 --- a/x-pack/plugins/security/server/routes/api_keys/enabled.ts +++ b/x-pack/plugins/security/server/routes/api_keys/enabled.ts @@ -16,6 +16,13 @@ export function defineEnabledApiKeysRoutes({ router.get( { path: '/internal/security/api_key/_enabled', + security: { + authz: { + enabled: false, + reason: + 'This route delegates authorization to the scoped ES cluster client of the internal authentication service', + }, + }, validate: false, }, createLicensedRouteHandler(async (context, request, response) => { diff --git a/x-pack/plugins/security/server/routes/api_keys/has_active.ts b/x-pack/plugins/security/server/routes/api_keys/has_active.ts index bf432b1861045..b1cc220f802b1 100644 --- a/x-pack/plugins/security/server/routes/api_keys/has_active.ts +++ b/x-pack/plugins/security/server/routes/api_keys/has_active.ts @@ -22,6 +22,12 @@ export function defineHasApiKeysRoutes({ router.get( { path: '/internal/security/api_key/_has_active', + security: { + authz: { + enabled: false, + reason: `This route delegates authorization to the scoped ES cluster client of the internal authentication service, and to Core's ES client`, + }, + }, validate: false, options: { access: 'internal', diff --git a/x-pack/plugins/security/server/routes/api_keys/invalidate.ts b/x-pack/plugins/security/server/routes/api_keys/invalidate.ts index 1983dbf2344e0..f2d72185d0b1c 100644 --- a/x-pack/plugins/security/server/routes/api_keys/invalidate.ts +++ b/x-pack/plugins/security/server/routes/api_keys/invalidate.ts @@ -21,6 +21,12 @@ export function defineInvalidateApiKeysRoutes({ router }: RouteDefinitionParams) router.post( { path: '/internal/security/api_key/invalidate', + security: { + authz: { + enabled: false, + reason: `This route delegates authorization to Core's ES client`, + }, + }, validate: { body: schema.object({ apiKeys: schema.arrayOf(schema.object({ id: schema.string(), name: schema.string() })), diff --git a/x-pack/plugins/security/server/routes/api_keys/query.ts b/x-pack/plugins/security/server/routes/api_keys/query.ts index 9fe8fdbdc734b..382d3a290aa7e 100644 --- a/x-pack/plugins/security/server/routes/api_keys/query.ts +++ b/x-pack/plugins/security/server/routes/api_keys/query.ts @@ -25,6 +25,12 @@ export function defineQueryApiKeysAndAggregationsRoute({ // on behalf of the user making the request and governed by the user's own cluster privileges. { path: '/internal/security/api_key/_query', + security: { + authz: { + enabled: false, + reason: `This route delegates authorization to the scoped ES cluster client of the internal authentication service, and to Core's ES client`, + }, + }, validate: { body: schema.object({ query: schema.maybe(schema.object({}, { unknowns: 'allow' })), diff --git a/x-pack/plugins/security/server/routes/api_keys/update.ts b/x-pack/plugins/security/server/routes/api_keys/update.ts index a7fe43c46e206..364a0af0b95ad 100644 --- a/x-pack/plugins/security/server/routes/api_keys/update.ts +++ b/x-pack/plugins/security/server/routes/api_keys/update.ts @@ -34,6 +34,12 @@ export function defineUpdateApiKeyRoutes({ router.put( { path: '/internal/security/api_key', + security: { + authz: { + enabled: false, + reason: `This route delegates authorization to the scoped ES cluster client of the internal authentication service`, + }, + }, validate: { body: schema.oneOf([ updateRestApiKeySchema, diff --git a/x-pack/plugins/security/server/routes/authentication/common.ts b/x-pack/plugins/security/server/routes/authentication/common.ts index b519171fd4fe6..0c91a6c7f3858 100644 --- a/x-pack/plugins/security/server/routes/authentication/common.ts +++ b/x-pack/plugins/security/server/routes/authentication/common.ts @@ -43,6 +43,12 @@ export function defineCommonRoutes({ router.get( { path, + security: { + authz: { + enabled: false, + reason: 'This route must remain accessible to 3rd-party IdPs', + }, + }, // Allow unknown query parameters as this endpoint can be hit by the 3rd-party with any // set of query string parameters (e.g. SAML/OIDC logout request/response parameters). validate: { query: schema.object({}, { unknowns: 'allow' }) }, @@ -92,7 +98,17 @@ export function defineCommonRoutes({ ]) { const deprecated = path === '/api/security/v1/me'; router.get( - { path, validate: false, options: { access: deprecated ? 'public' : 'internal' } }, + { + path, + security: { + authz: { + enabled: false, + reason: `This route delegates authorization to Core's security service; there must be an authenticated user for this route to return information`, + }, + }, + validate: false, + options: { access: deprecated ? 'public' : 'internal' }, + }, createLicensedRouteHandler(async (context, request, response) => { if (deprecated) { logger.warn( @@ -135,10 +151,16 @@ export function defineCommonRoutes({ } // Register the login route for serverless for the time being. Note: This route will move into the buildFlavor !== 'serverless' block below. See next line. - // ToDo: In the serverless environment, we do not support API login - the only valid authentication methodology (or maybe just method or mechanism?) is SAML + // ToDo: In the serverless environment, we do not support API login - the only valid authentication type is SAML router.post( { path: '/internal/security/login', + security: { + authz: { + enabled: false, + reason: `This route provides basic and token login capbility, which is delegated to the internal authentication service`, + }, + }, validate: { body: schema.object({ providerType: schema.string(), @@ -183,7 +205,16 @@ export function defineCommonRoutes({ if (buildFlavor !== 'serverless') { // In the serverless offering, the access agreement functionality isn't available. router.post( - { path: '/internal/security/access_agreement/acknowledge', validate: false }, + { + path: '/internal/security/access_agreement/acknowledge', + security: { + authz: { + enabled: false, + reason: `This route delegates authorization to the internal authentication service; there must be an authenticated user for this route to function`, + }, + }, + validate: false, + }, createLicensedRouteHandler(async (context, request, response) => { // If license doesn't allow access agreement we shouldn't handle request. if (!license.getFeatures().allowAccessAgreement) { diff --git a/x-pack/plugins/security/server/routes/authentication/oidc.ts b/x-pack/plugins/security/server/routes/authentication/oidc.ts index 69c3ce1700671..bb1ed6959e690 100644 --- a/x-pack/plugins/security/server/routes/authentication/oidc.ts +++ b/x-pack/plugins/security/server/routes/authentication/oidc.ts @@ -87,6 +87,12 @@ export function defineOIDCRoutes({ router.get( { path, + security: { + authz: { + enabled: false, + reason: 'This route must remain accessible to 3rd-party OIDC providers', + }, + }, validate: { query: schema.object( { @@ -176,6 +182,12 @@ export function defineOIDCRoutes({ router.post( { path, + security: { + authz: { + enabled: false, + reason: 'This route must remain accessible to 3rd-party OIDC providers', + }, + }, validate: { body: schema.object( { @@ -221,6 +233,12 @@ export function defineOIDCRoutes({ router.get( { path: '/api/security/oidc/initiate_login', + security: { + authz: { + enabled: false, + reason: 'This route must remain accessible to 3rd-party OIDC providers', + }, + }, validate: { query: schema.object( { diff --git a/x-pack/plugins/security/server/routes/authentication/saml.ts b/x-pack/plugins/security/server/routes/authentication/saml.ts index 3c72fd908e6c4..8cee1df2da88b 100644 --- a/x-pack/plugins/security/server/routes/authentication/saml.ts +++ b/x-pack/plugins/security/server/routes/authentication/saml.ts @@ -30,6 +30,12 @@ export function defineSAMLRoutes({ router.post( { path, + security: { + authz: { + enabled: false, + reason: 'This route must remain accessible to 3rd-party SAML providers', + }, + }, validate: { body: schema.object( { SAMLResponse: schema.string(), RelayState: schema.maybe(schema.string()) }, diff --git a/x-pack/plugins/security/server/routes/authorization/privileges/get.ts b/x-pack/plugins/security/server/routes/authorization/privileges/get.ts index b7204faaa7ca4..23fb7ccd9bf39 100644 --- a/x-pack/plugins/security/server/routes/authorization/privileges/get.ts +++ b/x-pack/plugins/security/server/routes/authorization/privileges/get.ts @@ -14,6 +14,13 @@ export function defineGetPrivilegesRoutes({ router, authz }: RouteDefinitionPara router.get( { path: '/api/security/privileges', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because it returns only the global list of Kibana privileges', + }, + }, validate: { query: schema.object({ // We don't use `schema.boolean` here, because all query string parameters are treated as diff --git a/x-pack/plugins/security/server/routes/authorization/privileges/get_builtin.ts b/x-pack/plugins/security/server/routes/authorization/privileges/get_builtin.ts index 9a9c2dd6fcc71..1a35875de72e0 100644 --- a/x-pack/plugins/security/server/routes/authorization/privileges/get_builtin.ts +++ b/x-pack/plugins/security/server/routes/authorization/privileges/get_builtin.ts @@ -9,7 +9,16 @@ import type { RouteDefinitionParams } from '../..'; export function defineGetBuiltinPrivilegesRoutes({ router }: RouteDefinitionParams) { router.get( - { path: '/internal/security/esPrivileges/builtin', validate: false }, + { + path: '/internal/security/esPrivileges/builtin', + security: { + authz: { + enabled: false, + reason: `This route delegates authorization to Core's scoped ES cluster client`, + }, + }, + validate: false, + }, async (context, request, response) => { const esClient = (await context.core).elasticsearch.client; const privileges = await esClient.asCurrentUser.security.getBuiltinPrivileges(); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/delete.ts b/x-pack/plugins/security/server/routes/authorization/roles/delete.ts index 07f314da4232b..b4ff278db219f 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/delete.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/delete.ts @@ -25,6 +25,12 @@ export function defineDeleteRolesRoutes({ router }: RouteDefinitionParams) { .addVersion( { version: API_VERSIONS.roles.public.v1, + security: { + authz: { + enabled: false, + reason: `This route delegates authorization to Core's scoped ES cluster client`, + }, + }, validate: { request: { params: schema.object({ name: schema.string({ minLength: 1 }) }), diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get.ts b/x-pack/plugins/security/server/routes/authorization/roles/get.ts index 63bfa38b76221..c819c5fc36753 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get.ts @@ -32,6 +32,12 @@ export function defineGetRolesRoutes({ .addVersion( { version: API_VERSIONS.roles.public.v1, + security: { + authz: { + enabled: false, + reason: `This route delegates authorization to Core's scoped ES cluster client`, + }, + }, validate: { request: { params: schema.object({ diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts b/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts index ef4a008ecb708..7d1442cb473ef 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts @@ -33,6 +33,12 @@ export function defineGetAllRolesRoutes({ .addVersion( { version: API_VERSIONS.roles.public.v1, + security: { + authz: { + enabled: false, + reason: `This route delegates authorization to Core's scoped ES cluster client`, + }, + }, validate: { request: { query: schema.maybe( diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get_all_by_space.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/get_all_by_space.test.ts index 5797948244dad..956ced4309304 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get_all_by_space.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get_all_by_space.test.ts @@ -149,7 +149,7 @@ describe('GET all roles by space id', () => { const paramsSchema = (config.validate as any).params; - expect(config.options).toEqual({ tags: ['access:manageSpaces'] }); + expect(config.security?.authz).toEqual({ requiredPrivileges: ['manage_spaces'] }); expect(() => paramsSchema.validate({})).toThrowErrorMatchingInlineSnapshot( `"[spaceId]: expected value of type [string] but got [undefined]"` ); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get_all_by_space.ts b/x-pack/plugins/security/server/routes/authorization/roles/get_all_by_space.ts index 48ec8e8f72461..a441ba15164c1 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get_all_by_space.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get_all_by_space.ts @@ -24,8 +24,10 @@ export function defineGetAllRolesBySpaceRoutes({ router.get( { path: '/internal/security/roles/{spaceId}', - options: { - tags: ['access:manageSpaces'], + security: { + authz: { + requiredPrivileges: ['manage_spaces'], + }, }, validate: { params: schema.object({ spaceId: schema.string({ minLength: 1 }) }), diff --git a/x-pack/plugins/security/server/routes/authorization/roles/post.ts b/x-pack/plugins/security/server/routes/authorization/roles/post.ts index 949553e960c9b..4a41533e93a85 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/post.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/post.ts @@ -52,6 +52,12 @@ export function defineBulkCreateOrUpdateRolesRoutes({ .addVersion( { version: API_VERSIONS.roles.public.v1, + security: { + authz: { + enabled: false, + reason: `This route delegates authorization to Core's scoped ES cluster client`, + }, + }, validate: { request: { body: getBulkCreateOrUpdatePayloadSchema(() => { diff --git a/x-pack/plugins/security/server/routes/authorization/roles/put.ts b/x-pack/plugins/security/server/routes/authorization/roles/put.ts index 268c84ff7420e..ce0b8222d412e 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/put.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/put.ts @@ -35,6 +35,12 @@ export function definePutRolesRoutes({ .addVersion( { version: API_VERSIONS.roles.public.v1, + security: { + authz: { + enabled: false, + reason: `This route delegates authorization to Core's scoped ES cluster client`, + }, + }, validate: { request: { params: schema.object({ diff --git a/x-pack/plugins/security/server/routes/authorization/spaces/share_saved_object_permissions.ts b/x-pack/plugins/security/server/routes/authorization/spaces/share_saved_object_permissions.ts index 536220eff03da..4c83455844a26 100644 --- a/x-pack/plugins/security/server/routes/authorization/spaces/share_saved_object_permissions.ts +++ b/x-pack/plugins/security/server/routes/authorization/spaces/share_saved_object_permissions.ts @@ -19,6 +19,12 @@ export function defineShareSavedObjectPermissionRoutes({ router.get( { path: '/internal/security/_share_saved_object_permissions', + security: { + authz: { + enabled: false, + reason: `This route delegates authorization to the internal authorization service's checkPrivilegesWithRequest function`, + }, + }, validate: { query: schema.object({ type: schema.string() }) }, }, createLicensedRouteHandler(async (context, request, response) => { diff --git a/x-pack/plugins/security/server/routes/deprecations/kibana_user_role.ts b/x-pack/plugins/security/server/routes/deprecations/kibana_user_role.ts index 638a8f8a1bc7d..e465369ff0911 100644 --- a/x-pack/plugins/security/server/routes/deprecations/kibana_user_role.ts +++ b/x-pack/plugins/security/server/routes/deprecations/kibana_user_role.ts @@ -23,6 +23,12 @@ export function defineKibanaUserRoleDeprecationRoutes({ router, logger }: RouteD router.post( { path: '/internal/security/deprecations/kibana_user_role/_fix_users', + security: { + authz: { + enabled: false, + reason: `This route delegates authorization to Core's scoped ES cluster client`, + }, + }, validate: false, }, createLicensedRouteHandler(async (context, request, response) => { @@ -88,6 +94,12 @@ export function defineKibanaUserRoleDeprecationRoutes({ router, logger }: RouteD router.post( { path: '/internal/security/deprecations/kibana_user_role/_fix_role_mappings', + security: { + authz: { + enabled: false, + reason: `This route delegates authorization to Core's scoped ES cluster client`, + }, + }, validate: false, }, createLicensedRouteHandler(async (context, request, response) => { diff --git a/x-pack/plugins/security/server/routes/feature_check/feature_check.ts b/x-pack/plugins/security/server/routes/feature_check/feature_check.ts index b256ee77e55ff..6f4cd5b4b2654 100644 --- a/x-pack/plugins/security/server/routes/feature_check/feature_check.ts +++ b/x-pack/plugins/security/server/routes/feature_check/feature_check.ts @@ -43,6 +43,12 @@ export function defineSecurityFeatureCheckRoute({ router, logger }: RouteDefinit router.get( { path: '/internal/security/_check_security_features', + security: { + authz: { + enabled: false, + reason: `This route delegates authorization to Core's scoped ES cluster client`, + }, + }, validate: false, }, createLicensedRouteHandler(async (context, request, response) => { diff --git a/x-pack/plugins/security/server/routes/indices/get_fields.ts b/x-pack/plugins/security/server/routes/indices/get_fields.ts index b0ec51339e080..4cfd6845e61bb 100644 --- a/x-pack/plugins/security/server/routes/indices/get_fields.ts +++ b/x-pack/plugins/security/server/routes/indices/get_fields.ts @@ -14,6 +14,12 @@ export function defineGetFieldsRoutes({ router }: RouteDefinitionParams) { router.get( { path: '/internal/security/fields/{query}', + security: { + authz: { + enabled: false, + reason: `This route delegates authorization to Core's scoped ES cluster client`, + }, + }, validate: { params: schema.object({ query: schema.string() }) }, }, async (context, request, response) => { diff --git a/x-pack/plugins/security/server/routes/role_mapping/delete.ts b/x-pack/plugins/security/server/routes/role_mapping/delete.ts index e305de6e4fcb4..8e331600ba490 100644 --- a/x-pack/plugins/security/server/routes/role_mapping/delete.ts +++ b/x-pack/plugins/security/server/routes/role_mapping/delete.ts @@ -15,6 +15,12 @@ export function defineRoleMappingDeleteRoutes({ router }: RouteDefinitionParams) router.delete( { path: '/internal/security/role_mapping/{name}', + security: { + authz: { + enabled: false, + reason: `This route delegates authorization to Core's scoped ES cluster client`, + }, + }, validate: { params: schema.object({ name: schema.string(), diff --git a/x-pack/plugins/security/server/routes/role_mapping/get.ts b/x-pack/plugins/security/server/routes/role_mapping/get.ts index ac6e7efaa8b0a..2b5ce017fbfb0 100644 --- a/x-pack/plugins/security/server/routes/role_mapping/get.ts +++ b/x-pack/plugins/security/server/routes/role_mapping/get.ts @@ -18,6 +18,12 @@ export function defineRoleMappingGetRoutes(params: RouteDefinitionParams) { router.get( { path: '/internal/security/role_mapping/{name?}', + security: { + authz: { + enabled: false, + reason: `This route delegates authorization to Core's scoped ES cluster client`, + }, + }, validate: { params: schema.object({ name: schema.maybe(schema.string()), diff --git a/x-pack/plugins/security/server/routes/role_mapping/post.ts b/x-pack/plugins/security/server/routes/role_mapping/post.ts index a9a87d4b2be51..e01dd446b6e51 100644 --- a/x-pack/plugins/security/server/routes/role_mapping/post.ts +++ b/x-pack/plugins/security/server/routes/role_mapping/post.ts @@ -15,6 +15,12 @@ export function defineRoleMappingPostRoutes({ router }: RouteDefinitionParams) { router.post( { path: '/internal/security/role_mapping/{name}', + security: { + authz: { + enabled: false, + reason: `This route delegates authorization to Core's scoped ES cluster client`, + }, + }, validate: { params: schema.object({ name: schema.string(), diff --git a/x-pack/plugins/security/server/routes/security_checkup/get_state.ts b/x-pack/plugins/security/server/routes/security_checkup/get_state.ts index 2946c3fa5dee3..40da0959c7418 100644 --- a/x-pack/plugins/security/server/routes/security_checkup/get_state.ts +++ b/x-pack/plugins/security/server/routes/security_checkup/get_state.ts @@ -29,7 +29,16 @@ export function defineSecurityCheckupGetStateRoutes({ const doesClusterHaveUserData = createClusterDataCheck(); router.get( - { path: '/internal/security/security_checkup/state', validate: false }, + { + path: '/internal/security/security_checkup/state', + security: { + authz: { + enabled: false, + reason: `This route delegates authorization to Core's scoped ES cluster client`, + }, + }, + validate: false, + }, async (context, _request, response) => { const esClient = (await context.core).elasticsearch.client; let displayAlert = false; diff --git a/x-pack/plugins/security/server/routes/session_management/extend.ts b/x-pack/plugins/security/server/routes/session_management/extend.ts index b1626ba4660b3..1180303d48aac 100644 --- a/x-pack/plugins/security/server/routes/session_management/extend.ts +++ b/x-pack/plugins/security/server/routes/session_management/extend.ts @@ -14,6 +14,13 @@ export function defineSessionExtendRoutes({ router, basePath }: RouteDefinitionP router.post( { path: '/internal/security/session', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because it only redirects to the /internal/security/session endpoint', + }, + }, validate: false, }, async (_context, _request, response) => { diff --git a/x-pack/plugins/security/server/routes/session_management/info.ts b/x-pack/plugins/security/server/routes/session_management/info.ts index 75fae27e8cb12..c49cb7575399e 100644 --- a/x-pack/plugins/security/server/routes/session_management/info.ts +++ b/x-pack/plugins/security/server/routes/session_management/info.ts @@ -14,7 +14,17 @@ import type { SessionInfo } from '../../../common/types'; */ export function defineSessionInfoRoutes({ router, getSession }: RouteDefinitionParams) { router.get( - { path: '/internal/security/session', validate: false }, + { + path: '/internal/security/session', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because a valid session is required, and it does not return sensative session information', + }, + }, + validate: false, + }, async (_context, request, response) => { const { value: sessionValue } = await getSession().get(request); if (sessionValue) { diff --git a/x-pack/plugins/security/server/routes/session_management/invalidate.test.ts b/x-pack/plugins/security/server/routes/session_management/invalidate.test.ts index fbc14015d80c1..12c31be12dd57 100644 --- a/x-pack/plugins/security/server/routes/session_management/invalidate.test.ts +++ b/x-pack/plugins/security/server/routes/session_management/invalidate.test.ts @@ -46,9 +46,10 @@ describe('Invalidate sessions routes', () => { expect(routeConfig.options).toEqual({ access: 'public', summary: 'Invalidate user sessions', - tags: ['access:sessionManagement'], }); + expect(routeConfig.security?.authz).toEqual({ requiredPrivileges: ['sessionManagement'] }); + const bodySchema = (routeConfig.validate as any).body as ObjectType; expect(() => bodySchema.validate({})).toThrowErrorMatchingInlineSnapshot( `"[match]: expected at least one defined value but got [undefined]"` diff --git a/x-pack/plugins/security/server/routes/session_management/invalidate.ts b/x-pack/plugins/security/server/routes/session_management/invalidate.ts index a45d8f00c1ca4..bbc81c21706d9 100644 --- a/x-pack/plugins/security/server/routes/session_management/invalidate.ts +++ b/x-pack/plugins/security/server/routes/session_management/invalidate.ts @@ -37,6 +37,11 @@ export function defineInvalidateSessionsRoutes({ ), }), }, + security: { + authz: { + requiredPrivileges: ['sessionManagement'], + }, + }, options: { // The invalidate session API was introduced to address situations where the session index // could grow rapidly - when session timeouts are disabled, or with anonymous access. @@ -44,7 +49,7 @@ export function defineInvalidateSessionsRoutes({ // anonymous access. However, keeping this endpoint available internally in serverless would // be useful in situations where we need to batch-invalidate user sessions. access: buildFlavor === 'serverless' ? 'internal' : 'public', - tags: ['access:sessionManagement'], + summary: `Invalidate user sessions`, }, }, diff --git a/x-pack/plugins/security/server/routes/user_profile/bulk_get.test.ts b/x-pack/plugins/security/server/routes/user_profile/bulk_get.test.ts index f5d449bd8423d..eece6b58f8f01 100644 --- a/x-pack/plugins/security/server/routes/user_profile/bulk_get.test.ts +++ b/x-pack/plugins/security/server/routes/user_profile/bulk_get.test.ts @@ -51,7 +51,7 @@ describe('Bulk get profile routes', () => { }); it('correctly defines route.', () => { - expect(routeConfig.options).toEqual({ tags: ['access:bulkGetUserProfiles'] }); + expect(routeConfig.security?.authz).toEqual({ requiredPrivileges: ['bulkGetUserProfiles'] }); const bodySchema = (routeConfig.validate as any).body as ObjectType; expect(() => bodySchema.validate(0)).toThrowErrorMatchingInlineSnapshot( diff --git a/x-pack/plugins/security/server/routes/user_profile/bulk_get.ts b/x-pack/plugins/security/server/routes/user_profile/bulk_get.ts index 20da1d573901f..0ffe760d57d52 100644 --- a/x-pack/plugins/security/server/routes/user_profile/bulk_get.ts +++ b/x-pack/plugins/security/server/routes/user_profile/bulk_get.ts @@ -24,7 +24,11 @@ export function defineBulkGetUserProfilesRoute({ dataPath: schema.maybe(schema.string()), }), }, - options: { tags: ['access:bulkGetUserProfiles'] }, + security: { + authz: { + requiredPrivileges: ['bulkGetUserProfiles'], + }, + }, }, createLicensedRouteHandler(async (context, request, response) => { const userProfileServiceInternal = getUserProfileService(); diff --git a/x-pack/plugins/security/server/routes/user_profile/get_current.ts b/x-pack/plugins/security/server/routes/user_profile/get_current.ts index 9661570e36b4e..4621d543b49ca 100644 --- a/x-pack/plugins/security/server/routes/user_profile/get_current.ts +++ b/x-pack/plugins/security/server/routes/user_profile/get_current.ts @@ -20,6 +20,13 @@ export function defineGetCurrentUserProfileRoute({ router.get( { path: '/internal/security/user_profile', + security: { + authz: { + enabled: false, + reason: + 'This route delegates authorization to the internal authorization service; a currently authenticated user is required', + }, + }, validate: { query: schema.object({ dataPath: schema.maybe(schema.string()) }), }, diff --git a/x-pack/plugins/security/server/routes/user_profile/update.ts b/x-pack/plugins/security/server/routes/user_profile/update.ts index 9a550ada52adc..a400d0db88b89 100644 --- a/x-pack/plugins/security/server/routes/user_profile/update.ts +++ b/x-pack/plugins/security/server/routes/user_profile/update.ts @@ -27,6 +27,13 @@ export function defineUpdateUserProfileDataRoute({ router.post( { path: '/internal/security/user_profile/_data', + security: { + authz: { + enabled: false, + reason: + 'This route delegates authorization to the internal authorization service; an authenticated user and valid session are required', + }, + }, validate: { body: schema.recordOf(schema.string(), schema.any()), }, diff --git a/x-pack/plugins/security/server/routes/users/change_password.ts b/x-pack/plugins/security/server/routes/users/change_password.ts index bd71785ab9549..964d3d6fe888b 100644 --- a/x-pack/plugins/security/server/routes/users/change_password.ts +++ b/x-pack/plugins/security/server/routes/users/change_password.ts @@ -24,6 +24,12 @@ export function defineChangeUserPasswordRoutes({ router.post( { path: '/internal/security/users/{username}/password', + security: { + authz: { + enabled: false, + reason: `This route delegates authorization to the internal authorization service and the Security plugin's canUserChangePassword function`, + }, + }, validate: { params: schema.object({ username: schema.string({ minLength: 1, maxLength: 1024 }) }), body: schema.object({ diff --git a/x-pack/plugins/security/server/routes/users/create_or_update.ts b/x-pack/plugins/security/server/routes/users/create_or_update.ts index de6adad78b4e8..c6c0bcbc48415 100644 --- a/x-pack/plugins/security/server/routes/users/create_or_update.ts +++ b/x-pack/plugins/security/server/routes/users/create_or_update.ts @@ -15,6 +15,12 @@ export function defineCreateOrUpdateUserRoutes({ router }: RouteDefinitionParams router.post( { path: '/internal/security/users/{username}', + security: { + authz: { + enabled: false, + reason: `This route delegates authorization to Core's scoped ES cluster client`, + }, + }, validate: { params: schema.object({ username: schema.string({ minLength: 1, maxLength: 1024 }) }), body: schema.object({ diff --git a/x-pack/plugins/security/server/routes/users/delete.ts b/x-pack/plugins/security/server/routes/users/delete.ts index 429adb368574a..39f838dff7d8c 100644 --- a/x-pack/plugins/security/server/routes/users/delete.ts +++ b/x-pack/plugins/security/server/routes/users/delete.ts @@ -15,6 +15,12 @@ export function defineDeleteUserRoutes({ router }: RouteDefinitionParams) { router.delete( { path: '/internal/security/users/{username}', + security: { + authz: { + enabled: false, + reason: `This route delegates authorization to Core's scoped ES cluster client`, + }, + }, validate: { params: schema.object({ username: schema.string({ minLength: 1, maxLength: 1024 }) }), }, diff --git a/x-pack/plugins/security/server/routes/users/disable.ts b/x-pack/plugins/security/server/routes/users/disable.ts index 87f61daca8c95..f2984504922b3 100644 --- a/x-pack/plugins/security/server/routes/users/disable.ts +++ b/x-pack/plugins/security/server/routes/users/disable.ts @@ -15,6 +15,12 @@ export function defineDisableUserRoutes({ router }: RouteDefinitionParams) { router.post( { path: '/internal/security/users/{username}/_disable', + security: { + authz: { + enabled: false, + reason: `This route delegates authorization to Core's scoped ES cluster client`, + }, + }, validate: { params: schema.object({ username: schema.string({ minLength: 1, maxLength: 1024 }) }), }, diff --git a/x-pack/plugins/security/server/routes/users/enable.ts b/x-pack/plugins/security/server/routes/users/enable.ts index a8a9d62bee938..18ec66683bd56 100644 --- a/x-pack/plugins/security/server/routes/users/enable.ts +++ b/x-pack/plugins/security/server/routes/users/enable.ts @@ -15,6 +15,12 @@ export function defineEnableUserRoutes({ router }: RouteDefinitionParams) { router.post( { path: '/internal/security/users/{username}/_enable', + security: { + authz: { + enabled: false, + reason: `This route delegates authorization to Core's scoped ES cluster client`, + }, + }, validate: { params: schema.object({ username: schema.string({ minLength: 1, maxLength: 1024 }) }), }, diff --git a/x-pack/plugins/security/server/routes/users/get.ts b/x-pack/plugins/security/server/routes/users/get.ts index ed18c8437627d..076c8c9beeef2 100644 --- a/x-pack/plugins/security/server/routes/users/get.ts +++ b/x-pack/plugins/security/server/routes/users/get.ts @@ -15,6 +15,12 @@ export function defineGetUserRoutes({ router }: RouteDefinitionParams) { router.get( { path: '/internal/security/users/{username}', + security: { + authz: { + enabled: false, + reason: `This route delegates authorization to Core's scoped ES cluster client`, + }, + }, validate: { params: schema.object({ username: schema.string({ minLength: 1, maxLength: 1024 }) }), }, diff --git a/x-pack/plugins/security/server/routes/users/get_all.ts b/x-pack/plugins/security/server/routes/users/get_all.ts index eae0664189340..c8c340f2b2ceb 100644 --- a/x-pack/plugins/security/server/routes/users/get_all.ts +++ b/x-pack/plugins/security/server/routes/users/get_all.ts @@ -11,7 +11,16 @@ import { createLicensedRouteHandler } from '../licensed_route_handler'; export function defineGetAllUsersRoutes({ router }: RouteDefinitionParams) { router.get( - { path: '/internal/security/users', validate: false }, + { + path: '/internal/security/users', + security: { + authz: { + enabled: false, + reason: `This route delegates authorization to Core's scoped ES cluster client`, + }, + }, + validate: false, + }, createLicensedRouteHandler(async (context, request, response) => { try { const esClient = (await context.core).elasticsearch.client; diff --git a/x-pack/plugins/security/server/routes/views/access_agreement.ts b/x-pack/plugins/security/server/routes/views/access_agreement.ts index 823fbb0286f33..ff6399f186610 100644 --- a/x-pack/plugins/security/server/routes/views/access_agreement.ts +++ b/x-pack/plugins/security/server/routes/views/access_agreement.ts @@ -35,7 +35,17 @@ export function defineAccessAgreementRoutes({ ); router.get( - { path: '/internal/security/access_agreement/state', validate: false }, + { + path: '/internal/security/access_agreement/state', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because it requires only an active session in order to function', + }, + }, + validate: false, + }, createLicensedRouteHandler(async (context, request, response) => { if (!canHandleRequest()) { return response.forbidden({ diff --git a/x-pack/plugins/security/server/routes/views/login.ts b/x-pack/plugins/security/server/routes/views/login.ts index 8cf8459d523b8..ed3228c244b51 100644 --- a/x-pack/plugins/security/server/routes/views/login.ts +++ b/x-pack/plugins/security/server/routes/views/login.ts @@ -57,7 +57,18 @@ export function defineLoginRoutes({ ); router.get( - { path: '/internal/security/login_state', validate: false, options: { authRequired: false } }, + { + path: '/internal/security/login_state', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because it only provides non-sensative information about authentication provider configuration', + }, + }, + validate: false, + options: { authRequired: false }, + }, async (context, request, response) => { const { allowLogin, layout = 'form' } = license.getFeatures(); const { sortedProviders, selector } = config.authc; diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/common/common.gen.ts b/x-pack/plugins/security_solution/common/api/entity_analytics/common/common.gen.ts index fcf8f5e3c6a71..267cf4d59a956 100644 --- a/x-pack/plugins/security_solution/common/api/entity_analytics/common/common.gen.ts +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/common/common.gen.ts @@ -25,22 +25,10 @@ export const EntityAnalyticsPrivileges = z.object({ has_write_permissions: z.boolean().optional(), privileges: z.object({ elasticsearch: z.object({ - cluster: z - .object({ - manage_index_templates: z.boolean().optional(), - manage_transform: z.boolean().optional(), - }) - .optional(), - index: z - .object({}) - .catchall( - z.object({ - read: z.boolean().optional(), - write: z.boolean().optional(), - }) - ) - .optional(), + cluster: z.object({}).catchall(z.boolean()).optional(), + index: z.object({}).catchall(z.object({}).catchall(z.boolean())).optional(), }), + kibana: z.object({}).catchall(z.boolean()).optional(), }), }); diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/common/common.schema.yaml b/x-pack/plugins/security_solution/common/api/entity_analytics/common/common.schema.yaml index 67428b261a0f9..1da4eca994aed 100644 --- a/x-pack/plugins/security_solution/common/api/entity_analytics/common/common.schema.yaml +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/common/common.schema.yaml @@ -23,20 +23,18 @@ components: properties: cluster: type: object - properties: - manage_index_templates: - type: boolean - manage_transform: - type: boolean + additionalProperties: + type: boolean index: type: object additionalProperties: type: object - properties: - read: - type: boolean - write: - type: boolean + additionalProperties: + type: boolean + kibana: + type: object + additionalProperties: + type: boolean required: - elasticsearch required: diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.gen.ts b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.gen.ts index 2dd83ca89bee0..228bf1e515675 100644 --- a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.gen.ts +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.gen.ts @@ -36,6 +36,7 @@ export const EngineDescriptor = z.object({ status: EngineStatus, filter: z.string().optional(), fieldHistoryLength: z.number().int(), + error: z.object({}).optional(), }); export type InspectQuery = z.infer; diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.schema.yaml b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.schema.yaml index 810961392aad1..00b100516b76c 100644 --- a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.schema.yaml +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.schema.yaml @@ -30,6 +30,8 @@ components: type: string fieldHistoryLength: type: integer + error: + type: object EngineStatus: type: string diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/get_privileges.gen.ts b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/get_privileges.gen.ts new file mode 100644 index 0000000000000..a9cbc9d75e00c --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/get_privileges.gen.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + * + * info: + * title: Get Entity Store Privileges Schema + * version: 1 + */ + +import type { z } from '@kbn/zod'; + +import { EntityAnalyticsPrivileges } from '../../common/common.gen'; + +export type EntityStoreGetPrivilegesResponse = z.infer; +export const EntityStoreGetPrivilegesResponse = EntityAnalyticsPrivileges; diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/get_privileges.schema.yaml b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/get_privileges.schema.yaml new file mode 100644 index 0000000000000..f1db3b3f93a5a --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/get_privileges.schema.yaml @@ -0,0 +1,19 @@ +openapi: 3.0.0 +info: + title: Get Entity Store Privileges Schema + version: '1' +paths: + /internal/entity_store/privileges: + get: + x-labels: [ess, serverless] + x-internal: true + x-codegen-enabled: true + operationId: EntityStoreGetPrivileges + summary: Get Entity Store Privileges + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '../../common/common.schema.yaml#/components/schemas/EntityAnalyticsPrivileges' 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 065601f1cf4ab..80afabb9f2c8a 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 @@ -249,6 +249,7 @@ import type { DeleteEntityEngineRequestParamsInput, DeleteEntityEngineResponse, } from './entity_analytics/entity_store/engine/delete.gen'; +import type { EntityStoreGetPrivilegesResponse } from './entity_analytics/entity_store/engine/get_privileges.gen'; import type { GetEntityEngineRequestParamsInput, GetEntityEngineResponse, @@ -364,7 +365,16 @@ import type { import type { CreateRuleMigrationRequestBodyInput, CreateRuleMigrationResponse, + GetAllStatsRuleMigrationResponse, + GetRuleMigrationRequestParamsInput, GetRuleMigrationResponse, + GetRuleMigrationStatsRequestParamsInput, + GetRuleMigrationStatsResponse, + StartRuleMigrationRequestParamsInput, + StartRuleMigrationRequestBodyInput, + StartRuleMigrationResponse, + StopRuleMigrationRequestParamsInput, + StopRuleMigrationResponse, } from '../siem_migrations/model/api/rules/rules_migration.gen'; export interface ClientOptions { @@ -1102,6 +1112,18 @@ Migrations are initiated per index. While the process is neither destructive nor }) .catch(catchAxiosErrorFormatAndThrow); } + async entityStoreGetPrivileges() { + this.log.info(`${new Date().toISOString()} Calling API EntityStoreGetPrivileges`); + return this.kbnClient + .request({ + path: '/internal/entity_store/privileges', + headers: { + [ELASTIC_HTTP_VERSION_HEADER]: '1', + }, + method: 'GET', + }) + .catch(catchAxiosErrorFormatAndThrow); + } /** * Export detection rules to an `.ndjson` file. The following configuration items are also included in the `.ndjson` file: - Actions @@ -1205,6 +1227,21 @@ finalize it. }) .catch(catchAxiosErrorFormatAndThrow); } + /** + * Retrieves the rule migrations stats for all migrations stored in the system + */ + async getAllStatsRuleMigration() { + this.log.info(`${new Date().toISOString()} Calling API GetAllStatsRuleMigration`); + return this.kbnClient + .request({ + path: '/internal/siem_migrations/rules/stats', + headers: { + [ELASTIC_HTTP_VERSION_HEADER]: '1', + }, + method: 'GET', + }) + .catch(catchAxiosErrorFormatAndThrow); + } /** * Get the criticality record for a specific asset. */ @@ -1395,13 +1432,28 @@ finalize it. .catch(catchAxiosErrorFormatAndThrow); } /** - * Retrieves the rule migrations stored in the system + * Retrieves the rule documents stored in the system given the rule migration id */ - async getRuleMigration() { + async getRuleMigration(props: GetRuleMigrationProps) { this.log.info(`${new Date().toISOString()} Calling API GetRuleMigration`); 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', + }, + method: 'GET', + }) + .catch(catchAxiosErrorFormatAndThrow); + } + /** + * Retrieves the stats of a SIEM rules migration using the migration id provided + */ + async getRuleMigrationStats(props: GetRuleMigrationStatsProps) { + this.log.info(`${new Date().toISOString()} Calling API GetRuleMigrationStats`); + return this.kbnClient + .request({ + path: replaceParams('/internal/siem_migrations/rules/{migration_id}/stats', props.params), headers: { [ELASTIC_HTTP_VERSION_HEADER]: '1', }, @@ -1913,6 +1965,22 @@ detection engine rules. }) .catch(catchAxiosErrorFormatAndThrow); } + /** + * Starts a SIEM rules migration using the migration id provided + */ + async startRuleMigration(props: StartRuleMigrationProps) { + this.log.info(`${new Date().toISOString()} Calling API StartRuleMigration`); + return this.kbnClient + .request({ + path: replaceParams('/internal/siem_migrations/rules/{migration_id}/start', props.params), + headers: { + [ELASTIC_HTTP_VERSION_HEADER]: '1', + }, + method: 'PUT', + body: props.body, + }) + .catch(catchAxiosErrorFormatAndThrow); + } async stopEntityEngine(props: StopEntityEngineProps) { this.log.info(`${new Date().toISOString()} Calling API StopEntityEngine`); return this.kbnClient @@ -1925,6 +1993,21 @@ detection engine rules. }) .catch(catchAxiosErrorFormatAndThrow); } + /** + * Stops a running SIEM rules migration using the migration id provided + */ + async stopRuleMigration(props: StopRuleMigrationProps) { + this.log.info(`${new Date().toISOString()} Calling API StopRuleMigration`); + return this.kbnClient + .request({ + path: replaceParams('/internal/siem_migrations/rules/{migration_id}/stop', props.params), + headers: { + [ELASTIC_HTTP_VERSION_HEADER]: '1', + }, + method: 'PUT', + }) + .catch(catchAxiosErrorFormatAndThrow); + } /** * Suggests user profiles. */ @@ -2161,6 +2244,12 @@ export interface GetRuleExecutionResultsProps { query: GetRuleExecutionResultsRequestQueryInput; params: GetRuleExecutionResultsRequestParamsInput; } +export interface GetRuleMigrationProps { + params: GetRuleMigrationRequestParamsInput; +} +export interface GetRuleMigrationStatsProps { + params: GetRuleMigrationStatsRequestParamsInput; +} export interface GetTimelineProps { query: GetTimelineRequestQueryInput; } @@ -2237,9 +2326,16 @@ export interface SetAlertTagsProps { export interface StartEntityEngineProps { params: StartEntityEngineRequestParamsInput; } +export interface StartRuleMigrationProps { + params: StartRuleMigrationRequestParamsInput; + body: StartRuleMigrationRequestBodyInput; +} export interface StopEntityEngineProps { params: StopEntityEngineRequestParamsInput; } +export interface StopRuleMigrationProps { + params: StopRuleMigrationRequestParamsInput; +} export interface SuggestUserProfilesProps { query: SuggestUserProfilesRequestQueryInput; } diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 68aaf7bf9cf04..137afe7ba9112 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -424,7 +424,6 @@ export const RULES_TABLE_MAX_PAGE_SIZE = 100; export const NEW_FEATURES_TOUR_STORAGE_KEYS = { RULE_MANAGEMENT_PAGE: 'securitySolution.rulesManagementPage.newFeaturesTour.v8.13', TIMELINES: 'securitySolution.security.timelineFlyoutHeader.saveTimelineTour', - FLYOUT: 'securitySolution.documentDetails.newFeaturesTour.v8.14', }; export const RULE_DETAILS_EXECUTION_LOG_TABLE_SHOW_METRIC_COLUMNS_STORAGE_KEY = diff --git a/x-pack/plugins/security_solution/common/entity_analytics/entity_store/constants.ts b/x-pack/plugins/security_solution/common/entity_analytics/entity_store/constants.ts index 157ec6845e33a..b6834422c8cfc 100644 --- a/x-pack/plugins/security_solution/common/entity_analytics/entity_store/constants.ts +++ b/x-pack/plugins/security_solution/common/entity_analytics/entity_store/constants.ts @@ -10,6 +10,16 @@ */ export const ENTITY_STORE_URL = '/api/entity_store' as const; +export const ENTITY_STORE_INTERNAL_PRIVILEGES_URL = `${ENTITY_STORE_URL}/privileges` as const; export const ENTITIES_URL = `${ENTITY_STORE_URL}/entities` as const; - export const LIST_ENTITIES_URL = `${ENTITIES_URL}/list` as const; + +export const ENTITY_STORE_REQUIRED_ES_CLUSTER_PRIVILEGES = [ + 'manage_index_templates', + 'manage_transform', + 'manage_ingest_pipelines', + 'manage_enrich', +]; + +// The index pattern for the entity store has to support '.entities.v1.latest.noop' index +export const ENTITY_STORE_INDEX_PATTERN = '.entities.v1.latest.*'; diff --git a/x-pack/plugins/security_solution/common/entity_analytics/privileges.test.ts b/x-pack/plugins/security_solution/common/entity_analytics/privileges.test.ts new file mode 100644 index 0000000000000..60947192a892f --- /dev/null +++ b/x-pack/plugins/security_solution/common/entity_analytics/privileges.test.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getAllMissingPrivileges } from './privileges'; +import type { EntityAnalyticsPrivileges } from '../api/entity_analytics'; + +describe('getAllMissingPrivileges', () => { + it('should return all missing privileges for elasticsearch and kibana', () => { + const privileges: EntityAnalyticsPrivileges = { + privileges: { + elasticsearch: { + index: { + 'logs-*': { read: true, view_index_metadata: true }, + 'auditbeat-*': { read: false, view_index_metadata: false }, + }, + cluster: { + manage_enrich: false, + manage_ingest_pipelines: true, + }, + }, + kibana: { + 'saved_object:entity-engine-status/all': false, + 'saved_object:entity-definition/all': true, + }, + }, + has_all_required: false, + has_read_permissions: false, + has_write_permissions: false, + }; + + const result = getAllMissingPrivileges(privileges); + + expect(result).toEqual({ + elasticsearch: { + index: [{ indexName: 'auditbeat-*', privileges: ['read', 'view_index_metadata'] }], + cluster: ['manage_enrich'], + }, + kibana: ['saved_object:entity-engine-status/all'], + }); + }); + + it('should return empty lists if all privileges are true', () => { + const privileges: EntityAnalyticsPrivileges = { + privileges: { + elasticsearch: { + index: { + 'logs-*': { read: true, view_index_metadata: true }, + }, + cluster: { + manage_enrich: true, + }, + }, + kibana: { + 'saved_object:entity-engine-status/all': true, + }, + }, + has_all_required: true, + has_read_permissions: true, + has_write_permissions: true, + }; + + const result = getAllMissingPrivileges(privileges); + + expect(result).toEqual({ + elasticsearch: { + index: [], + cluster: [], + }, + kibana: [], + }); + }); + + it('should handle empty privileges object', () => { + const privileges: EntityAnalyticsPrivileges = { + privileges: { + elasticsearch: { + index: {}, + cluster: {}, + }, + kibana: {}, + }, + has_all_required: false, + has_read_permissions: false, + has_write_permissions: false, + }; + + const result = getAllMissingPrivileges(privileges); + + expect(result).toEqual({ + elasticsearch: { + index: [], + cluster: [], + }, + kibana: [], + }); + }); +}); diff --git a/x-pack/plugins/security_solution/common/entity_analytics/privileges.ts b/x-pack/plugins/security_solution/common/entity_analytics/privileges.ts new file mode 100644 index 0000000000000..89f90651943a4 --- /dev/null +++ b/x-pack/plugins/security_solution/common/entity_analytics/privileges.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EntityAnalyticsPrivileges } from '../api/entity_analytics'; + +export const getAllMissingPrivileges = (privilege: EntityAnalyticsPrivileges) => { + const esPrivileges = privilege.privileges.elasticsearch; + const kbnPrivileges = privilege.privileges.kibana; + + const index = Object.entries(esPrivileges.index ?? {}) + .map(([indexName, indexPrivileges]) => ({ + indexName, + privileges: filterUnauthorized(indexPrivileges), + })) + .filter(({ privileges }) => privileges.length > 0); + + return { + elasticsearch: { index, cluster: filterUnauthorized(esPrivileges.cluster) }, + kibana: filterUnauthorized(kbnPrivileges), + }; +}; + +const filterUnauthorized = (obj: Record | undefined) => + Object.entries(obj ?? {}) + .filter(([_, authorized]) => !authorized) + .map(([privileges, _]) => privileges); diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 113a232b5775e..892b0a0226639 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -111,7 +111,7 @@ export const allowedExperimentalValues = Object.freeze({ /** * Enables new Knowledge Base Entries features, introduced in `8.15.0`. */ - assistantKnowledgeBaseByDefault: false, + assistantKnowledgeBaseByDefault: true, /** * Enables the Managed User section inside the new user details flyout. diff --git a/x-pack/plugins/security_solution/common/guided_onboarding/translations.ts b/x-pack/plugins/security_solution/common/guided_onboarding/translations.ts index 46017971df1b0..ffd758d1a1ffc 100644 --- a/x-pack/plugins/security_solution/common/guided_onboarding/translations.ts +++ b/x-pack/plugins/security_solution/common/guided_onboarding/translations.ts @@ -87,6 +87,6 @@ export const CASES_MANUAL_TITLE = i18n.translate( export const CASES_MANUAL_DESCRIPTION = i18n.translate( 'xpack.securitySolution.guideConfig.alertsStep.manualCompletion.description', { - defaultMessage: `After you've explored the case, continue.`, + defaultMessage: `View the case's details by clicking View case in the confirmation message that appears. Alternatively, go to the Insights section of the alert details flyout, find the case you created, and select it. After you've explored the case, continue.`, } ); 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 96ca75679f112..f2efc646a8101 100644 --- a/x-pack/plugins/security_solution/common/siem_migrations/constants.ts +++ b/x-pack/plugins/security_solution/common/siem_migrations/constants.ts @@ -8,9 +8,24 @@ export const SIEM_MIGRATIONS_PATH = '/internal/siem_migrations' as const; export const SIEM_RULE_MIGRATIONS_PATH = `${SIEM_MIGRATIONS_PATH}/rules` as const; -export enum SiemMigrationsStatus { +export const SIEM_RULE_MIGRATIONS_ALL_STATS_PATH = `${SIEM_RULE_MIGRATIONS_PATH}/stats` as const; +export const SIEM_RULE_MIGRATIONS_GET_PATH = `${SIEM_RULE_MIGRATIONS_PATH}/{migration_id}` as const; +export const SIEM_RULE_MIGRATIONS_START_PATH = + `${SIEM_RULE_MIGRATIONS_PATH}/{migration_id}/start` as const; +export const SIEM_RULE_MIGRATIONS_STATS_PATH = + `${SIEM_RULE_MIGRATIONS_PATH}/{migration_id}/stats` as const; +export const SIEM_RULE_MIGRATIONS_STOP_PATH = + `${SIEM_RULE_MIGRATIONS_PATH}/{migration_id}/stop` as const; + +export enum SiemMigrationStatus { PENDING = 'pending', PROCESSING = 'processing', - FINISHED = 'finished', - ERROR = 'error', + COMPLETED = 'completed', + FAILED = 'failed', +} + +export enum SiemMigrationRuleTranslationResult { + FULL = 'full', + PARTIAL = 'partial', + UNTRANSLATABLE = 'untranslatable', } diff --git a/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rules_migration.gen.ts b/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rules_migration.gen.ts index fa8a1cc8a6778..120505ec43cb7 100644 --- a/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rules_migration.gen.ts +++ b/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rules_migration.gen.ts @@ -16,7 +16,13 @@ import { z } from '@kbn/zod'; -import { OriginalRule, RuleMigration } from '../../rule_migration.gen'; +import { + OriginalRule, + RuleMigrationAllTaskStats, + RuleMigration, + RuleMigrationTaskStats, +} from '../../rule_migration.gen'; +import { ConnectorId, LangSmithOptions } from '../common.gen'; export type CreateRuleMigrationRequestBody = z.infer; export const CreateRuleMigrationRequestBody = z.array(OriginalRule); @@ -30,5 +36,60 @@ export const CreateRuleMigrationResponse = z.object({ migration_id: z.string(), }); +export type GetAllStatsRuleMigrationResponse = z.infer; +export const GetAllStatsRuleMigrationResponse = RuleMigrationAllTaskStats; + +export type GetRuleMigrationRequestParams = z.infer; +export const GetRuleMigrationRequestParams = z.object({ + migration_id: z.string(), +}); +export type GetRuleMigrationRequestParamsInput = z.input; + export type GetRuleMigrationResponse = z.infer; export const GetRuleMigrationResponse = z.array(RuleMigration); + +export type GetRuleMigrationStatsRequestParams = z.infer; +export const GetRuleMigrationStatsRequestParams = z.object({ + migration_id: z.string(), +}); +export type GetRuleMigrationStatsRequestParamsInput = z.input< + typeof GetRuleMigrationStatsRequestParams +>; + +export type GetRuleMigrationStatsResponse = z.infer; +export const GetRuleMigrationStatsResponse = RuleMigrationTaskStats; + +export type StartRuleMigrationRequestParams = z.infer; +export const StartRuleMigrationRequestParams = z.object({ + migration_id: z.string(), +}); +export type StartRuleMigrationRequestParamsInput = z.input; + +export type StartRuleMigrationRequestBody = z.infer; +export const StartRuleMigrationRequestBody = z.object({ + connector_id: ConnectorId, + langsmith_options: LangSmithOptions.optional(), +}); +export type StartRuleMigrationRequestBodyInput = z.input; + +export type StartRuleMigrationResponse = z.infer; +export const StartRuleMigrationResponse = z.object({ + /** + * Indicates the migration has been started. `false` means the migration does not need to be started. + */ + started: z.boolean(), +}); + +export type StopRuleMigrationRequestParams = z.infer; +export const StopRuleMigrationRequestParams = z.object({ + migration_id: z.string(), +}); +export type StopRuleMigrationRequestParamsInput = z.input; + +export type StopRuleMigrationResponse = z.infer; +export const StopRuleMigrationResponse = z.object({ + /** + * Indicates the migration has been stopped. + */ + stopped: z.boolean(), +}); diff --git a/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rules_migration.schema.yaml b/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rules_migration.schema.yaml index 40596ba7e712d..7b06c3d6a22ac 100644 --- a/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rules_migration.schema.yaml +++ b/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rules_migration.schema.yaml @@ -10,8 +10,7 @@ paths: x-codegen-enabled: true description: Creates a new SIEM rules migration using the original vendor rules provided tags: - - SIEM Migrations - - Rule Migrations + - SIEM Rule Migrations requestBody: required: true content: @@ -33,20 +32,146 @@ paths: migration_id: type: string description: The migration id created. + + /internal/siem_migrations/rules/stats: get: - summary: Retrieves rule migrations - operationId: GetRuleMigration + summary: Retrieves the stats for all rule migrations + operationId: GetAllStatsRuleMigration x-codegen-enabled: true - description: Retrieves the rule migrations stored in the system + description: Retrieves the rule migrations stats for all migrations stored in the system tags: - - SIEM Migrations - - Rule Migrations + - SIEM Rule Migrations responses: 200: description: Indicates rule migrations have been retrieved correctly. + content: + application/json: + schema: + $ref: '../../rule_migration.schema.yaml#/components/schemas/RuleMigrationAllTaskStats' + + /internal/siem_migrations/rules/{migration_id}: + get: + summary: Retrieves all the rules of a migration + operationId: GetRuleMigration + x-codegen-enabled: true + description: Retrieves the rule documents stored in the system given the rule migration id + tags: + - SIEM Rule Migrations + parameters: + - name: migration_id + in: path + required: true + schema: + type: string + description: The migration id to start + responses: + 200: + description: Indicates rule migration have been retrieved correctly. content: application/json: schema: type: array items: $ref: '../../rule_migration.schema.yaml#/components/schemas/RuleMigration' + 204: + description: Indicates the migration id was not found. + + /internal/siem_migrations/rules/{migration_id}/start: + put: + summary: Starts a rule migration + operationId: StartRuleMigration + x-codegen-enabled: true + description: Starts a SIEM rules migration using the migration id provided + tags: + - SIEM Rule Migrations + parameters: + - name: migration_id + in: path + required: true + schema: + type: string + description: The migration id to start + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - connector_id + properties: + connector_id: + $ref: '../common.schema.yaml#/components/schemas/ConnectorId' + langsmith_options: + $ref: '../common.schema.yaml#/components/schemas/LangSmithOptions' + responses: + 200: + description: Indicates the migration start request has been processed successfully. + content: + application/json: + schema: + type: object + required: + - started + properties: + started: + type: boolean + description: Indicates the migration has been started. `false` means the migration does not need to be started. + 204: + description: Indicates the migration id was not found. + + /internal/siem_migrations/rules/{migration_id}/stats: + get: + summary: Gets a rule migration task stats + operationId: GetRuleMigrationStats + x-codegen-enabled: true + description: Retrieves the stats of a SIEM rules migration using the migration id provided + tags: + - SIEM Rule Migrations + parameters: + - name: migration_id + in: path + required: true + schema: + type: string + description: The migration id to start + responses: + 200: + description: Indicates the migration stats has been retrieved correctly. + content: + application/json: + schema: + $ref: '../../rule_migration.schema.yaml#/components/schemas/RuleMigrationTaskStats' + 204: + description: Indicates the migration id was not found. + + /internal/siem_migrations/rules/{migration_id}/stop: + put: + summary: Stops an existing rule migration + operationId: StopRuleMigration + x-codegen-enabled: true + description: Stops a running SIEM rules migration using the migration id provided + tags: + - SIEM Rule Migrations + parameters: + - name: migration_id + in: path + required: true + schema: + type: string + description: The migration id to stop + responses: + 200: + description: Indicates migration task stop has been processed successfully. + content: + application/json: + schema: + type: object + required: + - stopped + properties: + stopped: + type: boolean + description: Indicates the migration has been stopped. + 204: + description: Indicates the migration id was not found running. 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 0e07ef2f208da..fe00c4b4df1c6 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 @@ -71,11 +71,11 @@ export const ElasticRule = z.object({ /** * The translated elastic query. */ - query: z.string(), + query: z.string().optional(), /** * The translated elastic query language. */ - query_language: z.literal('esql').default('esql'), + query_language: z.literal('esql').optional(), /** * The Elastic prebuilt rule id matched. */ @@ -99,16 +99,20 @@ export const RuleMigration = z.object({ * The migration id. */ migration_id: z.string(), + /** + * The username of the user who created the migration. + */ + created_by: z.string(), original_rule: OriginalRule, elastic_rule: ElasticRule.optional(), /** - * The translation state. + * The rule translation result. */ - translation_state: z.enum(['complete', 'partial', 'untranslatable']).optional(), + translation_result: z.enum(['full', 'partial', 'untranslatable']).optional(), /** - * The status of the rule migration. + * The status of the rule migration process. */ - status: z.enum(['pending', 'processing', 'finished', 'error']).default('pending'), + status: z.enum(['pending', 'processing', 'completed', 'failed']).default('pending'), /** * The comments for the migration including a summary from the LLM in markdown. */ @@ -122,3 +126,55 @@ export const RuleMigration = z.object({ */ updated_by: z.string().optional(), }); + +/** + * The rule migration task stats object. + */ +export type RuleMigrationTaskStats = z.infer; +export const RuleMigrationTaskStats = z.object({ + /** + * Indicates if the migration task status. + */ + status: z.enum(['ready', 'running', 'stopped', 'finished']), + /** + * The rules migration stats. + */ + rules: z.object({ + /** + * The total number of rules to migrate. + */ + total: z.number().int(), + /** + * The number of rules that are pending migration. + */ + pending: z.number().int(), + /** + * The number of rules that are being migrated. + */ + processing: z.number().int(), + /** + * The number of rules that have been migrated successfully. + */ + completed: z.number().int(), + /** + * The number of rules that have failed migration. + */ + failed: z.number().int(), + }), + /** + * The moment of the last update. + */ + last_updated_at: z.string().optional(), +}); + +export type RuleMigrationAllTaskStats = z.infer; +export const RuleMigrationAllTaskStats = z.array( + RuleMigrationTaskStats.merge( + z.object({ + /** + * The migration id + */ + migration_id: z.string(), + }) + ) +); 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 9ec825389a52b..c9841856a6914 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 @@ -48,8 +48,6 @@ components: description: The migrated elastic rule. required: - title - - query - - query_language properties: title: type: string @@ -68,7 +66,6 @@ components: description: The translated elastic query language. enum: - esql - default: esql prebuilt_rule_id: type: string description: The Elastic prebuilt rule id matched. @@ -84,32 +81,36 @@ components: - migration_id - original_rule - status + - created_by properties: - "@timestamp": + '@timestamp': type: string description: The moment of creation migration_id: type: string description: The migration id. + created_by: + type: string + description: The username of the user who created the migration. original_rule: $ref: '#/components/schemas/OriginalRule' elastic_rule: $ref: '#/components/schemas/ElasticRule' - translation_state: + translation_result: type: string - description: The translation state. - enum: - - complete + description: The rule translation result. + enum: # should match SiemMigrationRuleTranslationResult enum at ../constants.ts + - full - partial - untranslatable status: type: string - description: The status of the rule migration. + description: The status of the rule migration process. enum: # should match SiemMigrationsStatus enum at ../constants.ts - pending - processing - - finished - - error + - completed + - failed default: pending comments: type: array @@ -122,3 +123,60 @@ components: updated_by: type: string description: The user who last updated the migration + + RuleMigrationTaskStats: + type: object + description: The rule migration task stats object. + required: + - status + - rules + properties: + status: + type: string + description: Indicates if the migration task status. + enum: + - ready + - running + - stopped + - finished + rules: + type: object + description: The rules migration stats. + required: + - total + - pending + - processing + - completed + - failed + properties: + total: + type: integer + description: The total number of rules to migrate. + pending: + type: integer + description: The number of rules that are pending migration. + processing: + type: integer + description: The number of rules that are being migrated. + completed: + type: integer + description: The number of rules that have been migrated successfully. + failed: + type: integer + description: The number of rules that have failed migration. + last_updated_at: + type: string + description: The moment of the last update. + + RuleMigrationAllTaskStats: + type: array + items: + allOf: + - $ref: '#/components/schemas/RuleMigrationTaskStats' + - type: object + required: + - migration_id + properties: + migration_id: + type: string + description: The migration id diff --git a/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml b/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml index 1c7be495492c6..1dfa9becae7db 100644 --- a/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml @@ -770,6 +770,8 @@ components: EngineDescriptor: type: object properties: + error: + type: object fieldHistoryLength: type: integer filter: diff --git a/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml b/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml index 9d736030856d9..a941f7215a972 100644 --- a/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml @@ -770,6 +770,8 @@ components: EngineDescriptor: type: object properties: + error: + type: object fieldHistoryLength: type: integer filter: diff --git a/x-pack/plugins/security_solution/kibana.jsonc b/x-pack/plugins/security_solution/kibana.jsonc index e48a9794b7e5c..0e713bc095888 100644 --- a/x-pack/plugins/security_solution/kibana.jsonc +++ b/x-pack/plugins/security_solution/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/security-solution-plugin", - "owner": "@elastic/security-solution", + "owner": [ + "@elastic/security-solution" + ], + "group": "security", + "visibility": "private", "plugin": { "id": "securitySolution", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "securitySolution" @@ -54,7 +58,8 @@ "savedSearch", "unifiedDocViewer", "charts", - "entityManager" + "entityManager", + "inference" ], "optionalPlugins": [ "encryptedSavedObjects", diff --git a/x-pack/plugins/security_solution/public/app/solution_navigation/links/app_links.ts b/x-pack/plugins/security_solution/public/app/solution_navigation/links/app_links.ts index f6a51f1d25f4f..e0b9016f0e6b8 100644 --- a/x-pack/plugins/security_solution/public/app/solution_navigation/links/app_links.ts +++ b/x-pack/plugins/security_solution/public/app/solution_navigation/links/app_links.ts @@ -8,11 +8,7 @@ import { SecurityPageName } from '@kbn/security-solution-navigation'; import { cloneDeep, remove, find } from 'lodash'; import type { AppLinkItems, LinkItem } from '../../../common/links/types'; -import { - createInvestigationsLinkFromNotes, - createInvestigationsLinkFromTimeline, - updateInvestigationsLinkFromNotes, -} from './sections/investigations_links'; +import { createInvestigationsLink, createTimelineLink } from './sections/investigations_links'; import { mlAppLink } from './sections/ml_links'; import { createAssetsLinkFromManage } from './sections/assets_links'; import { createSettingsLinksFromManage } from './sections/settings_links'; @@ -24,25 +20,6 @@ import { createSettingsLinksFromManage } from './sections/settings_links'; export const solutionAppLinksSwitcher = (appLinks: AppLinkItems): AppLinkItems => { const solutionAppLinks = cloneDeep(appLinks) as LinkItem[]; - // Remove timeline link - const [timelineLinkItem] = remove(solutionAppLinks, { id: SecurityPageName.timelines }); - if (timelineLinkItem) { - solutionAppLinks.push(createInvestigationsLinkFromTimeline(timelineLinkItem)); - } - - // Remove note link - const investigationsLinkItem = find(solutionAppLinks, { id: SecurityPageName.investigations }); - const [noteLinkItem] = remove(solutionAppLinks, { id: SecurityPageName.notes }); - if (noteLinkItem) { - if (!investigationsLinkItem) { - solutionAppLinks.push(createInvestigationsLinkFromNotes(noteLinkItem)); - } else { - solutionAppLinks.push( - updateInvestigationsLinkFromNotes(investigationsLinkItem, noteLinkItem) - ); - } - } - // Remove manage link const [manageLinkItem] = remove(solutionAppLinks, { id: SecurityPageName.administration }); @@ -51,6 +28,22 @@ export const solutionAppLinksSwitcher = (appLinks: AppLinkItems): AppLinkItems = solutionAppLinks.push(...createSettingsLinksFromManage(manageLinkItem)); } + // Create investigations link + const investigationsLinks = []; + const [timelineLinkItem] = remove(solutionAppLinks, { id: SecurityPageName.timelines }); + if (timelineLinkItem) { + investigationsLinks.push(createTimelineLink(timelineLinkItem)); + } + if (manageLinkItem) { + const noteLinkItem = find(manageLinkItem.links, { id: SecurityPageName.notes }); + if (noteLinkItem) { + investigationsLinks.push(noteLinkItem); + } + } + if (investigationsLinks.length > 0) { + solutionAppLinks.push(createInvestigationsLink(investigationsLinks)); + } + // Add ML link solutionAppLinks.push(mlAppLink); diff --git a/x-pack/plugins/security_solution/public/app/solution_navigation/links/sections/investigations_links.ts b/x-pack/plugins/security_solution/public/app/solution_navigation/links/sections/investigations_links.ts index 1e2fe4dc5cf36..ddcd88667d967 100644 --- a/x-pack/plugins/security_solution/public/app/solution_navigation/links/sections/investigations_links.ts +++ b/x-pack/plugins/security_solution/public/app/solution_navigation/links/sections/investigations_links.ts @@ -24,37 +24,16 @@ const investigationsAppLink: LinkItem = { links: [], // timeline and note links are added via the methods below }; -export const createInvestigationsLinkFromTimeline = (timelineLink: LinkItem): LinkItem => { - return { - ...investigationsAppLink, - links: [ - { ...timelineLink, description: i18n.TIMELINE_DESCRIPTION, landingIcon: IconTimelineLazy }, - ], - }; -}; - -export const createInvestigationsLinkFromNotes = (noteLink: LinkItem): LinkItem => { - return { - ...investigationsAppLink, - links: [{ ...noteLink, description: i18n.NOTE_DESCRIPTION, landingIcon: IconTimelineLazy }], - }; -}; +export const createInvestigationsLink = (links: LinkItem[]): LinkItem => ({ + ...investigationsAppLink, + links, +}); -export const updateInvestigationsLinkFromNotes = ( - investigationsLink: LinkItem, - noteLink: LinkItem -): LinkItem => { - const currentLinks = investigationsLink.links ?? []; - currentLinks.push({ - ...noteLink, - description: i18n.NOTE_DESCRIPTION, - landingIcon: 'filebeatApp', - }); - return { - ...investigationsLink, - links: currentLinks, - }; -}; +export const createTimelineLink = (timelineLink: LinkItem): LinkItem => ({ + ...timelineLink, + description: i18n.TIMELINE_DESCRIPTION, + landingIcon: IconTimelineLazy, +}); // navLinks define the navigation links for the Security Solution pages and External pages as well export const investigationsNavLinks: SolutionNavLink[] = [ diff --git a/x-pack/plugins/security_solution/public/app/solution_navigation/links/sections/investigations_translations.ts b/x-pack/plugins/security_solution/public/app/solution_navigation/links/sections/investigations_translations.ts index 931c3c20d4002..55c6fe74f846d 100644 --- a/x-pack/plugins/security_solution/public/app/solution_navigation/links/sections/investigations_translations.ts +++ b/x-pack/plugins/security_solution/public/app/solution_navigation/links/sections/investigations_translations.ts @@ -21,14 +21,6 @@ export const TIMELINE_DESCRIPTION = i18n.translate( } ); -export const NOTE_DESCRIPTION = i18n.translate( - 'xpack.securitySolution.navLinks.investigations.note.title', - { - defaultMessage: - 'Oversee, revise, and revisit the notes attached to alerts, events and Timelines.', - } -); - export const OSQUERY_TITLE = i18n.translate( 'xpack.securitySolution.navLinks.investigations.osquery.title', { diff --git a/x-pack/plugins/security_solution/public/app_links.ts b/x-pack/plugins/security_solution/public/app_links.ts index 481b58949ed24..dca76b1c37f70 100644 --- a/x-pack/plugins/security_solution/public/app_links.ts +++ b/x-pack/plugins/security_solution/public/app_links.ts @@ -6,7 +6,6 @@ */ import type { CoreStart } from '@kbn/core/public'; -import { links as notesLink } from './notes/links'; import { links as attackDiscoveryLinks } from './attack_discovery/links'; import type { AppLinkItems } from './common/links/types'; import { indicatorsLinks } from './threat_intelligence/links'; @@ -36,7 +35,6 @@ export const appLinks: AppLinkItems = Object.freeze([ rulesLinks, onboardingLinks, managementLinks, - notesLink, ]); export const getFilteredLinks = async ( @@ -57,6 +55,5 @@ export const getFilteredLinks = async ( rulesLinks, onboardingLinks, managementFilteredLinks, - notesLink, ]); }; diff --git a/x-pack/plugins/security_solution/public/cases/pages/index.test.tsx b/x-pack/plugins/security_solution/public/cases/pages/index.test.tsx deleted file mode 100644 index 94fc9063f042e..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/pages/index.test.tsx +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { Cases } from '.'; -import { Router } from '@kbn/shared-ux-router'; -import { render } from '@testing-library/react'; -import { TestProviders } from '../../common/mock'; -import { useTourContext } from '../../common/components/guided_onboarding_tour'; -import { - AlertsCasesTourSteps, - SecurityStepId, -} from '../../common/components/guided_onboarding_tour/tour_config'; - -jest.mock('../../common/components/guided_onboarding_tour'); -jest.mock('../../common/lib/kibana'); - -type Action = 'PUSH' | 'POP' | 'REPLACE'; -const pop: Action = 'POP'; -const location = { - pathname: '/network', - search: '', - state: '', - hash: '', -}; -const mockHistory = { - length: 2, - location, - action: pop, - push: jest.fn(), - replace: jest.fn(), - go: jest.fn(), - goBack: jest.fn(), - goForward: jest.fn(), - block: jest.fn(), - createHref: jest.fn(), - listen: jest.fn(), -}; - -describe('cases page in security', () => { - const endTourStep = jest.fn(); - beforeEach(() => { - (useTourContext as jest.Mock).mockReturnValue({ - activeStep: AlertsCasesTourSteps.viewCase, - incrementStep: () => null, - endTourStep, - isTourShown: () => true, - }); - jest.clearAllMocks(); - }); - - it('calls endTour on cases details page when SecurityStepId.alertsCases tour is active and step is AlertsCasesTourSteps.viewCase', () => { - render( - - - , - { wrapper: TestProviders } - ); - - expect(endTourStep).toHaveBeenCalledWith(SecurityStepId.alertsCases); - }); - - it('does not call endTour on cases details page when SecurityStepId.alertsCases tour is not active', () => { - (useTourContext as jest.Mock).mockReturnValue({ - activeStep: AlertsCasesTourSteps.viewCase, - incrementStep: () => null, - endTourStep, - isTourShown: () => false, - }); - render( - - - , - { wrapper: TestProviders } - ); - - expect(endTourStep).not.toHaveBeenCalled(); - }); - - it('does not call endTour on cases details page when SecurityStepId.alertsCases tour is active and step is not AlertsCasesTourSteps.viewCase', () => { - (useTourContext as jest.Mock).mockReturnValue({ - activeStep: AlertsCasesTourSteps.expandEvent, - incrementStep: () => null, - endTourStep, - isTourShown: () => true, - }); - - render( - - - , - { wrapper: TestProviders } - ); - - expect(endTourStep).not.toHaveBeenCalled(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/cases/pages/index.tsx b/x-pack/plugins/security_solution/public/cases/pages/index.tsx index c873e48e4975f..77fc7db0c0a8a 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/index.tsx @@ -5,22 +5,15 @@ * 2.0. */ -import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { useDispatch } from 'react-redux'; import type { CaseViewRefreshPropInterface } from '@kbn/cases-plugin/common'; import { CaseMetricsFeature } from '@kbn/cases-plugin/common'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { CaseDetailsRefreshContext } from '../../common/components/endpoint'; import { DocumentDetailsRightPanelKey } from '../../flyout/document_details/shared/constants/panel_keys'; -import { useTourContext } from '../../common/components/guided_onboarding_tour'; -import { - AlertsCasesTourSteps, - SecurityStepId, -} from '../../common/components/guided_onboarding_tour/tour_config'; +import { RulePanelKey } from '../../flyout/rule_details/right'; import { TimelineId } from '../../../common/types/timeline'; - -import { getRuleDetailsUrl, useFormatUrl } from '../../common/components/link_to'; - import { useKibana, useNavigation } from '../../common/lib/kibana'; import { APP_ID, CASES_PATH, SecurityPageName } from '../../../common/constants'; import { timelineActions } from '../../timelines/store'; @@ -38,17 +31,8 @@ const CaseContainerComponent: React.FC = () => { const { getAppUrl, navigateTo } = useNavigation(); const userCasesPermissions = cases.helpers.canUseCases([APP_ID]); const dispatch = useDispatch(); - const { formatUrl: detectionsFormatUrl, search: detectionsUrlSearch } = useFormatUrl( - SecurityPageName.rules - ); const { openFlyout } = useExpandableFlyoutApi(); - const getDetectionsRuleDetailsHref = useCallback( - (ruleId: string | null | undefined) => - detectionsFormatUrl(getRuleDetailsUrl(ruleId ?? '', detectionsUrlSearch)), - [detectionsFormatUrl, detectionsUrlSearch] - ); - const interactionsUpsellingMessage = useUpsellingMessage('investigation_guide_interactions'); const showAlertDetails = useCallback( @@ -71,6 +55,15 @@ const CaseContainerComponent: React.FC = () => { [openFlyout, telemetry] ); + const onRuleDetailsClick = useCallback( + (ruleId: string | null | undefined) => { + if (ruleId) { + openFlyout({ right: { id: RulePanelKey, params: { ruleId } } }); + } + }, + [openFlyout] + ); + const { onLoad: onAlertsTableLoaded } = useFetchNotes(); const endpointDetailsHref = (endpointId: string) => @@ -82,16 +75,6 @@ const CaseContainerComponent: React.FC = () => { }); const refreshRef = useRef(null); - const { activeStep, endTourStep, isTourShown } = useTourContext(); - - const isTourActive = useMemo( - () => activeStep === AlertsCasesTourSteps.viewCase && isTourShown(SecurityStepId.alertsCases), - [activeStep, isTourShown] - ); - - useEffect(() => { - if (isTourActive) endTourStep(SecurityStepId.alertsCases); - }, [endTourStep, isTourActive]); useEffect(() => { dispatch( @@ -138,16 +121,7 @@ const CaseContainerComponent: React.FC = () => { }, }, ruleDetailsNavigation: { - href: getDetectionsRuleDetailsHref, - onClick: async (ruleId: string | null | undefined, e) => { - if (e) { - e.preventDefault(); - } - return navigateTo({ - deepLinkId: SecurityPageName.rules, - path: getRuleDetailsUrl(ruleId ?? ''), - }); - }, + onClick: onRuleDetailsClick, }, showAlertDetails, timelineIntegration: { diff --git a/x-pack/plugins/security_solution/public/cloud_security_posture/components/misconfiguration/misconfiguration_preview.tsx b/x-pack/plugins/security_solution/public/cloud_security_posture/components/misconfiguration/misconfiguration_preview.tsx index 433e493493e37..b133e9db22050 100644 --- a/x-pack/plugins/security_solution/public/cloud_security_posture/components/misconfiguration/misconfiguration_preview.tsx +++ b/x-pack/plugins/security_solution/public/cloud_security_posture/components/misconfiguration/misconfiguration_preview.tsx @@ -12,18 +12,17 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, useEuiTheme, EuiTitle } import { FormattedMessage } from '@kbn/i18n-react'; import { DistributionBar } from '@kbn/security-solution-distribution-bar'; import { useMisconfigurationPreview } from '@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'; -import { euiThemeVars } from '@kbn/ui-theme'; import { i18n } from '@kbn/i18n'; -import { ExpandablePanel } from '@kbn/security-solution-common'; import { buildEntityFlyoutPreviewQuery } from '@kbn/cloud-security-posture-common'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { useVulnerabilitiesPreview } from '@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'; -import { hasVulnerabilitiesData } from '@kbn/cloud-security-posture'; +import { hasVulnerabilitiesData, statusColors } from '@kbn/cloud-security-posture'; import { METRIC_TYPE } from '@kbn/analytics'; import { ENTITY_FLYOUT_WITH_MISCONFIGURATION_VISIT, uiMetricService, } from '@kbn/cloud-security-posture-common/utils/ui_metrics'; +import { ExpandablePanel } from '../../../flyout/shared/components/expandable_panel'; import { CspInsightLeftPanelSubTab, EntityDetailsLeftPanelTab, @@ -51,7 +50,7 @@ export const getFindingsStats = (passedFindingsStats: number, failedFindingsStat } ), count: passedFindingsStats, - color: euiThemeVars.euiColorSuccess, + color: statusColors.passed, }, { key: i18n.translate( @@ -61,7 +60,7 @@ export const getFindingsStats = (passedFindingsStats: number, failedFindingsStat } ), count: failedFindingsStats, - color: euiThemeVars.euiColorVis9, + color: statusColors.failed, }, ]; }; @@ -70,14 +69,10 @@ const MisconfigurationPreviewScore = ({ passedFindings, failedFindings, euiTheme, - numberOfPassedFindings, - numberOfFailedFindings, }: { passedFindings: number; failedFindings: number; euiTheme: EuiThemeComputed<{}>; - numberOfPassedFindings?: number; - numberOfFailedFindings?: number; }) => { return ( diff --git a/x-pack/plugins/security_solution/public/cloud_security_posture/components/vulnerabilities/vulnerabilities_preview.tsx b/x-pack/plugins/security_solution/public/cloud_security_posture/components/vulnerabilities/vulnerabilities_preview.tsx index 2c162ba9db894..1caa740662ad8 100644 --- a/x-pack/plugins/security_solution/public/cloud_security_posture/components/vulnerabilities/vulnerabilities_preview.tsx +++ b/x-pack/plugins/security_solution/public/cloud_security_posture/components/vulnerabilities/vulnerabilities_preview.tsx @@ -12,7 +12,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, useEuiTheme, EuiTitle } import { FormattedMessage } from '@kbn/i18n-react'; import { DistributionBar } from '@kbn/security-solution-distribution-bar'; import { useVulnerabilitiesPreview } from '@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'; -import { ExpandablePanel } from '@kbn/security-solution-common'; import { buildEntityFlyoutPreviewQuery, getAbbreviatedNumber, @@ -25,6 +24,7 @@ import { uiMetricService, } from '@kbn/cloud-security-posture-common/utils/ui_metrics'; import { METRIC_TYPE } from '@kbn/analytics'; +import { ExpandablePanel } from '../../../flyout/shared/components/expandable_panel'; import { EntityDetailsLeftPanelTab } from '../../../flyout/entity_details/shared/components/left_panel/left_panel_header'; import { HostDetailsPanelKey } from '../../../flyout/entity_details/host_details_left'; import { useRiskScore } from '../../../entity_analytics/api/hooks/use_risk_score'; diff --git a/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.tsx b/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.tsx index 5ec3e0c2d0e3d..aebf34f094027 100644 --- a/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.tsx @@ -19,12 +19,12 @@ import type { SetEventsLoading, ControlColumnProps, } from '../../../../../common/types'; -import { getMappedNonEcsValue } from '../../../../timelines/components/timeline/body/data_driven_columns'; import type { TimelineItem, TimelineNonEcsData } from '../../../../../common/search_strategy'; import type { ColumnHeaderOptions, OnRowSelected } from '../../../../../common/types/timeline'; import { useIsExperimentalFeatureEnabled } from '../../../hooks/use_experimental_features'; import { useTourContext } from '../../guided_onboarding_tour'; import { AlertsCasesTourSteps, SecurityStepId } from '../../guided_onboarding_tour/tour_config'; +import { getMappedNonEcsValue } from '../../../utils/get_mapped_non_ecs_value'; export type RowActionProps = EuiDataGridCellValueElementProps & { columnHeaders: ColumnHeaderOptions[]; diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index e251370c7e4d3..fb13cf4a3ceed 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -36,7 +36,6 @@ import type { EuiTheme } from '@kbn/kibana-react-plugin/common'; import type { EuiDataGridRowHeightsOptions } from '@elastic/eui'; import type { RunTimeMappings } from '@kbn/timelines-plugin/common/search_strategy'; import { ALERTS_TABLE_VIEW_SELECTION_KEY } from '../../../../common/constants'; -import type { Sort } from '../../../timelines/components/timeline/body/sort'; import type { ControlColumnProps, OnRowSelected, @@ -44,7 +43,7 @@ import type { SetEventsDeleted, SetEventsLoading, } from '../../../../common/types'; -import type { RowRenderer } from '../../../../common/types/timeline'; +import type { RowRenderer, SortColumnTimeline as Sort } from '../../../../common/types/timeline'; import { InputsModelId } from '../../store/inputs/constants'; import type { State } from '../../store'; import { inputsActions } from '../../store/actions'; diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.test.tsx b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.test.tsx index cb99772343c7b..e3f85df557e80 100644 --- a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.test.tsx @@ -104,8 +104,8 @@ describe('useTourContext', () => { wrapper: TourContextProvider, }); await waitForNextUpdate(); - result.current.setStep(tourId, 7); - expect(result.current.activeStep).toBe(7); + result.current.setStep(tourId, 6); + expect(result.current.activeStep).toBe(6); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_config.ts b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_config.ts index dd04b76d061a8..31080d7ea49f0 100644 --- a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_config.ts +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_config.ts @@ -22,7 +22,6 @@ export enum AlertsCasesTourSteps { addAlertToCase = 4, createCase = 5, submitCase = 6, - viewCase = 7, } export type StepConfig = Pick< @@ -72,7 +71,6 @@ export const hiddenWhenCaseFlyoutExpanded: Record { }); const mockCall = { ...mockTourStep.mock.calls[0][0] }; expect(mockCall.step).toEqual(1); - expect(mockCall.stepsTotal).toEqual(7); + expect(mockCall.stepsTotal).toEqual(6); }); it('forces the render for createCase step of the SecurityStepId.alertsCases tour step', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/header_actions/header_actions.tsx b/x-pack/plugins/security_solution/public/common/components/header_actions/header_actions.tsx index 1341cb72104d8..db88061d86013 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_actions/header_actions.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_actions/header_actions.tsx @@ -10,9 +10,9 @@ import { EuiButtonIcon, EuiToolTip, EuiCheckbox } from '@elastic/eui'; import { useDispatch } from 'react-redux'; import styled from 'styled-components'; +import { isFullScreen } from '../../../timelines/components/timeline/helpers'; import type { HeaderActionProps } from '../../../../common/types'; import { TimelineId } from '../../../../common/types'; -import { isFullScreen } from '../../../timelines/components/timeline/body/column_headers'; import { isActiveTimeline } from '../../../helpers'; import { getColumnHeader } from '../../../timelines/components/timeline/body/column_headers/helpers'; import { timelineActions } from '../../../timelines/store'; diff --git a/x-pack/plugins/security_solution/public/common/components/line_clamp/index.tsx b/x-pack/plugins/security_solution/public/common/components/line_clamp/index.tsx index 69e26715206e6..f92fd495dd357 100644 --- a/x-pack/plugins/security_solution/public/common/components/line_clamp/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/line_clamp/index.tsx @@ -13,7 +13,8 @@ import { useIsOverflow } from '../../hooks/use_is_overflow'; import * as i18n from './translations'; const LINE_CLAMP = 3; -const LINE_CLAMP_HEIGHT = 5.5; +const LINE_CLAMP_HEIGHT = '5.5em'; +const MAX_HEIGHT = '33vh'; const ReadMore = styled(EuiButtonEmpty)` span.euiButtonContent { @@ -21,26 +22,33 @@ const ReadMore = styled(EuiButtonEmpty)` } `; -const ExpandedContent = styled.div` - max-height: 33vh; +const ExpandedContent = styled.div<{ maxHeight: string }>` + max-height: ${({ maxHeight }) => maxHeight}; overflow-wrap: break-word; overflow-x: hidden; overflow-y: auto; `; -const StyledLineClamp = styled.div<{ lineClampHeight: number }>` +const StyledLineClamp = styled.div<{ lineClampHeight: string; lineClamp: number }>` display: -webkit-box; - -webkit-line-clamp: ${LINE_CLAMP}; + -webkit-line-clamp: ${({ lineClamp }) => lineClamp}; -webkit-box-orient: vertical; overflow: hidden; - max-height: ${({ lineClampHeight }) => lineClampHeight}em; - height: ${({ lineClampHeight }) => lineClampHeight}em; + max-height: ${({ lineClampHeight }) => lineClampHeight}; + height: ${({ lineClampHeight }) => lineClampHeight}; `; const LineClampComponent: React.FC<{ children: ReactNode; - lineClampHeight?: number; -}> = ({ children, lineClampHeight = LINE_CLAMP_HEIGHT }) => { + lineClampHeight?: string; + lineClamp?: number; + maxHeight?: string; +}> = ({ + children, + lineClampHeight = LINE_CLAMP_HEIGHT, + lineClamp = LINE_CLAMP, + maxHeight = MAX_HEIGHT, +}) => { const [isExpanded, setIsExpanded] = useState(null); const [isOverflow, descriptionRef] = useIsOverflow(children); @@ -51,7 +59,7 @@ const LineClampComponent: React.FC<{ if (isExpanded) { return ( <> - +

{children}

{isOverflow && ( @@ -70,6 +78,7 @@ const LineClampComponent: React.FC<{ data-test-subj="styled-line-clamp" ref={descriptionRef} lineClampHeight={lineClampHeight} + lineClamp={lineClamp} > {children} diff --git a/x-pack/plugins/security_solution/public/common/components/links/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/links/index.test.tsx index dad30ee050dda..ab5507b958e23 100644 --- a/x-pack/plugins/security_solution/public/common/components/links/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/links/index.test.tsx @@ -10,10 +10,7 @@ import { mount, shallow } from 'enzyme'; import React from 'react'; import { removeExternalLinkText } from '@kbn/securitysolution-io-ts-utils'; import { mountWithIntl } from '@kbn/test-jest-helpers'; - import { encodeIpv6 } from '../../lib/helpers'; -import { useUiSetting$ } from '../../lib/kibana'; - import { GoogleLink, HostDetailsLink, @@ -26,16 +23,33 @@ import { DEFAULT_NUMBER_OF_LINK, ExternalLink, SecuritySolutionLinkButton, + CaseDetailsLink, } from '.'; import { SecurityPageName } from '../../../app/types'; import { mockGetAppUrl, mockNavigateTo } from '@kbn/security-solution-navigation/mocks/navigation'; +import { APP_UI_ID } from '../../../../common'; jest.mock('@kbn/security-solution-navigation/src/navigation'); jest.mock('../navigation/use_url_state_query_params'); - jest.mock('../../../overview/components/events_by_dataset'); -jest.mock('../../lib/kibana'); +const mockNavigateToApp = jest.fn(); +const mockUseUiSetting$ = jest.fn(); +jest.mock('../../lib/kibana', () => { + const original = jest.requireActual('../../lib/kibana'); + return { + ...original, + useKibana: () => ({ + services: { + ...original.useKibana().services, + application: { + navigateToApp: mockNavigateToApp, + }, + }, + }), + useUiSetting$: () => mockUseUiSetting$(), + }; +}); mockGetAppUrl.mockImplementation(({ path }) => path); @@ -96,6 +110,55 @@ describe('Custom Links', () => { }); }); + describe('CaseDetailsLink', () => { + test('should render a link with detailName as displayed text', () => { + const wrapper = mountWithIntl(); + expect(wrapper.text()).toEqual('name'); + expect(wrapper.find('EuiLink').last().prop('aria-label')).toEqual( + 'click to visit case with title name' + ); + expect(wrapper.find('EuiLink').last().prop('href')).toEqual('/name'); + }); + + test('should render a link with children instead of detailName', () => { + const wrapper = mountWithIntl( + +
{'children'}
+
+ ); + expect(wrapper.text()).toEqual('children'); + }); + + test('should render a link with aria-label using title prop instead of detailName', () => { + const wrapper = mountWithIntl(); + expect(wrapper.find('EuiLink').last().prop('aria-label')).toEqual( + 'click to visit case with title title' + ); + }); + + it('should call navigateToApp with correct values', () => { + const wrapper = mountWithIntl(); + wrapper.find('a[href="/name"]').simulate('click'); + + expect(mockNavigateToApp).toHaveBeenCalledWith(APP_UI_ID, { + deepLinkId: SecurityPageName.case, + path: '/name', + openInNewTab: false, + }); + }); + + it('should call navigateToApp with value of openInNewTab prop', () => { + const wrapper = mountWithIntl(); + wrapper.find('a[href="/name"]').simulate('click'); + + expect(mockNavigateToApp).toHaveBeenCalledWith(APP_UI_ID, { + deepLinkId: SecurityPageName.case, + path: '/name', + openInNewTab: true, + }); + }); + }); + describe('GoogleLink', () => { test('it renders text passed in as value', () => { const wrapper = mountWithIntl( @@ -309,8 +372,7 @@ describe('Custom Links', () => { describe('links property', () => { beforeEach(() => { - (useUiSetting$ as jest.Mock).mockReset(); - (useUiSetting$ as jest.Mock).mockReturnValue([mockDefaultReputationLinks]); + mockUseUiSetting$.mockReturnValue([mockDefaultReputationLinks]); }); test('it renders default link text', () => { @@ -321,8 +383,7 @@ describe('Custom Links', () => { }); test('it renders customized link text', () => { - (useUiSetting$ as jest.Mock).mockReset(); - (useUiSetting$ as jest.Mock).mockReturnValue([mockCustomizedReputationLinks]); + mockUseUiSetting$.mockReturnValue([mockCustomizedReputationLinks]); const wrapper = shallow(); wrapper.find('[data-test-subj="externalLink"]').forEach((node, idx) => { expect(node.at(idx).text()).toEqual(mockCustomizedReputationLinks[idx].name); @@ -341,12 +402,7 @@ describe('Custom Links', () => { describe('number of links', () => { beforeAll(() => { - (useUiSetting$ as jest.Mock).mockReset(); - (useUiSetting$ as jest.Mock).mockReturnValue([mockCustomizedReputationLinks]); - }); - - afterEach(() => { - (useUiSetting$ as jest.Mock).mockClear(); + mockUseUiSetting$.mockReturnValue([mockCustomizedReputationLinks]); }); test('it renders correct number of links by default', () => { @@ -364,8 +420,7 @@ describe('Custom Links', () => { }); test('it renders correct number of visible link', () => { - (useUiSetting$ as jest.Mock).mockReset(); - (useUiSetting$ as jest.Mock).mockReturnValue([mockCustomizedReputationLinks]); + mockUseUiSetting$.mockReturnValue([mockCustomizedReputationLinks]); const wrapper = mountWithIntl( @@ -374,8 +429,7 @@ describe('Custom Links', () => { }); test('it renders correct number of tooltips for visible links', () => { - (useUiSetting$ as jest.Mock).mockReset(); - (useUiSetting$ as jest.Mock).mockReturnValue([mockCustomizedReputationLinks]); + mockUseUiSetting$.mockReturnValue([mockCustomizedReputationLinks]); const wrapper = mountWithIntl( @@ -391,12 +445,9 @@ describe('Custom Links', () => { ]; const mockInvalidLinksNoUrl = [{ name: 'Link 1' }]; const mockInvalidUrl = [{ name: 'Link 1', url_template: "" }]; - afterEach(() => { - (useUiSetting$ as jest.Mock).mockReset(); - }); test('it filters empty object', () => { - (useUiSetting$ as jest.Mock).mockReturnValue([mockInvalidLinksEmptyObj]); + mockUseUiSetting$.mockReturnValue([mockInvalidLinksEmptyObj]); const wrapper = mountWithIntl( @@ -405,7 +456,7 @@ describe('Custom Links', () => { }); test('it filters object without name property', () => { - (useUiSetting$ as jest.Mock).mockReturnValue([mockInvalidLinksNoName]); + mockUseUiSetting$.mockReturnValue([mockInvalidLinksNoName]); const wrapper = mountWithIntl( @@ -414,7 +465,7 @@ describe('Custom Links', () => { }); test('it filters object without url_template property', () => { - (useUiSetting$ as jest.Mock).mockReturnValue([mockInvalidLinksNoUrl]); + mockUseUiSetting$.mockReturnValue([mockInvalidLinksNoUrl]); const wrapper = mountWithIntl( @@ -423,7 +474,7 @@ describe('Custom Links', () => { }); test('it filters object with invalid url', () => { - (useUiSetting$ as jest.Mock).mockReturnValue([mockInvalidUrl]); + mockUseUiSetting$.mockReturnValue([mockInvalidUrl]); const wrapper = mountWithIntl( @@ -434,12 +485,7 @@ describe('Custom Links', () => { describe('external icon', () => { beforeAll(() => { - (useUiSetting$ as jest.Mock).mockReset(); - (useUiSetting$ as jest.Mock).mockReturnValue([mockCustomizedReputationLinks]); - }); - - afterEach(() => { - (useUiSetting$ as jest.Mock).mockClear(); + mockUseUiSetting$.mockReturnValue([mockCustomizedReputationLinks]); }); test('it renders correct number of external icons by default', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/links/index.tsx b/x-pack/plugins/security_solution/public/common/components/links/index.tsx index 9d615d80be63f..0648dd60d84f9 100644 --- a/x-pack/plugins/security_solution/public/common/components/links/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/links/index.tsx @@ -8,11 +8,9 @@ import type { EuiButtonEmpty, EuiButtonIcon } from '@elastic/eui'; import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiToolTip } from '@elastic/eui'; import type { SyntheticEvent, MouseEvent } from 'react'; -import React, { useMemo, useCallback, useEffect } from 'react'; +import React, { useMemo, useCallback } from 'react'; import { isArray, isNil } from 'lodash/fp'; -import { GuidedOnboardingTourStep } from '../guided_onboarding_tour/tour_step'; -import { AlertsCasesTourSteps, SecurityStepId } from '../guided_onboarding_tour/tour_config'; -import { useTourContext } from '../guided_onboarding_tour'; +import type { NavigateToAppOptions } from '@kbn/core-application-browser'; import { IP_REPUTATION_LINKS_SETTING, APP_UI_ID } from '../../../../common/constants'; import { encodeIpv6 } from '../../lib/helpers'; import { @@ -306,27 +304,19 @@ export interface CaseDetailsLinkComponentProps { */ title?: string; /** - * Link index + * If true, will open the app in new tab, will share session information via window.open if base */ - index?: number; + openInNewTab?: NavigateToAppOptions['openInNewTab']; } const CaseDetailsLinkComponent: React.FC = ({ - index, children, detailName, title, + openInNewTab = false, }) => { const { formatUrl, search } = useFormatUrl(SecurityPageName.case); const { navigateToApp } = useKibana().services.application; - const { activeStep, isTourShown } = useTourContext(); - const isTourStepActive = useMemo( - () => - activeStep === AlertsCasesTourSteps.viewCase && - isTourShown(SecurityStepId.alertsCases) && - index === 0, - [activeStep, index, isTourShown] - ); const goToCaseDetails = useCallback( async (ev?: SyntheticEvent) => { @@ -334,32 +324,21 @@ const CaseDetailsLinkComponent: React.FC = ({ return navigateToApp(APP_UI_ID, { deepLinkId: SecurityPageName.case, path: getCaseDetailsUrl({ id: detailName, search }), + openInNewTab, }); }, - [detailName, navigateToApp, search] + [detailName, navigateToApp, openInNewTab, search] ); - useEffect(() => { - if (isTourStepActive) - document.querySelector(`[tour-step="RelatedCases-accordion"]`)?.scrollIntoView(); - }, [isTourStepActive]); - return ( - - - {children ? children : detailName} - - + {children ? children : detailName} + ); }; export const CaseDetailsLink = React.memo(CaseDetailsLinkComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_embeddable.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_embeddable.tsx index a72675d381404..0461cb8888be5 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_embeddable.tsx +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_embeddable.tsx @@ -34,24 +34,26 @@ import { useEmbeddableInspect } from './use_embeddable_inspect'; import { useVisualizationResponse } from './use_visualization_response'; import { useInspect } from '../inspect/use_inspect'; -const HOVER_ACTIONS_PADDING = 24; const DISABLED_ACTIONS = ['ACTION_CUSTOMIZE_PANEL']; const LensComponentWrapper = styled.div<{ $height?: number; width?: string | number; - $addHoverActionsPadding?: boolean; }>` height: ${({ $height }) => ($height ? `${$height}px` : 'auto')}; width: ${({ width }) => width ?? 'auto'}; - ${({ $addHoverActionsPadding }) => - $addHoverActionsPadding ? `.embPanel__header { top: ${HOVER_ACTIONS_PADDING * -1}px; }` : ''} + .embPanel { + outline: none; + } + + .embPanel__hoverActions.embPanel__hoverActionsRight { + border-radius: 6px !important; + border-bottom: 1px solid #d3dae6 !important; + } - .embPanel__header { - z-index: 2; - position: absolute; - right: 0; + .embPanel__hoverActionsAnchor .embPanel__hoverActionsWrapper { + top: -20px; } .expExpressionRenderer__expression { @@ -110,10 +112,7 @@ const LensEmbeddableComponent: React.FC = ({ title: '', }); const preferredSeriesType = (attributes?.state?.visualization as XYState)?.preferredSeriesType; - // Avoid hover actions button overlaps with its chart - const addHoverActionsPadding = - attributes?.visualizationType !== 'lnsLegacyMetric' && - attributes?.visualizationType !== 'lnsPie'; + const LensComponent = lens.EmbeddableComponent; const overrides: TypedLensByValueInput['overrides'] = useMemo( @@ -255,11 +254,7 @@ const LensEmbeddableComponent: React.FC = ({ return ( <> {attributes && searchSessionId && ( - + ; + } + | { + eventType: TelemetryEventTypes.EntityStoreEnablementToggleClicked; + schema: RootSchema; + } + | { + eventType: TelemetryEventTypes.EntityStoreDashboardInitButtonClicked; + schema: RootSchema; }; diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/events/telemetry_events.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/events/telemetry_events.ts index a0328099b9ff7..3e7c9f1138391 100644 --- a/x-pack/plugins/security_solution/public/common/lib/telemetry/events/telemetry_events.ts +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/events/telemetry_events.ts @@ -21,6 +21,8 @@ import { assetCriticalityCsvPreviewGeneratedEvent, assetCriticalityFileSelectedEvent, assetCriticalityCsvImportedEvent, + entityStoreEnablementEvent, + entityStoreInitEvent, } from './entity_analytics'; import { assistantInvokedEvent, @@ -172,6 +174,8 @@ export const telemetryEvents = [ assetCriticalityCsvPreviewGeneratedEvent, assetCriticalityFileSelectedEvent, assetCriticalityCsvImportedEvent, + entityStoreEnablementEvent, + entityStoreInitEvent, toggleRiskSummaryClickedEvent, RiskInputsExpandedFlyoutOpenedEvent, addRiskInputToTimelineClickedEvent, diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.mock.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.mock.ts index 98d6aa64bb9cb..87d4b215543dc 100644 --- a/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.mock.ts +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.mock.ts @@ -43,4 +43,6 @@ export const createTelemetryClientMock = (): jest.Mocked = reportOpenNoteInExpandableFlyoutClicked: jest.fn(), reportAddNoteFromExpandableFlyoutClicked: jest.fn(), reportPreviewRule: jest.fn(), + reportEntityStoreEnablement: jest.fn(), + reportEntityStoreInit: jest.fn(), }); diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts index e09f0a3c2eb66..689209f284dbb 100644 --- a/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts @@ -45,6 +45,8 @@ import type { ReportEventLogShowSourceEventDateRangeParams, ReportEventLogFilterByRunTypeParams, PreviewRuleParams, + ReportEntityStoreEnablementParams, + ReportEntityStoreInitParams, } from './types'; import { TelemetryEventTypes } from './constants'; @@ -216,4 +218,12 @@ export class TelemetryClient implements TelemetryClientStart { public reportPreviewRule = (params: PreviewRuleParams) => { this.analytics.reportEvent(TelemetryEventTypes.PreviewRule, params); }; + + public reportEntityStoreEnablement = (params: ReportEntityStoreEnablementParams) => { + this.analytics.reportEvent(TelemetryEventTypes.EntityStoreEnablementToggleClicked, params); + }; + + public reportEntityStoreInit = (params: ReportEntityStoreInitParams) => { + this.analytics.reportEvent(TelemetryEventTypes.EntityStoreDashboardInitButtonClicked, params); + }; } diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/types.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/types.ts index 55b91837a2585..95896bf74a6a7 100644 --- a/x-pack/plugins/security_solution/public/common/lib/telemetry/types.ts +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/types.ts @@ -32,6 +32,8 @@ import type { ReportAssetCriticalityCsvPreviewGeneratedParams, ReportAssetCriticalityFileSelectedParams, ReportAssetCriticalityCsvImportedParams, + ReportEntityStoreEnablementParams, + ReportEntityStoreInitParams, } from './events/entity_analytics/types'; import type { AssistantTelemetryEvent, @@ -78,17 +80,7 @@ export * from './events/ai_assistant/types'; export * from './events/alerts_grouping/types'; export * from './events/data_quality/types'; export * from './events/onboarding/types'; -export type { - ReportEntityAlertsClickedParams, - ReportEntityDetailsClickedParams, - ReportEntityRiskFilteredParams, - ReportRiskInputsExpandedFlyoutOpenedParams, - ReportToggleRiskSummaryClickedParams, - ReportAddRiskInputToTimelineClickedParams, - ReportAssetCriticalityCsvPreviewGeneratedParams, - ReportAssetCriticalityFileSelectedParams, - ReportAssetCriticalityCsvImportedParams, -} from './events/entity_analytics/types'; +export * from './events/entity_analytics/types'; export * from './events/document_details/types'; export * from './events/manual_rule_run/types'; export * from './events/event_log/types'; @@ -168,6 +160,9 @@ export interface TelemetryClientStart { ): void; reportAssetCriticalityCsvImported(params: ReportAssetCriticalityCsvImportedParams): void; reportCellActionClicked(params: ReportCellActionClickedParams): void; + // Entity Analytics Entity Store + reportEntityStoreEnablement(params: ReportEntityStoreEnablementParams): void; + reportEntityStoreInit(params: ReportEntityStoreInitParams): void; reportAnomaliesCountClicked(params: ReportAnomaliesCountClickedParams): void; reportDataQualityIndexChecked(params: ReportDataQualityIndexCheckedParams): void; diff --git a/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx b/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx index b42dbc3b7a0b8..23abdab4d14f9 100644 --- a/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx @@ -283,7 +283,7 @@ export const createAppRootMockRenderer = (): AppContextTestRender => { }, }); - const AppWrapper: React.FC<{ children: React.ReactElement }> = ({ children }) => ( + const AppWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( { + it('should return the correct value', () => { + const data: TimelineNonEcsData[] = [{ field: 'field1', value: ['value1'] }]; + const fieldName = 'field1'; + const result = getMappedNonEcsValue({ data, fieldName }); + expect(result).toEqual(['value1']); + }); + + it('should return undefined if item is null', () => { + const data: TimelineNonEcsData[] = [{ field: 'field1', value: ['value1'] }]; + const fieldName = 'field2'; + const result = getMappedNonEcsValue({ data, fieldName }); + expect(result).toEqual(undefined); + }); + + it('should return undefined if item.value is null', () => { + const data: TimelineNonEcsData[] = [{ field: 'field1', value: null }]; + const fieldName = 'non_existent_field'; + const result = getMappedNonEcsValue({ data, fieldName }); + expect(result).toEqual(undefined); + }); + + it('should return undefined if data is undefined', () => { + const data = undefined; + const fieldName = 'field1'; + const result = getMappedNonEcsValue({ data, fieldName }); + expect(result).toEqual(undefined); + }); + + it('should return undefined if data is empty', () => { + const data: TimelineNonEcsData[] = []; + const fieldName = 'field1'; + const result = getMappedNonEcsValue({ data, fieldName }); + expect(result).toEqual(undefined); + }); +}); + +describe('useGetMappedNonEcsValue', () => { + it('should return the correct value', () => { + const data: TimelineNonEcsData[] = [{ field: 'field1', value: ['value1'] }]; + const fieldName = 'field1'; + const { result } = renderHook(() => useGetMappedNonEcsValue({ data, fieldName })); + expect(result.current).toEqual(['value1']); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/utils/get_mapped_non_ecs_value.ts b/x-pack/plugins/security_solution/public/common/utils/get_mapped_non_ecs_value.ts new file mode 100644 index 0000000000000..e0711127e1e40 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/utils/get_mapped_non_ecs_value.ts @@ -0,0 +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. + */ + +import type { TimelineNonEcsData } from '@kbn/timelines-plugin/common'; +import { useMemo } from 'react'; + +export const getMappedNonEcsValue = ({ + data, + fieldName, +}: { + data?: TimelineNonEcsData[]; + fieldName: string; +}): string[] | undefined => { + /* + While data _should_ always be defined + There is the potential for race conditions where a component using this function + is still visible in the UI, while the data has since been removed. + To cover all scenarios where this happens we'll check for the presence of data here + */ + if (!data || data.length === 0) return undefined; + const item = data.find((d) => d.field === fieldName); + if (item != null && item.value != null) { + return item.value; + } + return undefined; +}; + +export const useGetMappedNonEcsValue = ({ + data, + fieldName, +}: { + data?: TimelineNonEcsData[]; + fieldName: string; +}): string[] | undefined => { + return useMemo(() => getMappedNonEcsValue({ data, fieldName }), [data, fieldName]); +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integrations.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integrations.test.tsx index dd08cae462a00..960df4c7de5b9 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integrations.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integrations.test.tsx @@ -499,6 +499,9 @@ describe('RelatedIntegrations form part', () => { comboBoxToggleButton: screen.getByTestId(COMBO_BOX_TOGGLE_BUTTON_TEST_ID), optionIndex: 0, }); + await waitFor(() => { + expect(screen.getByTestId(VERSION_INPUT_TEST_ID)).toHaveValue('^1.2.0'); + }); await setVersion({ input: screen.getByTestId(VERSION_INPUT_TEST_ID), value: '1.0.0' }); await submitForm(); await waitFor(() => { @@ -614,6 +617,9 @@ describe('RelatedIntegrations form part', () => { await selectFirstEuiComboBoxOption({ comboBoxToggleButton: screen.getByTestId(COMBO_BOX_TOGGLE_BUTTON_TEST_ID), }); + await waitFor(() => { + expect(screen.getByTestId(VERSION_INPUT_TEST_ID)).toHaveValue('^1.0.0'); + }); await setVersion({ input: screen.getByTestId(VERSION_INPUT_TEST_ID), value: '100' }); expect(screen.getByTestId(RELATED_INTEGRATION_ROW)).toHaveTextContent( diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/customized_prebuilt_rule_badge.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/customized_prebuilt_rule_badge.tsx index 56a559a91794a..e4b00196f4768 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/customized_prebuilt_rule_badge.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/customized_prebuilt_rule_badge.tsx @@ -10,7 +10,7 @@ import { EuiBadge } from '@elastic/eui'; import * as i18n from './translations'; import { isCustomizedPrebuiltRule } from '../../../../../common/api/detection_engine'; import type { RuleResponse } from '../../../../../common/api/detection_engine'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import { useIsPrebuiltRulesCustomizationEnabled } from '../../hooks/use_is_prebuilt_rules_customization_enabled'; interface CustomizedPrebuiltRuleBadgeProps { rule: RuleResponse | null; @@ -19,9 +19,7 @@ interface CustomizedPrebuiltRuleBadgeProps { export const CustomizedPrebuiltRuleBadge: React.FC = ({ rule, }) => { - const isPrebuiltRulesCustomizationEnabled = useIsExperimentalFeatureEnabled( - 'prebuiltRulesCustomizationEnabled' - ); + const isPrebuiltRulesCustomizationEnabled = useIsPrebuiltRulesCustomizationEnabled(); if (!isPrebuiltRulesCustomizationEnabled) { return null; @@ -31,5 +29,5 @@ export const CustomizedPrebuiltRuleBadge: React.FC{i18n.CUSTOMIZED_PREBUILT_RULE_LABEL}; + return {i18n.MODIFIED_PREBUILT_RULE_LABEL}; }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/translations.ts index 89c22a285e327..e7f36e2011f3c 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/translations.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/translations.ts @@ -350,10 +350,10 @@ export const MAX_SIGNALS_FIELD_LABEL = i18n.translate( } ); -export const CUSTOMIZED_PREBUILT_RULE_LABEL = i18n.translate( +export const MODIFIED_PREBUILT_RULE_LABEL = i18n.translate( 'xpack.securitySolution.detectionEngine.ruleDetails.customizedPrebuiltRuleLabel', { - defaultMessage: 'Customized Elastic rule', + defaultMessage: 'Modified Elastic rule', } ); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/hooks/use_default_index_pattern.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/hooks/use_default_index_pattern.tsx index b5ca86c6f1f57..5450cf9950d59 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/hooks/use_default_index_pattern.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/hooks/use_default_index_pattern.tsx @@ -7,7 +7,7 @@ import { useKibana } from '../../../common/lib/kibana/kibana_react'; import { DEFAULT_INDEX_KEY, DEFAULT_INDEX_PATTERN } from '../../../../common/constants'; -import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; +import { useIsPrebuiltRulesCustomizationEnabled } from './use_is_prebuilt_rules_customization_enabled'; /** * Gets the default index pattern for cases when rule has neither index patterns or data view. @@ -15,9 +15,7 @@ import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_exper */ export function useDefaultIndexPattern(): string[] { const { services } = useKibana(); - const isPrebuiltRulesCustomizationEnabled = useIsExperimentalFeatureEnabled( - 'prebuiltRulesCustomizationEnabled' - ); + const isPrebuiltRulesCustomizationEnabled = useIsPrebuiltRulesCustomizationEnabled(); return isPrebuiltRulesCustomizationEnabled ? services.settings.client.get(DEFAULT_INDEX_KEY, DEFAULT_INDEX_PATTERN) diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/hooks/use_is_prebuilt_rules_customization_enabled.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/hooks/use_is_prebuilt_rules_customization_enabled.tsx new file mode 100644 index 0000000000000..d25925860c175 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/hooks/use_is_prebuilt_rules_customization_enabled.tsx @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; + +export const useIsPrebuiltRulesCustomizationEnabled = () => { + return useIsExperimentalFeatureEnabled('prebuiltRulesCustomizationEnabled'); +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts index e12442c97aa4c..59ac52d592bcd 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts @@ -99,6 +99,7 @@ export interface FilterOptions { excludeRuleTypes?: Type[]; enabled?: boolean; // undefined is to display all the rules ruleExecutionStatus?: RuleExecutionStatus; // undefined means "all" + ruleSource?: RuleCustomizationEnum[]; // undefined is to display all the rules } export interface FetchRulesResponse { @@ -202,3 +203,8 @@ export interface FindRulesReferencedByExceptionsProps { lists: FindRulesReferencedByExceptionsListProp[]; signal?: AbortSignal; } + +export enum RuleCustomizationEnum { + customized = 'CUSTOMIZED', + not_customized = 'NOT_CUSTOMIZED', +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/upgrade_prebuilt_rules_table_context.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/upgrade_prebuilt_rules_table_context.tsx index 39e9d3db2f2c1..6ec9ffdd02e67 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/upgrade_prebuilt_rules_table_context.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/upgrade_prebuilt_rules_table_context.tsx @@ -8,8 +8,8 @@ import type { Dispatch, SetStateAction } from 'react'; import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'; import { EuiButton, EuiToolTip } from '@elastic/eui'; +import { useIsPrebuiltRulesCustomizationEnabled } from '../../../../rule_management/hooks/use_is_prebuilt_rules_customization_enabled'; import type { RulesUpgradeState } from '../../../../rule_management/model/prebuilt_rule_upgrade'; -import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; import { RuleUpgradeConflictsResolverTab } from '../../../../rule_management/components/rule_details/three_way_diff/rule_upgrade_conflicts_resolver_tab'; import { PerFieldRuleDiffTab } from '../../../../rule_management/components/rule_details/per_field_rule_diff_tab'; import { useIsUpgradingSecurityPackages } from '../../../../rule_management/logic/use_upgrade_security_packages'; @@ -75,11 +75,14 @@ export interface UpgradePrebuiltRulesTableState { * List of rule IDs that are currently being upgraded */ loadingRules: RuleSignatureId[]; - /** /** * The timestamp for when the rules were successfully fetched */ lastUpdated: number; + /** + * Feature Flag to enable prebuilt rules customization + */ + isPrebuiltRulesCustomizationEnabled: boolean; } export const PREBUILT_RULE_UPDATE_FLYOUT_ANCHOR = 'updatePrebuiltRulePreview'; @@ -108,13 +111,12 @@ interface UpgradePrebuiltRulesTableContextProviderProps { export const UpgradePrebuiltRulesTableContextProvider = ({ children, }: UpgradePrebuiltRulesTableContextProviderProps) => { - const isPrebuiltRulesCustomizationEnabled = useIsExperimentalFeatureEnabled( - 'prebuiltRulesCustomizationEnabled' - ); + const isPrebuiltRulesCustomizationEnabled = useIsPrebuiltRulesCustomizationEnabled(); const [loadingRules, setLoadingRules] = useState([]); const [filterOptions, setFilterOptions] = useState({ filter: '', tags: [], + ruleSource: [], }); const isUpgradingSecurityPackages = useIsUpgradingSecurityPackages(); @@ -318,6 +320,7 @@ export const UpgradePrebuiltRulesTableContextProvider = ({ isUpgradingSecurityPackages, loadingRules, lastUpdated: dataUpdatedAt, + isPrebuiltRulesCustomizationEnabled, }, actions, }; @@ -334,6 +337,7 @@ export const UpgradePrebuiltRulesTableContextProvider = ({ loadingRules, dataUpdatedAt, actions, + isPrebuiltRulesCustomizationEnabled, ]); return ( diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/upgrade_prebuilt_rules_table_filters.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/upgrade_prebuilt_rules_table_filters.tsx index 215a810bf3aa2..900d81d0b0037 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/upgrade_prebuilt_rules_table_filters.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/upgrade_prebuilt_rules_table_filters.tsx @@ -9,10 +9,13 @@ import { EuiFilterGroup, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { isEqual } from 'lodash/fp'; import React, { useCallback } from 'react'; import styled from 'styled-components'; +import { useIsPrebuiltRulesCustomizationEnabled } from '../../../../rule_management/hooks/use_is_prebuilt_rules_customization_enabled'; +import type { RuleCustomizationEnum } from '../../../../rule_management/logic'; import * as i18n from './translations'; import { TagsFilterPopover } from '../rules_table_filters/tags_filter_popover'; import { RuleSearchField } from '../rules_table_filters/rule_search_field'; import { useUpgradePrebuiltRulesTableContext } from './upgrade_prebuilt_rules_table_context'; +import { RuleCustomizationFilterPopover } from './upgrade_rule_customization_filter_popover'; const FilterWrapper = styled(EuiFlexGroup)` margin-bottom: ${({ theme }) => theme.eui.euiSizeM}; @@ -28,7 +31,9 @@ const UpgradePrebuiltRulesTableFiltersComponent = () => { actions: { setFilterOptions }, } = useUpgradePrebuiltRulesTableContext(); - const { tags: selectedTags } = filterOptions; + const isPrebuiltRulesCustomizationEnabled = useIsPrebuiltRulesCustomizationEnabled(); + + const { tags: selectedTags, ruleSource: selectedRuleSource = [] } = filterOptions; const handleOnSearch = useCallback( (filterString: string) => { @@ -52,22 +57,45 @@ const UpgradePrebuiltRulesTableFiltersComponent = () => { [selectedTags, setFilterOptions] ); + const handleSelectedRuleSource = useCallback( + (newRuleSource: RuleCustomizationEnum[]) => { + if (!isEqual(newRuleSource, selectedRuleSource)) { + setFilterOptions((filters) => ({ + ...filters, + ruleSource: newRuleSource, + })); + } + }, + [selectedRuleSource, setFilterOptions] + ); + return ( - + - - - + + {isPrebuiltRulesCustomizationEnabled && ( + + + + )} + + + + ); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/upgrade_rule_customization_filter_popover.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/upgrade_rule_customization_filter_popover.tsx new file mode 100644 index 0000000000000..234943e333272 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/upgrade_rule_customization_filter_popover.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useMemo } from 'react'; +import type { EuiSelectableOption } from '@elastic/eui'; +import { EuiFilterButton, EuiPopover, EuiSelectable } from '@elastic/eui'; +import { RuleCustomizationEnum } from '../../../../rule_management/logic'; +import * as i18n from '../../../../../detections/pages/detection_engine/rules/translations'; +import { toggleSelectedGroup } from '../../../../../common/components/ml_popover/jobs_table/filters/toggle_selected_group'; + +interface RuleCustomizationFilterPopoverProps { + selectedRuleSource: RuleCustomizationEnum[]; + onSelectedRuleSourceChanged: (newRuleSource: RuleCustomizationEnum[]) => void; +} + +const RULE_CUSTOMIZATION_POPOVER_WIDTH = 200; + +const RuleCustomizationFilterPopoverComponent = ({ + selectedRuleSource, + onSelectedRuleSourceChanged, +}: RuleCustomizationFilterPopoverProps) => { + const [isRuleCustomizationPopoverOpen, setIsRuleCustomizationPopoverOpen] = useState(false); + + const selectableOptions: EuiSelectableOption[] = useMemo( + () => [ + { + label: i18n.MODIFIED_LABEL, + key: RuleCustomizationEnum.customized, + checked: selectedRuleSource.includes(RuleCustomizationEnum.customized) ? 'on' : undefined, + }, + { + label: i18n.UNMODIFIED_LABEL, + key: RuleCustomizationEnum.not_customized, + checked: selectedRuleSource.includes(RuleCustomizationEnum.not_customized) + ? 'on' + : undefined, + }, + ], + [selectedRuleSource] + ); + + const handleSelectableOptionsChange = ( + newOptions: EuiSelectableOption[], + _: unknown, + changedOption: EuiSelectableOption + ) => { + toggleSelectedGroup( + changedOption.key ?? '', + selectedRuleSource, + onSelectedRuleSourceChanged as (args: string[]) => void + ); + }; + + const triggerButton = ( + setIsRuleCustomizationPopoverOpen(!isRuleCustomizationPopoverOpen)} + numFilters={selectableOptions.length} + isSelected={isRuleCustomizationPopoverOpen} + hasActiveFilters={selectedRuleSource.length > 0} + numActiveFilters={selectedRuleSource.length} + data-test-subj="rule-customization-filter-popover-button" + > + {i18n.RULE_SOURCE} + + ); + + return ( + setIsRuleCustomizationPopoverOpen(!isRuleCustomizationPopoverOpen)} + panelPaddingSize="none" + repositionOnScroll + panelProps={{ + 'data-test-subj': 'rule-customization-filter-popover', + }} + > + + {(list) =>
{list}
} +
+
+ ); +}; + +export const RuleCustomizationFilterPopover = React.memo(RuleCustomizationFilterPopoverComponent); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_filter_prebuilt_rules_to_upgrade.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_filter_prebuilt_rules_to_upgrade.ts index 342a1e6e8768e..b5a0e123d7510 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_filter_prebuilt_rules_to_upgrade.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_filter_prebuilt_rules_to_upgrade.ts @@ -7,9 +7,12 @@ import { useMemo } from 'react'; import type { RuleUpgradeInfoForReview } from '../../../../../../common/api/detection_engine/prebuilt_rules'; -import type { FilterOptions } from '../../../../rule_management/logic/types'; +import { RuleCustomizationEnum, type FilterOptions } from '../../../../rule_management/logic/types'; -export type UpgradePrebuiltRulesTableFilterOptions = Pick; +export type UpgradePrebuiltRulesTableFilterOptions = Pick< + FilterOptions, + 'filter' | 'tags' | 'ruleSource' +>; export const useFilterPrebuiltRulesToUpgrade = ({ rules, @@ -19,7 +22,7 @@ export const useFilterPrebuiltRulesToUpgrade = ({ filterOptions: UpgradePrebuiltRulesTableFilterOptions; }) => { const filteredRules = useMemo(() => { - const { filter, tags } = filterOptions; + const { filter, tags, ruleSource } = filterOptions; return rules.filter((ruleInfo) => { if (filter && !ruleInfo.current_rule.name.toLowerCase().includes(filter.toLowerCase())) { return false; @@ -29,6 +32,25 @@ export const useFilterPrebuiltRulesToUpgrade = ({ return tags.every((tag) => ruleInfo.current_rule.tags.includes(tag)); } + if (ruleSource && ruleSource.length > 0) { + if ( + ruleSource.includes(RuleCustomizationEnum.customized) && + ruleSource.includes(RuleCustomizationEnum.not_customized) + ) { + return true; + } else if ( + ruleSource.includes(RuleCustomizationEnum.customized) && + ruleInfo.current_rule.rule_source.type === 'external' + ) { + return ruleInfo.current_rule.rule_source.is_customized; + } else if ( + ruleSource.includes(RuleCustomizationEnum.not_customized) && + ruleInfo.current_rule.rule_source.type === 'external' + ) { + return ruleInfo.current_rule.rule_source.is_customized === false; + } + } + return true; }); }, [filterOptions, rules]); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_prebuilt_rules_upgrade_state.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_prebuilt_rules_upgrade_state.ts index 29c5b2b201fe6..8c97a4ef52e2b 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_prebuilt_rules_upgrade_state.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_prebuilt_rules_upgrade_state.ts @@ -6,7 +6,7 @@ */ import { useCallback, useMemo, useState } from 'react'; -import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; +import { useIsPrebuiltRulesCustomizationEnabled } from '../../../../rule_management/hooks/use_is_prebuilt_rules_customization_enabled'; import type { RulesUpgradeState, FieldsUpgradeState, @@ -33,9 +33,7 @@ interface UseRulesUpgradeStateResult { export function usePrebuiltRulesUpgradeState( ruleUpgradeInfos: RuleUpgradeInfoForReview[] ): UseRulesUpgradeStateResult { - const isPrebuiltRulesCustomizationEnabled = useIsExperimentalFeatureEnabled( - 'prebuiltRulesCustomizationEnabled' - ); + const isPrebuiltRulesCustomizationEnabled = useIsPrebuiltRulesCustomizationEnabled(); const [rulesResolvedConflicts, setRulesResolvedConflicts] = useState({}); const setRuleFieldResolvedValue = useCallback( diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_upgrade_prebuilt_rules_table_columns.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_upgrade_prebuilt_rules_table_columns.tsx index cf93925375082..dbfba0b927041 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_upgrade_prebuilt_rules_table_columns.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_upgrade_prebuilt_rules_table_columns.tsx @@ -6,7 +6,14 @@ */ import type { EuiBasicTableColumn } from '@elastic/eui'; -import { EuiBadge, EuiButtonEmpty, EuiLink, EuiLoadingSpinner, EuiText } from '@elastic/eui'; +import { + EuiBadge, + EuiButtonEmpty, + EuiLink, + EuiLoadingSpinner, + EuiText, + EuiToolTip, +} from '@elastic/eui'; import React, { useMemo } from 'react'; import type { RuleUpgradeState } from '../../../../rule_management/model/prebuilt_rule_upgrade/rule_upgrade_state'; import { RulesTableEmptyColumnName } from '../rules_table_empty_column_name'; @@ -104,16 +111,48 @@ const INTEGRATIONS_COLUMN: TableColumn = { truncateText: true, }; +const MODIFIED_COLUMN: TableColumn = { + field: 'current_rule.rule_source', + name: , + align: 'center', + render: (ruleSource: Rule['rule_source']) => { + if ( + ruleSource == null || + ruleSource.type === 'internal' || + (ruleSource.type === 'external' && ruleSource.is_customized === false) + ) { + return null; + } + + return ( + + + {i18n.MODIFIED_LABEL} + + + ); + }, + width: '90px', + truncateText: true, +}; + const createUpgradeButtonColumn = ( upgradeRules: UpgradePrebuiltRulesTableActions['upgradeRules'], loadingRules: RuleSignatureId[], - isDisabled: boolean + isDisabled: boolean, + isPrebuiltRulesCustomizationEnabled: boolean ): TableColumn => ({ field: 'rule_id', name: , render: (ruleId: RuleSignatureId, record) => { const isRuleUpgrading = loadingRules.includes(ruleId); - const isUpgradeButtonDisabled = isRuleUpgrading || isDisabled || record.hasUnresolvedConflicts; + const isDisabledByConflicts = + isPrebuiltRulesCustomizationEnabled && record.hasUnresolvedConflicts; + const isUpgradeButtonDisabled = isRuleUpgrading || isDisabled || isDisabledByConflicts; const spinner = ( { const hasCRUDPermissions = hasUserCRUDPermission(canUserCRUD); const [showRelatedIntegrations] = useUiSetting$(SHOW_RELATED_INTEGRATIONS_SETTING); const { - state: { loadingRules, isRefetching, isUpgradingSecurityPackages }, + state: { + loadingRules, + isRefetching, + isUpgradingSecurityPackages, + isPrebuiltRulesCustomizationEnabled, + }, actions: { upgradeRules }, } = useUpgradePrebuiltRulesTableContext(); const isDisabled = isRefetching || isUpgradingSecurityPackages; + // TODO: move this change to the `INTEGRATIONS_COLUMN` when `prebuiltRulesCustomizationEnabled` feature flag is removed + if (isPrebuiltRulesCustomizationEnabled) { + INTEGRATIONS_COLUMN.width = '70px'; + } + return useMemo( () => [ RULE_NAME_COLUMN, + ...(isPrebuiltRulesCustomizationEnabled ? [MODIFIED_COLUMN] : []), ...(showRelatedIntegrations ? [INTEGRATIONS_COLUMN] : []), TAGS_COLUMN, { @@ -173,9 +223,23 @@ export const useUpgradePrebuiltRulesTableColumns = (): TableColumn[] => { width: '12%', }, ...(hasCRUDPermissions - ? [createUpgradeButtonColumn(upgradeRules, loadingRules, isDisabled)] + ? [ + createUpgradeButtonColumn( + upgradeRules, + loadingRules, + isDisabled, + isPrebuiltRulesCustomizationEnabled + ), + ] : []), ], - [hasCRUDPermissions, loadingRules, isDisabled, showRelatedIntegrations, upgradeRules] + [ + isPrebuiltRulesCustomizationEnabled, + showRelatedIntegrations, + hasCRUDPermissions, + upgradeRules, + loadingRules, + isDisabled, + ] ); }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_columns.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_columns.tsx index c38fec638e478..ae24b2bb482d3 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_columns.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_columns.tsx @@ -46,6 +46,7 @@ import { useRulesTableActions } from './use_rules_table_actions'; import { MlRuleWarningPopover } from '../ml_rule_warning_popover/ml_rule_warning_popover'; import { getMachineLearningJobId } from '../../../../detections/pages/detection_engine/rules/helpers'; import type { TimeRange } from '../../../rule_gaps/types'; +import { useIsPrebuiltRulesCustomizationEnabled } from '../../../rule_management/hooks/use_is_prebuilt_rules_customization_enabled'; export type TableColumn = EuiBasicTableColumn | EuiTableActionsColumnType; @@ -233,6 +234,35 @@ const INTEGRATIONS_COLUMN: TableColumn = { truncateText: true, }; +const MODIFIED_COLUMN: TableColumn = { + field: 'rule_source', + name: , + align: 'center', + render: (ruleSource: Rule['rule_source']) => { + if ( + ruleSource == null || + ruleSource.type === 'internal' || + (ruleSource.type === 'external' && ruleSource.is_customized === false) + ) { + return null; + } + + return ( + + + {i18n.MODIFIED_LABEL} + + + ); + }, + width: '90px', + truncateText: true, +}; + const useActionsColumn = ({ showExceptionsDuplicateConfirmation, showManualRuleRunConfirmation, @@ -265,6 +295,7 @@ export const useRulesColumns = ({ }); const ruleNameColumn = useRuleNameColumn(); const [showRelatedIntegrations] = useUiSetting$(SHOW_RELATED_INTEGRATIONS_SETTING); + const isPrebuiltRulesCustomizationEnabled = useIsPrebuiltRulesCustomizationEnabled(); const enabledColumn = useEnabledColumn({ hasCRUDPermissions, isLoadingJobs, @@ -279,9 +310,15 @@ export const useRulesColumns = ({ }); const snoozeColumn = useRuleSnoozeColumn(); + // TODO: move this change to the `INTEGRATIONS_COLUMN` when `prebuiltRulesCustomizationEnabled` feature flag is removed + if (isPrebuiltRulesCustomizationEnabled) { + INTEGRATIONS_COLUMN.width = '70px'; + } + return useMemo( () => [ ruleNameColumn, + ...(isPrebuiltRulesCustomizationEnabled ? [MODIFIED_COLUMN] : []), ...(showRelatedIntegrations ? [INTEGRATIONS_COLUMN] : []), TAGS_COLUMN, { @@ -352,13 +389,14 @@ export const useRulesColumns = ({ ...(hasCRUDPermissions ? [actionsColumn] : []), ], [ - actionsColumn, - enabledColumn, + ruleNameColumn, + isPrebuiltRulesCustomizationEnabled, + showRelatedIntegrations, executionStatusColumn, snoozeColumn, + enabledColumn, hasCRUDPermissions, - ruleNameColumn, - showRelatedIntegrations, + actionsColumn, ] ); }; @@ -380,6 +418,7 @@ export const useMonitoringColumns = ({ }); const ruleNameColumn = useRuleNameColumn(); const [showRelatedIntegrations] = useUiSetting$(SHOW_RELATED_INTEGRATIONS_SETTING); + const isPrebuiltRulesCustomizationEnabled = useIsPrebuiltRulesCustomizationEnabled(); const enabledColumn = useEnabledColumn({ hasCRUDPermissions, isLoadingJobs, @@ -393,12 +432,18 @@ export const useMonitoringColumns = ({ mlJobs, }); + // TODO: move this change to the `INTEGRATIONS_COLUMN` when `prebuiltRulesCustomizationEnabled` feature flag is removed + if (isPrebuiltRulesCustomizationEnabled) { + INTEGRATIONS_COLUMN.width = '70px'; + } + return useMemo( () => [ { ...ruleNameColumn, width: '28%', }, + ...(isPrebuiltRulesCustomizationEnabled ? [MODIFIED_COLUMN] : []), ...(showRelatedIntegrations ? [INTEGRATIONS_COLUMN] : []), TAGS_COLUMN, { @@ -503,6 +548,7 @@ export const useMonitoringColumns = ({ enabledColumn, executionStatusColumn, hasCRUDPermissions, + isPrebuiltRulesCustomizationEnabled, ruleNameColumn, showRelatedIntegrations, ] diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx index 3d284b89f0745..4eeb343134014 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx @@ -57,7 +57,7 @@ import { } from '@kbn/lists-plugin/common/constants.mock'; import { of } from 'rxjs'; import { timelineDefaults } from '../../../timelines/store/defaults'; -import { defaultUdtHeaders } from '../../../timelines/components/timeline/unified_components/default_headers'; +import { defaultUdtHeaders } from '../../../timelines/components/timeline/body/column_headers/default_headers'; jest.mock('../../../timelines/containers/api', () => ({ getTimelineTemplate: jest.fn(), diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx index 0fa09d4bf4354..60a19f005c53e 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx @@ -63,7 +63,7 @@ export const useAddToCaseActions = ({ : []; }, [casesUi.helpers, ecsData, nonEcsData]); - const { activeStep, incrementStep, setStep, isTourShown } = useTourContext(); + const { activeStep, endTourStep, incrementStep, isTourShown } = useTourContext(); const onCaseSuccess = useCallback(() => { if (onSuccess) { @@ -77,9 +77,9 @@ export const useAddToCaseActions = ({ const afterCaseCreated = useCallback(async () => { if (isTourShown(SecurityStepId.alertsCases)) { - setStep(SecurityStepId.alertsCases, AlertsCasesTourSteps.viewCase); + endTourStep(SecurityStepId.alertsCases); } - }, [setStep, isTourShown]); + }, [endTourStep, isTourShown]); const prefillCasesValue = useMemo( () => diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx index a67eb08496dd0..d7df06616f221 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx @@ -33,7 +33,7 @@ import { getField } from '../../../../helpers'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { useStartTransaction } from '../../../../common/lib/apm/use_start_transaction'; import { ALERTS_ACTIONS } from '../../../../common/lib/apm/user_actions'; -import { defaultUdtHeaders } from '../../../../timelines/components/timeline/unified_components/default_headers'; +import { defaultUdtHeaders } from '../../../../timelines/components/timeline/body/column_headers/default_headers'; interface UseInvestigateInTimelineActionProps { ecsRowData?: Ecs | Ecs[] | null; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_popover/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_popover/index.tsx index 2d2875d5a8734..49b6c1d1e4e99 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_popover/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_popover/index.tsx @@ -16,6 +16,7 @@ import { EuiSpacer, } from '@elastic/eui'; +import { useIsPrebuiltRulesCustomizationEnabled } from '../../../../../detection_engine/rule_management/hooks/use_is_prebuilt_rules_customization_enabled'; import type { RelatedIntegrationArray } from '../../../../../../common/api/detection_engine/model/rule_schema'; import { IntegrationDescription } from '../integrations_description'; import { useRelatedIntegrations } from '../use_related_integrations'; @@ -54,6 +55,7 @@ const IntegrationListItem = styled('li')` const IntegrationsPopoverComponent = ({ relatedIntegrations }: IntegrationsPopoverProps) => { const [isPopoverOpen, setPopoverOpen] = useState(false); const { integrations, isLoaded } = useRelatedIntegrations(relatedIntegrations); + const isPrebuiltRulesCustomizationEnabled = useIsPrebuiltRulesCustomizationEnabled(); const enabledIntegrations = useMemo(() => { return integrations.filter( @@ -65,10 +67,13 @@ const IntegrationsPopoverComponent = ({ relatedIntegrations }: IntegrationsPopov const numIntegrationsEnabled = enabledIntegrations.length; const badgeTitle = useMemo(() => { + if (isPrebuiltRulesCustomizationEnabled) { + return isLoaded ? `${numIntegrationsEnabled}/${numIntegrations}` : `${numIntegrations}`; + } return isLoaded ? `${numIntegrationsEnabled}/${numIntegrations} ${i18n.INTEGRATIONS_BADGE}` : `${numIntegrations} ${i18n.INTEGRATIONS_BADGE}`; - }, [isLoaded, numIntegrations, numIntegrationsEnabled]); + }, [isLoaded, isPrebuiltRulesCustomizationEnabled, numIntegrations, numIntegrationsEnabled]); return ( { method: 'GET', }); + /** + * Get Entity Store privileges + */ + const fetchEntityStorePrivileges = () => + http.fetch(ENTITY_STORE_INTERNAL_PRIVILEGES_URL, { + version: '1', + method: 'GET', + }); + /** * Create asset criticality */ @@ -295,6 +307,7 @@ export const useEntityAnalyticsRoutes = () => { scheduleNowRiskEngine, fetchRiskEnginePrivileges, fetchAssetCriticalityPrivileges, + fetchEntityStorePrivileges, createAssetCriticality, deleteAssetCriticality, fetchAssetCriticality, diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/components/result_step.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/components/result_step.tsx index 07629b2c6e0b6..7026525f45785 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/components/result_step.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality_file_uploader/components/result_step.tsx @@ -7,7 +7,6 @@ import { EuiButtonEmpty, - EuiButton, EuiCallOut, EuiCodeBlock, EuiFlexGroup, @@ -19,6 +18,7 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { css } from '@emotion/react'; import { SecurityPageName } from '@kbn/deeplinks-security'; +import { SecuritySolutionLinkButton } from '../../../../common/components/links'; import type { BulkUpsertAssetCriticalityRecordsResponse } from '../../../../../common/api/entity_analytics'; import { buildAnnotationsFromError } from '../helpers'; import { ScheduleRiskEngineCallout } from './schedule_risk_engine_callout'; @@ -75,14 +75,14 @@ export const AssetCriticalityResultStep: React.FC<{ id="xpack.securitySolution.entityAnalytics.assetCriticalityResultStep.successMessage" /> - + { } - + diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_analytics_risk_score/columns.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_analytics_risk_score/columns.tsx index e6ac30f15c5b0..9459953b6d1d0 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_analytics_risk_score/columns.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_analytics_risk_score/columns.tsx @@ -30,6 +30,7 @@ import { SecurityCellActionType, } from '../../../common/components/cell_actions'; import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date'; +import { formatRiskScore } from '../../common'; type HostRiskScoreColumns = Array>; @@ -128,7 +129,7 @@ export const getRiskScoreColumns = ( if (riskScore != null) { return ( - {Math.round(riskScore)} + {formatRiskScore(riskScore)} ); } diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/dashboard_panels.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/dashboard_panels.tsx index 3b4f661e949f2..d70eb9fe34b51 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/dashboard_panels.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/dashboard_panels.tsx @@ -15,6 +15,7 @@ import { EuiLoadingLogo, EuiPanel, EuiImage, + EuiCallOut, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -50,9 +51,25 @@ const EntityStoreDashboardPanelsComponent = () => { const entityStore = useEntityEngineStatus(); const riskEngineStatus = useRiskEngineStatus(); - const { enable: enableStore } = useEntityStoreEnablement(); + const { enable: enableStore, query } = useEntityStoreEnablement(); + const { mutate: initRiskEngine } = useInitRiskEngineMutation(); + const callouts = entityStore.errors.map((err) => ( + + } + color="danger" + iconType="error" + > +

{err?.message}

+
+ )); + const enableEntityStore = (enable: Enablements) => () => { setModalState({ visible: false }); if (enable.riskScore) { @@ -66,6 +83,7 @@ const EntityStoreDashboardPanelsComponent = () => { }; setRiskEngineInitializing(true); initRiskEngine(undefined, options); + return; } if (enable.entityStore) { @@ -73,6 +91,26 @@ const EntityStoreDashboardPanelsComponent = () => { } }; + if (query.error) { + return ( + <> + + } + color="danger" + iconType="error" + > +

{(query.error as { body: { message: string } }).body.message}

+
+ {callouts} + + ); + } + if (entityStore.status === 'loading') { return ( @@ -103,27 +141,27 @@ const EntityStoreDashboardPanelsComponent = () => { ); } + // TODO Rename variable because the Risk score could be installed but disabled const isRiskScoreAvailable = riskEngineStatus.data && riskEngineStatus.data.risk_engine_status !== RiskEngineStatusEnum.NOT_INSTALLED; return ( - {entityStore.status === 'enabled' && isRiskScoreAvailable && ( + {entityStore.status === 'error' && isRiskScoreAvailable && ( <> + {callouts} - - - )} - {entityStore.status === 'enabled' && !isRiskScoreAvailable && ( + {entityStore.status === 'error' && !isRiskScoreAvailable && ( <> + {callouts} setModalState({ visible: true })} @@ -131,43 +169,69 @@ const EntityStoreDashboardPanelsComponent = () => { enablements="riskScore" /> - + + )} + {entityStore.status === 'enabled' && isRiskScoreAvailable && ( + <> + + + + + + )} - - {entityStore.status === 'not_installed' && !isRiskScoreAvailable && ( - // TODO: Move modal inside EnableEntityStore component, eliminating the onEnable prop in favour of forwarding the riskScoreEnabled status - setModalState({ visible: true })} - loadingRiskEngine={riskEngineInitializing} - /> - )} - - {entityStore.status === 'not_installed' && isRiskScoreAvailable && ( + {entityStore.status === 'enabled' && !isRiskScoreAvailable && ( <> - setModalState({ - visible: true, - }) - } + onEnable={() => setModalState({ visible: true })} + loadingRiskEngine={riskEngineInitializing} + enablements="riskScore" /> + - - - - + )} + {(entityStore.status === 'not_installed' || entityStore.status === 'stopped') && + !isRiskScoreAvailable && ( + // TODO: Move modal inside EnableEntityStore component, eliminating the onEnable prop in favour of forwarding the riskScoreEnabled status + setModalState({ visible: true })} + loadingRiskEngine={riskEngineInitializing} + /> + )} + + {(entityStore.status === 'not_installed' || entityStore.status === 'stopped') && + isRiskScoreAvailable && ( + <> + + + setModalState({ + visible: true, + }) + } + /> + + + + + + + + + )} + setModalState({ visible })} diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/enablement_modal.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/enablement_modal.tsx index 94a3b6cd48edf..6c2528620eb4c 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/enablement_modal.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/enablement_modal.tsx @@ -28,6 +28,8 @@ import { ENABLEMENT_DESCRIPTION_RISK_ENGINE_ONLY, ENABLEMENT_DESCRIPTION_ENTITY_STORE_ONLY, } from '../translations'; +import { useEntityEnginePrivileges } from '../hooks/use_entity_engine_privileges'; +import { MissingPrivilegesCallout } from './missing_privileges_callout'; export interface Enablements { riskScore: boolean; @@ -59,6 +61,7 @@ export const EntityStoreEnablementModal: React.FC - - - - } checked={enablements.entityStore} - disabled={entityStore.disabled || false} + disabled={ + entityStore.disabled || (!isLoadingPrivileges && !privileges?.has_all_required) + } onChange={() => setEnablements((prev) => ({ ...prev, entityStore: !prev.entityStore })) } @@ -121,6 +119,11 @@ export const EntityStoreEnablementModal: React.FC
+ {!privileges || privileges.has_all_required ? null : ( + + + + )} {ENABLEMENT_DESCRIPTION_ENTITY_STORE_ONLY} diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/missing_privileges_callout.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/missing_privileges_callout.tsx new file mode 100644 index 0000000000000..7615f7c33a8f5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/missing_privileges_callout.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiCallOut, EuiCode, EuiText } from '@elastic/eui'; +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { LineClamp } from '../../../../common/components/line_clamp'; +import type { EntityAnalyticsPrivileges } from '../../../../../common/api/entity_analytics'; +import { getAllMissingPrivileges } from '../../../../../common/entity_analytics/privileges'; +import { CommaSeparatedValues } from '../../../../detections/components/callouts/missing_privileges_callout/comma_separated_values'; + +interface MissingPrivilegesCalloutProps { + privileges: EntityAnalyticsPrivileges; +} + +/** + * The height of the callout when the content is clamped. + * The value was chosen based on trial and error. + */ +const LINE_CLAMP_HEIGHT = '4.4em'; + +export const MissingPrivilegesCallout = React.memo( + ({ privileges }: MissingPrivilegesCalloutProps) => { + const missingPrivileges = getAllMissingPrivileges(privileges); + const indexPrivileges = missingPrivileges.elasticsearch.index ?? {}; + const clusterPrivileges = missingPrivileges.elasticsearch.cluster ?? {}; + const featurePrivileges = missingPrivileges.kibana; + const id = `missing-entity-store-privileges`; + return ( + + } + iconType={'iInCircle'} + data-test-subj={`callout-${id}`} + data-test-messages={`[${id}]`} + > + + + {indexPrivileges.length > 0 ? ( + <> + +
    + {indexPrivileges.map(({ indexName, privileges: privilege }) => ( +
  • + , + index: {indexName}, + }} + /> +
  • + ))} +
+ + ) : null} + + {clusterPrivileges.length > 0 ? ( + <> + +
    + {clusterPrivileges.map((privilege) => ( +
  • + {privilege} +
  • + ))} +
+ + ) : null} + + {featurePrivileges.length > 0 ? ( + <> + +
    + {featurePrivileges.map((feature) => ( +
  • + {feature}, + }} + /> +
  • + ))} +
+ + ) : null} +
+
+
+ ); + } +); +MissingPrivilegesCallout.displayName = 'MissingPrivilegesCallout'; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/entities_list.test.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/entities_list.test.tsx index 0e598d6463c5a..0f493304e1f87 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/entities_list.test.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/entities_list.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { EntitiesList } from './entities_list'; import { useGlobalTime } from '../../../common/containers/use_global_time'; import { useQueryToggle } from '../../../common/containers/query_toggle'; @@ -15,6 +15,7 @@ import { useErrorToast } from '../../../common/hooks/use_error_toast'; import type { ListEntitiesResponse } from '../../../../common/api/entity_analytics/entity_store/entities/list_entities.gen'; import { useGlobalFilterQuery } from '../../../common/hooks/use_global_filter_query'; import { TestProviders } from '../../../common/mock'; +import { times } from 'lodash/fp'; jest.mock('../../../common/containers/use_global_time'); jest.mock('../../../common/containers/query_toggle'); @@ -22,21 +23,23 @@ jest.mock('./hooks/use_entities_list_query'); jest.mock('../../../common/hooks/use_error_toast'); jest.mock('../../../common/hooks/use_global_filter_query'); -const entityName = 'Entity Name'; +const secondPageTestId = 'pagination-button-1'; +const entityName = 'Entity Name 1'; const responseData: ListEntitiesResponse = { page: 1, per_page: 10, - total: 1, - records: [ - { + total: 20, + records: times( + (index) => ({ '@timestamp': '2021-08-02T14:00:00.000Z', - user: { name: entityName }, + user: { name: `Entity Name ${index}` }, entity: { - name: entityName, + name: `Entity Name ${index}`, source: 'test-index', }, - }, - ], + }), + 10 + ), inspect: undefined, }; @@ -81,7 +84,7 @@ describe('EntitiesList', () => { it('displays the correct number of rows', () => { render(, { wrapper: TestProviders }); - expect(screen.getAllByRole('row')).toHaveLength(2); + expect(screen.getAllByRole('row')).toHaveLength(10 + 1); }); it('calls refetch on time range change', () => { @@ -106,12 +109,27 @@ describe('EntitiesList', () => { fireEvent.click(columnHeader); expect(mockUseEntitiesListQuery).toHaveBeenCalledWith( expect.objectContaining({ - sortField: 'entity.name.text', + sortField: 'entity.name', sortOrder: 'asc', }) ); }); + it('should reset the page when sort order changes ', async () => { + render(, { wrapper: TestProviders }); + + const secondPageButton = screen.getByTestId(secondPageTestId); + fireEvent.click(secondPageButton); + + const columnHeader = screen.getByText('Name'); + fireEvent.click(columnHeader); + + await waitFor(() => { + const firstPageButton = screen.getByTestId('pagination-button-0'); + expect(firstPageButton).toHaveAttribute('aria-current', 'true'); + }); + }); + it('displays error toast when there is an error', () => { const error = new Error('Test error'); mockUseEntitiesListQuery.mockReturnValueOnce({ diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/entities_list.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/entities_list.tsx index aa03e41c553cb..69afa8dd32108 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/entities_list.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/entities_list.tsx @@ -94,6 +94,11 @@ export const EntitiesList: React.FC = () => { inspect: data?.inspect ?? null, }); + // Reset the active page when the search criteria changes + useEffect(() => { + setActivePage(0); + }, [sorting, limit, filter]); + const columns = useEntitiesListColumns(); // Force a refetch when "refresh" button is clicked. @@ -112,7 +117,7 @@ export const EntitiesList: React.FC = () => { return ( , @@ -79,7 +80,7 @@ export const useEntitiesListColumns = (): EntitiesListColumns => { width: '5%', }, { - field: 'entity.name.text', + field: 'entity.name', name: ( { if (riskScore != null) { return ( - {Math.round(riskScore)} + {formatRiskScore(riskScore)} ); } diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/hooks/use_entity_engine_privileges.ts b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/hooks/use_entity_engine_privileges.ts new file mode 100644 index 0000000000000..346651df5ed5b --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/hooks/use_entity_engine_privileges.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { UseQueryResult } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; +import type { SecurityAppError } from '@kbn/securitysolution-t-grid'; +import type { EntityAnalyticsPrivileges } from '../../../../../common/api/entity_analytics'; +import { useEntityAnalyticsRoutes } from '../../../api/api'; + +export const GET_ENTITY_ENGINE_PRIVILEGES = ['get_entity_engine_privileges'] as const; + +export const useEntityEnginePrivileges = (): UseQueryResult< + EntityAnalyticsPrivileges, + SecurityAppError +> => { + const { fetchEntityStorePrivileges } = useEntityAnalyticsRoutes(); + return useQuery(GET_ENTITY_ENGINE_PRIVILEGES, fetchEntityStorePrivileges); +}; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/hooks/use_entity_engine_status.ts b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/hooks/use_entity_engine_status.ts index ef6ccd5d6fe20..8a1760728074b 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/hooks/use_entity_engine_status.ts +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/hooks/use_entity_engine_status.ts @@ -17,6 +17,10 @@ interface Options { polling?: UseQueryOptions['refetchInterval']; } +interface EngineError { + message: string; +} + export const useEntityEngineStatus = (opts: Options = {}) => { // QUESTION: Maybe we should have an `EnablementStatus` API route for this? const { listEntityEngines } = useEntityStoreRoutes(); @@ -33,6 +37,10 @@ export const useEntityEngineStatus = (opts: Options = {}) => { return 'not_installed'; } + if (data?.engines?.some((engine) => engine.status === 'error')) { + return 'error'; + } + if (data?.engines?.every((engine) => engine.status === 'stopped')) { return 'stopped'; } @@ -52,7 +60,12 @@ export const useEntityEngineStatus = (opts: Options = {}) => { return 'enabled'; })(); + const errors = (data?.engines + ?.filter((engine) => engine.status === 'error') + .map((engine) => engine.error) ?? []) as EngineError[]; + return { status, + errors, }; }; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/hooks/use_entity_store.ts b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/hooks/use_entity_store.ts index 0ac684555fd0d..21e73241451e5 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/hooks/use_entity_store.ts +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/hooks/use_entity_store.ts @@ -9,6 +9,7 @@ import type { UseMutationOptions } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useCallback, useState } from 'react'; +import { useKibana } from '../../../../common/lib/kibana/kibana_react'; import type { DeleteEntityEngineResponse, InitEntityEngineResponse, @@ -21,6 +22,7 @@ const ENTITY_STORE_ENABLEMENT_INIT = 'ENTITY_STORE_ENABLEMENT_INIT'; export const useEntityStoreEnablement = () => { const [polling, setPolling] = useState(false); + const { telemetry } = useKibana().services; useEntityEngineStatus({ disabled: !polling, @@ -39,17 +41,21 @@ export const useEntityStoreEnablement = () => { }); const { initEntityStore } = useEntityStoreRoutes(); - const { refetch: initialize } = useQuery({ + const { refetch: initialize, ...query } = useQuery({ queryKey: [ENTITY_STORE_ENABLEMENT_INIT], - queryFn: () => Promise.all([initEntityStore('user'), initEntityStore('host')]), + queryFn: async () => + initEntityStore('user').then((usr) => initEntityStore('host').then((host) => [usr, host])), enabled: false, }); const enable = useCallback(() => { - initialize().then(() => setPolling(true)); - }, [initialize]); + telemetry?.reportEntityStoreInit({ + timestamp: new Date().toISOString(), + }); + return initialize().then(() => setPolling(true)); + }, [initialize, telemetry]); - return { enable }; + return { enable, query }; }; export const INIT_ENTITY_ENGINE_STATUS_KEY = ['POST', 'INIT_ENTITY_ENGINE']; @@ -65,10 +71,19 @@ export const useInvalidateEntityEngineStatusQuery = () => { }; export const useInitEntityEngineMutation = (options?: UseMutationOptions<{}>) => { + const { telemetry } = useKibana().services; const invalidateEntityEngineStatusQuery = useInvalidateEntityEngineStatusQuery(); const { initEntityStore } = useEntityStoreRoutes(); return useMutation( - () => Promise.all([initEntityStore('user'), initEntityStore('host')]), + () => { + telemetry?.reportEntityStoreEnablement({ + timestamp: new Date().toISOString(), + action: 'start', + }); + return initEntityStore('user').then((usr) => + initEntityStore('host').then((host) => [usr, host]) + ); + }, { ...options, mutationKey: INIT_ENTITY_ENGINE_STATUS_KEY, @@ -86,10 +101,19 @@ export const useInitEntityEngineMutation = (options?: UseMutationOptions<{}>) => export const STOP_ENTITY_ENGINE_STATUS_KEY = ['POST', 'STOP_ENTITY_ENGINE']; export const useStopEntityEngineMutation = (options?: UseMutationOptions<{}>) => { + const { telemetry } = useKibana().services; const invalidateEntityEngineStatusQuery = useInvalidateEntityEngineStatusQuery(); const { stopEntityStore } = useEntityStoreRoutes(); return useMutation( - () => Promise.all([stopEntityStore('user'), stopEntityStore('host')]), + () => { + telemetry?.reportEntityStoreEnablement({ + timestamp: new Date().toISOString(), + action: 'stop', + }); + return stopEntityStore('user').then((usr) => + stopEntityStore('host').then((host) => [usr, host]) + ); + }, { ...options, mutationKey: STOP_ENTITY_ENGINE_STATUS_KEY, @@ -110,7 +134,10 @@ export const useDeleteEntityEngineMutation = (options?: UseMutationOptions<{}>) const invalidateEntityEngineStatusQuery = useInvalidateEntityEngineStatusQuery(); const { deleteEntityEngine } = useEntityStoreRoutes(); return useMutation( - () => Promise.all([deleteEntityEngine('user', true), deleteEntityEngine('host', true)]), + () => + deleteEntityEngine('user', true).then((usr) => + deleteEntityEngine('host', true).then((host) => [usr, host]) + ), { ...options, mutationKey: DELETE_ENTITY_ENGINE_STATUS_KEY, diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/host_risk_score_table/columns.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/host_risk_score_table/columns.tsx index e1509a03a9a90..ac85589736336 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/host_risk_score_table/columns.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/host_risk_score_table/columns.tsx @@ -23,6 +23,7 @@ import { RiskScoreLevel } from '../severity/common'; import { ENTITY_RISK_LEVEL } from '../risk_score/translations'; import { CELL_ACTIONS_TELEMETRY } from '../risk_score/constants'; import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date'; +import { formatRiskScore } from '../../common'; export const getHostRiskScoreColumns = ({ dispatchSeverityUpdate, @@ -82,7 +83,7 @@ export const getHostRiskScoreColumns = ({ if (riskScore != null) { return ( - {Math.round(riskScore)} + {formatRiskScore(riskScore)} ); } diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.tsx index fee62f34dfdee..00cc1abe4d957 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.tsx @@ -22,16 +22,14 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { euiThemeVars } from '@kbn/ui-theme'; import dateMath from '@kbn/datemath'; import { i18n } from '@kbn/i18n'; -import { ExpandablePanel } from '@kbn/security-solution-common'; import { useKibana } from '../../../common/lib/kibana/kibana_react'; - import { EntityDetailsLeftPanelTab } from '../../../flyout/entity_details/shared/components/left_panel/left_panel_header'; - import { InspectButton, InspectButtonContainer } from '../../../common/components/inspect'; import { ONE_WEEK_IN_HOURS } from '../../../flyout/entity_details/shared/constants'; import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date'; import { RiskScoreEntity } from '../../../../common/entity_analytics/risk_engine'; import { VisualizationEmbeddable } from '../../../common/components/visualization_actions/visualization_embeddable'; +import { ExpandablePanel } from '../../../flyout/shared/components/expandable_panel'; import type { RiskScoreState } from '../../api/hooks/use_risk_score'; import { getRiskScoreSummaryAttributes } from '../../lens_attributes/risk_score_summary'; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/user_risk_score_table/columns.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/user_risk_score_table/columns.tsx index 49eaf7b3cc26b..0cb7960ecabba 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/user_risk_score_table/columns.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/user_risk_score_table/columns.tsx @@ -24,6 +24,7 @@ import { UsersTableType } from '../../../explore/users/store/model'; import { ENTITY_RISK_LEVEL } from '../risk_score/translations'; import { CELL_ACTIONS_TELEMETRY } from '../risk_score/constants'; import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date'; +import { formatRiskScore } from '../../common'; export const getUserRiskScoreColumns = ({ dispatchSeverityUpdate, @@ -85,7 +86,7 @@ export const getUserRiskScoreColumns = ({ if (riskScore != null) { return ( - {Math.round(riskScore)} + {formatRiskScore(riskScore)} ); } diff --git a/x-pack/plugins/security_solution/public/entity_analytics/pages/entity_store_management_page.tsx b/x-pack/plugins/security_solution/public/entity_analytics/pages/entity_store_management_page.tsx index 1ca2b0e2b02da..84648d89f912d 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/pages/entity_store_management_page.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/pages/entity_store_management_page.tsx @@ -41,6 +41,8 @@ import { useStopEntityEngineMutation, } from '../components/entity_store/hooks/use_entity_store'; import { TECHNICAL_PREVIEW, TECHNICAL_PREVIEW_TOOLTIP } from '../../common/translations'; +import { useEntityEnginePrivileges } from '../components/entity_store/hooks/use_entity_engine_privileges'; +import { MissingPrivilegesCallout } from '../components/entity_store/components/missing_privileges_callout'; const entityStoreEnabledStatuses = ['enabled']; const switchDisabledStatuses = ['error', 'loading', 'installing']; @@ -99,6 +101,8 @@ export const EntityStoreManagementPage = () => { } }, [initEntityEngineMutation, stopEntityEngineMutation, entityStoreStatus]); + const { data: privileges } = useEntityEnginePrivileges(); + if (assetCriticalityIsLoading) { // Wait for permission before rendering content to avoid flickering return null; @@ -252,6 +256,21 @@ export const EntityStoreManagementPage = () => { stopEntityEngineMutation.isLoading || deleteEntityEngineMutation.isLoading; + const callouts = entityStoreStatus.errors.map((error) => ( + + } + color="danger" + iconType="alert" + > +

{error.message}

+
+ )); + return ( <> { } alignItems="center" rightSideItems={ - !isEntityStoreFeatureFlagDisabled + !isEntityStoreFeatureFlagDisabled && privileges?.has_all_required ? [ { /> {isEntityStoreFeatureFlagDisabled && } + {!privileges || privileges.has_all_required ? null : ( + <> + + + + + )} + + {initEntityEngineMutation.isError && ( + + } + color="danger" + iconType="alert" + > +

+ {(initEntityEngineMutation.error as { body: { message: string } }).body.message} +

+
+ )} + {deleteEntityEngineMutation.isError && ( + + } + color="danger" + iconType="alert" + > +

+ {(deleteEntityEngineMutation.error as { body: { message: string } }).body.message} +

+
+ )} + {callouts} - {!isEntityStoreFeatureFlagDisabled && canDeleteEntityEngine && } + {!isEntityStoreFeatureFlagDisabled && + privileges?.has_all_required && + canDeleteEntityEngine && }
diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/alert_reason/alert_reason.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/alert_reason/alert_reason.tsx index 03b84bc625552..62e6e9f492b2f 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/alert_reason/alert_reason.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/alert_reason/alert_reason.tsx @@ -10,11 +10,11 @@ import { EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; import styled from '@emotion/styled'; import { euiThemeVars } from '@kbn/ui-theme'; import { FormattedMessage } from '@kbn/i18n-react'; -import { FlyoutError } from '@kbn/security-solution-common'; import { ALERT_REASON_BODY_TEST_ID } from './test_ids'; import { useAlertReasonPanelContext } from './context'; import { getRowRenderer } from '../../../timelines/components/timeline/body/renderers/get_row_renderer'; import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers'; +import { FlyoutError } from '../../shared/components/flyout_error'; const ReasonContainerWrapper = styled.div` overflow-x: auto; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/alert_reason/context.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/alert_reason/context.tsx index ec8096d60e72c..b67c9af508d96 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/alert_reason/context.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/alert_reason/context.tsx @@ -7,8 +7,9 @@ import React, { createContext, memo, useContext, useMemo } from 'react'; import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; -import { FlyoutError, FlyoutLoading } from '@kbn/security-solution-common'; import { useEventDetails } from '../shared/hooks/use_event_details'; +import { FlyoutError } from '../../shared/components/flyout_error'; +import { FlyoutLoading } from '../../shared/components/flyout_loading'; import type { AlertReasonPanelProps } from '.'; export interface AlertReasonPanelContext { diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/analyzer_panels/index.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/analyzer_panels/index.tsx index f61993da5321a..4fe9086c70a0e 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/analyzer_panels/index.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/analyzer_panels/index.tsx @@ -7,13 +7,13 @@ import React, { useCallback } from 'react'; import type { FlyoutPanelProps } from '@kbn/expandable-flyout'; -import { FlyoutBody } from '@kbn/security-solution-common'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import type { DocumentDetailsAnalyzerPanelKey } from '../shared/constants/panel_keys'; import { DetailsPanel } from '../../../resolver/view/details_panel'; import type { NodeEventOnClick } from '../../../resolver/view/panels/node_events_of_type'; import { DocumentDetailsPreviewPanelKey } from '../shared/constants/panel_keys'; import { ALERT_PREVIEW_BANNER, EVENT_PREVIEW_BANNER } from '../preview/constants'; +import { FlyoutBody } from '../../shared/components/flyout_body'; interface AnalyzerPanelProps extends Record { /** diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/isolate_host/content.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/isolate_host/content.tsx index 6ae172ca62556..0c9f05391d82a 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/isolate_host/content.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/isolate_host/content.tsx @@ -8,7 +8,6 @@ import type { FC } from 'react'; import React, { useCallback } from 'react'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; -import { FlyoutBody } from '@kbn/security-solution-common'; import { DocumentDetailsRightPanelKey } from '../shared/constants/panel_keys'; import { useBasicDataFromDetailsData } from '../shared/hooks/use_basic_data_from_details_data'; import { @@ -17,6 +16,7 @@ import { } from '../../../common/components/endpoint/host_isolation'; import { useHostIsolation } from '../shared/hooks/use_host_isolation'; import { useIsolateHostPanelContext } from './context'; +import { FlyoutBody } from '../../shared/components/flyout_body'; /** * Document details expandable flyout section content for the isolate host component, displaying the form or the success banner diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/isolate_host/context.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/isolate_host/context.tsx index 6abfe1c4e8650..53393e2f8a79b 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/isolate_host/context.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/isolate_host/context.tsx @@ -8,8 +8,9 @@ import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common'; import React, { createContext, memo, useContext, useMemo } from 'react'; -import { FlyoutError, FlyoutLoading } from '@kbn/security-solution-common'; import { useEventDetails } from '../shared/hooks/use_event_details'; +import { FlyoutError } from '../../shared/components/flyout_error'; +import { FlyoutLoading } from '../../shared/components/flyout_loading'; import type { IsolateHostPanelProps } from '.'; export interface IsolateHostPanelContext { diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/isolate_host/header.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/isolate_host/header.tsx index d7effaea8016b..c0f4174cff95a 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/isolate_host/header.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/isolate_host/header.tsx @@ -8,11 +8,11 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui'; import type { FC } from 'react'; import React from 'react'; -import { FlyoutHeader } from '@kbn/security-solution-common'; import { AgentTypeIntegration } from '../../../common/components/endpoint/agents/agent_type_integration'; import { useAlertResponseActionsSupport } from '../../../common/hooks/endpoint/use_alert_response_actions_support'; import { useIsolateHostPanelContext } from './context'; import { FLYOUT_HEADER_TITLE_TEST_ID } from './test_ids'; +import { FlyoutHeader } from '../../shared/components/flyout_header'; import { ISOLATE_HOST, UNISOLATE_HOST } from '../../../common/components/endpoint'; /** diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/correlations_details.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/correlations_details.test.tsx index 859da37c4082d..b3b129a75c13d 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/correlations_details.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/correlations_details.test.tsx @@ -28,7 +28,7 @@ import { useFetchRelatedAlertsBySameSourceEvent } from '../../shared/hooks/use_f import { useFetchRelatedCases } from '../../shared/hooks/use_fetch_related_cases'; import { mockContextValue } from '../../shared/mocks/mock_context'; import { useTimelineDataFilters } from '../../../../timelines/containers/use_timeline_data_filters'; -import { EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID } from '@kbn/security-solution-common'; +import { EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID } from '../../../shared/components/test_ids'; jest.mock('react-router-dom', () => { const actual = jest.requireActual('react-router-dom'); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/correlations_details_alerts_table.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/correlations_details_alerts_table.tsx index 20663b56dab39..d8497ca984ea8 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/correlations_details_alerts_table.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/correlations_details_alerts_table.tsx @@ -14,13 +14,13 @@ import { isRight } from 'fp-ts/lib/Either'; import { ALERT_REASON, ALERT_RULE_NAME } from '@kbn/rule-data-utils'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; -import { ExpandablePanel } from '@kbn/security-solution-common'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { CellTooltipWrapper } from '../../shared/components/cell_tooltip_wrapper'; import type { DataProvider } from '../../../../../common/types'; import { SeverityBadge } from '../../../../common/components/severity_badge'; import { usePaginatedAlerts } from '../hooks/use_paginated_alerts'; import { InvestigateInTimelineButton } from '../../../../common/components/event_details/investigate_in_timeline_button'; +import { ExpandablePanel } from '../../../shared/components/expandable_panel'; import { ACTION_INVESTIGATE_IN_TIMELINE } from '../../../../detections/components/alerts_table/translations'; import { getDataProvider } from '../../../../common/components/event_details/use_action_cell_data_provider'; import { AlertPreviewButton } from '../../../shared/components/alert_preview_button'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/entities_details.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/entities_details.test.tsx index b6c5dc0078b02..d9d468649a221 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/entities_details.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/entities_details.test.tsx @@ -13,7 +13,7 @@ import { TestProviders } from '../../../../common/mock'; import { EntitiesDetails } from './entities_details'; import { ENTITIES_DETAILS_TEST_ID, HOST_DETAILS_TEST_ID, USER_DETAILS_TEST_ID } from './test_ids'; import { mockContextValue } from '../../shared/mocks/mock_context'; -import { EXPANDABLE_PANEL_CONTENT_TEST_ID } from '@kbn/security-solution-common'; +import { EXPANDABLE_PANEL_CONTENT_TEST_ID } from '../../../shared/components/test_ids'; import type { Anomalies } from '../../../../common/components/ml/types'; import { useMlCapabilities } from '../../../../common/components/ml/hooks/use_ml_capabilities'; import { mockAnomalies } from '../../../../common/components/ml/mock'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.test.tsx index 23f6969c36778..e998c29d8ab6f 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.test.tsx @@ -30,7 +30,7 @@ import { HOST_DETAILS_VULNERABILITIES_TEST_ID, HOST_DETAILS_ALERT_COUNT_TEST_ID, } from './test_ids'; -import { EXPANDABLE_PANEL_CONTENT_TEST_ID } from '@kbn/security-solution-common'; +import { EXPANDABLE_PANEL_CONTENT_TEST_ID } from '../../../shared/components/test_ids'; import { useRiskScore } from '../../../../entity_analytics/api/hooks/use_risk_score'; import { mockContextValue } from '../../shared/mocks/mock_context'; import { mockFlyoutApi } from '../../shared/mocks/mock_flyout_context'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.tsx index 122caa657b039..c315e991d9f06 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.tsx @@ -25,8 +25,8 @@ import type { EuiBasicTableColumn } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; -import { ExpandablePanel } from '@kbn/security-solution-common'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import { ExpandablePanel } from '../../../shared/components/expandable_panel'; import type { RelatedUser } from '../../../../../common/search_strategy/security_solution/related_entities/related_users'; import type { RiskSeverity } from '../../../../../common/search_strategy'; import { HostOverview } from '../../../../overview/components/host_overview'; @@ -359,11 +359,13 @@ export const HostDetails: React.FC = ({ hostName, timestamp, s name={hostName} direction="column" data-test-subj={HOST_DETAILS_MISCONFIGURATIONS_TEST_ID} + telemetrySuffix={'host-details'} /> diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/investigation_guide.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/investigation_guide.tsx index f39057a16bfdb..ee1bebdb336ce 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/investigation_guide.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/investigation_guide.tsx @@ -7,11 +7,11 @@ import React from 'react'; import { EuiLink } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { FlyoutLoading } from '@kbn/security-solution-common'; import { useInvestigationGuide } from '../../shared/hooks/use_investigation_guide'; import { useDocumentDetailsContext } from '../../shared/context'; import { INVESTIGATION_GUIDE_TEST_ID, INVESTIGATION_GUIDE_LOADING_TEST_ID } from './test_ids'; import { InvestigationGuideView } from './investigation_guide_view'; +import { FlyoutLoading } from '../../../shared/components/flyout_loading'; /** * Investigation guide displayed in the left panel. diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/investigation_guide_view.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/investigation_guide_view.tsx index 3d61c223fd47f..2b219cac38db4 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/investigation_guide_view.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/investigation_guide_view.tsx @@ -74,7 +74,7 @@ const InvestigationGuideViewComponent: React.FC = ( ) : ( - + {ruleNote} diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_ancestry.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_ancestry.test.tsx index 62012b72052bc..52468f0aedbb9 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_ancestry.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_ancestry.test.tsx @@ -20,7 +20,7 @@ import { EXPANDABLE_PANEL_HEADER_TITLE_ICON_TEST_ID, EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID, EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID, -} from '@kbn/security-solution-common'; +} from '../../../shared/components/test_ids'; import { usePaginatedAlerts } from '../hooks/use_paginated_alerts'; jest.mock('../../shared/hooks/use_fetch_related_alerts_by_ancestry'); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_same_source_event.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_same_source_event.test.tsx index f4244fcc59a04..3cf2d93896bc3 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_same_source_event.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_same_source_event.test.tsx @@ -20,7 +20,7 @@ import { EXPANDABLE_PANEL_HEADER_TITLE_ICON_TEST_ID, EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID, EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID, -} from '@kbn/security-solution-common'; +} from '../../../shared/components/test_ids'; import { usePaginatedAlerts } from '../hooks/use_paginated_alerts'; jest.mock('../../shared/hooks/use_fetch_related_alerts_by_same_source_event'); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_session.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_session.test.tsx index 54df7e8924f68..0120f462b9ac5 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_session.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_alerts_by_session.test.tsx @@ -21,7 +21,7 @@ import { EXPANDABLE_PANEL_HEADER_TITLE_ICON_TEST_ID, EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID, EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID, -} from '@kbn/security-solution-common'; +} from '../../../shared/components/test_ids'; jest.mock('../../shared/hooks/use_fetch_related_alerts_by_session'); jest.mock('../hooks/use_paginated_alerts'); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_cases.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_cases.test.tsx index 4d90cc7f56133..48f7a1fbce0a6 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_cases.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_cases.test.tsx @@ -6,7 +6,6 @@ */ import React from 'react'; -import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { render } from '@testing-library/react'; import { CORRELATIONS_DETAILS_CASES_SECTION_TABLE_TEST_ID, @@ -18,14 +17,27 @@ import { EXPANDABLE_PANEL_HEADER_TITLE_ICON_TEST_ID, EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID, EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID, -} from '@kbn/security-solution-common'; +} from '../../../shared/components/test_ids'; +import { SecurityPageName } from '@kbn/deeplinks-security'; +import { TestProviders } from '../../../../common/mock'; +import { APP_UI_ID } from '../../../../../common'; jest.mock('../../shared/hooks/use_fetch_related_cases'); -jest.mock('../../../../common/components/links', () => ({ - CaseDetailsLink: jest - .fn() - .mockImplementation(({ title }) => <>{``}), -})); + +const mockNavigateToApp = jest.fn(); +jest.mock('../../../../common/lib/kibana', () => { + const original = jest.requireActual('../../../../common/lib/kibana'); + return { + ...original, + useKibana: () => ({ + services: { + application: { + navigateToApp: mockNavigateToApp, + }, + }, + }), + }; +}); const eventId = 'eventId'; @@ -41,13 +53,53 @@ const TITLE_TEXT = EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID( const renderRelatedCases = () => render( - + - + ); describe('', () => { it('should render many related cases correctly', () => { + (useFetchRelatedCases as jest.Mock).mockReturnValue({ + loading: false, + error: false, + data: [ + { + id: 'id1', + title: 'title1', + description: 'description1', + status: 'open', + }, + { + id: 'id2', + title: 'title2', + description: 'description2', + status: 'in-progress', + }, + { + id: 'id3', + title: 'title3', + description: 'description3', + status: 'closed', + }, + ], + dataCount: 3, + }); + + const { getByTestId, getByText } = renderRelatedCases(); + expect(getByTestId(TOGGLE_ICON)).toBeInTheDocument(); + expect(getByTestId(TITLE_ICON)).toBeInTheDocument(); + expect(getByTestId(TITLE_TEXT)).toHaveTextContent('3 related cases'); + expect(getByTestId(CORRELATIONS_DETAILS_CASES_SECTION_TABLE_TEST_ID)).toBeInTheDocument(); + expect(getByText('title1')).toBeInTheDocument(); + expect(getByText('open')).toBeInTheDocument(); + expect(getByText('title2')).toBeInTheDocument(); + expect(getByText('in-progress')).toBeInTheDocument(); + expect(getByText('title3')).toBeInTheDocument(); + expect(getByText('closed')).toBeInTheDocument(); + }); + + it('should open new tab when clicking on the case link', () => { (useFetchRelatedCases as jest.Mock).mockReturnValue({ loading: false, error: false, @@ -63,10 +115,12 @@ describe('', () => { }); const { getByTestId } = renderRelatedCases(); - expect(getByTestId(TOGGLE_ICON)).toBeInTheDocument(); - expect(getByTestId(TITLE_ICON)).toBeInTheDocument(); - expect(getByTestId(TITLE_TEXT)).toHaveTextContent('1 related case'); - expect(getByTestId(CORRELATIONS_DETAILS_CASES_SECTION_TABLE_TEST_ID)).toBeInTheDocument(); + getByTestId('case-details-link').click(); + expect(mockNavigateToApp).toHaveBeenCalledWith(APP_UI_ID, { + deepLinkId: SecurityPageName.case, + path: '/id', + openInNewTab: true, + }); }); it('should render null if error', () => { diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_cases.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_cases.tsx index 8e00e0e65478b..0ce04507b9b05 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_cases.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_cases.tsx @@ -7,10 +7,10 @@ import React, { useMemo } from 'react'; import type { EuiBasicTableColumn } from '@elastic/eui'; -import { EuiInMemoryTable } from '@elastic/eui'; +import { EuiIcon, EuiInMemoryTable } from '@elastic/eui'; import type { RelatedCase } from '@kbn/cases-plugin/common'; import { FormattedMessage } from '@kbn/i18n-react'; -import { ExpandablePanel } from '@kbn/security-solution-common'; +import { css } from '@emotion/react'; import { CellTooltipWrapper } from '../../shared/components/cell_tooltip_wrapper'; import { CaseDetailsLink } from '../../../../common/components/links'; import { @@ -18,8 +18,13 @@ import { CORRELATIONS_DETAILS_CASES_SECTION_TEST_ID, } from './test_ids'; import { useFetchRelatedCases } from '../../shared/hooks/use_fetch_related_cases'; +import { ExpandablePanel } from '../../../shared/components/expandable_panel'; const ICON = 'warning'; +const EXPAND_PROPERTIES = { + expandable: true, + expandedOnFirstRender: true, +}; const getColumns: (data: RelatedCase[]) => Array> = (data) => [ { @@ -30,16 +35,21 @@ const getColumns: (data: RelatedCase[]) => Array ), - render: (value: string, caseData: RelatedCase) => { - const index = data.findIndex((d) => d.id === caseData.id); - return ( - - - {caseData.title} - - - ); - }, + render: (_: string, caseData: RelatedCase) => ( + + + {caseData.title} + + + + ), }, { field: 'status', @@ -62,33 +72,38 @@ export interface RelatedCasesProps { } /** - * + * Show related cases in an expandable panel with a table */ export const RelatedCases: React.FC = ({ eventId }) => { const { loading, error, data, dataCount } = useFetchRelatedCases({ eventId }); const columns = useMemo(() => getColumns(data), [data]); + const title = useMemo( + () => ( + + ), + [dataCount] + ); + const header = useMemo( + () => ({ + title, + iconType: ICON, + }), + [title] + ); + if (error) { return null; } return ( - ), - iconType: ICON, - }} - content={{ error }} - expand={{ - expandable: true, - expandedOnFirstRender: true, - }} + header={header} + expand={EXPAND_PROPERTIES} data-test-subj={CORRELATIONS_DETAILS_CASES_SECTION_TEST_ID} > - render( - - - - {Object.values(FLYOUT_TOUR_CONFIG_ANCHORS).map((i, idx) => ( -
- ))} - - - ); - -describe('', () => { - beforeEach(() => { - (useKibana as jest.Mock).mockReturnValue({ - ...mockedUseKibana, - services: { - ...mockedUseKibana.services, - storage: storageMock, - }, - }); - (useWhichFlyout as jest.Mock).mockReturnValue(Flyouts.securitySolution); - - storageMock.clear(); - }); - - it('should render left panel tour for alerts starting as step 4', async () => { - storageMock.set('securitySolution.documentDetails.newFeaturesTour.v8.14', { - currentTourStep: 4, - isTourActive: true, - }); - - const { getByText, getByTestId } = renderLeftPanelTour(); - await waitFor(() => { - expect(getByTestId(`${FLYOUT_TOUR_TEST_ID}-4`)).toBeVisible(); - }); - fireEvent.click(getByText('Next')); - await waitFor(() => { - expect(getByTestId(`${FLYOUT_TOUR_TEST_ID}-5`)).toBeVisible(); - }); - await waitFor(() => { - expect(getByText('Finish')).toBeVisible(); - }); - }); - - it('should not render left panel tour for preview', () => { - storageMock.set('securitySolution.documentDetails.newFeaturesTour.v8.14', { - currentTourStep: 3, - isTourActive: true, - }); - - const { queryByTestId, queryByText } = renderLeftPanelTour({ - ...mockContextValue, - isPreview: true, - }); - expect(queryByTestId(`${FLYOUT_TOUR_TEST_ID}-4`)).not.toBeInTheDocument(); - expect(queryByText('Next')).not.toBeInTheDocument(); - }); - - it('should not render left panel tour for non-alerts', async () => { - storageMock.set('securitySolution.documentDetails.newFeaturesTour.v8.14', { - currentTourStep: 3, - isTourActive: true, - }); - - const { queryByTestId, queryByText } = renderLeftPanelTour({ - ...mockContextValue, - getFieldsData: () => '', - }); - expect(queryByTestId(`${FLYOUT_TOUR_TEST_ID}-4`)).not.toBeInTheDocument(); - expect(queryByText('Next')).not.toBeInTheDocument(); - }); - - it('should not render left panel tour for flyout in timeline', () => { - (useWhichFlyout as jest.Mock).mockReturnValue(Flyouts.timeline); - storageMock.set('securitySolution.documentDetails.newFeaturesTour.v8.14', { - currentTourStep: 3, - isTourActive: true, - }); - - const { queryByTestId, queryByText } = renderLeftPanelTour({ - ...mockContextValue, - isPreview: true, - }); - expect(queryByTestId(`${FLYOUT_TOUR_TEST_ID}-4`)).not.toBeInTheDocument(); - expect(queryByText('Next')).not.toBeInTheDocument(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/tour.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/tour.tsx deleted file mode 100644 index 196d9113457a3..0000000000000 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/tour.tsx +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo, useMemo } from 'react'; -import { useWhichFlyout } from '../../shared/hooks/use_which_flyout'; -import { getField } from '../../shared/utils'; -import { EventKind } from '../../shared/constants/event_kinds'; -import { useDocumentDetailsContext } from '../../shared/context'; -import { getLeftSectionTourSteps } from '../../shared/utils/tour_step_config'; -import { Flyouts } from '../../shared/constants/flyouts'; -import { FlyoutTour } from '../../shared/components/flyout_tour'; - -/** - * Guided tour for the left panel in details flyout - */ -export const LeftPanelTour = memo(() => { - const { getFieldsData, isPreview } = useDocumentDetailsContext(); - const eventKind = getField(getFieldsData('event.kind')); - const isAlert = eventKind === EventKind.signal; - const isTimelineFlyoutOpen = useWhichFlyout() === Flyouts.timeline; - const showTour = isAlert && !isPreview && !isTimelineFlyoutOpen; - - const tourStepContent = useMemo(() => getLeftSectionTourSteps(), []); - - return showTour ? : null; -}); - -LeftPanelTour.displayName = 'LeftPanelTour'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.test.tsx index a2c53afb8c3f3..17ec5f052be32 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.test.tsx @@ -28,7 +28,7 @@ import { USER_DETAILS_MISCONFIGURATIONS_TEST_ID, USER_DETAILS_ALERT_COUNT_TEST_ID, } from './test_ids'; -import { EXPANDABLE_PANEL_CONTENT_TEST_ID } from '@kbn/security-solution-common'; +import { EXPANDABLE_PANEL_CONTENT_TEST_ID } from '../../../shared/components/test_ids'; import { useRiskScore } from '../../../../entity_analytics/api/hooks/use_risk_score'; import { mockContextValue } from '../../shared/mocks/mock_context'; import { mockFlyoutApi } from '../../shared/mocks/mock_flyout_context'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.tsx index c90d11f4b8bc2..2f98c641b5954 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.tsx @@ -25,8 +25,8 @@ import type { EuiBasicTableColumn } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; -import { ExpandablePanel } from '@kbn/security-solution-common'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import { ExpandablePanel } from '../../../shared/components/expandable_panel'; import type { RelatedHost } from '../../../../../common/search_strategy/security_solution/related_entities/related_hosts'; import type { RiskSeverity } from '../../../../../common/search_strategy'; import { UserOverview } from '../../../../overview/components/user_overview'; @@ -359,6 +359,7 @@ export const UserDetails: React.FC = ({ userName, timestamp, s name={userName} direction="column" data-test-subj={USER_DETAILS_MISCONFIGURATIONS_TEST_ID} + telemetrySuffix={'user-details'} /> diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/content.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/content.tsx index 226765e6c4e37..53d2efe883397 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/content.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/content.tsx @@ -9,9 +9,9 @@ import { useEuiBackgroundColor } from '@elastic/eui'; import type { FC } from 'react'; import React, { useMemo } from 'react'; import { css } from '@emotion/react'; -import { FlyoutBody } from '@kbn/security-solution-common'; import type { LeftPanelPaths } from '.'; import type { LeftPanelTabType } from './tabs'; +import { FlyoutBody } from '../../shared/components/flyout_body'; export interface PanelContentProps { /** diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/header.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/header.tsx index f276ccca842be..2b61a97577e06 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/header.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/header.tsx @@ -9,8 +9,8 @@ import { EuiTab, EuiTabs, useEuiBackgroundColor } from '@elastic/eui'; import type { FC } from 'react'; import React, { memo } from 'react'; import { css } from '@emotion/react'; -import { FlyoutHeader } from '@kbn/security-solution-common'; import type { LeftPanelPaths } from '.'; +import { FlyoutHeader } from '../../shared/components/flyout_header'; import type { LeftPanelTabType } from './tabs'; import { getField } from '../shared/utils'; import { EventKind } from '../shared/constants/event_kinds'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/index.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/index.tsx index 32b0b10d61ffd..56375426c5f68 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/index.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/index.tsx @@ -22,7 +22,6 @@ import { getField } from '../shared/utils'; import { EventKind } from '../shared/constants/event_kinds'; import { useDocumentDetailsContext } from '../shared/context'; import type { DocumentDetailsProps } from '../shared/types'; -import { LeftPanelTour } from './components/tour'; export type LeftPanelPaths = 'visualize' | 'insights' | 'investigation' | 'response' | 'notes'; export const LeftPanelVisualizeTab: LeftPanelPaths = 'visualize'; @@ -85,7 +84,6 @@ export const LeftPanel: FC> = memo(({ path }) => { return ( <> - - -
+ ), 'data-test-subj': INSIGHTS_TAB_ENTITIES_BUTTON_TEST_ID, }, @@ -62,12 +58,10 @@ const insightsButtons: EuiButtonGroupOptionProps[] = [ { id: PREVALENCE_TAB_ID, label: ( -
- -
+ ), 'data-test-subj': INSIGHTS_TAB_PREVALENCE_BUTTON_TEST_ID, }, diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/tabs/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/document_details/left/tabs/test_ids.ts index 1e99fb63d18a5..eb64c91b2143d 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/tabs/test_ids.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/tabs/test_ids.ts @@ -17,12 +17,8 @@ const INSIGHTS_TAB_TEST_ID = `${PREFIX}InsightsTab` as const; export const INSIGHTS_TAB_BUTTON_GROUP_TEST_ID = `${INSIGHTS_TAB_TEST_ID}ButtonGroup` as const; export const INSIGHTS_TAB_ENTITIES_BUTTON_TEST_ID = `${INSIGHTS_TAB_TEST_ID}EntitiesButton` as const; -export const INSIGHTS_TAB_ENTITIES_BUTTON_LABEL_TEST_ID = - `${INSIGHTS_TAB_TEST_ID}Entities` as const; export const INSIGHTS_TAB_THREAT_INTELLIGENCE_BUTTON_TEST_ID = `${INSIGHTS_TAB_TEST_ID}ThreatIntelligenceButton` as const; -export const INSIGHTS_TAB_PREVALENCE_BUTTON_LABEL_TEST_ID = - `${INSIGHTS_TAB_TEST_ID}Prevalence` as const; export const INSIGHTS_TAB_PREVALENCE_BUTTON_TEST_ID = `${INSIGHTS_TAB_TEST_ID}PrevalenceButton` as const; export const INSIGHTS_TAB_CORRELATIONS_BUTTON_TEST_ID = diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/document_details/left/test_ids.ts index 4e2c1be90b01c..9f5eeb035786c 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/test_ids.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/test_ids.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { PREFIX } from '@kbn/security-solution-common'; +import { PREFIX } from '../../shared/test_ids'; export const VISUALIZE_TAB_TEST_ID = `${PREFIX}VisualizeTab` as const; export const INSIGHTS_TAB_TEST_ID = `${PREFIX}InsightsTab` as const; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/preview/footer.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/preview/footer.tsx index f437c9e77a158..0201332888675 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/preview/footer.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/preview/footer.tsx @@ -9,9 +9,9 @@ import { EuiLink, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; -import { FlyoutFooter } from '@kbn/security-solution-common'; import { getField } from '../shared/utils'; import { EventKind } from '../shared/constants/event_kinds'; +import { FlyoutFooter } from '../../shared/components/flyout_footer'; import { DocumentDetailsRightPanelKey } from '../shared/constants/panel_keys'; import { useDocumentDetailsContext } from '../shared/context'; import { PREVIEW_FOOTER_TEST_ID, PREVIEW_FOOTER_LINK_TEST_ID } from './test_ids'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/alert_header_title.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/alert_header_title.tsx index 931da6e8e57c8..cc7ef14585833 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/alert_header_title.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/alert_header_title.tsx @@ -9,7 +9,6 @@ import React, { memo, useCallback, useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiLink } from '@elastic/eui'; import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils'; import { i18n } from '@kbn/i18n'; -import { FlyoutTitle } from '@kbn/security-solution-common'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { Notes } from './notes'; import { useRuleDetailsLink } from '../../shared/hooks/use_rule_details_link'; @@ -22,6 +21,7 @@ import { useDocumentDetailsContext } from '../../shared/context'; import { PreferenceFormattedDate } from '../../../../common/components/formatted_date'; import { FLYOUT_ALERT_HEADER_TITLE_TEST_ID, ALERT_SUMMARY_PANEL_TEST_ID } from './test_ids'; import { Assignees } from './assignees'; +import { FlyoutTitle } from '../../../shared/components/flyout_title'; // minWidth for each block, allows to switch for a 1 row 4 blocks to 2 rows with 2 block each const blockStyles = { diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview_container.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview_container.test.tsx index f9179cecc6b5a..3e841da34a4fa 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview_container.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview_container.test.tsx @@ -21,7 +21,8 @@ import { EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID, EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID, EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID, -} from '@kbn/security-solution-common'; +} from '../../../shared/components/test_ids'; + import { useInvestigateInTimeline } from '../../../../detections/components/alerts_table/timeline_actions/use_investigate_in_timeline'; jest.mock( diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview_container.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview_container.tsx index 6896f15ca88cb..286394d16aadc 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview_container.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview_container.tsx @@ -10,7 +10,6 @@ import { useDispatch } from 'react-redux'; import { TimelineTabs } from '@kbn/securitysolution-data-table'; import { EuiLink, EuiMark } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { ExpandablePanel } from '@kbn/security-solution-common'; import { useUiSetting$ } from '@kbn/kibana-react-plugin/public'; import { ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING } from '../../../../../common/constants'; import { useStartTransaction } from '../../../../common/lib/apm/use_start_transaction'; @@ -23,6 +22,7 @@ import { useIsInvestigateInResolverActionEnabled } from '../../../../detections/ import { AnalyzerPreview } from './analyzer_preview'; import { ANALYZER_PREVIEW_TEST_ID } from './test_ids'; import { useNavigateToAnalyzer } from '../../shared/hooks/use_navigate_to_analyzer'; +import { ExpandablePanel } from '../../../shared/components/expandable_panel'; const timelineId = 'timeline-1'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.test.tsx index f4a97b7deed11..ff8961a087801 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.test.tsx @@ -22,7 +22,8 @@ import { CORRELATIONS_RELATED_CASES_TEST_ID, CORRELATIONS_SUPPRESSED_ALERTS_TEST_ID, CORRELATIONS_TEST_ID, - SUMMARY_ROW_VALUE_TEST_ID, + SUMMARY_ROW_BUTTON_TEST_ID, + SUMMARY_ROW_TEXT_TEST_ID, } from './test_ids'; import { useShowRelatedAlertsByAncestry } from '../../shared/hooks/use_show_related_alerts_by_ancestry'; import { useShowRelatedAlertsBySameSourceEvent } from '../../shared/hooks/use_show_related_alerts_by_same_source_event'; @@ -39,7 +40,7 @@ import { EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID, EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID, EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID, -} from '@kbn/security-solution-common'; +} from '../../../shared/components/test_ids'; import { useTourContext } from '../../../../common/components/guided_onboarding_tour'; import { AlertsCasesTourSteps } from '../../../../common/components/guided_onboarding_tour/tour_config'; @@ -58,17 +59,32 @@ const TITLE_LINK_TEST_ID = EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(CORRELATIO const TITLE_ICON_TEST_ID = EXPANDABLE_PANEL_HEADER_TITLE_ICON_TEST_ID(CORRELATIONS_TEST_ID); const TITLE_TEXT_TEST_ID = EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID(CORRELATIONS_TEST_ID); -const SUPPRESSED_ALERTS_TEST_ID = SUMMARY_ROW_VALUE_TEST_ID(CORRELATIONS_SUPPRESSED_ALERTS_TEST_ID); -const RELATED_ALERTS_BY_ANCESTRY_TEST_ID = SUMMARY_ROW_VALUE_TEST_ID( +const SUPPRESSED_ALERTS_TEXT_TEST_ID = SUMMARY_ROW_TEXT_TEST_ID( + CORRELATIONS_SUPPRESSED_ALERTS_TEST_ID +); +const SUPPRESSED_ALERTS_VALUE_TEST_ID = SUMMARY_ROW_BUTTON_TEST_ID( + CORRELATIONS_SUPPRESSED_ALERTS_TEST_ID +); +const RELATED_ALERTS_BY_ANCESTRY_TEXT_TEST_ID = SUMMARY_ROW_TEXT_TEST_ID( + CORRELATIONS_RELATED_ALERTS_BY_ANCESTRY_TEST_ID +); +const RELATED_ALERTS_BY_ANCESTRY_VALUE_TEST_ID = SUMMARY_ROW_BUTTON_TEST_ID( CORRELATIONS_RELATED_ALERTS_BY_ANCESTRY_TEST_ID ); -const RELATED_ALERTS_BY_SAME_SOURCE_EVENT_TEST_ID = SUMMARY_ROW_VALUE_TEST_ID( +const RELATED_ALERTS_BY_SAME_SOURCE_EVENT_TEXT_TEST_ID = SUMMARY_ROW_TEXT_TEST_ID( CORRELATIONS_RELATED_ALERTS_BY_SAME_SOURCE_EVENT_TEST_ID ); -const RELATED_ALERTS_BY_SESSION_TEST_ID = SUMMARY_ROW_VALUE_TEST_ID( +const RELATED_ALERTS_BY_SAME_SOURCE_EVENT_VALUE_TEST_ID = SUMMARY_ROW_BUTTON_TEST_ID( + CORRELATIONS_RELATED_ALERTS_BY_SAME_SOURCE_EVENT_TEST_ID +); +const RELATED_ALERTS_BY_SESSION_TEXT_TEST_ID = SUMMARY_ROW_TEXT_TEST_ID( + CORRELATIONS_RELATED_ALERTS_BY_SESSION_TEST_ID +); +const RELATED_ALERTS_BY_SESSION_VALUE_TEST_ID = SUMMARY_ROW_BUTTON_TEST_ID( CORRELATIONS_RELATED_ALERTS_BY_SESSION_TEST_ID ); -const RELATED_CASES_TEST_ID = SUMMARY_ROW_VALUE_TEST_ID(CORRELATIONS_RELATED_CASES_TEST_ID); +const RELATED_CASES_TEXT_TEST_ID = SUMMARY_ROW_TEXT_TEST_ID(CORRELATIONS_RELATED_CASES_TEST_ID); +const RELATED_CASES_VALUE_TEST_ID = SUMMARY_ROW_BUTTON_TEST_ID(CORRELATIONS_RELATED_CASES_TEST_ID); const panelContextValue = { eventId: 'event id', @@ -111,7 +127,7 @@ describe('', () => { jest.mocked(useTourContext).mockReturnValue({ hidden: false, setAllTourStepsHidden: jest.fn(), - activeStep: AlertsCasesTourSteps.viewCase, + activeStep: AlertsCasesTourSteps.submitCase, endTourStep: jest.fn(), incrementStep: jest.fn(), isTourShown: jest.fn(), @@ -193,11 +209,16 @@ describe('', () => { }); const { getByTestId, queryByText } = render(renderCorrelationsOverview(panelContextValue)); - expect(getByTestId(RELATED_ALERTS_BY_ANCESTRY_TEST_ID)).toBeInTheDocument(); - expect(getByTestId(RELATED_ALERTS_BY_SAME_SOURCE_EVENT_TEST_ID)).toBeInTheDocument(); - expect(getByTestId(RELATED_ALERTS_BY_SESSION_TEST_ID)).toBeInTheDocument(); - expect(getByTestId(RELATED_CASES_TEST_ID)).toBeInTheDocument(); - expect(getByTestId(SUPPRESSED_ALERTS_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(RELATED_ALERTS_BY_ANCESTRY_TEXT_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(RELATED_ALERTS_BY_ANCESTRY_VALUE_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(RELATED_ALERTS_BY_SAME_SOURCE_EVENT_TEXT_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(RELATED_ALERTS_BY_SAME_SOURCE_EVENT_VALUE_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(RELATED_ALERTS_BY_SESSION_TEXT_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(RELATED_ALERTS_BY_SESSION_VALUE_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(RELATED_CASES_TEXT_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(RELATED_CASES_VALUE_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(SUPPRESSED_ALERTS_TEXT_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(SUPPRESSED_ALERTS_VALUE_TEST_ID)).toBeInTheDocument(); expect(queryByText(NO_DATA_MESSAGE)).not.toBeInTheDocument(); }); @@ -215,11 +236,18 @@ describe('', () => { jest.mocked(useShowSuppressedAlerts).mockReturnValue({ show: false, alertSuppressionCount: 0 }); const { getByText, queryByTestId } = render(renderCorrelationsOverview(panelContextValue)); - expect(queryByTestId(RELATED_ALERTS_BY_ANCESTRY_TEST_ID)).not.toBeInTheDocument(); - expect(queryByTestId(RELATED_ALERTS_BY_SAME_SOURCE_EVENT_TEST_ID)).not.toBeInTheDocument(); - expect(queryByTestId(RELATED_ALERTS_BY_SESSION_TEST_ID)).not.toBeInTheDocument(); - expect(queryByTestId(RELATED_CASES_TEST_ID)).not.toBeInTheDocument(); - expect(queryByTestId(SUPPRESSED_ALERTS_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(RELATED_ALERTS_BY_ANCESTRY_TEXT_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(RELATED_ALERTS_BY_ANCESTRY_VALUE_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(RELATED_ALERTS_BY_SAME_SOURCE_EVENT_TEXT_TEST_ID)).not.toBeInTheDocument(); + expect( + queryByTestId(RELATED_ALERTS_BY_SAME_SOURCE_EVENT_VALUE_TEST_ID) + ).not.toBeInTheDocument(); + expect(queryByTestId(RELATED_ALERTS_BY_SESSION_TEXT_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(RELATED_ALERTS_BY_SESSION_VALUE_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(RELATED_CASES_TEXT_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(RELATED_CASES_VALUE_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(SUPPRESSED_ALERTS_TEXT_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(SUPPRESSED_ALERTS_VALUE_TEST_ID)).not.toBeInTheDocument(); expect(getByText(NO_DATA_MESSAGE)).toBeInTheDocument(); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.tsx index c2494b4cde675..4043230d5269e 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.tsx @@ -12,8 +12,8 @@ import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { FormattedMessage } from '@kbn/i18n-react'; import { ALERT_RULE_TYPE } from '@kbn/rule-data-utils'; -import { ExpandablePanel } from '@kbn/security-solution-common'; import type { Type } from '@kbn/securitysolution-io-ts-alerting-types'; +import { ExpandablePanel } from '../../../shared/components/expandable_panel'; import { useShowRelatedAlertsBySession } from '../../shared/hooks/use_show_related_alerts_by_session'; import { RelatedAlertsBySession } from './related_alerts_by_session'; import { useShowRelatedAlertsBySameSourceEvent } from '../../shared/hooks/use_show_related_alerts_by_same_source_event'; @@ -73,7 +73,7 @@ export const CorrelationsOverview: React.FC = () => { }, [eventId, openLeftPanel, indexName, scopeId]); useEffect(() => { - if (isTourShown(SecurityStepId.alertsCases) && activeStep === AlertsCasesTourSteps.viewCase) { + if (isTourShown(SecurityStepId.alertsCases) && activeStep === AlertsCasesTourSteps.createCase) { goToCorrelationsTab(); } }, [activeStep, goToCorrelationsTab, isTourShown]); @@ -134,7 +134,7 @@ export const CorrelationsOverview: React.FC = () => { data-test-subj={CORRELATIONS_TEST_ID} > {canShowAtLeastOneInsight ? ( - + {showSuppressedAlerts && ( )} diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/entities_overview.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/entities_overview.test.tsx index e4975d3136424..92248c6de2828 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/entities_overview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/entities_overview.test.tsx @@ -24,7 +24,7 @@ import { EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID, EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID, EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID, -} from '@kbn/security-solution-common'; +} from '../../../shared/components/test_ids'; import { useRiskScore } from '../../../../entity_analytics/api/hooks/use_risk_score'; const from = '2022-04-05T12:00:00.000Z'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/entities_overview.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/entities_overview.tsx index 60657a1346101..16fe6cbe1c1e0 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/entities_overview.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/entities_overview.tsx @@ -9,8 +9,8 @@ import React, { useCallback, useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { FormattedMessage } from '@kbn/i18n-react'; -import { ExpandablePanel } from '@kbn/security-solution-common'; import { INSIGHTS_ENTITIES_TEST_ID } from './test_ids'; +import { ExpandablePanel } from '../../../shared/components/expandable_panel'; import { useDocumentDetailsContext } from '../../shared/context'; import { getField } from '../../shared/utils'; import { HostEntityOverview } from './host_entity_overview'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/event_header_title.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/event_header_title.tsx index f93b8451c744a..953a2371ffa88 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/event_header_title.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/event_header_title.tsx @@ -9,7 +9,7 @@ import React, { memo, useMemo } from 'react'; import { startCase } from 'lodash'; import { EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FlyoutTitle } from '@kbn/security-solution-common'; +import { FlyoutTitle } from '../../../shared/components/flyout_title'; import { DocumentSeverity } from './severity'; import { useBasicDataFromDetailsData } from '../../shared/hooks/use_basic_data_from_details_data'; import { useDocumentDetailsContext } from '../../shared/context'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/graph_preview_container.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/graph_preview_container.test.tsx index 6b30e2127a2f8..d44321a4926bd 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/graph_preview_container.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/graph_preview_container.test.tsx @@ -14,14 +14,13 @@ import { GraphPreviewContainer } from './graph_preview_container'; import { GRAPH_PREVIEW_TEST_ID } from './test_ids'; import { useGraphPreview } from '../hooks/use_graph_preview'; import { useFetchGraphData } from '../hooks/use_fetch_graph_data'; - import { EXPANDABLE_PANEL_CONTENT_TEST_ID, EXPANDABLE_PANEL_HEADER_TITLE_ICON_TEST_ID, EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID, EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID, EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID, -} from '@kbn/security-solution-common'; +} from '../../../shared/components/test_ids'; jest.mock('../hooks/use_graph_preview'); jest.mock('../hooks/use_fetch_graph_data', () => ({ diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/graph_preview_container.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/graph_preview_container.tsx index 1bc6a8dd7e547..be65593364593 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/graph_preview_container.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/graph_preview_container.tsx @@ -7,12 +7,12 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { ExpandablePanel } from '@kbn/security-solution-common'; import { useDocumentDetailsContext } from '../../shared/context'; import { GRAPH_PREVIEW_TEST_ID } from './test_ids'; import { GraphPreview } from './graph_preview'; import { useFetchGraphData } from '../hooks/use_fetch_graph_data'; import { useGraphPreview } from '../hooks/use_graph_preview'; +import { ExpandablePanel } from '../../../shared/components/expandable_panel'; const DEFAULT_FROM = 'now-60d/d'; const DEFAULT_TO = 'now/d'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.tsx index 90405286b004c..9b60eefbb5f61 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.tsx @@ -285,10 +285,12 @@ export const HostEntityOverview: React.FC = ({ hostName fieldName={'host.name'} name={hostName} data-test-subj={ENTITIES_HOST_OVERVIEW_MISCONFIGURATIONS_TEST_ID} + telemetrySuffix={'host-entity-overview'} /> ); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_section.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_section.test.tsx index 96dff8150e654..c06481c6b2812 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_section.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_section.test.tsx @@ -171,7 +171,7 @@ describe('', () => { it('should render the component expanded if guided onboarding tour is shown', () => { (useExpandSection as jest.Mock).mockReturnValue(false); - mockUseTourContext.mockReturnValue({ activeStep: 7, isTourShown: jest.fn(() => true) }); + mockUseTourContext.mockReturnValue({ activeStep: 5, isTourShown: jest.fn(() => true) }); const contextValue = { eventId: 'some_Id', diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_section.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_section.tsx index 19c75a77cbabf..c2d71ee37baa8 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_section.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_section.tsx @@ -35,7 +35,7 @@ export const InsightsSection = memo(() => { const { activeStep, isTourShown } = useTourContext(); const isGuidedOnboardingTourShown = - isTourShown(SecurityStepId.alertsCases) && activeStep === AlertsCasesTourSteps.viewCase; + isTourShown(SecurityStepId.alertsCases) && activeStep === AlertsCasesTourSteps.createCase; const expanded = useExpandSection({ title: KEY, defaultValue: false }) || isGuidedOnboardingTourShown; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_summary_row.stories.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_summary_row.stories.tsx deleted file mode 100644 index eb76108d6b215..0000000000000 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_summary_row.stories.tsx +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import type { Story } from '@storybook/react'; -import { css } from '@emotion/react'; -import { InsightsSummaryRow } from './insights_summary_row'; - -export default { - component: InsightsSummaryRow, - title: 'Flyout/InsightsSummaryRow', -}; - -const wrapper = (children: React.ReactNode) => ( -
- {children} -
-); - -export const Default: Story = () => - wrapper( - - ); - -export const InvalidColor: Story = () => - wrapper( - - ); - -export const NoColor: Story = () => - wrapper(); - -export const LongText: Story = () => - wrapper( - - ); - -export const LongNumber: Story = () => - wrapper( - - ); - -export const Loading: Story = () => - wrapper(); - -export const Error: Story = () => - wrapper(); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_summary_row.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_summary_row.test.tsx index 3e10e83332a97..2a721e317781e 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_summary_row.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_summary_row.test.tsx @@ -9,74 +9,147 @@ import React from 'react'; import { render } from '@testing-library/react'; import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { InsightsSummaryRow } from './insights_summary_row'; +import { useDocumentDetailsContext } from '../../shared/context'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { DocumentDetailsLeftPanelKey } from '../../shared/constants/panel_keys'; +import { LeftPanelInsightsTab } from '../../left'; + +jest.mock('@kbn/expandable-flyout'); +jest.mock('../../shared/context'); + +const mockOpenLeftPanel = jest.fn(); +const scopeId = 'scopeId'; +const eventId = 'eventId'; +const indexName = 'indexName'; const testId = 'test'; -const iconTestId = `${testId}Icon`; +const textTestId = `${testId}Text`; +const buttonTestId = `${testId}Button`; const valueTestId = `${testId}Value`; -const colorTestId = `${testId}Color`; const loadingTestId = `${testId}Loading`; describe('', () => { - it('should render by default', () => { + beforeEach(() => { + jest.clearAllMocks(); + + (useDocumentDetailsContext as jest.Mock).mockReturnValue({ + eventId, + indexName, + scopeId, + isPreviewMode: false, + }); + (useExpandableFlyoutApi as jest.Mock).mockReturnValue({ openLeftPanel: mockOpenLeftPanel }); + }); + + it('should render loading skeleton if loading is true', () => { const { getByTestId } = render( + {'value for this'}
} + data-test-subj={testId} + /> + ); + + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + }); + + it('should only render null when error is true', () => { + const { container } = render( + {'value for this'}
} /> + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it('should render the value component', () => { + const { getByTestId, queryByTestId } = render( {'value for this'}
} data-test-subj={testId} /> ); - expect(getByTestId(iconTestId)).toBeInTheDocument(); - expect(getByTestId(valueTestId)).toHaveTextContent('1 this is a test for red'); - expect(getByTestId(colorTestId)).toBeInTheDocument(); + expect(getByTestId(textTestId)).toHaveTextContent('this is a test for red'); + expect(getByTestId(valueTestId)).toHaveTextContent('value for this'); + expect(queryByTestId(buttonTestId)).not.toBeInTheDocument(); }); - it('should render loading skeletton if loading is true', () => { - const { getByTestId } = render( - + it('should render the value as EuiBadge and EuiButtonEmpty', () => { + const { getByTestId, queryByTestId } = render( + + + ); - expect(getByTestId(loadingTestId)).toBeInTheDocument(); + expect(getByTestId(textTestId)).toHaveTextContent('this is a test for red'); + expect(getByTestId(buttonTestId)).toHaveTextContent('2'); + expect(queryByTestId(valueTestId)).not.toBeInTheDocument(); }); - it('should only render null when error is true', () => { - const { container } = render(); + it('should render big numbers formatted correctly', () => { + const { getByTestId } = render( + + + + ); - expect(container).toBeEmptyDOMElement(); + expect(getByTestId(buttonTestId)).toHaveTextContent('2k'); }); - it('should handle big number in a compact notation', () => { + it('should open the expanded section to the correct tab when the number is clicked', () => { const { getByTestId } = render( ); + getByTestId(buttonTestId).click(); - expect(getByTestId(valueTestId)).toHaveTextContent('160k this is a test for red'); + expect(mockOpenLeftPanel).toHaveBeenCalledWith({ + id: DocumentDetailsLeftPanelKey, + path: { + tab: LeftPanelInsightsTab, + subTab: 'subTab', + }, + params: { + id: eventId, + indexName, + scopeId, + }, + }); }); - it(`should not show the colored dot if color isn't provided`, () => { - const { queryByTestId } = render( + it('should disabled the click when in preview mode', () => { + (useDocumentDetailsContext as jest.Mock).mockReturnValue({ + eventId, + indexName, + scopeId, + isPreviewMode: true, + }); + + const { getByTestId } = render( ); + const button = getByTestId(buttonTestId); + + expect(button).toHaveAttribute('disabled'); - expect(queryByTestId(colorTestId)).not.toBeInTheDocument(); + button.click(); + expect(mockOpenLeftPanel).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_summary_row.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_summary_row.tsx index 23f838f5068bb..56a19d2eca965 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_summary_row.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_summary_row.tsx @@ -6,19 +6,25 @@ */ import type { ReactElement, VFC } from 'react'; -import React from 'react'; +import React, { useMemo, useCallback } from 'react'; import { css } from '@emotion/react'; import { i18n } from '@kbn/i18n'; -import { - EuiIcon, - EuiFlexGroup, - EuiFlexItem, - EuiHealth, - EuiSkeletonText, - useEuiTheme, -} from '@elastic/eui'; +import { EuiBadge, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSkeletonText } from '@elastic/eui'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { useDocumentDetailsContext } from '../../shared/context'; +import { DocumentDetailsLeftPanelKey } from '../../shared/constants/panel_keys'; +import { LeftPanelInsightsTab } from '../../left'; import { FormattedCount } from '../../../../common/components/formatted_number'; +const LOADING = i18n.translate( + 'xpack.securitySolution.flyout.right.insights.insightSummaryLoadingAriaLabel', + { defaultMessage: 'Loading' } +); +const BUTTON = i18n.translate( + 'xpack.securitySolution.flyout.right.insights.insightSummaryButtonAriaLabel', + { defaultMessage: 'Click to see more details' } +); + export interface InsightsSummaryRowProps { /** * Optional parameter used to display a loading spinner @@ -29,22 +35,17 @@ export interface InsightsSummaryRowProps { */ error?: boolean; /** - * Icon to display on the left side of each row + * Text corresponding of the number of results/entries */ - icon: string; + text: string | ReactElement; /** * Number of results/entries found */ - value?: number; + value: number | ReactElement; /** - * Text corresponding of the number of results/entries + * Optional parameter used to know which subtab to navigate to when the user clicks on the button */ - text: string | ReactElement; - /** - * Optional parameter for now, will be used to display a dot on the right side - * (corresponding to some sort of severity?) - */ - color?: string; // TODO remove optional when we have guidance on what the colors will actually be + expandedSubTab?: string; /** * Prefix data-test-subj because this component will be used in multiple places */ @@ -52,35 +53,73 @@ export interface InsightsSummaryRowProps { } /** - * Panel showing summary information as an icon, a count and text as well as a severity colored dot. + * Panel showing summary information. + * The default display is a text on the left and a count on the right, displayed with a clickable EuiBadge. + * The left and right section can accept a ReactElement to allow for more complex display. * Should be used for Entities, Threat intelligence, Prevalence, Correlations and Results components under the Insights section. - * The colored dot is currently optional but will ultimately be mandatory (waiting on PM and UIUX). */ export const InsightsSummaryRow: VFC = ({ loading = false, error = false, - icon, value, text, - color, + expandedSubTab, 'data-test-subj': dataTestSubj, }) => { - const { euiTheme } = useEuiTheme(); + const { eventId, indexName, scopeId, isPreviewMode } = useDocumentDetailsContext(); + const { openLeftPanel } = useExpandableFlyoutApi(); + + const onClick = useCallback(() => { + openLeftPanel({ + id: DocumentDetailsLeftPanelKey, + path: { + tab: LeftPanelInsightsTab, + subTab: expandedSubTab, + }, + params: { + id: eventId, + indexName, + scopeId, + }, + }); + }, [eventId, expandedSubTab, indexName, openLeftPanel, scopeId]); + + const textDataTestSubj = useMemo(() => `${dataTestSubj}Text`, [dataTestSubj]); + const loadingDataTestSubj = useMemo(() => `${dataTestSubj}Loading`, [dataTestSubj]); + + const button = useMemo(() => { + const buttonDataTestSubj = `${dataTestSubj}Button`; + const valueDataTestSubj = `${dataTestSubj}Value`; + + return ( + <> + {typeof value === 'number' ? ( + + + + + + ) : ( +
{value}
+ )} + + ); + }, [dataTestSubj, isPreviewMode, onClick, value]); - const loadingDataTestSubj = `${dataTestSubj}Loading`; if (loading) { return ( ); @@ -90,10 +129,6 @@ export const InsightsSummaryRow: VFC = ({ return null; } - const iconDataTestSubj = `${dataTestSubj}Icon`; - const valueDataTestSubj = `${dataTestSubj}Value`; - const colorDataTestSubj = `${dataTestSubj}Color`; - return ( = ({ alignItems={'center'} responsive={false} > - - - = ({ overflow: hidden; `} > - {value && } {text} + {text} - {color && ( - - - - )} + {button} ); }; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/notes.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/notes.test.tsx index f70cd2aae3e8d..023b0202ecb63 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/notes.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/notes.test.tsx @@ -23,6 +23,7 @@ import type { Note } from '../../../../../common/api/timeline'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { DocumentDetailsLeftPanelKey } from '../../shared/constants/panel_keys'; import { LeftPanelNotesTab } from '../../left'; +import { getEmptyValue } from '../../../../common/components/empty_value'; jest.mock('@kbn/expandable-flyout'); @@ -43,6 +44,10 @@ jest.mock('react-redux', () => { }); describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should render loading spinner', () => { (useExpandableFlyoutApi as jest.Mock).mockReturnValue({ openLeftPanel: jest.fn() }); @@ -99,6 +104,34 @@ describe('', () => { }); }); + it('should disabled the Add note button if in preview mode', () => { + const contextValue = { + ...mockContextValue, + isPreviewMode: true, + }; + + const mockOpenLeftPanel = jest.fn(); + (useExpandableFlyoutApi as jest.Mock).mockReturnValue({ openLeftPanel: mockOpenLeftPanel }); + + const { getByTestId } = render( + + + + + + ); + + expect(mockDispatch).not.toHaveBeenCalled(); + + const button = getByTestId(NOTES_ADD_NOTE_BUTTON_TEST_ID); + expect(button).toBeInTheDocument(); + expect(button).toBeDisabled(); + + button.click(); + + expect(mockOpenLeftPanel).not.toHaveBeenCalled(); + }); + it('should render number of notes and plus button', () => { const mockOpenLeftPanel = jest.fn(); (useExpandableFlyoutApi as jest.Mock).mockReturnValue({ openLeftPanel: mockOpenLeftPanel }); @@ -135,6 +168,38 @@ describe('', () => { }); }); + it('should disable the plus button if in preview mode', () => { + const mockOpenLeftPanel = jest.fn(); + (useExpandableFlyoutApi as jest.Mock).mockReturnValue({ openLeftPanel: mockOpenLeftPanel }); + + const contextValue = { + ...mockContextValue, + eventId: '1', + isPreviewMode: true, + }; + + const { getByTestId } = render( + + + + + + ); + + expect(getByTestId(NOTES_COUNT_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(NOTES_COUNT_TEST_ID)).toHaveTextContent('1'); + + expect(mockDispatch).not.toHaveBeenCalled(); + + const button = getByTestId(NOTES_ADD_NOTE_ICON_BUTTON_TEST_ID); + + expect(button).toBeInTheDocument(); + button.click(); + expect(button).toBeDisabled(); + + expect(mockOpenLeftPanel).not.toHaveBeenCalled(); + }); + it('should render number of notes in scientific notation for big numbers', () => { const createMockNote = (noteId: string): Note => ({ eventId: '1', // should be a valid id based on mockTimelineData @@ -180,6 +245,30 @@ describe('', () => { expect(getByTestId(NOTES_COUNT_TEST_ID)).toHaveTextContent('1k'); }); + it('should show a - when in rule creation workflow', () => { + const contextValue = { + ...mockContextValue, + isPreview: true, + }; + + (useExpandableFlyoutApi as jest.Mock).mockReturnValue({ openLeftPanel: jest.fn() }); + + const { getByText, queryByTestId } = render( + + + + + + ); + + expect(mockDispatch).not.toHaveBeenCalled(); + + expect(queryByTestId(NOTES_ADD_NOTE_ICON_BUTTON_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(NOTES_ADD_NOTE_BUTTON_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(NOTES_COUNT_TEST_ID)).not.toBeInTheDocument(); + expect(getByText(getEmptyValue())).toBeInTheDocument(); + }); + it('should render toast error', () => { const store = createMockStore({ ...mockGlobalState, diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/notes.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/notes.tsx index a981a16117a68..b0e2008c04103 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/notes.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/notes.tsx @@ -19,6 +19,7 @@ import { } from '@elastic/eui'; import { css } from '@emotion/react'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { getEmptyTagValue } from '../../../../common/components/empty_value'; import { DocumentDetailsLeftPanelKey } from '../../shared/constants/panel_keys'; import { FormattedCount } from '../../../../common/components/formatted_number'; import { useDocumentDetailsContext } from '../../shared/context'; @@ -61,7 +62,7 @@ export const ADD_NOTE_BUTTON = i18n.translate( export const Notes = memo(() => { const { euiTheme } = useEuiTheme(); const dispatch = useDispatch(); - const { eventId, indexName, scopeId } = useDocumentDetailsContext(); + const { eventId, indexName, scopeId, isPreview, isPreviewMode } = useDocumentDetailsContext(); const { addError: addErrorToast } = useAppToasts(); const { openLeftPanel } = useExpandableFlyoutApi(); @@ -80,8 +81,11 @@ export const Notes = memo(() => { ); useEffect(() => { - dispatch(fetchNotesByDocumentIds({ documentIds: [eventId] })); - }, [dispatch, eventId]); + // only fetch notes if we are not in a preview panel, or not in a rule preview workflow + if (!isPreviewMode && !isPreview) { + dispatch(fetchNotesByDocumentIds({ documentIds: [eventId] })); + } + }, [dispatch, eventId, isPreview, isPreviewMode]); const fetchStatus = useSelector((state: State) => selectFetchNotesByDocumentIdsStatus(state)); const fetchError = useSelector((state: State) => selectFetchNotesByDocumentIdsError(state)); @@ -107,37 +111,45 @@ export const Notes = memo(() => { } data-test-subj={NOTES_TITLE_TEST_ID} > - {fetchStatus === ReqStatus.Loading ? ( - + {isPreview ? ( + getEmptyTagValue() ) : ( <> - {notes.length === 0 ? ( - - {ADD_NOTE_BUTTON} - + {fetchStatus === ReqStatus.Loading ? ( + ) : ( - - - - - - + {notes.length === 0 ? ( + - - + data-test-subj={NOTES_ADD_NOTE_BUTTON_TEST_ID} + > + {ADD_NOTE_BUTTON} + + ) : ( + + + + + + + + + )} + )} )} diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/prevalence_overview.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/prevalence_overview.test.tsx index a47ed04c85b5a..57770b58e2fcb 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/prevalence_overview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/prevalence_overview.test.tsx @@ -8,7 +8,11 @@ import { render } from '@testing-library/react'; import { TestProviders } from '../../../../common/mock'; import { DocumentDetailsContext } from '../../shared/context'; -import { PREVALENCE_TEST_ID } from './test_ids'; +import { + PREVALENCE_TEST_ID, + SUMMARY_ROW_TEXT_TEST_ID, + SUMMARY_ROW_VALUE_TEST_ID, +} from './test_ids'; import { DocumentDetailsLeftPanelKey } from '../../shared/constants/panel_keys'; import { LeftPanelInsightsTab } from '../../left'; import React from 'react'; @@ -20,7 +24,7 @@ import { EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID, EXPANDABLE_PANEL_LOADING_TEST_ID, EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID, -} from '@kbn/security-solution-common'; +} from '../../../shared/components/test_ids'; import { usePrevalence } from '../../shared/hooks/use_prevalence'; import { mockContextValue } from '../../shared/mocks/mock_context'; import { type ExpandableFlyoutApi, useExpandableFlyoutApi } from '@kbn/expandable-flyout'; @@ -38,7 +42,10 @@ const flyoutContextValue = { openLeftPanel: jest.fn(), } as unknown as ExpandableFlyoutApi; -jest.mock('@kbn/expandable-flyout'); +jest.mock('@kbn/expandable-flyout', () => ({ + useExpandableFlyoutApi: jest.fn(), + ExpandableFlyoutProvider: ({ children }: React.PropsWithChildren<{}>) => <>{children}, +})); const renderPrevalenceOverview = (contextValue: DocumentDetailsContext = mockContextValue) => render( @@ -149,21 +156,19 @@ describe('', () => { expect(getByTestId(TITLE_LINK_TEST_ID)).toHaveTextContent('Prevalence'); - const iconDataTestSubj1 = `${PREVALENCE_TEST_ID}${field1}Icon`; - const valueDataTestSubj1 = `${PREVALENCE_TEST_ID}${field1}Value`; - expect(getByTestId(iconDataTestSubj1)).toBeInTheDocument(); - expect(getByTestId(valueDataTestSubj1)).toBeInTheDocument(); - expect(getByTestId(valueDataTestSubj1)).toHaveTextContent('field1, value1 is uncommon'); - - const iconDataTestSubj2 = `${PREVALENCE_TEST_ID}${field2}Icon`; - const valueDataTestSubj2 = `${PREVALENCE_TEST_ID}${field2}Value`; - expect(getByTestId(iconDataTestSubj2)).toBeInTheDocument(); - expect(getByTestId(valueDataTestSubj2)).toBeInTheDocument(); - expect(getByTestId(valueDataTestSubj2)).toHaveTextContent('field2, value2,value22 is uncommon'); - - const iconDataTestSubj3 = `${PREVALENCE_TEST_ID}${field3}Icon`; - const valueDataTestSubj3 = `${PREVALENCE_TEST_ID}${field3}Value`; - expect(queryByTestId(iconDataTestSubj3)).not.toBeInTheDocument(); + const textDataTestSubj1 = SUMMARY_ROW_TEXT_TEST_ID(`${PREVALENCE_TEST_ID}${field1}`); + const valueDataTestSubj1 = SUMMARY_ROW_VALUE_TEST_ID(`${PREVALENCE_TEST_ID}${field1}`); + expect(getByTestId(textDataTestSubj1)).toHaveTextContent('field1, value1'); + expect(getByTestId(valueDataTestSubj1)).toHaveTextContent('Uncommon'); + + const textDataTestSubj2 = SUMMARY_ROW_TEXT_TEST_ID(`${PREVALENCE_TEST_ID}${field2}`); + const valueDataTestSubj2 = SUMMARY_ROW_VALUE_TEST_ID(`${PREVALENCE_TEST_ID}${field2}`); + expect(getByTestId(textDataTestSubj2)).toHaveTextContent('field2, value2'); + expect(getByTestId(valueDataTestSubj2)).toHaveTextContent('Uncommon'); + + const textDataTestSubj3 = SUMMARY_ROW_TEXT_TEST_ID(`${PREVALENCE_TEST_ID}${field3}`); + const valueDataTestSubj3 = SUMMARY_ROW_VALUE_TEST_ID(`${PREVALENCE_TEST_ID}${field3}`); + expect(queryByTestId(textDataTestSubj3)).not.toBeInTheDocument(); expect(queryByTestId(valueDataTestSubj3)).not.toBeInTheDocument(); expect(queryByText(NO_DATA_MESSAGE)).not.toBeInTheDocument(); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/prevalence_overview.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/prevalence_overview.tsx index 96ee603607742..966df0293db77 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/prevalence_overview.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/prevalence_overview.tsx @@ -7,10 +7,10 @@ import type { FC } from 'react'; import React, { useCallback, useMemo } from 'react'; -import { EuiFlexGroup } from '@elastic/eui'; +import { EuiBadge, EuiFlexGroup } from '@elastic/eui'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { FormattedMessage } from '@kbn/i18n-react'; -import { ExpandablePanel } from '@kbn/security-solution-common'; +import { ExpandablePanel } from '../../../shared/components/expandable_panel'; import { usePrevalence } from '../../shared/hooks/use_prevalence'; import { PREVALENCE_TEST_ID } from './test_ids'; import { useDocumentDetailsContext } from '../../shared/context'; @@ -19,6 +19,13 @@ import { LeftPanelInsightsTab } from '../../left'; import { PREVALENCE_TAB_ID } from '../../left/components/prevalence_details'; import { InsightsSummaryRow } from './insights_summary_row'; +const UNCOMMON = ( + +); + const PERCENTAGE_THRESHOLD = 0.1; // we show the prevalence if its value is below 10% const DEFAULT_FROM = 'now-30d'; const DEFAULT_TO = 'now'; @@ -104,18 +111,17 @@ export const PrevalenceOverview: FC = () => { content={{ loading, error }} data-test-subj={PREVALENCE_TEST_ID} > - + {uncommonData.length > 0 ? ( uncommonData.map((d) => ( + <> + {d.field} + {','} {d.values.toString()} + } + value={{UNCOMMON}} data-test-subj={`${PREVALENCE_TEST_ID}${d.field}`} key={`${PREVALENCE_TEST_ID}${d.field}`} /> diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_alerts_by_ancestry.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_alerts_by_ancestry.test.tsx index 5aad641c6e400..38efe27b16ea9 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_alerts_by_ancestry.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_alerts_by_ancestry.test.tsx @@ -9,22 +9,32 @@ import React from 'react'; import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { render } from '@testing-library/react'; import { - SUMMARY_ROW_ICON_TEST_ID, - SUMMARY_ROW_VALUE_TEST_ID, + SUMMARY_ROW_TEXT_TEST_ID, SUMMARY_ROW_LOADING_TEST_ID, CORRELATIONS_RELATED_ALERTS_BY_ANCESTRY_TEST_ID, + SUMMARY_ROW_BUTTON_TEST_ID, } from './test_ids'; import { RelatedAlertsByAncestry } from './related_alerts_by_ancestry'; import { useFetchRelatedAlertsByAncestry } from '../../shared/hooks/use_fetch_related_alerts_by_ancestry'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { DocumentDetailsLeftPanelKey } from '../../shared/constants/panel_keys'; +import { LeftPanelInsightsTab } from '../../left'; +import { CORRELATIONS_TAB_ID } from '../../left/components/correlations_details'; +import { useDocumentDetailsContext } from '../../shared/context'; +jest.mock('@kbn/expandable-flyout'); +jest.mock('../../shared/context'); jest.mock('../../shared/hooks/use_fetch_related_alerts_by_ancestry'); +const mockOpenLeftPanel = jest.fn(); const documentId = 'documentId'; const indices = ['indices']; const scopeId = 'scopeId'; +const eventId = 'eventId'; +const indexName = 'indexName'; -const ICON_TEST_ID = SUMMARY_ROW_ICON_TEST_ID(CORRELATIONS_RELATED_ALERTS_BY_ANCESTRY_TEST_ID); -const VALUE_TEST_ID = SUMMARY_ROW_VALUE_TEST_ID(CORRELATIONS_RELATED_ALERTS_BY_ANCESTRY_TEST_ID); +const TEXT_TEST_ID = SUMMARY_ROW_TEXT_TEST_ID(CORRELATIONS_RELATED_ALERTS_BY_ANCESTRY_TEST_ID); +const BUTTON_TEST_ID = SUMMARY_ROW_BUTTON_TEST_ID(CORRELATIONS_RELATED_ALERTS_BY_ANCESTRY_TEST_ID); const LOADING_TEST_ID = SUMMARY_ROW_LOADING_TEST_ID( CORRELATIONS_RELATED_ALERTS_BY_ANCESTRY_TEST_ID ); @@ -37,34 +47,40 @@ const renderRelatedAlertsByAncestry = () => ); describe('', () => { - it('should render many related alerts correctly', () => { + beforeEach(() => { + jest.clearAllMocks(); + + (useDocumentDetailsContext as jest.Mock).mockReturnValue({ + eventId, + indexName, + scopeId, + isPreviewMode: false, + }); + (useExpandableFlyoutApi as jest.Mock).mockReturnValue({ openLeftPanel: mockOpenLeftPanel }); + }); + + it('should render single related alert correctly', () => { (useFetchRelatedAlertsByAncestry as jest.Mock).mockReturnValue({ loading: false, error: false, - dataCount: 2, + dataCount: 1, }); const { getByTestId } = renderRelatedAlertsByAncestry(); - expect(getByTestId(ICON_TEST_ID)).toBeInTheDocument(); - const value = getByTestId(VALUE_TEST_ID); - expect(value).toBeInTheDocument(); - expect(value).toHaveTextContent('2 alerts related by ancestry'); - expect(getByTestId(VALUE_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(TEXT_TEST_ID)).toHaveTextContent('Alert related by ancestry'); + expect(getByTestId(BUTTON_TEST_ID)).toHaveTextContent('1'); }); - it('should render single related alerts correctly', () => { + it('should render multiple related alerts correctly', () => { (useFetchRelatedAlertsByAncestry as jest.Mock).mockReturnValue({ loading: false, error: false, - dataCount: 1, + dataCount: 2, }); const { getByTestId } = renderRelatedAlertsByAncestry(); - expect(getByTestId(ICON_TEST_ID)).toBeInTheDocument(); - const value = getByTestId(VALUE_TEST_ID); - expect(value).toBeInTheDocument(); - expect(value).toHaveTextContent('1 alert related by ancestry'); - expect(getByTestId(VALUE_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(TEXT_TEST_ID)).toHaveTextContent('Alerts related by ancestry'); + expect(getByTestId(BUTTON_TEST_ID)).toHaveTextContent('2'); }); it('should render loading skeleton', () => { @@ -85,4 +101,28 @@ describe('', () => { const { container } = renderRelatedAlertsByAncestry(); expect(container).toBeEmptyDOMElement(); }); + + it('should open the expanded section to the correct tab when the number is clicked', () => { + (useFetchRelatedAlertsByAncestry as jest.Mock).mockReturnValue({ + loading: false, + error: false, + dataCount: 1, + }); + + const { getByTestId } = renderRelatedAlertsByAncestry(); + getByTestId(BUTTON_TEST_ID).click(); + + expect(mockOpenLeftPanel).toHaveBeenCalledWith({ + id: DocumentDetailsLeftPanelKey, + path: { + tab: LeftPanelInsightsTab, + subTab: CORRELATIONS_TAB_ID, + }, + params: { + id: eventId, + indexName, + scopeId, + }, + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_alerts_by_ancestry.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_alerts_by_ancestry.tsx index 2e628ba61a7be..4b225d5595883 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_alerts_by_ancestry.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_alerts_by_ancestry.tsx @@ -5,14 +5,13 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; +import { CORRELATIONS_TAB_ID } from '../../left/components/correlations_details'; import { useFetchRelatedAlertsByAncestry } from '../../shared/hooks/use_fetch_related_alerts_by_ancestry'; import { InsightsSummaryRow } from './insights_summary_row'; import { CORRELATIONS_RELATED_ALERTS_BY_ANCESTRY_TEST_ID } from './test_ids'; -const ICON = 'warning'; - export interface RelatedAlertsByAncestryProps { /** * Id of the document @@ -41,21 +40,25 @@ export const RelatedAlertsByAncestry: React.VFC = indices, scopeId, }); - const text = ( - + + const text = useMemo( + () => ( + + ), + [dataCount] ); return ( diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_alerts_by_same_source_event.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_alerts_by_same_source_event.test.tsx index d52d547397789..80e7c99a60917 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_alerts_by_same_source_event.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_alerts_by_same_source_event.test.tsx @@ -9,23 +9,33 @@ import React from 'react'; import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { render } from '@testing-library/react'; import { - SUMMARY_ROW_ICON_TEST_ID, - SUMMARY_ROW_VALUE_TEST_ID, + SUMMARY_ROW_TEXT_TEST_ID, SUMMARY_ROW_LOADING_TEST_ID, CORRELATIONS_RELATED_ALERTS_BY_SAME_SOURCE_EVENT_TEST_ID, + SUMMARY_ROW_BUTTON_TEST_ID, } from './test_ids'; import { useFetchRelatedAlertsBySameSourceEvent } from '../../shared/hooks/use_fetch_related_alerts_by_same_source_event'; import { RelatedAlertsBySameSourceEvent } from './related_alerts_by_same_source_event'; +import { DocumentDetailsLeftPanelKey } from '../../shared/constants/panel_keys'; +import { LeftPanelInsightsTab } from '../../left'; +import { CORRELATIONS_TAB_ID } from '../../left/components/correlations_details'; +import { useDocumentDetailsContext } from '../../shared/context'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +jest.mock('@kbn/expandable-flyout'); +jest.mock('../../shared/context'); jest.mock('../../shared/hooks/use_fetch_related_alerts_by_same_source_event'); +const mockOpenLeftPanel = jest.fn(); const originalEventId = 'originalEventId'; const scopeId = 'scopeId'; +const eventId = 'eventId'; +const indexName = 'indexName'; -const ICON_TEST_ID = SUMMARY_ROW_ICON_TEST_ID( +const TEXT_TEST_ID = SUMMARY_ROW_TEXT_TEST_ID( CORRELATIONS_RELATED_ALERTS_BY_SAME_SOURCE_EVENT_TEST_ID ); -const VALUE_TEST_ID = SUMMARY_ROW_VALUE_TEST_ID( +const BUTTON_TEST_ID = SUMMARY_ROW_BUTTON_TEST_ID( CORRELATIONS_RELATED_ALERTS_BY_SAME_SOURCE_EVENT_TEST_ID ); const LOADING_TEST_ID = SUMMARY_ROW_LOADING_TEST_ID( @@ -40,34 +50,40 @@ const renderRelatedAlertsBySameSourceEvent = () => ); describe('', () => { - it('should render many related alerts correctly', () => { + beforeEach(() => { + jest.clearAllMocks(); + + (useDocumentDetailsContext as jest.Mock).mockReturnValue({ + eventId, + indexName, + scopeId, + isPreviewMode: false, + }); + (useExpandableFlyoutApi as jest.Mock).mockReturnValue({ openLeftPanel: mockOpenLeftPanel }); + }); + + it('should render single related alert correctly', () => { (useFetchRelatedAlertsBySameSourceEvent as jest.Mock).mockReturnValue({ loading: false, error: false, - dataCount: 2, + dataCount: 1, }); const { getByTestId } = renderRelatedAlertsBySameSourceEvent(); - expect(getByTestId(ICON_TEST_ID)).toBeInTheDocument(); - const value = getByTestId(VALUE_TEST_ID); - expect(value).toBeInTheDocument(); - expect(value).toHaveTextContent('2 alerts related by source event'); - expect(getByTestId(VALUE_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(TEXT_TEST_ID)).toHaveTextContent('Alert related by source event'); + expect(getByTestId(BUTTON_TEST_ID)).toHaveTextContent('1'); }); - it('should render single related alerts correctly', () => { + it('should render multiple related alerts correctly', () => { (useFetchRelatedAlertsBySameSourceEvent as jest.Mock).mockReturnValue({ loading: false, error: false, - dataCount: 1, + dataCount: 2, }); const { getByTestId } = renderRelatedAlertsBySameSourceEvent(); - expect(getByTestId(ICON_TEST_ID)).toBeInTheDocument(); - const value = getByTestId(VALUE_TEST_ID); - expect(value).toBeInTheDocument(); - expect(value).toHaveTextContent('1 alert related by source event'); - expect(getByTestId(VALUE_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(TEXT_TEST_ID)).toHaveTextContent('Alerts related by source event'); + expect(getByTestId(BUTTON_TEST_ID)).toHaveTextContent('2'); }); it('should render loading skeleton', () => { @@ -87,10 +103,31 @@ describe('', () => { }); const { getByTestId } = renderRelatedAlertsBySameSourceEvent(); - expect(getByTestId(ICON_TEST_ID)).toBeInTheDocument(); - const value = getByTestId(VALUE_TEST_ID); - expect(value).toBeInTheDocument(); - expect(value).toHaveTextContent('0 alerts related by source event'); - expect(getByTestId(VALUE_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(TEXT_TEST_ID)).toHaveTextContent('Alerts related by source event'); + expect(getByTestId(BUTTON_TEST_ID)).toHaveTextContent('0'); + }); + + it('should open the expanded section to the correct tab when the number is clicked', () => { + (useFetchRelatedAlertsBySameSourceEvent as jest.Mock).mockReturnValue({ + loading: false, + error: true, + dataCount: 1, + }); + + const { getByTestId } = renderRelatedAlertsBySameSourceEvent(); + getByTestId(BUTTON_TEST_ID).click(); + + expect(mockOpenLeftPanel).toHaveBeenCalledWith({ + id: DocumentDetailsLeftPanelKey, + path: { + tab: LeftPanelInsightsTab, + subTab: CORRELATIONS_TAB_ID, + }, + params: { + id: eventId, + indexName, + scopeId, + }, + }); }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_alerts_by_same_source_event.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_alerts_by_same_source_event.tsx index 0c1550dbb8692..dade35ca75546 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_alerts_by_same_source_event.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_alerts_by_same_source_event.tsx @@ -5,13 +5,12 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { useFetchRelatedAlertsBySameSourceEvent } from '../../shared/hooks/use_fetch_related_alerts_by_same_source_event'; -import { InsightsSummaryRow } from './insights_summary_row'; +import { CORRELATIONS_TAB_ID } from '../../left/components/correlations_details'; import { CORRELATIONS_RELATED_ALERTS_BY_SAME_SOURCE_EVENT_TEST_ID } from './test_ids'; - -const ICON = 'warning'; +import { InsightsSummaryRow } from './insights_summary_row'; +import { useFetchRelatedAlertsBySameSourceEvent } from '../../shared/hooks/use_fetch_related_alerts_by_same_source_event'; export interface RelatedAlertsBySameSourceEventProps { /** @@ -35,20 +34,24 @@ export const RelatedAlertsBySameSourceEvent: React.VFC + + const text = useMemo( + () => ( + + ), + [dataCount] ); return ( diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_alerts_by_session.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_alerts_by_session.test.tsx index 96ab397229420..4aeeef1feb8b1 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_alerts_by_session.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_alerts_by_session.test.tsx @@ -9,21 +9,31 @@ import React from 'react'; import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { render } from '@testing-library/react'; import { - SUMMARY_ROW_ICON_TEST_ID, - SUMMARY_ROW_VALUE_TEST_ID, + SUMMARY_ROW_TEXT_TEST_ID, SUMMARY_ROW_LOADING_TEST_ID, CORRELATIONS_RELATED_ALERTS_BY_SESSION_TEST_ID, + SUMMARY_ROW_BUTTON_TEST_ID, } from './test_ids'; import { RelatedAlertsBySession } from './related_alerts_by_session'; import { useFetchRelatedAlertsBySession } from '../../shared/hooks/use_fetch_related_alerts_by_session'; +import { DocumentDetailsLeftPanelKey } from '../../shared/constants/panel_keys'; +import { LeftPanelInsightsTab } from '../../left'; +import { CORRELATIONS_TAB_ID } from '../../left/components/correlations_details'; +import { useDocumentDetailsContext } from '../../shared/context'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +jest.mock('@kbn/expandable-flyout'); +jest.mock('../../shared/context'); jest.mock('../../shared/hooks/use_fetch_related_alerts_by_session'); +const mockOpenLeftPanel = jest.fn(); +const eventId = 'eventId'; +const indexName = 'indexName'; const entityId = 'entityId'; const scopeId = 'scopeId'; -const ICON_TEST_ID = SUMMARY_ROW_ICON_TEST_ID(CORRELATIONS_RELATED_ALERTS_BY_SESSION_TEST_ID); -const VALUE_TEST_ID = SUMMARY_ROW_VALUE_TEST_ID(CORRELATIONS_RELATED_ALERTS_BY_SESSION_TEST_ID); +const TEXT_TEST_ID = SUMMARY_ROW_TEXT_TEST_ID(CORRELATIONS_RELATED_ALERTS_BY_SESSION_TEST_ID); +const BUTTON_TEST_ID = SUMMARY_ROW_BUTTON_TEST_ID(CORRELATIONS_RELATED_ALERTS_BY_SESSION_TEST_ID); const LOADING_TEST_ID = SUMMARY_ROW_LOADING_TEST_ID(CORRELATIONS_RELATED_ALERTS_BY_SESSION_TEST_ID); const renderRelatedAlertsBySession = () => @@ -34,34 +44,40 @@ const renderRelatedAlertsBySession = () => ); describe('', () => { - it('should render many related alerts correctly', () => { + beforeEach(() => { + jest.clearAllMocks(); + + (useDocumentDetailsContext as jest.Mock).mockReturnValue({ + eventId, + indexName, + scopeId, + isPreviewMode: false, + }); + (useExpandableFlyoutApi as jest.Mock).mockReturnValue({ openLeftPanel: mockOpenLeftPanel }); + }); + + it('should render single related alerts correctly', () => { (useFetchRelatedAlertsBySession as jest.Mock).mockReturnValue({ loading: false, error: false, - dataCount: 2, + dataCount: 1, }); const { getByTestId } = renderRelatedAlertsBySession(); - expect(getByTestId(ICON_TEST_ID)).toBeInTheDocument(); - const value = getByTestId(VALUE_TEST_ID); - expect(value).toBeInTheDocument(); - expect(value).toHaveTextContent('2 alerts related by session'); - expect(getByTestId(VALUE_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(TEXT_TEST_ID)).toHaveTextContent('Alert related by session'); + expect(getByTestId(BUTTON_TEST_ID)).toHaveTextContent('1'); }); - it('should render single related alerts correctly', () => { + it('should render multiple related alerts correctly', () => { (useFetchRelatedAlertsBySession as jest.Mock).mockReturnValue({ loading: false, error: false, - dataCount: 1, + dataCount: 2, }); const { getByTestId } = renderRelatedAlertsBySession(); - expect(getByTestId(ICON_TEST_ID)).toBeInTheDocument(); - const value = getByTestId(VALUE_TEST_ID); - expect(value).toBeInTheDocument(); - expect(value).toHaveTextContent('1 alert related by session'); - expect(getByTestId(VALUE_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(TEXT_TEST_ID)).toHaveTextContent('Alerts related by session'); + expect(getByTestId(BUTTON_TEST_ID)).toHaveTextContent('2'); }); it('should render loading skeleton', () => { @@ -82,4 +98,28 @@ describe('', () => { const { container } = renderRelatedAlertsBySession(); expect(container).toBeEmptyDOMElement(); }); + + it('should open the expanded section to the correct tab when the number is clicked', () => { + (useFetchRelatedAlertsBySession as jest.Mock).mockReturnValue({ + loading: false, + error: false, + dataCount: 1, + }); + + const { getByTestId } = renderRelatedAlertsBySession(); + getByTestId(BUTTON_TEST_ID).click(); + + expect(mockOpenLeftPanel).toHaveBeenCalledWith({ + id: DocumentDetailsLeftPanelKey, + path: { + tab: LeftPanelInsightsTab, + subTab: CORRELATIONS_TAB_ID, + }, + params: { + id: eventId, + indexName, + scopeId, + }, + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_alerts_by_session.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_alerts_by_session.tsx index 4b41389137fad..9037ebca232a0 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_alerts_by_session.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_alerts_by_session.tsx @@ -5,14 +5,13 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; +import { CORRELATIONS_TAB_ID } from '../../left/components/correlations_details'; import { useFetchRelatedAlertsBySession } from '../../shared/hooks/use_fetch_related_alerts_by_session'; import { InsightsSummaryRow } from './insights_summary_row'; import { CORRELATIONS_RELATED_ALERTS_BY_SESSION_TEST_ID } from './test_ids'; -const ICON = 'warning'; - export interface RelatedAlertsBySessionProps { /** * Value of the process.entry_leader.entity_id field @@ -35,21 +34,25 @@ export const RelatedAlertsBySession: React.VFC = ({ entityId, scopeId, }); - const text = ( - + + const text = useMemo( + () => ( + + ), + [dataCount] ); return ( diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_cases.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_cases.test.tsx index 3d20e6399af38..e55d0e109d1d7 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_cases.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_cases.test.tsx @@ -10,19 +10,29 @@ import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { render } from '@testing-library/react'; import { CORRELATIONS_RELATED_CASES_TEST_ID, - SUMMARY_ROW_ICON_TEST_ID, + SUMMARY_ROW_TEXT_TEST_ID, SUMMARY_ROW_LOADING_TEST_ID, - SUMMARY_ROW_VALUE_TEST_ID, + SUMMARY_ROW_BUTTON_TEST_ID, } from './test_ids'; import { RelatedCases } from './related_cases'; import { useFetchRelatedCases } from '../../shared/hooks/use_fetch_related_cases'; +import { DocumentDetailsLeftPanelKey } from '../../shared/constants/panel_keys'; +import { LeftPanelInsightsTab } from '../../left'; +import { CORRELATIONS_TAB_ID } from '../../left/components/correlations_details'; +import { useDocumentDetailsContext } from '../../shared/context'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +jest.mock('@kbn/expandable-flyout'); +jest.mock('../../shared/context'); jest.mock('../../shared/hooks/use_fetch_related_cases'); +const mockOpenLeftPanel = jest.fn(); const eventId = 'eventId'; +const indexName = 'indexName'; +const scopeId = 'scopeId'; -const ICON_TEST_ID = SUMMARY_ROW_ICON_TEST_ID(CORRELATIONS_RELATED_CASES_TEST_ID); -const VALUE_TEST_ID = SUMMARY_ROW_VALUE_TEST_ID(CORRELATIONS_RELATED_CASES_TEST_ID); +const TEXT_TEST_ID = SUMMARY_ROW_TEXT_TEST_ID(CORRELATIONS_RELATED_CASES_TEST_ID); +const BUTTON_TEST_ID = SUMMARY_ROW_BUTTON_TEST_ID(CORRELATIONS_RELATED_CASES_TEST_ID); const LOADING_TEST_ID = SUMMARY_ROW_LOADING_TEST_ID(CORRELATIONS_RELATED_CASES_TEST_ID); const renderRelatedCases = () => @@ -33,34 +43,40 @@ const renderRelatedCases = () => ); describe('', () => { - it('should render many related cases correctly', () => { + beforeEach(() => { + jest.clearAllMocks(); + + (useDocumentDetailsContext as jest.Mock).mockReturnValue({ + eventId, + indexName, + scopeId, + isPreviewMode: false, + }); + (useExpandableFlyoutApi as jest.Mock).mockReturnValue({ openLeftPanel: mockOpenLeftPanel }); + }); + + it('should render single related case correctly', () => { (useFetchRelatedCases as jest.Mock).mockReturnValue({ loading: false, error: false, - dataCount: 2, + dataCount: 1, }); const { getByTestId } = renderRelatedCases(); - expect(getByTestId(ICON_TEST_ID)).toBeInTheDocument(); - const value = getByTestId(VALUE_TEST_ID); - expect(value).toBeInTheDocument(); - expect(value).toHaveTextContent('2 related cases'); - expect(getByTestId(VALUE_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(TEXT_TEST_ID)).toHaveTextContent('Related case'); + expect(getByTestId(BUTTON_TEST_ID)).toHaveTextContent('1'); }); - it('should render single related case correctly', () => { + it('should render multiple related cases correctly', () => { (useFetchRelatedCases as jest.Mock).mockReturnValue({ loading: false, error: false, - dataCount: 1, + dataCount: 2, }); const { getByTestId } = renderRelatedCases(); - expect(getByTestId(ICON_TEST_ID)).toBeInTheDocument(); - const value = getByTestId(VALUE_TEST_ID); - expect(value).toBeInTheDocument(); - expect(value).toHaveTextContent('1 related case'); - expect(getByTestId(VALUE_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(TEXT_TEST_ID)).toHaveTextContent('Related cases'); + expect(getByTestId(BUTTON_TEST_ID)).toHaveTextContent('2'); }); it('should render loading skeleton', () => { @@ -81,4 +97,28 @@ describe('', () => { const { container } = renderRelatedCases(); expect(container).toBeEmptyDOMElement(); }); + + it('should open the expanded section to the correct tab when the number is clicked', () => { + (useFetchRelatedCases as jest.Mock).mockReturnValue({ + loading: false, + error: false, + dataCount: 1, + }); + + const { getByTestId } = renderRelatedCases(); + getByTestId(BUTTON_TEST_ID).click(); + + expect(mockOpenLeftPanel).toHaveBeenCalledWith({ + id: DocumentDetailsLeftPanelKey, + path: { + tab: LeftPanelInsightsTab, + subTab: CORRELATIONS_TAB_ID, + }, + params: { + id: eventId, + indexName, + scopeId, + }, + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_cases.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_cases.tsx index d45cc971dc046..8a01b21799d86 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_cases.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/related_cases.tsx @@ -5,13 +5,12 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { useFetchRelatedCases } from '../../shared/hooks/use_fetch_related_cases'; -import { InsightsSummaryRow } from './insights_summary_row'; +import { CORRELATIONS_TAB_ID } from '../../left/components/correlations_details'; import { CORRELATIONS_RELATED_CASES_TEST_ID } from './test_ids'; - -const ICON = 'warning'; +import { InsightsSummaryRow } from './insights_summary_row'; +import { useFetchRelatedCases } from '../../shared/hooks/use_fetch_related_cases'; export interface RelatedCasesProps { /** @@ -21,25 +20,29 @@ export interface RelatedCasesProps { } /** - * + * Show related cases in summary row */ export const RelatedCases: React.VFC = ({ eventId }) => { const { loading, error, dataCount } = useFetchRelatedCases({ eventId }); - const text = ( - + + const text = useMemo( + () => ( + + ), + [dataCount] ); return ( diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview_container.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview_container.test.tsx index db7f60938c0c3..9ccb512030b43 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview_container.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview_container.test.tsx @@ -19,7 +19,7 @@ import { EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID, EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID, EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID, -} from '@kbn/security-solution-common'; +} from '../../../shared/components/test_ids'; import { mockContextValue } from '../../shared/mocks/mock_context'; jest.mock('../hooks/use_session_preview'); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview_container.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview_container.tsx index 974c74b995393..4098b35a0abfc 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview_container.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview_container.tsx @@ -9,7 +9,6 @@ import React, { type FC, useCallback } from 'react'; import { TimelineTabs } from '@kbn/securitysolution-data-table'; import { useDispatch } from 'react-redux'; import { FormattedMessage } from '@kbn/i18n-react'; -import { ExpandablePanel } from '@kbn/security-solution-common'; import { useUiSetting$ } from '@kbn/kibana-react-plugin/public'; import { ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING } from '../../../../../common/constants'; import { useLicense } from '../../../../common/hooks/use_license'; @@ -18,6 +17,7 @@ import { useSessionPreview } from '../hooks/use_session_preview'; import { useInvestigateInTimeline } from '../../../../detections/components/alerts_table/timeline_actions/use_investigate_in_timeline'; import { useDocumentDetailsContext } from '../../shared/context'; import { ALERTS_ACTIONS } from '../../../../common/lib/apm/user_actions'; +import { ExpandablePanel } from '../../../shared/components/expandable_panel'; import { SESSION_PREVIEW_TEST_ID } from './test_ids'; import { useStartTransaction } from '../../../../common/lib/apm/use_start_transaction'; import { setActiveTabTimeline } from '../../../../timelines/store/actions'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/suppressed_alerts.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/suppressed_alerts.test.tsx index b5954c251c014..331283e194ed0 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/suppressed_alerts.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/suppressed_alerts.test.tsx @@ -9,16 +9,27 @@ import React from 'react'; import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { render } from '@testing-library/react'; import { - SUMMARY_ROW_ICON_TEST_ID, - SUMMARY_ROW_VALUE_TEST_ID, + SUMMARY_ROW_TEXT_TEST_ID, CORRELATIONS_SUPPRESSED_ALERTS_TEST_ID, CORRELATIONS_SUPPRESSED_ALERTS_TECHNICAL_PREVIEW_TEST_ID, + SUMMARY_ROW_BUTTON_TEST_ID, } from './test_ids'; import { SuppressedAlerts } from './suppressed_alerts'; import { isSuppressionRuleInGA } from '../../../../../common/detection_engine/utils'; +import { DocumentDetailsLeftPanelKey } from '../../shared/constants/panel_keys'; +import { LeftPanelInsightsTab } from '../../left'; +import { CORRELATIONS_TAB_ID } from '../../left/components/correlations_details'; +import { useDocumentDetailsContext } from '../../shared/context'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; -const ICON_TEST_ID = SUMMARY_ROW_ICON_TEST_ID(CORRELATIONS_SUPPRESSED_ALERTS_TEST_ID); -const VALUE_TEST_ID = SUMMARY_ROW_VALUE_TEST_ID(CORRELATIONS_SUPPRESSED_ALERTS_TEST_ID); +jest.mock('@kbn/expandable-flyout'); +jest.mock('../../shared/context'); +jest.mock('../../../../../common/detection_engine/utils', () => ({ + isSuppressionRuleInGA: jest.fn().mockReturnValue(false), +})); + +const TEXT_TEST_ID = SUMMARY_ROW_TEXT_TEST_ID(CORRELATIONS_SUPPRESSED_ALERTS_TEST_ID); +const BUTTON_TEST_ID = SUMMARY_ROW_BUTTON_TEST_ID(CORRELATIONS_SUPPRESSED_ALERTS_TEST_ID); const renderSuppressedAlerts = (alertSuppressionCount: number) => render( @@ -27,34 +38,30 @@ const renderSuppressedAlerts = (alertSuppressionCount: number) => ); -jest.mock('../../../../../common/detection_engine/utils', () => ({ - isSuppressionRuleInGA: jest.fn().mockReturnValue(false), -})); - +const mockOpenLeftPanel = jest.fn(); +const scopeId = 'scopeId'; +const eventId = 'eventId'; +const indexName = 'indexName'; const isSuppressionRuleInGAMock = isSuppressionRuleInGA as jest.Mock; describe('', () => { - it('should render zero suppressed alert correctly', () => { - const { getByTestId } = renderSuppressedAlerts(0); + beforeEach(() => { + jest.clearAllMocks(); - expect(getByTestId(ICON_TEST_ID)).toBeInTheDocument(); - const value = getByTestId(VALUE_TEST_ID); - expect(value).toBeInTheDocument(); - expect(value).toHaveTextContent('0 suppressed alert'); - expect(getByTestId(VALUE_TEST_ID)).toBeInTheDocument(); - expect( - getByTestId(CORRELATIONS_SUPPRESSED_ALERTS_TECHNICAL_PREVIEW_TEST_ID) - ).toBeInTheDocument(); + (useDocumentDetailsContext as jest.Mock).mockReturnValue({ + eventId, + indexName, + scopeId, + isPreviewMode: false, + }); + (useExpandableFlyoutApi as jest.Mock).mockReturnValue({ openLeftPanel: mockOpenLeftPanel }); }); it('should render single suppressed alert correctly', () => { const { getByTestId } = renderSuppressedAlerts(1); - expect(getByTestId(ICON_TEST_ID)).toBeInTheDocument(); - const value = getByTestId(VALUE_TEST_ID); - expect(value).toBeInTheDocument(); - expect(value).toHaveTextContent('1 suppressed alert'); - expect(getByTestId(VALUE_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(TEXT_TEST_ID)).toHaveTextContent('Suppressed alert'); + expect(getByTestId(BUTTON_TEST_ID)).toHaveTextContent('1'); expect( getByTestId(CORRELATIONS_SUPPRESSED_ALERTS_TECHNICAL_PREVIEW_TEST_ID) ).toBeInTheDocument(); @@ -63,14 +70,8 @@ describe('', () => { it('should render multiple suppressed alerts row correctly', () => { const { getByTestId } = renderSuppressedAlerts(2); - expect(getByTestId(ICON_TEST_ID)).toBeInTheDocument(); - const value = getByTestId(VALUE_TEST_ID); - expect(value).toBeInTheDocument(); - expect(value).toHaveTextContent('2 suppressed alerts'); - expect(getByTestId(VALUE_TEST_ID)).toBeInTheDocument(); - expect( - getByTestId(CORRELATIONS_SUPPRESSED_ALERTS_TECHNICAL_PREVIEW_TEST_ID) - ).toBeInTheDocument(); + expect(getByTestId(TEXT_TEST_ID)).toHaveTextContent('Suppressed alerts'); + expect(getByTestId(BUTTON_TEST_ID)).toHaveTextContent('2'); }); it('should not render Technical Preview badge if rule type is in GA', () => { @@ -81,4 +82,22 @@ describe('', () => { queryByTestId(CORRELATIONS_SUPPRESSED_ALERTS_TECHNICAL_PREVIEW_TEST_ID) ).not.toBeInTheDocument(); }); + + it('should open the expanded section to the correct tab when the number is clicked', () => { + const { getByTestId } = renderSuppressedAlerts(1); + getByTestId(BUTTON_TEST_ID).click(); + + expect(mockOpenLeftPanel).toHaveBeenCalledWith({ + id: DocumentDetailsLeftPanelKey, + path: { + tab: LeftPanelInsightsTab, + subTab: CORRELATIONS_TAB_ID, + }, + params: { + id: eventId, + indexName, + scopeId, + }, + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/suppressed_alerts.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/suppressed_alerts.tsx index a8cd147a4ac14..c7eb50aeb383a 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/suppressed_alerts.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/suppressed_alerts.tsx @@ -5,18 +5,19 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiBetaBadge } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import type { Type } from '@kbn/securitysolution-io-ts-alerting-types'; import { i18n } from '@kbn/i18n'; -import { isSuppressionRuleInGA } from '../../../../../common/detection_engine/utils'; +import { CORRELATIONS_TAB_ID } from '../../left/components/correlations_details'; +import { InsightsSummaryRow } from './insights_summary_row'; import { CORRELATIONS_SUPPRESSED_ALERTS_TEST_ID, CORRELATIONS_SUPPRESSED_ALERTS_TECHNICAL_PREVIEW_TEST_ID, } from './test_ids'; -import { InsightsSummaryRow } from './insights_summary_row'; +import { isSuppressionRuleInGA } from '../../../../../common/detection_engine/utils'; const SUPPRESSED_ALERTS_COUNT_TECHNICAL_PREVIEW = i18n.translate( 'xpack.securitySolution.flyout.right.overview.insights.suppressedAlertsCountTechnicalPreview', @@ -43,21 +44,24 @@ export const SuppressedAlerts: React.VFC = ({ alertSuppressionCount, ruleType, }) => { + const text = useMemo( + () => ( + + ), + [alertSuppressionCount] + ); + return ( - } + expandedSubTab={CORRELATIONS_TAB_ID} data-test-subj={CORRELATIONS_SUPPRESSED_ALERTS_TEST_ID} key={`correlation-row-suppressed-alerts`} /> diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/test_ids.ts index e649c578bf487..959f8f106bb08 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/test_ids.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/test_ids.ts @@ -104,8 +104,9 @@ export const INSIGHTS_CONTENT_TEST_ID = INSIGHTS_TEST_ID + CONTENT_TEST_ID; /* Summary row */ export const SUMMARY_ROW_LOADING_TEST_ID = (dataTestSubj: string) => `${dataTestSubj}Loading`; -export const SUMMARY_ROW_ICON_TEST_ID = (dataTestSubj: string) => `${dataTestSubj}Icon`; +export const SUMMARY_ROW_TEXT_TEST_ID = (dataTestSubj: string) => `${dataTestSubj}Text`; export const SUMMARY_ROW_VALUE_TEST_ID = (dataTestSubj: string) => `${dataTestSubj}Value`; +export const SUMMARY_ROW_BUTTON_TEST_ID = (dataTestSubj: string) => `${dataTestSubj}Button`; /* Entities */ @@ -146,6 +147,10 @@ export const ENTITIES_HOST_OVERVIEW_VULNERABILITIES_TEST_ID = /* Threat intelligence */ export const INSIGHTS_THREAT_INTELLIGENCE_TEST_ID = `${PREFIX}InsightsThreatIntelligence` as const; +export const INSIGHTS_THREAT_INTELLIGENCE_THREAT_MATCHES_TEST_ID = + `${INSIGHTS_THREAT_INTELLIGENCE_TEST_ID}ThreatMatches` as const; +export const INSIGHTS_THREAT_INTELLIGENCE_ENRICHED_WITH_THREAT_INTELLIGENCE_TEST_ID = + `${INSIGHTS_THREAT_INTELLIGENCE_TEST_ID}EnrichedWithThreatIntelligence` as const; /* Correlations */ diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/threat_intelligence_overview.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/threat_intelligence_overview.test.tsx index af92283b781b5..1d19dacc39d42 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/threat_intelligence_overview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/threat_intelligence_overview.test.tsx @@ -6,25 +6,32 @@ */ import React from 'react'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { render } from '@testing-library/react'; -import { useExpandableFlyoutApi, type ExpandableFlyoutApi } from '@kbn/expandable-flyout'; -import { DocumentDetailsContext } from '../../shared/context'; -import { TestProviders } from '../../../../common/mock'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { useDocumentDetailsContext } from '../../shared/context'; import { ThreatIntelligenceOverview } from './threat_intelligence_overview'; import { DocumentDetailsLeftPanelKey } from '../../shared/constants/panel_keys'; import { LeftPanelInsightsTab } from '../../left'; import { useFetchThreatIntelligence } from '../hooks/use_fetch_threat_intelligence'; import { THREAT_INTELLIGENCE_TAB_ID } from '../../left/components/threat_intelligence_details'; -import { INSIGHTS_THREAT_INTELLIGENCE_TEST_ID } from './test_ids'; import { - EXPANDABLE_PANEL_CONTENT_TEST_ID, + INSIGHTS_THREAT_INTELLIGENCE_ENRICHED_WITH_THREAT_INTELLIGENCE_TEST_ID, + INSIGHTS_THREAT_INTELLIGENCE_TEST_ID, + INSIGHTS_THREAT_INTELLIGENCE_THREAT_MATCHES_TEST_ID, + SUMMARY_ROW_BUTTON_TEST_ID, + SUMMARY_ROW_TEXT_TEST_ID, +} from './test_ids'; +import { EXPANDABLE_PANEL_HEADER_TITLE_ICON_TEST_ID, EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID, EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID, EXPANDABLE_PANEL_LOADING_TEST_ID, EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID, -} from '@kbn/security-solution-common'; +} from '../../../shared/components/test_ids'; +jest.mock('@kbn/expandable-flyout'); +jest.mock('../../shared/context'); jest.mock('../hooks/use_fetch_threat_intelligence'); const TOGGLE_ICON_TEST_ID = EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID( @@ -39,32 +46,45 @@ const TITLE_ICON_TEST_ID = EXPANDABLE_PANEL_HEADER_TITLE_ICON_TEST_ID( const TITLE_TEXT_TEST_ID = EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID( INSIGHTS_THREAT_INTELLIGENCE_TEST_ID ); -const CONTENT_TEST_ID = EXPANDABLE_PANEL_CONTENT_TEST_ID(INSIGHTS_THREAT_INTELLIGENCE_TEST_ID); const LOADING_TEST_ID = EXPANDABLE_PANEL_LOADING_TEST_ID(INSIGHTS_THREAT_INTELLIGENCE_TEST_ID); +const THREAT_MATCHES_TEXT_TEST_ID = SUMMARY_ROW_TEXT_TEST_ID( + INSIGHTS_THREAT_INTELLIGENCE_THREAT_MATCHES_TEST_ID +); +const THREAT_MATCHES_BUTTON_TEST_ID = SUMMARY_ROW_BUTTON_TEST_ID( + INSIGHTS_THREAT_INTELLIGENCE_THREAT_MATCHES_TEST_ID +); +const ENRICHED_WITH_THREAT_INTELLIGENCE_TEXT_TEST_ID = SUMMARY_ROW_TEXT_TEST_ID( + INSIGHTS_THREAT_INTELLIGENCE_ENRICHED_WITH_THREAT_INTELLIGENCE_TEST_ID +); +const ENRICHED_WITH_THREAT_INTELLIGENCE_BUTTON_TEST_ID = SUMMARY_ROW_BUTTON_TEST_ID( + INSIGHTS_THREAT_INTELLIGENCE_ENRICHED_WITH_THREAT_INTELLIGENCE_TEST_ID +); -const panelContextValue = { - eventId: 'event id', - indexName: 'indexName', - dataFormattedForFieldBrowser: [], -} as unknown as DocumentDetailsContext; +const mockOpenLeftPanel = jest.fn(); +const eventId = 'eventId'; +const indexName = 'indexName'; +const scopeId = 'scopeId'; +const dataFormattedForFieldBrowser = ['scopeId']; -jest.mock('@kbn/expandable-flyout'); - -const renderThreatIntelligenceOverview = (contextValue: DocumentDetailsContext) => ( - - +const renderThreatIntelligenceOverview = () => + render( + - - -); - -const flyoutContextValue = { - openLeftPanel: jest.fn(), -} as unknown as ExpandableFlyoutApi; + + ); describe('', () => { - beforeAll(() => { - jest.mocked(useExpandableFlyoutApi).mockReturnValue(flyoutContextValue); + beforeEach(() => { + jest.clearAllMocks(); + + (useDocumentDetailsContext as jest.Mock).mockReturnValue({ + eventId, + indexName, + scopeId, + dataFormattedForFieldBrowser, + isPreviewMode: false, + }); + (useExpandableFlyoutApi as jest.Mock).mockReturnValue({ openLeftPanel: mockOpenLeftPanel }); }); it('should render wrapper component', () => { @@ -72,9 +92,7 @@ describe('', () => { loading: false, }); - const { getByTestId, queryByTestId } = render( - renderThreatIntelligenceOverview(panelContextValue) - ); + const { getByTestId, queryByTestId } = renderThreatIntelligenceOverview(); expect(queryByTestId(TOGGLE_ICON_TEST_ID)).not.toBeInTheDocument(); expect(getByTestId(TITLE_ICON_TEST_ID)).toBeInTheDocument(); @@ -82,14 +100,19 @@ describe('', () => { expect(queryByTestId(TITLE_TEXT_TEST_ID)).not.toBeInTheDocument(); }); - it('should not render link if isPrenviewMode is true', () => { + it('should not render link if isPreviewMode is true', () => { + (useDocumentDetailsContext as jest.Mock).mockReturnValue({ + eventId, + indexName, + scopeId, + dataFormattedForFieldBrowser, + isPreviewMode: true, + }); (useFetchThreatIntelligence as jest.Mock).mockReturnValue({ loading: false, }); - const { getByTestId, queryByTestId } = render( - renderThreatIntelligenceOverview({ ...panelContextValue, isPreviewMode: true }) - ); + const { getByTestId, queryByTestId } = renderThreatIntelligenceOverview(); expect(queryByTestId(TOGGLE_ICON_TEST_ID)).not.toBeInTheDocument(); expect(queryByTestId(TITLE_ICON_TEST_ID)).not.toBeInTheDocument(); @@ -104,13 +127,15 @@ describe('', () => { threatEnrichmentsCount: 1, }); - const { getByTestId } = render(renderThreatIntelligenceOverview(panelContextValue)); + const { getByTestId } = renderThreatIntelligenceOverview(); expect(getByTestId(TITLE_LINK_TEST_ID)).toHaveTextContent('Threat intelligence'); - expect(getByTestId(CONTENT_TEST_ID)).toHaveTextContent('1 threat match detected'); - expect(getByTestId(CONTENT_TEST_ID)).toHaveTextContent( - '1 field enriched with threat intelligence' + expect(getByTestId(THREAT_MATCHES_TEXT_TEST_ID)).toHaveTextContent('Threat match detected'); + expect(getByTestId(THREAT_MATCHES_BUTTON_TEST_ID)).toHaveTextContent('1'); + expect(getByTestId(ENRICHED_WITH_THREAT_INTELLIGENCE_TEXT_TEST_ID)).toHaveTextContent( + 'Field enriched with threat intelligence' ); + expect(getByTestId(ENRICHED_WITH_THREAT_INTELLIGENCE_BUTTON_TEST_ID)).toHaveTextContent('1'); }); it('should render 2 matches detected and 2 fields enriched', () => { @@ -120,72 +145,85 @@ describe('', () => { threatEnrichmentsCount: 2, }); - const { getByTestId } = render(renderThreatIntelligenceOverview(panelContextValue)); + const { getByTestId } = renderThreatIntelligenceOverview(); expect(getByTestId(TITLE_LINK_TEST_ID)).toHaveTextContent('Threat intelligence'); - expect(getByTestId(CONTENT_TEST_ID)).toHaveTextContent('2 threat matches detected'); - expect(getByTestId(CONTENT_TEST_ID)).toHaveTextContent( - '2 fields enriched with threat intelligence' + expect(getByTestId(THREAT_MATCHES_TEXT_TEST_ID)).toHaveTextContent('Threat matches detected'); + expect(getByTestId(THREAT_MATCHES_BUTTON_TEST_ID)).toHaveTextContent('2'); + expect(getByTestId(ENRICHED_WITH_THREAT_INTELLIGENCE_TEXT_TEST_ID)).toHaveTextContent( + 'Fields enriched with threat intelligence' ); + expect(getByTestId(ENRICHED_WITH_THREAT_INTELLIGENCE_BUTTON_TEST_ID)).toHaveTextContent('2'); }); - it('should render 0 fields enriched', () => { + it('should render loading', () => { (useFetchThreatIntelligence as jest.Mock).mockReturnValue({ - loading: false, - threatMatchesCount: 1, - threatEnrichmentsCount: 0, + loading: true, }); - const { getByTestId } = render(renderThreatIntelligenceOverview(panelContextValue)); + const { getByTestId } = renderThreatIntelligenceOverview(); - expect(getByTestId(CONTENT_TEST_ID)).toHaveTextContent( - '0 fields enriched with threat intelligence' - ); + expect(getByTestId(LOADING_TEST_ID)).toBeInTheDocument(); }); - it('should render 0 matches detected', () => { + it('should navigate to left section Insights tab when clicking on button', () => { (useFetchThreatIntelligence as jest.Mock).mockReturnValue({ loading: false, - threatMatchesCount: 0, - threatEnrichmentsCount: 2, + threatMatchesCount: 1, + threatEnrichmentsCount: 1, }); + const { getByTestId } = renderThreatIntelligenceOverview(); - const { getByTestId } = render(renderThreatIntelligenceOverview(panelContextValue)); - - expect(getByTestId(CONTENT_TEST_ID)).toHaveTextContent('0 threat matches detected'); - }); - - it('should render loading', () => { - (useFetchThreatIntelligence as jest.Mock).mockReturnValue({ - loading: true, + getByTestId(TITLE_LINK_TEST_ID).click(); + expect(mockOpenLeftPanel).toHaveBeenCalledWith({ + id: DocumentDetailsLeftPanelKey, + path: { + tab: LeftPanelInsightsTab, + subTab: THREAT_INTELLIGENCE_TAB_ID, + }, + params: { + id: eventId, + indexName, + scopeId, + }, }); - - const { getByTestId } = render(renderThreatIntelligenceOverview(panelContextValue)); - - expect(getByTestId(LOADING_TEST_ID)).toBeInTheDocument(); }); - it('should navigate to left section Insights tab when clicking on button', () => { + it('should open the expanded section to the correct tab when the number is clicked', () => { (useFetchThreatIntelligence as jest.Mock).mockReturnValue({ loading: false, threatMatchesCount: 1, threatEnrichmentsCount: 1, }); - const { getByTestId } = render( - - - - - - ); - getByTestId(TITLE_LINK_TEST_ID).click(); - expect(flyoutContextValue.openLeftPanel).toHaveBeenCalledWith({ + const { getByTestId } = renderThreatIntelligenceOverview(); + getByTestId(THREAT_MATCHES_BUTTON_TEST_ID).click(); + + expect(mockOpenLeftPanel).toHaveBeenCalledWith({ + id: DocumentDetailsLeftPanelKey, + path: { + tab: LeftPanelInsightsTab, + subTab: THREAT_INTELLIGENCE_TAB_ID, + }, + params: { + id: eventId, + indexName, + scopeId, + }, + }); + + getByTestId(ENRICHED_WITH_THREAT_INTELLIGENCE_BUTTON_TEST_ID).click(); + + expect(mockOpenLeftPanel).toHaveBeenCalledWith({ id: DocumentDetailsLeftPanelKey, - path: { tab: LeftPanelInsightsTab, subTab: THREAT_INTELLIGENCE_TAB_ID }, + path: { + tab: LeftPanelInsightsTab, + subTab: THREAT_INTELLIGENCE_TAB_ID, + }, params: { - id: panelContextValue.eventId, - indexName: panelContextValue.indexName, + id: eventId, + indexName, + scopeId, }, }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/threat_intelligence_overview.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/threat_intelligence_overview.tsx index 10b23ecfc2340..73dfc62520c32 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/threat_intelligence_overview.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/threat_intelligence_overview.tsx @@ -10,15 +10,32 @@ import React, { useCallback, useMemo } from 'react'; import { EuiFlexGroup } from '@elastic/eui'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { FormattedMessage } from '@kbn/i18n-react'; -import { ExpandablePanel } from '@kbn/security-solution-common'; +import { ExpandablePanel } from '../../../shared/components/expandable_panel'; import { useFetchThreatIntelligence } from '../hooks/use_fetch_threat_intelligence'; import { InsightsSummaryRow } from './insights_summary_row'; import { useDocumentDetailsContext } from '../../shared/context'; -import { INSIGHTS_THREAT_INTELLIGENCE_TEST_ID } from './test_ids'; +import { + INSIGHTS_THREAT_INTELLIGENCE_ENRICHED_WITH_THREAT_INTELLIGENCE_TEST_ID, + INSIGHTS_THREAT_INTELLIGENCE_TEST_ID, + INSIGHTS_THREAT_INTELLIGENCE_THREAT_MATCHES_TEST_ID, +} from './test_ids'; import { DocumentDetailsLeftPanelKey } from '../../shared/constants/panel_keys'; import { LeftPanelInsightsTab } from '../../left'; import { THREAT_INTELLIGENCE_TAB_ID } from '../../left/components/threat_intelligence_details'; +const TITLE = ( + +); +const TOOLTIP = ( + +); + /** * Threat intelligence section under Insights section, overview tab. * The component fetches the necessary data, then pass it down to the InsightsSubSection component for loading and error state, @@ -53,26 +70,38 @@ export const ThreatIntelligenceOverview: FC = () => { !isPreviewMode ? { callback: goToThreatIntelligenceTab, - tooltip: ( - - ), + tooltip: TOOLTIP, } : undefined, [isPreviewMode, goToThreatIntelligenceTab] ); + const threatMatchCountText = useMemo( + () => ( + + ), + [threatMatchesCount] + ); + + const threatEnrichmentsCountText = useMemo( + () => ( + + ), + [threatEnrichmentsCount] + ); + return ( - ), + title: TITLE, link, iconType: !isPreviewMode ? 'arrowStart' : undefined, }} @@ -81,32 +110,20 @@ export const ThreatIntelligenceOverview: FC = () => { > - } - data-test-subj={INSIGHTS_THREAT_INTELLIGENCE_TEST_ID} + expandedSubTab={THREAT_INTELLIGENCE_TAB_ID} + data-test-subj={INSIGHTS_THREAT_INTELLIGENCE_THREAT_MATCHES_TEST_ID} /> - } - data-test-subj={INSIGHTS_THREAT_INTELLIGENCE_TEST_ID} + expandedSubTab={THREAT_INTELLIGENCE_TAB_ID} + data-test-subj={INSIGHTS_THREAT_INTELLIGENCE_ENRICHED_WITH_THREAT_INTELLIGENCE_TEST_ID} /> diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/tour.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/tour.test.tsx deleted file mode 100644 index 20540184156b9..0000000000000 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/tour.test.tsx +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { render, waitFor, fireEvent } from '@testing-library/react'; -import { RightPanelTour } from './tour'; -import { DocumentDetailsContext } from '../../shared/context'; -import { mockContextValue } from '../../shared/mocks/mock_context'; -import { - createMockStore, - createSecuritySolutionStorageMock, - TestProviders, -} from '../../../../common/mock'; -import { useKibana as mockUseKibana } from '../../../../common/lib/kibana/__mocks__'; -import { useKibana } from '../../../../common/lib/kibana'; -import { FLYOUT_TOUR_CONFIG_ANCHORS } from '../../shared/utils/tour_step_config'; -import { FLYOUT_TOUR_TEST_ID } from '../../shared/components/test_ids'; -import { useTourContext } from '../../../../common/components/guided_onboarding_tour/tour'; -import { casesPluginMock } from '@kbn/cases-plugin/public/mocks'; -import { useWhichFlyout } from '../../shared/hooks/use_which_flyout'; -import { Flyouts } from '../../shared/constants/flyouts'; - -jest.mock('../../../../common/lib/kibana'); -jest.mock('../../shared/hooks/use_which_flyout'); -jest.mock('../../../../common/components/guided_onboarding_tour/tour'); - -const mockedUseKibana = mockUseKibana(); - -const { storage: storageMock } = createSecuritySolutionStorageMock(); -const mockStore = createMockStore(undefined, undefined, undefined, { - ...storageMock, -}); -const mockCasesContract = casesPluginMock.createStartContract(); -const mockUseIsAddToCaseOpen = mockCasesContract.hooks.useIsAddToCaseOpen as jest.Mock; -mockUseIsAddToCaseOpen.mockReturnValue(false); - -const renderRightPanelTour = (context: DocumentDetailsContext = mockContextValue) => - render( - - - - {Object.values(FLYOUT_TOUR_CONFIG_ANCHORS).map((i, idx) => ( -
- ))} - - - ); - -describe('', () => { - beforeEach(() => { - (useKibana as jest.Mock).mockReturnValue({ - ...mockedUseKibana, - services: { - ...mockedUseKibana.services, - storage: storageMock, - cases: mockCasesContract, - }, - }); - (useWhichFlyout as jest.Mock).mockReturnValue(Flyouts.securitySolution); - (useTourContext as jest.Mock).mockReturnValue({ isTourShown: jest.fn(() => false) }); - storageMock.clear(); - }); - - it('should render tour for alerts', async () => { - const { getByText, getByTestId } = renderRightPanelTour(); - await waitFor(() => { - expect(getByTestId(`${FLYOUT_TOUR_TEST_ID}-1`)).toBeVisible(); - }); - fireEvent.click(getByText('Next')); - await waitFor(() => { - expect(getByTestId(`${FLYOUT_TOUR_TEST_ID}-2`)).toBeVisible(); - }); - fireEvent.click(getByText('Next')); - await waitFor(() => { - expect(getByTestId(`${FLYOUT_TOUR_TEST_ID}-3`)).toBeVisible(); - }); - fireEvent.click(getByText('Next')); - }); - - it('should not render tour for preview', () => { - const { queryByTestId, queryByText } = renderRightPanelTour({ - ...mockContextValue, - isPreview: true, - }); - expect(queryByTestId(`${FLYOUT_TOUR_TEST_ID}-1`)).not.toBeInTheDocument(); - expect(queryByText('Next')).not.toBeInTheDocument(); - }); - - it('should not render tour when guided onboarding tour is active', () => { - (useTourContext as jest.Mock).mockReturnValue({ isTourShown: jest.fn(() => true) }); - const { queryByText, queryByTestId } = renderRightPanelTour({ - ...mockContextValue, - getFieldsData: () => '', - }); - - expect(queryByTestId(`${FLYOUT_TOUR_TEST_ID}-1`)).not.toBeInTheDocument(); - expect(queryByText('Next')).not.toBeInTheDocument(); - }); - - it('should not render tour when case modal is open', () => { - mockUseIsAddToCaseOpen.mockReturnValue(true); - const { queryByText, queryByTestId } = renderRightPanelTour({ - ...mockContextValue, - getFieldsData: () => '', - }); - - expect(queryByTestId(`${FLYOUT_TOUR_TEST_ID}-1`)).not.toBeInTheDocument(); - expect(queryByText('Next')).not.toBeInTheDocument(); - }); - - it('should not render tour for flyout in timeline', () => { - (useWhichFlyout as jest.Mock).mockReturnValue(Flyouts.timeline); - const { queryByText, queryByTestId } = renderRightPanelTour({ - ...mockContextValue, - getFieldsData: () => '', - }); - - expect(queryByTestId(`${FLYOUT_TOUR_TEST_ID}-1`)).not.toBeInTheDocument(); - expect(queryByText('Next')).not.toBeInTheDocument(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/tour.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/tour.tsx deleted file mode 100644 index 839b77766886a..0000000000000 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/tour.tsx +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo, useMemo, useCallback } from 'react'; -import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; -import { useWhichFlyout } from '../../shared/hooks/use_which_flyout'; -import { Flyouts } from '../../shared/constants/flyouts'; -import { useDocumentDetailsContext } from '../../shared/context'; -import { - getRightSectionTourSteps, - getLeftSectionTourSteps, -} from '../../shared/utils/tour_step_config'; -import { getField } from '../../shared/utils'; -import { - DocumentDetailsLeftPanelKey, - DocumentDetailsRightPanelKey, -} from '../../shared/constants/panel_keys'; -import { EventKind } from '../../shared/constants/event_kinds'; -import { useTourContext } from '../../../../common/components/guided_onboarding_tour/tour'; -import { SecurityStepId } from '../../../../common/components/guided_onboarding_tour/tour_config'; -import { useKibana } from '../../../../common/lib/kibana'; -import { FlyoutTour } from '../../shared/components/flyout_tour'; - -/** - * Guided tour for the right panel in details flyout - */ -export const RightPanelTour = memo(() => { - const { useIsAddToCaseOpen } = useKibana().services.cases.hooks; - - const casesFlyoutExpanded = useIsAddToCaseOpen(); - - const { isTourShown: isGuidedOnboardingTourShown } = useTourContext(); - - const { openLeftPanel, openRightPanel } = useExpandableFlyoutApi(); - const { eventId, indexName, scopeId, isPreview, getFieldsData } = useDocumentDetailsContext(); - - const eventKind = getField(getFieldsData('event.kind')); - const isAlert = eventKind === EventKind.signal; - const isTimelineFlyoutOpen = useWhichFlyout() === Flyouts.timeline; - const showTour = - isAlert && - !isPreview && - !isTimelineFlyoutOpen && - !isGuidedOnboardingTourShown(SecurityStepId.alertsCases) && - !casesFlyoutExpanded; - - const goToLeftPanel = useCallback(() => { - openLeftPanel({ - id: DocumentDetailsLeftPanelKey, - params: { - id: eventId, - indexName, - scopeId, - }, - }); - }, [eventId, indexName, scopeId, openLeftPanel]); - - const goToOverviewTab = useCallback(() => { - openRightPanel({ - id: DocumentDetailsRightPanelKey, - path: { tab: 'overview' }, - params: { - id: eventId, - indexName, - scopeId, - }, - }); - }, [eventId, indexName, scopeId, openRightPanel]); - - const tourStepContent = useMemo( - // we append the left tour steps here to support the scenarios where the flyout left section is already expanded when starting the tour - () => [...getRightSectionTourSteps(), ...getLeftSectionTourSteps()], - [] - ); - - return showTour ? ( - - ) : null; -}); - -RightPanelTour.displayName = 'RightPanelTour'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.tsx index 0019228d656cd..1008f6139cd67 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.tsx @@ -283,6 +283,7 @@ export const UserEntityOverview: React.FC = ({ userName fieldName={'user.name'} name={userName} data-test-subj={ENTITIES_USER_OVERVIEW_MISCONFIGURATIONS_TEST_ID} + telemetrySuffix={'user-entity-overview'} /> ); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/content.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/content.tsx index 075921de192b8..dbc530a4dd3f6 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/content.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/content.tsx @@ -7,10 +7,10 @@ import type { FC } from 'react'; import React, { useMemo } from 'react'; -import { FlyoutBody } from '@kbn/security-solution-common'; import { FLYOUT_BODY_TEST_ID } from './test_ids'; import type { RightPanelPaths } from '.'; import type { RightPanelTabType } from './tabs'; +import { FlyoutBody } from '../../shared/components/flyout_body'; export interface PanelContentProps { /** diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/header.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/header.tsx index b7aea63ee9a24..aa1de9eda1b00 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/header.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/header.tsx @@ -9,9 +9,10 @@ import type { EuiFlyoutHeader } from '@elastic/eui'; import { EuiSpacer, EuiTab } from '@elastic/eui'; import type { FC } from 'react'; import React, { memo, useMemo } from 'react'; -import { FlyoutHeader, FlyoutHeaderTabs } from '@kbn/security-solution-common'; import type { RightPanelPaths } from '.'; import type { RightPanelTabType } from './tabs'; +import { FlyoutHeader } from '../../shared/components/flyout_header'; +import { FlyoutHeaderTabs } from '../../shared/components/flyout_header_tabs'; import { AlertHeaderTitle } from './components/alert_header_title'; import { EventHeaderTitle } from './components/event_header_title'; import { useDocumentDetailsContext } from '../shared/context'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/index.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/index.tsx index 1f9006b8d04a8..56c24d9562091 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/index.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/index.tsx @@ -17,7 +17,6 @@ import type { DocumentDetailsProps } from '../shared/types'; import { PanelNavigation } from './navigation'; import { PanelHeader } from './header'; import { PanelContent } from './content'; -import { RightPanelTour } from './components/tour'; import type { RightPanelTabType } from './tabs'; import { PanelFooter } from './footer'; import { useFlyoutIsExpandable } from './hooks/use_flyout_is_expandable'; @@ -76,7 +75,6 @@ export const RightPanel: FC> = memo(({ path }) => return ( <> - {flyoutIsExpandable && } - -
+ ), content: , }; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/document_details/right/test_ids.ts index 54199abf55de8..89a71e5fd17ba 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/test_ids.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/test_ids.ts @@ -12,6 +12,5 @@ export const FLYOUT_FOOTER_TEST_ID = `${PREFIX}Footer` as const; export const FLYOUT_FOOTER_DEOPDOEN_BUTTON_TEST_ID = `${FLYOUT_FOOTER_TEST_ID}DropdownButton` as const; export const OVERVIEW_TAB_TEST_ID = `${PREFIX}OverviewTab` as const; -export const OVERVIEW_TAB_LABEL_TEST_ID = `${PREFIX}OverviewTabLabel` as const; export const TABLE_TAB_TEST_ID = `${PREFIX}TableTab` as const; export const JSON_TAB_TEST_ID = `${PREFIX}JsonTab` as const; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/flyout_tour.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/flyout_tour.test.tsx deleted file mode 100644 index 246bd6a8940c9..0000000000000 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/flyout_tour.test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { type FlyoutTourProps, FlyoutTour } from './flyout_tour'; -import { render, waitFor, fireEvent } from '@testing-library/react'; -import { - createMockStore, - createSecuritySolutionStorageMock, - TestProviders, -} from '../../../../common/mock'; -import { useKibana as mockUseKibana } from '../../../../common/lib/kibana/__mocks__'; -import { useKibana } from '../../../../common/lib/kibana'; -import { FLYOUT_TOUR_TEST_ID } from './test_ids'; - -jest.mock('../../../../common/lib/kibana'); - -const mockedUseKibana = mockUseKibana(); - -const { storage: storageMock } = createSecuritySolutionStorageMock(); -const mockStore = createMockStore(undefined, undefined, undefined, storageMock); - -const content = [1, 2, 3, 4].map((i) => ({ - title: `step${i}`, - content:

{`step${i}`}

, - stepNumber: i, - anchor: `step${i}`, -})); - -const tourProps = { - tourStepContent: content, - totalSteps: 4, -}; -const goToLeftPanel = jest.fn(); -const goToOverviewTab = jest.fn(); - -const renderTour = (props: FlyoutTourProps) => - render( - - -
-
-
-
- - ); - -describe('Flyout Tour', () => { - beforeEach(() => { - (useKibana as jest.Mock).mockReturnValue({ - ...mockedUseKibana, - services: { - ...mockedUseKibana.services, - storage: storageMock, - }, - }); - - storageMock.clear(); - }); - - it('should render tour steps', async () => { - const wrapper = renderTour(tourProps); - - await waitFor(() => { - expect(wrapper.getByTestId(`${FLYOUT_TOUR_TEST_ID}-1`)).toBeVisible(); - }); - fireEvent.click(wrapper.getByText('Next')); - await waitFor(() => { - expect(wrapper.getByTestId(`${FLYOUT_TOUR_TEST_ID}-2`)).toBeVisible(); - }); - fireEvent.click(wrapper.getByText('Next')); - await waitFor(() => { - expect(wrapper.getByTestId(`${FLYOUT_TOUR_TEST_ID}-3`)).toBeVisible(); - }); - fireEvent.click(wrapper.getByText('Next')); - await waitFor(() => { - expect(wrapper.getByTestId(`${FLYOUT_TOUR_TEST_ID}-4`)).toBeVisible(); - }); - await waitFor(() => { - expect(wrapper.getByText('Finish')).toBeVisible(); - }); - }); - - it('should call goToOverview at step 1', () => { - renderTour({ - ...tourProps, - goToOverviewTab, - }); - expect(goToOverviewTab).toHaveBeenCalled(); - }); - - it('should call goToLeftPanel when after step 3', async () => { - const wrapper = renderTour({ - ...tourProps, - goToLeftPanel, - }); - - await waitFor(() => { - expect(wrapper.getByTestId(`${FLYOUT_TOUR_TEST_ID}-1`)).toBeVisible(); - }); - fireEvent.click(wrapper.getByText('Next')); - await waitFor(() => { - expect(wrapper.getByTestId(`${FLYOUT_TOUR_TEST_ID}-2`)).toBeVisible(); - }); - fireEvent.click(wrapper.getByText('Next')); - await waitFor(() => { - expect(wrapper.getByTestId(`${FLYOUT_TOUR_TEST_ID}-3`)).toBeVisible(); - }); - fireEvent.click(wrapper.getByText('Next')); - - expect(goToLeftPanel).toHaveBeenCalled(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/flyout_tour.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/flyout_tour.tsx deleted file mode 100644 index c5ee76ca558a9..0000000000000 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/flyout_tour.tsx +++ /dev/null @@ -1,160 +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. - */ - -/* - * This timeline tour only valid for 8.12 release is not needed for 8.13 - * - * */ - -import type { FC } from 'react'; -import React, { useCallback, useState, useEffect } from 'react'; -import { EuiButton, EuiButtonEmpty, EuiTourStep } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { tourConfig, type TourState } from '../utils/tour_step_config'; -import type { FlyoutTourStepsProps } from '../utils/tour_step_config'; -import { NEW_FEATURES_TOUR_STORAGE_KEYS } from '../../../../../common/constants'; -import { useKibana } from '../../../../common/lib/kibana'; -import { FLYOUT_TOUR_TEST_ID } from './test_ids'; - -export interface FlyoutTourProps { - /** - * Content to be displayed in each tour card - */ - tourStepContent: FlyoutTourStepsProps[]; - /** - * Total number of tour steps - */ - totalSteps: number; - /** - * Callback to go to overview tab before tour - */ - goToOverviewTab?: () => void; - /** - * Callback to go to open left panel - */ - goToLeftPanel?: () => void; -} - -const MAX_POPOVER_WIDTH = 500; -const TOUR_SUBTITLE = i18n.translate('xpack.securitySolution.flyout.tour.subtitle', { - defaultMessage: 'A redesigned alert experience', -}); - -/** - * Shared component that generates tour steps based on supplied tour step content. - * Supports tours being shown in different panels and manages state via local storage - */ -export const FlyoutTour: FC = ({ - tourStepContent, - totalSteps, - goToOverviewTab, - goToLeftPanel, -}) => { - const { - services: { storage }, - } = useKibana(); - - const [tourState, setTourState] = useState(() => { - const restoredTourState = storage.get(NEW_FEATURES_TOUR_STORAGE_KEYS.FLYOUT); - if (restoredTourState != null) { - return restoredTourState; - } - return tourConfig; - }); - - useEffect(() => { - storage.set(NEW_FEATURES_TOUR_STORAGE_KEYS.FLYOUT, tourState); - if (tourState.isTourActive && tourState.currentTourStep === 1 && goToOverviewTab) { - goToOverviewTab(); - } - }, [storage, tourState, goToOverviewTab]); - - const nextStep = useCallback(() => { - setTourState((prev) => { - if (prev.currentTourStep === 3 && goToLeftPanel) { - goToLeftPanel(); - } - return { - ...prev, - currentTourStep: prev.currentTourStep + 1, - }; - }); - }, [goToLeftPanel]); - - const finishTour = useCallback(() => { - setTourState((prev) => { - return { - ...prev, - isTourActive: false, - }; - }); - }, []); - - const getFooterAction = useCallback( - (step: number) => { - // if it's the last step, we don't want to show the next button - return step === totalSteps ? ( - - {i18n.translate('xpack.securitySolution.flyout.tour.finish.text', { - defaultMessage: 'Finish', - })} - - ) : ( - [ - - {i18n.translate('xpack.securitySolution.flyout.tour.exit.text', { - defaultMessage: 'Exit', - })} - , - - {i18n.translate('xpack.securitySolution.flyout.tour.Next.text', { - defaultMessage: 'Next', - })} - , - ] - ); - }, - [finishTour, nextStep, totalSteps] - ); - - // Do not show tour if it is inactive - if (!tourState.isTourActive) { - return null; - } - - return ( - <> - {tourStepContent.map((steps) => { - const stepCount = steps.stepNumber; - if (tourState.currentTourStep !== stepCount) return null; - const panelProps = { - 'data-test-subj': `${FLYOUT_TOUR_TEST_ID}-${stepCount}`, - }; - - return ( - - ); - })} - - ); -}; - -FlyoutTour.displayName = 'FlyoutTour'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/misconfiguration_insight.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/misconfiguration_insight.tsx index 961fa1d5f3a45..e7ebb371fb020 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/misconfiguration_insight.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/misconfiguration_insight.tsx @@ -5,12 +5,17 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { EuiFlexItem, type EuiFlexGroupProps, useEuiTheme } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { css } from '@emotion/css'; import { useMisconfigurationPreview } from '@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'; import { buildEntityFlyoutPreviewQuery } from '@kbn/cloud-security-posture-common'; +import { + MISCONFIGURATION_INSIGHT, + uiMetricService, +} from '@kbn/cloud-security-posture-common/utils/ui_metrics'; +import { METRIC_TYPE } from '@kbn/analytics'; import { InsightDistributionBar } from './insight_distribution_bar'; import { getFindingsStats } from '../../../../cloud_security_posture/components/misconfiguration/misconfiguration_preview'; import { FormattedCount } from '../../../../common/components/formatted_number'; @@ -34,6 +39,10 @@ interface MisconfigurationsInsightProps { * The data-test-subj to use for the component */ ['data-test-subj']?: string; + /** + * used to track the instance of this component, prefer kebab-case + */ + telemetrySuffix?: string; } /* @@ -44,6 +53,7 @@ export const MisconfigurationsInsight: React.FC = fieldName, direction, 'data-test-subj': dataTestSubj, + telemetrySuffix, }) => { const { scopeId, isPreview } = useDocumentDetailsContext(); const { euiTheme } = useEuiTheme(); @@ -54,6 +64,14 @@ export const MisconfigurationsInsight: React.FC = pageSize: 1, }); + useEffect(() => { + uiMetricService.trackUiMetric( + METRIC_TYPE.COUNT, + `${MISCONFIGURATION_INSIGHT}-${telemetrySuffix}` + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const passedFindings = data?.count.passed || 0; const failedFindings = data?.count.failed || 0; const totalFindings = useMemo( diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/test_ids.ts index 7c2ce2ff5870b..b250e9a688380 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/test_ids.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/test_ids.ts @@ -7,7 +7,6 @@ import { PREFIX } from '../../../shared/test_ids'; -export const FLYOUT_TOUR_TEST_ID = `${PREFIX}Tour` as const; export const FLYOUT_PREVIEW_LINK_TEST_ID = `${PREFIX}PreviewLink` as const; export const SESSION_VIEW_UPSELL_TEST_ID = `${PREFIX}SessionViewUpsell` as const; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/vulnerabilities_insight.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/vulnerabilities_insight.tsx index c675c0a0e079b..1dab4660194b9 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/vulnerabilities_insight.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/vulnerabilities_insight.tsx @@ -5,13 +5,18 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { EuiFlexItem, type EuiFlexGroupProps, useEuiTheme } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { css } from '@emotion/css'; import { useVulnerabilitiesPreview } from '@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'; import { buildEntityFlyoutPreviewQuery } from '@kbn/cloud-security-posture-common'; import { getVulnerabilityStats, hasVulnerabilitiesData } from '@kbn/cloud-security-posture'; +import { + uiMetricService, + VULNERABILITIES_INSIGHT, +} from '@kbn/cloud-security-posture-common/utils/ui_metrics'; +import { METRIC_TYPE } from '@kbn/analytics'; import { InsightDistributionBar } from './insight_distribution_bar'; import { FormattedCount } from '../../../../common/components/formatted_number'; import { PreviewLink } from '../../../shared/components/preview_link'; @@ -30,6 +35,10 @@ interface VulnerabilitiesInsightProps { * The data-test-subj to use for the component */ ['data-test-subj']?: string; + /** + * used to track the instance of this component, prefer kebab-case + */ + telemetrySuffix?: string; } /* @@ -39,6 +48,7 @@ export const VulnerabilitiesInsight: React.FC = ({ hostName, direction, 'data-test-subj': dataTestSubj, + telemetrySuffix, }) => { const { scopeId, isPreview } = useDocumentDetailsContext(); const { euiTheme } = useEuiTheme(); @@ -49,6 +59,14 @@ export const VulnerabilitiesInsight: React.FC = ({ pageSize: 1, }); + useEffect(() => { + uiMetricService.trackUiMetric( + METRIC_TYPE.COUNT, + `${VULNERABILITIES_INSIGHT}-${telemetrySuffix}` + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const { CRITICAL = 0, HIGH = 0, MEDIUM = 0, LOW = 0, NONE = 0 } = data?.count || {}; const totalVulnerabilities = useMemo( () => CRITICAL + HIGH + MEDIUM + LOW + NONE, diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/context.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/context.tsx index f2c5d8bfa530f..12e2ad4f2a0b6 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/context.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/context.tsx @@ -9,8 +9,9 @@ import type { BrowserFields, TimelineEventsDetailsItem } from '@kbn/timelines-pl import React, { createContext, memo, useContext, useMemo } from 'react'; import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; import { TableId } from '@kbn/securitysolution-data-table'; -import { FlyoutError, FlyoutLoading } from '@kbn/security-solution-common/src/flyout'; import { useEventDetails } from './hooks/use_event_details'; +import { FlyoutError } from '../../shared/components/flyout_error'; +import { FlyoutLoading } from '../../shared/components/flyout_loading'; import type { SearchHit } from '../../../../common/search_strategy'; import { useBasicDataFromDetailsData } from './hooks/use_basic_data_from_details_data'; import type { DocumentDetailsProps } from './types'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils/tour_step_config.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils/tour_step_config.tsx deleted file mode 100644 index 3f597e47c770c..0000000000000 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils/tour_step_config.tsx +++ /dev/null @@ -1,199 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiText, EuiCode, type EuiTourStepProps } from '@elastic/eui'; -import React from 'react'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { i18n } from '@kbn/i18n'; -import { HEADER_NAVIGATION_BUTTON_TEST_ID } from '@kbn/security-solution-common'; -import { OVERVIEW_TAB_LABEL_TEST_ID } from '../../right/test_ids'; -import { RULE_SUMMARY_BUTTON_TEST_ID } from '../../right/components/test_ids'; -import { - INSIGHTS_TAB_PREVALENCE_BUTTON_LABEL_TEST_ID, - INSIGHTS_TAB_ENTITIES_BUTTON_LABEL_TEST_ID, -} from '../../left/tabs/test_ids'; - -export const FLYOUT_TOUR_CONFIG_ANCHORS = { - OVERVIEW_TAB: OVERVIEW_TAB_LABEL_TEST_ID, - RULE_PREVIEW: RULE_SUMMARY_BUTTON_TEST_ID, - NAVIGATION_BUTTON: HEADER_NAVIGATION_BUTTON_TEST_ID, - ENTITIES: INSIGHTS_TAB_ENTITIES_BUTTON_LABEL_TEST_ID, - PREVALENCE: INSIGHTS_TAB_PREVALENCE_BUTTON_LABEL_TEST_ID, -}; - -export interface TourState { - /** - * The current step number - */ - currentTourStep: number; - /** - * True if tour is active (user has not completed or exited the tour) - */ - isTourActive: boolean; -} - -export const tourConfig: TourState = { - currentTourStep: 1, - isTourActive: true, -}; - -export interface FlyoutTourStepsProps { - /** - * Title of the tour step - */ - title: string; - /** - * Content of tour step - */ - content: JSX.Element; - /** - * Step number - */ - stepNumber: number; - /** - * Data test subject of the anchor component - */ - anchor: string; - /** - * Optional anchor position prop - */ - anchorPosition?: EuiTourStepProps['anchorPosition']; -} - -export const getRightSectionTourSteps = (): FlyoutTourStepsProps[] => { - const rightSectionTourSteps: FlyoutTourStepsProps[] = [ - { - title: i18n.translate('xpack.securitySolution.flyout.tour.overview.title', { - defaultMessage: 'More ways to understand your alerts', - }), - content: ( - - - {i18n.translate('xpack.securitySolution.flyout.tour.overview.entities.text', { - defaultMessage: 'Entities', - })} - - ), - prevalence: ( - - {i18n.translate('xpack.securitySolution.flyout.tour.overview.prevalence.text', { - defaultMessage: 'Prevalence', - })} - - ), - }} - /> - - ), - stepNumber: 1, - anchor: FLYOUT_TOUR_CONFIG_ANCHORS.OVERVIEW_TAB, - anchorPosition: 'downCenter', - }, - { - title: i18n.translate('xpack.securitySolution.flyout.tour.preview.title', { - defaultMessage: 'A quick way to access rule details', - }), - content: ( - - - {i18n.translate('xpack.securitySolution.flyout.tour.rulePreview.text', { - defaultMessage: 'Show rule summary', - })} - - ), - }} - /> - - ), - stepNumber: 2, - anchor: FLYOUT_TOUR_CONFIG_ANCHORS.RULE_PREVIEW, - anchorPosition: 'rightUp', - }, - { - title: i18n.translate('xpack.securitySolution.flyout.tour.expandDetails.title', { - defaultMessage: 'An expanded view of important alert details', - }), - content: ( - - - - ), - stepNumber: 3, - anchor: FLYOUT_TOUR_CONFIG_ANCHORS.NAVIGATION_BUTTON, - anchorPosition: 'downCenter', - }, - ]; - return rightSectionTourSteps; -}; - -export const getLeftSectionTourSteps = (): FlyoutTourStepsProps[] => { - return [ - { - title: i18n.translate('xpack.securitySolution.flyout.tour.entities.title', { - defaultMessage: 'New host and user insights are available', - }), - content: ( - - - {i18n.translate('xpack.securitySolution.flyout.tour.entities.text', { - defaultMessage: 'Entities', - })} - - ), - }} - /> - - ), - stepNumber: 4, - anchor: FLYOUT_TOUR_CONFIG_ANCHORS.ENTITIES, - anchorPosition: 'rightUp', - }, - { - title: i18n.translate('xpack.securitySolution.flyout.tour.prevalence.title', { - defaultMessage: 'New host and user insights are available', - }), - content: ( - - - {i18n.translate('xpack.securitySolution.flyout.tour.prevalence.text', { - defaultMessage: 'Prevalence', - })} - - ), - }} - /> - - ), - stepNumber: 5, - anchor: FLYOUT_TOUR_CONFIG_ANCHORS.PREVALENCE, - anchorPosition: 'rightUp', - }, - ]; -}; diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/host_preview/footer.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/host_preview/footer.tsx index 3ea6d7a5e0438..e550da28b532a 100644 --- a/x-pack/plugins/security_solution/public/flyout/entity_details/host_preview/footer.tsx +++ b/x-pack/plugins/security_solution/public/flyout/entity_details/host_preview/footer.tsx @@ -9,7 +9,7 @@ import { EuiLink, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React, { useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; -import { FlyoutFooter } from '@kbn/security-solution-common'; +import { FlyoutFooter } from '../../shared/components/flyout_footer'; import { HostPanelKey } from '../host_right'; export interface HostPreviewPanelFooterProps { diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/content.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/content.tsx index 4538f53f0bd81..2e7c14fc38027 100644 --- a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/content.tsx +++ b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/content.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { EuiHorizontalRule } from '@elastic/eui'; -import { FlyoutBody } from '@kbn/security-solution-common'; +import { FlyoutBody } from '../../shared/components/flyout_body'; import { EntityInsight } from '../../../cloud_security_posture/components/entity_insight'; import { AssetCriticalityAccordion } from '../../../entity_analytics/components/asset_criticality/asset_criticality_selector'; import { FlyoutRiskSummary } from '../../../entity_analytics/components/risk_summary_flyout/risk_summary'; diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/header.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/header.tsx index 706790770eb6c..b5df2f81d1b0a 100644 --- a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/header.tsx +++ b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/header.tsx @@ -9,11 +9,12 @@ import { EuiSpacer, EuiBadge, EuiText, EuiFlexItem, EuiFlexGroup } from '@elasti import { FormattedMessage } from '@kbn/i18n-react'; import React, { useMemo } from 'react'; import { SecurityPageName } from '@kbn/security-solution-navigation'; -import { FlyoutHeader, FlyoutTitle } from '@kbn/security-solution-common'; import type { HostItem } from '../../../../common/search_strategy'; import { getHostDetailsUrl } from '../../../common/components/link_to'; import { SecuritySolutionLinkAnchor } from '../../../common/components/links'; import { PreferenceFormattedDate } from '../../../common/components/formatted_date'; +import { FlyoutHeader } from '../../shared/components/flyout_header'; +import { FlyoutTitle } from '../../shared/components/flyout_title'; import type { ObservedEntityData } from '../shared/components/observed_entity/types'; interface HostPanelHeaderProps { diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/index.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/index.tsx index 9c75287ed0657..adc54b58f75cb 100644 --- a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/index.tsx +++ b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/index.tsx @@ -9,7 +9,6 @@ import React, { useCallback, useMemo } from 'react'; import type { FlyoutPanelProps } from '@kbn/expandable-flyout'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; -import { FlyoutLoading, FlyoutNavigation } from '@kbn/security-solution-common'; import { buildEntityFlyoutPreviewQuery } from '@kbn/cloud-security-posture-common'; import { useMisconfigurationPreview } from '@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'; import { useVulnerabilitiesPreview } from '@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'; @@ -26,6 +25,8 @@ import { useGlobalTime } from '../../../common/containers/use_global_time'; import type { HostItem } from '../../../../common/search_strategy'; import { buildHostNamesFilter } from '../../../../common/search_strategy'; import { RiskScoreEntity } from '../../../../common/entity_analytics/risk_engine'; +import { FlyoutLoading } from '../../shared/components/flyout_loading'; +import { FlyoutNavigation } from '../../shared/components/flyout_navigation'; import { HostPanelContent } from './content'; import { HostPanelHeader } from './header'; import { AnomalyTableProvider } from '../../../common/components/ml/anomaly/anomaly_table_provider'; diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/left_panel/left_panel_content.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/left_panel/left_panel_content.tsx index f52480285a567..5a66a5b305611 100644 --- a/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/left_panel/left_panel_content.tsx +++ b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/left_panel/left_panel_content.tsx @@ -9,7 +9,7 @@ import { useEuiBackgroundColor } from '@elastic/eui'; import type { VFC } from 'react'; import React, { useMemo } from 'react'; import { css } from '@emotion/react'; -import { FlyoutBody } from '@kbn/security-solution-common'; +import { FlyoutBody } from '../../../../shared/components/flyout_body'; import type { EntityDetailsLeftPanelTab, LeftPanelTabsType } from './left_panel_header'; export interface PanelContentProps { diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/left_panel/left_panel_header.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/left_panel/left_panel_header.tsx index 438f75e7a4ccb..08623c941ba67 100644 --- a/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/left_panel/left_panel_header.tsx +++ b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/left_panel/left_panel_header.tsx @@ -9,7 +9,7 @@ import { EuiTab, EuiTabs, useEuiBackgroundColor } from '@elastic/eui'; import type { ReactElement, VFC } from 'react'; import React, { memo } from 'react'; import { css } from '@emotion/react'; -import { FlyoutHeader } from '@kbn/security-solution-common'; +import { FlyoutHeader } from '../../../../shared/components/flyout_header'; export type LeftPanelTabsType = Array<{ id: EntityDetailsLeftPanelTab; diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/user_details_left/index.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/user_details_left/index.tsx index ae3e99cc17cfe..8e6cf3a9ee9d2 100644 --- a/x-pack/plugins/security_solution/public/flyout/entity_details/user_details_left/index.tsx +++ b/x-pack/plugins/security_solution/public/flyout/entity_details/user_details_left/index.tsx @@ -8,9 +8,9 @@ import React, { useMemo } from 'react'; import type { FlyoutPanelProps, PanelPath } from '@kbn/expandable-flyout'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; -import { FlyoutLoading } from '@kbn/security-solution-common'; import { useManagedUser } from '../shared/hooks/use_managed_user'; import { useTabs } from './tabs'; +import { FlyoutLoading } from '../../shared/components/flyout_loading'; import type { EntityDetailsLeftPanelTab, LeftPanelTabsType, diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/user_details_left/tabs/asset_document.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/user_details_left/tabs/asset_document.tsx index 31053cf88d931..3aa4a72ffe898 100644 --- a/x-pack/plugins/security_solution/public/flyout/entity_details/user_details_left/tabs/asset_document.tsx +++ b/x-pack/plugins/security_solution/public/flyout/entity_details/user_details_left/tabs/asset_document.tsx @@ -12,10 +12,10 @@ import { FormattedMessage } from '@kbn/i18n-react'; import type { EuiButtonGroupOptionProps } from '@elastic/eui'; import { EuiButtonGroup } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FlyoutBody } from '@kbn/security-solution-common'; import { JsonTab } from '../../../document_details/right/tabs/json_tab'; import { TableTab } from '../../../document_details/right/tabs/table_tab'; import { FLYOUT_BODY_TEST_ID, JSON_TAB_TEST_ID, TABLE_TAB_TEST_ID } from './test_ids'; +import { FlyoutBody } from '../../../shared/components/flyout_body'; export type RightPanelPaths = 'overview' | 'table' | 'json'; export interface AssetDocumentPanelProps extends FlyoutPanelProps { diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/user_preview/footer.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/user_preview/footer.tsx index 6921d1a73d4e2..7f55feb7a347c 100644 --- a/x-pack/plugins/security_solution/public/flyout/entity_details/user_preview/footer.tsx +++ b/x-pack/plugins/security_solution/public/flyout/entity_details/user_preview/footer.tsx @@ -9,7 +9,7 @@ import { EuiLink, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React, { useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; -import { FlyoutFooter } from '@kbn/security-solution-common'; +import { FlyoutFooter } from '../../shared/components/flyout_footer'; import { UserPanelKey } from '../user_right'; export interface UserPreviewPanelFooterProps { diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/components/managed_user_accordion.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/components/managed_user_accordion.tsx index 84f6c96cb772b..8d9007713549e 100644 --- a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/components/managed_user_accordion.tsx +++ b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/components/managed_user_accordion.tsx @@ -11,14 +11,13 @@ import React from 'react'; import { css } from '@emotion/react'; import { FormattedMessage } from '@kbn/i18n-react'; import { get } from 'lodash/fp'; -import { ExpandablePanel } from '@kbn/security-solution-common'; import { EntityDetailsLeftPanelTab } from '../../shared/components/left_panel/left_panel_header'; +import { ExpandablePanel } from '../../../shared/components/expandable_panel'; import type { ManagedUserFields } from '../../../../../common/search_strategy/security_solution/users/managed_details'; import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date'; import { ONE_WEEK_IN_HOURS } from '../../shared/constants'; import { UserAssetTableType } from '../../../../explore/users/store/model'; - interface ManagedUserAccordionProps { children: React.ReactNode; title: string; diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/content.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/content.tsx index 8bcf5dd690200..0dbc1faa5cb42 100644 --- a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/content.tsx +++ b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/content.tsx @@ -8,7 +8,6 @@ import { EuiHorizontalRule } from '@elastic/eui'; import React from 'react'; -import { FlyoutBody } from '@kbn/security-solution-common'; import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; import { AssetCriticalityAccordion } from '../../../entity_analytics/components/asset_criticality/asset_criticality_selector'; @@ -19,6 +18,7 @@ import { ManagedUser } from './components/managed_user'; import type { ManagedUserData } from './types'; import type { RiskScoreEntity, UserItem } from '../../../../common/search_strategy'; import { USER_PANEL_RISK_SCORE_QUERY_ID } from '.'; +import { FlyoutBody } from '../../shared/components/flyout_body'; import { ObservedEntity } from '../shared/components/observed_entity'; import type { ObservedEntityData } from '../shared/components/observed_entity/types'; import { useObservedUserItems } from './hooks/use_observed_user_items'; diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/header.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/header.tsx index 8fb54478b3c4f..e141779b559cf 100644 --- a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/header.tsx +++ b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/header.tsx @@ -10,7 +10,6 @@ import { FormattedMessage } from '@kbn/i18n-react'; import React, { useMemo } from 'react'; import { max } from 'lodash/fp'; import { SecurityPageName } from '@kbn/security-solution-navigation'; -import { FlyoutHeader, FlyoutTitle } from '@kbn/security-solution-common'; import type { UserItem } from '../../../../common/search_strategy'; import { ManagedUserDatasetKey } from '../../../../common/search_strategy/security_solution/users/managed_details'; import { getUsersDetailsUrl } from '../../../common/components/link_to/redirect_to_users'; @@ -18,6 +17,8 @@ import type { ManagedUserData } from './types'; import { SecuritySolutionLinkAnchor } from '../../../common/components/links'; import { PreferenceFormattedDate } from '../../../common/components/formatted_date'; +import { FlyoutHeader } from '../../shared/components/flyout_header'; +import { FlyoutTitle } from '../../shared/components/flyout_title'; import type { ObservedEntityData } from '../shared/components/observed_entity/types'; interface UserPanelHeaderProps { diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/index.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/index.tsx index ec55fb292abfd..3a60c06e3faea 100644 --- a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/index.tsx +++ b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/index.tsx @@ -8,9 +8,8 @@ import React, { useCallback, useMemo } from 'react'; import type { FlyoutPanelProps } from '@kbn/expandable-flyout'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; -import { FlyoutLoading, FlyoutNavigation } from '@kbn/security-solution-common/src/flyout'; -import { buildEntityFlyoutPreviewQuery } from '@kbn/cloud-security-posture-common'; import { useMisconfigurationPreview } from '@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'; +import { buildEntityFlyoutPreviewQuery } from '@kbn/cloud-security-posture-common'; import { useRefetchQueryById } from '../../../entity_analytics/api/hooks/use_refetch_query_by_id'; import type { Refetch } from '../../../common/types'; import { RISK_INPUTS_TAB_QUERY_ID } from '../../../entity_analytics/components/entity_details_flyout/tabs/risk_inputs/risk_inputs_tab'; @@ -26,6 +25,8 @@ import { useGlobalTime } from '../../../common/containers/use_global_time'; import { AnomalyTableProvider } from '../../../common/components/ml/anomaly/anomaly_table_provider'; import { buildUserNamesFilter } from '../../../../common/search_strategy'; import { RiskScoreEntity } from '../../../../common/entity_analytics/risk_engine'; +import { FlyoutLoading } from '../../shared/components/flyout_loading'; +import { FlyoutNavigation } from '../../shared/components/flyout_navigation'; import { UserPanelContent } from './content'; import { UserPanelHeader } from './header'; import { UserDetailsPanelKey } from '../user_details_left'; diff --git a/x-pack/plugins/security_solution/public/flyout/network_details/content.tsx b/x-pack/plugins/security_solution/public/flyout/network_details/content.tsx index 3634f73ef5a7f..8c9aa355ed43a 100644 --- a/x-pack/plugins/security_solution/public/flyout/network_details/content.tsx +++ b/x-pack/plugins/security_solution/public/flyout/network_details/content.tsx @@ -8,8 +8,8 @@ import type { FC } from 'react'; import React, { memo } from 'react'; import { EuiSpacer } from '@elastic/eui'; -import { FlyoutBody } from '@kbn/security-solution-common'; import { NetworkDetails } from './components/network_details'; +import { FlyoutBody } from '../shared/components/flyout_body'; import type { FlowTargetSourceDest } from '../../../common/search_strategy'; export interface PanelContentProps { diff --git a/x-pack/plugins/security_solution/public/flyout/network_details/header.tsx b/x-pack/plugins/security_solution/public/flyout/network_details/header.tsx index 1ef47c00689d9..8ffceb345b1e0 100644 --- a/x-pack/plugins/security_solution/public/flyout/network_details/header.tsx +++ b/x-pack/plugins/security_solution/public/flyout/network_details/header.tsx @@ -9,10 +9,11 @@ import type { FC } from 'react'; import React, { memo, useMemo } from 'react'; import type { EuiFlyoutHeader } from '@elastic/eui'; import { SecurityPageName } from '@kbn/deeplinks-security'; -import { FlyoutHeader, FlyoutTitle } from '@kbn/security-solution-common'; import { getNetworkDetailsUrl } from '../../common/components/link_to'; import { SecuritySolutionLinkAnchor } from '../../common/components/links'; import type { FlowTargetSourceDest } from '../../../common/search_strategy'; +import { FlyoutHeader } from '../shared/components/flyout_header'; +import { FlyoutTitle } from '../shared/components/flyout_title'; import { encodeIpv6 } from '../../common/lib/helpers'; export interface PanelHeaderProps extends React.ComponentProps { diff --git a/x-pack/plugins/security_solution/public/flyout/rule_details/preview/footer.tsx b/x-pack/plugins/security_solution/public/flyout/rule_details/preview/footer.tsx index 42c8c1a6d85b9..4440555e5fc53 100644 --- a/x-pack/plugins/security_solution/public/flyout/rule_details/preview/footer.tsx +++ b/x-pack/plugins/security_solution/public/flyout/rule_details/preview/footer.tsx @@ -8,9 +8,9 @@ import React, { memo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FlyoutFooter } from '@kbn/security-solution-common'; import { RULE_PREVIEW_FOOTER_TEST_ID, RULE_PREVIEW_OPEN_RULE_FLYOUT_TEST_ID } from './test_ids'; import { useRuleDetailsLink } from '../../document_details/shared/hooks/use_rule_details_link'; +import { FlyoutFooter } from '../../shared/components/flyout_footer'; /** * Footer in rule preview panel diff --git a/x-pack/plugins/security_solution/public/flyout/rule_details/right/content.tsx b/x-pack/plugins/security_solution/public/flyout/rule_details/right/content.tsx index 2778fc9c7ca22..8acc6cfe9b715 100644 --- a/x-pack/plugins/security_solution/public/flyout/rule_details/right/content.tsx +++ b/x-pack/plugins/security_solution/public/flyout/rule_details/right/content.tsx @@ -8,7 +8,6 @@ import React, { memo } from 'react'; import { EuiText, EuiHorizontalRule, EuiSpacer, EuiPanel } from '@elastic/eui'; import { css } from '@emotion/css'; import { FormattedMessage } from '@kbn/i18n-react'; -import { FlyoutBody } from '@kbn/security-solution-common'; import { ExpandableSection } from '../../document_details/right/components/expandable_section'; import { RuleAboutSection } from '../../../detection_engine/rule_management/components/rule_details/rule_about_section'; import { RuleScheduleSection } from '../../../detection_engine/rule_management/components/rule_details/rule_schedule_section'; @@ -23,6 +22,7 @@ import { ACTIONS_TEST_ID, } from './test_ids'; import type { RuleResponse } from '../../../../common/api/detection_engine'; +import { FlyoutBody } from '../../shared/components/flyout_body'; const panelViewStyle = css` dt { diff --git a/x-pack/plugins/security_solution/public/flyout/rule_details/right/header.tsx b/x-pack/plugins/security_solution/public/flyout/rule_details/right/header.tsx index 3dbbcc6b5b259..294870d6eebb7 100644 --- a/x-pack/plugins/security_solution/public/flyout/rule_details/right/header.tsx +++ b/x-pack/plugins/security_solution/public/flyout/rule_details/right/header.tsx @@ -15,7 +15,6 @@ import { EuiBadge, EuiLink, } from '@elastic/eui'; -import { FlyoutHeader, FlyoutTitle } from '@kbn/security-solution-common'; import { DELETED_RULE } from '../../../detection_engine/rule_details_ui/pages/rule_details/translations'; import { CreatedBy, UpdatedBy } from '../../../detections/components/rules/rule_info'; import { @@ -27,6 +26,8 @@ import { } from './test_ids'; import type { RuleResponse } from '../../../../common/api/detection_engine'; import { useRuleDetailsLink } from '../../document_details/shared/hooks/use_rule_details_link'; +import { FlyoutHeader } from '../../shared/components/flyout_header'; +import { FlyoutTitle } from '../../shared/components/flyout_title'; export interface PanelHeaderProps { /** diff --git a/x-pack/plugins/security_solution/public/flyout/rule_details/right/index.tsx b/x-pack/plugins/security_solution/public/flyout/rule_details/right/index.tsx index 958a2d4265186..10b22e22a575c 100644 --- a/x-pack/plugins/security_solution/public/flyout/rule_details/right/index.tsx +++ b/x-pack/plugins/security_solution/public/flyout/rule_details/right/index.tsx @@ -7,13 +7,15 @@ import React, { memo } from 'react'; import type { FlyoutPanelProps } from '@kbn/expandable-flyout'; -import { FlyoutLoading, FlyoutError, FlyoutNavigation } from '@kbn/security-solution-common'; import { i18n } from '@kbn/i18n'; import { PanelContent } from './content'; import { PanelHeader } from './header'; import { PreviewFooter } from '../preview/footer'; import { useRuleDetails } from '../hooks/use_rule_details'; import { LOADING_TEST_ID } from './test_ids'; +import { FlyoutLoading } from '../../shared/components/flyout_loading'; +import { FlyoutNavigation } from '../../shared/components/flyout_navigation'; +import { FlyoutError } from '../../shared/components/flyout_error'; export interface RulePanelExpandableFlyoutProps extends FlyoutPanelProps { key: 'rule-panel' | 'rule-preview-panel'; diff --git a/x-pack/packages/security-solution/common/src/flyout/common/components/expandable_panel.stories.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/expandable_panel.stories.tsx similarity index 100% rename from x-pack/packages/security-solution/common/src/flyout/common/components/expandable_panel.stories.tsx rename to x-pack/plugins/security_solution/public/flyout/shared/components/expandable_panel.stories.tsx diff --git a/x-pack/packages/security-solution/common/src/flyout/common/components/expandable_panel.test.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/expandable_panel.test.tsx similarity index 99% rename from x-pack/packages/security-solution/common/src/flyout/common/components/expandable_panel.test.tsx rename to x-pack/plugins/security_solution/public/flyout/shared/components/expandable_panel.test.tsx index cc282eb1156b5..87592b608613f 100644 --- a/x-pack/packages/security-solution/common/src/flyout/common/components/expandable_panel.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/shared/components/expandable_panel.test.tsx @@ -13,7 +13,7 @@ import { EXPANDABLE_PANEL_CONTENT_TEST_ID, EXPANDABLE_PANEL_HEADER_TITLE_ICON_TEST_ID, EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID, -} from '../test_ids'; +} from './test_ids'; import { ThemeProvider } from '@emotion/react'; import { ExpandablePanel } from './expandable_panel'; diff --git a/x-pack/packages/security-solution/common/src/flyout/common/components/expandable_panel.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/expandable_panel.tsx similarity index 97% rename from x-pack/packages/security-solution/common/src/flyout/common/components/expandable_panel.tsx rename to x-pack/plugins/security_solution/public/flyout/shared/components/expandable_panel.tsx index 383bbbb341c8e..88318ee2f2cfc 100644 --- a/x-pack/packages/security-solution/common/src/flyout/common/components/expandable_panel.tsx +++ b/x-pack/plugins/security_solution/public/flyout/shared/components/expandable_panel.tsx @@ -110,7 +110,7 @@ export const ExpandablePanel: FC> = > = diff --git a/x-pack/packages/security-solution/common/src/flyout/common/components/flyout_body.test.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_body.test.tsx similarity index 100% rename from x-pack/packages/security-solution/common/src/flyout/common/components/flyout_body.test.tsx rename to x-pack/plugins/security_solution/public/flyout/shared/components/flyout_body.test.tsx diff --git a/x-pack/packages/security-solution/common/src/flyout/common/components/flyout_body.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_body.tsx similarity index 100% rename from x-pack/packages/security-solution/common/src/flyout/common/components/flyout_body.tsx rename to x-pack/plugins/security_solution/public/flyout/shared/components/flyout_body.tsx diff --git a/x-pack/packages/security-solution/common/src/flyout/common/components/flyout_error.stories.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_error.stories.tsx similarity index 100% rename from x-pack/packages/security-solution/common/src/flyout/common/components/flyout_error.stories.tsx rename to x-pack/plugins/security_solution/public/flyout/shared/components/flyout_error.stories.tsx diff --git a/x-pack/packages/security-solution/common/src/flyout/common/components/flyout_error.test.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_error.test.tsx similarity index 94% rename from x-pack/packages/security-solution/common/src/flyout/common/components/flyout_error.test.tsx rename to x-pack/plugins/security_solution/public/flyout/shared/components/flyout_error.test.tsx index f0565fe1df43f..e58d586a063b5 100644 --- a/x-pack/packages/security-solution/common/src/flyout/common/components/flyout_error.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_error.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { render } from '@testing-library/react'; import { FlyoutError } from './flyout_error'; -import { FLYOUT_ERROR_TEST_ID } from '../test_ids'; +import { FLYOUT_ERROR_TEST_ID } from './test_ids'; describe('', () => { it('should render error title and body', () => { diff --git a/x-pack/packages/security-solution/common/src/flyout/common/components/flyout_error.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_error.tsx similarity index 84% rename from x-pack/packages/security-solution/common/src/flyout/common/components/flyout_error.tsx rename to x-pack/plugins/security_solution/public/flyout/shared/components/flyout_error.tsx index f319d80dafe12..9ebef345540fe 100644 --- a/x-pack/packages/security-solution/common/src/flyout/common/components/flyout_error.tsx +++ b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_error.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { EuiEmptyPrompt, EuiFlexItem } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { FLYOUT_ERROR_TEST_ID } from '../test_ids'; +import { FLYOUT_ERROR_TEST_ID } from './test_ids'; /** * Use this when you need to show an error state in the flyout @@ -21,7 +21,7 @@ export const FlyoutError: React.VFC = () => ( title={

@@ -30,7 +30,7 @@ export const FlyoutError: React.VFC = () => ( body={

diff --git a/x-pack/packages/security-solution/common/src/flyout/common/components/flyout_footer.test.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_footer.test.tsx similarity index 100% rename from x-pack/packages/security-solution/common/src/flyout/common/components/flyout_footer.test.tsx rename to x-pack/plugins/security_solution/public/flyout/shared/components/flyout_footer.test.tsx diff --git a/x-pack/packages/security-solution/common/src/flyout/common/components/flyout_footer.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_footer.tsx similarity index 100% rename from x-pack/packages/security-solution/common/src/flyout/common/components/flyout_footer.tsx rename to x-pack/plugins/security_solution/public/flyout/shared/components/flyout_footer.tsx diff --git a/x-pack/packages/security-solution/common/src/flyout/common/components/flyout_header.test.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_header.test.tsx similarity index 100% rename from x-pack/packages/security-solution/common/src/flyout/common/components/flyout_header.test.tsx rename to x-pack/plugins/security_solution/public/flyout/shared/components/flyout_header.test.tsx diff --git a/x-pack/packages/security-solution/common/src/flyout/common/components/flyout_header.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_header.tsx similarity index 100% rename from x-pack/packages/security-solution/common/src/flyout/common/components/flyout_header.tsx rename to x-pack/plugins/security_solution/public/flyout/shared/components/flyout_header.tsx diff --git a/x-pack/packages/security-solution/common/src/flyout/common/components/flyout_header_tabs.test.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_header_tabs.test.tsx similarity index 100% rename from x-pack/packages/security-solution/common/src/flyout/common/components/flyout_header_tabs.test.tsx rename to x-pack/plugins/security_solution/public/flyout/shared/components/flyout_header_tabs.test.tsx diff --git a/x-pack/packages/security-solution/common/src/flyout/common/components/flyout_header_tabs.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_header_tabs.tsx similarity index 100% rename from x-pack/packages/security-solution/common/src/flyout/common/components/flyout_header_tabs.tsx rename to x-pack/plugins/security_solution/public/flyout/shared/components/flyout_header_tabs.tsx diff --git a/x-pack/packages/security-solution/common/src/flyout/common/components/flyout_loading.stories.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_loading.stories.tsx similarity index 100% rename from x-pack/packages/security-solution/common/src/flyout/common/components/flyout_loading.stories.tsx rename to x-pack/plugins/security_solution/public/flyout/shared/components/flyout_loading.stories.tsx diff --git a/x-pack/packages/security-solution/common/src/flyout/common/components/flyout_loading.test.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_loading.test.tsx similarity index 94% rename from x-pack/packages/security-solution/common/src/flyout/common/components/flyout_loading.test.tsx rename to x-pack/plugins/security_solution/public/flyout/shared/components/flyout_loading.test.tsx index d55e85b3e978b..a164db8a6ce01 100644 --- a/x-pack/packages/security-solution/common/src/flyout/common/components/flyout_loading.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_loading.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { FLYOUT_LOADING_TEST_ID } from '../test_ids'; +import { FLYOUT_LOADING_TEST_ID } from './test_ids'; import { FlyoutLoading } from './flyout_loading'; describe('', () => { diff --git a/x-pack/packages/security-solution/common/src/flyout/common/components/flyout_loading.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_loading.tsx similarity index 94% rename from x-pack/packages/security-solution/common/src/flyout/common/components/flyout_loading.tsx rename to x-pack/plugins/security_solution/public/flyout/shared/components/flyout_loading.tsx index cffe17c8ccbf1..0c98957dd929b 100644 --- a/x-pack/packages/security-solution/common/src/flyout/common/components/flyout_loading.tsx +++ b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_loading.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; import { css } from '@emotion/react'; -import { FLYOUT_LOADING_TEST_ID } from '../test_ids'; +import { FLYOUT_LOADING_TEST_ID } from './test_ids'; export interface FlyoutLoadingProps { /** diff --git a/x-pack/packages/security-solution/common/src/flyout/common/components/flyout_navigation.stories.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.stories.tsx similarity index 100% rename from x-pack/packages/security-solution/common/src/flyout/common/components/flyout_navigation.stories.tsx rename to x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.stories.tsx diff --git a/x-pack/packages/security-solution/common/src/flyout/common/components/flyout_navigation.test.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.test.tsx similarity index 78% rename from x-pack/packages/security-solution/common/src/flyout/common/components/flyout_navigation.test.tsx rename to x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.test.tsx index d010796008880..321245ccde86e 100644 --- a/x-pack/packages/security-solution/common/src/flyout/common/components/flyout_navigation.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.test.tsx @@ -8,61 +8,39 @@ import type { FC, PropsWithChildren } from 'react'; import React from 'react'; import { act, render } from '@testing-library/react'; +import { TestProviders } from '../../../common/mock'; import { FlyoutNavigation } from './flyout_navigation'; import { COLLAPSE_DETAILS_BUTTON_TEST_ID, EXPAND_DETAILS_BUTTON_TEST_ID, HEADER_ACTIONS_TEST_ID, -} from '../test_ids'; +} from './test_ids'; +import type { ExpandableFlyoutState } from '@kbn/expandable-flyout'; import { - ExpandableFlyoutApi, - ExpandableFlyoutProvider, - ExpandableFlyoutState, useExpandableFlyoutApi, + type ExpandableFlyoutApi, useExpandableFlyoutState, } from '@kbn/expandable-flyout'; -import { I18nProvider } from '@kbn/i18n-react'; const expandDetails = jest.fn(); -const mockFlyoutCloseLeftPanel = jest.fn(); + +const ExpandableFlyoutTestProviders: FC> = ({ children }) => { + return {children}; +}; jest.mock('@kbn/expandable-flyout', () => ({ - useExpandableFlyoutApi: jest.fn(() => { - return { - closeFlyout: jest.fn(), - closeLeftPanel: jest.fn(), - closePreviewPanel: jest.fn(), - closeRightPanel: jest.fn(), - previousPreviewPanel: jest.fn(), - openFlyout: jest.fn(), - openLeftPanel: jest.fn(), - openPreviewPanel: jest.fn(), - openRightPanel: jest.fn(), - }; - }), + useExpandableFlyoutApi: jest.fn(), useExpandableFlyoutState: jest.fn(), ExpandableFlyoutProvider: ({ children }: React.PropsWithChildren<{}>) => <>{children}, - withExpandableFlyoutProvider: (Component: React.ComponentType) => { - return (props: T) => { - return ; - }; - }, - ExpandableFlyout: jest.fn(), })); -const ExpandableFlyoutTestProviders: FC> = ({ children }) => { - return ( - - {children} - - ); -}; +const flyoutContextValue = { + closeLeftPanel: jest.fn(), +} as unknown as ExpandableFlyoutApi; describe('', () => { beforeEach(() => { - jest.mocked(useExpandableFlyoutApi).mockReturnValue({ - closeLeftPanel: mockFlyoutCloseLeftPanel, - } as unknown as ExpandableFlyoutApi); + jest.mocked(useExpandableFlyoutApi).mockReturnValue(flyoutContextValue); jest.mocked(useExpandableFlyoutState).mockReturnValue({} as unknown as ExpandableFlyoutState); }); @@ -97,7 +75,7 @@ describe('', () => { expect(queryByTestId(EXPAND_DETAILS_BUTTON_TEST_ID)).not.toBeInTheDocument(); getByTestId(COLLAPSE_DETAILS_BUTTON_TEST_ID).click(); - expect(mockFlyoutCloseLeftPanel).toHaveBeenCalled(); + expect(flyoutContextValue.closeLeftPanel).toHaveBeenCalled(); }); }); diff --git a/x-pack/packages/security-solution/common/src/flyout/common/components/flyout_navigation.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.tsx similarity index 88% rename from x-pack/packages/security-solution/common/src/flyout/common/components/flyout_navigation.tsx rename to x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.tsx index 961629147d781..1915c5a4484a4 100644 --- a/x-pack/packages/security-solution/common/src/flyout/common/components/flyout_navigation.tsx +++ b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.tsx @@ -22,8 +22,7 @@ import { HEADER_ACTIONS_TEST_ID, COLLAPSE_DETAILS_BUTTON_TEST_ID, EXPAND_DETAILS_BUTTON_TEST_ID, - HEADER_NAVIGATION_BUTTON_TEST_ID, -} from '../test_ids'; +} from './test_ids'; export interface FlyoutNavigationProps { /** @@ -63,14 +62,14 @@ export const FlyoutNavigation: FC = memo( size="s" data-test-subj={COLLAPSE_DETAILS_BUTTON_TEST_ID} aria-label={i18n.translate( - 'securitySolutionPackages.flyout.right.header.collapseDetailButtonAriaLabel', + 'xpack.securitySolution.flyout.right.header.collapseDetailButtonAriaLabel', { defaultMessage: 'Collapse details', } )} > @@ -87,14 +86,14 @@ export const FlyoutNavigation: FC = memo( size="s" data-test-subj={EXPAND_DETAILS_BUTTON_TEST_ID} aria-label={i18n.translate( - 'securitySolutionPackages.flyout.right.header.expandDetailButtonAriaLabel', + 'xpack.securitySolution.flyout.right.header.expandDetailButtonAriaLabel', { defaultMessage: 'Expand details', } )} > @@ -116,7 +115,7 @@ export const FlyoutNavigation: FC = memo( height: ${euiTheme.size.xxl}; `} > - + {flyoutIsExpandable && expandDetails && (isExpanded ? collapseButton : expandButton)} {actions && ( diff --git a/x-pack/packages/security-solution/common/src/flyout/common/components/flyout_title.stories.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_title.stories.tsx similarity index 100% rename from x-pack/packages/security-solution/common/src/flyout/common/components/flyout_title.stories.tsx rename to x-pack/plugins/security_solution/public/flyout/shared/components/flyout_title.stories.tsx diff --git a/x-pack/packages/security-solution/common/src/flyout/common/components/flyout_title.test.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_title.test.tsx similarity index 98% rename from x-pack/packages/security-solution/common/src/flyout/common/components/flyout_title.test.tsx rename to x-pack/plugins/security_solution/public/flyout/shared/components/flyout_title.test.tsx index 3fde2b034219b..1f2d0c128f411 100644 --- a/x-pack/packages/security-solution/common/src/flyout/common/components/flyout_title.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_title.test.tsx @@ -12,7 +12,7 @@ import { TITLE_HEADER_ICON_TEST_ID, TITLE_HEADER_TEXT_TEST_ID, TITLE_LINK_ICON_TEST_ID, -} from '../test_ids'; +} from './test_ids'; const title = 'test title'; const TEST_ID = 'test'; diff --git a/x-pack/packages/security-solution/common/src/flyout/common/components/flyout_title.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_title.tsx similarity index 100% rename from x-pack/packages/security-solution/common/src/flyout/common/components/flyout_title.tsx rename to x-pack/plugins/security_solution/public/flyout/shared/components/flyout_title.tsx diff --git a/x-pack/packages/security-solution/common/src/flyout/common/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/shared/components/test_ids.ts similarity index 93% rename from x-pack/packages/security-solution/common/src/flyout/common/test_ids.ts rename to x-pack/plugins/security_solution/public/flyout/shared/components/test_ids.ts index 60ccb0a234fde..f8a589f31561e 100644 --- a/x-pack/packages/security-solution/common/src/flyout/common/test_ids.ts +++ b/x-pack/plugins/security_solution/public/flyout/shared/components/test_ids.ts @@ -5,7 +5,7 @@ * 2.0. */ -export const PREFIX = 'securitySolutionFlyout' as const; +import { PREFIX } from '../test_ids'; export const FLYOUT_ERROR_TEST_ID = `${PREFIX}Error` as const; export const FLYOUT_LOADING_TEST_ID = `${PREFIX}Loading` as const; @@ -27,7 +27,6 @@ export const EXPANDABLE_PANEL_CONTENT_TEST_ID = (dataTestSubj: string) => `${dat /* Header Navigation */ const FLYOUT_NAVIGATION_TEST_ID = `${PREFIX}Navigation` as const; -export const HEADER_NAVIGATION_BUTTON_TEST_ID = `${PREFIX}NavigationButton` as const; export const EXPAND_DETAILS_BUTTON_TEST_ID = `${FLYOUT_NAVIGATION_TEST_ID}ExpandDetailButton` as const; export const COLLAPSE_DETAILS_BUTTON_TEST_ID = diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/artifacts/artifacts_mocked_data.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/artifacts/artifacts_mocked_data.cy.ts index 6ab7979c46086..b5c41d1e66faf 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/artifacts/artifacts_mocked_data.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/artifacts/artifacts_mocked_data.cy.ts @@ -35,8 +35,7 @@ const loginWithoutAccess = (url: string) => { loadPage(url); }; -// Failing: See https://github.com/elastic/kibana/issues/191914 -describe.skip('Artifacts pages', { tags: ['@ess', '@serverless', '@skipInServerlessMKI'] }, () => { +describe('Artifacts pages', { tags: ['@ess', '@serverless', '@skipInServerlessMKI'] }, () => { let endpointData: ReturnTypeFromChainable | undefined; before(() => { 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..543961ef9900b 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 @@ -41,7 +41,8 @@ describe( login(); }); - describe('Scan operation:', () => { + // FLAKY: https://github.com/elastic/kibana/issues/187932 + describe.skip('Scan operation:', () => { const homeFilePath = Cypress.env('IS_CI') ? '/home/vagrant' : '/home'; const fileContent = 'This is a test file for the scan command.'; diff --git a/x-pack/plugins/security_solution/public/management/cypress/support/plugin_handlers/endpoint_data_loader.ts b/x-pack/plugins/security_solution/public/management/cypress/support/plugin_handlers/endpoint_data_loader.ts index 76004f91ccb48..4b4e9adef0ec2 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/support/plugin_handlers/endpoint_data_loader.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/support/plugin_handlers/endpoint_data_loader.ts @@ -10,6 +10,10 @@ import type { KbnClient } from '@kbn/test'; import pRetry from 'p-retry'; import { kibanaPackageJson } from '@kbn/repo-info'; import type { ToolingLog } from '@kbn/tooling-log'; +import { + RETRYABLE_TRANSIENT_ERRORS, + retryOnError, +} from '../../../../../common/endpoint/data_loaders/utils'; import { fetchFleetLatestAvailableAgentVersion } from '../../../../../common/endpoint/utils/fetch_fleet_version'; import { dump } from '../../../../../scripts/endpoint/common/utils'; import { STARTED_TRANSFORM_STATES } from '../../../../../common/constants'; @@ -158,18 +162,17 @@ const stopTransform = async ( ): Promise => { log.debug(`Stopping transform id: ${transformId}`); - await esClient.transform - .stopTransform({ - transform_id: `${transformId}*`, - force: true, - wait_for_completion: true, - allow_no_match: true, - }) - .catch((e) => { - Error.captureStackTrace(e); - log.verbose(dump(e, 8)); - throw e; - }); + await retryOnError( + () => + esClient.transform.stopTransform({ + transform_id: `${transformId}*`, + force: true, + wait_for_completion: true, + allow_no_match: true, + }), + RETRYABLE_TRANSIENT_ERRORS, + log + ); }; const startTransform = async ( @@ -177,9 +180,14 @@ const startTransform = async ( log: ToolingLog, transformId: string ): Promise => { - const transformsResponse = await esClient.transform.getTransformStats({ - transform_id: `${transformId}*`, - }); + const transformsResponse = await retryOnError( + () => + esClient.transform.getTransformStats({ + transform_id: `${transformId}*`, + }), + RETRYABLE_TRANSIENT_ERRORS, + log + ); log.verbose( `Transform status found for [${transformId}*] returned:\n${dump(transformsResponse)}` @@ -193,7 +201,11 @@ const startTransform = async ( log.debug(`Staring transform id: [${transform.id}]`); - return esClient.transform.startTransform({ transform_id: transform.id }); + return retryOnError( + () => esClient.transform.startTransform({ transform_id: transform.id }), + RETRYABLE_TRANSIENT_ERRORS, + log + ); }) ); }; diff --git a/x-pack/plugins/security_solution/public/management/links.ts b/x-pack/plugins/security_solution/public/management/links.ts index ee9b1005808d1..49049218d4dc1 100644 --- a/x-pack/plugins/security_solution/public/management/links.ts +++ b/x-pack/plugins/security_solution/public/management/links.ts @@ -21,7 +21,6 @@ import { EVENT_FILTERS_PATH, HOST_ISOLATION_EXCEPTIONS_PATH, MANAGE_PATH, - NOTES_PATH, POLICIES_PATH, RESPONSE_ACTIONS_HISTORY_PATH, SecurityPageName, @@ -38,13 +37,13 @@ import { RESPONSE_ACTIONS_HISTORY, TRUSTED_APPLICATIONS, ENTITY_ANALYTICS_RISK_SCORE, - NOTES, ENTITY_STORE, } from '../app/translations'; import { licenseService } from '../common/hooks/use_license'; import type { LinkItem } from '../common/links/types'; import type { StartPlugins } from '../types'; import { cloudDefendLink } from '../cloud_defend/links'; +import { links as notesLink } from '../notes/links'; import { IconConsole } from '../common/icons/console'; import { IconShield } from '../common/icons/shield'; import { IconEndpoints } from '../common/icons/endpoints'; @@ -218,19 +217,7 @@ export const links: LinkItem = { hideTimeline: true, }, cloudDefendLink, - { - id: SecurityPageName.notes, - title: NOTES, - description: i18n.translate('xpack.securitySolution.appLinks.notesDescription', { - defaultMessage: - 'Oversee, revise, and revisit the notes attached to alerts, events and Timelines.', - }), - landingIcon: 'filebeatApp', - path: NOTES_PATH, - skipUrlState: true, - hideTimeline: true, - hideWhenExperimentalKey: 'securitySolutionNotesDisabled', - }, + notesLink, ], }; diff --git a/x-pack/plugins/security_solution/public/notes/links.ts b/x-pack/plugins/security_solution/public/notes/links.ts index b09877e200fb9..628904ae30c41 100644 --- a/x-pack/plugins/security_solution/public/notes/links.ts +++ b/x-pack/plugins/security_solution/public/notes/links.ts @@ -12,14 +12,15 @@ import type { LinkItem } from '../common/links/types'; export const links: LinkItem = { id: SecurityPageName.notes, - title: NOTES, path: NOTES_PATH, + title: NOTES, + description: i18n.translate('xpack.securitySolution.appLinks.notesDescription', { + defaultMessage: + 'Oversee, revise, and revisit the notes attached to alerts, events and Timelines.', + }), capabilities: [`${SERVER_APP_ID}.show`], - globalSearchKeywords: [ - i18n.translate('xpack.securitySolution.appLinks.notes', { - defaultMessage: 'Notes', - }), - ], - links: [], + landingIcon: 'filebeatApp', + skipUrlState: true, + hideTimeline: true, hideWhenExperimentalKey: 'securitySolutionNotesDisabled', }; diff --git a/x-pack/plugins/security_solution/public/overview/pages/data_quality.test.tsx b/x-pack/plugins/security_solution/public/overview/pages/data_quality.test.tsx index e39e2abd24169..8b14fff8082c5 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/data_quality.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/data_quality.test.tsx @@ -8,6 +8,7 @@ import { render, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { MemoryRouter } from 'react-router-dom'; +import type { HttpFetchOptions } from '@kbn/core-http-browser'; import { useKibana as mockUseKibana } from '../../common/lib/kibana/__mocks__'; import { TestProviders } from '../../common/mock'; @@ -22,7 +23,17 @@ jest.mock('../../common/lib/kibana', () => { const mockKibanaServices = { get: () => ({ - http: { fetch: jest.fn() }, + http: { + fetch: jest.fn().mockImplementation((path: string, options: HttpFetchOptions) => { + if ( + path.startsWith('/internal/ecs_data_quality_dashboard/results_latest') && + options.method === 'GET' + ) { + return Promise.resolve([]); + } + return Promise.resolve(); + }), + }, }), }; diff --git a/x-pack/plugins/security_solution/public/overview/pages/data_quality.tsx b/x-pack/plugins/security_solution/public/overview/pages/data_quality.tsx index 37fc927094993..67dcc3848f02a 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/data_quality.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/data_quality.tsx @@ -171,6 +171,8 @@ const DataQualityComponent: React.FC = () => { startDate={startDate} theme={theme} toasts={toasts} + defaultStartTime={DEFAULT_START_TIME} + defaultEndTime={DEFAULT_END_TIME} /> ) : ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx index 75c074c517758..4e298e50d2a26 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx @@ -29,12 +29,12 @@ import { useGlobalFullScreen, useTimelineFullScreen, } from '../../../common/containers/use_full_screen'; -import { isFullScreen } from '../timeline/body/column_headers'; import { inputsActions } from '../../../common/store/actions'; import { Resolver } from '../../../resolver/view'; import { useTimelineDataFilters } from '../../containers/use_timeline_data_filters'; import { timelineSelectors } from '../../store'; import { timelineDefaults } from '../../store/defaults'; +import { isFullScreen } from '../timeline/helpers'; const SESSION_VIEW_FULL_SCREEN = 'sessionViewFullScreen'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/modal/actions/new_timeline_button.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/modal/actions/new_timeline_button.test.tsx index c26b34dffcaf4..92f9b874f8743 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/modal/actions/new_timeline_button.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/modal/actions/new_timeline_button.test.tsx @@ -12,7 +12,7 @@ import { TimelineId } from '../../../../../common/types'; import { timelineActions } from '../../../store'; import { TestProviders } from '../../../../common/mock'; import { RowRendererValues } from '../../../../../common/api/timeline'; -import { defaultUdtHeaders } from '../../timeline/unified_components/default_headers'; +import { defaultUdtHeaders } from '../../timeline/body/column_headers/default_headers'; jest.mock('../../../../common/components/discover_in_timeline/use_discover_in_timeline_context'); jest.mock('../../../../common/hooks/use_selector'); diff --git a/x-pack/plugins/security_solution/public/timelines/components/new_timeline/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/new_timeline/index.test.tsx index 8e08e4b957d64..0d1ed98f0bc99 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/new_timeline/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/new_timeline/index.test.tsx @@ -17,7 +17,7 @@ import { TimelineTypeEnum, } from '../../../../common/api/timeline'; import { TestProviders } from '../../../common/mock'; -import { defaultUdtHeaders } from '../timeline/unified_components/default_headers'; +import { defaultUdtHeaders } from '../timeline/body/column_headers/default_headers'; jest.mock('../../../common/components/discover_in_timeline/use_discover_in_timeline_context'); jest.mock('../../../common/hooks/use_selector'); 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 5da977c30a410..917f1d1bc29db 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 @@ -35,7 +35,7 @@ import { mockTemplate as mockSelectedTemplate, } from './__mocks__'; import { resolveTimeline } from '../../containers/api'; -import { defaultUdtHeaders } from '../timeline/unified_components/default_headers'; +import { defaultUdtHeaders } from '../timeline/body/column_headers/default_headers'; jest.mock('../../../common/hooks/use_experimental_features'); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts index b3f84a82d4ed0..e9c1d85b9049e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts @@ -35,7 +35,10 @@ import { useUpdateTimeline } from './use_update_timeline'; import type { TimelineModel } from '../../store/model'; import { timelineDefaults } from '../../store/defaults'; -import { defaultColumnHeaderType } from '../timeline/body/column_headers/default_headers'; +import { + defaultColumnHeaderType, + defaultUdtHeaders, +} from '../timeline/body/column_headers/default_headers'; import type { OpenTimelineResult, TimelineErrorCallback } from './types'; import { IS_OPERATOR } from '../timeline/data_providers/data_provider'; @@ -46,7 +49,6 @@ import { DEFAULT_TO_MOMENT, } from '../../../common/utils/default_date_settings'; import { resolveTimeline } from '../../containers/api'; -import { defaultUdtHeaders } from '../timeline/unified_components/default_headers'; import { timelineActions } from '../../store'; export const OPEN_TIMELINE_CLASS_NAME = 'open-timeline'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx index 6afd900185af7..8af06fe910f99 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx @@ -53,7 +53,7 @@ import { SourcererScopeName } from '../../../sourcerer/store/model'; import { useSourcererDataView } from '../../../sourcerer/containers'; import { useStartTransaction } from '../../../common/lib/apm/use_start_transaction'; import { TIMELINE_ACTIONS } from '../../../common/lib/apm/user_actions'; -import { defaultUdtHeaders } from '../timeline/unified_components/default_headers'; +import { defaultUdtHeaders } from '../timeline/body/column_headers/default_headers'; import { timelineDefaults } from '../../store/defaults'; interface OwnProps { 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 10088446c045c..b2c990b12eced 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 @@ -116,7 +116,7 @@ describe('dispatchUpdateTimeline', () => { ...mockTimelineModel, version: null, updated: undefined, - changed: undefined, + changed: true, }, }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_update_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_update_timeline.tsx index 2d380e700f7d3..2927b2365221d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_update_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_update_timeline.tsx @@ -53,7 +53,9 @@ export const useUpdateTimeline = () => { }: UpdateTimeline) => { let _timeline = timeline; if (duplicate) { - _timeline = { ...timeline, updated: undefined, changed: undefined, version: null }; + // Reset the `updated` and `version` fields because a duplicated timeline has not been saved yet. + // The `changed` field is set to true because the duplicated timeline needs to be saved. + _timeline = { ...timeline, updated: undefined, changed: true, version: null }; } if (!isEmpty(_timeline.indexNames)) { dispatch( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 48209711babbf..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,513 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = ` - -`; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/actions/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/actions/index.tsx deleted file mode 100644 index 025d12bc6ddc5..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/actions/index.tsx +++ /dev/null @@ -1,70 +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 { EuiButtonIcon } from '@elastic/eui'; -import React, { useCallback } from 'react'; - -import type { ColumnHeaderOptions } from '../../../../../../../common/types'; -import type { OnColumnRemoved } from '../../../events'; -import { EventsHeadingExtra, EventsLoading } from '../../../styles'; -import type { Sort } from '../../sort'; - -import * as i18n from '../translations'; - -interface Props { - header: ColumnHeaderOptions; - isLoading: boolean; - onColumnRemoved: OnColumnRemoved; - sort: Sort[]; -} - -/** Given a `header`, returns the `SortDirection` applicable to it */ - -export const CloseButton = React.memo<{ - columnId: string; - onColumnRemoved: OnColumnRemoved; -}>(({ columnId, onColumnRemoved }) => { - const handleClick = useCallback( - (event: React.MouseEvent) => { - // To avoid a re-sorting when you delete a column - event.preventDefault(); - event.stopPropagation(); - onColumnRemoved(columnId); - }, - [columnId, onColumnRemoved] - ); - - return ( - - ); -}); - -CloseButton.displayName = 'CloseButton'; - -export const Actions = React.memo(({ header, onColumnRemoved, sort, isLoading }) => { - return ( - <> - {sort.some((i) => i.columnId === header.id) && isLoading ? ( - - - - ) : ( - - - - )} - - ); -}); - -Actions.displayName = 'Actions'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/column_header.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/column_header.tsx deleted file mode 100644 index 2048cefb9bf0f..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/column_header.tsx +++ /dev/null @@ -1,307 +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 { EuiContextMenuPanelDescriptor } from '@elastic/eui'; -import { EuiContextMenu, EuiIcon, EuiPopover } from '@elastic/eui'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; -import type { DraggableChildrenFn } from '@hello-pangea/dnd'; -import { Draggable } from '@hello-pangea/dnd'; -import type { ResizeCallback } from 're-resizable'; -import { Resizable } from 're-resizable'; -import { useDispatch } from 'react-redux'; -import styled from 'styled-components'; -import { DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME } from '@kbn/securitysolution-t-grid'; - -import { useDraggableKeyboardWrapper } from '../../../../../common/components/drag_and_drop/draggable_keyboard_wrapper_hook'; -import { DEFAULT_COLUMN_MIN_WIDTH } from '../constants'; -import { getDraggableFieldId } from '../../../../../common/components/drag_and_drop/helpers'; -import type { ColumnHeaderOptions } from '../../../../../../common/types/timeline'; -import { TimelineTabs } from '../../../../../../common/types/timeline'; -import { Direction } from '../../../../../../common/search_strategy'; -import type { OnFilterChange } from '../../events'; -import { ARIA_COLUMN_INDEX_OFFSET } from '../../helpers'; -import { EventsTh, EventsThContent, EventsHeadingHandle } from '../../styles'; -import type { Sort } from '../sort'; - -import { Header } from './header'; -import { timelineActions } from '../../../../store'; - -import * as i18n from './translations'; - -const ContextMenu = styled(EuiContextMenu)` - width: 115px; - - & .euiContextMenuItem { - font-size: 12px; - padding: 4px 8px; - width: 115px; - } -`; - -const PopoverContainer = styled.div<{ $width: number }>` - & .euiPopover { - padding-right: 8px; - width: ${({ $width }) => $width}px; - } -`; - -const RESIZABLE_ENABLE = { right: true }; - -interface ColumneHeaderProps { - draggableIndex: number; - header: ColumnHeaderOptions; - isDragging: boolean; - onFilterChange?: OnFilterChange; - sort: Sort[]; - tabType: TimelineTabs; - timelineId: string; -} - -const ColumnHeaderComponent: React.FC = ({ - draggableIndex, - header, - timelineId, - isDragging, - onFilterChange, - sort, - tabType, -}) => { - const keyboardHandlerRef = useRef(null); - const [hoverActionsOwnFocus, setHoverActionsOwnFocus] = useState(false); - const restoreFocus = useCallback(() => keyboardHandlerRef.current?.focus(), []); - - const dispatch = useDispatch(); - const resizableSize = useMemo( - () => ({ - width: header.initialWidth ?? DEFAULT_COLUMN_MIN_WIDTH, - height: 'auto', - }), - [header.initialWidth] - ); - const resizableStyle: { - position: 'absolute' | 'relative'; - } = useMemo( - () => ({ - position: isDragging ? 'absolute' : 'relative', - }), - [isDragging] - ); - const resizableHandleComponent = useMemo( - () => ({ - right: , - }), - [] - ); - const handleResizeStop: ResizeCallback = useCallback( - (e, direction, ref, delta) => { - dispatch( - timelineActions.applyDeltaToColumnWidth({ - columnId: header.id, - delta: delta.width, - id: timelineId, - }) - ); - }, - [dispatch, header.id, timelineId] - ); - const draggableId = useMemo( - () => - getDraggableFieldId({ - contextId: `timeline-column-headers-${tabType}-${timelineId}`, - fieldId: header.id, - }), - [tabType, timelineId, header.id] - ); - - const onColumnSort = useCallback( - (sortDirection: Direction) => { - const columnId = header.id; - const columnType = header.type ?? ''; - const esTypes = header.esTypes ?? []; - const headerIndex = sort.findIndex((col) => col.columnId === columnId); - const newSort = - headerIndex === -1 - ? [ - ...sort, - { - columnId, - columnType, - esTypes, - sortDirection, - }, - ] - : [ - ...sort.slice(0, headerIndex), - { - columnId, - columnType, - esTypes, - sortDirection, - }, - ...sort.slice(headerIndex + 1), - ]; - - dispatch( - timelineActions.updateSort({ - id: timelineId, - sort: newSort, - }) - ); - }, - [dispatch, header, sort, timelineId] - ); - - const handleClosePopOverTrigger = useCallback(() => { - setHoverActionsOwnFocus(false); - restoreFocus(); - }, [restoreFocus]); - - const panels: EuiContextMenuPanelDescriptor[] = useMemo( - () => [ - { - id: 0, - items: [ - { - icon: , - name: i18n.HIDE_COLUMN, - onClick: () => { - dispatch(timelineActions.removeColumn({ id: timelineId, columnId: header.id })); - handleClosePopOverTrigger(); - }, - }, - ...(tabType !== TimelineTabs.eql - ? [ - { - disabled: !header.aggregatable, - icon: , - name: i18n.SORT_AZ, - onClick: () => { - onColumnSort(Direction.asc); - handleClosePopOverTrigger(); - }, - }, - { - disabled: !header.aggregatable, - icon: , - name: i18n.SORT_ZA, - onClick: () => { - onColumnSort(Direction.desc); - handleClosePopOverTrigger(); - }, - }, - ] - : []), - ], - }, - ], - [ - dispatch, - handleClosePopOverTrigger, - header.aggregatable, - header.id, - onColumnSort, - tabType, - timelineId, - ] - ); - - const headerButton = useMemo( - () => ( -

- ), - [header, onFilterChange, sort, timelineId] - ); - - const DraggableContent = useCallback( - (dragProvided) => ( - - - - - - - - - - ), - [handleClosePopOverTrigger, headerButton, header.initialWidth, hoverActionsOwnFocus, panels] - ); - - const onFocus = useCallback(() => { - keyboardHandlerRef.current?.focus(); - }, []); - - const openPopover = useCallback(() => { - setHoverActionsOwnFocus(true); - }, []); - - const { onBlur, onKeyDown } = useDraggableKeyboardWrapper({ - closePopover: handleClosePopOverTrigger, - draggableId, - fieldName: header.id, - keyboardHandlerRef, - openPopover, - }); - - const keyDownHandler = useCallback( - (keyboardEvent: React.KeyboardEvent) => { - if (!hoverActionsOwnFocus) { - onKeyDown(keyboardEvent); - } - }, - [hoverActionsOwnFocus, onKeyDown] - ); - - return ( - -
- - {DraggableContent} - -
-
- ); -}; - -export const ColumnHeader = React.memo(ColumnHeaderComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/default_headers.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/default_headers.ts index 56c29462415bd..a249f2ef2a851 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/default_headers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/default_headers.ts @@ -6,51 +6,48 @@ */ import type { ColumnHeaderOptions, ColumnHeaderType } from '../../../../../../common/types'; -import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../constants'; +import { + DEFAULT_COLUMN_MIN_WIDTH, + DEFAULT_UNIFIED_TABLE_DATE_COLUMN_MIN_WIDTH, +} from '../constants'; export const defaultColumnHeaderType: ColumnHeaderType = 'not-filtered'; -export const defaultHeaders: ColumnHeaderOptions[] = [ +export const defaultUdtHeaders: ColumnHeaderOptions[] = [ { columnHeaderType: defaultColumnHeaderType, id: '@timestamp', - initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_UNIFIED_TABLE_DATE_COLUMN_MIN_WIDTH, esTypes: ['date'], type: 'date', }, { columnHeaderType: defaultColumnHeaderType, id: 'message', - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH * 2, }, { columnHeaderType: defaultColumnHeaderType, id: 'event.category', - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'event.action', - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'host.name', - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'source.ip', - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'destination.ip', - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'user.name', - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, ]; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/filter/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/filter/__snapshots__/index.test.tsx.snap deleted file mode 100644 index ed15a5f635e9f..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/filter/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,9 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Filter renders correctly against snapshot 1`] = ` - -`; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/filter/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/filter/index.test.tsx deleted file mode 100644 index bd6e75efd94d9..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/filter/index.test.tsx +++ /dev/null @@ -1,55 +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 { mount, shallow } from 'enzyme'; -import React from 'react'; - -import { defaultHeaders } from '../default_headers'; - -import { Filter } from '.'; -import type { ColumnHeaderType } from '../../../../../../../common/types'; - -const textFilter: ColumnHeaderType = 'text-filter'; -const notFiltered: ColumnHeaderType = 'not-filtered'; - -describe('Filter', () => { - test('renders correctly against snapshot', () => { - const textFilterColumnHeader = { - ...defaultHeaders[0], - columnHeaderType: textFilter, - }; - - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); - }); - - describe('rendering', () => { - test('it renders a text filter when the columnHeaderType is "text-filter"', () => { - const textFilterColumnHeader = { - ...defaultHeaders[0], - columnHeaderType: textFilter, - }; - - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="textFilter"]').first().props()).toHaveProperty( - 'placeholder' - ); - }); - - test('it does NOT render a filter when the columnHeaderType is "not-filtered"', () => { - const notFilteredHeader = { - ...defaultHeaders[0], - columnHeaderType: notFiltered, - }; - - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="textFilter"]').exists()).toEqual(false); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/filter/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/filter/index.tsx deleted file mode 100644 index 3eb2cda8af242..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/filter/index.tsx +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { noop } from 'lodash/fp'; -import React from 'react'; - -import type { ColumnHeaderOptions } from '../../../../../../../common/types'; -import { DEFAULT_COLUMN_MIN_WIDTH } from '../../constants'; -import type { OnFilterChange } from '../../../events'; -import { TextFilter } from '../text_filter'; - -interface Props { - header: ColumnHeaderOptions; - onFilterChange?: OnFilterChange; -} - -/** Renders a header's filter, based on the `columnHeaderType` */ -export const Filter = React.memo(({ header, onFilterChange = noop }) => { - switch (header.columnHeaderType) { - case 'text-filter': - return ( - - ); - case 'not-filtered': // fall through - default: - return null; - } -}); - -Filter.displayName = 'Filter'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 0a621f0218337..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,77 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Header renders correctly against snapshot 1`] = ` - - - - - - -`; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/header_content.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/header_content.tsx deleted file mode 100644 index 24b75c88d7963..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/header_content.tsx +++ /dev/null @@ -1,84 +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 { EuiToolTip } from '@elastic/eui'; -import { noop } from 'lodash/fp'; -import React from 'react'; -import type { ColumnHeaderOptions } from '../../../../../../../common/types/timeline'; - -import { TruncatableText } from '../../../../../../common/components/truncatable_text'; -import { EventsHeading, EventsHeadingTitleButton, EventsHeadingTitleSpan } from '../../../styles'; -import type { Sort } from '../../sort'; -import { SortIndicator } from '../../sort/sort_indicator'; -import { HeaderToolTipContent } from '../header_tooltip_content'; -import { getSortDirection, getSortIndex } from './helpers'; -interface HeaderContentProps { - children: React.ReactNode; - header: ColumnHeaderOptions; - isLoading: boolean; - isResizing: boolean; - onClick: () => void; - showSortingCapability: boolean; - sort: Sort[]; -} - -const HeaderContentComponent: React.FC = ({ - children, - header, - isLoading, - isResizing, - onClick, - showSortingCapability, - sort, -}) => ( - - {header.aggregatable && showSortingCapability ? ( - - - } - > - <> - {React.isValidElement(header.display) - ? header.display - : header.displayAsText ?? header.id} - - - - - - - ) : ( - - - } - > - <> - {React.isValidElement(header.display) - ? header.display - : header.displayAsText ?? header.id} - - - - - )} - - {children} - -); - -export const HeaderContent = React.memo(HeaderContentComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/helpers.ts deleted file mode 100644 index e31ed05e55929..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/helpers.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { Direction } from '../../../../../../../common/search_strategy'; -import type { - ColumnHeaderOptions, - SortDirection, -} from '../../../../../../../common/types/timeline'; -import type { Sort } from '../../sort'; - -interface GetNewSortDirectionOnClickParams { - clickedHeader: ColumnHeaderOptions; - currentSort: Sort[]; -} - -/** Given a `header`, returns the `SortDirection` applicable to it */ -export const getNewSortDirectionOnClick = ({ - clickedHeader, - currentSort, -}: GetNewSortDirectionOnClickParams): Direction => - currentSort.reduce( - (acc, item) => (clickedHeader.id === item.columnId ? getNextSortDirection(item) : acc), - Direction.desc - ); - -/** Given a current sort direction, it returns the next sort direction */ -export const getNextSortDirection = (currentSort: Sort): Direction => { - switch (currentSort.sortDirection) { - case Direction.desc: - return Direction.asc; - case Direction.asc: - return Direction.desc; - case 'none': - return Direction.desc; - default: - return Direction.desc; - } -}; - -interface GetSortDirectionParams { - header: ColumnHeaderOptions; - sort: Sort[]; -} - -export const getSortDirection = ({ header, sort }: GetSortDirectionParams): SortDirection => - sort.reduce( - (acc, item) => (header.id === item.columnId ? item.sortDirection : acc), - 'none' - ); - -export const getSortIndex = ({ header, sort }: GetSortDirectionParams): number => - sort.findIndex((s) => s.columnId === header.id); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.test.tsx deleted file mode 100644 index abf452b834a1d..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.test.tsx +++ /dev/null @@ -1,364 +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 { mount, shallow } from 'enzyme'; -import React, { type ReactNode } from 'react'; - -import { timelineActions } from '../../../../../store'; -import { TestProviders } from '../../../../../../common/mock'; -import type { Sort } from '../../sort'; -import { CloseButton } from '../actions'; -import { defaultHeaders } from '../default_headers'; - -import { HeaderComponent } from '.'; -import { getNewSortDirectionOnClick, getNextSortDirection, getSortDirection } from './helpers'; -import { Direction } from '../../../../../../../common/search_strategy'; -import { useDeepEqualSelector } from '../../../../../../common/hooks/use_selector'; -import type { ColumnHeaderType } from '../../../../../../../common/types'; -import { TimelineId } from '../../../../../../../common/types/timeline'; - -const mockDispatch = jest.fn(); -jest.mock('react-redux', () => { - const original = jest.requireActual('react-redux'); - - return { - ...original, - useSelector: jest.fn(), - useDispatch: () => mockDispatch, - }; -}); - -jest.mock('../../../../../../common/hooks/use_selector', () => ({ - useShallowEqualSelector: jest.fn(), - useDeepEqualSelector: jest.fn(), -})); - -const filteredColumnHeader: ColumnHeaderType = 'text-filter'; - -describe('Header', () => { - const columnHeader = defaultHeaders[0]; - const sort: Sort[] = [ - { - columnId: columnHeader.id, - columnType: columnHeader.type ?? '', - esTypes: columnHeader.esTypes ?? [], - sortDirection: Direction.desc, - }, - ]; - const timelineId = TimelineId.test; - - beforeEach(() => { - (useDeepEqualSelector as jest.Mock).mockReturnValue({ isLoading: false }); - }); - - test('renders correctly against snapshot', () => { - const wrapper = shallow( - - - - ); - expect(wrapper.find('HeaderComponent').dive()).toMatchSnapshot(); - }); - - describe('rendering', () => { - test('it renders the header text', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper.find(`[data-test-subj="header-text-${columnHeader.id}"]`).first().text() - ).toEqual(columnHeader.id); - }); - - test('it renders the header text alias when displayAsText is provided', () => { - const displayAsText = 'Timestamp'; - const headerWithLabel = { ...columnHeader, displayAsText }; - const wrapper = mount( - - - - ); - - expect( - wrapper.find(`[data-test-subj="header-text-${columnHeader.id}"]`).first().text() - ).toEqual(displayAsText); - }); - - test('it renders the header as a `ReactNode` when `display` is provided', () => { - const display: React.ReactNode = ( -
- {'The display property renders the column heading as a ReactNode'} -
- ); - const headerWithLabel = { ...columnHeader, display }; - const wrapper = mount( - - - - ); - - expect(wrapper.find(`[data-test-subj="rendered-via-display"]`).exists()).toBe(true); - }); - - test('it prefers to render `display` instead of `displayAsText` when both are provided', () => { - const displayAsText = 'this text should NOT be rendered'; - const display: React.ReactNode = ( -
{'this text is rendered via display'}
- ); - const headerWithLabel = { ...columnHeader, display, displayAsText }; - const wrapper = mount( - - - - ); - - expect(wrapper.text()).toBe('this text is rendered via display'); - }); - - test('it falls back to rendering header.id when `display` is not a valid React node', () => { - const display = {} as unknown as ReactNode; // a plain object is NOT a `ReactNode` - const headerWithLabel = { ...columnHeader, display }; - const wrapper = mount( - - - - ); - - expect( - wrapper.find(`[data-test-subj="header-text-${columnHeader.id}"]`).first().text() - ).toEqual(columnHeader.id); - }); - - test('it renders a sort indicator', () => { - const headerSortable = { ...columnHeader, aggregatable: true }; - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="header-sort-indicator"]').first().exists()).toEqual( - true - ); - }); - - test('it renders a filter', () => { - const columnWithFilter = { - ...columnHeader, - columnHeaderType: filteredColumnHeader, - }; - - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="textFilter"]').first().props()).toHaveProperty( - 'placeholder' - ); - }); - }); - - describe('onColumnSorted', () => { - test('it invokes the onColumnSorted callback when the header sort button is clicked', () => { - const headerSortable = { ...columnHeader, aggregatable: true }; - const wrapper = mount( - - - - ); - - wrapper.find('[data-test-subj="header-sort-button"]').first().simulate('click'); - - expect(mockDispatch).toBeCalledWith( - timelineActions.updateSort({ - id: timelineId, - sort: [ - { - columnId: columnHeader.id, - columnType: columnHeader.type ?? 'number', - esTypes: columnHeader.esTypes ?? [], - sortDirection: Direction.asc, // (because the previous state was Direction.desc) - }, - ], - }) - ); - }); - - test('it does NOT render the header sort button when aggregatable is false', () => { - const headerSortable = { ...columnHeader, aggregatable: false }; - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="header-sort-button"]').length).toEqual(0); - }); - - test('it does NOT render the header sort button when aggregatable is missing', () => { - const headerSortable = { ...columnHeader }; - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="header-sort-button"]').length).toEqual(0); - }); - - test('it does NOT invoke the onColumnSorted callback when the header is clicked and aggregatable is undefined', () => { - const mockOnColumnSorted = jest.fn(); - const headerSortable = { ...columnHeader, aggregatable: undefined }; - const wrapper = mount( - - - - ); - - wrapper.find(`[data-test-subj="header-${columnHeader.id}"]`).first().simulate('click'); - - expect(mockOnColumnSorted).not.toHaveBeenCalled(); - }); - }); - - describe('CloseButton', () => { - test('it invokes the onColumnRemoved callback with the column ID when the close button is clicked', () => { - const mockOnColumnRemoved = jest.fn(); - - const wrapper = mount( - - ); - - wrapper.find('[data-test-subj="remove-column"]').first().simulate('click'); - - expect(mockOnColumnRemoved).toBeCalledWith(columnHeader.id); - }); - }); - - describe('getSortDirection', () => { - test('it returns the sort direction when the header id matches the sort column id', () => { - expect(getSortDirection({ header: columnHeader, sort })).toEqual(sort[0].sortDirection); - }); - - test('it returns "none" when sort direction when the header id does NOT match the sort column id', () => { - const nonMatching: Sort[] = [ - { - columnId: 'differentSocks', - columnType: columnHeader.type ?? 'number', - esTypes: columnHeader.esTypes ?? [], - sortDirection: Direction.desc, - }, - ]; - - expect(getSortDirection({ header: columnHeader, sort: nonMatching })).toEqual('none'); - }); - }); - - describe('getNextSortDirection', () => { - test('it returns "asc" when the current direction is "desc"', () => { - const sortDescending: Sort = { - columnId: columnHeader.id, - columnType: columnHeader.type ?? 'number', - esTypes: columnHeader.esTypes ?? [], - sortDirection: Direction.desc, - }; - - expect(getNextSortDirection(sortDescending)).toEqual('asc'); - }); - - test('it returns "desc" when the current direction is "asc"', () => { - const sortAscending: Sort = { - columnId: columnHeader.id, - columnType: columnHeader.type ?? 'number', - esTypes: columnHeader.esTypes ?? [], - sortDirection: Direction.asc, - }; - - expect(getNextSortDirection(sortAscending)).toEqual(Direction.desc); - }); - - test('it returns "desc" by default', () => { - const sortNone: Sort = { - columnId: columnHeader.id, - columnType: columnHeader.type ?? 'number', - esTypes: columnHeader.esTypes ?? [], - sortDirection: 'none', - }; - - expect(getNextSortDirection(sortNone)).toEqual(Direction.desc); - }); - }); - - describe('getNewSortDirectionOnClick', () => { - test('it returns the expected new sort direction when the header id matches the sort column id', () => { - const sortMatches: Sort[] = [ - { - columnId: columnHeader.id, - columnType: columnHeader.type ?? 'number', - esTypes: columnHeader.esTypes ?? [], - sortDirection: Direction.desc, - }, - ]; - - expect( - getNewSortDirectionOnClick({ - clickedHeader: columnHeader, - currentSort: sortMatches, - }) - ).toEqual(Direction.asc); - }); - - test('it returns the expected new sort direction when the header id does NOT match the sort column id', () => { - const sortDoesNotMatch: Sort[] = [ - { - columnId: 'someOtherColumn', - columnType: columnHeader.type ?? 'number', - esTypes: columnHeader.esTypes ?? [], - sortDirection: 'none', - }, - ]; - - expect( - getNewSortDirectionOnClick({ - clickedHeader: columnHeader, - currentSort: sortDoesNotMatch, - }) - ).toEqual(Direction.desc); - }); - }); - - describe('text truncation styling', () => { - test('truncates the header text with an ellipsis', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper.find(`[data-test-subj="header-text-${columnHeader.id}"]`).at(1) - ).toHaveStyleRule('text-overflow', 'ellipsis'); - }); - }); - - describe('header tooltip', () => { - test('it has a tooltip to display the properties of the field', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="header-tooltip"]').exists()).toEqual(true); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.tsx deleted file mode 100644 index 08af0bf9cbdd1..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.tsx +++ /dev/null @@ -1,118 +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 { noop } from 'lodash/fp'; -import React, { useCallback, useMemo } from 'react'; -import { useDispatch } from 'react-redux'; -import { isDataViewFieldSubtypeNested } from '@kbn/es-query'; - -import type { ColumnHeaderOptions } from '../../../../../../../common/types'; -import { - useDeepEqualSelector, - useShallowEqualSelector, -} from '../../../../../../common/hooks/use_selector'; -import { timelineActions, timelineSelectors } from '../../../../../store'; -import type { OnColumnRemoved, OnFilterChange } from '../../../events'; -import type { Sort } from '../../sort'; -import { Actions } from '../actions'; -import { Filter } from '../filter'; -import { getNewSortDirectionOnClick } from './helpers'; -import { HeaderContent } from './header_content'; -import { isEqlOnSelector } from './selectors'; - -interface Props { - header: ColumnHeaderOptions; - onFilterChange?: OnFilterChange; - sort: Sort[]; - timelineId: string; -} - -export const HeaderComponent: React.FC = ({ - header, - onFilterChange = noop, - sort, - timelineId, -}) => { - const dispatch = useDispatch(); - const getIsEqlOn = useMemo(() => isEqlOnSelector(), []); - const isEqlOn = useShallowEqualSelector((state) => getIsEqlOn(state, timelineId)); - - const onColumnSort = useCallback(() => { - const columnId = header.id; - const columnType = header.type ?? ''; - const esTypes = header.esTypes ?? []; - const sortDirection = getNewSortDirectionOnClick({ - clickedHeader: header, - currentSort: sort, - }); - const headerIndex = sort.findIndex((col) => col.columnId === columnId); - let newSort = []; - if (headerIndex === -1) { - newSort = [ - ...sort, - { - columnId, - columnType, - esTypes, - sortDirection, - }, - ]; - } else { - newSort = [ - ...sort.slice(0, headerIndex), - { - columnId, - columnType, - esTypes, - sortDirection, - }, - ...sort.slice(headerIndex + 1), - ]; - } - dispatch( - timelineActions.updateSort({ - id: timelineId, - sort: newSort, - }) - ); - }, [dispatch, header, sort, timelineId]); - - const onColumnRemoved = useCallback( - (columnId) => dispatch(timelineActions.removeColumn({ id: timelineId, columnId })), - [dispatch, timelineId] - ); - - const getManageTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); - const { isLoading } = useDeepEqualSelector( - (state) => getManageTimeline(state, timelineId) || { isLoading: false } - ); - const showSortingCapability = !isEqlOn && !isDataViewFieldSubtypeNested(header); - - return ( - <> - - - - - - - ); -}; - -export const Header = React.memo(HeaderComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/selectors.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/selectors.tsx deleted file mode 100644 index cac232fd6ea34..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/selectors.tsx +++ /dev/null @@ -1,13 +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 { createSelector } from 'reselect'; -import { TimelineTabs } from '../../../../../../../common/types/timeline'; -import { selectTimeline } from '../../../../../store/selectors'; - -export const isEqlOnSelector = () => - createSelector(selectTimeline, (timeline) => timeline?.activeTab === TimelineTabs.eql); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header_tooltip_content/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header_tooltip_content/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 750f3956786a5..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header_tooltip_content/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,67 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`HeaderToolTipContent it renders the expected table content 1`] = ` - -

- - Category - : - - - base - -

-

- - Field - : - - - @timestamp - -

-

- - Type - : - - - - - date - - -

-

- - Description - : - - - Date/time when the event originated. -For log events this is the date/time when the event was generated, and not when it was read. -Required field for all events. - -

-
-`; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header_tooltip_content/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header_tooltip_content/index.test.tsx deleted file mode 100644 index d2a134f37aba4..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header_tooltip_content/index.test.tsx +++ /dev/null @@ -1,89 +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 { mount, shallow } from 'enzyme'; -import { cloneDeep } from 'lodash/fp'; -import React from 'react'; - -import type { ColumnHeaderOptions } from '../../../../../../../common/types'; -import { defaultHeaders } from '../../../../../../common/mock'; -import { HeaderToolTipContent } from '.'; - -describe('HeaderToolTipContent', () => { - let header: ColumnHeaderOptions; - beforeEach(() => { - header = cloneDeep(defaultHeaders[0]); - }); - - test('it renders the category', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="category-value"]').first().text()).toEqual( - header.category - ); - }); - - test('it renders the name of the field', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="field-value"]').first().text()).toEqual(header.id); - }); - - test('it renders the expected icon for the header type', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="type-icon"]').first().props().type).toEqual('clock'); - }); - - test('it renders the type of the field', () => { - const wrapper = mount(); - - expect( - wrapper - .find(`[data-test-subj="type-value-${header.esTypes?.at(0)}"]`) - .first() - .text() - ).toEqual(header.esTypes?.at(0)); - }); - - test('it renders multiple `esTypes`', () => { - const hasMultipleTypes = { ...header, esTypes: ['long', 'date'] }; - - const wrapper = mount(); - - hasMultipleTypes.esTypes.forEach((esType) => { - expect(wrapper.find(`[data-test-subj="type-value-${esType}"]`).first().text()).toEqual( - esType - ); - }); - }); - - test('it renders the description of the field', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="description-value"]').first().text()).toEqual( - header.description - ); - }); - - test('it does NOT render the description column when the field does NOT contain a description', () => { - const noDescription = { - ...header, - description: '', - }; - - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="description"]').exists()).toEqual(false); - }); - - test('it renders the expected table content', () => { - const wrapper = shallow(); - - expect(wrapper).toMatchSnapshot(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header_tooltip_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header_tooltip_content/index.tsx deleted file mode 100644 index c96f3473a0064..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header_tooltip_content/index.tsx +++ /dev/null @@ -1,86 +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 { EuiIcon, EuiBadge } from '@elastic/eui'; -import { isEmpty } from 'lodash/fp'; -import React from 'react'; -import styled from 'styled-components'; - -import type { ColumnHeaderOptions } from '../../../../../../../common/types'; -import { getIconFromType } from '../../../../../../common/components/event_details/helpers'; -import * as i18n from '../translations'; - -const IconType = styled(EuiIcon)` - margin-right: 3px; - position: relative; - top: -2px; -`; -IconType.displayName = 'IconType'; - -const P = styled.span` - margin-bottom: 5px; -`; -P.displayName = 'P'; - -const ToolTipTableMetadata = styled.span` - margin-right: 5px; - display: block; - font-weight: bold; -`; -ToolTipTableMetadata.displayName = 'ToolTipTableMetadata'; - -const ToolTipTableValue = styled.span` - word-wrap: break-word; -`; -ToolTipTableValue.displayName = 'ToolTipTableValue'; - -export const HeaderToolTipContent = React.memo<{ header: ColumnHeaderOptions }>(({ header }) => ( - <> - {!isEmpty(header.category) && ( -

- - {i18n.CATEGORY} - {':'} - - {header.category} -

- )} -

- - {i18n.FIELD} - {':'} - - {header.id} -

-

- - {i18n.TYPE} - {':'} - - - - {header.esTypes?.map((esType) => ( - - {esType} - - ))} - -

- {!isEmpty(header.description) && ( -

- - {i18n.DESCRIPTION} - {':'} - - - {header.description} - -

- )} - -)); -HeaderToolTipContent.displayName = 'HeaderToolTipContent'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts index 8a4f024daac83..816ba731ba4fd 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts @@ -9,8 +9,7 @@ import { mockBrowserFields } from '../../../../../common/containers/source/mock' import type { BrowserFields } from '../../../../../../common/search_strategy'; import type { ColumnHeaderOptions } from '../../../../../../common/types'; import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../constants'; -import { defaultHeaders } from './default_headers'; -import { defaultUdtHeaders } from '../../unified_components/default_headers'; +import { defaultUdtHeaders } from './default_headers'; import { getColumnWidthFromType, getColumnHeaders, @@ -88,7 +87,7 @@ describe('helpers', () => { }); }); - test('should return the expected metadata in case of unified header', () => { + test('should return the expected metadata in case of default header', () => { const inputHeaders = defaultUdtHeaders; expect(getColumnHeader('@timestamp', inputHeaders)).toEqual({ columnHeaderType: 'not-filtered', @@ -112,7 +111,7 @@ describe('helpers', () => { searchable: true, type: 'date', esTypes: ['date'], - initialWidth: 190, + initialWidth: 215, }, { aggregatable: true, @@ -122,7 +121,6 @@ describe('helpers', () => { searchable: true, type: 'ip', esTypes: ['ip'], - initialWidth: 180, }, { aggregatable: true, @@ -132,10 +130,9 @@ describe('helpers', () => { searchable: true, type: 'ip', esTypes: ['ip'], - initialWidth: 180, }, ]; - const mockHeader = defaultHeaders.filter((h) => + const mockHeader = defaultUdtHeaders.filter((h) => ['@timestamp', 'source.ip', 'destination.ip'].includes(h.id) ); expect(getColumnHeaders(mockHeader, mockBrowserFields)).toEqual(expectedData); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.test.tsx deleted file mode 100644 index c20fcc45ea300..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.test.tsx +++ /dev/null @@ -1,336 +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 { shallow } from 'enzyme'; -import React from 'react'; - -import { defaultHeaders } from './default_headers'; -import { mockBrowserFields } from '../../../../../common/containers/source/mock'; -import type { Sort } from '../sort'; -import { TestProviders } from '../../../../../common/mock/test_providers'; -import { useMountAppended } from '../../../../../common/utils/use_mount_appended'; - -import type { ColumnHeadersComponentProps } from '.'; -import { ColumnHeadersComponent } from '.'; -import { cloneDeep } from 'lodash/fp'; -import { timelineActions } from '../../../../store'; -import { TimelineId, TimelineTabs } from '../../../../../../common/types/timeline'; -import { Direction } from '../../../../../../common/search_strategy'; -import { getDefaultControlColumn } from '../control_columns'; -import { testTrailingControlColumns } from '../../../../../common/mock/mock_timeline_control_columns'; -import type { UseFieldBrowserOptionsProps } from '../../../fields_browser'; -import { mockTriggersActionsUi } from '../../../../../common/mock/mock_triggers_actions_ui_plugin'; -import { mockTimelines } from '../../../../../common/mock/mock_timelines_plugin'; -import { HeaderActions } from '../../../../../common/components/header_actions/header_actions'; -import { getActionsColumnWidth } from '../../../../../common/components/header_actions'; - -jest.mock('../../../../../common/lib/kibana', () => ({ - useKibana: () => ({ - services: { - timelines: mockTimelines, - triggersActionsUi: mockTriggersActionsUi, - }, - }), -})); - -const mockUseFieldBrowserOptions = jest.fn(); -jest.mock('../../../fields_browser', () => ({ - useFieldBrowserOptions: (props: UseFieldBrowserOptionsProps) => mockUseFieldBrowserOptions(props), -})); - -const mockDispatch = jest.fn(); -jest.mock('react-redux', () => { - const original = jest.requireActual('react-redux'); - - return { - ...original, - useDispatch: () => mockDispatch, - }; -}); -const timelineId = TimelineId.test; - -describe('ColumnHeaders', () => { - const mount = useMountAppended(); - const ACTION_BUTTON_COUNT = 4; - const actionsColumnWidth = getActionsColumnWidth(ACTION_BUTTON_COUNT); - const leadingControlColumns = getDefaultControlColumn(ACTION_BUTTON_COUNT).map((x) => ({ - ...x, - headerCellRender: HeaderActions, - })); - const sort: Sort[] = [ - { - columnId: '@timestamp', - columnType: 'date', - esTypes: ['date'], - sortDirection: Direction.desc, - }, - ]; - const defaultProps: ColumnHeadersComponentProps = { - actionsColumnWidth, - browserFields: mockBrowserFields, - columnHeaders: defaultHeaders, - isSelectAllChecked: false, - onSelectAll: jest.fn, - show: true, - showEventsSelect: false, - showSelectAllCheckbox: false, - sort, - tabType: TimelineTabs.query, - timelineId, - leadingControlColumns, - trailingControlColumns: [], - }; - - describe('rendering', () => { - test('renders correctly against snapshot', () => { - const wrapper = shallow( - - - - ); - expect(wrapper.find('ColumnHeadersComponent')).toMatchSnapshot(); - }); - - test('it renders the field browser', () => { - const mockCloseEditor = jest.fn(); - mockUseFieldBrowserOptions.mockImplementation(({ editorActionsRef }) => { - editorActionsRef.current = { closeEditor: mockCloseEditor }; - return {}; - }); - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="field-browser"]').first().exists()).toEqual(true); - }); - - test('it renders every column header', () => { - const wrapper = mount( - - - - ); - - defaultHeaders.forEach((h) => { - expect(wrapper.find('[data-test-subj="headers-group"]').first().text()).toContain(h.id); - }); - }); - }); - - describe('#onColumnsSorted', () => { - let mockSort: Sort[] = [ - { - columnId: '@timestamp', - columnType: 'date', - esTypes: ['date'], - sortDirection: Direction.desc, - }, - { - columnId: 'host.name', - columnType: 'string', - esTypes: [], - sortDirection: Direction.asc, - }, - ]; - let mockDefaultHeaders = cloneDeep( - defaultHeaders.map((h) => (h.id === 'message' ? h : { ...h, aggregatable: true })) - ); - - beforeEach(() => { - mockDefaultHeaders = cloneDeep( - defaultHeaders.map((h) => (h.id === 'message' ? h : { ...h, aggregatable: true })) - ); - mockSort = [ - { - columnId: '@timestamp', - columnType: 'date', - esTypes: ['date'], - sortDirection: Direction.desc, - }, - { - columnId: 'host.name', - columnType: 'string', - esTypes: [], - sortDirection: Direction.asc, - }, - ]; - }); - - test('Add column `event.category` as desc sorting', () => { - const wrapper = mount( - - - - ); - - wrapper - .find('[data-test-subj="header-event.category"] [data-test-subj="header-sort-button"]') - .first() - .simulate('click'); - - expect(mockDispatch).toHaveBeenCalledWith( - timelineActions.updateSort({ - id: timelineId, - sort: [ - { - columnId: '@timestamp', - columnType: 'date', - esTypes: ['date'], - sortDirection: Direction.desc, - }, - { - columnId: 'host.name', - columnType: 'string', - esTypes: [], - sortDirection: Direction.asc, - }, - { - columnId: 'event.category', - columnType: '', - esTypes: [], - sortDirection: Direction.desc, - }, - ], - }) - ); - }); - - test('Change order of column `@timestamp` from desc to asc without changing index position', () => { - const wrapper = mount( - - - - ); - - wrapper - .find('[data-test-subj="header-@timestamp"] [data-test-subj="header-sort-button"]') - .first() - .simulate('click'); - expect(mockDispatch).toHaveBeenCalledWith( - timelineActions.updateSort({ - id: timelineId, - sort: [ - { - columnId: '@timestamp', - columnType: 'date', - esTypes: ['date'], - sortDirection: Direction.asc, - }, - { - columnId: 'host.name', - columnType: 'string', - esTypes: [], - sortDirection: Direction.asc, - }, - ], - }) - ); - }); - - test('Change order of column `host.name` from asc to desc without changing index position', () => { - const wrapper = mount( - - - - ); - - wrapper - .find('[data-test-subj="header-host.name"] [data-test-subj="header-sort-button"]') - .first() - .simulate('click'); - - expect(mockDispatch).toHaveBeenCalledWith( - timelineActions.updateSort({ - id: timelineId, - sort: [ - { - columnId: '@timestamp', - columnType: 'date', - esTypes: ['date'], - sortDirection: Direction.desc, - }, - { - columnId: 'host.name', - columnType: '', - esTypes: [], - sortDirection: Direction.desc, - }, - ], - }) - ); - }); - test('Does not render the default leading action column header and renders a custom trailing header', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.exists('[data-test-subj="field-browser"]')).toBeFalsy(); - expect(wrapper.exists('[data-test-subj="test-header-action-cell"]')).toBeTruthy(); - }); - }); - - describe('Field Editor', () => { - test('Closes field editor when the timeline is unmounted', () => { - const mockCloseEditor = jest.fn(); - mockUseFieldBrowserOptions.mockImplementation(({ editorActionsRef }) => { - editorActionsRef.current = { closeEditor: mockCloseEditor }; - return {}; - }); - - const wrapper = mount( - - - - ); - expect(mockCloseEditor).not.toHaveBeenCalled(); - - wrapper.unmount(); - expect(mockCloseEditor).toHaveBeenCalled(); - }); - - test('Closes field editor when the timeline is closed', () => { - const mockCloseEditor = jest.fn(); - mockUseFieldBrowserOptions.mockImplementation(({ editorActionsRef }) => { - editorActionsRef.current = { closeEditor: mockCloseEditor }; - return {}; - }); - - const Proxy = (props: ColumnHeadersComponentProps) => ( - - - - ); - const wrapper = mount(); - expect(mockCloseEditor).not.toHaveBeenCalled(); - - wrapper.setProps({ ...defaultProps, show: false }); - expect(mockCloseEditor).toHaveBeenCalled(); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx deleted file mode 100644 index f343b9af8ed97..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx +++ /dev/null @@ -1,316 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; -import type { DraggableChildrenFn, DroppableProps } from '@hello-pangea/dnd'; -import { Droppable } from '@hello-pangea/dnd'; - -import { useDispatch } from 'react-redux'; -import type { ControlColumnProps, HeaderActionProps } from '../../../../../../common/types'; -import { removeColumn, upsertColumn } from '../../../../store/actions'; -import { DragEffects } from '../../../../../common/components/drag_and_drop/draggable_wrapper'; -import { DraggableFieldBadge } from '../../../../../common/components/draggables/field_badge'; -import type { BrowserFields } from '../../../../../common/containers/source'; -import { - DRAG_TYPE_FIELD, - droppableTimelineColumnsPrefix, -} from '../../../../../common/components/drag_and_drop/helpers'; -import type { ColumnHeaderOptions, TimelineTabs } from '../../../../../../common/types/timeline'; -import type { OnSelectAll } from '../../events'; -import { - EventsTh, - EventsThead, - EventsThGroupData, - EventsTrHeader, - EventsThGroupActions, -} from '../../styles'; -import type { Sort } from '../sort'; -import { ColumnHeader } from './column_header'; - -import { SourcererScopeName } from '../../../../../sourcerer/store/model'; -import type { FieldEditorActions } from '../../../fields_browser'; -import { useFieldBrowserOptions } from '../../../fields_browser'; - -export interface ColumnHeadersComponentProps { - actionsColumnWidth: number; - browserFields: BrowserFields; - columnHeaders: ColumnHeaderOptions[]; - isEventViewer?: boolean; - isSelectAllChecked: boolean; - onSelectAll: OnSelectAll; - show: boolean; - showEventsSelect: boolean; - showSelectAllCheckbox: boolean; - sort: Sort[]; - tabType: TimelineTabs; - timelineId: string; - leadingControlColumns: ControlColumnProps[]; - trailingControlColumns: ControlColumnProps[]; -} - -interface DraggableContainerProps { - children: React.ReactNode; - onMount: () => void; - onUnmount: () => void; -} - -export const DraggableContainer = React.memo( - ({ children, onMount, onUnmount }) => { - useEffect(() => { - onMount(); - - return () => onUnmount(); - }, [onMount, onUnmount]); - - return <>{children}; - } -); - -DraggableContainer.displayName = 'DraggableContainer'; - -export const isFullScreen = ({ - globalFullScreen, - isActiveTimelines, - timelineFullScreen, -}: { - globalFullScreen: boolean; - isActiveTimelines: boolean; - timelineFullScreen: boolean; -}) => - (isActiveTimelines && timelineFullScreen) || (isActiveTimelines === false && globalFullScreen); - -/** Renders the timeline header columns */ -export const ColumnHeadersComponent = ({ - actionsColumnWidth, - browserFields, - columnHeaders, - isEventViewer = false, - isSelectAllChecked, - onSelectAll, - show, - showEventsSelect, - showSelectAllCheckbox, - sort, - tabType, - timelineId, - leadingControlColumns, - trailingControlColumns, -}: ColumnHeadersComponentProps) => { - const dispatch = useDispatch(); - - const [draggingIndex, setDraggingIndex] = useState(null); - const fieldEditorActionsRef = useRef(null); - - useEffect(() => { - return () => { - if (fieldEditorActionsRef.current) { - // eslint-disable-next-line react-hooks/exhaustive-deps - fieldEditorActionsRef.current.closeEditor(); - } - }; - }, []); - - useEffect(() => { - if (!show && fieldEditorActionsRef.current) { - fieldEditorActionsRef.current.closeEditor(); - } - }, [show]); - - const renderClone: DraggableChildrenFn = useCallback( - (dragProvided, _dragSnapshot, rubric) => { - const index = rubric.source.index; - const header = columnHeaders[index]; - - const onMount = () => setDraggingIndex(index); - const onUnmount = () => setDraggingIndex(null); - - return ( - - - - - - - - ); - }, - [columnHeaders, setDraggingIndex] - ); - - const ColumnHeaderList = useMemo( - () => - columnHeaders.map((header, draggableIndex) => ( - - )), - [columnHeaders, timelineId, draggingIndex, sort, tabType] - ); - - const DroppableContent = useCallback( - (dropProvided, snapshot) => ( - <> - - {ColumnHeaderList} - - - ), - [ColumnHeaderList] - ); - - const leadingHeaderCells = useMemo( - () => - leadingControlColumns ? leadingControlColumns.map((column) => column.headerCellRender) : [], - [leadingControlColumns] - ); - - const trailingHeaderCells = useMemo( - () => - trailingControlColumns ? trailingControlColumns.map((column) => column.headerCellRender) : [], - [trailingControlColumns] - ); - - const fieldBrowserOptions = useFieldBrowserOptions({ - sourcererScope: SourcererScopeName.timeline, - editorActionsRef: fieldEditorActionsRef, - upsertColumn: (column, index) => dispatch(upsertColumn({ column, id: timelineId, index })), - removeColumn: (columnId) => dispatch(removeColumn({ columnId, id: timelineId })), - }); - - const LeadingHeaderActions = useMemo(() => { - return leadingHeaderCells.map( - (Header: React.ComponentType | React.ComponentType | undefined, index) => { - const passedWidth = leadingControlColumns[index] && leadingControlColumns[index].width; - const width = passedWidth ? passedWidth : actionsColumnWidth; - return ( - - {Header && ( -
- )} - - ); - } - ); - }, [ - leadingHeaderCells, - leadingControlColumns, - actionsColumnWidth, - browserFields, - columnHeaders, - fieldBrowserOptions, - isEventViewer, - isSelectAllChecked, - onSelectAll, - showEventsSelect, - showSelectAllCheckbox, - sort, - tabType, - timelineId, - ]); - - const TrailingHeaderActions = useMemo(() => { - return trailingHeaderCells.map( - (Header: React.ComponentType | React.ComponentType | undefined, index) => { - const passedWidth = trailingControlColumns[index] && trailingControlColumns[index].width; - const width = passedWidth ? passedWidth : actionsColumnWidth; - return ( - - {Header && ( -
- )} - - ); - } - ); - }, [ - trailingHeaderCells, - trailingControlColumns, - actionsColumnWidth, - browserFields, - columnHeaders, - fieldBrowserOptions, - isEventViewer, - isSelectAllChecked, - onSelectAll, - showEventsSelect, - showSelectAllCheckbox, - sort, - tabType, - timelineId, - ]); - return ( - - - {LeadingHeaderActions} - - {DroppableContent} - - {TrailingHeaderActions} - - - ); -}; - -export const ColumnHeaders = React.memo(ColumnHeadersComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/range_picker/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/range_picker/index.test.tsx deleted file mode 100644 index 4d531e34fa3cd..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/range_picker/index.test.tsx +++ /dev/null @@ -1,47 +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 { mount } from 'enzyme'; -import React from 'react'; - -import { RangePicker } from '.'; -import { Ranges } from './ranges'; - -describe('RangePicker', () => { - describe('rendering', () => { - test('it renders the ranges', () => { - const wrapper = mount(); - - Ranges.forEach((range) => { - expect(wrapper.text()).toContain(range); - }); - }); - - test('it selects the option specified by the "selected" prop', () => { - const selected = '1 Month'; - const wrapper = mount(); - - expect(wrapper.find('select').props().value).toBe(selected); - }); - }); - - describe('#onRangeSelected', () => { - test('it invokes the onRangeSelected callback when a new range is selected', () => { - const oldSelection = '1 Week'; - const newSelection = '1 Day'; - const mockOnRangeSelected = jest.fn(); - - const wrapper = mount( - - ); - - wrapper.find('select').simulate('change', { target: { value: newSelection } }); - - expect(mockOnRangeSelected).toBeCalledWith(newSelection); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/range_picker/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/range_picker/index.tsx deleted file mode 100644 index 2eca94729aeb7..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/range_picker/index.tsx +++ /dev/null @@ -1,51 +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 { EuiSelect } from '@elastic/eui'; -import React from 'react'; -import styled from 'styled-components'; - -import type { OnRangeSelected } from '../../../events'; - -import { Ranges } from './ranges'; - -interface Props { - selected: string; - onRangeSelected: OnRangeSelected; -} - -export const rangePickerWidth = 120; - -// TODO: Upgrade Eui library and use EuiSuperSelect -const SelectContainer = styled.div` - cursor: pointer; - width: ${rangePickerWidth}px; -`; - -SelectContainer.displayName = 'SelectContainer'; - -/** Renders a time range picker for the MiniMap (e.g. 1 Day, 1 Week...) */ -export const RangePicker = React.memo(({ selected, onRangeSelected }) => { - const onChange = (event: React.ChangeEvent): void => { - onRangeSelected(event.target.value); - }; - - return ( - - ({ - text: range, - }))} - onChange={onChange} - /> - - ); -}); - -RangePicker.displayName = 'RangePicker'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/range_picker/ranges.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/range_picker/ranges.ts deleted file mode 100644 index a174207b2b057..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/range_picker/ranges.ts +++ /dev/null @@ -1,11 +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 * as i18n from './translations'; - -/** Enables runtime enumeration of valid `Range`s */ -export const Ranges: string[] = [i18n.ONE_DAY, i18n.ONE_WEEK, i18n.ONE_MONTH, i18n.ONE_YEAR]; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/range_picker/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/range_picker/translations.ts deleted file mode 100644 index 339c56f92490a..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/range_picker/translations.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; - -export const ONE_DAY = i18n.translate('xpack.securitySolution.timeline.rangePicker.oneDay', { - defaultMessage: '1 Day', -}); - -export const ONE_WEEK = i18n.translate('xpack.securitySolution.timeline.rangePicker.oneWeek', { - defaultMessage: '1 Week', -}); - -export const ONE_MONTH = i18n.translate('xpack.securitySolution.timeline.rangePicker.oneMonth', { - defaultMessage: '1 Month', -}); - -export const ONE_YEAR = i18n.translate('xpack.securitySolution.timeline.rangePicker.oneYear', { - defaultMessage: '1 Year', -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/text_filter/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/text_filter/__snapshots__/index.test.tsx.snap deleted file mode 100644 index fc4bd7bbd6148..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/text_filter/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,11 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`TextFilter rendering renders correctly against snapshot 1`] = ` - -`; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/text_filter/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/text_filter/index.test.tsx deleted file mode 100644 index aae979c19902e..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/text_filter/index.test.tsx +++ /dev/null @@ -1,79 +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 { mount, shallow } from 'enzyme'; -import React from 'react'; - -import { DEFAULT_PLACEHOLDER, TextFilter } from '.'; - -describe('TextFilter', () => { - describe('rendering', () => { - test('renders correctly against snapshot', () => { - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); - }); - - describe('placeholder', () => { - test('it renders the default placeholder when no filter is specified, and a placeholder is NOT provided', () => { - const wrapper = mount(); - - expect(wrapper.find(`input[placeholder="${DEFAULT_PLACEHOLDER}"]`).exists()).toEqual(true); - }); - - test('it renders the default placeholder when no filter is specified, a placeholder is provided', () => { - const placeholder = 'Make a jazz noise here'; - const wrapper = mount( - - ); - - expect(wrapper.find(`input[placeholder="${placeholder}"]`).exists()).toEqual(true); - }); - }); - - describe('minWidth', () => { - test('it applies the value of the minwidth prop to the input', () => { - const minWidth = 150; - const wrapper = mount(); - - expect(wrapper.find('input').props()).toHaveProperty('minwidth', `${minWidth}px`); - }); - }); - - describe('value', () => { - test('it renders the value of the filter prop', () => { - const filter = 'out the noise'; - const wrapper = mount(); - - expect(wrapper.find('input').prop('value')).toEqual(filter); - }); - }); - - describe('#onFilterChange', () => { - test('it invokes the onFilterChange callback when the input is updated', () => { - const columnId = 'foo'; - const oldFilter = 'abcdef'; - const newFilter = `${oldFilter}g`; - const onFilterChange = jest.fn(); - - const wrapper = mount( - - ); - - wrapper.find('input').simulate('change', { target: { value: newFilter } }); - expect(onFilterChange).toBeCalledWith({ - columnId, - filter: newFilter, - }); - }); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/text_filter/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/text_filter/index.tsx deleted file mode 100644 index d22e2ca40ca40..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/text_filter/index.tsx +++ /dev/null @@ -1,57 +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 { EuiFieldText } from '@elastic/eui'; -import { noop } from 'lodash/fp'; -import React from 'react'; -import styled from 'styled-components'; - -import type { OnFilterChange } from '../../../events'; -import type { ColumnId } from '../../column_id'; - -interface Props { - columnId: ColumnId; - filter?: string; - minWidth: number; - onFilterChange?: OnFilterChange; - placeholder?: string; -} - -export const DEFAULT_PLACEHOLDER = 'Filter'; - -const FieldText = styled(EuiFieldText)<{ minwidth: string }>` - min-width: ${(props) => props.minwidth}; -`; - -FieldText.displayName = 'FieldText'; - -/** Renders a text-based column filter */ -export const TextFilter = React.memo( - ({ - columnId, - minWidth, - filter = '', - onFilterChange = noop, - placeholder = DEFAULT_PLACEHOLDER, - }) => { - const onChange = (event: React.ChangeEvent): void => { - onFilterChange({ columnId, filter: event.target.value }); - }; - - return ( - - ); - } -); - -TextFilter.displayName = 'TextFilter'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/__snapshots__/index.test.tsx.snap deleted file mode 100644 index fd4566ca440e8..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,1037 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Columns it renders the expected columns 1`] = ` - - - - - - - - - -`; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.test.tsx deleted file mode 100644 index 67f3c35a3ec13..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { shallow } from 'enzyme'; - -import React from 'react'; - -import { DefaultCellRenderer } from '../../cell_rendering/default_cell_renderer'; -import { mockTimelineData } from '../../../../../common/mock'; -import { defaultHeaders } from '../column_headers/default_headers'; -import { getDefaultControlColumn } from '../control_columns'; - -import { DataDrivenColumns, getMappedNonEcsValue } from '.'; - -describe('Columns', () => { - const headersSansTimestamp = defaultHeaders.filter((h) => h.id !== '@timestamp'); - const ACTION_BUTTON_COUNT = 4; - const leadingControlColumns = getDefaultControlColumn(ACTION_BUTTON_COUNT); - - test('it renders the expected columns', () => { - const wrapper = shallow( - - ); - - expect(wrapper).toMatchSnapshot(); - }); - - describe('getMappedNonEcsValue', () => { - const existingField = 'Descarte'; - const existingValue = ['IThinkThereforeIAm']; - - test('should return the value if the fieldName is found', () => { - const result = getMappedNonEcsValue({ - data: [{ field: existingField, value: existingValue }], - fieldName: existingField, - }); - - expect(result).toBe(existingValue); - }); - - test('should return undefined if the value cannot be found in the array', () => { - const result = getMappedNonEcsValue({ - data: [{ field: existingField, value: existingValue }], - fieldName: 'nonExistent', - }); - - expect(result).toBeUndefined(); - }); - - test('should return undefined when data is an empty array', () => { - const result = getMappedNonEcsValue({ data: [], fieldName: existingField }); - - expect(result).toBeUndefined(); - }); - - test('should return undefined when data is undefined', () => { - const result = getMappedNonEcsValue({ data: undefined, fieldName: existingField }); - - expect(result).toBeUndefined(); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.tsx deleted file mode 100644 index a1d65e69dba58..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.tsx +++ /dev/null @@ -1,472 +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 { EuiScreenReaderOnly } from '@elastic/eui'; -import React, { useMemo } from 'react'; -import { getOr } from 'lodash/fp'; -import { DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME } from '@kbn/securitysolution-t-grid'; - -import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; -import type { - SetEventsDeleted, - SetEventsLoading, - ActionProps, - ControlColumnProps, - RowCellRender, -} from '../../../../../../common/types'; -import type { - CellValueElementProps, - ColumnHeaderOptions, - TimelineTabs, -} from '../../../../../../common/types/timeline'; -import type { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline'; -import { ARIA_COLUMN_INDEX_OFFSET } from '../../helpers'; -import type { OnRowSelected } from '../../events'; -import type { inputsModel } from '../../../../../common/store'; -import { - EventsTd, - EVENTS_TD_CLASS_NAME, - EventsTdContent, - EventsTdGroupData, - EventsTdGroupActions, -} from '../../styles'; - -import { StatefulCell } from './stateful_cell'; -import * as i18n from './translations'; - -interface CellProps { - _id: string; - ariaRowindex: number; - index: number; - header: ColumnHeaderOptions; - data: TimelineNonEcsData[]; - ecsData: Ecs; - hasRowRenderers: boolean; - notesCount: number; - renderCellValue: (props: CellValueElementProps) => React.ReactNode; - tabType?: TimelineTabs; - timelineId: string; -} - -interface DataDrivenColumnProps { - id: string; - actionsColumnWidth: number; - ariaRowindex: number; - checked: boolean; - columnHeaders: ColumnHeaderOptions[]; - columnValues: string; - data: TimelineNonEcsData[]; - ecsData: Ecs; - eventIdToNoteIds: Readonly>; - isEventPinned: boolean; - isEventViewer?: boolean; - loadingEventIds: Readonly; - notesCount: number; - onEventDetailsPanelOpened: () => void; - onRowSelected: OnRowSelected; - refetch: inputsModel.Refetch; - onRuleChange?: () => void; - hasRowRenderers: boolean; - selectedEventIds: Readonly>; - showCheckboxes: boolean; - showNotes: boolean; - renderCellValue: (props: CellValueElementProps) => React.ReactNode; - tabType?: TimelineTabs; - timelineId: string; - toggleShowNotes: () => void; - trailingControlColumns: ControlColumnProps[]; - leadingControlColumns: ControlColumnProps[]; - setEventsLoading: SetEventsLoading; - setEventsDeleted: SetEventsDeleted; -} - -const SPACE = ' '; - -export const shouldForwardKeyDownEvent = (key: string): boolean => { - switch (key) { - case SPACE: // fall through - case 'Enter': - return true; - default: - return false; - } -}; - -export const onKeyDown = (keyboardEvent: React.KeyboardEvent) => { - const { altKey, ctrlKey, key, metaKey, shiftKey, target, type } = keyboardEvent; - - const targetElement = target as Element; - - // we *only* forward the event to the (child) draggable keyboard wrapper - // if the keyboard event originated from the container (TD) element - if (shouldForwardKeyDownEvent(key) && targetElement.className?.includes(EVENTS_TD_CLASS_NAME)) { - const draggableKeyboardWrapper = targetElement.querySelector( - `.${DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME}` - ); - - const newEvent = new KeyboardEvent(type, { - altKey, - bubbles: true, - cancelable: true, - ctrlKey, - key, - metaKey, - shiftKey, - }); - - if (key === ' ') { - // prevent the default behavior of scrolling the table when space is pressed - keyboardEvent.preventDefault(); - } - - draggableKeyboardWrapper?.dispatchEvent(newEvent); - } -}; - -const TgridActionTdCell = ({ - action: Action, - width, - actionsColumnWidth, - ariaRowindex, - columnId, - columnValues, - data, - ecsData, - eventIdToNoteIds, - index, - isEventPinned, - isEventViewer, - eventId, - loadingEventIds, - notesCount, - onEventDetailsPanelOpened, - onRowSelected, - refetch, - rowIndex, - hasRowRenderers, - onRuleChange, - selectedEventIds, - showCheckboxes, - showNotes, - tabType, - timelineId, - toggleShowNotes, - setEventsLoading, - setEventsDeleted, -}: ActionProps & { - columnId: string; - hasRowRenderers: boolean; - actionsColumnWidth: number; - notesCount: number; - selectedEventIds: Readonly>; -}) => { - const displayWidth = width ? width : actionsColumnWidth; - return ( - - - - <> - -

{i18n.YOU_ARE_IN_A_TABLE_CELL({ row: ariaRowindex, column: index + 2 })}

-
- {Action && ( - - )} - -
- {hasRowRenderers ? ( - -

{i18n.EVENT_HAS_AN_EVENT_RENDERER(ariaRowindex)}

-
- ) : null} - - {notesCount ? ( - -

{i18n.EVENT_HAS_NOTES({ row: ariaRowindex, notesCount })}

-
- ) : null} -
-
- ); -}; - -const TgridTdCell = ({ - _id, - ariaRowindex, - index, - header, - data, - ecsData, - hasRowRenderers, - notesCount, - renderCellValue, - tabType, - timelineId, -}: CellProps) => { - const ariaColIndex = index + ARIA_COLUMN_INDEX_OFFSET; - return ( - - - <> - -

{i18n.YOU_ARE_IN_A_TABLE_CELL({ row: ariaRowindex, column: ariaColIndex })}

-
- - -
- {hasRowRenderers ? ( - -

{i18n.EVENT_HAS_AN_EVENT_RENDERER(ariaRowindex)}

-
- ) : null} - - {notesCount ? ( - -

{i18n.EVENT_HAS_NOTES({ row: ariaRowindex, notesCount })}

-
- ) : null} -
- ); -}; - -export const DataDrivenColumns = React.memo( - ({ - ariaRowindex, - actionsColumnWidth, - columnHeaders, - columnValues, - data, - ecsData, - eventIdToNoteIds, - isEventPinned, - isEventViewer, - id: _id, - loadingEventIds, - notesCount, - onEventDetailsPanelOpened, - onRowSelected, - refetch, - hasRowRenderers, - onRuleChange, - renderCellValue, - selectedEventIds, - showCheckboxes, - showNotes, - tabType, - timelineId, - toggleShowNotes, - trailingControlColumns, - leadingControlColumns, - setEventsLoading, - setEventsDeleted, - }) => { - const trailingActionCells = useMemo( - () => - trailingControlColumns ? trailingControlColumns.map((column) => column.rowCellRender) : [], - [trailingControlColumns] - ); - const leadingAndDataColumnCount = useMemo( - () => leadingControlColumns.length + columnHeaders.length, - [leadingControlColumns, columnHeaders] - ); - const TrailingActions = useMemo( - () => - trailingActionCells.map((Action: RowCellRender | undefined, index) => { - return ( - Action && ( - - ) - ); - }), - [ - trailingControlColumns, - _id, - data, - ecsData, - onRowSelected, - isEventPinned, - isEventViewer, - actionsColumnWidth, - ariaRowindex, - columnValues, - eventIdToNoteIds, - hasRowRenderers, - leadingAndDataColumnCount, - loadingEventIds, - notesCount, - onEventDetailsPanelOpened, - onRuleChange, - refetch, - selectedEventIds, - showCheckboxes, - showNotes, - tabType, - timelineId, - toggleShowNotes, - trailingActionCells, - setEventsLoading, - setEventsDeleted, - ] - ); - const ColumnHeaders = useMemo( - () => - columnHeaders.map((header, index) => ( - - )), - [ - _id, - ariaRowindex, - columnHeaders, - data, - ecsData, - hasRowRenderers, - notesCount, - renderCellValue, - tabType, - timelineId, - ] - ); - return ( - - {ColumnHeaders} - {TrailingActions} - - ); - } -); - -DataDrivenColumns.displayName = 'DataDrivenColumns'; - -export const getMappedNonEcsValue = ({ - data, - fieldName, -}: { - data?: TimelineNonEcsData[]; - fieldName: string; -}): string[] | undefined => { - /* - While data _should_ always be defined - There is the potential for race conditions where a component using this function - is still visible in the UI, while the data has since been removed. - To cover all scenarios where this happens we'll check for the presence of data here - */ - if (!data || data.length === 0) return undefined; - const item = data.find((d) => d.field === fieldName); - if (item != null && item.value != null) { - return item.value; - } - return undefined; -}; - -export const useGetMappedNonEcsValue = ({ - data, - fieldName, -}: { - data?: TimelineNonEcsData[]; - fieldName: string; -}): string[] | undefined => { - return useMemo(() => getMappedNonEcsValue({ data, fieldName }), [data, fieldName]); -}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/stateful_cell.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/stateful_cell.test.tsx deleted file mode 100644 index 32ca51ca62f78..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/stateful_cell.test.tsx +++ /dev/null @@ -1,171 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { mount } from 'enzyme'; -import { cloneDeep } from 'lodash/fp'; -import React, { useEffect } from 'react'; - -import { defaultHeaders, mockTimelineData } from '../../../../../common/mock'; -import type { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline'; -import type { - ColumnHeaderOptions, - CellValueElementProps, -} from '../../../../../../common/types/timeline'; -import { TimelineId, TimelineTabs } from '../../../../../../common/types/timeline'; - -import { StatefulCell } from './stateful_cell'; -import { useGetMappedNonEcsValue } from '.'; - -/** - * This (test) component implement's `EuiDataGrid`'s `renderCellValue` interface, - * as documented here: https://elastic.github.io/eui/#/tabular-content/data-grid - * - * Its `CellValueElementProps` props are a superset of `EuiDataGridCellValueElementProps`. - * The `setCellProps` function, defined by the `EuiDataGridCellValueElementProps` interface, - * is typically called in a `useEffect`, as illustrated by `EuiDataGrid`'s code sandbox example: - * https://codesandbox.io/s/zhxmo - */ -const RenderCellValue: React.FC = ({ columnId, data, setCellProps }) => { - const value = useGetMappedNonEcsValue({ - data, - fieldName: columnId, - }); - useEffect(() => { - // branching logic that conditionally renders a specific cell green: - if (columnId === defaultHeaders[0].id) { - if (value?.length) { - setCellProps({ - style: { - backgroundColor: 'green', - }, - }); - } - } - }, [columnId, data, setCellProps, value]); - - return
{value}
; -}; - -describe('StatefulCell', () => { - const rowIndex = 123; - const colIndex = 0; - const eventId = '_id-123'; - const linkValues = ['foo', 'bar', '@baz']; - const timelineId = TimelineId.test; - - let header: ColumnHeaderOptions; - let data: TimelineNonEcsData[]; - beforeEach(() => { - data = cloneDeep(mockTimelineData[0].data); - header = cloneDeep(defaultHeaders[0]); - }); - - test('it invokes renderCellValue with the expected arguments when tabType is specified', () => { - const renderCellValue = jest.fn(); - - mount( - - ); - - expect(renderCellValue).toBeCalledWith( - expect.objectContaining({ - columnId: header.id, - eventId, - data, - header, - isExpandable: true, - isExpanded: false, - isDetails: false, - linkValues, - rowIndex, - colIndex, - scopeId: timelineId, - }) - ); - }); - - test('it invokes renderCellValue with the expected arguments when tabType is NOT specified', () => { - const renderCellValue = jest.fn(); - - mount( - - ); - - expect(renderCellValue).toBeCalledWith( - expect.objectContaining({ - columnId: header.id, - eventId, - data, - header, - isExpandable: true, - isExpanded: false, - isDetails: false, - linkValues, - rowIndex, - colIndex, - scopeId: timelineId, - }) - ); - }); - - test('it renders the React.Node returned by renderCellValue', () => { - const renderCellValue = () =>
; - - const wrapper = mount( - - ); - - expect(wrapper.find('[data-test-subj="renderCellValue"]').exists()).toBe(true); - }); - - test("it renders a div with the styles set by `renderCellValue`'s `setCellProps` argument", () => { - const wrapper = mount( - - ); - - expect( - wrapper.find('[data-test-subj="statefulCell"]').getDOMNode().getAttribute('style') - ).toEqual('background-color: green;'); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/stateful_cell.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/stateful_cell.tsx deleted file mode 100644 index 941f6499fe854..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/stateful_cell.tsx +++ /dev/null @@ -1,71 +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 { HTMLAttributes } from 'react'; -import React, { useState } from 'react'; - -import type { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline'; -import type { - ColumnHeaderOptions, - CellValueElementProps, - TimelineTabs, -} from '../../../../../../common/types/timeline'; - -export interface CommonProps { - className?: string; - 'aria-label'?: string; - 'data-test-subj'?: string; -} - -const StatefulCellComponent = ({ - rowIndex, - colIndex, - data, - header, - eventId, - linkValues, - renderCellValue, - tabType, - timelineId, -}: { - rowIndex: number; - colIndex: number; - data: TimelineNonEcsData[]; - header: ColumnHeaderOptions; - eventId: string; - linkValues: string[] | undefined; - renderCellValue: (props: CellValueElementProps) => React.ReactNode; - tabType?: TimelineTabs; - timelineId: string; -}) => { - const [cellProps, setCellProps] = useState>({}); - return ( -
- {renderCellValue({ - columnId: header.id, - eventId, - data, - header, - isDraggable: true, - isExpandable: true, - isExpanded: false, - isDetails: false, - isTimeline: true, - linkValues, - rowIndex, - colIndex, - setCellProps, - scopeId: timelineId, - key: tabType != null ? `${timelineId}-${tabType}` : timelineId, - })} -
- ); -}; - -StatefulCellComponent.displayName = 'StatefulCellComponent'; - -export const StatefulCell = React.memo(StatefulCellComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/translations.ts deleted file mode 100644 index 18bd7a600f7cd..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/translations.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; - -export const YOU_ARE_IN_A_TABLE_CELL = ({ column, row }: { column: number; row: number }) => - i18n.translate('xpack.securitySolution.timeline.youAreInATableCellScreenReaderOnly', { - values: { column, row }, - defaultMessage: 'You are in a table cell. row: {row}, column: {column}', - }); - -export const EVENT_HAS_AN_EVENT_RENDERER = (row: number) => - i18n.translate('xpack.securitySolution.timeline.eventHasEventRendererScreenReaderOnly', { - values: { row }, - defaultMessage: - 'The event in row {row} has an event renderer. Press shift + down arrow to focus it.', - }); - -export const EVENT_HAS_NOTES = ({ notesCount, row }: { notesCount: number; row: number }) => - i18n.translate('xpack.securitySolution.timeline.eventHasNotesScreenReaderOnly', { - values: { notesCount, row }, - defaultMessage: - 'The event in row {row} has {notesCount, plural, =1 {a note} other {{notesCount} notes}}. Press shift + right arrow to focus notes.', - }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx deleted file mode 100644 index 8b49c12eaf73c..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx +++ /dev/null @@ -1,164 +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 { mount, type ComponentType as EnzymeComponentType } from 'enzyme'; -import React from 'react'; - -import { TestProviders } from '../../../../../common/mock'; - -import { EventColumnView } from './event_column_view'; -import { DefaultCellRenderer } from '../../cell_rendering/default_cell_renderer'; -import { TimelineId, TimelineTabs } from '../../../../../../common/types/timeline'; -import { TimelineTypeEnum } from '../../../../../../common/api/timeline'; -import { useShallowEqualSelector } from '../../../../../common/hooks/use_selector'; -import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; -import { getDefaultControlColumn } from '../control_columns'; -import { testLeadingControlColumn } from '../../../../../common/mock/mock_timeline_control_columns'; -import { mockTimelines } from '../../../../../common/mock/mock_timelines_plugin'; -import { mockCasesContract } from '@kbn/cases-plugin/public/mocks'; -import { getActionsColumnWidth } from '../../../../../common/components/header_actions'; - -jest.mock('../../../../../common/components/header_actions/add_note_icon_item', () => { - return { - AddEventNoteAction: jest.fn(() =>
), - }; -}); - -jest.mock('../../../../../common/hooks/use_experimental_features'); -const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock; -jest.mock('../../../../../common/hooks/use_selector', () => ({ - useShallowEqualSelector: jest.fn(), - useDeepEqualSelector: jest.fn(), -})); -jest.mock('../../../../../common/components/user_privileges', () => { - return { - useUserPrivileges: () => ({ - listPrivileges: { loading: false, error: undefined, result: undefined }, - detectionEnginePrivileges: { loading: false, error: undefined, result: undefined }, - endpointPrivileges: {}, - kibanaSecuritySolutionsPrivileges: { crud: true, read: true }, - }), - }; -}); -jest.mock('../../../../../common/components/guided_onboarding_tour/tour_step'); -jest.mock('../../../../../common/lib/kibana', () => { - const originalModule = jest.requireActual('../../../../../common/lib/kibana'); - - return { - ...originalModule, - useKibana: () => ({ - services: { - timelines: { ...mockTimelines }, - data: { - search: jest.fn(), - query: jest.fn(), - }, - application: { - capabilities: { - siem: { crud_alerts: true, read_alerts: true }, - }, - }, - cases: mockCasesContract(), - }, - }), - useNavigateTo: () => ({ - navigateTo: jest.fn(), - }), - useToasts: jest.fn().mockReturnValue({ - addError: jest.fn(), - addSuccess: jest.fn(), - addWarning: jest.fn(), - remove: jest.fn(), - }), - }; -}); - -describe('EventColumnView', () => { - useIsExperimentalFeatureEnabledMock.mockReturnValue(false); - (useShallowEqualSelector as jest.Mock).mockReturnValue(TimelineTypeEnum.default); - const ACTION_BUTTON_COUNT = 4; - const leadingControlColumns = getDefaultControlColumn(ACTION_BUTTON_COUNT); - - const props = { - ariaRowindex: 2, - id: 'event-id', - actionsColumnWidth: getActionsColumnWidth(ACTION_BUTTON_COUNT), - associateNote: jest.fn(), - columnHeaders: [], - columnRenderers: [], - data: [ - { - field: 'host.name', - }, - ], - ecsData: { - _id: 'id', - }, - eventIdToNoteIds: {}, - expanded: false, - hasRowRenderers: false, - loading: false, - loadingEventIds: [], - notesCount: 0, - onEventDetailsPanelOpened: jest.fn(), - onRowSelected: jest.fn(), - refetch: jest.fn(), - renderCellValue: DefaultCellRenderer, - selectedEventIds: {}, - showCheckboxes: false, - showNotes: false, - tabType: TimelineTabs.query, - timelineId: TimelineId.active, - toggleShowNotes: jest.fn(), - updateNote: jest.fn(), - isEventPinned: false, - leadingControlColumns, - trailingControlColumns: [], - setEventsLoading: jest.fn(), - setEventsDeleted: jest.fn(), - }; - - test('it does NOT render a notes button when isEventsViewer is true', () => { - const wrapper = mount(, { - wrappingComponent: TestProviders as EnzymeComponentType<{}>, - }); - - expect(wrapper.find('[data-test-subj="add-note-button-mock"]').exists()).toBe(false); - }); - - test('it does NOT render a notes button when showNotes is false', () => { - const wrapper = mount(, { - wrappingComponent: TestProviders as EnzymeComponentType<{}>, - }); - - expect(wrapper.find('[data-test-subj="add-note-button-mock"]').exists()).toBe(false); - }); - - test('it does NOT render a pin button when isEventViewer is true', () => { - const wrapper = mount(, { - wrappingComponent: TestProviders as EnzymeComponentType<{}>, - }); - - expect(wrapper.find('[data-test-subj="pin"]').exists()).toBe(false); - }); - - test('it renders a custom control column in addition to the default control column', () => { - const wrapper = mount( - , - { - wrappingComponent: TestProviders as EnzymeComponentType<{}>, - } - ); - - expect(wrapper.find('[data-test-subj="expand-event"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="test-body-control-column-cell"]').exists()).toBeTruthy(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx deleted file mode 100644 index e184e27d428ef..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx +++ /dev/null @@ -1,225 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useMemo } from 'react'; - -import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; -import type { - ControlColumnProps, - RowCellRender, - SetEventsDeleted, - SetEventsLoading, -} from '../../../../../../common/types'; -import type { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline'; -import type { OnRowSelected } from '../../events'; -import { EventsTrData, EventsTdGroupActions } from '../../styles'; -import { DataDrivenColumns, getMappedNonEcsValue } from '../data_driven_columns'; -import type { inputsModel } from '../../../../../common/store'; -import type { - ColumnHeaderOptions, - CellValueElementProps, - TimelineTabs, -} from '../../../../../../common/types/timeline'; - -interface Props { - id: string; - actionsColumnWidth: number; - ariaRowindex: number; - columnHeaders: ColumnHeaderOptions[]; - data: TimelineNonEcsData[]; - ecsData: Ecs; - eventIdToNoteIds: Readonly>; - isEventPinned: boolean; - isEventViewer?: boolean; - loadingEventIds: Readonly; - notesCount: number; - onEventDetailsPanelOpened: () => void; - onRowSelected: OnRowSelected; - refetch: inputsModel.Refetch; - renderCellValue: (props: CellValueElementProps) => React.ReactNode; - onRuleChange?: () => void; - hasRowRenderers: boolean; - selectedEventIds: Readonly>; - showCheckboxes: boolean; - showNotes: boolean; - tabType?: TimelineTabs; - timelineId: string; - toggleShowNotes: (eventId?: string) => void; - leadingControlColumns: ControlColumnProps[]; - trailingControlColumns: ControlColumnProps[]; - setEventsLoading: SetEventsLoading; - setEventsDeleted: SetEventsDeleted; -} - -export const EventColumnView = React.memo( - ({ - id, - actionsColumnWidth, - ariaRowindex, - columnHeaders, - data, - ecsData, - eventIdToNoteIds, - isEventPinned = false, - isEventViewer = false, - loadingEventIds, - notesCount, - onEventDetailsPanelOpened, - onRowSelected, - refetch, - hasRowRenderers, - onRuleChange, - renderCellValue, - selectedEventIds, - showCheckboxes, - showNotes, - tabType, - timelineId, - toggleShowNotes, - leadingControlColumns, - trailingControlColumns, - setEventsLoading, - setEventsDeleted, - }) => { - // Each action button shall announce itself to screen readers via an `aria-label` - // in the following format: - // "button description, for the event in row {ariaRowindex}, with columns {columnValues}", - // so we combine the column values here: - const columnValues = useMemo( - () => - columnHeaders - .map( - (header) => - getMappedNonEcsValue({ - data, - fieldName: header.id, - }) ?? [] - ) - .join(' '), - [columnHeaders, data] - ); - - const leadingActionCells = useMemo( - () => - leadingControlColumns ? leadingControlColumns.map((column) => column.rowCellRender) : [], - [leadingControlColumns] - ); - const LeadingActions = useMemo( - () => - leadingActionCells.map((Action: RowCellRender | undefined, index) => { - const width = leadingControlColumns[index].width - ? leadingControlColumns[index].width - : actionsColumnWidth; - return ( - - {Action && ( - - )} - - ); - }), - [ - actionsColumnWidth, - ariaRowindex, - columnValues, - data, - ecsData, - eventIdToNoteIds, - id, - isEventPinned, - isEventViewer, - leadingActionCells, - leadingControlColumns, - loadingEventIds, - onEventDetailsPanelOpened, - onRowSelected, - onRuleChange, - refetch, - selectedEventIds, - showCheckboxes, - tabType, - timelineId, - toggleShowNotes, - setEventsLoading, - setEventsDeleted, - showNotes, - ] - ); - return ( - - {LeadingActions} - - - ); - } -); - -EventColumnView.displayName = 'EventColumnView'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/index.tsx deleted file mode 100644 index 76c28f24b14d6..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/index.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { isEmpty } from 'lodash'; - -import type { ControlColumnProps } from '../../../../../../common/types'; -import type { inputsModel } from '../../../../../common/store'; -import type { - TimelineItem, - TimelineNonEcsData, -} from '../../../../../../common/search_strategy/timeline'; -import type { - ColumnHeaderOptions, - CellValueElementProps, - RowRenderer, - TimelineTabs, -} from '../../../../../../common/types/timeline'; -import type { OnRowSelected } from '../../events'; -import { EventsTbody } from '../../styles'; -import { StatefulEvent } from './stateful_event'; -import { eventIsPinned } from '../helpers'; - -/** This offset begins at two, because the header row counts as "row 1", and aria-rowindex starts at "1" */ -const ARIA_ROW_INDEX_OFFSET = 2; - -interface Props { - actionsColumnWidth: number; - columnHeaders: ColumnHeaderOptions[]; - containerRef: React.MutableRefObject; - data: TimelineItem[]; - eventIdToNoteIds: Readonly>; - id: string; - isEventViewer?: boolean; - lastFocusedAriaColindex: number; - loadingEventIds: Readonly; - onRowSelected: OnRowSelected; - pinnedEventIds: Readonly>; - refetch: inputsModel.Refetch; - renderCellValue: (props: CellValueElementProps) => React.ReactNode; - onRuleChange?: () => void; - rowRenderers: RowRenderer[]; - selectedEventIds: Readonly>; - showCheckboxes: boolean; - tabType?: TimelineTabs; - leadingControlColumns: ControlColumnProps[]; - trailingControlColumns: ControlColumnProps[]; - onToggleShowNotes?: (eventId?: string) => void; -} - -const EventsComponent: React.FC = ({ - actionsColumnWidth, - columnHeaders, - containerRef, - data, - eventIdToNoteIds, - id, - isEventViewer = false, - lastFocusedAriaColindex, - loadingEventIds, - onRowSelected, - pinnedEventIds, - refetch, - onRuleChange, - renderCellValue, - rowRenderers, - selectedEventIds, - showCheckboxes, - tabType, - leadingControlColumns, - trailingControlColumns, - onToggleShowNotes, -}) => ( - - {data.map((event, i) => ( - - ))} - -); - -export const Events = React.memo(EventsComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx deleted file mode 100644 index 05f7a9d8b8e2b..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx +++ /dev/null @@ -1,266 +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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import type { PropsWithChildren } from 'react'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; -import { useDispatch } from 'react-redux'; -import { isEventBuildingBlockType } from '@kbn/securitysolution-data-table'; -import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; -import { DocumentDetailsRightPanelKey } from '../../../../../flyout/document_details/shared/constants/panel_keys'; -import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector'; -import { useKibana } from '../../../../../common/lib/kibana'; -import type { - ColumnHeaderOptions, - CellValueElementProps, - RowRenderer, - TimelineTabs, -} from '../../../../../../common/types/timeline'; -import type { - TimelineItem, - TimelineNonEcsData, -} from '../../../../../../common/search_strategy/timeline'; -import type { OnRowSelected } from '../../events'; -import { STATEFUL_EVENT_CSS_CLASS_NAME } from '../../helpers'; -import { EventsTrGroup, EventsTrSupplement, EventsTrSupplementContainer } from '../../styles'; -import { getEventType, isEvenEqlSequence } from '../helpers'; -import { useEventDetailsWidthContext } from '../../../../../common/components/events_viewer/event_details_width_context'; -import { EventColumnView } from './event_column_view'; -import type { inputsModel } from '../../../../../common/store'; -import { appSelectors } from '../../../../../common/store'; -import { timelineActions } from '../../../../store'; -import type { TimelineResultNote } from '../../../open_timeline/types'; -import { getRowRenderer } from '../renderers/get_row_renderer'; -import { StatefulRowRenderer } from './stateful_row_renderer'; -import { NOTES_BUTTON_CLASS_NAME } from '../../properties/helpers'; -import { StatefulEventContext } from '../../../../../common/components/events_viewer/stateful_event_context'; -import type { - ControlColumnProps, - SetEventsDeleted, - SetEventsLoading, -} from '../../../../../../common/types'; - -interface Props { - actionsColumnWidth: number; - containerRef: React.MutableRefObject; - columnHeaders: ColumnHeaderOptions[]; - event: TimelineItem; - eventIdToNoteIds: Readonly>; - isEventViewer?: boolean; - lastFocusedAriaColindex: number; - loadingEventIds: Readonly; - onRowSelected: OnRowSelected; - isEventPinned: boolean; - refetch: inputsModel.Refetch; - ariaRowindex: number; - onRuleChange?: () => void; - renderCellValue: (props: CellValueElementProps) => React.ReactNode; - rowRenderers: RowRenderer[]; - selectedEventIds: Readonly>; - showCheckboxes: boolean; - tabType?: TimelineTabs; - timelineId: string; - leadingControlColumns: ControlColumnProps[]; - trailingControlColumns: ControlColumnProps[]; - onToggleShowNotes?: (eventId?: string) => void; -} - -const emptyNotes: string[] = []; - -const EventsTrSupplementContainerWrapper = React.memo>( - ({ children }) => { - const width = useEventDetailsWidthContext(); - return {children}; - } -); - -EventsTrSupplementContainerWrapper.displayName = 'EventsTrSupplementContainerWrapper'; - -const StatefulEventComponent: React.FC = ({ - actionsColumnWidth, - containerRef, - columnHeaders, - event, - eventIdToNoteIds, - isEventViewer = false, - isEventPinned = false, - lastFocusedAriaColindex, - loadingEventIds, - onRowSelected, - refetch, - renderCellValue, - rowRenderers, - onRuleChange, - ariaRowindex, - selectedEventIds, - showCheckboxes, - tabType, - timelineId, - leadingControlColumns, - trailingControlColumns, - onToggleShowNotes, -}) => { - const { telemetry } = useKibana().services; - const trGroupRef = useRef(null); - const dispatch = useDispatch(); - - const { openFlyout } = useExpandableFlyoutApi(); - - // Store context in state rather than creating object in provider value={} to prevent re-renders caused by a new object being created - const [activeStatefulEventContext] = useState({ - timelineID: timelineId, - enableHostDetailsFlyout: true, - enableIpDetailsFlyout: true, - tabType, - }); - - const [, setFocusedNotes] = useState<{ [eventId: string]: boolean }>({}); - - const eventId = event._id; - - const isDetailPanelExpanded: boolean = false; - - const getNotesByIds = useMemo(() => appSelectors.notesByIdsSelector(), []); - const notesById = useDeepEqualSelector(getNotesByIds); - const noteIds: string[] = eventIdToNoteIds[eventId] || emptyNotes; - - const notes: TimelineResultNote[] = useMemo( - () => - appSelectors.getNotes(notesById, noteIds).map((note) => ({ - savedObjectId: note.saveObjectId, - note: note.note, - noteId: note.id, - updated: (note.lastEdit ?? note.created).getTime(), - updatedBy: note.user, - })), - [notesById, noteIds] - ); - - const hasRowRenderers: boolean = useMemo( - () => getRowRenderer({ data: event.ecs, rowRenderers }) != null, - [event.ecs, rowRenderers] - ); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const indexName = event._index!; - - const onToggleShowNotesHandler = useCallback( - (currentEventId?: string) => { - onToggleShowNotes?.(currentEventId); - setFocusedNotes((prevShowNotes) => { - if (prevShowNotes[eventId]) { - // notes are closing, so focus the notes button on the next tick, after escaping the EuiFocusTrap - setTimeout(() => { - const notesButtonElement = trGroupRef.current?.querySelector( - `.${NOTES_BUTTON_CLASS_NAME}` - ); - notesButtonElement?.focus(); - }, 0); - } - - return { ...prevShowNotes, [eventId]: !prevShowNotes[eventId] }; - }); - }, - [onToggleShowNotes, eventId] - ); - - const handleOnEventDetailPanelOpened = useCallback(() => { - openFlyout({ - right: { - id: DocumentDetailsRightPanelKey, - params: { - id: eventId, - indexName, - scopeId: timelineId, - }, - }, - }); - telemetry.reportDetailsFlyoutOpened({ - location: timelineId, - panel: 'right', - }); - }, [eventId, indexName, openFlyout, timelineId, telemetry]); - - const setEventsLoading = useCallback( - ({ eventIds, isLoading }) => { - dispatch(timelineActions.setEventsLoading({ id: timelineId, eventIds, isLoading })); - }, - [dispatch, timelineId] - ); - - const setEventsDeleted = useCallback( - ({ eventIds, isDeleted }) => { - dispatch(timelineActions.setEventsDeleted({ id: timelineId, eventIds, isDeleted })); - }, - [dispatch, timelineId] - ); - - return ( - - - - - - - - - - - - - - - - ); -}; - -export const StatefulEvent = React.memo(StatefulEventComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx deleted file mode 100644 index 0c365ae42798d..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx +++ /dev/null @@ -1,407 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { mount, type ComponentType as EnzymeComponentType } from 'enzyme'; -import { waitFor } from '@testing-library/react'; - -import { useKibana, useCurrentUser } from '../../../../common/lib/kibana'; -import { DefaultCellRenderer } from '../cell_rendering/default_cell_renderer'; -import { mockBrowserFields } from '../../../../common/containers/source/mock'; -import { Direction } from '../../../../../common/search_strategy'; -import { - defaultHeaders, - mockGlobalState, - mockTimelineData, - createMockStore, - TestProviders, -} from '../../../../common/mock'; -import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; -import { useAppToastsMock } from '../../../../common/hooks/use_app_toasts.mock'; - -import type { Props } from '.'; -import { StatefulBody } from '.'; -import type { Sort } from './sort'; -import { getDefaultControlColumn } from './control_columns'; -import { TimelineId, TimelineTabs } from '../../../../../common/types/timeline'; -import { defaultRowRenderers } from './renderers'; -import type { State } from '../../../../common/store'; -import type { UseFieldBrowserOptionsProps } from '../../fields_browser'; -import type { - DraggableProvided, - DraggableStateSnapshot, - DroppableProvided, - DroppableStateSnapshot, -} from '@hello-pangea/dnd'; -import { DocumentDetailsRightPanelKey } from '../../../../flyout/document_details/shared/constants/panel_keys'; -import { createTelemetryServiceMock } from '../../../../common/lib/telemetry/telemetry_service.mock'; -import { createExpandableFlyoutApiMock } from '../../../../common/mock/expandable_flyout'; -import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; - -jest.mock('../../../../common/hooks/use_app_toasts'); -jest.mock('../../../../common/components/guided_onboarding_tour/tour_step'); -jest.mock( - '../../../../detections/components/alerts_table/timeline_actions/use_add_to_case_actions' -); - -jest.mock('../../../../common/hooks/use_upselling', () => ({ - useUpsellingMessage: jest.fn(), -})); - -jest.mock('../../../../common/components/user_privileges', () => { - return { - useUserPrivileges: () => ({ - listPrivileges: { loading: false, error: undefined, result: undefined }, - detectionEnginePrivileges: { loading: false, error: undefined, result: undefined }, - endpointPrivileges: {}, - kibanaSecuritySolutionsPrivileges: { crud: true, read: true }, - }), - }; -}); - -const mockUseFieldBrowserOptions = jest.fn(); -const mockUseKibana = useKibana as jest.Mock; -const mockUseCurrentUser = useCurrentUser as jest.Mock>>; -const mockCasesContract = jest.requireActual('@kbn/cases-plugin/public/mocks'); -jest.mock('../../fields_browser', () => ({ - useFieldBrowserOptions: (props: UseFieldBrowserOptionsProps) => mockUseFieldBrowserOptions(props), -})); - -const useAddToTimeline = () => ({ - beginDrag: jest.fn(), - cancelDrag: jest.fn(), - dragToLocation: jest.fn(), - endDrag: jest.fn(), - hasDraggableLock: jest.fn(), - startDragToTimeline: jest.fn(), -}); - -jest.mock('../../../../common/lib/kibana'); -const mockSort: Sort[] = [ - { - columnId: '@timestamp', - columnType: 'date', - esTypes: ['date'], - sortDirection: Direction.desc, - }, -]; - -const mockDispatch = jest.fn(); -jest.mock('react-redux', () => { - const original = jest.requireActual('react-redux'); - - return { - ...original, - useDispatch: () => mockDispatch, - }; -}); - -const mockOpenFlyout = jest.fn(); -jest.mock('@kbn/expandable-flyout'); - -const mockedTelemetry = createTelemetryServiceMock(); - -jest.mock('../../../../common/components/link_to', () => { - const originalModule = jest.requireActual('../../../../common/components/link_to'); - return { - ...originalModule, - useGetSecuritySolutionUrl: () => - jest.fn(({ deepLinkId }: { deepLinkId: string }) => `/${deepLinkId}`), - useNavigateTo: () => { - return { navigateTo: jest.fn() }; - }, - useAppUrl: () => { - return { getAppUrl: jest.fn() }; - }, - }; -}); - -jest.mock('../../../../common/components/links', () => { - const originalModule = jest.requireActual('../../../../common/components/links'); - return { - ...originalModule, - useGetSecuritySolutionUrl: () => - jest.fn(({ deepLinkId }: { deepLinkId: string }) => `/${deepLinkId}`), - useNavigateTo: () => { - return { navigateTo: jest.fn() }; - }, - useAppUrl: () => { - return { getAppUrl: jest.fn() }; - }, - }; -}); - -// Prevent Resolver from rendering -jest.mock('../../graph_overlay'); - -jest.mock('../../fields_browser/create_field_button', () => ({ - useCreateFieldButton: () => <>, -})); - -jest.mock('@elastic/eui', () => { - const original = jest.requireActual('@elastic/eui'); - return { - ...original, - EuiScreenReaderOnly: () => <>, - }; -}); -jest.mock('suricata-sid-db', () => { - return { - db: [], - }; -}); -jest.mock( - '../../../../detections/components/alerts_table/timeline_actions/use_add_to_case_actions', - () => { - return { - useAddToCaseActions: () => { - return { - addToCaseActionItems: [], - }; - }, - }; - } -); - -jest.mock('@hello-pangea/dnd', () => ({ - Droppable: ({ - children, - }: { - children: (a: DroppableProvided, b: DroppableStateSnapshot) => void; - }) => - children( - { - droppableProps: { - 'data-rfd-droppable-context-id': '123', - 'data-rfd-droppable-id': '123', - }, - innerRef: jest.fn(), - placeholder: null, - }, - { - isDraggingOver: false, - draggingOverWith: null, - draggingFromThisWith: null, - isUsingPlaceholder: false, - } - ), - Draggable: ({ - children, - }: { - children: (a: DraggableProvided, b: DraggableStateSnapshot) => void; - }) => - children( - { - draggableProps: { - 'data-rfd-draggable-context-id': '123', - 'data-rfd-draggable-id': '123', - }, - innerRef: jest.fn(), - dragHandleProps: null, - }, - { - isDragging: false, - isDropAnimating: false, - isClone: false, - dropAnimation: null, - draggingOver: null, - combineWith: null, - combineTargetFor: null, - mode: null, - } - ), - DragDropContext: ({ children }: { children: React.ReactNode }) => children, -})); - -describe('Body', () => { - const getWrapper = async ( - childrenComponent: JSX.Element, - store?: { store: ReturnType } - ) => { - const wrapper = mount(childrenComponent, { - wrappingComponent: TestProviders as EnzymeComponentType<{}>, - wrappingComponentProps: store ?? {}, - }); - await waitFor(() => wrapper.find('[data-test-subj="suricataRefs"]').exists()); - - return wrapper; - }; - const mockRefetch = jest.fn(); - let appToastsMock: jest.Mocked>; - - beforeEach(() => { - jest.mocked(useExpandableFlyoutApi).mockReturnValue({ - ...createExpandableFlyoutApiMock(), - openFlyout: mockOpenFlyout, - }); - - mockUseCurrentUser.mockReturnValue({ username: 'test-username' }); - mockUseKibana.mockReturnValue({ - services: { - application: { - navigateToApp: jest.fn(), - getUrlForApp: jest.fn(), - capabilities: { - siem: { crud_alerts: true, read_alerts: true }, - }, - }, - cases: mockCasesContract.mockCasesContract(), - data: { - search: jest.fn(), - query: jest.fn(), - dataViews: jest.fn(), - }, - uiSettings: { - get: jest.fn(), - }, - savedObjects: { - client: {}, - }, - telemetry: mockedTelemetry, - timelines: { - getLastUpdated: jest.fn(), - getLoadingPanel: jest.fn(), - getFieldBrowser: jest.fn(), - getUseAddToTimeline: () => useAddToTimeline, - }, - }, - useNavigateTo: jest.fn().mockReturnValue({ - navigateTo: jest.fn(), - }), - }); - appToastsMock = useAppToastsMock.create(); - (useAppToasts as jest.Mock).mockReturnValue(appToastsMock); - }); - - const ACTION_BUTTON_COUNT = 4; - - const props: Props = { - activePage: 0, - browserFields: mockBrowserFields, - data: [mockTimelineData[0]], - id: TimelineId.test, - refetch: mockRefetch, - renderCellValue: DefaultCellRenderer, - rowRenderers: defaultRowRenderers, - sort: mockSort, - tabType: TimelineTabs.query, - totalPages: 1, - leadingControlColumns: getDefaultControlColumn(ACTION_BUTTON_COUNT), - trailingControlColumns: [], - }; - - describe('rendering', () => { - beforeEach(() => { - mockDispatch.mockClear(); - }); - - test('it renders the column headers', async () => { - const wrapper = await getWrapper(); - expect(wrapper.find('[data-test-subj="column-headers"]').first().exists()).toEqual(true); - }); - - test('it renders the scroll container', async () => { - const wrapper = await getWrapper(); - expect(wrapper.find('[data-test-subj="timeline-body"]').first().exists()).toEqual(true); - }); - - test('it renders events', async () => { - const wrapper = await getWrapper(); - expect(wrapper.find('[data-test-subj="events"]').first().exists()).toEqual(true); - }); - test('it renders a tooltip for timestamp', async () => { - const headersJustTimestamp = defaultHeaders.filter((h) => h.id === '@timestamp'); - const state: State = { - ...mockGlobalState, - timeline: { - ...mockGlobalState.timeline, - timelineById: { - ...mockGlobalState.timeline.timelineById, - [TimelineId.test]: { - ...mockGlobalState.timeline.timelineById[TimelineId.test], - id: TimelineId.test, - columns: headersJustTimestamp, - }, - }, - }, - }; - - const store = createMockStore(state); - const wrapper = await getWrapper(, { store }); - - headersJustTimestamp.forEach(() => { - expect( - wrapper - .find('[data-test-subj="data-driven-columns"]') - .first() - .find('[data-test-subj="localized-date-tool-tip"]') - .exists() - ).toEqual(true); - }); - }); - }); - - describe('event details', () => { - beforeEach(() => { - mockDispatch.mockReset(); - }); - - test('open the expandable flyout to show event details for query tab', async () => { - const wrapper = await getWrapper(); - - wrapper.find(`[data-test-subj="expand-event"]`).first().simulate('click'); - wrapper.update(); - expect(mockDispatch).not.toHaveBeenCalled(); - expect(mockOpenFlyout).toHaveBeenCalledWith({ - right: { - id: DocumentDetailsRightPanelKey, - params: { - id: '1', - indexName: undefined, - scopeId: 'timeline-test', - }, - }, - }); - }); - - test('open the expandable flyout to show event details for pinned tab', async () => { - const wrapper = await getWrapper(); - - wrapper.find(`[data-test-subj="expand-event"]`).first().simulate('click'); - wrapper.update(); - expect(mockDispatch).not.toHaveBeenCalled(); - expect(mockOpenFlyout).toHaveBeenCalledWith({ - right: { - id: DocumentDetailsRightPanelKey, - params: { - id: '1', - indexName: undefined, - scopeId: 'timeline-test', - }, - }, - }); - }); - - test('open the expandable flyout to show event details for notes tab', async () => { - const wrapper = await getWrapper(); - - wrapper.find(`[data-test-subj="expand-event"]`).first().simulate('click'); - wrapper.update(); - expect(mockDispatch).not.toHaveBeenCalled(); - expect(mockOpenFlyout).toHaveBeenCalledWith({ - right: { - id: DocumentDetailsRightPanelKey, - params: { - id: '1', - indexName: undefined, - scopeId: 'timeline-test', - }, - }, - }); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx deleted file mode 100644 index ab60e061fcdf9..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx +++ /dev/null @@ -1,271 +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 { noop } from 'lodash/fp'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; - -import { - FIRST_ARIA_INDEX, - ARIA_COLINDEX_ATTRIBUTE, - ARIA_ROWINDEX_ATTRIBUTE, - onKeyDownFocusHandler, -} from '@kbn/timelines-plugin/public'; -import { getActionsColumnWidth } from '../../../../common/components/header_actions'; -import type { ControlColumnProps } from '../../../../../common/types'; -import type { CellValueElementProps } from '../cell_rendering'; -import { DEFAULT_COLUMN_MIN_WIDTH } from './constants'; -import type { RowRenderer, TimelineTabs } from '../../../../../common/types/timeline'; -import { RowRendererCount } from '../../../../../common/api/timeline'; -import type { BrowserFields } from '../../../../common/containers/source'; -import type { TimelineItem } from '../../../../../common/search_strategy/timeline'; -import type { inputsModel, State } from '../../../../common/store'; -import { timelineActions } from '../../../store'; -import type { OnRowSelected, OnSelectAll } from '../events'; -import { getColumnHeaders } from './column_headers/helpers'; -import { getEventIdToDataMapping } from './helpers'; -import type { Sort } from './sort'; -import { plainRowRenderer } from './renderers/plain_row_renderer'; -import { EventsTable, TimelineBody, TimelineBodyGlobalStyle } from '../styles'; -import { ColumnHeaders } from './column_headers'; -import { Events } from './events'; -import { useLicense } from '../../../../common/hooks/use_license'; -import { selectTimelineById } from '../../../store/selectors'; - -export interface Props { - activePage: number; - browserFields: BrowserFields; - data: TimelineItem[]; - id: string; - isEventViewer?: boolean; - sort: Sort[]; - refetch: inputsModel.Refetch; - renderCellValue: (props: CellValueElementProps) => React.ReactNode; - rowRenderers: RowRenderer[]; - leadingControlColumns: ControlColumnProps[]; - trailingControlColumns: ControlColumnProps[]; - tabType: TimelineTabs; - totalPages: number; - onRuleChange?: () => void; - onToggleShowNotes?: (eventId?: string) => void; -} - -/** - * The Body component is used everywhere timeline is used within the security application. It is the highest level component - * that is shared across all implementations of the timeline. - */ -export const StatefulBody = React.memo( - ({ - activePage, - browserFields, - data, - id, - isEventViewer = false, - onRuleChange, - refetch, - renderCellValue, - rowRenderers, - sort, - tabType, - totalPages, - leadingControlColumns = [], - trailingControlColumns = [], - onToggleShowNotes, - }) => { - const dispatch = useDispatch(); - const containerRef = useRef(null); - const { - columns, - eventIdToNoteIds, - excludedRowRendererIds, - isSelectAllChecked, - loadingEventIds, - pinnedEventIds, - selectedEventIds, - show, - queryFields, - selectAll, - } = useSelector((state: State) => selectTimelineById(state, id)); - - const columnHeaders = useMemo( - () => getColumnHeaders(columns, browserFields), - [browserFields, columns] - ); - - const isEnterprisePlus = useLicense().isEnterprise(); - const ACTION_BUTTON_COUNT = isEnterprisePlus ? 6 : 5; - - const onRowSelected: OnRowSelected = useCallback( - ({ eventIds, isSelected }: { eventIds: string[]; isSelected: boolean }) => { - dispatch( - timelineActions.setSelected({ - id, - eventIds: getEventIdToDataMapping(data, eventIds, queryFields), - isSelected, - isSelectAllChecked: - isSelected && Object.keys(selectedEventIds).length + 1 === data.length, - }) - ); - }, - [data, dispatch, id, queryFields, selectedEventIds] - ); - - const onSelectAll: OnSelectAll = useCallback( - ({ isSelected }: { isSelected: boolean }) => - isSelected - ? dispatch( - timelineActions.setSelected({ - id, - eventIds: getEventIdToDataMapping( - data, - data.map((event) => event._id), - queryFields - ), - isSelected, - isSelectAllChecked: isSelected, - }) - ) - : dispatch(timelineActions.clearSelected({ id })), - [data, dispatch, id, queryFields] - ); - - // Sync to selectAll so parent components can select all events - useEffect(() => { - if (selectAll && !isSelectAllChecked) { - onSelectAll({ isSelected: true }); - } - }, [isSelectAllChecked, onSelectAll, selectAll]); - - const enabledRowRenderers = useMemo(() => { - if (excludedRowRendererIds && excludedRowRendererIds.length === RowRendererCount) - return [plainRowRenderer]; - - if (!excludedRowRendererIds) return rowRenderers; - - return rowRenderers.filter((rowRenderer) => !excludedRowRendererIds.includes(rowRenderer.id)); - }, [excludedRowRendererIds, rowRenderers]); - - const actionsColumnWidth = useMemo( - () => getActionsColumnWidth(ACTION_BUTTON_COUNT), - [ACTION_BUTTON_COUNT] - ); - - const columnWidths = useMemo( - () => - columnHeaders.reduce( - (totalWidth, header) => totalWidth + (header.initialWidth ?? DEFAULT_COLUMN_MIN_WIDTH), - 0 - ), - [columnHeaders] - ); - - const leadingActionColumnsWidth = useMemo(() => { - return leadingControlColumns - ? leadingControlColumns.reduce( - (totalWidth, header) => - header.width ? totalWidth + header.width : totalWidth + actionsColumnWidth, - 0 - ) - : 0; - }, [actionsColumnWidth, leadingControlColumns]); - - const trailingActionColumnsWidth = useMemo(() => { - return trailingControlColumns - ? trailingControlColumns.reduce( - (totalWidth, header) => - header.width ? totalWidth + header.width : totalWidth + actionsColumnWidth, - 0 - ) - : 0; - }, [actionsColumnWidth, trailingControlColumns]); - - const totalWidth = useMemo(() => { - return columnWidths + leadingActionColumnsWidth + trailingActionColumnsWidth; - }, [columnWidths, leadingActionColumnsWidth, trailingActionColumnsWidth]); - - const [lastFocusedAriaColindex] = useState(FIRST_ARIA_INDEX); - - const columnCount = useMemo(() => { - return columnHeaders.length + trailingControlColumns.length + leadingControlColumns.length; - }, [columnHeaders, trailingControlColumns, leadingControlColumns]); - - const onKeyDown = useCallback( - (e: React.KeyboardEvent) => { - onKeyDownFocusHandler({ - colindexAttribute: ARIA_COLINDEX_ATTRIBUTE, - containerElement: containerRef.current, - event: e, - maxAriaColindex: columnHeaders.length + 1, - maxAriaRowindex: data.length + 1, - onColumnFocused: noop, - rowindexAttribute: ARIA_ROWINDEX_ATTRIBUTE, - }); - }, - [columnHeaders.length, containerRef, data.length] - ); - - return ( - <> - - - - - - - - - - ); - } -); - -StatefulBody.displayName = 'StatefulBody'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/mini_map/date_ranges.test.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/mini_map/date_ranges.test.ts deleted file mode 100644 index 36749de01333a..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/mini_map/date_ranges.test.ts +++ /dev/null @@ -1,147 +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 moment from 'moment'; - -import type { MomentUnit } from './date_ranges'; -import { getDateRange, getDates } from './date_ranges'; - -describe('dateRanges', () => { - describe('#getDates', () => { - test('given a unit of "year", it returns the four quarters of the year', () => { - const unit: MomentUnit = 'year'; - const end = moment.utc('Mon, 31 Dec 2018 23:59:59 -0700'); - const current = moment.utc('Mon, 01 Jan 2018 00:00:00 -0700'); - - expect(getDates({ unit, end, current })).toEqual( - [ - '2018-01-01T07:00:00.000Z', - '2018-04-01T07:00:00.000Z', - '2018-07-01T07:00:00.000Z', - '2018-10-01T07:00:00.000Z', - ].map((d) => new Date(d)) - ); - }); - - test('given a unit of "month", it returns all the weeks of the month', () => { - const unit: MomentUnit = 'month'; - const end = moment.utc('Wed, 31 Oct 2018 23:59:59 -0600'); - const current = moment.utc('Mon, 01 Oct 2018 00:00:00 -0600'); - - expect(getDates({ unit, end, current })).toEqual( - [ - '2018-10-01T06:00:00.000Z', - '2018-10-08T06:00:00.000Z', - '2018-10-15T06:00:00.000Z', - '2018-10-22T06:00:00.000Z', - '2018-10-29T06:00:00.000Z', - ].map((d) => new Date(d)) - ); - }); - - test('given a unit of "week", it returns all the days of the week', () => { - const unit: MomentUnit = 'week'; - const end = moment.utc('Sat, 27 Oct 2018 23:59:59 -0600'); - const current = moment.utc('Sun, 21 Oct 2018 00:00:00 -0600'); - - expect(getDates({ unit, end, current })).toEqual( - [ - '2018-10-21T06:00:00.000Z', - '2018-10-22T06:00:00.000Z', - '2018-10-23T06:00:00.000Z', - '2018-10-24T06:00:00.000Z', - '2018-10-25T06:00:00.000Z', - '2018-10-26T06:00:00.000Z', - '2018-10-27T06:00:00.000Z', - ].map((d) => new Date(d)) - ); - }); - - test('given a unit of "day", it returns all the hours of the day', () => { - const unit: MomentUnit = 'day'; - const end = moment.utc('Tue, 23 Oct 2018 23:59:59 -0600'); - const current = moment.utc('Tue, 23 Oct 2018 00:00:00 -0600'); - - expect(getDates({ unit, end, current })).toEqual( - [ - '2018-10-23T06:00:00.000Z', - '2018-10-23T07:00:00.000Z', - '2018-10-23T08:00:00.000Z', - '2018-10-23T09:00:00.000Z', - '2018-10-23T10:00:00.000Z', - '2018-10-23T11:00:00.000Z', - '2018-10-23T12:00:00.000Z', - '2018-10-23T13:00:00.000Z', - '2018-10-23T14:00:00.000Z', - '2018-10-23T15:00:00.000Z', - '2018-10-23T16:00:00.000Z', - '2018-10-23T17:00:00.000Z', - '2018-10-23T18:00:00.000Z', - '2018-10-23T19:00:00.000Z', - '2018-10-23T20:00:00.000Z', - '2018-10-23T21:00:00.000Z', - '2018-10-23T22:00:00.000Z', - '2018-10-23T23:00:00.000Z', - '2018-10-24T00:00:00.000Z', - '2018-10-24T01:00:00.000Z', - '2018-10-24T02:00:00.000Z', - '2018-10-24T03:00:00.000Z', - '2018-10-24T04:00:00.000Z', - '2018-10-24T05:00:00.000Z', - ].map((d) => new Date(d)) - ); - }); - }); - - describe('#getDateRange', () => { - let dateSpy: jest.SpyInstance; - - beforeEach(() => { - dateSpy = jest - .spyOn(Date, 'now') - .mockImplementation(() => new Date(Date.UTC(2018, 10, 23)).valueOf()); - }); - - afterEach(() => { - dateSpy.mockReset(); - }); - - test('given a unit of "day", it returns all the hours of the day', () => { - const unit: MomentUnit = 'day'; - - const dates = getDateRange(unit); - expect(dates).toEqual( - [ - '2018-11-23T00:00:00.000Z', - '2018-11-23T01:00:00.000Z', - '2018-11-23T02:00:00.000Z', - '2018-11-23T03:00:00.000Z', - '2018-11-23T04:00:00.000Z', - '2018-11-23T05:00:00.000Z', - '2018-11-23T06:00:00.000Z', - '2018-11-23T07:00:00.000Z', - '2018-11-23T08:00:00.000Z', - '2018-11-23T09:00:00.000Z', - '2018-11-23T10:00:00.000Z', - '2018-11-23T11:00:00.000Z', - '2018-11-23T12:00:00.000Z', - '2018-11-23T13:00:00.000Z', - '2018-11-23T14:00:00.000Z', - '2018-11-23T15:00:00.000Z', - '2018-11-23T16:00:00.000Z', - '2018-11-23T17:00:00.000Z', - '2018-11-23T18:00:00.000Z', - '2018-11-23T19:00:00.000Z', - '2018-11-23T20:00:00.000Z', - '2018-11-23T21:00:00.000Z', - '2018-11-23T22:00:00.000Z', - '2018-11-23T23:00:00.000Z', - ].map((d) => new Date(d)) - ); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/mini_map/date_ranges.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/mini_map/date_ranges.ts deleted file mode 100644 index c715b2004c69c..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/mini_map/date_ranges.ts +++ /dev/null @@ -1,76 +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 moment from 'moment'; - -export type MomentUnit = 'year' | 'month' | 'week' | 'day'; - -export type MomentIncrement = 'quarters' | 'months' | 'weeks' | 'days' | 'hours'; - -export type MomentUnitToIncrement = { [key in MomentUnit]: MomentIncrement }; - -const unitsToIncrements: MomentUnitToIncrement = { - day: 'hours', - month: 'weeks', - week: 'days', - year: 'quarters', -}; - -interface GetDatesParams { - unit: MomentUnit; - end: moment.Moment; - current: moment.Moment; -} - -/** - * A pure function that given a unit (e.g. `'year' | 'month' | 'week'...`) and - * a date range, returns a range of `Date`s with a granularity appropriate - * to the unit. - * - * @example - * test('given a unit of "year", it returns the four quarters of the year', () => { - * const unit: MomentUnit = 'year'; - * const end = moment.utc('Mon, 31 Dec 2018 23:59:59 -0700'); - * const current = moment.utc('Mon, 01 Jan 2018 00:00:00 -0700'); - * - * expect(getDates({ unit, end, current })).toEqual( - * [ - * '2018-01-01T07:00:00.000Z', - * '2018-04-01T06:00:00.000Z', - * '2018-07-01T06:00:00.000Z', - * '2018-10-01T06:00:00.000Z' - * ].map(d => new Date(d)) - * ); - * }); - */ -export const getDates = ({ unit, end, current }: GetDatesParams): Date[] => - current <= end - ? [ - current.toDate(), - ...getDates({ - current: current.clone().add(1, unitsToIncrements[unit]), - end, - unit, - }), - ] - : []; - -/** - * An impure function (it performs IO to get the current `Date`) that, - * given a unit (e.g. `'year' | 'month' | 'week'...`), it - * returns range of `Date`s with a granularity appropriate to the unit. - */ -export function getDateRange(unit: MomentUnit): Date[] { - const current = moment().utc().startOf(unit); - const end = moment().utc().endOf(unit); - - return getDates({ - current, - end, // TODO: this should be relative to `unit` - unit, - }); -} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field_udt.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field_udt.test.tsx index d731b6e831f9d..c1fe2f3278dd5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field_udt.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field_udt.test.tsx @@ -6,7 +6,7 @@ */ import { mockTimelineData } from '../../../../../common/mock'; -import { defaultUdtHeaders } from '../../unified_components/default_headers'; +import { defaultUdtHeaders } from '../column_headers/default_headers'; import { getFormattedFields } from './formatted_field_udt'; import type { DataTableRecord } from '@kbn/discover-utils/types'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_column_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_column_renderer.test.tsx index 551ba3c4ac570..ad75ef79aa049 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_column_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_column_renderer.test.tsx @@ -13,13 +13,13 @@ import type { TimelineNonEcsData } from '../../../../../../common/search_strateg import { mockTimelineData } from '../../../../../common/mock'; import { TestProviders } from '../../../../../common/mock/test_providers'; import { getEmptyValue } from '../../../../../common/components/empty_value'; -import { defaultHeaders } from '../column_headers/default_headers'; import { columnRenderers } from '.'; import { getColumnRenderer } from './get_column_renderer'; import { getValues, findItem, deleteItemIdx } from './helpers'; import { useMountAppended } from '../../../../../common/utils/use_mount_appended'; import { TimelineId } from '../../../../../../common/types/timeline'; +import { defaultUdtHeaders } from '../column_headers/default_headers'; jest.mock('../../../../../common/lib/kibana'); @@ -47,7 +47,7 @@ describe('get_column_renderer', () => { columnName, eventId: _id, values: getValues(columnName, nonSuricata), - field: defaultHeaders[1], + field: defaultUdtHeaders[1], scopeId: TimelineId.test, }); @@ -62,7 +62,7 @@ describe('get_column_renderer', () => { columnName, eventId: _id, values: getValues(columnName, nonSuricata), - field: defaultHeaders[1], + field: defaultUdtHeaders[1], scopeId: TimelineId.test, }); const wrapper = mount( @@ -82,7 +82,7 @@ describe('get_column_renderer', () => { columnName, eventId: _id, values: getValues(columnName, nonSuricata), - field: defaultHeaders[7], + field: defaultUdtHeaders[7], scopeId: TimelineId.test, }); const wrapper = mount( @@ -100,7 +100,7 @@ describe('get_column_renderer', () => { columnName, eventId: _id, values: getValues(columnName, nonSuricata), - field: defaultHeaders[7], + field: defaultUdtHeaders[7], scopeId: TimelineId.test, }); const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/reason_column_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/reason_column_renderer.test.tsx index a5cd33efdd5c4..3912d7d9ef8ca 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/reason_column_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/reason_column_renderer.test.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { mockTimelineData, TestProviders } from '../../../../../common/mock'; -import { defaultColumnHeaderType } from '../column_headers/default_headers'; import { REASON_FIELD_NAME } from './constants'; import { reasonColumnRenderer } from './reason_column_renderer'; import { plainColumnRenderer } from './plain_column_renderer'; @@ -19,6 +18,7 @@ import { RowRendererIdEnum } from '../../../../../../common/api/timeline'; import { render } from '@testing-library/react'; import { cloneDeep } from 'lodash'; import { TableId } from '@kbn/securitysolution-data-table'; +import { defaultColumnHeaderType } from '../column_headers/default_headers'; jest.mock('./plain_column_renderer'); jest.mock('../../../../../common/components/link_to', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/__snapshots__/sort_indicator.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/__snapshots__/sort_indicator.test.tsx.snap deleted file mode 100644 index 8a7b179da059f..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/__snapshots__/sort_indicator.test.tsx.snap +++ /dev/null @@ -1,19 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`SortIndicator rendering renders correctly against snapshot 1`] = ` - - - - -`; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/index.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/index.ts deleted file mode 100644 index 96503dcac3812..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/index.ts +++ /dev/null @@ -1,11 +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 { SortColumnTimeline } from '../../../../../../common/types/timeline'; - -/** Specifies which column the timeline is sorted on */ -export type Sort = SortColumnTimeline; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.test.tsx deleted file mode 100644 index 56f98a6795cd1..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.test.tsx +++ /dev/null @@ -1,85 +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 { mount, shallow } from 'enzyme'; -import React from 'react'; -import { Direction } from '../../../../../../common/search_strategy'; - -import * as i18n from '../translations'; - -import { getDirection, SortIndicator } from './sort_indicator'; - -describe('SortIndicator', () => { - describe('rendering', () => { - test('renders correctly against snapshot', () => { - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); - }); - - test('it renders the expected sort indicator when direction is ascending', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="sortIndicator"]').first().prop('type')).toEqual( - 'sortUp' - ); - }); - - test('it renders the expected sort indicator when direction is descending', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="sortIndicator"]').first().prop('type')).toEqual( - 'sortDown' - ); - }); - - test('it renders the expected sort indicator when direction is `none`', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="sortIndicator"]').first().prop('type')).toEqual( - 'empty' - ); - }); - }); - - describe('getDirection', () => { - test('it returns the expected symbol when the direction is ascending', () => { - expect(getDirection(Direction.asc)).toEqual('sortUp'); - }); - - test('it returns the expected symbol when the direction is descending', () => { - expect(getDirection(Direction.desc)).toEqual('sortDown'); - }); - - test('it returns the expected symbol (undefined) when the direction is neither ascending, nor descending', () => { - expect(getDirection('none')).toEqual(undefined); - }); - }); - - describe('sort indicator tooltip', () => { - test('it returns the expected tooltip when the direction is ascending', () => { - const wrapper = mount(); - - expect( - wrapper.find('[data-test-subj="sort-indicator-tooltip"]').first().props().content - ).toEqual(i18n.SORTED_ASCENDING); - }); - - test('it returns the expected tooltip when the direction is descending', () => { - const wrapper = mount(); - - expect( - wrapper.find('[data-test-subj="sort-indicator-tooltip"]').first().props().content - ).toEqual(i18n.SORTED_DESCENDING); - }); - - test('it does NOT render a tooltip when sort direction is `none`', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="sort-indicator-tooltip"]').exists()).toBe(false); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.tsx deleted file mode 100644 index 82c25f00c78ab..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiIcon, EuiToolTip } from '@elastic/eui'; -import React from 'react'; - -import * as i18n from '../translations'; -import { SortNumber } from './sort_number'; - -import { Direction } from '../../../../../../common/search_strategy'; -import type { SortDirection } from '../../../../../../common/types/timeline'; - -enum SortDirectionIndicatorEnum { - SORT_UP = 'sortUp', - SORT_DOWN = 'sortDown', -} - -export type SortDirectionIndicator = undefined | SortDirectionIndicatorEnum; - -/** Returns the symbol that corresponds to the specified `SortDirection` */ -export const getDirection = (sortDirection: SortDirection): SortDirectionIndicator => { - switch (sortDirection) { - case Direction.asc: - return SortDirectionIndicatorEnum.SORT_UP; - case Direction.desc: - return SortDirectionIndicatorEnum.SORT_DOWN; - case 'none': - return undefined; - default: - throw new Error('Unhandled sort direction'); - } -}; - -interface Props { - sortDirection: SortDirection; - sortNumber: number; -} - -/** Renders a sort indicator */ -export const SortIndicator = React.memo(({ sortDirection, sortNumber }) => { - const direction = getDirection(sortDirection); - - if (direction != null) { - return ( - - <> - - - - - ); - } else { - return ; - } -}); - -SortIndicator.displayName = 'SortIndicator'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_number.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_number.tsx deleted file mode 100644 index 3fdd31eae5c47..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_number.tsx +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiIcon, EuiNotificationBadge } from '@elastic/eui'; -import React from 'react'; - -interface Props { - sortNumber: number; -} - -export const SortNumber = React.memo(({ sortNumber }) => { - if (sortNumber >= 0) { - return ( - - {sortNumber + 1} - - ); - } else { - return ; - } -}); - -SortNumber.displayName = 'SortNumber'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.test.tsx index 031604c6a3da6..41cdec6d6d4bb 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.test.tsx @@ -9,7 +9,7 @@ import { TimelineTabs } from '../../../../../common/types'; import { DataLoadingState } from '@kbn/unified-data-table'; import React from 'react'; import { UnifiedTimeline } from '../unified_components'; -import { defaultUdtHeaders } from '../unified_components/default_headers'; +import { defaultUdtHeaders } from './column_headers/default_headers'; import type { UnifiedTimelineBodyProps } from './unified_timeline_body'; import { UnifiedTimelineBody } from './unified_timeline_body'; import { render, screen } from '@testing-library/react'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.tsx index fe6b668ed6837..95feab8543617 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.tsx @@ -10,7 +10,7 @@ import React, { useEffect, useState, useMemo } from 'react'; import { RootDragDropProvider } from '@kbn/dom-drag-drop'; import { StyledTableFlexGroup, StyledUnifiedTableFlexItem } from '../unified_components/styles'; import { UnifiedTimeline } from '../unified_components'; -import { defaultUdtHeaders } from '../unified_components/default_headers'; +import { defaultUdtHeaders } from './column_headers/default_headers'; import type { PaginationInputPaginated, TimelineItem } from '../../../../../common/search_strategy'; export interface UnifiedTimelineBodyProps extends ComponentProps { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.tsx index 9e5006267d32b..ec230139dc95e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.tsx @@ -8,7 +8,7 @@ import React, { useMemo } from 'react'; import styled from 'styled-components'; -import { useGetMappedNonEcsValue } from '../body/data_driven_columns'; +import { useGetMappedNonEcsValue } from '../../../../common/utils/get_mapped_non_ecs_value'; import { columnRenderers } from '../body/renderers'; import { getColumnRenderer } from '../body/renderers/get_column_renderer'; import type { CellValueElementProps } from '.'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.test.tsx index f7dad276cb939..9f187f91dcdff 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.test.tsx @@ -17,6 +17,7 @@ import { buildIsOneOfQueryMatch, buildIsQueryMatch, handleIsOperator, + isFullScreen, isPrimitiveArray, showGlobalFilters, } from './helpers'; @@ -392,3 +393,42 @@ describe('isStringOrNumberArray', () => { }); }); }); + +describe('isFullScreen', () => { + describe('globalFullScreen is false', () => { + it('should return false if isActiveTimelines is false', () => { + const result = isFullScreen({ + globalFullScreen: false, + isActiveTimelines: false, + timelineFullScreen: true, + }); + expect(result).toBe(false); + }); + it('should return false if timelineFullScreen is false', () => { + const result = isFullScreen({ + globalFullScreen: false, + isActiveTimelines: true, + timelineFullScreen: false, + }); + expect(result).toBe(false); + }); + }); + describe('globalFullScreen is true', () => { + it('should return true if isActiveTimelines is true and timelineFullScreen is true', () => { + const result = isFullScreen({ + globalFullScreen: true, + isActiveTimelines: true, + timelineFullScreen: true, + }); + expect(result).toBe(true); + }); + it('should return true if isActiveTimelines is false', () => { + const result = isFullScreen({ + globalFullScreen: true, + isActiveTimelines: false, + timelineFullScreen: false, + }); + expect(result).toBe(true); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx index 04f08f203ec7f..43c4648abb83a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx @@ -282,3 +282,14 @@ export const TIMELINE_FILTER_DROP_AREA = 'timeline-filter-drop-area'; export const getNonDropAreaFilters = (filters: Filter[] = []) => filters.filter((f: Filter) => f.meta.controlledBy !== TIMELINE_FILTER_DROP_AREA); + +export const isFullScreen = ({ + globalFullScreen, + isActiveTimelines, + timelineFullScreen, +}: { + globalFullScreen: boolean; + isActiveTimelines: boolean; + timelineFullScreen: boolean; +}) => + (isActiveTimelines && timelineFullScreen) || (isActiveTimelines === false && globalFullScreen); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx index ea0edabdfe7bb..05d15f076f569 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx @@ -29,7 +29,7 @@ import { useTimelineFullScreen } from '../../../common/containers/use_full_scree import { EXIT_FULL_SCREEN_CLASS_NAME } from '../../../common/components/exit_full_screen'; import { useResolveConflict } from '../../../common/hooks/use_resolve_conflict'; import { sourcererSelectors } from '../../../common/store'; -import { defaultUdtHeaders } from './unified_components/default_headers'; +import { defaultUdtHeaders } from './body/column_headers/default_headers'; const TimelineTemplateBadge = styled.div` background: ${({ theme }) => theme.eui.euiColorVis3_behindText}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx index 97762de6bcb91..1591c7f8c791b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx @@ -10,11 +10,6 @@ import { rgba } from 'polished'; import styled, { createGlobalStyle } from 'styled-components'; import { IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME } from '@kbn/securitysolution-t-grid'; -import type { TimelineEventsType } from '../../../../common/types/timeline'; - -import { ACTIONS_COLUMN_ARIA_COL_INDEX } from './helpers'; -import { EVENTS_TABLE_ARIA_LABEL } from './translations'; - /** * TIMELINE BODY */ @@ -73,79 +68,6 @@ TimelineBody.displayName = 'TimelineBody'; export const EVENTS_TABLE_CLASS_NAME = 'siemEventsTable'; -interface EventsTableProps { - $activePage: number; - $columnCount: number; - columnWidths: number; - $rowCount: number; - $totalPages: number; -} - -export const EventsTable = styled.div.attrs( - ({ className = '', $columnCount, columnWidths, $activePage, $rowCount, $totalPages }) => ({ - 'aria-label': EVENTS_TABLE_ARIA_LABEL({ activePage: $activePage + 1, totalPages: $totalPages }), - 'aria-colcount': `${$columnCount}`, - 'aria-rowcount': `${$rowCount + 1}`, - className: `siemEventsTable ${className}`, - role: 'grid', - style: { - minWidth: `${columnWidths}px`, - }, - tabindex: '-1', - }) -)` - padding: 3px; -`; - -/* EVENTS HEAD */ - -export const EventsThead = styled.div.attrs(({ className = '' }) => ({ - className: `siemEventsTable__thead ${className}`, - role: 'rowgroup', -}))` - background-color: ${({ theme }) => theme.eui.euiColorEmptyShade}; - border-bottom: ${({ theme }) => theme.eui.euiBorderWidthThick} solid - ${({ theme }) => theme.eui.euiColorLightShade}; - position: sticky; - top: 0; - z-index: ${({ theme }) => theme.eui.euiZLevel1}; -`; - -export const EventsTrHeader = styled.div.attrs(({ className }) => ({ - 'aria-rowindex': '1', - className: `siemEventsTable__trHeader ${className}`, - role: 'row', -}))` - display: flex; -`; - -export const EventsThGroupActions = styled.div.attrs(({ className = '' }) => ({ - 'aria-colindex': `${ACTIONS_COLUMN_ARIA_COL_INDEX}`, - className: `siemEventsTable__thGroupActions ${className}`, - role: 'columnheader', - tabIndex: '0', -}))<{ actionsColumnWidth: number; isEventViewer: boolean }>` - display: flex; - flex: 0 0 - ${({ actionsColumnWidth, isEventViewer }) => - `${!isEventViewer ? actionsColumnWidth + 4 : actionsColumnWidth}px`}; - min-width: 0; - padding-left: ${({ isEventViewer }) => - !isEventViewer ? '4px;' : '0;'}; // match timeline event border -`; - -export const EventsThGroupData = styled.div.attrs(({ className = '' }) => ({ - className: `siemEventsTable__thGroupData ${className}`, -}))<{ isDragging?: boolean }>` - display: flex; - - > div:hover .siemEventsHeading__handle { - display: ${({ isDragging }) => (isDragging ? 'none' : 'block')}; - opacity: 1; - visibility: visible; - } -`; - export const EventsTh = styled.div.attrs<{ role: string }>( ({ className = '', role = 'columnheader' }) => ({ className: `siemEventsTable__th ${className}`, @@ -197,76 +119,6 @@ export const EventsThContent = styled.div.attrs(({ className = '' }) => ({ } `; -/* EVENTS BODY */ - -export const EventsTbody = styled.div.attrs(({ className = '' }) => ({ - className: `siemEventsTable__tbody ${className}`, - role: 'rowgroup', -}))` - overflow-x: hidden; -`; - -export const EventsTrGroup = styled.div.attrs( - ({ className = '', $ariaRowindex }: { className?: string; $ariaRowindex: number }) => ({ - 'aria-rowindex': `${$ariaRowindex}`, - className: `siemEventsTable__trGroup ${className}`, - role: 'row', - }) -)<{ - className?: string; - eventType: Omit; - isEvenEqlSequence: boolean; - isBuildingBlockType: boolean; - isExpanded: boolean; - showLeftBorder: boolean; -}>` - border-bottom: ${({ theme }) => theme.eui.euiBorderWidthThin} solid - ${({ theme }) => theme.eui.euiColorLightShade}; - ${({ theme, eventType, isEvenEqlSequence, showLeftBorder }) => - showLeftBorder - ? `border-left: 4px solid - ${ - eventType === 'raw' - ? theme.eui.euiColorLightShade - : eventType === 'eql' && isEvenEqlSequence - ? theme.eui.euiColorPrimary - : eventType === 'eql' && !isEvenEqlSequence - ? theme.eui.euiColorAccent - : theme.eui.euiColorWarning - }` - : ''}; - ${({ isBuildingBlockType }) => - isBuildingBlockType - ? 'background: repeating-linear-gradient(127deg, rgba(245, 167, 0, 0.2), rgba(245, 167, 0, 0.2) 1px, rgba(245, 167, 0, 0.05) 2px, rgba(245, 167, 0, 0.05) 10px);' - : ''}; - ${({ eventType, isEvenEqlSequence }) => - eventType === 'eql' - ? isEvenEqlSequence - ? 'background: repeating-linear-gradient(127deg, rgba(0, 107, 180, 0.2), rgba(0, 107, 180, 0.2) 1px, rgba(0, 107, 180, 0.05) 2px, rgba(0, 107, 180, 0.05) 10px);' - : 'background: repeating-linear-gradient(127deg, rgba(221, 10, 115, 0.2), rgba(221, 10, 115, 0.2) 1px, rgba(221, 10, 115, 0.05) 2px, rgba(221, 10, 115, 0.05) 10px);' - : ''}; - - &:hover { - background-color: ${({ theme }) => theme.eui.euiTableHoverColor}; - } - - ${({ isExpanded, theme }) => - isExpanded && - ` - background: ${theme.eui.euiTableSelectedColor}; - - &:hover { - ${theme.eui.euiTableHoverSelectedColor} - } - `} -`; - -export const EventsTrData = styled.div.attrs(({ className = '' }) => ({ - className: `siemEventsTable__trData ${className}`, -}))` - display: flex; -`; - const TIMELINE_EVENT_DETAILS_OFFSET = 40; interface WidthProp { @@ -295,57 +147,6 @@ export const EventsTrSupplement = styled.div.attrs(({ className = '' }) => ({ } `; -export const EventsTdGroupActions = styled.div.attrs(({ className = '' }) => ({ - 'aria-colindex': `${ACTIONS_COLUMN_ARIA_COL_INDEX}`, - className: `siemEventsTable__tdGroupActions ${className}`, - role: 'gridcell', -}))<{ width: number }>` - align-items: center; - display: flex; - flex: 0 0 ${({ width }) => `${width}px`}; - min-width: 0; -`; - -export const EventsTdGroupData = styled.div.attrs(({ className = '' }) => ({ - className: `siemEventsTable__tdGroupData ${className}`, -}))` - display: flex; -`; -interface EventsTdProps { - $ariaColumnIndex?: number; - width?: number; -} - -export const EVENTS_TD_CLASS_NAME = 'siemEventsTable__td'; - -export const EventsTd = styled.div.attrs( - ({ className = '', $ariaColumnIndex, width }) => { - const common = { - className: `siemEventsTable__td ${className}`, - role: 'gridcell', - style: { - flexBasis: width ? `${width}px` : 'auto', - }, - }; - - return $ariaColumnIndex != null - ? { - ...common, - 'aria-colindex': `${$ariaColumnIndex}`, - } - : common; - } -)` - align-items: center; - display: flex; - flex-shrink: 0; - min-width: 0; - - .siemEventsTable__tdGroupActions &:first-child:last-child { - flex: 1; - } -`; - export const EventsTdContent = styled.div.attrs(({ className }) => ({ className: `siemEventsTable__tdContent ${className != null ? className : ''}`, }))<{ textAlign?: string; width?: number }>` @@ -363,89 +164,9 @@ export const EventsTdContent = styled.div.attrs(({ className }) => ({ } `; -/** - * EVENTS HEADING - */ - -export const EventsHeading = styled.div.attrs(({ className = '' }) => ({ - className: `siemEventsHeading ${className}`, -}))<{ isLoading: boolean }>` - align-items: center; - display: flex; - - &:hover { - cursor: ${({ isLoading }) => (isLoading ? 'wait' : 'grab')}; - } -`; - -export const EventsHeadingTitleButton = styled.button.attrs(({ className = '' }) => ({ - className: `siemEventsHeading__title siemEventsHeading__title--aggregatable ${className}`, - type: 'button', -}))` - align-items: center; - display: flex; - font-weight: inherit; - min-width: 0; - - &:hover, - &:focus { - color: ${({ theme }) => theme.eui.euiColorPrimary}; - text-decoration: underline; - } - - &:hover { - cursor: pointer; - } - - & > * + * { - margin-left: ${({ theme }) => theme.eui.euiSizeXS}; - } -`; - -export const EventsHeadingTitleSpan = styled.span.attrs(({ className }) => ({ - className: `siemEventsHeading__title siemEventsHeading__title--notAggregatable ${className}`, -}))` - min-width: 0; -`; - -export const EventsHeadingExtra = styled.div.attrs(({ className = '' }) => ({ - className: `siemEventsHeading__extra ${className}` as string, -}))` - margin-left: auto; - margin-right: 2px; - - &.siemEventsHeading__extra--close { - opacity: 0; - transition: all ${({ theme }) => theme.eui.euiAnimSpeedNormal} ease; - visibility: hidden; - - .siemEventsTable__th:hover & { - opacity: 1; - visibility: visible; - } - } -`; - -export const EventsHeadingHandle = styled.div.attrs(({ className = '' }) => ({ - className: `siemEventsHeading__handle ${className}`, -}))` - background-color: ${({ theme }) => theme.eui.euiBorderColor}; - height: 100%; - opacity: 0; - transition: all ${({ theme }) => theme.eui.euiAnimSpeedNormal} ease; - visibility: hidden; - width: ${({ theme }) => theme.eui.euiBorderWidthThick}; - - &:hover { - background-color: ${({ theme }) => theme.eui.euiColorPrimary}; - cursor: col-resize; - } -`; - /** * EVENTS LOADING */ - export const EventsLoading = styled(EuiLoadingSpinner)` margin: 0 2px; vertical-align: middle; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/eql/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/eql/index.test.tsx index 7c8949c1b6121..23fb44d04910f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/eql/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/eql/index.test.tsx @@ -35,9 +35,6 @@ jest.mock('../../../../containers/details', () => ({ jest.mock('../../../fields_browser', () => ({ useFieldBrowserOptions: jest.fn(), })); -jest.mock('../../body/events', () => ({ - Events: () => <>, -})); jest.mock('../../../../../sourcerer/containers'); jest.mock('../../../../../sourcerer/containers/use_signal_helpers', () => ({ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/pinned/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/pinned/index.test.tsx index 0df50a8cf47c3..55604cd958c23 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/pinned/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/pinned/index.test.tsx @@ -14,7 +14,7 @@ import { DefaultCellRenderer } from '../../cell_rendering/default_cell_renderer' import { defaultHeaders, mockTimelineData } from '../../../../../common/mock'; import { TestProviders } from '../../../../../common/mock/test_providers'; import { defaultRowRenderers } from '../../body/renderers'; -import type { Sort } from '../../body/sort'; +import type { SortColumnTimeline as Sort } from '../../../../../../common/types/timeline'; import { TimelineId } from '../../../../../../common/types/timeline'; import { useTimelineEvents } from '../../../../containers'; import { useTimelineEventsDetails } from '../../../../containers/details'; @@ -38,9 +38,6 @@ jest.mock('../../../../containers/details', () => ({ jest.mock('../../../fields_browser', () => ({ useFieldBrowserOptions: jest.fn(), })); -jest.mock('../../body/events', () => ({ - Events: () => <>, -})); jest.mock('../../../../../sourcerer/containers'); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/pinned/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/pinned/index.tsx index 8f19e90c77a70..e4593f7eec959 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/pinned/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/pinned/index.tsx @@ -21,7 +21,6 @@ import { useKibana } from '../../../../../common/lib/kibana'; import { timelineSelectors } from '../../../../store'; import type { Direction } from '../../../../../../common/search_strategy'; import { useTimelineEvents } from '../../../../containers'; -import { defaultHeaders } from '../../body/column_headers/default_headers'; import { requiredFieldsForActions } from '../../../../../detections/components/alerts_table/default_config'; import { SourcererScopeName } from '../../../../../sourcerer/store/model'; import { timelineDefaults } from '../../../../store/defaults'; @@ -37,6 +36,7 @@ import { useTimelineControlColumn } from '../shared/use_timeline_control_columns import { LeftPanelNotesTab } from '../../../../../flyout/document_details/left'; import { useNotesInFlyout } from '../../properties/use_notes_in_flyout'; import { NotesFlyout } from '../../properties/notes_flyout'; +import { defaultUdtHeaders } from '../../body/column_headers/default_headers'; interface PinnedFilter { bool: { @@ -111,7 +111,7 @@ export const PinnedTabContentComponent: React.FC = ({ }, [pinnedEventIds]); const timelineQueryFields = useMemo(() => { - const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; + const columnsHeader = isEmpty(columns) ? defaultUdtHeaders : columns; const columnFields = columnsHeader.map((c) => c.id); return [...columnFields, ...requiredFieldsForActions]; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/index.test.tsx index 70afec0d73135..f0a2c06bbffb4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/index.test.tsx @@ -30,8 +30,10 @@ import { useDispatch } from 'react-redux'; import type { ExperimentalFeatures } from '../../../../../../common'; import { allowedExperimentalValues } from '../../../../../../common'; import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; -import { defaultUdtHeaders } from '../../unified_components/default_headers'; -import { defaultColumnHeaderType } from '../../body/column_headers/default_headers'; +import { + defaultUdtHeaders, + defaultColumnHeaderType, +} from '../../body/column_headers/default_headers'; import { useUserPrivileges } from '../../../../../common/components/user_privileges'; import { getEndpointPrivilegesInitialStateMock } from '../../../../../common/components/user_privileges/endpoint/mocks'; import * as timelineActions from '../../../../store/actions'; @@ -52,10 +54,6 @@ jest.mock('../../../fields_browser', () => ({ useFieldBrowserOptions: jest.fn(), })); -jest.mock('../../body/events', () => ({ - Events: () => <>, -})); - jest.mock('../../../../../sourcerer/containers'); jest.mock('../../../../../sourcerer/containers/use_signal_helpers', () => ({ useSignalHelpers: () => ({ signalIndexNeedsInit: false }), diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/session/use_session_view.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/session/use_session_view.tsx index eae2eec549dfe..c711208cb6806 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/session/use_session_view.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/session/use_session_view.tsx @@ -23,7 +23,6 @@ import { useKibana } from '../../../../../common/lib/kibana'; import * as i18n from './translations'; import { TimelineTabs } from '../../../../../../common/types/timeline'; import { SourcererScopeName } from '../../../../../sourcerer/store/model'; -import { isFullScreen } from '../../body/column_headers'; import { SCROLLING_DISABLED_CLASS_NAME } from '../../../../../../common/constants'; import { FULL_SCREEN } from '../../body/column_headers/translations'; import { EXIT_FULL_SCREEN } from '../../../../../common/components/exit_full_screen/translations'; @@ -35,6 +34,7 @@ import { useUserPrivileges } from '../../../../../common/components/user_privile import { timelineActions, timelineSelectors } from '../../../../store'; import { timelineDefaults } from '../../../../store/defaults'; import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector'; +import { isFullScreen } from '../../helpers'; const FullScreenButtonIcon = styled(EuiButtonIcon)` margin: 4px 0 4px 0; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/layout.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/layout.tsx index aacb38ea2e798..1cac4dc2536f5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/layout.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/layout.tsx @@ -5,14 +5,7 @@ * 2.0. */ -import { - EuiFlexGroup, - EuiFlexItem, - EuiFlyoutHeader, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiBadge, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlyoutHeader, EuiBadge } from '@elastic/eui'; import styled from 'styled-components'; export const TabHeaderContainer = styled.div` @@ -33,46 +26,12 @@ export const StyledEuiFlyoutHeader = styled(EuiFlyoutHeader)` } `; -export const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` - overflow-y: hidden; - flex: 1; - - .euiFlyoutBody__overflow { - overflow: hidden; - mask-image: none; - } - - .euiFlyoutBody__overflowContent { - padding: 0; - height: 100%; - display: flex; - } -`; - -export const StyledEuiFlyoutFooter = styled(EuiFlyoutFooter)` - background: none; - &.euiFlyoutFooter { - ${({ theme }) => `padding: ${theme.eui.euiSizeS} 0;`} - } -`; - export const FullWidthFlexGroup = styled(EuiFlexGroup)` margin: 0; width: 100%; overflow: hidden; `; -export const ScrollableFlexItem = styled(EuiFlexItem)` - ${({ theme }) => `margin: 0 ${theme.eui.euiSizeM};`} - overflow: hidden; -`; - -export const SourcererFlex = styled(EuiFlexItem)` - align-items: flex-end; -`; - -SourcererFlex.displayName = 'SourcererFlex'; - export const VerticalRule = styled.div` width: 2px; height: 100%; 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 8bbda9a255a09..926082ff9ed41 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 @@ -8,7 +8,7 @@ import { TestProviders } from '../../../../../common/mock'; import { renderHook } from '@testing-library/react-hooks'; import { useTimelineColumns } from './use_timeline_columns'; -import { defaultUdtHeaders } from '../../unified_components/default_headers'; +import { defaultUdtHeaders } from '../../body/column_headers/default_headers'; import type { ColumnHeaderOptions } from '../../../../../../common/types/timeline/columns'; jest.mock('../../../../../common/hooks/use_experimental_features', () => ({ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_columns.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_columns.tsx index f42bf47c76423..006c6ba1eb679 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_columns.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_columns.tsx @@ -9,7 +9,7 @@ import { useMemo } from 'react'; import { SourcererScopeName } from '../../../../../sourcerer/store/model'; import { useSourcererDataView } from '../../../../../sourcerer/containers'; import { requiredFieldsForActions } from '../../../../../detections/components/alerts_table/default_config'; -import { defaultUdtHeaders } from '../../unified_components/default_headers'; +import { defaultUdtHeaders } from '../../body/column_headers/default_headers'; import type { ColumnHeaderOptions } from '../../../../../../common/types'; import { memoizedGetTimelineColumnHeaders } from './utils'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/utils.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/utils.ts index 879e4b140a61a..543c7daaf8679 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/utils.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/utils.ts @@ -7,7 +7,7 @@ import type { BrowserFields, ColumnHeaderOptions } from '@kbn/timelines-plugin/common'; import memoizeOne from 'memoize-one'; import type { ControlColumnProps } from '../../../../../../common/types'; -import type { Sort } from '../../body/sort'; +import type { SortColumnTimeline as Sort } from '../../../../../../common/types/timeline'; import type { TimelineItem } from '../../../../../../common/search_strategy'; import type { inputsModel } from '../../../../../common/store'; import { getColumnHeaders } from '../../body/column_headers/helpers'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/custom_timeline_data_grid_body.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/custom_timeline_data_grid_body.test.tsx index cfdb2b0d2dbf9..cc8b24710e5af 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/custom_timeline_data_grid_body.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/custom_timeline_data_grid_body.test.tsx @@ -12,7 +12,7 @@ import { CustomTimelineDataGridBody } from './custom_timeline_data_grid_body'; import { mockTimelineData, TestProviders } from '../../../../../common/mock'; import type { TimelineItem } from '@kbn/timelines-plugin/common'; import type { DataTableRecord } from '@kbn/discover-utils/types'; -import { defaultUdtHeaders } from '../default_headers'; +import { defaultUdtHeaders } from '../../body/column_headers/default_headers'; import type { EuiDataGridColumn } from '@elastic/eui'; import { useStatefulRowRenderer } from '../../body/events/stateful_row_renderer/use_stateful_row_renderer'; import { TIMELINE_EVENT_DETAIL_ROW_ID } from '../../body/constants'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.test.tsx index 78ffadeb37ff8..649817d5f8ef2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.test.tsx @@ -8,7 +8,6 @@ import { createMockStore, mockTimelineData, TestProviders } from '../../../../../common/mock'; import React from 'react'; import { TimelineDataTable } from '.'; -import { defaultUdtHeaders } from '../default_headers'; import { TimelineId, TimelineTabs } from '../../../../../../common/types'; import { DataLoadingState } from '@kbn/unified-data-table'; import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; @@ -18,6 +17,7 @@ import { getColumnHeaders } from '../../body/column_headers/helpers'; import { mockSourcererScope } from '../../../../../sourcerer/containers/mocks'; import { timelineActions } from '../../../../store'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { defaultUdtHeaders } from '../../body/column_headers/default_headers'; jest.mock('../../../../../sourcerer/containers'); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/default_headers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/default_headers.tsx deleted file mode 100644 index a0cf9d6355b9d..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/default_headers.tsx +++ /dev/null @@ -1,53 +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 { ColumnHeaderOptions, ColumnHeaderType } from '../../../../../common/types'; -import { - DEFAULT_COLUMN_MIN_WIDTH, - DEFAULT_UNIFIED_TABLE_DATE_COLUMN_MIN_WIDTH, -} from '../body/constants'; - -export const defaultColumnHeaderType: ColumnHeaderType = 'not-filtered'; - -export const defaultUdtHeaders: ColumnHeaderOptions[] = [ - { - columnHeaderType: defaultColumnHeaderType, - id: '@timestamp', - initialWidth: DEFAULT_UNIFIED_TABLE_DATE_COLUMN_MIN_WIDTH, - esTypes: ['date'], - type: 'date', - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'message', - initialWidth: DEFAULT_COLUMN_MIN_WIDTH * 2, - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'event.category', - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'event.action', - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'host.name', - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'source.ip', - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'destination.ip', - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'user.name', - }, -]; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.test.tsx index 9703efd8d5bb3..c660893ba379e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.test.tsx @@ -32,7 +32,7 @@ import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_ex import { TimelineTabs } from '@kbn/securitysolution-data-table'; import { DataLoadingState } from '@kbn/unified-data-table'; import { getColumnHeaders } from '../body/column_headers/helpers'; -import { defaultUdtHeaders } from './default_headers'; +import { defaultUdtHeaders } from '../body/column_headers/default_headers'; import type { ColumnHeaderType } from '../../../../../common/types'; jest.mock('../../../containers', () => ({ @@ -45,10 +45,6 @@ jest.mock('../../fields_browser', () => ({ useFieldBrowserOptions: jest.fn(), })); -jest.mock('../body/events', () => ({ - Events: () => <>, -})); - jest.mock('../../../../sourcerer/containers'); jest.mock('../../../../sourcerer/containers/use_signal_helpers', () => ({ useSignalHelpers: () => ({ signalIndexNeedsInit: false }), diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.tsx index 7d89da9002ba8..112886f93ca32 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.tsx @@ -31,7 +31,6 @@ import { withDataView } from '../../../../common/components/with_data_view'; import { EventDetailsWidthProvider } from '../../../../common/components/events_viewer/event_details_width_context'; import type { TimelineItem } from '../../../../../common/search_strategy'; import { useKibana } from '../../../../common/lib/kibana'; -import { defaultHeaders } from '../body/column_headers/default_headers'; import type { ColumnHeaderOptions, OnChangePage, @@ -47,7 +46,7 @@ import { TimelineResizableLayout } from './resizable_layout'; import TimelineDataTable from './data_table'; import { timelineActions } from '../../../store'; import { getFieldsListCreationOptions } from './get_fields_list_creation_options'; -import { defaultUdtHeaders } from './default_headers'; +import { defaultUdtHeaders } from '../body/column_headers/default_headers'; import { getTimelineShowStatusByIdSelector } from '../../../store/selectors'; const TimelineBodyContainer = styled.div.attrs(({ className = '' }) => ({ @@ -291,7 +290,7 @@ const UnifiedTimelineComponent: React.FC = ({ (columnId: string) => { dispatch( timelineActions.upsertColumn({ - column: getColumnHeader(columnId, defaultHeaders), + column: getColumnHeader(columnId, defaultUdtHeaders), id: timelineId, index: 1, }) 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 e8e2fe9dbbadf..a4c054371a316 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 @@ -18,7 +18,7 @@ import { appActions } from '../../common/store/app'; import { SourcererScopeName } from '../../sourcerer/store/model'; import { InputsModelId } from '../../common/store/inputs/constants'; import { TestProviders, mockGlobalState } from '../../common/mock'; -import { defaultUdtHeaders } from '../components/timeline/unified_components/default_headers'; +import { defaultUdtHeaders } from '../components/timeline/body/column_headers/default_headers'; jest.mock('../../common/components/discover_in_timeline/use_discover_in_timeline_context'); jest.mock('../../common/containers/use_global_time', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/hooks/use_create_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/hooks/use_create_timeline.tsx index 527f372c1a447..2f80e969cab5e 100644 --- a/x-pack/plugins/security_solution/public/timelines/hooks/use_create_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/hooks/use_create_timeline.tsx @@ -19,7 +19,7 @@ import { SourcererScopeName } from '../../sourcerer/store/model'; import { appActions } from '../../common/store/app'; import type { TimeRange } from '../../common/store/inputs/model'; import { useDiscoverInTimelineContext } from '../../common/components/discover_in_timeline/use_discover_in_timeline_context'; -import { defaultUdtHeaders } from '../components/timeline/unified_components/default_headers'; +import { defaultUdtHeaders } from '../components/timeline/body/column_headers/default_headers'; import { timelineDefaults } from '../store/defaults'; export interface UseCreateTimelineParams { diff --git a/x-pack/plugins/security_solution/public/timelines/store/defaults.ts b/x-pack/plugins/security_solution/public/timelines/store/defaults.ts index dd9b811e144e8..e4fd97cd50534 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/defaults.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/defaults.ts @@ -12,10 +12,9 @@ import { RowRendererIdEnum, } from '../../../common/api/timeline'; -import { defaultHeaders } from '../components/timeline/body/column_headers/default_headers'; import { normalizeTimeRange } from '../../common/utils/normalize_time_range'; import type { SubsetTimelineModel, TimelineModel } from './model'; -import { defaultUdtHeaders } from '../components/timeline/unified_components/default_headers'; +import { defaultUdtHeaders } from '../components/timeline/body/column_headers/default_headers'; // normalizeTimeRange uses getTimeRangeSettings which cannot be used outside Kibana context if the uiSettings is not false const { from: start, to: end } = normalizeTimeRange({ from: '', to: '' }, false); @@ -109,7 +108,7 @@ export const timelineDefaults: SubsetTimelineModel & }; export const getTimelineManageDefaults = (id: string) => ({ - defaultColumns: defaultHeaders, + defaultColumns: defaultUdtHeaders, documentType: '', selectAll: false, id, diff --git a/x-pack/plugins/security_solution/public/timelines/store/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/store/helpers.test.ts index 4503d1026d7c8..b0067d6d0d9f4 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/helpers.test.ts @@ -18,7 +18,10 @@ import type { DataProvidersAnd, } from '../components/timeline/data_providers/data_provider'; import { IS_OPERATOR } from '../components/timeline/data_providers/data_provider'; -import { defaultColumnHeaderType } from '../components/timeline/body/column_headers/default_headers'; +import { + defaultUdtHeaders, + defaultColumnHeaderType, +} from '../components/timeline/body/column_headers/default_headers'; import { DEFAULT_COLUMN_MIN_WIDTH, RESIZED_COLUMN_MIN_WITH, @@ -50,7 +53,6 @@ import type { TimelineModel } from './model'; import { timelineDefaults } from './defaults'; import type { TimelineById } from './types'; import { Direction } from '../../../common/search_strategy'; -import { defaultUdtHeaders } from '../components/timeline/unified_components/default_headers'; jest.mock('../../common/utils/normalize_time_range'); jest.mock('../../common/utils/default_date_settings', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/store/helpers.ts b/x-pack/plugins/security_solution/public/timelines/store/helpers.ts index b876465449740..a9566c22a814a 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/helpers.ts @@ -10,7 +10,6 @@ import { v4 as uuidv4 } from 'uuid'; import type { Filter } from '@kbn/es-query'; import type { SessionViewConfig } from '../../../common/types'; import type { TimelineNonEcsData } from '../../../common/search_strategy'; -import type { Sort } from '../components/timeline/body/sort'; import type { DataProvider, QueryOperator, @@ -23,15 +22,16 @@ import { TimelineStatusEnum, TimelineTypeEnum, } from '../../../common/api/timeline'; +import { TimelineId } from '../../../common/types/timeline'; import type { ColumnHeaderOptions, TimelineEventsType, SerializedFilterQuery, TimelinePersistInput, SortColumnTimeline, + SortColumnTimeline as Sort, } from '../../../common/types/timeline'; import type { RowRendererId, TimelineType } from '../../../common/api/timeline'; -import { TimelineId } from '../../../common/types/timeline'; import { normalizeTimeRange } from '../../common/utils/normalize_time_range'; import { getTimelineManageDefaults, timelineDefaults } from './defaults'; import type { KqlMode, TimelineModel } from './model'; diff --git a/x-pack/plugins/security_solution/server/assistant/tools/esql/nl_to_esql_tool.ts b/x-pack/plugins/security_solution/server/assistant/tools/esql/nl_to_esql_tool.ts index 1205fb03b0458..11ca8ffd94edd 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/esql/nl_to_esql_tool.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/esql/nl_to_esql_tool.ts @@ -49,11 +49,7 @@ export const NL_TO_ESQL_TOOL: AssistantTool = { connectorId, input: question, ...(isOssModel ? { functionCalling: 'simulated' } : {}), - logger: { - debug: (source) => { - logger.debug(typeof source === 'function' ? source() : source); - }, - }, + logger, }) ); }; diff --git a/x-pack/plugins/security_solution/server/config.mock.ts b/x-pack/plugins/security_solution/server/config.mock.ts index 1d0d31e9387e2..5fb3dc7b3b48d 100644 --- a/x-pack/plugins/security_solution/server/config.mock.ts +++ b/x-pack/plugins/security_solution/server/config.mock.ts @@ -10,6 +10,7 @@ import type { ExperimentalFeatures } from '../common/experimental_features'; import { parseExperimentalConfigValue } from '../common/experimental_features'; import { getDefaultConfigSettings } from '../common/config_settings'; import type { ConfigType } from './config'; +import { duration } from 'moment'; export const createMockConfig = (): ConfigType => { const enableExperimental: Array = ['responseActionUploadEnabled']; @@ -45,6 +46,8 @@ export const createMockConfig = (): ConfigType => { }, }, entityStore: { + frequency: duration('1m'), + syncDelay: duration('5m'), developer: { pipelineDebugMode: false, }, diff --git a/x-pack/plugins/security_solution/server/config.ts b/x-pack/plugins/security_solution/server/config.ts index 1265aa4c25749..240e452cd44bc 100644 --- a/x-pack/plugins/security_solution/server/config.ts +++ b/x-pack/plugins/security_solution/server/config.ts @@ -176,6 +176,8 @@ export const configSchema = schema.object({ }), }), entityStore: schema.object({ + syncDelay: schema.duration({ defaultValue: '60s' }), + frequency: schema.duration({ defaultValue: '60s' }), developer: schema.object({ pipelineDebugMode: schema.boolean({ defaultValue: false }), }), diff --git a/x-pack/plugins/security_solution/server/endpoint/mocks/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/mocks/mocks.ts index 91a2bc40454b9..03c2e7e857e10 100644 --- a/x-pack/plugins/security_solution/server/endpoint/mocks/mocks.ts +++ b/x-pack/plugins/security_solution/server/endpoint/mocks/mocks.ts @@ -76,6 +76,7 @@ import type { EndpointAuthz } from '../../../common/endpoint/types/authz'; import { createLicenseServiceMock } from '../../../common/license/mocks'; import { createFeatureUsageServiceMock } from '../services/feature_usage/mocks'; import { createProductFeaturesServiceMock } from '../../lib/product_features_service/mocks'; +import type { ConfigType } from '../../config'; /** * Creates a mocked EndpointAppContext. @@ -163,11 +164,15 @@ export const createMockEndpointAppContextServiceSetupContract = }; }; +type CreateMockEndpointAppContextServiceStartContractType = Omit< + DeeplyMockedKeys, + 'config' +> & { config: ConfigType }; // DeeplyMockedKeys doesn't support moment.Duration /** * Creates a mocked input contract for the `EndpointAppContextService#start()` method */ export const createMockEndpointAppContextServiceStartContract = - (): DeeplyMockedKeys => { + (): CreateMockEndpointAppContextServiceStartContractType => { const config = createMockConfig(); const logger = loggingSystemMock.create().get('mock_endpoint_app_context'); @@ -189,7 +194,7 @@ export const createMockEndpointAppContextServiceStartContract = securityMock.createMockAuthenticatedUser({ roles: ['superuser'] }) ); - const startContract: DeeplyMockedKeys = { + const startContract: CreateMockEndpointAppContextServiceStartContractType = { security, config, productFeaturesService: createProductFeaturesServiceMock( diff --git a/x-pack/plugins/security_solution/server/lib/dashboards/routes/get_dashboards_by_tags.ts b/x-pack/plugins/security_solution/server/lib/dashboards/routes/get_dashboards_by_tags.ts index e7a3ebf9a7f10..dda4a6af5d221 100644 --- a/x-pack/plugins/security_solution/server/lib/dashboards/routes/get_dashboards_by_tags.ts +++ b/x-pack/plugins/security_solution/server/lib/dashboards/routes/get_dashboards_by_tags.ts @@ -7,7 +7,7 @@ import type { Logger } from '@kbn/core/server'; import { i18n } from '@kbn/i18n'; -import type { DashboardAttributes } from '@kbn/dashboard-plugin/common'; +import type { DashboardSavedObjectAttributes } from '@kbn/dashboard-plugin/server'; import { transformError } from '@kbn/securitysolution-es-utils'; import { INTERNAL_DASHBOARDS_URL } from '../../../../common/constants'; import type { SecuritySolutionPluginRouter } from '../../../types'; @@ -36,7 +36,7 @@ export const getDashboardsByTagsRoute = (router: SecuritySolutionPluginRouter, l const { tagIds } = request.body; try { - const dashboardsResponse = await savedObjectsClient.find({ + const dashboardsResponse = await savedObjectsClient.find({ type: 'dashboard', hasReference: tagIds.map((id) => ({ id, type: 'tag' })), }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/create_upgradeable_rules_payload.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/create_upgradeable_rules_payload.ts index 97e587646e524..b25320e1131ef 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/create_upgradeable_rules_payload.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/create_upgradeable_rules_payload.ts @@ -5,6 +5,7 @@ * 2.0. */ import { pickBy } from 'lodash'; +import { withSecuritySpanSync } from '../../../../../utils/with_security_span'; import type { PromisePoolError } from '../../../../../utils/promise_pool'; import { PickVersionValuesEnum, @@ -36,77 +37,82 @@ export const createModifiedPrebuiltRuleAssets = ({ upgradeableRules, requestBody, }: CreateModifiedPrebuiltRuleAssetsProps) => { - const { pick_version: globalPickVersion = PickVersionValuesEnum.MERGED, mode } = requestBody; - - const { modifiedPrebuiltRuleAssets, processingErrors } = upgradeableRules.reduce( - (processedRules, upgradeableRule) => { - const targetRuleType = upgradeableRule.target.type; - const ruleId = upgradeableRule.target.rule_id; - const fieldNames = FIELD_NAMES_BY_RULE_TYPE_MAP.get(targetRuleType); - - try { - if (fieldNames === undefined) { - throw new Error(`Unexpected rule type: ${targetRuleType}`); - } - - const { current, target } = upgradeableRule; - if (current.type !== target.type) { - assertPickVersionIsTarget({ ruleId, requestBody }); - } - - const calculatedRuleDiff = calculateRuleFieldsDiff({ - base_version: upgradeableRule.base - ? convertRuleToDiffable(convertPrebuiltRuleAssetToRuleResponse(upgradeableRule.base)) - : MissingVersion, - current_version: convertRuleToDiffable(upgradeableRule.current), - target_version: convertRuleToDiffable( - convertPrebuiltRuleAssetToRuleResponse(upgradeableRule.target) - ), - }) as AllFieldsDiff; - - if (mode === 'ALL_RULES' && globalPickVersion === 'MERGED') { - const fieldsWithConflicts = Object.keys(getFieldsDiffConflicts(calculatedRuleDiff)); - if (fieldsWithConflicts.length > 0) { - // If the mode is ALL_RULES, no fields can be overriden to any other pick_version - // than "MERGED", so throw an error for the fields that have conflicts. - throw new Error( - `Merge conflicts found in rule '${ruleId}' for fields: ${fieldsWithConflicts.join( - ', ' - )}. Please resolve the conflict manually or choose another value for 'pick_version'` - ); + return withSecuritySpanSync(createModifiedPrebuiltRuleAssets.name, () => { + const { pick_version: globalPickVersion = PickVersionValuesEnum.MERGED, mode } = requestBody; + + const { modifiedPrebuiltRuleAssets, processingErrors } = + upgradeableRules.reduce( + (processedRules, upgradeableRule) => { + const targetRuleType = upgradeableRule.target.type; + const ruleId = upgradeableRule.target.rule_id; + const fieldNames = FIELD_NAMES_BY_RULE_TYPE_MAP.get(targetRuleType); + + try { + if (fieldNames === undefined) { + throw new Error(`Unexpected rule type: ${targetRuleType}`); + } + + const { current, target } = upgradeableRule; + if (current.type !== target.type) { + assertPickVersionIsTarget({ ruleId, requestBody }); + } + + const calculatedRuleDiff = calculateRuleFieldsDiff({ + base_version: upgradeableRule.base + ? convertRuleToDiffable( + convertPrebuiltRuleAssetToRuleResponse(upgradeableRule.base) + ) + : MissingVersion, + current_version: convertRuleToDiffable(upgradeableRule.current), + target_version: convertRuleToDiffable( + convertPrebuiltRuleAssetToRuleResponse(upgradeableRule.target) + ), + }) as AllFieldsDiff; + + if (mode === 'ALL_RULES' && globalPickVersion === 'MERGED') { + const fieldsWithConflicts = Object.keys(getFieldsDiffConflicts(calculatedRuleDiff)); + if (fieldsWithConflicts.length > 0) { + // If the mode is ALL_RULES, no fields can be overriden to any other pick_version + // than "MERGED", so throw an error for the fields that have conflicts. + throw new Error( + `Merge conflicts found in rule '${ruleId}' for fields: ${fieldsWithConflicts.join( + ', ' + )}. Please resolve the conflict manually or choose another value for 'pick_version'` + ); + } + } + + const modifiedPrebuiltRuleAsset = createModifiedPrebuiltRuleAsset({ + upgradeableRule, + fieldNames, + requestBody, + globalPickVersion, + calculatedRuleDiff, + }); + + processedRules.modifiedPrebuiltRuleAssets.push(modifiedPrebuiltRuleAsset); + + return processedRules; + } catch (err) { + processedRules.processingErrors.push({ + error: err, + item: { rule_id: ruleId }, + }); + + return processedRules; } + }, + { + modifiedPrebuiltRuleAssets: [], + processingErrors: [], } + ); - const modifiedPrebuiltRuleAsset = createModifiedPrebuiltRuleAsset({ - upgradeableRule, - fieldNames, - requestBody, - globalPickVersion, - calculatedRuleDiff, - }); - - processedRules.modifiedPrebuiltRuleAssets.push(modifiedPrebuiltRuleAsset); - - return processedRules; - } catch (err) { - processedRules.processingErrors.push({ - error: err, - item: { rule_id: ruleId }, - }); - - return processedRules; - } - }, - { - modifiedPrebuiltRuleAssets: [], - processingErrors: [], - } - ); - - return { - modifiedPrebuiltRuleAssets, - processingErrors, - }; + return { + modifiedPrebuiltRuleAssets, + processingErrors, + }; + }); }; interface CreateModifiedPrebuiltRuleAssetParams { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/get_upgradeable_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/get_upgradeable_rules.ts index acfdb674c309a..750561b9858a9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/get_upgradeable_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/get_upgradeable_rules.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { withSecuritySpanSync } from '../../../../../utils/with_security_span'; import type { RuleResponse, RuleUpgradeSpecifier, @@ -26,58 +26,60 @@ export const getUpgradeableRules = ({ versionSpecifiers?: RuleUpgradeSpecifier[]; mode: Mode; }) => { - const upgradeableRules = new Map( - rawUpgradeableRules.map((_rule) => [_rule.current.rule_id, _rule]) - ); - const fetchErrors: Array> = []; - const skippedRules: SkippedRuleUpgrade[] = []; + return withSecuritySpanSync(getUpgradeableRules.name, () => { + const upgradeableRules = new Map( + rawUpgradeableRules.map((_rule) => [_rule.current.rule_id, _rule]) + ); + const fetchErrors: Array> = []; + const skippedRules: SkippedRuleUpgrade[] = []; - if (mode === ModeEnum.SPECIFIC_RULES) { - const installedRuleIds = new Set(currentRules.map((rule) => rule.rule_id)); - const upgradeableRuleIds = new Set(rawUpgradeableRules.map(({ current }) => current.rule_id)); - versionSpecifiers?.forEach((rule) => { - // Check that the requested rule was found - if (!installedRuleIds.has(rule.rule_id)) { - fetchErrors.push({ - error: new Error( - `Rule with rule_id "${rule.rule_id}" and version "${rule.version}" not found` - ), - item: rule, - }); - return; - } + if (mode === ModeEnum.SPECIFIC_RULES) { + const installedRuleIds = new Set(currentRules.map((rule) => rule.rule_id)); + const upgradeableRuleIds = new Set(rawUpgradeableRules.map(({ current }) => current.rule_id)); + versionSpecifiers?.forEach((rule) => { + // Check that the requested rule was found + if (!installedRuleIds.has(rule.rule_id)) { + fetchErrors.push({ + error: new Error( + `Rule with rule_id "${rule.rule_id}" and version "${rule.version}" not found` + ), + item: rule, + }); + return; + } - // Check that the requested rule is upgradeable - if (!upgradeableRuleIds.has(rule.rule_id)) { - skippedRules.push({ - rule_id: rule.rule_id, - reason: SkipRuleUpgradeReasonEnum.RULE_UP_TO_DATE, - }); - return; - } + // Check that the requested rule is upgradeable + if (!upgradeableRuleIds.has(rule.rule_id)) { + skippedRules.push({ + rule_id: rule.rule_id, + reason: SkipRuleUpgradeReasonEnum.RULE_UP_TO_DATE, + }); + return; + } - // Check that rule revisions match (no update slipped in since the user reviewed the list) - const currentRevision = currentRules.find( - (currentRule) => currentRule.rule_id === rule.rule_id - )?.revision; - if (rule.revision !== currentRevision) { - fetchErrors.push({ - error: new Error( - `Revision mismatch for rule_id ${rule.rule_id}: expected ${currentRevision}, got ${rule.revision}` - ), - item: rule, - }); - // Remove the rule from the list of upgradeable rules - if (upgradeableRules.has(rule.rule_id)) { - upgradeableRules.delete(rule.rule_id); + // Check that rule revisions match (no update slipped in since the user reviewed the list) + const currentRevision = currentRules.find( + (currentRule) => currentRule.rule_id === rule.rule_id + )?.revision; + if (rule.revision !== currentRevision) { + fetchErrors.push({ + error: new Error( + `Revision mismatch for rule_id ${rule.rule_id}: expected ${currentRevision}, got ${rule.revision}` + ), + item: rule, + }); + // Remove the rule from the list of upgradeable rules + if (upgradeableRules.has(rule.rule_id)) { + upgradeableRules.delete(rule.rule_id); + } } - } - }); - } + }); + } - return { - upgradeableRules: Array.from(upgradeableRules.values()), - fetchErrors, - skippedRules, - }; + return { + upgradeableRules: Array.from(upgradeableRules.values()), + fetchErrors, + skippedRules, + }; + }); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts index ebc1706b309f8..cba989890438e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts @@ -79,7 +79,8 @@ export const createMockClients = () => { internalFleetServices: { packages: packageServiceMock.createClient(), }, - siemMigrationsClient: siemMigrationsServiceMock.createClient(), + siemRuleMigrationsClient: siemMigrationsServiceMock.createRulesClient(), + getInferenceClient: jest.fn(), }; }; @@ -164,8 +165,10 @@ const createSecuritySolutionRequestContextMock = ( getRiskScoreDataClient: jest.fn(() => clients.riskScoreDataClient), getAssetCriticalityDataClient: jest.fn(() => clients.assetCriticalityDataClient), getAuditLogger: jest.fn(() => mockAuditLogger), + getDataViewsService: jest.fn(), getEntityStoreDataClient: jest.fn(() => clients.entityStoreDataClient), - getSiemMigrationsClient: jest.fn(() => clients.siemMigrationsClient), + getSiemRuleMigrationsClient: jest.fn(() => clients.siemRuleMigrationsClient), + getInferenceClient: jest.fn(() => clients.getInferenceClient()), }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/create_index_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/create_index_route.ts index b5c61bb82c29e..85fe5a2c29a1b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/create_index_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/create_index_route.ts @@ -40,8 +40,10 @@ export const createIndexRoute = (router: SecuritySolutionPluginRouter) => { .post({ path: DETECTION_ENGINE_INDEX_URL, access: 'public', - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/delete_index_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/delete_index_route.ts index 49b14944633cc..08f975c023851 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/delete_index_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/delete_index_route.ts @@ -35,8 +35,10 @@ export const deleteIndexRoute = (router: SecuritySolutionPluginRouter) => { .delete({ path: DETECTION_ENGINE_INDEX_URL, access: 'public', - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/read_alerts_index_exists_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/read_alerts_index_exists_route.ts index 9f75689cf7811..894e5ec642c07 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/read_alerts_index_exists_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/read_alerts_index_exists_route.ts @@ -18,8 +18,10 @@ export const readAlertsIndexExistsRoute = (router: SecuritySolutionPluginRouter) .get({ path: DETECTION_ENGINE_ALERTS_INDEX_URL, access: 'internal', - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/read_index_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/read_index_route.ts index bd13fe09b687b..2f5131d1abf8b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/read_index_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/read_index_route.ts @@ -26,8 +26,10 @@ export const readIndexRoute = ( .get({ path: DETECTION_ENGINE_INDEX_URL, access: 'public', - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/create_signals_migration_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/create_signals_migration_route.ts index 8d8d80a700478..b4947b939b336 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/create_signals_migration_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/create_signals_migration_route.ts @@ -24,8 +24,10 @@ export const createSignalsMigrationRoute = (router: SecuritySolutionPluginRouter .post({ path: DETECTION_ENGINE_SIGNALS_MIGRATION_URL, access: 'public', - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/delete_signals_migration_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/delete_signals_migration_route.ts index c4838280ac6a4..14a490d34d4fc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/delete_signals_migration_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/delete_signals_migration_route.ts @@ -20,8 +20,10 @@ export const deleteSignalsMigrationRoute = (router: SecuritySolutionPluginRouter .delete({ path: DETECTION_ENGINE_SIGNALS_MIGRATION_URL, access: 'public', - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/finalize_signals_migration_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/finalize_signals_migration_route.ts index 9e09ffe0cf895..6ea0584c0b0c4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/finalize_signals_migration_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/finalize_signals_migration_route.ts @@ -25,8 +25,10 @@ export const finalizeSignalsMigrationRoute = ( .post({ path: DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL, access: 'public', - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/get_signals_migration_status_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/get_signals_migration_status_route.ts index ece4d3444be99..fc7cfe6fc2eae 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/get_signals_migration_status_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/get_signals_migration_status_route.ts @@ -23,8 +23,10 @@ export const getSignalsMigrationStatusRoute = (router: SecuritySolutionPluginRou .get({ path: DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL, access: 'public', - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals_route.ts index dde24af7007c4..5438db042e7df 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals_route.ts @@ -37,8 +37,10 @@ export const setSignalsStatusRoute = ( .post({ path: DETECTION_ENGINE_SIGNALS_STATUS_URL, access: 'public', - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/query_signals_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/query_signals_route.ts index 60e0bde69c590..f49eaad74e490 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/query_signals_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/query_signals_route.ts @@ -23,8 +23,10 @@ export const querySignalsRoute = ( .post({ path: DETECTION_ENGINE_QUERY_SIGNALS_URL, access: 'public', - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_assignees_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_assignees_route.ts index 1ce791143705b..9e5547e03c7e6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_assignees_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_assignees_route.ts @@ -22,8 +22,10 @@ export const setAlertAssigneesRoute = (router: SecuritySolutionPluginRouter) => .post({ path: DETECTION_ENGINE_ALERT_ASSIGNEES_URL, access: 'public', - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_tags_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_tags_route.ts index d285c381c4b54..3fbd21c57ffef 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_tags_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_tags_route.ts @@ -22,8 +22,10 @@ export const setAlertTagsRoute = (router: SecuritySolutionPluginRouter) => { .post({ path: DETECTION_ENGINE_ALERT_TAGS_URL, access: 'public', - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/api/create_legacy_notification/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/api/create_legacy_notification/route.ts index 518ece11dbefe..d5623df8db91c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/api/create_legacy_notification/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/api/create_legacy_notification/route.ts @@ -34,8 +34,10 @@ export const legacyCreateLegacyNotificationRoute = ( .post({ path: UPDATE_OR_CREATE_LEGACY_ACTIONS, access: 'internal', - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_exceptions/api/create_rule_exceptions/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_exceptions/api/create_rule_exceptions/route.ts index 22aa94ad80aee..1d8038b1052c3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_exceptions/api/create_rule_exceptions/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_exceptions/api/create_rule_exceptions/route.ts @@ -36,8 +36,10 @@ export const createRuleExceptionsRoute = (router: SecuritySolutionPluginRouter) .post({ path: CREATE_RULE_EXCEPTIONS_URL, access: 'public', - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_exceptions/api/find_exception_references/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_exceptions/api/find_exception_references/route.ts index f8714c4e260ee..3680a70525fd4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_exceptions/api/find_exception_references/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_exceptions/api/find_exception_references/route.ts @@ -29,8 +29,10 @@ export const findRuleExceptionReferencesRoute = (router: SecuritySolutionPluginR .get({ path: DETECTION_ENGINE_RULES_EXCEPTIONS_REFERENCE_URL, access: 'internal', - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_preview/api/preview_rules/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_preview/api/preview_rules/route.ts index 3b3656c82f06d..bc805867f69e0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_preview/api/preview_rules/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_preview/api/preview_rules/route.ts @@ -89,8 +89,13 @@ export const previewRulesRoute = ( .post({ path: DETECTION_ENGINE_RULES_PREVIEW, access: 'public', + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, + }, options: { - tags: ['access:securitySolution', routeLimitedConcurrencyTag(MAX_ROUTE_CONCURRENCY)], + tags: [routeLimitedConcurrencyTag(MAX_ROUTE_CONCURRENCY)], }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/get_threshold_signal_history.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/get_threshold_signal_history.ts index 018d63c345e3a..e82e33c9e6e95 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/get_threshold_signal_history.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/get_threshold_signal_history.ts @@ -46,6 +46,9 @@ export const getThresholdSignalHistory = async ({ const response = await esClient.search({ ...request, index: indexPattern, + // If alerts index is not yet created, + // do not throw a 404 + ignore_unavailable: true, }); return { signalHistory: buildThresholdSignalHistory({ alerts: response.hits.hits }), diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/bulk_upload.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/bulk_upload.ts index 93251bcf92652..e0de56da88c8d 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/bulk_upload.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/bulk_upload.ts @@ -33,8 +33,10 @@ export const assetCriticalityPublicBulkUploadRoute = ( .post({ access: 'public', path: ASSET_CRITICALITY_PUBLIC_BULK_UPLOAD_URL, - options: { - tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/delete.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/delete.ts index 6c2437081500d..de3f8cda4f5ba 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/delete.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/delete.ts @@ -28,8 +28,10 @@ export const assetCriticalityPublicDeleteRoute = ( .delete({ access: 'public', path: ASSET_CRITICALITY_PUBLIC_URL, - options: { - tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/get.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/get.ts index 048df61757a56..2cea50a2bbe20 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/get.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/get.ts @@ -30,8 +30,10 @@ export const assetCriticalityPublicGetRoute = ( .get({ access: 'public', path: ASSET_CRITICALITY_PUBLIC_URL, - options: { - tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/list.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/list.ts index a6316646bc612..7cfc763e8b97c 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/list.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/list.ts @@ -28,8 +28,10 @@ export const assetCriticalityPublicListRoute = ( .get({ access: 'public', path: ASSET_CRITICALITY_PUBLIC_LIST_URL, - options: { - tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/privileges.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/privileges.ts index 8c40335423973..59cdc983770ca 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/privileges.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/privileges.ts @@ -28,8 +28,10 @@ export const assetCriticalityInternalPrivilegesRoute = ( .get({ access: 'internal', path: ASSET_CRITICALITY_INTERNAL_PRIVILEGES_URL, - options: { - tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/status.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/status.ts index fc1cc92bbe1cf..6443981b327d9 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/status.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/status.ts @@ -26,8 +26,10 @@ export const assetCriticalityInternalStatusRoute = ( .get({ access: 'internal', path: ASSET_CRITICALITY_INTERNAL_STATUS_URL, - options: { - tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/upload_csv.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/upload_csv.ts index 8c1d94176111c..dccf24d161054 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/upload_csv.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/upload_csv.ts @@ -35,8 +35,12 @@ export const assetCriticalityPublicCSVUploadRoute = ( .post({ access: 'public', path: ASSET_CRITICALITY_PUBLIC_CSV_UPLOAD_URL, + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, + }, options: { - tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], body: { output: 'stream', accepts: 'multipart/form-data', diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/upsert.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/upsert.ts index 488a75c0196ab..8614a2b8e9ad1 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/upsert.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/upsert.ts @@ -31,8 +31,10 @@ export const assetCriticalityPublicUpsertRoute = ( .post({ access: 'public', path: ASSET_CRITICALITY_PUBLIC_URL, - options: { - tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/auditing/actions.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/auditing/actions.ts new file mode 100644 index 0000000000000..63d594a9711a3 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/auditing/actions.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const EntityEngineActions = { + INIT: 'init', + START: 'start', + STOP: 'stop', + CREATE: 'create', + DELETE: 'delete', + EXECUTE: 'execute', +} as const; + +export type EntityEngineActions = (typeof EntityEngineActions)[keyof typeof EntityEngineActions]; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/auditing/resources.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/auditing/resources.ts new file mode 100644 index 0000000000000..67d33fb42dc93 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/auditing/resources.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const EntityStoreResource = { + ENTITY_ENGINE: 'entity_engine', + ENTITY_DEFINITION: 'entity_definition', + ENTITY_INDEX: 'entity_index', + INDEX_COMPONENT_TEMPLATE: 'index_component_template', + PLATFORM_PIPELINE: 'platform_pipeline', + FIELD_RETENTION_ENRICH_POLICY: 'field_retention_enrich_policy', + FIELD_RETENTION_ENRICH_POLICY_TASK: 'field_retention_enrich_policy_task', +} as const; + +export type EntityStoreResource = (typeof EntityStoreResource)[keyof typeof EntityStoreResource]; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/constants.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/constants.ts index 796932d79b364..8b2e802b17b6d 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/constants.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/constants.ts @@ -9,8 +9,6 @@ import type { EngineStatus } from '../../../../common/api/entity_analytics'; export const DEFAULT_LOOKBACK_PERIOD = '24h'; -export const DEFAULT_INTERVAL = '30s'; - export const ENGINE_STATUS: Record, EngineStatus> = { INSTALLING: 'installing', STARTED: 'started', diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/elasticsearch_assets/entity_index.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/elasticsearch_assets/entity_index.ts index 7bc139ea08adf..6fb5935618dfc 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/elasticsearch_assets/entity_index.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/elasticsearch_assets/entity_index.ts @@ -8,6 +8,7 @@ import type { ElasticsearchClient, Logger } from '@kbn/core/server'; import type { EntityType } from '../../../../../common/api/entity_analytics'; import { getEntitiesIndexName } from '../utils'; +import { createOrUpdateIndex } from '../../utils/create_or_update_index'; interface Options { entityType: EntityType; @@ -17,18 +18,13 @@ interface Options { } export const createEntityIndex = async ({ entityType, esClient, namespace, logger }: Options) => { - try { - await esClient.indices.create({ + await createOrUpdateIndex({ + esClient, + logger, + options: { index: getEntitiesIndexName(entityType, namespace), - body: {}, - }); - } catch (e) { - if (e.meta.body.error.type === 'resource_already_exists_exception') { - logger.debug(`Index for ${entityType} already exists, skipping creation.`); - } else { - throw e; - } - } + }, + }); }; export const deleteEntityIndex = ({ entityType, esClient, namespace }: Options) => diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/elasticsearch_assets/ingest_pipeline.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/elasticsearch_assets/ingest_pipeline.ts index c2a5bff51f830..f4d2b848b726f 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/elasticsearch_assets/ingest_pipeline.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/elasticsearch_assets/ingest_pipeline.ts @@ -125,7 +125,7 @@ export const createPlatformPipeline = async ({ managed_by: 'entity_store', managed: true, }, - description: `Ingest pipeline for entity defiinition ${entityManagerDefinition.id}`, + description: `Ingest pipeline for entity definition ${entityManagerDefinition.id}`, processors: buildIngestPipeline({ namespace: unitedDefinition.namespace, version: unitedDefinition.version, diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.test.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.test.ts index 858047952801d..733e85fd6ed55 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.test.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.test.ts @@ -15,6 +15,7 @@ import type { SortOrder } from '@elastic/elasticsearch/lib/api/types'; import type { EntityType } from '../../../../common/api/entity_analytics/entity_store/common.gen'; import type { DataViewsService } from '@kbn/data-views-plugin/common'; import type { AppClient } from '../../..'; +import type { EntityStoreConfig } from './types'; describe('EntityStoreDataClient', () => { const mockSavedObjectClient = savedObjectsClientMock.create(); @@ -29,6 +30,7 @@ describe('EntityStoreDataClient', () => { kibanaVersion: '9.0.0', dataViewsService: {} as DataViewsService, appClient: {} as AppClient, + config: {} as EntityStoreConfig, }); const defaultSearchParams = { 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 429d77482841e..22da331503e4d 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 @@ -11,12 +11,15 @@ import type { SavedObjectsClientContract, AuditLogger, IScopedClusterClient, + AuditEvent, + AnalyticsServiceSetup, } from '@kbn/core/server'; import { EntityClient } from '@kbn/entityManager-plugin/server/lib/entity_client'; import type { SortOrder } from '@elastic/elasticsearch/lib/api/types'; import type { TaskManagerStartContract } from '@kbn/task-manager-plugin/server'; import type { DataViewsService } from '@kbn/data-views-plugin/common'; import { isEqual } from 'lodash/fp'; +import moment from 'moment'; import type { AppClient } from '../../..'; import type { Entity, @@ -53,7 +56,15 @@ import { isPromiseFulfilled, isPromiseRejected, } from './utils'; -import type { EntityRecord } from './types'; + +import { EntityEngineActions } from './auditing/actions'; +import { EntityStoreResource } from './auditing/resources'; +import { AUDIT_CATEGORY, AUDIT_OUTCOME, AUDIT_TYPE } from '../audit'; +import type { EntityRecord, EntityStoreConfig } from './types'; +import { + ENTITY_ENGINE_INITIALIZATION_EVENT, + ENTITY_ENGINE_RESOURCE_INIT_FAILURE_EVENT, +} from '../../telemetry/event_based/events'; import { CRITICALITY_VALUES } from '../asset_criticality/constants'; interface EntityStoreClientOpts { @@ -66,6 +77,8 @@ interface EntityStoreClientOpts { kibanaVersion: string; dataViewsService: DataViewsService; appClient: AppClient; + telemetry?: AnalyticsServiceSetup; + config: EntityStoreConfig; } interface SearchEntitiesParams { @@ -123,7 +136,7 @@ export class EntityStoreDataClient { throw new Error('Task Manager is not available'); } - const { logger } = this.options; + const { config } = this.options; await this.riskScoreDataClient.createRiskScoreLatestIndex(); @@ -135,18 +148,20 @@ export class EntityStoreDataClient { 'Asset criticality data migration is required before initializing entity store. If this error persists, please restart Kibana.' ); } - logger.info( - `In namespace ${this.options.namespace}: Initializing entity store for ${entityType}` - ); + this.log('info', entityType, `Initializing entity store`); + this.audit( + EntityEngineActions.INIT, + EntityStoreResource.ENTITY_ENGINE, + entityType, + 'Initializing entity engine' + ); const descriptor = await this.engineClient.init(entityType, { filter, fieldHistoryLength, indexPattern, }); - logger.debug(`Initialized engine for ${entityType}`); - // first create the entity definition without starting it - // so that the index template is created which we can add a component template to + this.log('debug', entityType, `Initialized engine saved object`); this.asyncSetup( entityType, @@ -154,10 +169,11 @@ export class EntityStoreDataClient { this.options.taskManager, indexPattern, filter, + config, pipelineDebugMode - ).catch((error) => { - logger.error(`There was an error during async setup of the Entity Store: ${error}`); - }); + ).catch((e) => + this.log('error', entityType, `Error during async setup of entity store: ${e.message}`) + ); return descriptor; } @@ -168,8 +184,10 @@ export class EntityStoreDataClient { taskManager: TaskManagerStartContract, indexPattern: string, filter: string, + config: EntityStoreConfig, pipelineDebugMode: boolean ) { + const setupStartTime = moment().utc().toISOString(); const { logger, namespace, appClient, dataViewsService } = this.options; const indexPatterns = await buildIndexPatterns(namespace, appClient, dataViewsService); @@ -178,12 +196,11 @@ export class EntityStoreDataClient { entityType, namespace, fieldHistoryLength, + syncDelay: `${config.syncDelay.asSeconds()}s`, + frequency: `${config.frequency.asSeconds()}s`, }); const { entityManagerDefinition } = unitedDefinition; - const debugLog = (message: string) => - logger.debug(`[Entity Engine] [${entityType}] ${message}`); - try { // clean up any existing entity store await this.delete(entityType, taskManager, { deleteData: false, deleteEngine: false }); @@ -199,7 +216,7 @@ export class EntityStoreDataClient { }, installOnly: true, }); - debugLog(`Created entity definition`); + this.log(`debug`, entityType, `Created entity definition`); // the index must be in place with the correct mapping before the enrich policy is created // this is because the enrich policy will fail if the index does not exist with the correct fields @@ -207,14 +224,14 @@ export class EntityStoreDataClient { unitedDefinition, esClient: this.esClient, }); - debugLog(`Created entity index component template`); + this.log(`debug`, entityType, `Created entity index component template`); await createEntityIndex({ entityType, esClient: this.esClient, namespace, logger, }); - debugLog(`Created entity index`); + this.log(`debug`, entityType, `Created entity index`); // we must create and execute the enrich policy before the pipeline is created // this is because the pipeline will fail if the enrich index does not exist @@ -222,24 +239,24 @@ export class EntityStoreDataClient { unitedDefinition, esClient: this.esClient, }); - debugLog(`Created field retention enrich policy`); + this.log(`debug`, entityType, `Created field retention enrich policy`); + await executeFieldRetentionEnrichPolicy({ unitedDefinition, esClient: this.esClient, logger, }); - debugLog(`Executed field retention enrich policy`); + this.log(`debug`, entityType, `Executed field retention enrich policy`); await createPlatformPipeline({ debugMode: pipelineDebugMode, unitedDefinition, logger, esClient: this.esClient, }); - debugLog(`Created @platform pipeline`); + this.log(`debug`, entityType, `Created @platform pipeline`); // finally start the entity definition now that everything is in place const updated = await this.start(entityType, { force: true }); - debugLog(`Started entity definition`); // the task will execute the enrich policy on a schedule await startEntityStoreFieldRetentionEnrichTask({ @@ -247,15 +264,39 @@ export class EntityStoreDataClient { logger, taskManager, }); - logger.info(`Entity store initialized for ${entityType}`); + this.log(`debug`, entityType, `Started entity store field retention enrich task`); + this.log(`info`, entityType, `Entity store initialized`); + + const setupEndTime = moment().utc().toISOString(); + const duration = moment(setupEndTime).diff(moment(setupStartTime), 'seconds'); + this.options.telemetry?.reportEvent(ENTITY_ENGINE_INITIALIZATION_EVENT.eventType, { + duration, + }); return updated; } catch (err) { - this.options.logger.error( - `Error initializing entity store for ${entityType}: ${err.message}` + this.log(`error`, entityType, `Error initializing entity store: ${err.message}`); + + this.audit( + EntityEngineActions.INIT, + EntityStoreResource.ENTITY_ENGINE, + entityType, + 'Failed to initialize entity engine resources', + err ); - await this.engineClient.update(entityType, ENGINE_STATUS.ERROR); + this.options.telemetry?.reportEvent(ENTITY_ENGINE_RESOURCE_INIT_FAILURE_EVENT.eventType, { + error: err.message, + }); + + await this.engineClient.update(entityType, { + status: ENGINE_STATUS.ERROR, + error: { + message: err.message, + stack: err.stack, + action: 'init', + }, + }); await this.delete(entityType, taskManager, { deleteData: true, deleteEngine: false }); } @@ -278,43 +319,56 @@ export class EntityStoreDataClient { } public async start(entityType: EntityType, options?: { force: boolean }) { + const { namespace } = this.options; const descriptor = await this.engineClient.get(entityType); if (!options?.force && descriptor.status !== ENGINE_STATUS.STOPPED) { throw new Error( - `In namespace ${this.options.namespace}: Cannot start Entity engine for ${entityType} when current status is: ${descriptor.status}` + `In namespace ${namespace}: Cannot start Entity engine for ${entityType} when current status is: ${descriptor.status}` ); } - this.options.logger.info( - `In namespace ${this.options.namespace}: Starting entity store for ${entityType}` - ); + this.log('info', entityType, `Starting entity store`); // startEntityDefinition requires more fields than the engine descriptor // provides so we need to fetch the full entity definition const fullEntityDefinition = await this.getExistingEntityDefinition(entityType); + this.audit( + EntityEngineActions.START, + EntityStoreResource.ENTITY_DEFINITION, + entityType, + 'Starting entity definition' + ); await this.entityClient.startEntityDefinition(fullEntityDefinition); + this.log('debug', entityType, `Started entity definition`); - return this.engineClient.update(entityType, ENGINE_STATUS.STARTED); + return this.engineClient.updateStatus(entityType, ENGINE_STATUS.STARTED); } public async stop(entityType: EntityType) { + const { namespace } = this.options; const descriptor = await this.engineClient.get(entityType); if (descriptor.status !== ENGINE_STATUS.STARTED) { throw new Error( - `In namespace ${this.options.namespace}: Cannot stop Entity engine for ${entityType} when current status is: ${descriptor.status}` + `In namespace ${namespace}: Cannot stop Entity engine for ${entityType} when current status is: ${descriptor.status}` ); } - this.options.logger.info( - `In namespace ${this.options.namespace}: Stopping entity store for ${entityType}` - ); + this.log('info', entityType, `Stopping entity store`); + // stopEntityDefinition requires more fields than the engine descriptor // provides so we need to fetch the full entity definition const fullEntityDefinition = await this.getExistingEntityDefinition(entityType); + this.audit( + EntityEngineActions.STOP, + EntityStoreResource.ENTITY_DEFINITION, + entityType, + 'Stopping entity definition' + ); await this.entityClient.stopEntityDefinition(fullEntityDefinition); + this.log('debug', entityType, `Stopped entity definition`); - return this.engineClient.update(entityType, ENGINE_STATUS.STOPPED); + return this.engineClient.updateStatus(entityType, ENGINE_STATUS.STOPPED); } public async get(entityType: EntityType) { @@ -330,42 +384,61 @@ export class EntityStoreDataClient { taskManager: TaskManagerStartContract, options = { deleteData: false, deleteEngine: true } ) { - const { namespace, logger, appClient, dataViewsService } = this.options; + const { namespace, logger, appClient, dataViewsService, config } = this.options; const { deleteData, deleteEngine } = options; const descriptor = await this.engineClient.maybeGet(entityType); const indexPatterns = await buildIndexPatterns(namespace, appClient, dataViewsService); + + // TODO delete unitedDefinition from this method. we only need the id for deletion const unitedDefinition = getUnitedEntityDefinition({ indexPatterns, entityType, namespace: this.options.namespace, fieldHistoryLength: descriptor?.fieldHistoryLength ?? 10, + syncDelay: `${config.syncDelay.asSeconds()}s`, + frequency: `${config.frequency.asSeconds()}s`, }); const { entityManagerDefinition } = unitedDefinition; - logger.info(`In namespace ${namespace}: Deleting entity store for ${entityType}`); + + this.log('info', entityType, `Deleting entity store`); + this.audit( + EntityEngineActions.DELETE, + EntityStoreResource.ENTITY_ENGINE, + entityType, + 'Deleting entity engine' + ); try { - try { - await this.entityClient.deleteEntityDefinition({ + await this.entityClient + .deleteEntityDefinition({ id: entityManagerDefinition.id, deleteData, - }); - } catch (e) { - logger.error(`Error deleting entity definition for ${entityType}: ${e.message}`); - } + }) + // Swallowing the error as it is expected to fail if no entity definition exists + .catch((e) => + this.log(`warn`, entityType, `Error deleting entity definition: ${e.message}`) + ); + this.log('debug', entityType, `Deleted entity definition`); + await deleteEntityIndexComponentTemplate({ unitedDefinition, esClient: this.esClient, }); + this.log('debug', entityType, `Deleted entity index component template`); + await deletePlatformPipeline({ unitedDefinition, logger, esClient: this.esClient, }); + this.log('debug', entityType, `Deleted platform pipeline`); + await deleteFieldRetentionEnrichPolicy({ unitedDefinition, esClient: this.esClient, logger, }); + this.log('debug', entityType, `Deleted field retention enrich policy`); if (deleteData) { await deleteEntityIndex({ @@ -374,6 +447,7 @@ export class EntityStoreDataClient { namespace, logger, }); + this.log('debug', entityType, `Deleted entity index`); } if (descriptor && deleteEngine) { @@ -387,13 +461,23 @@ export class EntityStoreDataClient { logger, taskManager, }); + this.log('debug', entityType, `Deleted entity store field retention enrich task`); } + logger.info(`[Entity Store] In namespace ${namespace}: Deleted store for ${entityType}`); return { deleted: true }; - } catch (e) { - logger.error(`Error deleting entity store for ${entityType}: ${e.message}`); - // TODO: should we set the engine status to error here? - throw e; + } catch (err) { + this.log(`error`, entityType, `Error deleting entity store: ${err.message}`); + + this.audit( + EntityEngineActions.DELETE, + EntityStoreResource.ENTITY_ENGINE, + entityType, + 'Failed to delete entity engine', + err + ); + + throw err; } } @@ -481,7 +565,7 @@ export class EntityStoreDataClient { } // Update savedObject status - await this.engineClient.update(engine.type, ENGINE_STATUS.UPDATING); + await this.engineClient.updateStatus(engine.type, ENGINE_STATUS.UPDATING); try { // Update entity manager definition @@ -494,12 +578,12 @@ export class EntityStoreDataClient { }); // Restore the savedObject status and set the new index pattern - await this.engineClient.update(engine.type, originalStatus); + await this.engineClient.updateStatus(engine.type, originalStatus); return { type: engine.type, changes: { indexPatterns } }; } catch (error) { // Rollback the engine initial status when the update fails - await this.engineClient.update(engine.type, originalStatus); + await this.engineClient.updateStatus(engine.type, originalStatus); throw error; } @@ -521,4 +605,48 @@ export class EntityStoreDataClient { errors: updateErrors, }; } + + private log( + level: Exclude, + entityType: EntityType, + msg: string + ) { + this.options.logger[level]( + `[Entity Engine] [entity.${entityType}] [namespace: ${this.options.namespace}] ${msg}` + ); + } + + private audit( + action: EntityEngineActions, + resource: EntityStoreResource, + entityType: EntityType, + msg: string, + error?: Error + ) { + // NOTE: Excluding errors, all auditing events are currently WRITE events, meaning the outcome is always UNKNOWN. + // This may change in the future, depending on the audit action. + const outcome = error ? AUDIT_OUTCOME.FAILURE : AUDIT_OUTCOME.UNKNOWN; + + const type = + action === EntityEngineActions.CREATE + ? AUDIT_TYPE.CREATION + : EntityEngineActions.DELETE + ? AUDIT_TYPE.DELETION + : AUDIT_TYPE.CHANGE; + + const category = AUDIT_CATEGORY.DATABASE; + + const message = error ? `${msg}: ${error.message}` : msg; + const event: AuditEvent = { + message: `[Entity Engine] [entity.${entityType}] ${message}`, + event: { + action: `${action}_${entityType}_${resource}`, + category, + outcome, + type, + }, + }; + + return this.options.auditLogger?.log(event); + } } diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/apply_dataview_indices.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/apply_dataview_indices.ts index 72cd02f273cad..115afdb6b0b3b 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/apply_dataview_indices.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/apply_dataview_indices.ts @@ -20,8 +20,10 @@ export const applyDataViewIndicesEntityEngineRoute = ( .post({ access: 'public', path: '/api/entity_store/engines/apply_dataview_indices', - options: { - tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/delete.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/delete.ts index e11c9d3fa7b9d..9e221093d9582 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/delete.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/delete.ts @@ -28,8 +28,10 @@ export const deleteEntityEngineRoute = ( .delete({ access: 'public', path: '/api/entity_store/engines/{entityType}', - options: { - tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/entities/list.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/entities/list.ts index 3eefcb7de5752..9f195bb33c8d9 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/entities/list.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/entities/list.ts @@ -32,8 +32,10 @@ export const listEntitiesRoute = (router: EntityAnalyticsRoutesDeps['router'], l .get({ access: 'public', path: LIST_ENTITIES_URL, - options: { - tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/get.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/get.ts index 23f013598b476..3ae59d6d748cb 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/get.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/get.ts @@ -23,8 +23,10 @@ export const getEntityEngineRoute = ( .get({ access: 'public', path: '/api/entity_store/engines/{entityType}', - options: { - tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/init.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/init.ts index 3535be022179b..c6ae2f23366a0 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/init.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/init.ts @@ -28,8 +28,10 @@ export const initEntityEngineRoute = ( .post({ access: 'public', path: '/api/entity_store/engines/{entityType}/init', - options: { - tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/list.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/list.ts index 7cec67bcdf5cd..372331cea7087 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/list.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/list.ts @@ -22,8 +22,10 @@ export const listEntityEnginesRoute = ( .get({ access: 'public', path: '/api/entity_store/engines', - options: { - tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/privileges.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/privileges.ts new file mode 100644 index 0000000000000..bdc23dc76008d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/privileges.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { IKibanaResponse, Logger } from '@kbn/core/server'; +import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import type { EntityStoreGetPrivilegesResponse } from '../../../../../common/api/entity_analytics/entity_store/engine/get_privileges.gen'; +import { ENTITY_STORE_INTERNAL_PRIVILEGES_URL } from '../../../../../common/entity_analytics/entity_store/constants'; +import { APP_ID, API_VERSIONS } from '../../../../../common/constants'; + +import type { EntityAnalyticsRoutesDeps } from '../../types'; +import { AUDIT_CATEGORY, AUDIT_OUTCOME, AUDIT_TYPE } from '../../audit'; +import { getEntityStorePrivileges } from '../utils/get_entity_store_privileges'; +import { buildIndexPatterns } from '../utils'; + +export const entityStoreInternalPrivilegesRoute = ( + router: EntityAnalyticsRoutesDeps['router'], + logger: Logger, + getStartServices: EntityAnalyticsRoutesDeps['getStartServices'] +) => { + router.versioned + .get({ + access: 'internal', + path: ENTITY_STORE_INTERNAL_PRIVILEGES_URL, + options: { + tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + }, + }) + .addVersion( + { + version: API_VERSIONS.internal.v1, + validate: false, + }, + async ( + context, + request, + response + ): Promise> => { + const siemResponse = buildSiemResponse(response); + try { + const [_, { security }] = await getStartServices(); + const { getSpaceId, getAppClient, getDataViewsService } = await context.securitySolution; + + const securitySolution = await context.securitySolution; + securitySolution.getAuditLogger()?.log({ + message: 'User checked if they have the required privileges to use the Entity Store', + event: { + action: `entity_store_privilege_get`, + category: AUDIT_CATEGORY.AUTHENTICATION, + type: AUDIT_TYPE.ACCESS, + outcome: AUDIT_OUTCOME.UNKNOWN, + }, + }); + + const securitySolutionIndices = await buildIndexPatterns( + getSpaceId(), + getAppClient(), + getDataViewsService() + ); + const body = await getEntityStorePrivileges(request, security, securitySolutionIndices); + + return response.ok({ body }); + } catch (e) { + const error = transformError(e); + + return siemResponse.error({ + statusCode: error.statusCode, + body: error.message, + }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/register_entity_store_routes.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/register_entity_store_routes.ts index 20b6d92d8f0ff..9784dcd619667 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/register_entity_store_routes.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/register_entity_store_routes.ts @@ -12,6 +12,7 @@ import { listEntitiesRoute } from './entities/list'; import { getEntityEngineRoute } from './get'; import { initEntityEngineRoute } from './init'; import { listEntityEnginesRoute } from './list'; +import { entityStoreInternalPrivilegesRoute } from './privileges'; import { startEntityEngineRoute } from './start'; import { stopEntityEngineRoute } from './stop'; @@ -29,4 +30,5 @@ export const registerEntityStoreRoutes = ({ listEntityEnginesRoute(router, logger); listEntitiesRoute(router, logger); applyDataViewIndicesEntityEngineRoute(router, logger); + entityStoreInternalPrivilegesRoute(router, logger, getStartServices); }; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/start.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/start.ts index 1872de211cb8f..2985553e874c1 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/start.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/start.ts @@ -24,8 +24,10 @@ export const startEntityEngineRoute = ( .post({ access: 'public', path: '/api/entity_store/engines/{entityType}/start', - options: { - tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/stats.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/stats.ts index 9ca3cd906f016..24785fbd5c015 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/stats.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/stats.ts @@ -23,8 +23,10 @@ export const getEntityEngineStatsRoute = ( .post({ access: 'public', path: '/api/entity_store/engines/{entityType}/stats', - options: { - tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/stop.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/stop.ts index e1c28bc2cc073..0ba0b008a731c 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/stop.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/stop.ts @@ -24,8 +24,10 @@ export const stopEntityEngineRoute = ( .post({ access: 'public', path: '/api/entity_store/engines/{entityType}/stop', - options: { - tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, }, }) .addVersion( @@ -47,7 +49,7 @@ export const stopEntityEngineRoute = ( return response.ok({ body: { stopped: engine.status === ENGINE_STATUS.STOPPED } }); } catch (e) { - logger.error('Error in StopEntityEngine:', e); + logger.error(`Error in StopEntityEngine: ${e.message}`); const error = transformError(e); return siemResponse.error({ statusCode: error.statusCode, diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/saved_object/engine_descriptor.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/saved_object/engine_descriptor.ts index af7b4ba80dde5..cfaea1b1da0ff 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/saved_object/engine_descriptor.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/saved_object/engine_descriptor.ts @@ -78,17 +78,21 @@ export class EngineDescriptorClient { return attributes; } - async update(entityType: EntityType, status: EngineStatus) { + async update(entityType: EntityType, engine: Partial) { const id = this.getSavedObjectId(entityType); const { attributes } = await this.deps.soClient.update( entityEngineDescriptorTypeName, id, - { status }, + engine, { refresh: 'wait_for' } ); return attributes; } + async updateStatus(entityType: EntityType, status: EngineStatus) { + return this.update(entityType, { status }); + } + async find(entityType: EntityType): Promise> { return this.deps.soClient.find({ type: entityEngineDescriptorTypeName, diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/task/field_retention_enrichment_task.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/task/field_retention_enrichment_task.ts index d008c3afe6f17..708b74277faae 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/task/field_retention_enrichment_task.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/task/field_retention_enrichment_task.ts @@ -6,6 +6,7 @@ */ import moment from 'moment'; +import type { AnalyticsServiceSetup } from '@kbn/core/server'; import { type Logger, SavedObjectsErrorHelpers } from '@kbn/core/server'; import type { ConcreteTaskInstance, @@ -26,15 +27,21 @@ import { } from '../united_entity_definitions'; import { executeFieldRetentionEnrichPolicy } from '../elasticsearch_assets'; +import { getEntitiesIndexName } from '../utils'; +import { + FIELD_RETENTION_ENRICH_POLICY_EXECUTION_EVENT, + ENTITY_STORE_USAGE_EVENT, +} from '../../../telemetry/event_based/events'; + const logFactory = (logger: Logger, taskId: string) => (message: string): void => - logger.info(`[task ${taskId}]: ${message}`); + logger.info(`[Entity Store] [task ${taskId}]: ${message}`); const debugLogFactory = (logger: Logger, taskId: string) => (message: string): void => - logger.debug(`[task ${taskId}]: ${message}`); + logger.debug(`[Entity Store] [task ${taskId}]: ${message}`); const getTaskName = (): string => TYPE; @@ -44,18 +51,23 @@ type ExecuteEnrichPolicy = ( namespace: string, entityType: EntityType ) => ReturnType; +type GetStoreSize = (index: string | string[]) => Promise; export const registerEntityStoreFieldRetentionEnrichTask = ({ getStartServices, logger, + telemetry, taskManager, }: { getStartServices: EntityAnalyticsRoutesDeps['getStartServices']; logger: Logger; + telemetry: AnalyticsServiceSetup; taskManager: TaskManagerSetupContract | undefined; }): void => { if (!taskManager) { - logger.info('Task Manager is unavailable; skipping entity store enrich policy registration.'); + logger.info( + '[Entity Store] Task Manager is unavailable; skipping entity store enrich policy registration.' + ); return; } @@ -75,6 +87,14 @@ export const registerEntityStoreFieldRetentionEnrichTask = ({ }); }; + const getStoreSize: GetStoreSize = async (index) => { + const [coreStart] = await getStartServices(); + const esClient = coreStart.elasticsearch.client.asInternalUser; + + const { count } = await esClient.count({ index }); + return count; + }; + taskManager.registerTaskDefinitions({ [getTaskName()]: { title: 'Entity Analytics Entity Store - Execute Enrich Policy Task', @@ -82,6 +102,8 @@ export const registerEntityStoreFieldRetentionEnrichTask = ({ stateSchemaByVersion, createTaskRunner: createTaskRunnerFactory({ logger, + telemetry, + getStoreSize, executeEnrichPolicy, }), }, @@ -114,7 +136,7 @@ export const startEntityStoreFieldRetentionEnrichTask = async ({ params: { version: VERSION }, }); } catch (e) { - logger.warn(`[task ${taskId}]: error scheduling task, received ${e.message}`); + logger.warn(`[Entity Store] [task ${taskId}]: error scheduling task, received ${e.message}`); throw e; } }; @@ -130,9 +152,14 @@ export const removeEntityStoreFieldRetentionEnrichTask = async ({ }) => { try { await taskManager.remove(getTaskId(namespace)); + logger.info( + `[Entity Store] Removed entity store enrich policy task for namespace ${namespace}` + ); } catch (err) { if (!SavedObjectsErrorHelpers.isNotFoundError(err)) { - logger.error(`Failed to remove entity store enrich policy task: ${err.message}`); + logger.error( + `[Entity Store] Failed to remove entity store enrich policy task: ${err.message}` + ); throw err; } } @@ -140,14 +167,18 @@ export const removeEntityStoreFieldRetentionEnrichTask = async ({ export const runTask = async ({ executeEnrichPolicy, + getStoreSize, isCancelled, logger, taskInstance, + telemetry, }: { logger: Logger; isCancelled: () => boolean; executeEnrichPolicy: ExecuteEnrichPolicy; + getStoreSize: GetStoreSize; taskInstance: ConcreteTaskInstance; + telemetry: AnalyticsServiceSetup; }): Promise<{ state: EntityStoreFieldRetentionTaskState; }> => { @@ -171,13 +202,14 @@ export const runTask = async ({ } const entityTypes = getAvailableEntityTypes(); + for (const entityType of entityTypes) { const start = Date.now(); debugLog(`executing field retention enrich policy for ${entityType}`); try { const { executed } = await executeEnrichPolicy(state.namespace, entityType); if (!executed) { - debugLog(`Field retention encrich policy for ${entityType} does not exist`); + debugLog(`Field retention enrich policy for ${entityType} does not exist`); } else { log( `Executed field retention enrich policy for ${entityType} in ${Date.now() - start}ms` @@ -192,17 +224,39 @@ export const runTask = async ({ const taskDurationInSeconds = moment(taskCompletionTime).diff(moment(taskStartTime), 'seconds'); log(`task run completed in ${taskDurationInSeconds} seconds`); + telemetry.reportEvent(FIELD_RETENTION_ENRICH_POLICY_EXECUTION_EVENT.eventType, { + duration: taskDurationInSeconds, + interval: INTERVAL, + }); + + // Track entity store usage + const indices = entityTypes.map((entityType) => + getEntitiesIndexName(entityType, state.namespace) + ); + const storeSize = await getStoreSize(indices); + telemetry.reportEvent(ENTITY_STORE_USAGE_EVENT.eventType, { storeSize }); + return { state: updatedState, }; } catch (e) { - logger.error(`[task ${taskId}]: error running task, received ${e.message}`); + logger.error(`[Entity Store] [task ${taskId}]: error running task, received ${e.message}`); throw e; } }; const createTaskRunnerFactory = - ({ logger, executeEnrichPolicy }: { logger: Logger; executeEnrichPolicy: ExecuteEnrichPolicy }) => + ({ + logger, + telemetry, + executeEnrichPolicy, + getStoreSize, + }: { + logger: Logger; + telemetry: AnalyticsServiceSetup; + executeEnrichPolicy: ExecuteEnrichPolicy; + getStoreSize: GetStoreSize; + }) => ({ taskInstance }: { taskInstance: ConcreteTaskInstance }) => { let cancelled = false; const isCancelled = () => cancelled; @@ -210,9 +264,11 @@ const createTaskRunnerFactory = run: async () => runTask({ executeEnrichPolicy, + getStoreSize, isCancelled, logger, taskInstance, + telemetry, }), cancel: async () => { cancelled = true; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/types.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/types.ts index e5f1e6db36bca..b71380b2e0677 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/types.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/types.ts @@ -7,6 +7,7 @@ import type { HostEntity, UserEntity } from '../../../../common/api/entity_analytics'; import type { CriticalityValues } from '../asset_criticality/constants'; +import type { EntityAnalyticsConfig } from '../types'; export interface HostEntityRecord extends Omit { asset?: { @@ -24,3 +25,5 @@ export interface UserEntityRecord extends Omit { * It represents the data stored in the entity store index. */ export type EntityRecord = HostEntityRecord | UserEntityRecord; + +export type EntityStoreConfig = EntityAnalyticsConfig['entityStore']; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/united_entity_definitions/constants.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/united_entity_definitions/constants.ts index 9f9dbb836498d..43730fa06357a 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/united_entity_definitions/constants.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/united_entity_definitions/constants.ts @@ -15,10 +15,10 @@ export const BASE_ENTITY_INDEX_MAPPING: MappingProperties = { type: 'keyword', }, 'entity.name': { - type: 'text', + type: 'keyword', fields: { text: { - type: 'keyword', + type: 'match_only_text', }, }, }, diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/united_entity_definitions/entity_types/host.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/united_entity_definitions/entity_types/host.ts index db9266997743e..5abeffd05b1f1 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/united_entity_definitions/entity_types/host.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/united_entity_definitions/entity_types/host.ts @@ -18,7 +18,17 @@ export const getHostUnitedDefinition: UnitedDefinitionBuilder = (fieldHistoryLen collect({ field: 'host.domain' }), collect({ field: 'host.hostname' }), collect({ field: 'host.id' }), - collect({ field: 'host.os.name' }), + collect({ + field: 'host.os.name', + mapping: { + type: 'keyword', + fields: { + text: { + type: 'match_only_text', + }, + }, + }, + }), collect({ field: 'host.os.type' }), collect({ field: 'host.ip', diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/united_entity_definitions/entity_types/user.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/united_entity_definitions/entity_types/user.ts index 632d1a685b992..3881425df77ce 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/united_entity_definitions/entity_types/user.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/united_entity_definitions/entity_types/user.ts @@ -16,7 +16,17 @@ export const getUserUnitedDefinition: UnitedDefinitionBuilder = (fieldHistoryLen fields: [ collect({ field: 'user.domain' }), collect({ field: 'user.email' }), - collect({ field: 'user.full_name' }), + collect({ + field: 'user.full_name', + mapping: { + type: 'keyword', + fields: { + text: { + type: 'match_only_text', + }, + }, + }, + }), collect({ field: 'user.hash' }), collect({ field: 'user.id' }), collect({ field: 'user.roles' }), diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/united_entity_definitions/get_united_definition.test.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/united_entity_definitions/get_united_definition.test.ts index d9c54e1fcd288..07c011b4791e6 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/united_entity_definitions/get_united_definition.test.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/united_entity_definitions/get_united_definition.test.ts @@ -15,6 +15,8 @@ describe('getUnitedEntityDefinition', () => { namespace: 'test', fieldHistoryLength: 10, indexPatterns, + syncDelay: '1m', + frequency: '1m', }); it('mapping', () => { @@ -30,10 +32,10 @@ describe('getUnitedEntityDefinition', () => { "entity.name": Object { "fields": Object { "text": Object { - "type": "keyword", + "type": "match_only_text", }, }, - "type": "text", + "type": "keyword", }, "entity.source": Object { "type": "keyword", @@ -57,9 +59,19 @@ describe('getUnitedEntityDefinition', () => { "type": "keyword", }, "host.name": Object { + "fields": Object { + "text": Object { + "type": "match_only_text", + }, + }, "type": "keyword", }, "host.os.name": Object { + "fields": Object { + "text": Object { + "type": "match_only_text", + }, + }, "type": "keyword", }, "host.os.type": Object { @@ -172,6 +184,10 @@ describe('getUnitedEntityDefinition', () => { ], "latest": Object { "lookbackPeriod": "24h", + "settings": Object { + "frequency": "1m", + "syncDelay": "1m", + }, "timestampField": "@timestamp", }, "managed": true, @@ -312,6 +328,8 @@ describe('getUnitedEntityDefinition', () => { namespace: 'test', fieldHistoryLength: 10, indexPatterns, + syncDelay: '1m', + frequency: '1m', }); it('mapping', () => { @@ -327,10 +345,10 @@ describe('getUnitedEntityDefinition', () => { "entity.name": Object { "fields": Object { "text": Object { - "type": "keyword", + "type": "match_only_text", }, }, - "type": "text", + "type": "keyword", }, "entity.source": Object { "type": "keyword", @@ -342,6 +360,11 @@ describe('getUnitedEntityDefinition', () => { "type": "keyword", }, "user.full_name": Object { + "fields": Object { + "text": Object { + "type": "match_only_text", + }, + }, "type": "keyword", }, "user.hash": Object { @@ -351,6 +374,11 @@ describe('getUnitedEntityDefinition', () => { "type": "keyword", }, "user.name": Object { + "fields": Object { + "text": Object { + "type": "match_only_text", + }, + }, "type": "keyword", }, "user.risk.calculated_level": Object { @@ -445,6 +473,10 @@ describe('getUnitedEntityDefinition', () => { ], "latest": Object { "lookbackPeriod": "24h", + "settings": Object { + "frequency": "1m", + "syncDelay": "1m", + }, "timestampField": "@timestamp", }, "managed": true, diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/united_entity_definitions/get_united_definition.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/united_entity_definitions/get_united_definition.ts index 32cb52a61d469..ba4963d5fea0a 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/united_entity_definitions/get_united_definition.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/united_entity_definitions/get_united_definition.ts @@ -25,6 +25,8 @@ interface Options { namespace: string; fieldHistoryLength: number; indexPatterns: string[]; + syncDelay: string; + frequency: string; } export const getUnitedEntityDefinition = memoize( @@ -33,6 +35,8 @@ export const getUnitedEntityDefinition = memoize( namespace, fieldHistoryLength, indexPatterns, + syncDelay, + frequency, }: Options): UnitedEntityDefinition => { const unitedDefinition = unitedDefinitionBuilders[entityType](fieldHistoryLength); @@ -47,6 +51,8 @@ export const getUnitedEntityDefinition = memoize( ...unitedDefinition, namespace, indexPatterns, + syncDelay, + frequency, }); } ); diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/united_entity_definitions/united_entity_definition.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/united_entity_definitions/united_entity_definition.ts index c5315c5dca2b0..fc7430ebb1806 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/united_entity_definitions/united_entity_definition.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/united_entity_definitions/united_entity_definition.ts @@ -7,7 +7,7 @@ import { entityDefinitionSchema, type EntityDefinition } from '@kbn/entities-schema'; import type { MappingTypeMapping } from '@elastic/elasticsearch/lib/api/types'; import type { EntityType } from '../../../../../common/api/entity_analytics/entity_store/common.gen'; -import { DEFAULT_INTERVAL, DEFAULT_LOOKBACK_PERIOD } from '../constants'; +import { DEFAULT_LOOKBACK_PERIOD } from '../constants'; import { buildEntityDefinitionId, getIdentityFieldForEntityType } from '../utils'; import type { FieldRetentionDefinition, @@ -25,6 +25,8 @@ export class UnitedEntityDefinition { entityManagerDefinition: EntityDefinition; fieldRetentionDefinition: FieldRetentionDefinition; indexMappings: MappingTypeMapping; + syncDelay: string; + frequency: string; constructor(opts: { version: string; @@ -32,11 +34,15 @@ export class UnitedEntityDefinition { indexPatterns: string[]; fields: UnitedDefinitionField[]; namespace: string; + syncDelay: string; + frequency: string; }) { this.version = opts.version; this.entityType = opts.entityType; this.indexPatterns = opts.indexPatterns; this.fields = opts.fields; + this.frequency = opts.frequency; + this.syncDelay = opts.syncDelay; this.namespace = opts.namespace; this.entityManagerDefinition = this.toEntityManagerDefinition(); this.fieldRetentionDefinition = this.toFieldRetentionDefinition(); @@ -44,7 +50,7 @@ export class UnitedEntityDefinition { } private toEntityManagerDefinition(): EntityDefinition { - const { entityType, namespace, indexPatterns } = this; + const { entityType, namespace, indexPatterns, syncDelay, frequency } = this; const identityField = getIdentityFieldForEntityType(this.entityType); const metadata = this.fields .filter((field) => field.definition) @@ -61,7 +67,10 @@ export class UnitedEntityDefinition { latest: { timestampField: '@timestamp', lookbackPeriod: DEFAULT_LOOKBACK_PERIOD, - interval: DEFAULT_INTERVAL, + settings: { + syncDelay, + frequency, + }, }, version: this.version, managed: true, @@ -85,6 +94,11 @@ export class UnitedEntityDefinition { ...BASE_ENTITY_INDEX_MAPPING, [identityField]: { type: 'keyword', + fields: { + text: { + type: 'match_only_text', + }, + }, }, }; @@ -98,6 +112,11 @@ export class UnitedEntityDefinition { properties[identityField] = { type: 'keyword', + fields: { + text: { + type: 'match_only_text', + }, + }, }; return { diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/utils/get_entity_store_privileges.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/utils/get_entity_store_privileges.ts new file mode 100644 index 0000000000000..3d5cf0691c519 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/utils/get_entity_store_privileges.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { KibanaRequest } from '@kbn/core/server'; +import type { SecurityPluginStart } from '@kbn/security-plugin/server'; +import { SO_ENTITY_DEFINITION_TYPE } from '@kbn/entityManager-plugin/server/saved_objects'; +import { RISK_SCORE_INDEX_PATTERN } from '../../../../../common/constants'; +import { + ENTITY_STORE_INDEX_PATTERN, + ENTITY_STORE_REQUIRED_ES_CLUSTER_PRIVILEGES, +} from '../../../../../common/entity_analytics/entity_store/constants'; +import { checkAndFormatPrivileges } from '../../utils/check_and_format_privileges'; +import { entityEngineDescriptorTypeName } from '../saved_object'; + +export const getEntityStorePrivileges = ( + request: KibanaRequest, + security: SecurityPluginStart, + securitySolutionIndices: string[] +) => { + // The entity store needs access to all security solution indices + const indicesPrivileges = securitySolutionIndices.reduce>( + (acc, index) => { + acc[index] = ['read', 'view_index_metadata']; + return acc; + }, + {} + ); + + // The entity store has to create the following indices + indicesPrivileges[ENTITY_STORE_INDEX_PATTERN] = ['read', 'manage']; + indicesPrivileges[RISK_SCORE_INDEX_PATTERN] = ['read', 'manage']; + + return checkAndFormatPrivileges({ + request, + security, + privilegesToCheck: { + kibana: [ + security.authz.actions.savedObject.get(entityEngineDescriptorTypeName, 'create'), + security.authz.actions.savedObject.get(SO_ENTITY_DEFINITION_TYPE, 'create'), + ], + elasticsearch: { + cluster: ENTITY_STORE_REQUIRED_ES_CLUSTER_PRIVILEGES, + index: indicesPrivileges, + }, + }, + }); +}; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/delete.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/delete.ts index 1776ddcca69b1..473627c9c7b09 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/delete.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/delete.ts @@ -23,8 +23,10 @@ export const riskEngineCleanupRoute = ( .delete({ access: 'public', path: RISK_ENGINE_CLEANUP_URL, - options: { - tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/disable.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/disable.ts index 59b4b4f77537e..fafa887d6fbb4 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/disable.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/disable.ts @@ -24,8 +24,10 @@ export const riskEngineDisableRoute = ( .post({ access: 'internal', path: RISK_ENGINE_DISABLE_URL, - options: { - tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/enable.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/enable.ts index 24b3c3816440d..ce86ac8f99bd6 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/enable.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/enable.ts @@ -24,8 +24,10 @@ export const riskEngineEnableRoute = ( .post({ access: 'internal', path: RISK_ENGINE_ENABLE_URL, - options: { - tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/init.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/init.ts index 4657d21cbcbe0..67edb78b740c5 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/init.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/init.ts @@ -26,8 +26,10 @@ export const riskEngineInitRoute = ( .post({ access: 'internal', path: RISK_ENGINE_INIT_URL, - options: { - tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/privileges.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/privileges.ts index f14e06fa72868..307da6980da50 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/privileges.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/privileges.ts @@ -24,8 +24,10 @@ export const riskEnginePrivilegesRoute = ( .get({ access: 'internal', path: RISK_ENGINE_PRIVILEGES_URL, - options: { - tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/risk_engine_privileges.mock.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/risk_engine_privileges.mock.ts index 189e72624c15c..a76fc2db4d669 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/risk_engine_privileges.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/risk_engine_privileges.mock.ts @@ -19,6 +19,7 @@ const createMockSecurityStartWithFullRiskEngineAccess = () => { 'index-name': ['read'], }, }, + kibana: [], }, }); @@ -39,6 +40,7 @@ const createMockSecurityStartWithNoRiskEngineAccess = () => { cluster: [], index: [], }, + kibana: [], }, }); diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/schedule_now.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/schedule_now.ts index 91f32954f6102..99ec60b281293 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/schedule_now.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/schedule_now.ts @@ -27,8 +27,10 @@ export const riskEngineScheduleNowRoute = ( .post({ access: 'public', path: RISK_ENGINE_SCHEDULE_NOW_URL, - options: { - tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/settings.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/settings.ts index e300f012b86cf..8073f1222302f 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/settings.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/settings.ts @@ -19,8 +19,10 @@ export const riskEngineSettingsRoute = (router: EntityAnalyticsRoutesDeps['route .get({ access: 'internal', path: RISK_ENGINE_SETTINGS_URL, - options: { - tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/status.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/status.ts index 9b69ddec6b005..5ece4cbf48e43 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/status.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/status.ts @@ -20,8 +20,10 @@ export const riskEngineStatusRoute = ( .get({ access: 'internal', path: RISK_ENGINE_STATUS_URL, - options: { - tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/entity_calculation.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/entity_calculation.ts index 4b1cf773a572b..8fe611721d323 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/entity_calculation.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/entity_calculation.ts @@ -166,8 +166,10 @@ export const deprecatedRiskScoreEntityCalculationRoute = ( .post({ path: '/api/risk_scores/calculation/entity', access: 'internal', - options: { - tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, }, }) .addVersion( @@ -192,8 +194,10 @@ export const riskScoreEntityCalculationRoute = ( .post({ path: RISK_SCORE_ENTITY_CALCULATION_URL, access: 'internal', - options: { - tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/preview.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/preview.ts index ae265d415288a..31fcf36ff46cc 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/preview.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/preview.ts @@ -30,8 +30,10 @@ export const riskScorePreviewRoute = ( .post({ access: 'internal', path: RISK_SCORE_PREVIEW_URL, - options: { - tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/utils/check_and_format_privileges.test.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/utils/check_and_format_privileges.test.ts index 04f4e95272116..6b2b806f1e408 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/utils/check_and_format_privileges.test.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/utils/check_and_format_privileges.test.ts @@ -54,6 +54,7 @@ describe('_formatPrivileges', () => { }, }, }, + kibana: {}, }); }); @@ -84,6 +85,7 @@ describe('_formatPrivileges', () => { monitor: true, }, }, + kibana: {}, }); }); @@ -145,6 +147,7 @@ describe('_formatPrivileges', () => { }, }, }, + kibana: {}, }); }); diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/utils/check_and_format_privileges.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/utils/check_and_format_privileges.ts index 713405b11d5e8..16b454828a381 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/utils/check_and_format_privileges.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/utils/check_and_format_privileges.ts @@ -29,6 +29,7 @@ export const _formatPrivileges = ( privileges: CheckPrivilegesResponse['privileges'] ): EntityAnalyticsPrivileges['privileges'] => { const clusterPrivilegesByPrivilege = groupPrivilegesByName(privileges.elasticsearch.cluster); + const kibanaPrivilegesByPrivilege = groupPrivilegesByName(privileges.kibana); const indexPrivilegesByIndex = Object.entries(privileges.elasticsearch.index).reduce< Record> @@ -50,13 +51,16 @@ export const _formatPrivileges = ( } : {}), }, + kibana: { + ...(Object.keys(kibanaPrivilegesByPrivilege).length > 0 ? kibanaPrivilegesByPrivilege : {}), + }, }; }; interface CheckAndFormatPrivilegesOpts { request: KibanaRequest; security: SecurityPluginStart; - privilegesToCheck: Pick; + privilegesToCheck: CheckPrivilegesPayload; } export async function checkAndFormatPrivileges({ diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/utils/create_or_update_index.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/utils/create_or_update_index.ts index f9525e14ac6c4..b6e49017c95a7 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/utils/create_or_update_index.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/utils/create_or_update_index.ts @@ -51,7 +51,16 @@ export const createOrUpdateIndex = async ({ ); } } else { - return esClient.indices.create(options); + try { + await esClient.indices.create(options); + } catch (err) { + // If the index already exists, we can ignore the error + if (err?.meta?.body?.error?.type === 'resource_already_exists_exception') { + logger.info(`${options.index} already exists`); + } else { + throw err; + } + } } } catch (err) { const error = transformError(err); diff --git a/x-pack/plugins/security_solution/server/lib/product_features_service/product_features_service.test.ts b/x-pack/plugins/security_solution/server/lib/product_features_service/product_features_service.test.ts index a1b71a9c4f04f..8d274a30ca3c9 100644 --- a/x-pack/plugins/security_solution/server/lib/product_features_service/product_features_service.test.ts +++ b/x-pack/plugins/security_solution/server/lib/product_features_service/product_features_service.test.ts @@ -23,6 +23,7 @@ import type { import { ProductFeatureKey } from '@kbn/security-solution-features/keys'; import { httpServiceMock } from '@kbn/core-http-server-mocks'; import type { + AuthzEnabled, KibanaRequest, LifecycleResponseFactory, OnPostAuthHandler, @@ -181,11 +182,6 @@ describe('ProductFeaturesService', () => { lastRegisteredFn = fn; }); - const getReq = (tags: string[] = []) => - ({ - route: { options: { tags } }, - url: { pathname: '', search: '' }, - } as unknown as KibanaRequest); const res = { notFound: jest.fn() } as unknown as LifecycleResponseFactory; const toolkit = httpServiceMock.createOnPostAuthToolkit(); @@ -204,93 +200,281 @@ describe('ProductFeaturesService', () => { expect(mockHttpSetup.registerOnPostAuth).toHaveBeenCalledTimes(1); }); - it('should authorize when no tag matches', async () => { - const experimentalFeatures = {} as ExperimentalFeatures; - const productFeaturesService = new ProductFeaturesService( - loggerMock.create(), - experimentalFeatures - ); - productFeaturesService.registerApiAccessControl(mockHttpSetup); - - await lastRegisteredFn(getReq(['access:something', 'access:securitySolution']), res, toolkit); - - expect(MockedProductFeatures.mock.instances[0].isActionRegistered).not.toHaveBeenCalled(); - expect(res.notFound).not.toHaveBeenCalled(); - expect(toolkit.next).toHaveBeenCalledTimes(1); - }); - - it('should check when tag matches and return not found when not action registered', async () => { - const experimentalFeatures = {} as ExperimentalFeatures; - const productFeaturesService = new ProductFeaturesService( - loggerMock.create(), - experimentalFeatures - ); - productFeaturesService.registerApiAccessControl(mockHttpSetup); - - (MockedProductFeatures.mock.instances[0].isActionRegistered as jest.Mock).mockReturnValueOnce( - false - ); - await lastRegisteredFn(getReq(['access:securitySolution-foo']), res, toolkit); - - expect(MockedProductFeatures.mock.instances[0].isActionRegistered).toHaveBeenCalledWith( - 'api:securitySolution-foo' - ); - expect(res.notFound).toHaveBeenCalledTimes(1); - expect(toolkit.next).not.toHaveBeenCalled(); - }); - - it('should check when tag matches and continue when action registered', async () => { - const experimentalFeatures = {} as ExperimentalFeatures; - const productFeaturesService = new ProductFeaturesService( - loggerMock.create(), - experimentalFeatures - ); - productFeaturesService.registerApiAccessControl(mockHttpSetup); - - (MockedProductFeatures.mock.instances[0].isActionRegistered as jest.Mock).mockReturnValueOnce( - true - ); - await lastRegisteredFn(getReq(['access:securitySolution-foo']), res, toolkit); - - expect(MockedProductFeatures.mock.instances[0].isActionRegistered).toHaveBeenCalledWith( - 'api:securitySolution-foo' - ); - expect(res.notFound).not.toHaveBeenCalled(); - expect(toolkit.next).toHaveBeenCalledTimes(1); - }); - - it('should check when productFeature tag when it matches and return not found when not enabled', async () => { - const experimentalFeatures = {} as ExperimentalFeatures; - const productFeaturesService = new ProductFeaturesService( - loggerMock.create(), - experimentalFeatures - ); - productFeaturesService.registerApiAccessControl(mockHttpSetup); - - productFeaturesService.isEnabled = jest.fn().mockReturnValueOnce(false); - - await lastRegisteredFn(getReq(['securitySolutionProductFeature:foo']), res, toolkit); - - expect(productFeaturesService.isEnabled).toHaveBeenCalledWith('foo'); - expect(res.notFound).toHaveBeenCalledTimes(1); - expect(toolkit.next).not.toHaveBeenCalled(); + describe('when using productFeature tag', () => { + const getReq = (tags: string[] = []) => + ({ + route: { options: { tags } }, + url: { pathname: '', search: '' }, + } as unknown as KibanaRequest); + + it('should check when productFeature tag when it matches and return not found when not enabled', async () => { + const experimentalFeatures = {} as ExperimentalFeatures; + const productFeaturesService = new ProductFeaturesService( + loggerMock.create(), + experimentalFeatures + ); + productFeaturesService.registerApiAccessControl(mockHttpSetup); + + productFeaturesService.isEnabled = jest.fn().mockReturnValueOnce(false); + + await lastRegisteredFn(getReq(['securitySolutionProductFeature:foo']), res, toolkit); + + expect(productFeaturesService.isEnabled).toHaveBeenCalledWith('foo'); + expect(res.notFound).toHaveBeenCalledTimes(1); + expect(toolkit.next).not.toHaveBeenCalled(); + }); + + it('should check when productFeature tag when it matches and continue when enabled', async () => { + const experimentalFeatures = {} as ExperimentalFeatures; + const productFeaturesService = new ProductFeaturesService( + loggerMock.create(), + experimentalFeatures + ); + productFeaturesService.registerApiAccessControl(mockHttpSetup); + + productFeaturesService.isEnabled = jest.fn().mockReturnValueOnce(true); + + await lastRegisteredFn(getReq(['securitySolutionProductFeature:foo']), res, toolkit); + + expect(productFeaturesService.isEnabled).toHaveBeenCalledWith('foo'); + expect(res.notFound).not.toHaveBeenCalled(); + expect(toolkit.next).toHaveBeenCalledTimes(1); + }); }); - it('should check when productFeature tag when it matches and continue when enabled', async () => { - const experimentalFeatures = {} as ExperimentalFeatures; - const productFeaturesService = new ProductFeaturesService( - loggerMock.create(), - experimentalFeatures - ); - productFeaturesService.registerApiAccessControl(mockHttpSetup); - - productFeaturesService.isEnabled = jest.fn().mockReturnValueOnce(true); - - await lastRegisteredFn(getReq(['securitySolutionProductFeature:foo']), res, toolkit); - - expect(productFeaturesService.isEnabled).toHaveBeenCalledWith('foo'); - expect(res.notFound).not.toHaveBeenCalled(); - expect(toolkit.next).toHaveBeenCalledTimes(1); + // Documentation: https://docs.elastic.dev/kibana-dev-docs/key-concepts/security-api-authorization + describe('when using authorization', () => { + let productFeaturesService: ProductFeaturesService; + let mockIsActionRegistered: jest.Mock; + + beforeEach(() => { + const experimentalFeatures = {} as ExperimentalFeatures; + productFeaturesService = new ProductFeaturesService( + loggerMock.create(), + experimentalFeatures + ); + productFeaturesService.registerApiAccessControl(mockHttpSetup); + mockIsActionRegistered = MockedProductFeatures.mock.instances[0] + .isActionRegistered as jest.Mock; + }); + + describe('when using access tag', () => { + const getReq = (tags: string[] = []) => + ({ + route: { options: { tags } }, + url: { pathname: '', search: '' }, + } as unknown as KibanaRequest); + + it('should authorize when no tag matches', async () => { + await lastRegisteredFn( + getReq(['access:something', 'access:securitySolution']), + res, + toolkit + ); + + expect(mockIsActionRegistered).not.toHaveBeenCalled(); + expect(res.notFound).not.toHaveBeenCalled(); + expect(toolkit.next).toHaveBeenCalledTimes(1); + }); + + it('should check when tag matches and return not found when not action registered', async () => { + mockIsActionRegistered.mockReturnValueOnce(false); + await lastRegisteredFn(getReq(['access:securitySolution-foo']), res, toolkit); + + expect(mockIsActionRegistered).toHaveBeenCalledWith('api:securitySolution-foo'); + expect(res.notFound).toHaveBeenCalledTimes(1); + expect(toolkit.next).not.toHaveBeenCalled(); + }); + + it('should check when tag matches and continue when action registered', async () => { + mockIsActionRegistered.mockReturnValueOnce(true); + await lastRegisteredFn(getReq(['access:securitySolution-foo']), res, toolkit); + + expect(mockIsActionRegistered).toHaveBeenCalledWith('api:securitySolution-foo'); + expect(res.notFound).not.toHaveBeenCalled(); + expect(toolkit.next).toHaveBeenCalledTimes(1); + }); + }); + + describe('when using security authz', () => { + beforeEach(() => { + mockIsActionRegistered.mockImplementation((action: string) => action.includes('enabled')); + }); + + const getReq = (requiredPrivileges?: AuthzEnabled['requiredPrivileges']) => + ({ + route: { options: { security: { authz: { requiredPrivileges } } } }, + url: { pathname: '', search: '' }, + } as unknown as KibanaRequest); + + it('should authorize when no privilege matches', async () => { + await lastRegisteredFn(getReq(['something', 'securitySolution']), res, toolkit); + + expect(mockIsActionRegistered).not.toHaveBeenCalled(); + expect(res.notFound).not.toHaveBeenCalled(); + expect(toolkit.next).toHaveBeenCalledTimes(1); + }); + + it('should check when privilege matches and return not found when not action registered', async () => { + await lastRegisteredFn(getReq(['securitySolution-disabled']), res, toolkit); + + expect(mockIsActionRegistered).toHaveBeenCalledWith('api:securitySolution-disabled'); + expect(res.notFound).toHaveBeenCalledTimes(1); + expect(toolkit.next).not.toHaveBeenCalled(); + }); + + it('should check when privilege matches and continue when action registered', async () => { + mockIsActionRegistered.mockReturnValueOnce(true); + await lastRegisteredFn(getReq(['securitySolution-enabled']), res, toolkit); + + expect(mockIsActionRegistered).toHaveBeenCalledWith('api:securitySolution-enabled'); + expect(res.notFound).not.toHaveBeenCalled(); + expect(toolkit.next).toHaveBeenCalledTimes(1); + }); + + it('should restrict access when one action is not registered', async () => { + mockIsActionRegistered.mockReturnValueOnce(true); + await lastRegisteredFn( + getReq([ + 'securitySolution-enabled', + 'securitySolution-disabled', + 'securitySolution-enabled2', + ]), + res, + toolkit + ); + + expect(mockIsActionRegistered).toHaveBeenCalledTimes(2); + expect(mockIsActionRegistered).toHaveBeenCalledWith('api:securitySolution-enabled'); + expect(mockIsActionRegistered).toHaveBeenCalledWith('api:securitySolution-disabled'); + + expect(res.notFound).toHaveBeenCalledTimes(1); + expect(toolkit.next).not.toHaveBeenCalled(); + }); + + describe('when using nested requiredPrivileges', () => { + describe('when using allRequired', () => { + it('should allow access when all actions are registered', async () => { + const req = getReq([ + { + allRequired: [ + 'securitySolution-enabled', + 'securitySolution-enabled2', + 'securitySolution-enabled3', + ], + }, + ]); + await lastRegisteredFn(req, res, toolkit); + + expect(mockIsActionRegistered).toHaveBeenCalledTimes(3); + expect(mockIsActionRegistered).toHaveBeenCalledWith('api:securitySolution-enabled'); + expect(mockIsActionRegistered).toHaveBeenCalledWith('api:securitySolution-enabled2'); + expect(mockIsActionRegistered).toHaveBeenCalledWith('api:securitySolution-enabled3'); + + expect(res.notFound).not.toHaveBeenCalled(); + expect(toolkit.next).toHaveBeenCalledTimes(1); + }); + + it('should restrict access if one action is not registered', async () => { + const req = getReq([ + { + allRequired: [ + 'securitySolution-enabled', + 'securitySolution-disabled', + 'securitySolution-notCalled', + ], + }, + ]); + await lastRegisteredFn(req, res, toolkit); + + expect(mockIsActionRegistered).toHaveBeenCalledTimes(2); + expect(mockIsActionRegistered).toHaveBeenCalledWith('api:securitySolution-enabled'); + expect(mockIsActionRegistered).toHaveBeenCalledWith('api:securitySolution-disabled'); + + expect(res.notFound).toHaveBeenCalledTimes(1); + expect(toolkit.next).not.toHaveBeenCalled(); + }); + + it('should allow only based on security privileges and ignore non-security', async () => { + const req = getReq([ + { allRequired: ['notSecurityPrivilege', 'securitySolution-enabled'] }, + ]); + await lastRegisteredFn(req, res, toolkit); + + expect(mockIsActionRegistered).toHaveBeenCalledTimes(1); + expect(mockIsActionRegistered).toHaveBeenCalledWith('api:securitySolution-enabled'); + + expect(res.notFound).not.toHaveBeenCalled(); + expect(toolkit.next).toHaveBeenCalledTimes(1); + }); + + it('should restrict only based on security privileges and ignore non-security', async () => { + const req = getReq([ + { allRequired: ['notSecurityPrivilege', 'securitySolution-disabled'] }, + ]); + await lastRegisteredFn(req, res, toolkit); + + expect(mockIsActionRegistered).toHaveBeenCalledTimes(1); + expect(mockIsActionRegistered).toHaveBeenCalledWith('api:securitySolution-disabled'); + + expect(res.notFound).toHaveBeenCalledTimes(1); + expect(toolkit.next).not.toHaveBeenCalled(); + }); + }); + + describe('when using anyRequired', () => { + it('should allow access when one action is registered', async () => { + const req = getReq([ + { + anyRequired: [ + 'securitySolution-disabled', + 'securitySolution-enabled', + 'securitySolution-notCalled', + ], + }, + ]); + await lastRegisteredFn(req, res, toolkit); + + expect(mockIsActionRegistered).toHaveBeenCalledTimes(2); + expect(mockIsActionRegistered).toHaveBeenCalledWith('api:securitySolution-disabled'); + expect(mockIsActionRegistered).toHaveBeenCalledWith('api:securitySolution-enabled'); + + expect(res.notFound).not.toHaveBeenCalled(); + expect(toolkit.next).toHaveBeenCalledTimes(1); + }); + + it('should restrict access when no action is registered', async () => { + const req = getReq([ + { + anyRequired: ['securitySolution-disabled', 'securitySolution-disabled2'], + }, + ]); + await lastRegisteredFn(req, res, toolkit); + + expect(mockIsActionRegistered).toHaveBeenCalledTimes(2); + expect(mockIsActionRegistered).toHaveBeenCalledWith('api:securitySolution-disabled'); + expect(mockIsActionRegistered).toHaveBeenCalledWith('api:securitySolution-disabled2'); + + expect(res.notFound).toHaveBeenCalledTimes(1); + expect(toolkit.next).not.toHaveBeenCalled(); + }); + + it('should restrict only based on security privileges and allow when non-security privilege is present', async () => { + const req = getReq([ + { + anyRequired: ['notSecurityPrivilege', 'securitySolution-disabled'], + }, + ]); + await lastRegisteredFn(req, res, toolkit); + + expect(mockIsActionRegistered).not.toHaveBeenCalled(); + + expect(res.notFound).not.toHaveBeenCalled(); + expect(toolkit.next).toHaveBeenCalledTimes(1); + }); + }); + }); + }); }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/product_features_service/product_features_service.ts b/x-pack/plugins/security_solution/server/lib/product_features_service/product_features_service.ts index 29ef513b40bb3..86928ff905545 100644 --- a/x-pack/plugins/security_solution/server/lib/product_features_service/product_features_service.ts +++ b/x-pack/plugins/security_solution/server/lib/product_features_service/product_features_service.ts @@ -11,7 +11,7 @@ * 2.0. */ -import type { HttpServiceSetup, Logger } from '@kbn/core/server'; +import type { AuthzEnabled, HttpServiceSetup, Logger, RouteAuthz } from '@kbn/core/server'; import { hiddenTypes as filesSavedObjectTypes } from '@kbn/files-plugin/server/saved_objects'; import type { FeaturesPluginSetup } from '@kbn/features-plugin/server'; import type { ProductFeatureKeyType } from '@kbn/security-solution-features'; @@ -21,6 +21,7 @@ import { getCasesFeature, getSecurityFeature, } from '@kbn/security-solution-features/product_features'; +import type { RecursiveReadonly } from '@kbn/utility-types'; import type { ExperimentalFeatures } from '../../../common'; import { APP_ID } from '../../../common'; import { ProductFeatures } from './product_features'; @@ -28,6 +29,9 @@ import type { ProductFeaturesConfigurator } from './types'; import { securityDefaultSavedObjects } from './security_saved_objects'; import { casesApiTags, casesUiCapabilities } from './cases_privileges'; +// The prefix ("securitySolution-") used by all the Security Solution API action privileges. +export const API_ACTION_PREFIX = `${APP_ID}-`; + export class ProductFeaturesService { private securityProductFeatures: ProductFeatures; private casesProductFeatures: ProductFeatures; @@ -116,8 +120,6 @@ export class ProductFeaturesService { return this.productFeatures.has(productFeatureKey); } - public getApiActionName = (apiPrivilege: string) => `api:${APP_ID}-${apiPrivilege}`; - public isActionRegistered(action: string) { return ( this.securityProductFeatures.isActionRegistered(action) || @@ -127,6 +129,9 @@ export class ProductFeaturesService { ); } + public getApiActionName = (apiPrivilege: string) => `api:${API_ACTION_PREFIX}${apiPrivilege}`; + + /** @deprecated Use security.authz.requiredPrivileges instead */ public isApiPrivilegeEnabled(apiPrivilege: string) { return this.isActionRegistered(this.getApiActionName(apiPrivilege)); } @@ -135,14 +140,24 @@ export class ProductFeaturesService { // The `securitySolutionProductFeature:` prefix is used for ProductFeature based control. // Should be used only by routes that do not need RBAC, only direct productFeature control. const APP_FEATURE_TAG_PREFIX = 'securitySolutionProductFeature:'; - // The "access:securitySolution-" prefix is used for API action based control. - // Should be used by routes that need RBAC, extending the `access:` role privilege check from the security plugin. - // An additional check is performed to ensure the privilege has been registered by the productFeature service, - // preventing full access (`*`) roles, such as superuser, from bypassing productFeature controls. + + /** @deprecated Use security.authz.requiredPrivileges instead */ const API_ACTION_TAG_PREFIX = `access:${APP_ID}-`; + const isAuthzEnabled = (authz?: RecursiveReadonly): authz is AuthzEnabled => { + return Boolean((authz as AuthzEnabled)?.requiredPrivileges); + }; + + /** Returns true only if the API privilege is a security action and is disabled */ + const isApiPrivilegeSecurityAndDisabled = (apiPrivilege: string): boolean => { + if (apiPrivilege.startsWith(API_ACTION_PREFIX)) { + return !this.isActionRegistered(`api:${apiPrivilege}`); + } + return false; + }; + http.registerOnPostAuth((request, response, toolkit) => { - for (const tag of request.route.options.tags) { + for (const tag of request.route.options.tags ?? []) { let isEnabled = true; if (tag.startsWith(APP_FEATURE_TAG_PREFIX)) { isEnabled = this.isEnabled( @@ -159,6 +174,36 @@ export class ProductFeaturesService { return response.notFound(); } } + + // This control ensures the action privileges have been registered by the productFeature service, + // preventing full access (`*`) roles, such as superuser, from bypassing productFeature controls. + const authz = request.route.options.security?.authz; + if (isAuthzEnabled(authz)) { + const disabled = authz.requiredPrivileges.some((privilegeEntry) => { + if (typeof privilegeEntry === 'object') { + if (privilegeEntry.allRequired) { + if (privilegeEntry.allRequired.some(isApiPrivilegeSecurityAndDisabled)) { + return true; + } + } + if (privilegeEntry.anyRequired) { + if (privilegeEntry.anyRequired.every(isApiPrivilegeSecurityAndDisabled)) { + return true; + } + } + return false; + } else { + return isApiPrivilegeSecurityAndDisabled(privilegeEntry); + } + }); + if (disabled) { + this.logger.warn( + `Accessing disabled route "${request.url.pathname}${request.url.search}": responding with 404` + ); + return response.notFound(); + } + } + return toolkit.next(); }); } diff --git a/x-pack/plugins/security_solution/server/lib/risk_score/index_status/index.ts b/x-pack/plugins/security_solution/server/lib/risk_score/index_status/index.ts index 79eef256f8e93..c53039367dfbe 100644 --- a/x-pack/plugins/security_solution/server/lib/risk_score/index_status/index.ts +++ b/x-pack/plugins/security_solution/server/lib/risk_score/index_status/index.ts @@ -18,8 +18,10 @@ export const getRiskScoreIndexStatusRoute = (router: SecuritySolutionPluginRoute .get({ access: 'internal', path: RISK_SCORE_INDEX_STATUS_API_URL, - options: { - tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/risk_score/indices/create_index_route.ts b/x-pack/plugins/security_solution/server/lib/risk_score/indices/create_index_route.ts index ef4aacf251ff4..d029d098b03bf 100644 --- a/x-pack/plugins/security_solution/server/lib/risk_score/indices/create_index_route.ts +++ b/x-pack/plugins/security_solution/server/lib/risk_score/indices/create_index_route.ts @@ -19,8 +19,10 @@ export const createEsIndexRoute = (router: SecuritySolutionPluginRouter, logger: .put({ access: 'internal', path: RISK_SCORE_CREATE_INDEX, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/risk_score/indices/delete_indices_route.ts b/x-pack/plugins/security_solution/server/lib/risk_score/indices/delete_indices_route.ts index 407e9e0a8f3e0..4e7f8ed0975f1 100644 --- a/x-pack/plugins/security_solution/server/lib/risk_score/indices/delete_indices_route.ts +++ b/x-pack/plugins/security_solution/server/lib/risk_score/indices/delete_indices_route.ts @@ -17,8 +17,10 @@ export const deleteEsIndicesRoute = (router: SecuritySolutionPluginRouter) => { .post({ access: 'internal', path: RISK_SCORE_DELETE_INDICES, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/risk_score/onboarding/routes/install_risk_scores.ts b/x-pack/plugins/security_solution/server/lib/risk_score/onboarding/routes/install_risk_scores.ts index de6675985fc00..342ae7a0c577b 100644 --- a/x-pack/plugins/security_solution/server/lib/risk_score/onboarding/routes/install_risk_scores.ts +++ b/x-pack/plugins/security_solution/server/lib/risk_score/onboarding/routes/install_risk_scores.ts @@ -21,8 +21,10 @@ export const installRiskScoresRoute = (router: SecuritySolutionPluginRouter, log .post({ access: 'internal', path: INTERNAL_RISK_SCORE_URL, - options: { - tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/risk_score/prebuilt_dev_tool_content/routes/read_prebuilt_dev_tool_content_route.ts b/x-pack/plugins/security_solution/server/lib/risk_score/prebuilt_dev_tool_content/routes/read_prebuilt_dev_tool_content_route.ts index 77553eca21d5c..81a7694ba79e9 100644 --- a/x-pack/plugins/security_solution/server/lib/risk_score/prebuilt_dev_tool_content/routes/read_prebuilt_dev_tool_content_route.ts +++ b/x-pack/plugins/security_solution/server/lib/risk_score/prebuilt_dev_tool_content/routes/read_prebuilt_dev_tool_content_route.ts @@ -53,8 +53,10 @@ export const readPrebuiltDevToolContentRoute = (router: SecuritySolutionPluginRo .get({ access: 'internal', path: DEV_TOOL_PREBUILT_CONTENT, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/risk_score/prebuilt_saved_objects/routes/create_prebuilt_saved_objects.ts b/x-pack/plugins/security_solution/server/lib/risk_score/prebuilt_saved_objects/routes/create_prebuilt_saved_objects.ts index a17669af734fe..2ccccc4bab787 100644 --- a/x-pack/plugins/security_solution/server/lib/risk_score/prebuilt_saved_objects/routes/create_prebuilt_saved_objects.ts +++ b/x-pack/plugins/security_solution/server/lib/risk_score/prebuilt_saved_objects/routes/create_prebuilt_saved_objects.ts @@ -24,8 +24,10 @@ export const createPrebuiltSavedObjectsRoute = ( .post({ access: 'internal', path: PREBUILT_SAVED_OBJECTS_BULK_CREATE, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/risk_score/prebuilt_saved_objects/routes/delete_prebuilt_saved_objects.ts b/x-pack/plugins/security_solution/server/lib/risk_score/prebuilt_saved_objects/routes/delete_prebuilt_saved_objects.ts index bd7ae03191ea5..7e772e710cc93 100644 --- a/x-pack/plugins/security_solution/server/lib/risk_score/prebuilt_saved_objects/routes/delete_prebuilt_saved_objects.ts +++ b/x-pack/plugins/security_solution/server/lib/risk_score/prebuilt_saved_objects/routes/delete_prebuilt_saved_objects.ts @@ -21,8 +21,10 @@ export const deletePrebuiltSavedObjectsRoute = (router: SecuritySolutionPluginRo .post({ access: 'internal', path: PREBUILT_SAVED_OBJECTS_BULK_DELETE, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/risk_score/stored_scripts/create_script_route.ts b/x-pack/plugins/security_solution/server/lib/risk_score/stored_scripts/create_script_route.ts index 573d1d30bcd28..85cd1fb9d1928 100644 --- a/x-pack/plugins/security_solution/server/lib/risk_score/stored_scripts/create_script_route.ts +++ b/x-pack/plugins/security_solution/server/lib/risk_score/stored_scripts/create_script_route.ts @@ -18,8 +18,10 @@ export const createStoredScriptRoute = (router: SecuritySolutionPluginRouter, lo .put({ access: 'internal', path: RISK_SCORE_CREATE_STORED_SCRIPT, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/risk_score/stored_scripts/delete_script_route.ts b/x-pack/plugins/security_solution/server/lib/risk_score/stored_scripts/delete_script_route.ts index 0d7ef94be2635..91bf387d55bd0 100644 --- a/x-pack/plugins/security_solution/server/lib/risk_score/stored_scripts/delete_script_route.ts +++ b/x-pack/plugins/security_solution/server/lib/risk_score/stored_scripts/delete_script_route.ts @@ -17,8 +17,10 @@ export const deleteStoredScriptRoute = (router: SecuritySolutionPluginRouter) => .delete({ access: 'internal', path: RISK_SCORE_DELETE_STORED_SCRIPT, - options: { - tags: ['access:securitySolution'], + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, }, }) .addVersion( diff --git a/x-pack/plugins/security_solution/server/lib/security_integrations/cribl/routes/index.ts b/x-pack/plugins/security_solution/server/lib/security_integrations/cribl/routes/index.ts index 02ea252440070..fc531525e4e89 100644 --- a/x-pack/plugins/security_solution/server/lib/security_integrations/cribl/routes/index.ts +++ b/x-pack/plugins/security_solution/server/lib/security_integrations/cribl/routes/index.ts @@ -17,6 +17,13 @@ export const getFleetManagedIndexTemplatesRoute = (router: IRouter) => { .addVersion( { version: '1', + security: { + authz: { + enabled: false, + reason: + 'This route delegates authorization of the current user to the Elasticsearch index template API.', + }, + }, validate: {}, }, async (context, _request, response) => { diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/__mocks__/mocks.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/__mocks__/mocks.ts index fcf119e19ece5..af961d48db5b1 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/__mocks__/mocks.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/__mocks__/mocks.ts @@ -7,18 +7,16 @@ import { createRuleMigrationClient } from '../rules/__mocks__/mocks'; -const createClient = () => ({ rules: createRuleMigrationClient() }); - export const mockSetup = jest.fn().mockResolvedValue(undefined); -export const mockCreateClient = jest.fn().mockReturnValue(createClient()); +export const mockCreateClient = jest.fn().mockReturnValue(createRuleMigrationClient()); export const mockStop = jest.fn(); export const siemMigrationsServiceMock = { create: () => jest.fn().mockImplementation(() => ({ setup: mockSetup, - createClient: mockCreateClient, + createRulesClient: mockCreateClient, stop: mockStop, })), - createClient: () => createClient(), + createRulesClient: () => createRuleMigrationClient(), }; diff --git a/x-pack/packages/security-solution/common/src/flyout/index.tsx b/x-pack/plugins/security_solution/server/lib/siem_migrations/__mocks__/siem_migrations_service.ts similarity index 66% rename from x-pack/packages/security-solution/common/src/flyout/index.tsx rename to x-pack/plugins/security_solution/server/lib/siem_migrations/__mocks__/siem_migrations_service.ts index e919538d497c6..659929d47570f 100644 --- a/x-pack/packages/security-solution/common/src/flyout/index.tsx +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/__mocks__/siem_migrations_service.ts @@ -5,6 +5,5 @@ * 2.0. */ -export * from './common/components'; -export * from './common/test_ids'; -export { HostRightPanel } from './panels'; +import { siemMigrationsServiceMock } from './mocks'; +export const SiemMigrationsService = siemMigrationsServiceMock.create(); diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/__mocks__/mocks.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/__mocks__/mocks.ts index 8233151f513e4..8811a54195e2b 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/__mocks__/mocks.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/__mocks__/mocks.ts @@ -5,17 +5,56 @@ * 2.0. */ -import type { SiemRuleMigrationsClient } from '../types'; - -export const createRuleMigrationClient = (): SiemRuleMigrationsClient => ({ +export const createRuleMigrationDataClient = jest.fn().mockImplementation(() => ({ create: jest.fn().mockResolvedValue({ success: true }), - search: jest.fn().mockResolvedValue([]), + getRules: jest.fn().mockResolvedValue([]), + takePending: jest.fn().mockResolvedValue([]), + saveFinished: jest.fn().mockResolvedValue({ success: true }), + saveError: jest.fn().mockResolvedValue({ success: true }), + releaseProcessing: jest.fn().mockResolvedValue({ success: true }), + releaseProcessable: jest.fn().mockResolvedValue({ success: true }), + getStats: jest.fn().mockResolvedValue({ + status: 'done', + rules: { + total: 1, + finished: 1, + processing: 0, + pending: 0, + failed: 0, + }, + }), + getAllStats: jest.fn().mockResolvedValue([]), +})); + +export const createRuleMigrationTaskClient = () => ({ + start: jest.fn().mockResolvedValue({ started: true }), + stop: jest.fn().mockResolvedValue({ stopped: true }), + getStats: jest.fn().mockResolvedValue({ + status: 'done', + rules: { + total: 1, + finished: 1, + processing: 0, + pending: 0, + failed: 0, + }, + }), + getAllStats: jest.fn().mockResolvedValue([]), }); +export const createRuleMigrationClient = () => ({ + data: createRuleMigrationDataClient(), + task: createRuleMigrationTaskClient(), +}); + +export const MockSiemRuleMigrationsClient = jest.fn().mockImplementation(createRuleMigrationClient); + export const mockSetup = jest.fn(); -export const mockGetClient = jest.fn().mockReturnValue(createRuleMigrationClient()); +export const mockCreateClient = jest.fn().mockReturnValue(createRuleMigrationClient()); +export const mockStop = jest.fn(); export const MockSiemRuleMigrationsService = jest.fn().mockImplementation(() => ({ setup: mockSetup, - getClient: mockGetClient, + createClient: mockCreateClient, + stop: mockStop, })); diff --git a/x-pack/packages/security-solution/common/src/cells/renderers/get.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/__mocks__/siem_rule_migrations_client.ts similarity index 66% rename from x-pack/packages/security-solution/common/src/cells/renderers/get.ts rename to x-pack/plugins/security_solution/server/lib/siem_migrations/rules/__mocks__/siem_rule_migrations_client.ts index 3102c520e31db..98032605ed233 100644 --- a/x-pack/packages/security-solution/common/src/cells/renderers/get.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/__mocks__/siem_rule_migrations_client.ts @@ -5,5 +5,5 @@ * 2.0. */ -export { getDiscoverCellRenderer } from './discover'; -export type { DiscoverCellRenderer } from './discover'; +import { MockSiemRuleMigrationsClient } from './mocks'; +export const SiemRuleMigrationsClient = MockSiemRuleMigrationsClient; 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 e2cf97dd094a9..e2505ca83beed 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,14 +8,11 @@ import type { IKibanaResponse, Logger } from '@kbn/core/server'; import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; import { v4 as uuidV4 } from 'uuid'; -import type { RuleMigration } from '../../../../../common/siem_migrations/model/rule_migration.gen'; import type { CreateRuleMigrationResponse } from '../../../../../common/siem_migrations/model/api/rules/rules_migration.gen'; import { CreateRuleMigrationRequestBody } from '../../../../../common/siem_migrations/model/api/rules/rules_migration.gen'; -import { - SIEM_RULE_MIGRATIONS_PATH, - SiemMigrationsStatus, -} from '../../../../../common/siem_migrations/constants'; +import { SIEM_RULE_MIGRATIONS_PATH } from '../../../../../common/siem_migrations/constants'; import type { SecuritySolutionPluginRouter } from '../../../../types'; +import type { CreateRuleMigrationInput } from '../data_stream/rule_migrations_data_client'; export const registerSiemRuleMigrationsCreateRoute = ( router: SecuritySolutionPluginRouter, @@ -25,7 +22,7 @@ export const registerSiemRuleMigrationsCreateRoute = ( .post({ path: SIEM_RULE_MIGRATIONS_PATH, access: 'internal', - options: { tags: ['access:securitySolution'] }, + security: { authz: { requiredPrivileges: ['securitySolution'] } }, }) .addVersion( { @@ -37,27 +34,22 @@ export const registerSiemRuleMigrationsCreateRoute = ( async (context, req, res): Promise> => { const originalRules = req.body; try { - const ctx = await context.resolve(['core', 'actions', 'securitySolution']); - - const siemMigrationClient = ctx.securitySolution.getSiemMigrationsClient(); + const ctx = await context.resolve(['securitySolution']); + const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient(); const migrationId = uuidV4(); - const timestamp = new Date().toISOString(); - const ruleMigrations = originalRules.map((originalRule) => ({ - '@timestamp': timestamp, + const ruleMigrations = originalRules.map((originalRule) => ({ migration_id: migrationId, original_rule: originalRule, - status: SiemMigrationsStatus.PENDING, })); - await siemMigrationClient.rules.create(ruleMigrations); + + await ruleMigrationsClient.data.create(ruleMigrations); return res.ok({ body: { migration_id: migrationId } }); } catch (err) { logger.error(err); - return res.badRequest({ - body: err.message, - }); + return res.badRequest({ body: err.message }); } } ); 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 new file mode 100644 index 0000000000000..0efb6706918f5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/get.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IKibanaResponse, Logger } from '@kbn/core/server'; +import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; +import type { GetRuleMigrationResponse } from '../../../../../common/siem_migrations/model/api/rules/rules_migration.gen'; +import { GetRuleMigrationRequestParams } from '../../../../../common/siem_migrations/model/api/rules/rules_migration.gen'; +import { SIEM_RULE_MIGRATIONS_GET_PATH } from '../../../../../common/siem_migrations/constants'; +import type { SecuritySolutionPluginRouter } from '../../../../types'; + +export const registerSiemRuleMigrationsGetRoute = ( + router: SecuritySolutionPluginRouter, + logger: Logger +) => { + router.versioned + .get({ + path: SIEM_RULE_MIGRATIONS_GET_PATH, + access: 'internal', + security: { authz: { requiredPrivileges: ['securitySolution'] } }, + }) + .addVersion( + { + version: '1', + validate: { + request: { params: buildRouteValidationWithZod(GetRuleMigrationRequestParams) }, + }, + }, + async (context, req, res): Promise> => { + const migrationId = req.params.migration_id; + try { + const ctx = await context.resolve(['securitySolution']); + const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient(); + + const migrationRules = await ruleMigrationsClient.data.getRules(migrationId); + + return res.ok({ body: migrationRules }); + } catch (err) { + logger.error(err); + return res.badRequest({ body: err.message }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/index.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/index.ts index 0de49eb7df92b..f37eb2108a8a4 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/index.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/index.ts @@ -8,10 +8,20 @@ import type { Logger } from '@kbn/core/server'; import type { SecuritySolutionPluginRouter } from '../../../../types'; import { registerSiemRuleMigrationsCreateRoute } from './create'; +import { registerSiemRuleMigrationsGetRoute } from './get'; +import { registerSiemRuleMigrationsStartRoute } from './start'; +import { registerSiemRuleMigrationsStatsRoute } from './stats'; +import { registerSiemRuleMigrationsStopRoute } from './stop'; +import { registerSiemRuleMigrationsStatsAllRoute } from './stats_all'; export const registerSiemRuleMigrationsRoutes = ( router: SecuritySolutionPluginRouter, logger: Logger ) => { registerSiemRuleMigrationsCreateRoute(router, logger); + registerSiemRuleMigrationsStatsAllRoute(router, logger); + registerSiemRuleMigrationsGetRoute(router, logger); + registerSiemRuleMigrationsStartRoute(router, logger); + registerSiemRuleMigrationsStatsRoute(router, logger); + registerSiemRuleMigrationsStopRoute(router, logger); }; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/start.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/start.ts new file mode 100644 index 0000000000000..f97a4f2ce2398 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/start.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IKibanaResponse, Logger } from '@kbn/core/server'; +import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; +import { APMTracer } from '@kbn/langchain/server/tracers/apm'; +import { getLangSmithTracer } from '@kbn/langchain/server/tracers/langsmith'; +import type { StartRuleMigrationResponse } from '../../../../../common/siem_migrations/model/api/rules/rules_migration.gen'; +import { + StartRuleMigrationRequestBody, + StartRuleMigrationRequestParams, +} from '../../../../../common/siem_migrations/model/api/rules/rules_migration.gen'; +import { SIEM_RULE_MIGRATIONS_START_PATH } from '../../../../../common/siem_migrations/constants'; +import type { SecuritySolutionPluginRouter } from '../../../../types'; + +export const registerSiemRuleMigrationsStartRoute = ( + router: SecuritySolutionPluginRouter, + logger: Logger +) => { + router.versioned + .put({ + path: SIEM_RULE_MIGRATIONS_START_PATH, + access: 'internal', + security: { authz: { requiredPrivileges: ['securitySolution'] } }, + }) + .addVersion( + { + version: '1', + validate: { + request: { + params: buildRouteValidationWithZod(StartRuleMigrationRequestParams), + body: buildRouteValidationWithZod(StartRuleMigrationRequestBody), + }, + }, + }, + async (context, req, res): Promise> => { + const migrationId = req.params.migration_id; + const { langsmith_options: langsmithOptions, connector_id: connectorId } = req.body; + + try { + const ctx = await context.resolve([ + 'core', + 'actions', + 'alerting', + 'securitySolution', + 'licensing', + ]); + if (!ctx.licensing.license.hasAtLeast('enterprise')) { + return res.forbidden({ + body: 'You must have a trial or enterprise license to use this feature', + }); + } + + const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient(); + const inferenceClient = ctx.securitySolution.getInferenceClient(); + const actionsClient = ctx.actions.getActionsClient(); + const soClient = ctx.core.savedObjects.client; + const rulesClient = ctx.alerting.getRulesClient(); + + const invocationConfig = { + callbacks: [ + new APMTracer({ projectName: langsmithOptions?.project_name ?? 'default' }, logger), + ...getLangSmithTracer({ ...langsmithOptions, logger }), + ], + }; + + const { exists, started } = await ruleMigrationsClient.task.start({ + migrationId, + connectorId, + invocationConfig, + inferenceClient, + actionsClient, + soClient, + rulesClient, + }); + + if (!exists) { + return res.noContent(); + } + return res.ok({ body: { started } }); + } catch (err) { + logger.error(err); + return res.badRequest({ body: err.message }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/stats.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/stats.ts new file mode 100644 index 0000000000000..8316e01fc6a9b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/stats.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IKibanaResponse, Logger } from '@kbn/core/server'; +import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; +import type { GetRuleMigrationStatsResponse } from '../../../../../common/siem_migrations/model/api/rules/rules_migration.gen'; +import { GetRuleMigrationStatsRequestParams } from '../../../../../common/siem_migrations/model/api/rules/rules_migration.gen'; +import { SIEM_RULE_MIGRATIONS_STATS_PATH } from '../../../../../common/siem_migrations/constants'; +import type { SecuritySolutionPluginRouter } from '../../../../types'; + +export const registerSiemRuleMigrationsStatsRoute = ( + router: SecuritySolutionPluginRouter, + logger: Logger +) => { + router.versioned + .get({ + path: SIEM_RULE_MIGRATIONS_STATS_PATH, + access: 'internal', + security: { authz: { requiredPrivileges: ['securitySolution'] } }, + }) + .addVersion( + { + version: '1', + validate: { + request: { params: buildRouteValidationWithZod(GetRuleMigrationStatsRequestParams) }, + }, + }, + async (context, req, res): Promise> => { + const migrationId = req.params.migration_id; + try { + const ctx = await context.resolve(['securitySolution']); + const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient(); + + const stats = await ruleMigrationsClient.task.getStats(migrationId); + + return res.ok({ body: stats }); + } catch (err) { + logger.error(err); + return res.badRequest({ body: err.message }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/stats_all.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/stats_all.ts new file mode 100644 index 0000000000000..dd2f2f503e19d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/stats_all.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IKibanaResponse, Logger } from '@kbn/core/server'; +import type { GetAllStatsRuleMigrationResponse } from '../../../../../common/siem_migrations/model/api/rules/rules_migration.gen'; +import { SIEM_RULE_MIGRATIONS_ALL_STATS_PATH } from '../../../../../common/siem_migrations/constants'; +import type { SecuritySolutionPluginRouter } from '../../../../types'; + +export const registerSiemRuleMigrationsStatsAllRoute = ( + router: SecuritySolutionPluginRouter, + logger: Logger +) => { + router.versioned + .get({ + path: SIEM_RULE_MIGRATIONS_ALL_STATS_PATH, + access: 'internal', + security: { authz: { requiredPrivileges: ['securitySolution'] } }, + }) + .addVersion( + { version: '1', validate: {} }, + async (context, req, res): Promise> => { + try { + const ctx = await context.resolve(['securitySolution']); + const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient(); + + const allStats = await ruleMigrationsClient.task.getAllStats(); + + return res.ok({ body: allStats }); + } catch (err) { + logger.error(err); + return res.badRequest({ body: err.message }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/stop.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/stop.ts new file mode 100644 index 0000000000000..4767106910186 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/stop.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { IKibanaResponse, Logger } from '@kbn/core/server'; +import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; +import type { StopRuleMigrationResponse } from '../../../../../common/siem_migrations/model/api/rules/rules_migration.gen'; +import { StopRuleMigrationRequestParams } from '../../../../../common/siem_migrations/model/api/rules/rules_migration.gen'; +import { SIEM_RULE_MIGRATIONS_STOP_PATH } from '../../../../../common/siem_migrations/constants'; +import type { SecuritySolutionPluginRouter } from '../../../../types'; + +export const registerSiemRuleMigrationsStopRoute = ( + router: SecuritySolutionPluginRouter, + logger: Logger +) => { + router.versioned + .put({ + path: SIEM_RULE_MIGRATIONS_STOP_PATH, + access: 'internal', + security: { authz: { requiredPrivileges: ['securitySolution'] } }, + }) + .addVersion( + { + version: '1', + validate: { + request: { params: buildRouteValidationWithZod(StopRuleMigrationRequestParams) }, + }, + }, + async (context, req, res): Promise> => { + const migrationId = req.params.migration_id; + try { + const ctx = await context.resolve(['securitySolution']); + const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient(); + + const { exists, stopped } = await ruleMigrationsClient.task.stop(migrationId); + + if (!exists) { + return res.noContent(); + } + return res.ok({ body: { stopped } }); + } catch (err) { + logger.error(err); + return res.badRequest({ body: err.message }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/__mocks__/mocks.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/__mocks__/mocks.ts index 103c0f9b0c952..1d9a181d2de5b 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/__mocks__/mocks.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/__mocks__/mocks.ts @@ -7,9 +7,9 @@ export const mockIndexName = 'mocked_data_stream_name'; export const mockInstall = jest.fn().mockResolvedValue(undefined); -export const mockInstallSpace = jest.fn().mockResolvedValue(mockIndexName); +export const mockCreateClient = jest.fn().mockReturnValue({}); export const MockRuleMigrationsDataStream = jest.fn().mockImplementation(() => ({ install: mockInstall, - installSpace: mockInstallSpace, + createClient: mockCreateClient, })); diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/rule_migrations_data_client.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/rule_migrations_data_client.ts new file mode 100644 index 0000000000000..83808901a0bd1 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/rule_migrations_data_client.ts @@ -0,0 +1,275 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { AuthenticatedUser, ElasticsearchClient, Logger } from '@kbn/core/server'; +import assert from 'assert'; +import type { + AggregationsFilterAggregate, + AggregationsMaxAggregate, + AggregationsStringTermsAggregate, + AggregationsStringTermsBucket, + QueryDslQueryContainer, + SearchHit, + SearchResponse, +} from '@elastic/elasticsearch/lib/api/types'; +import type { StoredRuleMigration } from '../types'; +import { SiemMigrationStatus } from '../../../../../common/siem_migrations/constants'; +import type { + RuleMigration, + RuleMigrationTaskStats, +} from '../../../../../common/siem_migrations/model/rule_migration.gen'; + +export type CreateRuleMigrationInput = Omit; +export type RuleMigrationDataStats = Omit; +export type RuleMigrationAllDataStats = Array; + +export class RuleMigrationsDataClient { + constructor( + private dataStreamNamePromise: Promise, + private currentUser: AuthenticatedUser, + private esClient: ElasticsearchClient, + private logger: Logger + ) {} + + /** Indexes an array of rule migrations to be processed */ + async create(ruleMigrations: CreateRuleMigrationInput[]): Promise { + const index = await this.dataStreamNamePromise; + await this.esClient + .bulk({ + refresh: 'wait_for', + operations: ruleMigrations.flatMap((ruleMigration) => [ + { create: { _index: index } }, + { + ...ruleMigration, + '@timestamp': new Date().toISOString(), + status: SiemMigrationStatus.PENDING, + created_by: this.currentUser.username, + }, + ]), + }) + .catch((error) => { + this.logger.error(`Error creating rule migrations: ${error.message}`); + throw error; + }); + } + + /** Retrieves an array of rule documents of a specific migrations */ + async getRules(migrationId: string): Promise { + const index = await this.dataStreamNamePromise; + const query = this.getFilterQuery(migrationId); + + const storedRuleMigrations = await this.esClient + .search({ index, query, sort: '_doc' }) + .catch((error) => { + this.logger.error(`Error searching getting rule migrations: ${error.message}`); + throw error; + }) + .then((response) => this.processHits(response.hits.hits)); + return storedRuleMigrations; + } + + /** + * Retrieves `pending` rule migrations with the provided id and updates their status to `processing`. + * This operation is not atomic at migration level: + * - Multiple tasks can process different migrations simultaneously. + * - Multiple tasks should not process the same migration simultaneously. + */ + async takePending(migrationId: string, size: number): Promise { + const index = await this.dataStreamNamePromise; + const query = this.getFilterQuery(migrationId, SiemMigrationStatus.PENDING); + + const storedRuleMigrations = await this.esClient + .search({ index, query, sort: '_doc', size }) + .catch((error) => { + this.logger.error(`Error searching for rule migrations: ${error.message}`); + throw error; + }) + .then((response) => + this.processHits(response.hits.hits, { status: SiemMigrationStatus.PROCESSING }) + ); + + await this.esClient + .bulk({ + refresh: 'wait_for', + operations: storedRuleMigrations.flatMap(({ _id, _index, status }) => [ + { update: { _id, _index } }, + { + doc: { + status, + updated_by: this.currentUser.username, + updated_at: new Date().toISOString(), + }, + }, + ]), + }) + .catch((error) => { + this.logger.error( + `Error updating for rule migrations status to processing: ${error.message}` + ); + throw error; + }); + + return storedRuleMigrations; + } + + /** Updates one rule migration with the provided data and sets the status to `completed` */ + async saveFinished({ _id, _index, ...ruleMigration }: StoredRuleMigration): Promise { + const doc = { + ...ruleMigration, + status: SiemMigrationStatus.COMPLETED, + updated_by: this.currentUser.username, + updated_at: new Date().toISOString(), + }; + await this.esClient + .update({ index: _index, id: _id, doc, refresh: 'wait_for' }) + .catch((error) => { + this.logger.error(`Error updating rule migration status to completed: ${error.message}`); + throw error; + }); + } + + /** Updates one rule migration with the provided data and sets the status to `failed` */ + async saveError({ _id, _index, ...ruleMigration }: StoredRuleMigration): Promise { + const doc = { + ...ruleMigration, + status: SiemMigrationStatus.FAILED, + updated_by: this.currentUser.username, + updated_at: new Date().toISOString(), + }; + await this.esClient + .update({ index: _index, id: _id, doc, refresh: 'wait_for' }) + .catch((error) => { + this.logger.error(`Error updating rule migration status to completed: ${error.message}`); + throw error; + }); + } + + /** Updates all the rule migration with the provided id with status `processing` back to `pending` */ + async releaseProcessing(migrationId: string): Promise { + const index = await this.dataStreamNamePromise; + const query = this.getFilterQuery(migrationId, SiemMigrationStatus.PROCESSING); + const script = { source: `ctx._source['status'] = '${SiemMigrationStatus.PENDING}'` }; + await this.esClient.updateByQuery({ index, query, script, refresh: false }).catch((error) => { + this.logger.error(`Error releasing rule migrations status to pending: ${error.message}`); + throw error; + }); + } + + /** Updates all the rule migration with the provided id with status `processing` or `failed` back to `pending` */ + async releaseProcessable(migrationId: string): Promise { + const index = await this.dataStreamNamePromise; + const query = this.getFilterQuery(migrationId, [ + SiemMigrationStatus.PROCESSING, + SiemMigrationStatus.FAILED, + ]); + const script = { source: `ctx._source['status'] = '${SiemMigrationStatus.PENDING}'` }; + await this.esClient.updateByQuery({ index, query, script, refresh: true }).catch((error) => { + this.logger.error(`Error releasing rule migrations status to pending: ${error.message}`); + throw error; + }); + } + + /** Retrieves the stats for the rule migrations with the provided id */ + async getStats(migrationId: string): Promise { + const index = await this.dataStreamNamePromise; + const query = this.getFilterQuery(migrationId); + const aggregations = { + pending: { filter: { term: { status: SiemMigrationStatus.PENDING } } }, + processing: { filter: { term: { status: SiemMigrationStatus.PROCESSING } } }, + completed: { filter: { term: { status: SiemMigrationStatus.COMPLETED } } }, + failed: { filter: { term: { status: SiemMigrationStatus.FAILED } } }, + lastUpdatedAt: { max: { field: 'updated_at' } }, + }; + const result = await this.esClient + .search({ index, query, aggregations, _source: false }) + .catch((error) => { + this.logger.error(`Error getting rule migrations stats: ${error.message}`); + throw error; + }); + + const { pending, processing, completed, lastUpdatedAt, failed } = result.aggregations ?? {}; + return { + rules: { + total: this.getTotalHits(result), + pending: (pending as AggregationsFilterAggregate)?.doc_count ?? 0, + processing: (processing as AggregationsFilterAggregate)?.doc_count ?? 0, + completed: (completed as AggregationsFilterAggregate)?.doc_count ?? 0, + failed: (failed as AggregationsFilterAggregate)?.doc_count ?? 0, + }, + last_updated_at: (lastUpdatedAt as AggregationsMaxAggregate)?.value_as_string, + }; + } + + /** Retrieves the stats for all the rule migrations aggregated by migration id */ + async getAllStats(): Promise { + const index = await this.dataStreamNamePromise; + const aggregations = { + migrationIds: { + terms: { field: 'migration_id' }, + aggregations: { + pending: { filter: { term: { status: SiemMigrationStatus.PENDING } } }, + processing: { filter: { term: { status: SiemMigrationStatus.PROCESSING } } }, + completed: { filter: { term: { status: SiemMigrationStatus.COMPLETED } } }, + failed: { filter: { term: { status: SiemMigrationStatus.FAILED } } }, + lastUpdatedAt: { max: { field: 'updated_at' } }, + }, + }, + }; + const result = await this.esClient + .search({ index, aggregations, _source: false }) + .catch((error) => { + this.logger.error(`Error getting all rule migrations stats: ${error.message}`); + throw error; + }); + + const migrationsAgg = result.aggregations?.migrationIds as AggregationsStringTermsAggregate; + const buckets = (migrationsAgg?.buckets as AggregationsStringTermsBucket[]) ?? []; + return buckets.map((bucket) => ({ + migration_id: bucket.key, + rules: { + total: bucket.doc_count, + pending: bucket.pending?.doc_count ?? 0, + processing: bucket.processing?.doc_count ?? 0, + completed: bucket.completed?.doc_count ?? 0, + failed: bucket.failed?.doc_count ?? 0, + }, + last_updated_at: bucket.lastUpdatedAt?.value_as_string, + })); + } + + private getFilterQuery( + migrationId: string, + status?: SiemMigrationStatus | SiemMigrationStatus[] + ): QueryDslQueryContainer { + const filter: QueryDslQueryContainer[] = [{ term: { migration_id: migrationId } }]; + if (status) { + if (Array.isArray(status)) { + filter.push({ terms: { status } }); + } else { + filter.push({ term: { status } }); + } + } + return { bool: { filter } }; + } + + private processHits( + hits: Array>, + override: Partial = {} + ): StoredRuleMigration[] { + return hits.map(({ _id, _index, _source }) => { + assert(_id, 'RuleMigration document should have _id'); + assert(_source, 'RuleMigration document should have _source'); + return { ..._source, ...override, _id, _index }; + }); + } + + private getTotalHits(response: SearchResponse) { + return typeof response.hits.total === 'number' + ? response.hits.total + : response.hits.total?.value ?? 0; + } +} diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/rule_migrations_data_stream.test.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/rule_migrations_data_stream.test.ts index 56510da48f1bb..467d26a380945 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/rule_migrations_data_stream.test.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/rule_migrations_data_stream.test.ts @@ -11,9 +11,19 @@ import type { InstallParams } from '@kbn/data-stream-adapter'; import { DataStreamSpacesAdapter } from '@kbn/data-stream-adapter'; import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; import { loggerMock } from '@kbn/logging-mocks'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import { securityServiceMock } from '@kbn/core-security-server-mocks'; jest.mock('@kbn/data-stream-adapter'); +// This mock is required to have a way to await the data stream name promise +const mockDataStreamNamePromise = jest.fn(); +jest.mock('./rule_migrations_data_client', () => ({ + RuleMigrationsDataClient: jest.fn((dataStreamNamePromise: Promise) => { + mockDataStreamNamePromise.mockReturnValue(dataStreamNamePromise); + }), +})); + const MockedDataStreamSpacesAdapter = DataStreamSpacesAdapter as unknown as jest.MockedClass< typeof DataStreamSpacesAdapter >; @@ -21,18 +31,21 @@ const MockedDataStreamSpacesAdapter = DataStreamSpacesAdapter as unknown as jest const esClient = elasticsearchServiceMock.createStart().client.asInternalUser; describe('SiemRuleMigrationsDataStream', () => { + const kibanaVersion = '8.16.0'; + const logger = loggingSystemMock.createLogger(); + beforeEach(() => { jest.clearAllMocks(); }); describe('constructor', () => { it('should create DataStreamSpacesAdapter', () => { - new RuleMigrationsDataStream({ kibanaVersion: '8.13.0' }); + new RuleMigrationsDataStream(logger, kibanaVersion); expect(MockedDataStreamSpacesAdapter).toHaveBeenCalledTimes(1); }); it('should create component templates', () => { - new RuleMigrationsDataStream({ kibanaVersion: '8.13.0' }); + new RuleMigrationsDataStream(logger, kibanaVersion); const [dataStreamSpacesAdapter] = MockedDataStreamSpacesAdapter.mock.instances; expect(dataStreamSpacesAdapter.setComponentTemplate).toHaveBeenCalledWith( expect.objectContaining({ name: '.kibana.siem-rule-migrations' }) @@ -40,7 +53,7 @@ describe('SiemRuleMigrationsDataStream', () => { }); it('should create index templates', () => { - new RuleMigrationsDataStream({ kibanaVersion: '8.13.0' }); + new RuleMigrationsDataStream(logger, kibanaVersion); const [dataStreamSpacesAdapter] = MockedDataStreamSpacesAdapter.mock.instances; expect(dataStreamSpacesAdapter.setIndexTemplate).toHaveBeenCalledWith( expect.objectContaining({ name: '.kibana.siem-rule-migrations' }) @@ -50,22 +63,20 @@ describe('SiemRuleMigrationsDataStream', () => { describe('install', () => { it('should install data stream', async () => { - const dataStream = new RuleMigrationsDataStream({ kibanaVersion: '8.13.0' }); - const params: InstallParams = { + const dataStream = new RuleMigrationsDataStream(logger, kibanaVersion); + const params: Omit = { esClient, - logger: loggerMock.create(), pluginStop$: new Subject(), }; await dataStream.install(params); const [dataStreamSpacesAdapter] = MockedDataStreamSpacesAdapter.mock.instances; - expect(dataStreamSpacesAdapter.install).toHaveBeenCalledWith(params); + expect(dataStreamSpacesAdapter.install).toHaveBeenCalledWith(expect.objectContaining(params)); }); it('should log error', async () => { - const dataStream = new RuleMigrationsDataStream({ kibanaVersion: '8.13.0' }); - const params: InstallParams = { + const dataStream = new RuleMigrationsDataStream(logger, kibanaVersion); + const params: Omit = { esClient, - logger: loggerMock.create(), pluginStop$: new Subject(), }; const [dataStreamSpacesAdapter] = MockedDataStreamSpacesAdapter.mock.instances; @@ -73,13 +84,16 @@ describe('SiemRuleMigrationsDataStream', () => { (dataStreamSpacesAdapter.install as jest.Mock).mockRejectedValueOnce(error); await dataStream.install(params); - expect(params.logger.error).toHaveBeenCalledWith(expect.any(String), error); + expect(logger.error).toHaveBeenCalledWith(expect.any(String), error); }); }); - describe('installSpace', () => { + describe('createClient', () => { + const currentUser = securityServiceMock.createMockAuthenticatedUser(); + const createClientParams = { spaceId: 'space1', currentUser, esClient }; + it('should install space data stream', async () => { - const dataStream = new RuleMigrationsDataStream({ kibanaVersion: '8.13.0' }); + const dataStream = new RuleMigrationsDataStream(logger, kibanaVersion); const params: InstallParams = { esClient, logger: loggerMock.create(), @@ -89,19 +103,23 @@ describe('SiemRuleMigrationsDataStream', () => { (dataStreamSpacesAdapter.install as jest.Mock).mockResolvedValueOnce(undefined); await dataStream.install(params); - await dataStream.installSpace('space1'); + dataStream.createClient(createClientParams); + await mockDataStreamNamePromise(); expect(dataStreamSpacesAdapter.getInstalledSpaceName).toHaveBeenCalledWith('space1'); expect(dataStreamSpacesAdapter.installSpace).toHaveBeenCalledWith('space1'); }); it('should not install space data stream if install not executed', async () => { - const dataStream = new RuleMigrationsDataStream({ kibanaVersion: '8.13.0' }); - await expect(dataStream.installSpace('space1')).rejects.toThrowError(); + const dataStream = new RuleMigrationsDataStream(logger, kibanaVersion); + await expect(async () => { + dataStream.createClient(createClientParams); + await mockDataStreamNamePromise(); + }).rejects.toThrowError(); }); it('should throw error if main install had error', async () => { - const dataStream = new RuleMigrationsDataStream({ kibanaVersion: '8.13.0' }); + const dataStream = new RuleMigrationsDataStream(logger, kibanaVersion); const params: InstallParams = { esClient, logger: loggerMock.create(), @@ -112,7 +130,10 @@ describe('SiemRuleMigrationsDataStream', () => { (dataStreamSpacesAdapter.install as jest.Mock).mockRejectedValueOnce(error); await dataStream.install(params); - await expect(dataStream.installSpace('space1')).rejects.toThrowError(error); + await expect(async () => { + dataStream.createClient(createClientParams); + await mockDataStreamNamePromise(); + }).rejects.toThrowError(error); }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/rule_migrations_data_stream.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/rule_migrations_data_stream.ts index 83eb471e0cee3..a5855cefb1324 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/rule_migrations_data_stream.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/rule_migrations_data_stream.ts @@ -6,51 +6,69 @@ */ import { DataStreamSpacesAdapter, type InstallParams } from '@kbn/data-stream-adapter'; +import type { AuthenticatedUser, ElasticsearchClient, Logger } from '@kbn/core/server'; import { ruleMigrationsFieldMap } from './rule_migrations_field_map'; +import { RuleMigrationsDataClient } from './rule_migrations_data_client'; const TOTAL_FIELDS_LIMIT = 2500; const DATA_STREAM_NAME = '.kibana.siem-rule-migrations'; -const ECS_COMPONENT_TEMPLATE_NAME = 'ecs'; + +interface RuleMigrationsDataStreamCreateClientParams { + spaceId: string; + currentUser: AuthenticatedUser; + esClient: ElasticsearchClient; +} export class RuleMigrationsDataStream { - private readonly dataStream: DataStreamSpacesAdapter; + private readonly dataStreamAdapter: DataStreamSpacesAdapter; private installPromise?: Promise; - constructor({ kibanaVersion }: { kibanaVersion: string }) { - this.dataStream = new DataStreamSpacesAdapter(DATA_STREAM_NAME, { + constructor(private logger: Logger, kibanaVersion: string) { + this.dataStreamAdapter = new DataStreamSpacesAdapter(DATA_STREAM_NAME, { kibanaVersion, totalFieldsLimit: TOTAL_FIELDS_LIMIT, }); - this.dataStream.setComponentTemplate({ + this.dataStreamAdapter.setComponentTemplate({ name: DATA_STREAM_NAME, fieldMap: ruleMigrationsFieldMap, }); - this.dataStream.setIndexTemplate({ + this.dataStreamAdapter.setIndexTemplate({ name: DATA_STREAM_NAME, - componentTemplateRefs: [DATA_STREAM_NAME, ECS_COMPONENT_TEMPLATE_NAME], + componentTemplateRefs: [DATA_STREAM_NAME], }); } - async install(params: InstallParams) { + async install(params: Omit) { try { - this.installPromise = this.dataStream.install(params); + this.installPromise = this.dataStreamAdapter.install({ ...params, logger: this.logger }); await this.installPromise; } catch (err) { - params.logger.error(`Error installing siem rule migrations data stream. ${err.message}`, err); + this.logger.error(`Error installing siem rule migrations data stream. ${err.message}`, err); } } - async installSpace(spaceId: string): Promise { + createClient({ + spaceId, + currentUser, + esClient, + }: RuleMigrationsDataStreamCreateClientParams): RuleMigrationsDataClient { + const dataStreamNamePromise = this.installSpace(spaceId); + return new RuleMigrationsDataClient(dataStreamNamePromise, currentUser, esClient, this.logger); + } + + // Installs the data stream for the specific space. it will only install if it hasn't been installed yet. + // The adapter stores the data stream name promise, it will return it directly when the data stream is known to be installed. + private async installSpace(spaceId: string): Promise { if (!this.installPromise) { throw new Error('Siem rule migrations data stream not installed'); } // wait for install to complete, may reject if install failed, routes should handle this await this.installPromise; - let dataStreamName = await this.dataStream.getInstalledSpaceName(spaceId); + let dataStreamName = await this.dataStreamAdapter.getInstalledSpaceName(spaceId); if (!dataStreamName) { - dataStreamName = await this.dataStream.installSpace(spaceId); + dataStreamName = await this.dataStreamAdapter.installSpace(spaceId); } return dataStreamName; } diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/rule_migrations_field_map.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/rule_migrations_field_map.ts index ba9a706957bcb..a65cd45b832e9 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/rule_migrations_field_map.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/rule_migrations_field_map.ts @@ -11,6 +11,7 @@ import type { RuleMigration } from '../../../../../common/siem_migrations/model/ export const ruleMigrationsFieldMap: FieldMap> = { '@timestamp': { type: 'date', required: false }, migration_id: { type: 'keyword', required: true }, + created_by: { type: 'keyword', required: true }, status: { type: 'keyword', required: true }, original_rule: { type: 'nested', required: true }, 'original_rule.vendor': { type: 'keyword', required: true }, @@ -28,7 +29,7 @@ export const ruleMigrationsFieldMap: FieldMap> 'elastic_rule.severity': { type: 'keyword', required: false }, 'elastic_rule.prebuilt_rule_id': { type: 'keyword', required: false }, 'elastic_rule.id': { type: 'keyword', required: false }, - translation_state: { type: 'keyword', required: false }, + translation_result: { type: 'keyword', required: false }, comments: { type: 'text', array: true, required: false }, updated_at: { type: 'date', required: false }, updated_by: { type: 'keyword', required: false }, diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/siem_rule_migrations_service.test.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/siem_rule_migrations_service.test.ts index 390d302264cea..5c611d85e0464 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/siem_rule_migrations_service.test.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/siem_rule_migrations_service.test.ts @@ -8,25 +8,28 @@ import { loggingSystemMock, elasticsearchServiceMock, httpServerMock, + securityServiceMock, } from '@kbn/core/server/mocks'; import { SiemRuleMigrationsService } from './siem_rule_migrations_service'; import { Subject } from 'rxjs'; -import type { RuleMigration } from '../../../../common/siem_migrations/model/rule_migration.gen'; import { MockRuleMigrationsDataStream, mockInstall, - mockInstallSpace, - mockIndexName, + mockCreateClient, } from './data_stream/__mocks__/mocks'; -import type { KibanaRequest } from '@kbn/core/server'; +import type { SiemRuleMigrationsCreateClientParams } from './types'; jest.mock('./data_stream/rule_migrations_data_stream'); +jest.mock('./task/rule_migrations_task_runner', () => ({ + RuleMigrationsTaskRunner: jest.fn(), +})); describe('SiemRuleMigrationsService', () => { let ruleMigrationsService: SiemRuleMigrationsService; const kibanaVersion = '8.16.0'; const esClusterClient = elasticsearchServiceMock.createClusterClient(); + const currentUser = securityServiceMock.createMockAuthenticatedUser(); const logger = loggingSystemMock.createLogger(); const pluginStop$ = new Subject(); @@ -36,7 +39,7 @@ describe('SiemRuleMigrationsService', () => { }); it('should instantiate the rule migrations data stream adapter', () => { - expect(MockRuleMigrationsDataStream).toHaveBeenCalledWith({ kibanaVersion }); + expect(MockRuleMigrationsDataStream).toHaveBeenCalledWith(logger, kibanaVersion); }); describe('when setup is called', () => { @@ -45,22 +48,26 @@ describe('SiemRuleMigrationsService', () => { expect(mockInstall).toHaveBeenCalledWith({ esClient: esClusterClient.asInternalUser, - logger, pluginStop$, }); }); }); - describe('when getClient is called', () => { - let request: KibanaRequest; + describe('when createClient is called', () => { + let createClientParams: SiemRuleMigrationsCreateClientParams; + beforeEach(() => { - request = httpServerMock.createKibanaRequest(); + createClientParams = { + spaceId: 'default', + currentUser, + request: httpServerMock.createKibanaRequest(), + }; }); describe('without setup', () => { it('should throw an error', () => { expect(() => { - ruleMigrationsService.getClient({ spaceId: 'default', request }); + ruleMigrationsService.createClient(createClientParams); }).toThrowError('ES client not available, please call setup first'); }); }); @@ -71,44 +78,19 @@ describe('SiemRuleMigrationsService', () => { }); it('should call installSpace', () => { - ruleMigrationsService.getClient({ spaceId: 'default', request }); - - expect(mockInstallSpace).toHaveBeenCalledWith('default'); - }); - - it('should return a client with create and search methods after setup', () => { - const client = ruleMigrationsService.getClient({ spaceId: 'default', request }); - - expect(client).toHaveProperty('create'); - expect(client).toHaveProperty('search'); + ruleMigrationsService.createClient(createClientParams); + expect(mockCreateClient).toHaveBeenCalledWith({ + spaceId: createClientParams.spaceId, + currentUser: createClientParams.currentUser, + esClient: esClusterClient.asScoped().asCurrentUser, + }); }); - it('should call ES bulk create API with the correct parameters with create is called', async () => { - const client = ruleMigrationsService.getClient({ spaceId: 'default', request }); - - const ruleMigrations = [{ migration_id: 'dummy_migration_id' } as RuleMigration]; - await client.create(ruleMigrations); - - expect(esClusterClient.asScoped().asCurrentUser.bulk).toHaveBeenCalledWith( - expect.objectContaining({ - body: [{ create: { _index: mockIndexName } }, { migration_id: 'dummy_migration_id' }], - refresh: 'wait_for', - }) - ); - }); - - it('should call ES search API with the correct parameters with search is called', async () => { - const client = ruleMigrationsService.getClient({ spaceId: 'default', request }); - - const term = { migration_id: 'dummy_migration_id' }; - await client.search(term); + it('should return data and task clients', () => { + const client = ruleMigrationsService.createClient(createClientParams); - expect(esClusterClient.asScoped().asCurrentUser.search).toHaveBeenCalledWith( - expect.objectContaining({ - index: mockIndexName, - body: { query: { term } }, - }) - ); + expect(client).toHaveProperty('data'); + expect(client).toHaveProperty('task'); }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/siem_rule_migrations_service.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/siem_rule_migrations_service.ts index 5b20f957cb6fa..1bf9dcf11fd95 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/siem_rule_migrations_service.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/siem_rule_migrations_service.ts @@ -5,52 +5,67 @@ * 2.0. */ +import assert from 'assert'; import type { IClusterClient, Logger } from '@kbn/core/server'; import { RuleMigrationsDataStream } from './data_stream/rule_migrations_data_stream'; import type { - SiemRuleMigrationsClient, SiemRulesMigrationsSetupParams, - SiemRuleMigrationsGetClientParams, + SiemRuleMigrationsCreateClientParams, + SiemRuleMigrationsClient, } from './types'; +import { RuleMigrationsTaskRunner } from './task/rule_migrations_task_runner'; export class SiemRuleMigrationsService { - private dataStreamAdapter: RuleMigrationsDataStream; + private rulesDataStream: RuleMigrationsDataStream; private esClusterClient?: IClusterClient; + private taskRunner: RuleMigrationsTaskRunner; constructor(private logger: Logger, kibanaVersion: string) { - this.dataStreamAdapter = new RuleMigrationsDataStream({ kibanaVersion }); + this.rulesDataStream = new RuleMigrationsDataStream(this.logger, kibanaVersion); + this.taskRunner = new RuleMigrationsTaskRunner(this.logger); } setup({ esClusterClient, ...params }: SiemRulesMigrationsSetupParams) { this.esClusterClient = esClusterClient; const esClient = esClusterClient.asInternalUser; - this.dataStreamAdapter.install({ ...params, esClient, logger: this.logger }).catch((err) => { + + this.rulesDataStream.install({ ...params, esClient }).catch((err) => { this.logger.error(`Error installing data stream for rule migrations: ${err.message}`); throw err; }); } - getClient({ spaceId, request }: SiemRuleMigrationsGetClientParams): SiemRuleMigrationsClient { - if (!this.esClusterClient) { - throw new Error('ES client not available, please call setup first'); - } - // Installs the data stream for the specific space. it will only install if it hasn't been installed yet. - // The adapter stores the data stream name promise, it will return it directly when the data stream is known to be installed. - const dataStreamNamePromise = this.dataStreamAdapter.installSpace(spaceId); + createClient({ + spaceId, + currentUser, + request, + }: SiemRuleMigrationsCreateClientParams): SiemRuleMigrationsClient { + assert(currentUser, 'Current user must be authenticated'); + assert(this.esClusterClient, 'ES client not available, please call setup first'); const esClient = this.esClusterClient.asScoped(request).asCurrentUser; + const dataClient = this.rulesDataStream.createClient({ spaceId, currentUser, esClient }); + return { - create: async (ruleMigrations) => { - const _index = await dataStreamNamePromise; - return esClient.bulk({ - refresh: 'wait_for', - body: ruleMigrations.flatMap((ruleMigration) => [{ create: { _index } }, ruleMigration]), - }); - }, - search: async (term) => { - const index = await dataStreamNamePromise; - return esClient.search({ index, body: { query: { term } } }); + data: dataClient, + task: { + start: (params) => { + return this.taskRunner.start({ ...params, currentUser, dataClient }); + }, + stop: (migrationId) => { + return this.taskRunner.stop({ migrationId, dataClient }); + }, + getStats: async (migrationId) => { + return this.taskRunner.getStats({ migrationId, dataClient }); + }, + getAllStats: async () => { + return this.taskRunner.getAllStats({ dataClient }); + }, }, }; } + + stop() { + this.taskRunner.stopAll(); + } } diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/graph.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/graph.ts new file mode 100644 index 0000000000000..a44197d82850f --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/graph.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { END, START, StateGraph } from '@langchain/langgraph'; +import { migrateRuleState } from './state'; +import type { MigrateRuleGraphParams, MigrateRuleState } from './types'; +import { getTranslateQueryNode } from './nodes/translate_query'; +import { getMatchPrebuiltRuleNode } from './nodes/match_prebuilt_rule'; + +export function getRuleMigrationAgent({ + model, + inferenceClient, + prebuiltRulesMap, + connectorId, + logger, +}: MigrateRuleGraphParams) { + const matchPrebuiltRuleNode = getMatchPrebuiltRuleNode({ model, prebuiltRulesMap, logger }); + const translationNode = getTranslateQueryNode({ inferenceClient, connectorId, logger }); + + const translateRuleGraph = new StateGraph(migrateRuleState) + // Nodes + .addNode('matchPrebuiltRule', matchPrebuiltRuleNode) + .addNode('translation', translationNode) + // Edges + .addEdge(START, 'matchPrebuiltRule') + .addConditionalEdges('matchPrebuiltRule', matchedPrebuiltRuleConditional) + .addEdge('translation', END); + + const graph = translateRuleGraph.compile(); + graph.name = 'Rule Migration Graph'; // Customizes the name displayed in LangSmith + return graph; +} + +const matchedPrebuiltRuleConditional = (state: MigrateRuleState) => { + if (state.elastic_rule?.prebuilt_rule_id) { + return END; + } + return 'translation'; +}; diff --git a/x-pack/packages/security-solution/common/src/flyout/panels/index.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/index.ts similarity index 82% rename from x-pack/packages/security-solution/common/src/flyout/panels/index.ts rename to x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/index.ts index 3134078ebdf7f..febf5fc85f5a0 100644 --- a/x-pack/packages/security-solution/common/src/flyout/panels/index.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/index.ts @@ -5,5 +5,4 @@ * 2.0. */ -export * from './host/right'; -export * from './keys'; +export { getRuleMigrationAgent } from './graph'; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/match_prebuilt_rule/index.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/match_prebuilt_rule/index.ts new file mode 100644 index 0000000000000..2d8b81d00eafb --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/match_prebuilt_rule/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { getMatchPrebuiltRuleNode } from './match_prebuilt_rule'; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/match_prebuilt_rule/match_prebuilt_rule.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/match_prebuilt_rule/match_prebuilt_rule.ts new file mode 100644 index 0000000000000..4a0404acf653d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/match_prebuilt_rule/match_prebuilt_rule.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger } from '@kbn/core/server'; +import { StringOutputParser } from '@langchain/core/output_parsers'; +import type { ChatModel } from '../../../util/actions_client_chat'; +import type { GraphNode } from '../../types'; +import { filterPrebuiltRules, type PrebuiltRulesMapByName } from '../../../util/prebuilt_rules'; +import { MATCH_PREBUILT_RULE_PROMPT } from './prompts'; + +interface GetMatchPrebuiltRuleNodeParams { + model: ChatModel; + prebuiltRulesMap: PrebuiltRulesMapByName; + logger: Logger; +} + +export const getMatchPrebuiltRuleNode = + ({ model, prebuiltRulesMap }: GetMatchPrebuiltRuleNodeParams): GraphNode => + async (state) => { + const mitreAttackIds = state.original_rule.mitre_attack_ids; + if (!mitreAttackIds?.length) { + return {}; + } + const filteredPrebuiltRulesMap = filterPrebuiltRules(prebuiltRulesMap, mitreAttackIds); + if (filteredPrebuiltRulesMap.size === 0) { + return {}; + } + + const outputParser = new StringOutputParser(); + const matchPrebuiltRule = MATCH_PREBUILT_RULE_PROMPT.pipe(model).pipe(outputParser); + + const elasticSecurityRules = Array(filteredPrebuiltRulesMap.keys()).join('\n'); + const response = await matchPrebuiltRule.invoke({ + elasticSecurityRules, + ruleTitle: state.original_rule.title, + }); + const cleanResponse = response.trim(); + if (cleanResponse === 'no_match') { + return {}; + } + + const result = filteredPrebuiltRulesMap.get(cleanResponse); + if (result != null) { + return { + elastic_rule: { + title: result.rule.name, + description: result.rule.description, + prebuilt_rule_id: result.rule.rule_id, + id: result.installedRuleId, + }, + }; + } + + return {}; + }; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/match_prebuilt_rule/prompts.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/match_prebuilt_rule/prompts.ts new file mode 100644 index 0000000000000..434636d0519b1 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/match_prebuilt_rule/prompts.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ChatPromptTemplate } from '@langchain/core/prompts'; +export const MATCH_PREBUILT_RULE_PROMPT = ChatPromptTemplate.fromMessages([ + [ + 'system', + `You are an expert assistant in Cybersecurity, your task is to help migrating a SIEM detection rule, from Splunk Security to Elastic Security. +You will be provided with a Splunk Detection Rule name by the user, your goal is to try find an Elastic Detection Rule that covers the same threat, if any. +The list of Elastic Detection Rules suggested is provided in the context below. + +Guidelines: +If there is no Elastic rule in the list that covers the same threat, answer only with the string: no_match +If there is one Elastic rule in the list that covers the same threat, answer only with its name without any further explanation. +If there are multiple rules in the list that cover the same threat, answer with the most specific of them, for example: "Linux User Account Creation" is more specific than "User Account Creation". + + +{elasticSecurityRules} + +`, + ], + [ + 'human', + `The Splunk Detection Rule is: +<> +{ruleTitle} +<> +`, + ], + ['ai', 'Please find the answer below:'], +]); diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/translate_query/esql_knowledge_base_caller.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/translate_query/esql_knowledge_base_caller.ts new file mode 100644 index 0000000000000..2277f2fae41a9 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/translate_query/esql_knowledge_base_caller.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger } from '@kbn/core/server'; +import { naturalLanguageToEsql, type InferenceClient } from '@kbn/inference-plugin/server'; +import { lastValueFrom } from 'rxjs'; + +export type EsqlKnowledgeBaseCaller = (input: string) => Promise; + +type GetEsqlTranslatorToolParams = (params: { + inferenceClient: InferenceClient; + connectorId: string; + logger: Logger; +}) => EsqlKnowledgeBaseCaller; + +export const getEsqlKnowledgeBase: GetEsqlTranslatorToolParams = + ({ inferenceClient: client, connectorId, logger }) => + async (input: string) => { + const { content } = await lastValueFrom( + naturalLanguageToEsql({ + client, + connectorId, + input, + logger: { + debug: (source) => { + logger.debug(typeof source === 'function' ? source() : source); + }, + }, + }) + ); + return content; + }; diff --git a/x-pack/plugins/cloud/common/index.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/translate_query/index.ts similarity index 81% rename from x-pack/plugins/cloud/common/index.ts rename to x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/translate_query/index.ts index 4aa6ce8d5edf0..7d247f755e9da 100644 --- a/x-pack/plugins/cloud/common/index.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/translate_query/index.ts @@ -4,5 +4,4 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -export type { OnBoardingDefaultSolution } from './types'; +export { getTranslateQueryNode } from './translate_query'; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/translate_query/prompt.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/translate_query/prompt.ts new file mode 100644 index 0000000000000..0b97faf7dc96f --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/translate_query/prompt.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { MigrateRuleState } from '../../types'; + +export const getEsqlTranslationPrompt = ( + state: MigrateRuleState +): string => `You are a helpful cybersecurity (SIEM) expert agent. Your task is to migrate "detection rules" from Splunk to Elastic Security. +Below you will find Splunk rule information: the title, description and the SPL (Search Processing Language) query. +Your goal is to translate the SPL query into an equivalent Elastic Security Query Language (ES|QL) query. + +Guidelines: +- Start the translation process by analyzing the SPL query and identifying the key components. +- Always use logs* index pattern for the ES|QL translated query. +- If, in the SPL query, you find a lookup list or macro that, based only on its name, you can not translate with confidence to ES|QL, mention it in the summary and +add a placeholder in the query with the format [macro:(parameters)] or [lookup:] including the [] keys, example: [macro:my_macro(first_param,second_param)] or [lookup:my_lookup]. + +The output will be parsed and should contain: +- First, the ES|QL query inside an \`\`\`esql code block. +- At the end, the summary of the translation process followed in markdown, starting with "## Migration Summary". + +This is the Splunk rule information: + +<> +${state.original_rule.title} +<> + +<> +${state.original_rule.description} +<> + +<> +${state.original_rule.query} +<> +`; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/translate_query/translate_query.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/translate_query/translate_query.ts new file mode 100644 index 0000000000000..00e1e60c7b5f3 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/translate_query/translate_query.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger } from '@kbn/core/server'; +import type { InferenceClient } from '@kbn/inference-plugin/server'; +import type { GraphNode } from '../../types'; +import { getEsqlKnowledgeBase } from './esql_knowledge_base_caller'; +import { getEsqlTranslationPrompt } from './prompt'; +import { SiemMigrationRuleTranslationResult } from '../../../../../../../../common/siem_migrations/constants'; + +interface GetTranslateQueryNodeParams { + inferenceClient: InferenceClient; + connectorId: string; + logger: Logger; +} + +export const getTranslateQueryNode = ({ + inferenceClient, + connectorId, + logger, +}: GetTranslateQueryNodeParams): GraphNode => { + const esqlKnowledgeBaseCaller = getEsqlKnowledgeBase({ inferenceClient, connectorId, logger }); + return async (state) => { + const input = getEsqlTranslationPrompt(state); + const response = await esqlKnowledgeBaseCaller(input); + + const esqlQuery = response.match(/```esql\n([\s\S]*?)\n```/)?.[1] ?? ''; + const summary = response.match(/## Migration Summary[\s\S]*$/)?.[0] ?? ''; + + const translationResult = getTranslationResult(esqlQuery); + + return { + response, + comments: [summary], + translation_result: translationResult, + elastic_rule: { + title: state.original_rule.title, + description: state.original_rule.description, + severity: 'low', + query: esqlQuery, + query_language: 'esql', + }, + }; + }; +}; + +const getTranslationResult = (esqlQuery: string): SiemMigrationRuleTranslationResult => { + if (esqlQuery.match(/\[(macro|lookup):[\s\S]*\]/)) { + return SiemMigrationRuleTranslationResult.PARTIAL; + } + return SiemMigrationRuleTranslationResult.FULL; +}; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/state.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/state.ts new file mode 100644 index 0000000000000..c1e510bdc052d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/state.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 { BaseMessage } from '@langchain/core/messages'; +import { Annotation, messagesStateReducer } from '@langchain/langgraph'; +import type { + ElasticRule, + OriginalRule, + RuleMigration, +} from '../../../../../../common/siem_migrations/model/rule_migration.gen'; +import type { SiemMigrationRuleTranslationResult } from '../../../../../../common/siem_migrations/constants'; + +export const migrateRuleState = Annotation.Root({ + messages: Annotation({ + reducer: messagesStateReducer, + default: () => [], + }), + original_rule: Annotation(), + elastic_rule: Annotation({ + reducer: (state, action) => ({ ...state, ...action }), + }), + translation_result: Annotation(), + comments: Annotation({ + reducer: (current, value) => (value ? (current ?? []).concat(value) : current), + default: () => [], + }), + response: Annotation(), +}); diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/types.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/types.ts new file mode 100644 index 0000000000000..643d200e4b0bf --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/types.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger } from '@kbn/core/server'; +import type { InferenceClient } from '@kbn/inference-plugin/server'; +import type { migrateRuleState } from './state'; +import type { ChatModel } from '../util/actions_client_chat'; +import type { PrebuiltRulesMapByName } from '../util/prebuilt_rules'; + +export type MigrateRuleState = typeof migrateRuleState.State; +export type GraphNode = (state: MigrateRuleState) => Promise>; + +export interface MigrateRuleGraphParams { + inferenceClient: InferenceClient; + model: ChatModel; + connectorId: string; + prebuiltRulesMap: PrebuiltRulesMapByName; + logger: Logger; +} diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.ts new file mode 100644 index 0000000000000..6ae7294fb5257 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.ts @@ -0,0 +1,285 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger } from '@kbn/core/server'; +import { AbortError, abortSignalToPromise } from '@kbn/kibana-utils-plugin/server'; +import type { RunnableConfig } from '@langchain/core/runnables'; +import type { + RuleMigrationAllTaskStats, + RuleMigrationTaskStats, +} from '../../../../../common/siem_migrations/model/rule_migration.gen'; +import type { RuleMigrationDataStats } from '../data_stream/rule_migrations_data_client'; +import type { + RuleMigrationTaskStartParams, + RuleMigrationTaskStartResult, + RuleMigrationTaskStatsParams, + RuleMigrationTaskStopParams, + RuleMigrationTaskStopResult, + RuleMigrationTaskPrepareParams, + RuleMigrationTaskRunParams, + MigrationAgent, + RuleMigrationAllTaskStatsParams, +} from './types'; +import { getRuleMigrationAgent } from './agent'; +import type { MigrateRuleState } from './agent/types'; +import { retrievePrebuiltRulesMap } from './util/prebuilt_rules'; +import { ActionsClientChat } from './util/actions_client_chat'; + +interface TaskLogger { + info: (msg: string) => void; + debug: (msg: string) => void; + error: (msg: string, error: Error) => void; +} +const getTaskLogger = (logger: Logger): TaskLogger => { + const prefix = '[ruleMigrationsTask]: '; + return { + info: (msg) => logger.info(`${prefix}${msg}`), + debug: (msg) => logger.debug(`${prefix}${msg}`), + error: (msg, error) => logger.error(`${prefix}${msg}: ${error.message}`), + }; +}; + +const ITERATION_BATCH_SIZE = 50 as const; +const ITERATION_SLEEP_SECONDS = 10 as const; + +export class RuleMigrationsTaskRunner { + private migrationsRunning: Map; + private taskLogger: TaskLogger; + + constructor(private logger: Logger) { + this.migrationsRunning = new Map(); + this.taskLogger = getTaskLogger(logger); + } + + /** Starts a rule migration task */ + async start(params: RuleMigrationTaskStartParams): Promise { + const { migrationId, dataClient } = params; + if (this.migrationsRunning.has(migrationId)) { + return { exists: true, started: false }; + } + // Just in case some previous execution was interrupted without releasing + await dataClient.releaseProcessable(migrationId); + + const { rules } = await dataClient.getStats(migrationId); + if (rules.total === 0) { + return { exists: false, started: false }; + } + if (rules.pending === 0) { + return { exists: true, started: false }; + } + + const abortController = new AbortController(); + + // Await the preparation to make sure the agent is created properly so the task can run + const agent = await this.prepare({ ...params, abortController }); + + // not awaiting the `run` promise to execute the task in the background + this.run({ ...params, agent, abortController }).catch((err) => { + // All errors in the `run` method are already catch, this should never happen, but just in case + this.taskLogger.error(`Unexpected error running the migration ID:${migrationId}`, err); + }); + + return { exists: true, started: true }; + } + + private async prepare({ + connectorId, + inferenceClient, + actionsClient, + rulesClient, + soClient, + abortController, + }: RuleMigrationTaskPrepareParams): Promise { + const prebuiltRulesMap = await retrievePrebuiltRulesMap({ soClient, rulesClient }); + + const actionsClientChat = new ActionsClientChat(connectorId, actionsClient, this.logger); + const model = await actionsClientChat.createModel({ + signal: abortController.signal, + temperature: 0.05, + }); + + const agent = getRuleMigrationAgent({ + connectorId, + model, + inferenceClient, + prebuiltRulesMap, + logger: this.logger, + }); + return agent; + } + + private async run({ + migrationId, + agent, + dataClient, + currentUser, + invocationConfig, + abortController, + }: RuleMigrationTaskRunParams): Promise { + if (this.migrationsRunning.has(migrationId)) { + // This should never happen, but just in case + throw new Error(`Task already running for migration ID:${migrationId} `); + } + this.taskLogger.info(`Starting migration ID:${migrationId}`); + + this.migrationsRunning.set(migrationId, { user: currentUser.username, abortController }); + const config: RunnableConfig = { + ...invocationConfig, + // signal: abortController.signal, // not working properly https://github.com/langchain-ai/langgraphjs/issues/319 + }; + + const abortPromise = abortSignalToPromise(abortController.signal); + + try { + const sleep = async (seconds: number) => { + this.taskLogger.debug(`Sleeping ${seconds}s for migration ID:${migrationId}`); + await Promise.race([ + new Promise((resolve) => setTimeout(resolve, seconds * 1000)), + abortPromise.promise, + ]); + }; + + let isDone: boolean = false; + do { + const ruleMigrations = await dataClient.takePending(migrationId, ITERATION_BATCH_SIZE); + this.taskLogger.debug( + `Processing ${ruleMigrations.length} rules for migration ID:${migrationId}` + ); + + await Promise.all( + ruleMigrations.map(async (ruleMigration) => { + this.taskLogger.debug( + `Starting migration of rule "${ruleMigration.original_rule.title}"` + ); + try { + const start = Date.now(); + + const ruleMigrationResult: MigrateRuleState = await Promise.race([ + agent.invoke({ original_rule: ruleMigration.original_rule }, config), + abortPromise.promise, // workaround for the issue with the langGraph signal + ]); + + const duration = (Date.now() - start) / 1000; + this.taskLogger.debug( + `Migration of rule "${ruleMigration.original_rule.title}" finished in ${duration}s` + ); + + await dataClient.saveFinished({ + ...ruleMigration, + elastic_rule: ruleMigrationResult.elastic_rule, + translation_result: ruleMigrationResult.translation_result, + comments: ruleMigrationResult.comments, + }); + } catch (error) { + if (error instanceof AbortError) { + throw error; + } + this.taskLogger.error( + `Error migrating rule "${ruleMigration.original_rule.title}"`, + error + ); + await dataClient.saveError({ + ...ruleMigration, + comments: [`Error migrating rule: ${error.message}`], + }); + } + }) + ); + + this.taskLogger.debug(`Batch processed successfully for migration ID:${migrationId}`); + + const { rules } = await dataClient.getStats(migrationId); + isDone = rules.pending === 0; + if (!isDone) { + await sleep(ITERATION_SLEEP_SECONDS); + } + } while (!isDone); + + this.taskLogger.info(`Finished migration ID:${migrationId}`); + } catch (error) { + await dataClient.releaseProcessing(migrationId); + + if (error instanceof AbortError) { + this.taskLogger.info(`Abort signal received, stopping migration ID:${migrationId}`); + return; + } else { + this.taskLogger.error(`Error processing migration ID:${migrationId}`, error); + } + } finally { + this.migrationsRunning.delete(migrationId); + abortPromise.cleanup(); + } + } + + /** Returns the stats of a migration */ + async getStats({ + migrationId, + dataClient, + }: RuleMigrationTaskStatsParams): Promise { + const dataStats = await dataClient.getStats(migrationId); + const status = this.getTaskStatus(migrationId, dataStats.rules); + return { status, ...dataStats }; + } + + /** Returns the stats of all migrations */ + async getAllStats({ + dataClient, + }: RuleMigrationAllTaskStatsParams): Promise { + const allDataStats = await dataClient.getAllStats(); + return allDataStats.map((dataStats) => { + const status = this.getTaskStatus(dataStats.migration_id, dataStats.rules); + return { status, ...dataStats }; + }); + } + + private getTaskStatus( + migrationId: string, + dataStats: RuleMigrationDataStats['rules'] + ): RuleMigrationTaskStats['status'] { + if (this.migrationsRunning.has(migrationId)) { + return 'running'; + } + if (dataStats.pending === dataStats.total) { + return 'ready'; + } + if (dataStats.completed + dataStats.failed === dataStats.total) { + return 'finished'; + } + return 'stopped'; + } + + /** Stops one running migration */ + async stop({ + migrationId, + dataClient, + }: RuleMigrationTaskStopParams): Promise { + try { + const migrationRunning = this.migrationsRunning.get(migrationId); + if (migrationRunning) { + migrationRunning.abortController.abort(); + return { exists: true, stopped: true }; + } + + const { rules } = await dataClient.getStats(migrationId); + if (rules.total > 0) { + return { exists: true, stopped: false }; + } + return { exists: false, stopped: false }; + } catch (err) { + this.taskLogger.error(`Error stopping migration ID:${migrationId}`, err); + return { exists: true, stopped: false }; + } + } + + /** Stops all running migrations */ + stopAll() { + this.migrationsRunning.forEach((migrationRunning) => { + migrationRunning.abortController.abort(); + }); + this.migrationsRunning.clear(); + } +} diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/types.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/types.ts new file mode 100644 index 0000000000000..e26a5b7216f48 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/types.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 type { AuthenticatedUser, SavedObjectsClientContract } from '@kbn/core/server'; +import type { RunnableConfig } from '@langchain/core/runnables'; +import type { InferenceClient } from '@kbn/inference-plugin/server'; +import type { ActionsClient } from '@kbn/actions-plugin/server'; +import type { RulesClient } from '@kbn/alerting-plugin/server'; +import type { RuleMigrationsDataClient } from '../data_stream/rule_migrations_data_client'; +import type { getRuleMigrationAgent } from './agent'; + +export type MigrationAgent = ReturnType; + +export interface RuleMigrationTaskStartParams { + migrationId: string; + currentUser: AuthenticatedUser; + connectorId: string; + invocationConfig: RunnableConfig; + inferenceClient: InferenceClient; + actionsClient: ActionsClient; + rulesClient: RulesClient; + soClient: SavedObjectsClientContract; + dataClient: RuleMigrationsDataClient; +} + +export interface RuleMigrationTaskPrepareParams { + connectorId: string; + inferenceClient: InferenceClient; + actionsClient: ActionsClient; + rulesClient: RulesClient; + soClient: SavedObjectsClientContract; + abortController: AbortController; +} + +export interface RuleMigrationTaskRunParams { + migrationId: string; + currentUser: AuthenticatedUser; + invocationConfig: RunnableConfig; + agent: MigrationAgent; + dataClient: RuleMigrationsDataClient; + abortController: AbortController; +} + +export interface RuleMigrationTaskStopParams { + migrationId: string; + dataClient: RuleMigrationsDataClient; +} + +export interface RuleMigrationTaskStatsParams { + migrationId: string; + dataClient: RuleMigrationsDataClient; +} + +export interface RuleMigrationAllTaskStatsParams { + dataClient: RuleMigrationsDataClient; +} + +export interface RuleMigrationTaskStartResult { + started: boolean; + exists: boolean; +} + +export interface RuleMigrationTaskStopResult { + stopped: boolean; + exists: boolean; +} diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/util/actions_client_chat.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/util/actions_client_chat.ts new file mode 100644 index 0000000000000..204978c901df6 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/util/actions_client_chat.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 type { ActionsClientSimpleChatModel } from '@kbn/langchain/server'; +import { + ActionsClientBedrockChatModel, + ActionsClientChatOpenAI, + ActionsClientChatVertexAI, +} from '@kbn/langchain/server'; +import type { Logger } from '@kbn/core/server'; +import type { ActionsClient } from '@kbn/actions-plugin/server'; +import type { ActionsClientChatOpenAIParams } from '@kbn/langchain/server/language_models/chat_openai'; +import type { CustomChatModelInput as ActionsClientBedrockChatModelParams } from '@kbn/langchain/server/language_models/bedrock_chat'; +import type { CustomChatModelInput as ActionsClientChatVertexAIParams } from '@kbn/langchain/server/language_models/gemini_chat'; +import type { CustomChatModelInput as ActionsClientSimpleChatModelParams } from '@kbn/langchain/server/language_models/simple_chat_model'; + +export type ChatModel = + | ActionsClientSimpleChatModel + | ActionsClientChatOpenAI + | ActionsClientBedrockChatModel + | ActionsClientChatVertexAI; + +export type ActionsClientChatModelClass = + | typeof ActionsClientSimpleChatModel + | typeof ActionsClientChatOpenAI + | typeof ActionsClientBedrockChatModel + | typeof ActionsClientChatVertexAI; + +export type ChatModelParams = Partial & + Partial & + Partial & + Partial & { + /** Enables the streaming mode of the response, disabled by default */ + streaming?: boolean; + }; + +const llmTypeDictionary: Record = { + [`.gen-ai`]: `openai`, + [`.bedrock`]: `bedrock`, + [`.gemini`]: `gemini`, +}; + +export class ActionsClientChat { + constructor( + private readonly connectorId: string, + private readonly actionsClient: ActionsClient, + private readonly logger: Logger + ) {} + + public async createModel(params?: ChatModelParams): Promise { + const connector = await this.actionsClient.get({ id: this.connectorId }); + if (!connector) { + throw new Error(`Connector not found: ${this.connectorId}`); + } + + const llmType = this.getLLMType(connector.actionTypeId); + const ChatModelClass = this.getLLMClass(llmType); + + const model = new ChatModelClass({ + actionsClient: this.actionsClient, + connectorId: this.connectorId, + logger: this.logger, + llmType, + model: connector.config?.defaultModel, + ...params, + streaming: params?.streaming ?? false, // disabling streaming by default, for some reason is enabled when omitted + }); + return model; + } + + private getLLMType(actionTypeId: string): string | undefined { + if (llmTypeDictionary[actionTypeId]) { + return llmTypeDictionary[actionTypeId]; + } + throw new Error(`Unknown LLM type for action type ID: ${actionTypeId}`); + } + + private getLLMClass(llmType?: string): ActionsClientChatModelClass { + switch (llmType) { + case 'bedrock': + return ActionsClientBedrockChatModel; + case 'gemini': + return ActionsClientChatVertexAI; + case 'openai': + default: + return ActionsClientChatOpenAI; + } + } +} diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/util/prebuilt_rules.test.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/util/prebuilt_rules.test.ts new file mode 100644 index 0000000000000..55256d0ad0fdd --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/util/prebuilt_rules.test.ts @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks'; +import type { PrebuiltRulesMapByName } from './prebuilt_rules'; +import { filterPrebuiltRules, retrievePrebuiltRulesMap } from './prebuilt_rules'; +import { rulesClientMock } from '@kbn/alerting-plugin/server/mocks'; + +jest.mock( + '../../../../detection_engine/prebuilt_rules/logic/rule_objects/prebuilt_rule_objects_client', + () => ({ createPrebuiltRuleObjectsClient: jest.fn() }) +); +jest.mock( + '../../../../detection_engine/prebuilt_rules/logic/rule_assets/prebuilt_rule_assets_client', + () => ({ createPrebuiltRuleAssetsClient: jest.fn() }) +); + +const mitreAttackIds = 'T1234'; +const rule1 = { + name: 'rule one', + id: 'rule1', + threat: [ + { + framework: 'MITRE ATT&CK', + technique: [{ id: mitreAttackIds, name: 'tactic one' }], + }, + ], +}; +const rule2 = { + name: 'rule two', + id: 'rule2', +}; + +const defaultRuleVersionsTriad = new Map([ + ['rule1', { target: rule1 }], + ['rule2', { target: rule2, current: rule2 }], +]); +const mockFetchRuleVersionsTriad = jest.fn().mockResolvedValue(defaultRuleVersionsTriad); +jest.mock( + '../../../../detection_engine/prebuilt_rules/logic/rule_versions/fetch_rule_versions_triad', + () => ({ + fetchRuleVersionsTriad: () => mockFetchRuleVersionsTriad(), + }) +); + +const defaultParams = { + soClient: savedObjectsClientMock.create(), + rulesClient: rulesClientMock.create(), +}; + +describe('retrievePrebuiltRulesMap', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('when prebuilt rule is installed', () => { + it('should return isInstalled flag', async () => { + const prebuiltRulesMap = await retrievePrebuiltRulesMap(defaultParams); + expect(prebuiltRulesMap.size).toBe(2); + expect(prebuiltRulesMap.get('rule one')).toEqual( + expect.objectContaining({ installedRuleId: undefined }) + ); + expect(prebuiltRulesMap.get('rule two')).toEqual( + expect.objectContaining({ installedRuleId: rule2.id }) + ); + }); + }); +}); + +describe('filterPrebuiltRules', () => { + let prebuiltRulesMap: PrebuiltRulesMapByName; + + beforeEach(async () => { + prebuiltRulesMap = await retrievePrebuiltRulesMap(defaultParams); + jest.clearAllMocks(); + }); + + describe('when splunk rule contains empty mitreAttackIds', () => { + it('should return empty rules map', async () => { + const filteredPrebuiltRules = filterPrebuiltRules(prebuiltRulesMap, []); + expect(filteredPrebuiltRules.size).toBe(0); + }); + }); + + describe('when splunk rule does not match mitreAttackIds', () => { + it('should return empty rules map', async () => { + const filteredPrebuiltRules = filterPrebuiltRules(prebuiltRulesMap, [`${mitreAttackIds}_2`]); + expect(filteredPrebuiltRules.size).toBe(0); + }); + }); + + describe('when splunk rule contains matching mitreAttackIds', () => { + it('should return the filtered rules map', async () => { + const filteredPrebuiltRules = filterPrebuiltRules(prebuiltRulesMap, [mitreAttackIds]); + expect(filteredPrebuiltRules.size).toBe(1); + expect(filteredPrebuiltRules.get('rule one')).toEqual( + expect.objectContaining({ rule: rule1 }) + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/util/prebuilt_rules.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/util/prebuilt_rules.ts new file mode 100644 index 0000000000000..ade6632aaa5b5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/util/prebuilt_rules.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { RulesClient } from '@kbn/alerting-plugin/server'; +import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; +import type { PrebuiltRuleAsset } from '../../../../detection_engine/prebuilt_rules'; +import { fetchRuleVersionsTriad } from '../../../../detection_engine/prebuilt_rules/logic/rule_versions/fetch_rule_versions_triad'; +import { createPrebuiltRuleObjectsClient } from '../../../../detection_engine/prebuilt_rules/logic/rule_objects/prebuilt_rule_objects_client'; +import { createPrebuiltRuleAssetsClient } from '../../../../detection_engine/prebuilt_rules/logic/rule_assets/prebuilt_rule_assets_client'; + +export interface PrebuiltRuleMapped { + rule: PrebuiltRuleAsset; + installedRuleId?: string; +} + +export type PrebuiltRulesMapByName = Map; + +interface RetrievePrebuiltRulesParams { + soClient: SavedObjectsClientContract; + rulesClient: RulesClient; +} + +export const retrievePrebuiltRulesMap = async ({ + soClient, + rulesClient, +}: RetrievePrebuiltRulesParams): Promise => { + const ruleAssetsClient = createPrebuiltRuleAssetsClient(soClient); + const ruleObjectsClient = createPrebuiltRuleObjectsClient(rulesClient); + + const prebuiltRulesMap = await fetchRuleVersionsTriad({ + ruleAssetsClient, + ruleObjectsClient, + }); + const prebuiltRulesByName: PrebuiltRulesMapByName = new Map(); + prebuiltRulesMap.forEach((ruleVersions) => { + const rule = ruleVersions.target || ruleVersions.current; + if (rule) { + prebuiltRulesByName.set(rule.name, { + rule, + installedRuleId: ruleVersions.current?.id, + }); + } + }); + return prebuiltRulesByName; +}; + +export const filterPrebuiltRules = ( + prebuiltRulesByName: PrebuiltRulesMapByName, + mitreAttackIds: string[] +) => { + const filteredPrebuiltRulesByName = new Map(); + if (mitreAttackIds?.length) { + // If this rule has MITRE ATT&CK IDs, remove unrelated prebuilt rules + prebuiltRulesByName.forEach(({ rule }, ruleName) => { + const mitreAttackThreat = rule.threat?.filter( + ({ framework }) => framework === 'MITRE ATT&CK' + ); + if (!mitreAttackThreat) { + // If this rule has no MITRE ATT&CK reference we skip it + return; + } + + const sameTechnique = mitreAttackThreat.find((threat) => + threat.technique?.some(({ id }) => mitreAttackIds?.includes(id)) + ); + + if (sameTechnique) { + filteredPrebuiltRulesByName.set(ruleName, prebuiltRulesByName.get(ruleName)); + } + }); + } + return filteredPrebuiltRulesByName; +}; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/types.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/types.ts index 1892032a21723..78ec2ef89c7a3 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/types.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/types.ts @@ -5,10 +5,29 @@ * 2.0. */ -import type { BulkResponse, SearchResponse } from '@elastic/elasticsearch/lib/api/types'; -import type { IClusterClient, KibanaRequest } from '@kbn/core/server'; +import type { + AuthenticatedUser, + IClusterClient, + KibanaRequest, + SavedObjectsClientContract, +} from '@kbn/core/server'; import type { Subject } from 'rxjs'; -import type { RuleMigration } from '../../../../common/siem_migrations/model/rule_migration.gen'; +import type { InferenceClient } from '@kbn/inference-plugin/server'; +import type { RunnableConfig } from '@langchain/core/runnables'; +import type { ActionsClient } from '@kbn/actions-plugin/server'; +import type { RulesClient } from '@kbn/alerting-plugin/server'; +import type { + RuleMigration, + RuleMigrationAllTaskStats, + RuleMigrationTaskStats, +} from '../../../../common/siem_migrations/model/rule_migration.gen'; +import type { RuleMigrationsDataClient } from './data_stream/rule_migrations_data_client'; +import type { RuleMigrationTaskStopResult, RuleMigrationTaskStartResult } from './task/types'; + +export interface StoredRuleMigration extends RuleMigration { + _id: string; + _index: string; +} export interface SiemRulesMigrationsSetupParams { esClusterClient: IClusterClient; @@ -16,15 +35,28 @@ export interface SiemRulesMigrationsSetupParams { tasksTimeoutMs?: number; } -export interface SiemRuleMigrationsGetClientParams { +export interface SiemRuleMigrationsCreateClientParams { request: KibanaRequest; + currentUser: AuthenticatedUser | null; spaceId: string; } -export interface RuleMigrationSearchParams { - migration_id?: string; +export interface SiemRuleMigrationsStartTaskParams { + migrationId: string; + connectorId: string; + invocationConfig: RunnableConfig; + inferenceClient: InferenceClient; + actionsClient: ActionsClient; + rulesClient: RulesClient; + soClient: SavedObjectsClientContract; } + export interface SiemRuleMigrationsClient { - create: (body: RuleMigration[]) => Promise; - search: (params: RuleMigrationSearchParams) => Promise; + data: RuleMigrationsDataClient; + task: { + start: (params: SiemRuleMigrationsStartTaskParams) => Promise; + stop: (migrationId: string) => Promise; + getStats: (migrationId: string) => Promise; + getAllStats: () => Promise; + }; } diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/siem_migrations_service.test.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/siem_migrations_service.test.ts index 3d9e5b9fe179b..adf77756cce34 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/siem_migrations_service.test.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/siem_migrations_service.test.ts @@ -8,9 +8,15 @@ import { loggingSystemMock, elasticsearchServiceMock, httpServerMock, + securityServiceMock, } from '@kbn/core/server/mocks'; import { SiemMigrationsService } from './siem_migrations_service'; -import { MockSiemRuleMigrationsService, mockSetup, mockGetClient } from './rules/__mocks__/mocks'; +import { + MockSiemRuleMigrationsService, + mockSetup, + mockCreateClient, + mockStop, +} from './rules/__mocks__/mocks'; import type { ConfigType } from '../../config'; jest.mock('./rules/siem_rule_migrations_service'); @@ -25,6 +31,7 @@ describe('SiemMigrationsService', () => { let siemMigrationsService: SiemMigrationsService; const kibanaVersion = '8.16.0'; + const currentUser = securityServiceMock.createMockAuthenticatedUser(); const esClusterClient = elasticsearchServiceMock.createClusterClient(); const logger = loggingSystemMock.createLogger(); @@ -57,17 +64,22 @@ describe('SiemMigrationsService', () => { }); }); - describe('when createClient is called', () => { + describe('when createRulesClient is called', () => { it('should create rules client', async () => { - const request = httpServerMock.createKibanaRequest(); - siemMigrationsService.createClient({ spaceId: 'default', request }); - expect(mockGetClient).toHaveBeenCalledWith({ spaceId: 'default', request }); + const createRulesClientParams = { + spaceId: 'default', + request: httpServerMock.createKibanaRequest(), + currentUser, + }; + siemMigrationsService.createRulesClient(createRulesClientParams); + expect(mockCreateClient).toHaveBeenCalledWith(createRulesClientParams); }); }); describe('when stop is called', () => { it('should trigger the pluginStop subject', async () => { siemMigrationsService.stop(); + expect(mockStop).toHaveBeenCalled(); expect(mockReplaySubject$.next).toHaveBeenCalled(); expect(mockReplaySubject$.complete).toHaveBeenCalled(); }); diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/siem_migrations_service.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/siem_migrations_service.ts index b84281eb13d9b..7a85dd625feec 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/siem_migrations_service.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/siem_migrations_service.ts @@ -9,11 +9,8 @@ import type { Logger } from '@kbn/core/server'; import { ReplaySubject, type Subject } from 'rxjs'; import type { ConfigType } from '../../config'; import { SiemRuleMigrationsService } from './rules/siem_rule_migrations_service'; -import type { - SiemMigrationsClient, - SiemMigrationsSetupParams, - SiemMigrationsGetClientParams, -} from './types'; +import type { SiemMigrationsSetupParams, SiemMigrationsCreateClientParams } from './types'; +import type { SiemRuleMigrationsClient } from './rules/types'; export class SiemMigrationsService { private pluginStop$: Subject; @@ -30,13 +27,12 @@ export class SiemMigrationsService { } } - createClient(params: SiemMigrationsGetClientParams): SiemMigrationsClient { - return { - rules: this.rules.getClient(params), - }; + createRulesClient(params: SiemMigrationsCreateClientParams): SiemRuleMigrationsClient { + return this.rules.createClient(params); } stop() { + this.rules.stop(); this.pluginStop$.next(); this.pluginStop$.complete(); } diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/types.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/types.ts index b5647ff65e214..d2af1e2518722 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/types.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/types.ts @@ -6,15 +6,11 @@ */ import type { IClusterClient } from '@kbn/core/server'; -import type { SiemRuleMigrationsClient, SiemRuleMigrationsGetClientParams } from './rules/types'; +import type { SiemRuleMigrationsCreateClientParams } from './rules/types'; export interface SiemMigrationsSetupParams { esClusterClient: IClusterClient; tasksTimeoutMs?: number; } -export type SiemMigrationsGetClientParams = SiemRuleMigrationsGetClientParams; - -export interface SiemMigrationsClient { - rules: SiemRuleMigrationsClient; -} +export type SiemMigrationsCreateClientParams = SiemRuleMigrationsCreateClientParams; diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/event_based/events.ts b/x-pack/plugins/security_solution/server/lib/telemetry/event_based/events.ts index b8a2df85f10ad..02a39be555110 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/event_based/events.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/event_based/events.ts @@ -128,6 +128,69 @@ export const ASSET_CRITICALITY_SYSTEM_PROCESSED_ASSIGNMENT_FILE_EVENT: EventType }, }; +export const FIELD_RETENTION_ENRICH_POLICY_EXECUTION_EVENT: EventTypeOpts<{ + duration: number; + interval: string; +}> = { + eventType: 'field_retention_enrich_policy_execution', + schema: { + duration: { + type: 'long', + _meta: { + description: 'Duration (in seconds) of the field retention enrich policy execution time', + }, + }, + interval: { + type: 'keyword', + _meta: { + description: 'Configured interval for the field retention enrich policy task', + }, + }, + }, +}; + +export const ENTITY_ENGINE_RESOURCE_INIT_FAILURE_EVENT: EventTypeOpts<{ + error: string; +}> = { + eventType: 'entity_engine_resource_init_failure', + schema: { + error: { + type: 'keyword', + _meta: { + description: 'Error message for a resource initialization failure', + }, + }, + }, +}; + +export const ENTITY_ENGINE_INITIALIZATION_EVENT: EventTypeOpts<{ + duration: number; +}> = { + eventType: 'entity_engine_initialization', + schema: { + duration: { + type: 'long', + _meta: { + description: 'Duration (in seconds) of the entity engine initialization', + }, + }, + }, +}; + +export const ENTITY_STORE_USAGE_EVENT: EventTypeOpts<{ + storeSize: number; +}> = { + eventType: 'entity_store_usage', + schema: { + storeSize: { + type: 'long', + _meta: { + description: 'Number of entities stored in the entity store', + }, + }, + }, +}; + export const ALERT_SUPPRESSION_EVENT: EventTypeOpts<{ suppressionAlertsCreated: number; suppressionAlertsSuppressed: number; @@ -390,4 +453,8 @@ export const events = [ ENDPOINT_RESPONSE_ACTION_SENT_EVENT, ENDPOINT_RESPONSE_ACTION_SENT_ERROR_EVENT, ENDPOINT_RESPONSE_ACTION_STATUS_CHANGE_EVENT, + FIELD_RETENTION_ENRICH_POLICY_EXECUTION_EVENT, + ENTITY_ENGINE_RESOURCE_INIT_FAILURE_EVENT, + ENTITY_ENGINE_INITIALIZATION_EVENT, + ENTITY_STORE_USAGE_EVENT, ]; diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 794c37cd38b40..428db0309346d 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -232,6 +232,7 @@ export class Plugin implements ISecuritySolutionPlugin { registerEntityStoreFieldRetentionEnrichTask({ getStartServices: core.getStartServices, logger: this.logger, + telemetry: core.analytics, taskManager: plugins.taskManager, }); } diff --git a/x-pack/plugins/security_solution/server/plugin_contract.ts b/x-pack/plugins/security_solution/server/plugin_contract.ts index c7ec67c1b07fc..c178f0654d9bd 100644 --- a/x-pack/plugins/security_solution/server/plugin_contract.ts +++ b/x-pack/plugins/security_solution/server/plugin_contract.ts @@ -45,6 +45,7 @@ import type { SharePluginStart } from '@kbn/share-plugin/server'; import type { GuidedOnboardingPluginSetup } from '@kbn/guided-onboarding-plugin/server'; import type { PluginSetup as UnifiedSearchServerPluginSetup } from '@kbn/unified-search-plugin/server'; import type { ElasticAssistantPluginStart } from '@kbn/elastic-assistant-plugin/server'; +import type { InferenceServerStart } from '@kbn/inference-plugin/server'; import type { ProductFeaturesService } from './lib/product_features_service/product_features_service'; import type { ExperimentalFeatures } from '../common'; @@ -88,6 +89,7 @@ export interface SecuritySolutionPluginStartDependencies { telemetry?: TelemetryPluginStart; share: SharePluginStart; actions: ActionsPluginStartContract; + inference: InferenceServerStart; } export interface SecuritySolutionPluginSetup { diff --git a/x-pack/plugins/security_solution/server/request_context_factory.ts b/x-pack/plugins/security_solution/server/request_context_factory.ts index 8e3af9b9bce8a..5cc321b2d58b1 100644 --- a/x-pack/plugins/security_solution/server/request_context_factory.ts +++ b/x-pack/plugins/security_solution/server/request_context_factory.ts @@ -135,6 +135,8 @@ export class RequestContextFactory implements IRequestContextFactory { getAuditLogger, + getDataViewsService: () => dataViewsService, + getDetectionRulesClient: memoize(() => { const mlAuthz = buildMlAuthz({ license: licensing.license, @@ -166,10 +168,16 @@ export class RequestContextFactory implements IRequestContextFactory { }) ), - getSiemMigrationsClient: memoize(() => - siemMigrationsService.createClient({ request, spaceId: getSpaceId() }) + getSiemRuleMigrationsClient: memoize(() => + siemMigrationsService.createRulesClient({ + request, + currentUser: coreContext.security.authc.getCurrentUser(), + spaceId: getSpaceId(), + }) ), + getInferenceClient: memoize(() => startPlugins.inference.getClient({ request })), + getExceptionListClient: () => { if (!lists) { return null; @@ -225,6 +233,8 @@ export class RequestContextFactory implements IRequestContextFactory { taskManager: startPlugins.taskManager, auditLogger: getAuditLogger(), kibanaVersion: options.kibanaVersion, + telemetry: core.analytics, + config: config.entityAnalytics.entityStore, }); }), }; diff --git a/x-pack/plugins/security_solution/server/types.ts b/x-pack/plugins/security_solution/server/types.ts index 1355904dbe7f7..882dd651a9614 100644 --- a/x-pack/plugins/security_solution/server/types.ts +++ b/x-pack/plugins/security_solution/server/types.ts @@ -20,6 +20,8 @@ import type { AlertsClient, IRuleDataService } from '@kbn/rule-registry-plugin/s import type { Readable } from 'stream'; import type { AuditLogger } from '@kbn/security-plugin-types-server'; +import type { DataViewsService } from '@kbn/data-views-plugin/common'; +import type { InferenceClient } from '@kbn/inference-plugin/server'; import type { Immutable } from '../common/endpoint/types'; import { AppClient } from './client'; import type { ConfigType } from './config'; @@ -35,7 +37,7 @@ import type { RiskScoreDataClient } from './lib/entity_analytics/risk_score/risk import type { AssetCriticalityDataClient } from './lib/entity_analytics/asset_criticality'; import type { IDetectionRulesClient } from './lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client_interface'; import type { EntityStoreDataClient } from './lib/entity_analytics/entity_store/entity_store_data_client'; -import type { SiemMigrationsClient } from './lib/siem_migrations/types'; +import type { SiemRuleMigrationsClient } from './lib/siem_migrations/rules/types'; export { AppClient }; export interface SecuritySolutionApiRequestHandlerContext { @@ -52,13 +54,15 @@ export interface SecuritySolutionApiRequestHandlerContext { getRuleExecutionLog: () => IRuleExecutionLogForRoutes; getRacClient: (req: KibanaRequest) => Promise; getAuditLogger: () => AuditLogger | undefined; + getDataViewsService: () => DataViewsService; getExceptionListClient: () => ExceptionListClient | null; getInternalFleetServices: () => EndpointInternalFleetServicesInterface; getRiskEngineDataClient: () => RiskEngineDataClient; getRiskScoreDataClient: () => RiskScoreDataClient; getAssetCriticalityDataClient: () => AssetCriticalityDataClient; getEntityStoreDataClient: () => EntityStoreDataClient; - getSiemMigrationsClient: () => SiemMigrationsClient; + getSiemRuleMigrationsClient: () => SiemRuleMigrationsClient; + getInferenceClient: () => InferenceClient; } export type SecuritySolutionRequestHandlerContext = CustomRequestHandlerContext<{ diff --git a/x-pack/plugins/security_solution/server/utils/with_security_span.ts b/x-pack/plugins/security_solution/server/utils/with_security_span.ts index 58787dc45d09b..f9f78600cfb8d 100644 --- a/x-pack/plugins/security_solution/server/utils/with_security_span.ts +++ b/x-pack/plugins/security_solution/server/utils/with_security_span.ts @@ -6,7 +6,7 @@ */ import type { SpanOptions } from '@kbn/apm-utils'; import { withSpan } from '@kbn/apm-utils'; -import type agent from 'elastic-apm-node'; +import agent from 'elastic-apm-node'; import { APP_ID } from '../../common/constants'; type Span = Exclude; @@ -35,3 +35,16 @@ export const withSecuritySpan = ( }, cb ); + +export const withSecuritySpanSync = (name: string, fn: (span: Span | null) => T): T => { + const span = agent.startSpan(name, APP_ID); + + try { + const result = fn(span); + return result; + } finally { + if (span) { + span.end(); + } + } +}; diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json index ae646627357f9..df743a666108e 100644 --- a/x-pack/plugins/security_solution/tsconfig.json +++ b/x-pack/plugins/security_solution/tsconfig.json @@ -15,11 +15,7 @@ "public/**/*.json", "../../../typings/**/*" ], - "exclude": [ - "target/**/*", - "**/cypress/**", - "public/management/cypress.config.ts" - ], + "exclude": ["target/**/*", "**/cypress/**", "public/management/cypress.config.ts"], "kbn_references": [ "@kbn/core", { @@ -209,7 +205,6 @@ "@kbn/core-theme-browser", "@kbn/integration-assistant-plugin", "@kbn/avc-banner", - "@kbn/security-solution-common", "@kbn/esql-ast", "@kbn/esql-validation-autocomplete", "@kbn/config", @@ -234,5 +229,6 @@ "@kbn/data-stream-adapter", "@kbn/core-lifecycle-server", "@kbn/core-user-profile-common", + "@kbn/langchain", ] } diff --git a/x-pack/plugins/security_solution_ess/kibana.jsonc b/x-pack/plugins/security_solution_ess/kibana.jsonc index b77bafa226adb..849c4eb529987 100644 --- a/x-pack/plugins/security_solution_ess/kibana.jsonc +++ b/x-pack/plugins/security_solution_ess/kibana.jsonc @@ -1,20 +1,29 @@ { "type": "plugin", "id": "@kbn/security-solution-ess", - "owner": "@elastic/security-solution", + "owner": [ + "@elastic/security-solution" + ], + "group": "security", + "visibility": "private", "description": "ESS customizations for Security Solution.", "plugin": { "id": "securitySolutionEss", - "server": true, "browser": true, - "configPath": ["xpack", "securitySolutionEss"], + "server": true, + "configPath": [ + "xpack", + "securitySolutionEss" + ], "requiredPlugins": [ "securitySolution", "management", "navigation", - "licensing", + "licensing" ], "optionalPlugins": [], - "requiredBundles": [ "kibanaReact"] + "requiredBundles": [ + "kibanaReact" + ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/security_solution_serverless/kibana.jsonc b/x-pack/plugins/security_solution_serverless/kibana.jsonc index 1829503bfe988..fa52190aa2784 100644 --- a/x-pack/plugins/security_solution_serverless/kibana.jsonc +++ b/x-pack/plugins/security_solution_serverless/kibana.jsonc @@ -1,15 +1,19 @@ { "type": "plugin", "id": "@kbn/security-solution-serverless", - "owner": "@elastic/security-solution", + "owner": [ + "@elastic/security-solution" + ], + "group": "security", + "visibility": "private", "description": "Serverless customizations for security.", "plugin": { "id": "securitySolutionServerless", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", - "securitySolutionServerless", + "securitySolutionServerless" ], "requiredPlugins": [ "kibanaReact", @@ -26,6 +30,6 @@ "optionalPlugins": [ "securitySolutionEss", "integrationAssistant" - ], + ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/serverless/kibana.jsonc b/x-pack/plugins/serverless/kibana.jsonc index 1c3d5cef4f7bf..e06b7147b79d0 100644 --- a/x-pack/plugins/serverless/kibana.jsonc +++ b/x-pack/plugins/serverless/kibana.jsonc @@ -1,16 +1,20 @@ { "type": "plugin", "id": "@kbn/serverless", - "owner": "@elastic/appex-sharedux", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "shared", "description": "The core Serverless plugin, providing APIs to Serverless Project plugins.", "plugin": { "id": "serverless", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "serverless", - "plugin", + "plugin" ], "requiredPlugins": [ "cloud" @@ -18,4 +22,4 @@ "optionalPlugins": [], "requiredBundles": [] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/serverless/public/types.ts b/x-pack/plugins/serverless/public/types.ts index 4627d24659b8e..3a416a676ee92 100644 --- a/x-pack/plugins/serverless/public/types.ts +++ b/x-pack/plugins/serverless/public/types.ts @@ -10,6 +10,7 @@ import type { ChromeSetProjectBreadcrumbsParams, SideNavComponent, NavigationTreeDefinition, + SolutionId, } from '@kbn/core-chrome-browser'; import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/public'; import type { Observable } from 'rxjs'; @@ -26,7 +27,7 @@ export interface ServerlessPluginStart { ) => void; setProjectHome(homeHref: string): void; initNavigation( - id: string, + id: SolutionId, navigationTree$: Observable, config?: { dataTestSubj?: string; diff --git a/x-pack/plugins/serverless_observability/kibana.jsonc b/x-pack/plugins/serverless_observability/kibana.jsonc index 95795670d0443..fce943c44865a 100644 --- a/x-pack/plugins/serverless_observability/kibana.jsonc +++ b/x-pack/plugins/serverless_observability/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/serverless-observability", - "owner": "@elastic/obs-ux-management-team", + "owner": [ + "@elastic/obs-ux-management-team" + ], + "group": "observability", + "visibility": "private", "description": "Serverless customizations for observability.", "plugin": { "id": "serverlessObservability", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "serverless", @@ -19,9 +23,9 @@ "observabilityShared", "management", "discover", - "security", + "security" ], "optionalPlugins": [], "requiredBundles": [] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/serverless_observability/public/navigation_tree.ts b/x-pack/plugins/serverless_observability/public/navigation_tree.ts index 5df900ee46812..7501a75abe876 100644 --- a/x-pack/plugins/serverless_observability/public/navigation_tree.ts +++ b/x-pack/plugins/serverless_observability/public/navigation_tree.ts @@ -70,147 +70,258 @@ export const navigationTree: NavigationTreeDefinition = { link: 'slo', }, { - id: 'aiops', - title: 'AIOps', - link: 'ml:anomalyDetection', - renderAs: 'accordion', - spaceBefore: null, + link: 'observabilityAIAssistant', + title: i18n.translate('xpack.serverlessObservability.nav.aiAssistant', { + defaultMessage: 'AI Assistant', + }), + }, + { link: 'inventory', spaceBefore: 'm' }, + { + id: 'apm', + title: i18n.translate('xpack.serverlessObservability.nav.applications', { + defaultMessage: 'Applications', + }), + renderAs: 'panelOpener', children: [ { - title: i18n.translate('xpack.serverlessObservability.nav.ml.jobs', { - defaultMessage: 'Anomaly detection', - }), - link: 'ml:anomalyDetection', - id: 'ml:anomalyDetection', - renderAs: 'item', children: [ { - link: 'ml:singleMetricViewer', - }, - { - link: 'ml:anomalyExplorer', + link: 'apm:services', + title: i18n.translate('xpack.serverlessObservability.nav.apm.services', { + defaultMessage: 'Service inventory', + }), }, + { link: 'apm:traces' }, + { link: 'apm:dependencies' }, + { link: 'apm:settings' }, { - link: 'ml:settings', + id: 'synthetics', + title: i18n.translate('xpack.serverlessObservability.nav.synthetics', { + defaultMessage: 'Synthetics', + }), + children: [ + { + title: i18n.translate( + 'xpack.serverlessObservability.nav.synthetics.overviewItem', + { + defaultMessage: 'Overview', + } + ), + id: 'synthetics-overview', + link: 'synthetics:overview', + breadcrumbStatus: 'hidden', + }, + { + link: 'synthetics:certificates', + title: i18n.translate( + 'xpack.serverlessObservability.nav.synthetics.certificatesItem', + { + defaultMessage: 'TLS certificates', + } + ), + id: 'synthetics-certificates', + breadcrumbStatus: 'hidden', + }, + ], }, ], }, - { - title: i18n.translate('xpack.serverlessObservability.ml.logRateAnalysis', { - defaultMessage: 'Log rate analysis', - }), - link: 'ml:logRateAnalysis', - getIsActive: ({ pathNameSerialized, prepend }) => { - return pathNameSerialized.includes(prepend('/app/ml/aiops/log_rate_analysis')); - }, - }, - { - title: i18n.translate('xpack.serverlessObservability.ml.changePointDetection', { - defaultMessage: 'Change point detection', - }), - link: 'ml:changePointDetections', - getIsActive: ({ pathNameSerialized, prepend }) => { - return pathNameSerialized.includes(prepend('/app/ml/aiops/change_point_detection')); - }, - }, - { - title: i18n.translate('xpack.serverlessObservability.nav.ml.job.notifications', { - defaultMessage: 'Job notifications', - }), - link: 'ml:notifications', - }, ], }, - { link: 'inventory', spaceBefore: 'm' }, { - id: 'apm', - title: i18n.translate('xpack.serverlessObservability.nav.applications', { - defaultMessage: 'Applications', + id: 'metrics', + title: i18n.translate('xpack.serverlessObservability.nav.infrastructure', { + defaultMessage: 'Infrastructure', }), - link: 'apm:services', - renderAs: 'accordion', + renderAs: 'panelOpener', children: [ { - link: 'apm:services', - getIsActive: ({ pathNameSerialized }) => { - const regex = /app\/apm\/.*service.*/; - return regex.test(pathNameSerialized); - }, - }, - { - link: 'apm:traces', - getIsActive: ({ pathNameSerialized, prepend }) => { - return pathNameSerialized.startsWith(prepend('/app/apm/traces')); - }, - }, - { - link: 'apm:dependencies', - getIsActive: ({ pathNameSerialized, prepend }) => { - return pathNameSerialized.startsWith(prepend('/app/apm/dependencies')); - }, - }, - { - link: 'apm:settings', - sideNavStatus: 'hidden', // only to be considered in the breadcrumbs + children: [ + { + link: 'metrics:inventory', + title: i18n.translate( + 'xpack.serverlessObservability.nav.infrastructureInventory', + { + defaultMessage: 'Infrastructure inventory', + } + ), + }, + { link: 'metrics:hosts' }, + { link: 'metrics:settings' }, + { link: 'metrics:assetDetails' }, + ], }, ], }, { - id: 'metrics', - title: i18n.translate('xpack.serverlessObservability.nav.infrastructure', { - defaultMessage: 'Infrastructure', + id: 'machine_learning-landing', + renderAs: 'panelOpener', + title: i18n.translate('xpack.serverlessObservability.nav.machineLearning', { + defaultMessage: 'Machine learning', }), - link: 'metrics:inventory', - renderAs: 'accordion', children: [ { - link: 'metrics:inventory', - getIsActive: ({ pathNameSerialized, prepend }) => { - return pathNameSerialized.startsWith(prepend('/app/metrics/inventory')); - }, + children: [ + { + link: 'ml:overview', + }, + { + link: 'ml:notifications', + }, + { + link: 'ml:memoryUsage', + title: i18n.translate( + 'xpack.serverlessObservability.nav.machineLearning.memoryUsage', + { + defaultMessage: 'Memory usage', + } + ), + }, + ], }, { - link: 'metrics:hosts', - getIsActive: ({ pathNameSerialized, prepend }) => { - return pathNameSerialized.startsWith(prepend('/app/metrics/hosts')); - }, + id: 'category-anomaly_detection', + title: i18n.translate('xpack.serverlessObservability.nav.ml.anomaly_detection', { + defaultMessage: 'Anomaly detection', + }), + breadcrumbStatus: 'hidden', + children: [ + { + link: 'ml:anomalyDetection', + title: i18n.translate( + 'xpack.serverlessObservability.nav.ml.anomaly_detection.jobs', + { + defaultMessage: 'Jobs', + } + ), + }, + { + link: 'ml:anomalyExplorer', + }, + { + link: 'ml:singleMetricViewer', + }, + { + link: 'ml:settings', + }, + { + link: 'ml:suppliedConfigurations', + }, + ], }, { - link: 'metrics:settings', - sideNavStatus: 'hidden', // only to be considered in the breadcrumbs + id: 'category-data_frame analytics', + title: i18n.translate('xpack.serverlessObservability.nav.ml.data_frame_analytics', { + defaultMessage: 'Data frame analytics', + }), + breadcrumbStatus: 'hidden', + children: [ + { + link: 'ml:dataFrameAnalytics', + title: i18n.translate( + 'xpack.serverlessObservability.nav.ml.data_frame_analytics.jobs', + { + defaultMessage: 'Jobs', + } + ), + }, + { + link: 'ml:resultExplorer', + }, + { + link: 'ml:analyticsMap', + }, + ], }, { - link: 'metrics:assetDetails', - sideNavStatus: 'hidden', // only to be considered in the breadcrumbs + id: 'category-model_management', + title: i18n.translate('xpack.serverlessObservability.nav.ml.model_management', { + defaultMessage: 'Model management', + }), + breadcrumbStatus: 'hidden', + children: [ + { + link: 'ml:nodesOverview', + title: i18n.translate( + 'xpack.serverlessObservability.nav.ml.model_management.trainedModels', + { + defaultMessage: 'Trained models', + } + ), + }, + ], }, - ], - }, - { - id: 'synthetics', - title: i18n.translate('xpack.serverlessObservability.nav.synthetics', { - defaultMessage: 'Synthetics', - }), - renderAs: 'accordion', - breadcrumbStatus: 'hidden', - children: [ { - title: i18n.translate('xpack.serverlessObservability.nav.synthetics.overviewItem', { - defaultMessage: 'Overview', + id: 'category-data_visualizer', + title: i18n.translate('xpack.serverlessObservability.nav.ml.data_visualizer', { + defaultMessage: 'Data visualizer', }), - id: 'synthetics-overview', - link: 'synthetics:overview', breadcrumbStatus: 'hidden', + children: [ + { + link: 'ml:fileUpload', + title: i18n.translate( + 'xpack.serverlessObservability.nav.ml.data_visualizer.file_data_visualizer', + { + defaultMessage: 'File data visualizer', + } + ), + }, + { + link: 'ml:indexDataVisualizer', + title: i18n.translate( + 'xpack.serverlessObservability.nav.ml.data_visualizer.data_view_data_visualizer', + { + defaultMessage: 'Data view data visualizer', + } + ), + }, + { + link: 'ml:dataDrift', + title: i18n.translate( + 'xpack.serverlessObservability.nav.ml.data_visualizer.data_drift', + { + defaultMessage: 'Data drift', + } + ), + }, + ], }, { - link: 'synthetics:certificates', - title: i18n.translate( - 'xpack.serverlessObservability.nav.synthetics.certificatesItem', - { - defaultMessage: 'TLS Certificates', - } - ), - id: 'synthetics-certificates', + id: 'category-aiops_labs', + title: i18n.translate('xpack.serverlessObservability.nav.ml.aiops_labs', { + defaultMessage: 'Aiops labs', + }), breadcrumbStatus: 'hidden', + children: [ + { + link: 'ml:logRateAnalysis', + title: i18n.translate( + 'xpack.serverlessObservability.nav.ml.aiops_labs.log_rate_analysis', + { + defaultMessage: 'Log rate analysis', + } + ), + }, + { + link: 'ml:logPatternAnalysis', + title: i18n.translate( + 'xpack.serverlessObservability.nav.ml.aiops_labs.log_pattern_analysis', + { + defaultMessage: 'Log pattern analysis', + } + ), + }, + { + link: 'ml:changePointDetections', + title: i18n.translate( + 'xpack.serverlessObservability.nav.ml.aiops_labs.change_point_detection', + { + defaultMessage: 'Change point detection', + } + ), + }, + ], }, ], }, diff --git a/x-pack/plugins/serverless_search/kibana.jsonc b/x-pack/plugins/serverless_search/kibana.jsonc index 504c346262492..f7b404edb37b1 100644 --- a/x-pack/plugins/serverless_search/kibana.jsonc +++ b/x-pack/plugins/serverless_search/kibana.jsonc @@ -1,13 +1,21 @@ { "type": "plugin", "id": "@kbn/serverless-search", - "owner": "@elastic/search-kibana", + "owner": [ + "@elastic/search-kibana" + ], + "group": "search", + "visibility": "private", "description": "Serverless customizations for search.", "plugin": { "id": "serverlessSearch", - "server": true, "browser": true, - "configPath": ["xpack", "serverless", "search"], + "server": true, + "configPath": [ + "xpack", + "serverless", + "search" + ], "requiredPlugins": [ "cloud", "console", diff --git a/x-pack/plugins/serverless_search/public/plugin.ts b/x-pack/plugins/serverless_search/public/plugin.ts index 491252a6d9e9f..3d246e4be2929 100644 --- a/x-pack/plugins/serverless_search/public/plugin.ts +++ b/x-pack/plugins/serverless_search/public/plugin.ts @@ -149,7 +149,7 @@ export class ServerlessSearchPlugin serverless.setProjectHome(services.searchIndices.startRoute); const navigationTree$ = of(navigationTree()); - serverless.initNavigation('search', navigationTree$, { dataTestSubj: 'svlSearchSideNav' }); + serverless.initNavigation('es', navigationTree$, { dataTestSubj: 'svlSearchSideNav' }); const extendCardNavDefinitions = serverless.getNavigationCards( security.authz.isRoleManagementEnabled() diff --git a/x-pack/plugins/session_view/kibana.jsonc b/x-pack/plugins/session_view/kibana.jsonc index d247f924256bb..3ec03862e6af9 100644 --- a/x-pack/plugins/session_view/kibana.jsonc +++ b/x-pack/plugins/session_view/kibana.jsonc @@ -1,13 +1,26 @@ { "type": "plugin", "id": "@kbn/session-view-plugin", - "owner": "@elastic/kibana-cloud-security-posture", + "owner": [ + "@elastic/kibana-cloud-security-posture" + ], + "group": "security", + "visibility": "private", "plugin": { "id": "sessionView", - "server": true, "browser": true, - "requiredPlugins": ["data", "timelines", "ruleRegistry"], - "optionalPlugins": ["usageCollection"], - "requiredBundles": ["kibanaReact", "esUiShared"] + "server": true, + "requiredPlugins": [ + "data", + "timelines", + "ruleRegistry" + ], + "optionalPlugins": [ + "usageCollection" + ], + "requiredBundles": [ + "kibanaReact", + "esUiShared" + ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/buttons.tsx b/x-pack/plugins/session_view/public/components/process_tree_node/buttons.tsx index f5e2220c5e909..401c55ca90c8d 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_node/buttons.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_node/buttons.tsx @@ -86,8 +86,8 @@ export const AlertButton = ({ {alertsCount > 1 ? ALERTS : ALERT} {alertsCount > 1 && (alertsCount > MAX_ALERT_COUNT ? ` (${MAX_ALERT_COUNT}+)` : ` (${alertsCount})`)} - {alertIcons?.map((icon: string) => ( - + {alertIcons?.map((icon: string, index: number) => ( + ))} diff --git a/x-pack/plugins/session_view/public/methods/index.tsx b/x-pack/plugins/session_view/public/methods/index.tsx index ad8d77660b3b1..43295737c21f1 100644 --- a/x-pack/plugins/session_view/public/methods/index.tsx +++ b/x-pack/plugins/session_view/public/methods/index.tsx @@ -28,10 +28,7 @@ const SUPPORTED_PACKAGES = [ CLOUD_DEFEND_DATA_SOURCE, AUDITBEAT_DATA_SOURCE, ]; -const INDEX_REGEX = new RegExp( - `([a-z0-9_-]+\:)?[a-z0-9\-.]*(${SUPPORTED_PACKAGES.join('|')})`, - 'i' -); +const INDEX_REGEX = new RegExp(`([a-z0-9_-]+\:)?[a-z0-9-.]*(${SUPPORTED_PACKAGES.join('|')})`, 'i'); export const DEFAULT_INDEX = 'logs-*'; export const CLOUD_DEFEND_INDEX = 'logs-cloud_defend.*'; diff --git a/x-pack/plugins/session_view/public/test/index.tsx b/x-pack/plugins/session_view/public/test/index.tsx index 23f5fe8b25ce1..876d0427e91a4 100644 --- a/x-pack/plugins/session_view/public/test/index.tsx +++ b/x-pack/plugins/session_view/public/test/index.tsx @@ -116,7 +116,7 @@ export const createAppRootMockRenderer = (): AppContextTestRender => { }, }); - const AppWrapper: React.FC<{ children: React.ReactElement }> = ({ children }) => ( + const AppWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( {children} diff --git a/x-pack/plugins/snapshot_restore/kibana.jsonc b/x-pack/plugins/snapshot_restore/kibana.jsonc index 07590900a7a69..10cbead02e85c 100644 --- a/x-pack/plugins/snapshot_restore/kibana.jsonc +++ b/x-pack/plugins/snapshot_restore/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/snapshot-restore-plugin", - "owner": "@elastic/kibana-management", + "owner": [ + "@elastic/kibana-management" + ], + "group": "platform", + "visibility": "private", "plugin": { "id": "snapshotRestore", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "snapshot_restore" @@ -27,4 +31,4 @@ "kibanaReact" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/spaces/common/types/space/v1.ts b/x-pack/plugins/spaces/common/types/space/v1.ts index ebd841e914e69..3d7bb94cf65ab 100644 --- a/x-pack/plugins/spaces/common/types/space/v1.ts +++ b/x-pack/plugins/spaces/common/types/space/v1.ts @@ -5,11 +5,11 @@ * 2.0. */ -import type { OnBoardingDefaultSolution } from '@kbn/cloud-plugin/common'; +import type { SolutionId } from '@kbn/core-chrome-browser'; import type { SOLUTION_VIEW_CLASSIC } from '../../constants'; -export type SolutionView = OnBoardingDefaultSolution | typeof SOLUTION_VIEW_CLASSIC; +export type SolutionView = SolutionId | typeof SOLUTION_VIEW_CLASSIC; /** * A Space. diff --git a/x-pack/plugins/spaces/kibana.jsonc b/x-pack/plugins/spaces/kibana.jsonc index f59caa16837c3..e502f1de9d8a6 100644 --- a/x-pack/plugins/spaces/kibana.jsonc +++ b/x-pack/plugins/spaces/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/spaces-plugin", - "owner": "@elastic/kibana-security", + "owner": [ + "@elastic/kibana-security" + ], + "group": "platform", + "visibility": "shared", "description": "This plugin provides the Spaces feature, which allows saved objects to be organized into meaningful categories.", "plugin": { "id": "spaces", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "spaces" @@ -19,15 +23,17 @@ "home", "management", "usageCollection", - "cloud", + "cloud" ], "requiredBundles": [ "esUiShared", "kibanaReact" ], + "runtimePluginDependencies": [ + "security" + ], "extraPublicDirs": [ "common" - ], - "runtimePluginDependencies": ["security"] + ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.test.tsx index 7f15a54e095a6..1c97b9c4d2bc6 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.test.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.test.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { EuiThemeProvider } from '@elastic/eui'; import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import crypto from 'crypto'; @@ -81,46 +82,48 @@ const renderPrivilegeRolesForm = ({ preSelectedRoles?: Role[]; } = {}) => { return render( - - _), - navigateToUrl: jest.fn(), - license: licenseMock, - isRoleManagementEnabled: true, - capabilities: { - navLinks: {}, - management: {}, - catalogue: {}, - spaces: { manage: true }, - }, - dispatch: dispatchMock, - state: { - roles: new Map(), - fetchRolesError: false, - }, - invokeClient: spacesClientsInvocatorMock, - }} - > - + + _), + navigateToUrl: jest.fn(), + license: licenseMock, + isRoleManagementEnabled: true, + capabilities: { + navLinks: {}, + management: {}, + catalogue: {}, + spaces: { manage: true }, + }, + dispatch: dispatchMock, + state: { + roles: new Map(), + fetchRolesError: false, + }, + invokeClient: spacesClientsInvocatorMock, }} - /> - - + > + + + + ); }; diff --git a/x-pack/plugins/spaces/public/nav_control/solution_view_tour/solution_view_tour.tsx b/x-pack/plugins/spaces/public/nav_control/solution_view_tour/solution_view_tour.tsx index bf80bf92bdf4e..caa9cc17b053c 100644 --- a/x-pack/plugins/spaces/public/nav_control/solution_view_tour/solution_view_tour.tsx +++ b/x-pack/plugins/spaces/public/nav_control/solution_view_tour/solution_view_tour.tsx @@ -9,7 +9,7 @@ import { EuiButtonEmpty, EuiLink, EuiText, EuiTourStep } from '@elastic/eui'; import React from 'react'; import type { FC, PropsWithChildren } from 'react'; -import type { OnBoardingDefaultSolution } from '@kbn/cloud-plugin/common'; +import type { SolutionId } from '@kbn/core-chrome-browser'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -26,7 +26,7 @@ const LearnMoreLink = () => ( ); -const solutionMap: Record = { +const solutionMap: Record = { es: i18n.translate('xpack.spaces.navControl.tour.esSolution', { defaultMessage: 'Search', }), diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx index 66baa2c5d97b9..bb303a4f7d6ac 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx @@ -174,7 +174,7 @@ export const ShareModeControl = (props: Props) => { onChange(updatedSpaceIds); }} legend={buttonGroupLegend} - color="success" + color="text" isFullWidth={true} isDisabled={!canShareToAllSpaces || isGlobalControlChangeProhibited} /> diff --git a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts index d48095638babf..33c32acfef011 100644 --- a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts +++ b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts @@ -66,7 +66,7 @@ const features = [ category: { id: 'securitySolution' }, }, { - // feature 4 intentionally delcares the same items as feature 3 + // feature 4 intentionally declares the same items as feature 3 id: 'feature_4', name: 'Feature 4', app: ['feature3', 'feature3_app'], @@ -87,6 +87,32 @@ const features = [ }, category: { id: 'observability' }, }, + { + deprecated: { notice: 'It was a mistake.' }, + id: 'deprecated_feature', + name: 'Deprecated Feature', + // Expose the same `app` and `catalogue` entries as `feature_2` to make sure they are disabled + // when `feature_2` is disabled even if the deprecated feature isn't explicitly disabled. + app: ['feature2'], + catalogue: ['feature2Entry'], + category: { id: 'deprecated', label: 'deprecated' }, + privileges: { + all: { + savedObject: { all: [], read: [] }, + ui: ['ui_deprecated_all'], + app: ['feature2'], + catalogue: ['feature2Entry'], + replacedBy: [{ feature: 'feature_2', privileges: ['all'] }], + }, + read: { + savedObject: { all: [], read: [] }, + ui: ['ui_deprecated_read'], + app: ['feature2'], + catalogue: ['feature2Entry'], + replacedBy: [{ feature: 'feature_2', privileges: ['all'] }], + }, + }, + }, ] as unknown as KibanaFeature[]; const buildCapabilities = () => @@ -366,14 +392,12 @@ describe('capabilitiesSwitcher', () => { { space.solution = 'oblt'; - // It should disable enterpriseSearch and securitySolution features - // which correspond to feature_1 and feature_3 + // It should disable securitySolution features + // which corresponds to feature_3 const result = await switcher(request, capabilities, false); const expectedCapabilities = buildCapabilities(); - expectedCapabilities.feature_1.bar = false; - expectedCapabilities.feature_1.foo = false; expectedCapabilities.feature_3.bar = false; expectedCapabilities.feature_3.foo = false; diff --git a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts index 90ee85fece486..41d5dcdf2cb14 100644 --- a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts +++ b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts @@ -72,7 +72,7 @@ function toggleDisabledFeatures( (acc, feature) => { if (disabledFeatureKeys.includes(feature.id)) { acc.disabledFeatures.push(feature); - } else { + } else if (!feature.deprecated) { acc.enabledFeatures.push(feature); } return acc; diff --git a/x-pack/plugins/spaces/server/default_space/create_default_space.ts b/x-pack/plugins/spaces/server/default_space/create_default_space.ts index 53658fd68a0f7..3c53543a756fb 100644 --- a/x-pack/plugins/spaces/server/default_space/create_default_space.ts +++ b/x-pack/plugins/spaces/server/default_space/create_default_space.ts @@ -5,9 +5,9 @@ * 2.0. */ -import type { OnBoardingDefaultSolution } from '@kbn/cloud-plugin/common'; import type { Logger, SavedObjectsRepository, SavedObjectsServiceStart } from '@kbn/core/server'; import { SavedObjectsErrorHelpers } from '@kbn/core/server'; +import type { SolutionId } from '@kbn/core-chrome-browser'; import { i18n } from '@kbn/i18n'; import { DEFAULT_SPACE_ID } from '../../common/constants'; @@ -15,7 +15,7 @@ import { DEFAULT_SPACE_ID } from '../../common/constants'; interface Deps { getSavedObjects: () => Promise>; logger: Logger; - solution?: OnBoardingDefaultSolution; + solution?: SolutionId; } export async function createDefaultSpace({ getSavedObjects, logger, solution }: Deps) { diff --git a/x-pack/plugins/spaces/server/default_space/default_space_service.ts b/x-pack/plugins/spaces/server/default_space/default_space_service.ts index 1faaf5490de89..9d537f93f63fc 100644 --- a/x-pack/plugins/spaces/server/default_space/default_space_service.ts +++ b/x-pack/plugins/spaces/server/default_space/default_space_service.ts @@ -19,9 +19,9 @@ import { timer, } from 'rxjs'; -import type { OnBoardingDefaultSolution } from '@kbn/cloud-plugin/common'; import type { CoreSetup, Logger, SavedObjectsServiceStart, ServiceStatus } from '@kbn/core/server'; import { ServiceStatusLevels } from '@kbn/core/server'; +import type { SolutionId } from '@kbn/core-chrome-browser'; import type { ILicense } from '@kbn/licensing-plugin/server'; import { createDefaultSpace } from './create_default_space'; @@ -33,7 +33,7 @@ interface Deps { license$: Observable; spacesLicense: SpacesLicense; logger: Logger; - solution?: OnBoardingDefaultSolution; + solution?: SolutionId; } export const RETRY_SCALE_DURATION = 100; diff --git a/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts index 24a94b43029e0..9da144facf4f4 100644 --- a/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts +++ b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts @@ -49,9 +49,21 @@ describe.skip('onPostAuthInterceptor', () => { */ function initKbnServer(router: IRouter, basePath: IBasePath) { - router.get({ path: '/api/np_test/foo', validate: false }, (context, req, h) => { - return h.ok({ body: { path: req.url.pathname, basePath: basePath.get(req) } }); - }); + router.get( + { + path: '/api/np_test/foo', + security: { + authz: { + enabled: false, + reason: 'This route is opted out from authorization', + }, + }, + validate: false, + }, + (context, req, h) => { + return h.ok({ body: { path: req.url.pathname, basePath: basePath.get(req) } }); + } + ); } async function request( diff --git a/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.test.ts b/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.test.ts index bf3d0a57ccae2..3a5ce95ec3341 100644 --- a/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.test.ts +++ b/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.test.ts @@ -38,14 +38,32 @@ describe.skip('onRequestInterceptor', () => { function initKbnServer(router: IRouter, basePath: IBasePath) { router.get( - { path: '/np_foo', validate: false }, + { + path: '/np_foo', + security: { + authz: { + enabled: false, + reason: 'This route is opted out from authorization', + }, + }, + validate: false, + }, (context: unknown, req: KibanaRequest, h: KibanaResponseFactory) => { return h.ok({ body: { path: req.url.pathname, basePath: basePath.get(req) } }); } ); router.get( - { path: '/some/path/s/np_foo/bar', validate: false }, + { + path: '/some/path/s/np_foo/bar', + security: { + authz: { + enabled: false, + reason: 'This route is opted out from authorization', + }, + }, + validate: false, + }, (context: unknown, req: KibanaRequest, h: KibanaResponseFactory) => { return h.ok({ body: { path: req.url.pathname, basePath: basePath.get(req) } }); } @@ -54,6 +72,12 @@ describe.skip('onRequestInterceptor', () => { router.get( { path: '/i/love/np_spaces', + security: { + authz: { + enabled: false, + reason: 'This route is opted out from authorization', + }, + }, validate: { query: schema.object({ queryParam: schema.string({ diff --git a/x-pack/plugins/spaces/server/lib/utils/space_solution_disabled_features.test.ts b/x-pack/plugins/spaces/server/lib/utils/space_solution_disabled_features.test.ts index 908a4ee2ced57..f19b4d585dc22 100644 --- a/x-pack/plugins/spaces/server/lib/utils/space_solution_disabled_features.test.ts +++ b/x-pack/plugins/spaces/server/lib/utils/space_solution_disabled_features.test.ts @@ -58,7 +58,7 @@ describe('#withSpaceSolutionDisabledFeatures', () => { }); describe('when the space solution is "oblt"', () => { - test('it removes the "search" and "security" features', () => { + test('it removes the "security" features', () => { const spaceDisabledFeatures: string[] = []; const spaceSolution = 'oblt'; @@ -68,7 +68,7 @@ describe('#withSpaceSolutionDisabledFeatures', () => { spaceSolution ); - expect(result).toEqual(['feature2', 'feature3']); + expect(result).toEqual(['feature3']); }); }); diff --git a/x-pack/plugins/spaces/server/lib/utils/space_solution_disabled_features.ts b/x-pack/plugins/spaces/server/lib/utils/space_solution_disabled_features.ts index 4e66260f3d057..2682daf3a1c54 100644 --- a/x-pack/plugins/spaces/server/lib/utils/space_solution_disabled_features.ts +++ b/x-pack/plugins/spaces/server/lib/utils/space_solution_disabled_features.ts @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import type { SolutionId } from '@kbn/core-chrome-browser'; import type { KibanaFeature } from '@kbn/features-plugin/server'; import type { SolutionView } from '../../../common'; @@ -23,11 +24,22 @@ const getFeatureIdsForCategories = ( .map((feature) => feature.id); }; +/** + * These features will be enabled per solution view, even if they fall under a category that is disabled in the solution. + */ + +const enabledFeaturesPerSolution: Record = { + es: ['observabilityAIAssistant'], + oblt: [], + security: [], +}; + /** * When a space has a `solution` defined, we want to disable features that are not part of that solution. * This function takes the current space's disabled features and the space solution and returns * the updated array of disabled features. * + * @param features The list of all Kibana registered features. * @param spaceDisabledFeatures The current space's disabled features * @param spaceSolution The current space's solution (es, oblt, security or classic) * @returns The updated array of disabled features @@ -47,17 +59,16 @@ export function withSpaceSolutionDisabledFeatures( disabledFeatureKeysFromSolution = getFeatureIdsForCategories(features, [ 'observability', 'securitySolution', - ]); + ]).filter((featureId) => !enabledFeaturesPerSolution.es.includes(featureId)); } else if (spaceSolution === 'oblt') { disabledFeatureKeysFromSolution = getFeatureIdsForCategories(features, [ - 'enterpriseSearch', 'securitySolution', - ]); + ]).filter((featureId) => !enabledFeaturesPerSolution.oblt.includes(featureId)); } else if (spaceSolution === 'security') { disabledFeatureKeysFromSolution = getFeatureIdsForCategories(features, [ 'observability', 'enterpriseSearch', - ]); + ]).filter((featureId) => !enabledFeaturesPerSolution.security.includes(featureId)); } return Array.from(new Set([...disabledFeatureKeysFromSolution])); diff --git a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts index b0758f5645cc1..509de14e2928b 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts @@ -37,9 +37,14 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) { router.post( { path: '/api/spaces/_copy_saved_objects', + security: { + authz: { + requiredPrivileges: ['copySavedObjectsToSpaces'], + }, + }, options: { access: isServerless ? 'internal' : 'public', - tags: ['access:copySavedObjectsToSpaces', 'oas-tag:spaces'], + tags: ['oas-tag:spaces'], summary: `Copy saved objects between spaces`, description: 'It also allows you to automatically copy related objects, so when you copy a dashboard, this can automatically copy over the associated visualizations, data views, and saved searches, as required. You can request to overwrite any objects that already exist in the target space if they share an identifier or you can use the resolve copy saved objects conflicts API to do this on a per-object basis.', @@ -188,9 +193,14 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) { router.post( { path: '/api/spaces/_resolve_copy_saved_objects_errors', + security: { + authz: { + requiredPrivileges: ['copySavedObjectsToSpaces'], + }, + }, options: { access: isServerless ? 'internal' : 'public', - tags: ['access:copySavedObjectsToSpaces'], + summary: `Resolve conflicts copying saved objects`, description: 'Overwrite saved objects that are returned as errors from the copy saved objects to space API.', diff --git a/x-pack/plugins/spaces/server/routes/api/external/delete.ts b/x-pack/plugins/spaces/server/routes/api/external/delete.ts index 06bef75774aa0..4908f1a747b74 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/delete.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/delete.ts @@ -31,6 +31,13 @@ export function initDeleteSpacesApi(deps: ExternalRouteDeps) { .addVersion( { version: API_VERSIONS.public.v1, + security: { + authz: { + enabled: false, + reason: + 'This route delegates authorization to the spaces service via a scoped spaces client', + }, + }, validate: { request: { params: schema.object({ diff --git a/x-pack/plugins/spaces/server/routes/api/external/disable_legacy_url_aliases.ts b/x-pack/plugins/spaces/server/routes/api/external/disable_legacy_url_aliases.ts index a1610bbfed975..2703e7c36f0cd 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/disable_legacy_url_aliases.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/disable_legacy_url_aliases.ts @@ -18,6 +18,13 @@ export function initDisableLegacyUrlAliasesApi(deps: ExternalRouteDeps) { router.post( { path: '/api/spaces/_disable_legacy_url_aliases', + security: { + authz: { + enabled: false, + reason: + 'This route delegates authorization to the spaces service via a scoped spaces client', + }, + }, options: { access: isServerless ? 'internal' : 'public', summary: 'Disable legacy URL aliases', diff --git a/x-pack/plugins/spaces/server/routes/api/external/get.ts b/x-pack/plugins/spaces/server/routes/api/external/get.ts index b1ab2dc575774..3c9871e44490c 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/get.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/get.ts @@ -28,6 +28,13 @@ export function initGetSpaceApi(deps: ExternalRouteDeps) { .addVersion( { version: API_VERSIONS.public.v1, + security: { + authz: { + enabled: false, + reason: + 'This route delegates authorization to the spaces service via a scoped spaces client', + }, + }, validate: { request: { params: schema.object({ diff --git a/x-pack/plugins/spaces/server/routes/api/external/get_all.ts b/x-pack/plugins/spaces/server/routes/api/external/get_all.ts index 746735bb3736e..f7a0c4592387c 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/get_all.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/get_all.ts @@ -27,6 +27,13 @@ export function initGetAllSpacesApi(deps: ExternalRouteDeps) { .addVersion( { version: API_VERSIONS.public.v1, + security: { + authz: { + enabled: false, + reason: + 'This route delegates authorization to the spaces service via a scoped spaces client', + }, + }, validate: { request: { query: schema.object({ diff --git a/x-pack/plugins/spaces/server/routes/api/external/get_shareable_references.ts b/x-pack/plugins/spaces/server/routes/api/external/get_shareable_references.ts index f49070be66fe2..98dab60cd9c95 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/get_shareable_references.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/get_shareable_references.ts @@ -17,6 +17,13 @@ export function initGetShareableReferencesApi(deps: ExternalRouteDeps) { router.post( { path: '/api/spaces/_get_shareable_references', + security: { + authz: { + enabled: false, + reason: + 'This route delegates authorization to the spaces service via a scoped spaces client', + }, + }, options: { access: isServerless ? 'internal' : 'public', summary: `Get shareable references`, diff --git a/x-pack/plugins/spaces/server/routes/api/external/post.test.ts b/x-pack/plugins/spaces/server/routes/api/external/post.test.ts index 984d684762159..88c846b77eb53 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/post.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/post.test.ts @@ -56,13 +56,9 @@ describe('Spaces Public API', () => { basePath: httpService.basePath, }); - const featuresPluginMockStart = featuresPluginMock.createStart(); - - featuresPluginMockStart.getKibanaFeatures.mockReturnValue([]); - const usageStatsServicePromise = Promise.resolve(usageStatsServiceMock.createSetupContract()); - const clientServiceStart = clientService.start(coreStart, featuresPluginMockStart); + const clientServiceStart = clientService.start(coreStart, featuresPluginMock.createStart()); const spacesServiceStart = service.start({ basePath: coreStart.http.basePath, diff --git a/x-pack/plugins/spaces/server/routes/api/external/post.ts b/x-pack/plugins/spaces/server/routes/api/external/post.ts index de1ec53aaee44..2ecd70828d570 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/post.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/post.ts @@ -30,6 +30,13 @@ export function initPostSpacesApi(deps: ExternalRouteDeps) { .addVersion( { version: API_VERSIONS.public.v1, + security: { + authz: { + enabled: false, + reason: + 'This route delegates authorization to the spaces service via a scoped spaces client', + }, + }, validate: { request: { body: getSpaceSchema(isServerless), diff --git a/x-pack/plugins/spaces/server/routes/api/external/put.test.ts b/x-pack/plugins/spaces/server/routes/api/external/put.test.ts index 8aa71d30fc4bb..cf2e9981fd024 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/put.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/put.test.ts @@ -56,13 +56,9 @@ describe('PUT /api/spaces/space', () => { basePath: httpService.basePath, }); - const featuresPluginMockStart = featuresPluginMock.createStart(); - - featuresPluginMockStart.getKibanaFeatures.mockReturnValue([]); - const usageStatsServicePromise = Promise.resolve(usageStatsServiceMock.createSetupContract()); - const clientServiceStart = clientService.start(coreStart, featuresPluginMockStart); + const clientServiceStart = clientService.start(coreStart, featuresPluginMock.createStart()); const spacesServiceStart = service.start({ basePath: coreStart.http.basePath, diff --git a/x-pack/plugins/spaces/server/routes/api/external/put.ts b/x-pack/plugins/spaces/server/routes/api/external/put.ts index 740e81bac446e..abdac1f0977d1 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/put.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/put.ts @@ -29,6 +29,13 @@ export function initPutSpacesApi(deps: ExternalRouteDeps) { .addVersion( { version: API_VERSIONS.public.v1, + security: { + authz: { + enabled: false, + reason: + 'This route delegates authorization to the spaces service via a scoped spaces client', + }, + }, validate: { request: { params: schema.object({ diff --git a/x-pack/plugins/spaces/server/routes/api/external/update_objects_spaces.ts b/x-pack/plugins/spaces/server/routes/api/external/update_objects_spaces.ts index 9fb2a8626a841..fb9137a834349 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/update_objects_spaces.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/update_objects_spaces.ts @@ -40,6 +40,13 @@ export function initUpdateObjectsSpacesApi(deps: ExternalRouteDeps) { router.post( { path: '/api/spaces/_update_objects_spaces', + security: { + authz: { + enabled: false, + reason: + 'This route delegates authorization to the spaces service via a scoped spaces client', + }, + }, options: { access: isServerless ? 'internal' : 'public', summary: `Update saved objects in spaces`, diff --git a/x-pack/plugins/spaces/server/routes/api/internal/get_active_space.ts b/x-pack/plugins/spaces/server/routes/api/internal/get_active_space.ts index 2996e7dbc4ed1..2480b0c003fee 100644 --- a/x-pack/plugins/spaces/server/routes/api/internal/get_active_space.ts +++ b/x-pack/plugins/spaces/server/routes/api/internal/get_active_space.ts @@ -15,6 +15,13 @@ export function initGetActiveSpaceApi(deps: InternalRouteDeps) { router.get( { path: '/internal/spaces/_active_space', + security: { + authz: { + enabled: false, + reason: + 'This route delegates authorization to the spaces service getActiveSpace API, which uses a scoped spaces client', + }, + }, validate: false, }, createLicensedRouteHandler(async (context, request, response) => { diff --git a/x-pack/plugins/spaces/server/routes/api/internal/get_content_summary.test.ts b/x-pack/plugins/spaces/server/routes/api/internal/get_content_summary.test.ts index 3de451ddfa730..a94d51fafc05d 100644 --- a/x-pack/plugins/spaces/server/routes/api/internal/get_content_summary.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/internal/get_content_summary.test.ts @@ -120,7 +120,7 @@ describe('GET /internal/spaces/{spaceId}/content_summary', () => { const paramsSchema = (config.validate as any).params; - expect(config.options).toEqual({ tags: ['access:manageSpaces'] }); + expect(config.security?.authz).toEqual({ requiredPrivileges: ['manage_spaces'] }); expect(() => paramsSchema.validate({})).toThrowErrorMatchingInlineSnapshot( `"[spaceId]: expected value of type [string] but got [undefined]"` ); diff --git a/x-pack/plugins/spaces/server/routes/api/internal/get_content_summary.ts b/x-pack/plugins/spaces/server/routes/api/internal/get_content_summary.ts index b582c304fd13b..6c80a645f0c62 100644 --- a/x-pack/plugins/spaces/server/routes/api/internal/get_content_summary.ts +++ b/x-pack/plugins/spaces/server/routes/api/internal/get_content_summary.ts @@ -38,8 +38,10 @@ export function initGetSpaceContentSummaryApi(deps: InternalRouteDeps) { router.get( { path: '/internal/spaces/{spaceId}/content_summary', - options: { - tags: ['access:manageSpaces'], + security: { + authz: { + requiredPrivileges: ['manage_spaces'], + }, }, validate: { params: schema.object({ diff --git a/x-pack/plugins/spaces/server/routes/api/internal/set_solution_space.ts b/x-pack/plugins/spaces/server/routes/api/internal/set_solution_space.ts index 6732a8520946d..cfe14705a4e22 100644 --- a/x-pack/plugins/spaces/server/routes/api/internal/set_solution_space.ts +++ b/x-pack/plugins/spaces/server/routes/api/internal/set_solution_space.ts @@ -37,6 +37,13 @@ export function initSetSolutionSpaceApi(deps: InternalRouteDeps) { router.put( { path: '/internal/spaces/space/{id}/solution', + security: { + authz: { + enabled: false, + reason: + 'This route delegates authorization to the spaces service via a scoped spaces client', + }, + }, options: { description: `Update solution for a space`, }, diff --git a/x-pack/plugins/spaces/server/spaces_client/spaces_client.test.ts b/x-pack/plugins/spaces/server/spaces_client/spaces_client.test.ts index 364afdcaba66a..4b7c1de0b3fcb 100644 --- a/x-pack/plugins/spaces/server/spaces_client/spaces_client.test.ts +++ b/x-pack/plugins/spaces/server/spaces_client/spaces_client.test.ts @@ -55,6 +55,37 @@ const features = [ catalogue: ['feature3Entry'], category: { id: 'securitySolution' }, }, + { + deprecated: { notice: 'It was a mistake.' }, + id: 'feature_4_deprecated', + name: 'Deprecated Feature', + app: ['feature2', 'feature3'], + catalogue: ['feature2Entry', 'feature3Entry'], + category: { id: 'deprecated', label: 'deprecated' }, + scope: ['spaces', 'security'], + privileges: { + all: { + savedObject: { all: [], read: [] }, + ui: [], + app: ['feature2', 'feature3'], + catalogue: ['feature2Entry', 'feature3Entry'], + replacedBy: [ + { feature: 'feature_2', privileges: ['all'] }, + { feature: 'feature_3', privileges: ['all'] }, + ], + }, + read: { + savedObject: { all: [], read: [] }, + ui: [], + app: ['feature2', 'feature3'], + catalogue: ['feature2Entry', 'feature3Entry'], + replacedBy: [ + { feature: 'feature_2', privileges: ['read'] }, + { feature: 'feature_3', privileges: ['read'] }, + ], + }, + }, + }, ] as unknown as KibanaFeature[]; const featuresStart = featuresPluginMock.createStart(); @@ -103,6 +134,17 @@ describe('#getAll', () => { bar: 'baz-bar', // an extra attribute that will be ignored during conversion }, }, + { + // alpha only has deprecated disabled features + id: 'alpha', + type: 'space', + references: [], + attributes: { + name: 'alpha-name', + description: 'alpha-description', + disabledFeatures: ['feature_1', 'feature_4_deprecated'], + }, + }, ]; const expectedSpaces: Space[] = [ @@ -130,6 +172,12 @@ describe('#getAll', () => { description: 'baz-description', disabledFeatures: [], }, + { + id: 'alpha', + name: 'alpha-name', + description: 'alpha-description', + disabledFeatures: ['feature_1', 'feature_2', 'feature_3'], + }, ]; test(`finds spaces using callWithRequestRepository`, async () => { diff --git a/x-pack/plugins/spaces/server/spaces_client/spaces_client.ts b/x-pack/plugins/spaces/server/spaces_client/spaces_client.ts index 5d7ae1159f5ea..66728636f9752 100644 --- a/x-pack/plugins/spaces/server/spaces_client/spaces_client.ts +++ b/x-pack/plugins/spaces/server/spaces_client/spaces_client.ts @@ -14,6 +14,7 @@ import type { SavedObject, } from '@kbn/core/server'; import type { LegacyUrlAliasTarget } from '@kbn/core-saved-objects-common'; +import type { KibanaFeature } from '@kbn/features-plugin/common'; import { KibanaFeatureScope } from '@kbn/features-plugin/common'; import type { FeaturesPluginStart } from '@kbn/features-plugin/server'; @@ -84,7 +85,13 @@ export interface ISpacesClient { * Client for interacting with spaces. */ export class SpacesClient implements ISpacesClient { - private isServerless = false; + private readonly isServerless: boolean; + + /** + * A map of deprecated feature IDs to the feature IDs that replace them used to transform the disabled features + * of a space to make sure they only reference non-deprecated features. + */ + private readonly deprecatedFeaturesReferences: Map>; constructor( private readonly debugLogger: (message: string) => void, @@ -95,6 +102,9 @@ export class SpacesClient implements ISpacesClient { private readonly features: FeaturesPluginStart ) { this.isServerless = this.buildFlavour === 'serverless'; + this.deprecatedFeaturesReferences = this.collectDeprecatedFeaturesReferences( + features.getKibanaFeatures() + ); } public async getAll(options: v1.GetAllSpacesOptions = {}): Promise { @@ -247,6 +257,8 @@ export class SpacesClient implements ISpacesClient { }; private transformSavedObjectToSpace = (savedObject: SavedObject): v1.Space => { + // Solution isn't supported in the serverless offering. + const solution = !this.isServerless ? savedObject.attributes.solution : undefined; return { id: savedObject.id, name: savedObject.attributes.name ?? '', @@ -256,11 +268,13 @@ export class SpacesClient implements ISpacesClient { imageUrl: savedObject.attributes.imageUrl, disabledFeatures: withSpaceSolutionDisabledFeatures( this.features.getKibanaFeatures(), - savedObject.attributes.disabledFeatures ?? [], - !this.isServerless ? savedObject.attributes.solution : undefined + savedObject.attributes.disabledFeatures?.flatMap((featureId: string) => + Array.from(this.deprecatedFeaturesReferences.get(featureId) ?? [featureId]) + ) ?? [], + solution ), _reserved: savedObject.attributes._reserved, - ...(!this.isServerless ? { solution: savedObject.attributes.solution } : {}), + ...(solution ? { solution } : {}), } as v1.Space; }; @@ -275,4 +289,41 @@ export class SpacesClient implements ISpacesClient { ...(!this.isServerless && space.solution ? { solution: space.solution } : {}), }; }; + + /** + * Collects a map of all deprecated feature IDs and the feature IDs that replace them. + * @param features A list of all available Kibana features including deprecated ones. + */ + private collectDeprecatedFeaturesReferences(features: KibanaFeature[]) { + const deprecatedFeatureReferences = new Map(); + for (const feature of features) { + if (!feature.deprecated || !feature.scope?.includes(KibanaFeatureScope.Spaces)) { + continue; + } + + // Collect all feature privileges including the ones provided by sub-features, if any. + const allPrivileges = Object.values(feature.privileges ?? {}).concat( + feature.subFeatures?.flatMap((subFeature) => + subFeature.privilegeGroups.flatMap(({ privileges }) => privileges) + ) ?? [] + ); + + // Collect all features IDs that are referenced by the deprecated feature privileges. + const referencedFeaturesIds = new Set(); + for (const privilege of allPrivileges) { + const replacedBy = privilege.replacedBy + ? 'default' in privilege.replacedBy + ? privilege.replacedBy.default.concat(privilege.replacedBy.minimal) + : privilege.replacedBy + : []; + for (const privilegeReference of replacedBy) { + referencedFeaturesIds.add(privilegeReference.feature); + } + } + + deprecatedFeatureReferences.set(feature.id, referencedFeaturesIds); + } + + return deprecatedFeatureReferences; + } } diff --git a/x-pack/plugins/spaces/tsconfig.json b/x-pack/plugins/spaces/tsconfig.json index 97cd0d3d4fdc8..a997ef7b3d97a 100644 --- a/x-pack/plugins/spaces/tsconfig.json +++ b/x-pack/plugins/spaces/tsconfig.json @@ -51,7 +51,8 @@ "@kbn/logging", "@kbn/core-logging-browser-mocks", "@kbn/core-http-router-server-mocks", - "@kbn/core-application-browser-mocks" + "@kbn/core-application-browser-mocks", + "@kbn/core-chrome-browser" ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/stack_alerts/kibana.jsonc b/x-pack/plugins/stack_alerts/kibana.jsonc index 4d000228b0e07..c3536e923ec26 100644 --- a/x-pack/plugins/stack_alerts/kibana.jsonc +++ b/x-pack/plugins/stack_alerts/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/stack-alerts-plugin", - "owner": "@elastic/response-ops", + "owner": [ + "@elastic/response-ops" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "stackAlerts", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "stack_alerts" @@ -24,6 +28,8 @@ "esUiShared", "esql" ], - "extraPublicDirs": ["common"] + "extraPublicDirs": [ + "common" + ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/stack_connectors/common/auth/constants.ts b/x-pack/plugins/stack_connectors/common/auth/constants.ts index bdd5b7352f921..ecf7637c956ee 100644 --- a/x-pack/plugins/stack_connectors/common/auth/constants.ts +++ b/x-pack/plugins/stack_connectors/common/auth/constants.ts @@ -19,4 +19,5 @@ export enum WebhookMethods { PATCH = 'patch', POST = 'post', PUT = 'put', + GET = 'get', } diff --git a/x-pack/plugins/stack_connectors/kibana.jsonc b/x-pack/plugins/stack_connectors/kibana.jsonc index da8e973b6f990..91991304f85cb 100644 --- a/x-pack/plugins/stack_connectors/kibana.jsonc +++ b/x-pack/plugins/stack_connectors/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/stack-connectors-plugin", - "owner": "@elastic/response-ops", + "owner": [ + "@elastic/response-ops" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "stackConnectors", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "stack_connectors" @@ -20,4 +24,4 @@ "public/common" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/steps/get.tsx b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/steps/get.tsx index e8f233408a4c9..5bf2689506ec4 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/steps/get.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/steps/get.tsx @@ -6,14 +6,25 @@ */ import React, { FunctionComponent } from 'react'; +import { css } from '@emotion/react'; import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; -import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { + FIELD_TYPES, + UseField, + useFormData, +} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { Field } from '@kbn/es-ui-shared-plugin/static/forms/components'; import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; -import { MustacheTextFieldWrapper } from '@kbn/triggers-actions-ui-plugin/public'; -import { containsExternalId, containsExternalIdOrTitle } from '../validator'; +import { JsonFieldWrapper, MustacheTextFieldWrapper } from '@kbn/triggers-actions-ui-plugin/public'; +import { WebhookMethods } from '../../../../common/auth/constants'; +import { + containsExternalIdForGet, + containsExternalIdOrTitle, + requiredJsonForPost, +} from '../validator'; import { urlVars, urlVarsExt } from '../action_variables'; import * as i18n from '../translations'; + const { emptyField, urlField } = fieldValidators; interface Props { @@ -21,88 +32,157 @@ interface Props { readOnly: boolean; } -export const GetStep: FunctionComponent = ({ display, readOnly }) => ( - - -

{i18n.STEP_3}

- -

{i18n.STEP_3_DESCRIPTION}

-
-
- - - - - - = ({ display, readOnly }) => { + const [{ config }] = useFormData({ + watch: ['config.getIncidentMethod'], + }); + const { getIncidentMethod = WebhookMethods.GET } = config ?? {}; + + return ( + + +

{i18n.STEP_3}

+ +

{i18n.STEP_3_DESCRIPTION}

+
+
+ + + + ({ + text: verb.toUpperCase(), + value: verb, + })), + readOnly, + }, + }} + /> + + + + + + {getIncidentMethod === WebhookMethods.POST ? ( + + + + ) : null} + + - - - + + + - - -
-); + }} + /> +
+
+
+ ); +}; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/translations.ts b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/translations.ts index 0b007e07cfd91..8c44b6197ef9c 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/translations.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/translations.ts @@ -178,13 +178,24 @@ export const ADD_CASES_VARIABLE = i18n.translate( defaultMessage: 'Add variable', } ); - +export const GET_INCIDENT_METHOD = i18n.translate( + 'xpack.stackConnectors.components.casesWebhook.getIncidentMethodTextFieldLabel', + { + defaultMessage: 'Get case method', + } +); export const GET_INCIDENT_URL = i18n.translate( 'xpack.stackConnectors.components.casesWebhook.getIncidentUrlTextFieldLabel', { defaultMessage: 'Get case URL', } ); +export const GET_METHOD_REQUIRED = i18n.translate( + 'xpack.stackConnectors.components.casesWebhook.error.requiredGetMethodText', + { + defaultMessage: 'Get case method is required.', + } +); export const GET_INCIDENT_URL_HELP = i18n.translate( 'xpack.stackConnectors.components.casesWebhook.getIncidentUrlHelp', { @@ -206,6 +217,28 @@ export const GET_INCIDENT_TITLE_KEY_HELP = i18n.translate( } ); +export const GET_INCIDENT_JSON_HELP = i18n.translate( + 'xpack.stackConnectors.components.casesWebhook.getIncidentJsonHelp', + { + defaultMessage: + 'JSON object to get a case. Use the variable selector to add cases data to the payload.', + } +); + +export const GET_INCIDENT_JSON = i18n.translate( + 'xpack.stackConnectors.components.casesWebhook.getIncidentJsonTextFieldLabel', + { + defaultMessage: 'Get case object', + } +); + +export const GET_INCIDENT_REQUIRED = i18n.translate( + 'xpack.stackConnectors.components.casesWebhook.error.requiredGetIncidentText', + { + defaultMessage: 'Get case object is required and must be valid JSON.', + } +); + export const EXTERNAL_INCIDENT_VIEW_URL = i18n.translate( 'xpack.stackConnectors.components.casesWebhook.viewIncidentUrlTextFieldLabel', { diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/validator.ts b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/validator.ts index d3d7f6dc8e612..d972c9bbd1f86 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/validator.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/validator.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { isEmpty } from 'lodash'; import { ERROR_CODE } from '@kbn/es-ui-shared-plugin/static/forms/helpers/field_validators/types'; import { ValidationError, @@ -12,6 +13,7 @@ import { } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { containsChars, isUrl } from '@kbn/es-ui-shared-plugin/static/validators/string'; import { templateActionVariable } from '@kbn/triggers-actions-ui-plugin/public'; +import { WebhookMethods } from '../../../common/auth/constants'; import * as i18n from './translations'; import { casesVars, commentVars, urlVars, urlVarsExt } from './action_variables'; @@ -42,17 +44,20 @@ export const containsTitleAndDesc = } }; -export const containsExternalId = - () => +export const containsExternalIdForGet = + (method?: string) => (...args: Parameters): ReturnType> => { const [{ value, path }] = args; const id = templateActionVariable( urlVars.find((actionVariable) => actionVariable.name === 'external.system.id')! ); - return containsChars(id)(value as string).doesContain - ? undefined - : missingVariableErrorMessage(path, [id]); + + return method === WebhookMethods.GET && + value !== null && + !containsChars(id)(value as string).doesContain + ? missingVariableErrorMessage(path, [id]) + : undefined; }; export const containsExternalIdOrTitle = @@ -77,6 +82,20 @@ export const containsExternalIdOrTitle = return error; }; +export const requiredJsonForPost = + (method?: string) => + (...args: Parameters): ReturnType> => { + const [{ value, path }] = args; + + const error = { + code: errorCode, + path, + message: i18n.GET_INCIDENT_REQUIRED, + }; + + return method === WebhookMethods.POST && (value === null || isEmpty(value)) ? error : undefined; + }; + export const containsCommentsOrEmpty = (message: string) => (...args: Parameters): ReturnType> => { diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/webhook_connectors.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/webhook_connectors.test.tsx index 8df473fef2ae8..713f2bd9e6f83 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/webhook_connectors.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/webhook_connectors.test.tsx @@ -42,6 +42,7 @@ const config = { headers: [{ key: 'content-type', value: 'text' }], viewIncidentUrl: 'https://coolsite.net/browse/{{{external.system.title}}}', getIncidentUrl: 'https://coolsite.net/rest/api/2/issue/{{{external.system.id}}}', + getIncidentMethod: 'get', updateIncidentJson: '{"fields":{"summary":{{{case.title}}},"description":{{{case.description}}},"project":{"key":"ROC"},"issuetype":{"id":"10024"}}}', updateIncidentMethod: 'put', @@ -536,5 +537,78 @@ describe('CasesWebhookActionConnectorFields renders', () => { ).toBeInTheDocument(); } ); + + it('validates get incident json required correctly', async () => { + const connector = { + ...actionConnector, + config: { + ...actionConnector.config, + getIncidentUrl: 'https://coolsite.net/rest/api/2/issue', + getIncidentMethod: 'post', + headers: [], + }, + }; + + render( + + {}} + /> + + ); + + await userEvent.click(await screen.findByTestId('form-test-provide-submit')); + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ data: {}, isValid: false })); + expect(await screen.findByText(i18n.GET_INCIDENT_REQUIRED)).toBeInTheDocument(); + }); + + it('validation succeeds get incident url with post correctly', async () => { + const connector = { + ...actionConnector, + config: { + ...actionConnector.config, + getIncidentUrl: 'https://coolsite.net/rest/api/2/issue/{{{external.system.id}}}', + getIncidentMethod: 'post', + getIncidentJson: '{"id": {{{external.system.id}}} }', + headers: [], + }, + }; + + const { isPreconfigured, ...rest } = actionConnector; + const { headers, ...rest2 } = actionConnector.config; + + render( + + {}} + /> + + ); + + await userEvent.click(await screen.findByTestId('form-test-provide-submit')); + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith({ + data: { + __internal__: { + hasCA: false, + hasHeaders: true, + }, + ...rest, + config: { + ...rest2, + getIncidentUrl: 'https://coolsite.net/rest/api/2/issue/{{{external.system.id}}}', + getIncidentMethod: 'post', + getIncidentJson: '{"id": {{{external.system.id}}} }', + }, + }, + isValid: true, + }) + ); + }); }); }); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/webhook_connectors.tsx b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/webhook_connectors.tsx index 73e424901469a..5aaf56fa8dd90 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/webhook_connectors.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/webhook_connectors.tsx @@ -22,7 +22,7 @@ import { useKibana } from '@kbn/triggers-actions-ui-plugin/public'; import * as i18n from './translations'; import { AuthStep, CreateStep, GetStep, UpdateStep } from './steps'; -export const HTTP_VERBS = ['post', 'put', 'patch']; +export const HTTP_VERBS = ['post', 'put', 'patch', 'get']; const fields = { step1: [ 'config.hasAuth', @@ -38,7 +38,9 @@ const fields = { 'config.createIncidentResponseKey', ], step3: [ + 'config.getIncidentMethod', 'config.getIncidentUrl', + 'config.getIncidentJson', 'config.getIncidentResponseExternalTitleKey', 'config.viewIncidentUrl', ], diff --git a/x-pack/plugins/stack_connectors/public/connector_types/lib/test_utils.tsx b/x-pack/plugins/stack_connectors/public/connector_types/lib/test_utils.tsx index 29e0a8dd55b4b..d1b9d1f0e3409 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/lib/test_utils.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/lib/test_utils.tsx @@ -107,7 +107,7 @@ export interface AppMockRenderer { export const createAppMockRenderer = (): AppMockRenderer => { const services = createStartServicesMock(); - const AppWrapper: React.FC<{ children: React.ReactElement }> = ({ children }) => ( + const AppWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( {children} diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/schema.ts b/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/schema.ts index 00b4fdc60a3ab..25b0d66e885b4 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/schema.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/schema.ts @@ -21,7 +21,14 @@ export const ExternalIncidentServiceConfiguration = { ), createIncidentJson: schema.string(), // stringified object createIncidentResponseKey: schema.string(), + getIncidentMethod: schema.oneOf( + [schema.literal(WebhookMethods.GET), schema.literal(WebhookMethods.POST)], + { + defaultValue: WebhookMethods.GET, + } + ), getIncidentUrl: schema.string(), + getIncidentJson: schema.nullable(schema.string()), getIncidentResponseExternalTitleKey: schema.string(), viewIncidentUrl: schema.string(), updateIncidentUrl: schema.string(), diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/service.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/service.test.ts index a44b34bf88fce..aaeca30be920a 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/service.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/service.test.ts @@ -47,6 +47,8 @@ const config: CasesWebhookPublicConfigurationType = { headers: { ['content-type']: 'application/json', foo: 'bar' }, viewIncidentUrl: 'https://coolsite.net/browse/{{{external.system.title}}}', getIncidentUrl: 'https://coolsite.net/issue/{{{external.system.id}}}', + getIncidentMethod: WebhookMethods.GET, + getIncidentJson: null, updateIncidentJson: '{"fields":{"title":{{{case.title}}},"description":{{{case.description}}},"tags":{{{case.tags}}},"project":{"key":"ROC"},"issuetype":{"id":"10024"}}}', updateIncidentMethod: WebhookMethods.PUT, @@ -239,6 +241,7 @@ describe('Cases webhook service', () => { configurationUtilities, sslOverrides: defaultSSLOverrides, connectorUsageCollector: expect.any(ConnectorUsageCollector), + method: WebhookMethods.GET, }); }); @@ -282,6 +285,7 @@ describe('Cases webhook service', () => { "trace": [MockFunction], "warn": [MockFunction], }, + "method": "get", "sslOverrides": Object { "cert": Object { "data": Array [ @@ -440,6 +444,271 @@ describe('Cases webhook service', () => { '[Action][Webhook - Case Management]: Unable to get case with id 1. Error: Response is missing the expected field: key' ); }); + + it('it returns the incident correctly with POST', async () => { + const postService: ExternalService = createExternalService( + actionId, + { + config: { + ...config, + getIncidentMethod: WebhookMethods.POST, + getIncidentJson: '{"id": {{{external.system.id}}} }', + getIncidentUrl: 'https://coolsite.net/issue', + }, + secrets, + }, + logger, + configurationUtilities, + connectorUsageCollector + ); + + requestMock.mockImplementation(() => createAxiosResponse(axiosRes)); + const res = await postService.getIncident('1'); + expect(res).toEqual({ + id: '1', + title: 'CK-1', + }); + }); + + it('it should call request with correct arguments using POST', async () => { + const postService: ExternalService = createExternalService( + actionId, + { + config: { + ...config, + getIncidentMethod: WebhookMethods.POST, + getIncidentJson: '{"id": {{{external.system.id}}} }', + getIncidentUrl: 'https://coolsite.net/issue', + }, + secrets, + }, + logger, + configurationUtilities, + connectorUsageCollector + ); + + requestMock.mockImplementation(() => createAxiosResponse(axiosRes)); + + await postService.getIncident('1'); + expect(requestMock).toHaveBeenCalledWith({ + axios, + url: 'https://coolsite.net/issue', + logger, + configurationUtilities, + sslOverrides: defaultSSLOverrides, + connectorUsageCollector: expect.any(ConnectorUsageCollector), + method: WebhookMethods.POST, + data: '{"id": "1" }', + }); + }); + + it('it should call request with correct arguments when authType=SSL using POST', async () => { + const postSslService = createExternalService( + actionId, + { + config: { + ...sslConfig, + getIncidentMethod: WebhookMethods.POST, + getIncidentJson: '{"id": {{{external.system.id}}} }', + getIncidentUrl: 'https://coolsite.net/issue', + }, + secrets: sslSecrets, + }, + logger, + configurationUtilities, + connectorUsageCollector + ); + + requestMock.mockImplementation(() => createAxiosResponse(axiosRes)); + + await postSslService.getIncident('1'); + + // irrelevant snapshot content + delete requestMock.mock.calls[0][0].configurationUtilities; + expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "axios": [Function], + "connectorUsageCollector": ConnectorUsageCollector { + "connectorId": "test-connector-id", + "logger": Object { + "context": Array [], + "debug": [MockFunction], + "error": [MockFunction], + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "isLevelEnabled": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + "usage": Object { + "requestBodyBytes": 0, + }, + }, + "data": "{\\"id\\": \\"1\\" }", + "logger": Object { + "context": Array [], + "debug": [MockFunction], + "error": [MockFunction], + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "isLevelEnabled": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + "method": "post", + "sslOverrides": Object { + "cert": Object { + "data": Array [ + 10, + 45, + 45, + 45, + 45, + 45, + 66, + 69, + 71, + 73, + 78, + 32, + 67, + 69, + 82, + 84, + 73, + 70, + 73, + 67, + 65, + 84, + 69, + 45, + 45, + 45, + 45, + 45, + 10, + 45, + 45, + 45, + 45, + 45, + 69, + 78, + 68, + 32, + 67, + 69, + 82, + 84, + 73, + 70, + 73, + 67, + 65, + 84, + 69, + 45, + 45, + 45, + 45, + 45, + 10, + ], + "type": "Buffer", + }, + "key": Object { + "data": Array [ + 10, + 45, + 45, + 45, + 45, + 45, + 66, + 69, + 71, + 73, + 78, + 32, + 80, + 82, + 73, + 86, + 65, + 84, + 69, + 32, + 75, + 69, + 89, + 45, + 45, + 45, + 45, + 45, + 10, + 45, + 45, + 45, + 45, + 45, + 69, + 78, + 68, + 32, + 80, + 82, + 73, + 86, + 65, + 84, + 69, + 32, + 75, + 69, + 89, + 45, + 45, + 45, + 45, + 45, + 10, + ], + "type": "Buffer", + }, + "passphrase": "foobar", + }, + "url": "https://coolsite.net/issue", + } + `); + }); + + it('it should throw if the request payload is not a valid JSON for POST', async () => { + const newService = createExternalService( + actionId, + { + config: { + ...config, + getIncidentMethod: WebhookMethods.POST, + getIncidentJson: '{"id": }', + getIncidentUrl: 'https://coolsite.net/issue', + }, + secrets, + }, + logger, + configurationUtilities, + connectorUsageCollector + ); + + await expect(newService.getIncident('1')).rejects.toThrow( + '[Action][Webhook - Case Management]: Unable to get case with id 1. Error: JSON Error: Get case JSON body must be valid JSON. ' + ); + }); }); describe('createIncident', () => { diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/service.ts b/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/service.ts index 170c63a1d4e5b..9f14f494c9424 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/service.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/service.ts @@ -14,6 +14,7 @@ import { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/action import { combineHeadersWithBasicAuthHeader } from '@kbn/actions-plugin/server/lib'; import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; import { buildConnectorAuth, validateConnectorAuthConfiguration } from '../../../common/auth/utils'; +import { WebhookMethods } from '../../../common/auth/constants'; import { validateAndNormalizeUrl, validateJson } from './validators'; import { createServiceError, @@ -52,6 +53,8 @@ export const createExternalService = ( createIncidentUrl: createIncidentUrlConfig, getIncidentResponseExternalTitleKey, getIncidentUrl, + getIncidentMethod, + getIncidentJson, hasAuth, authType, headers, @@ -113,10 +116,28 @@ export const createExternalService = ( configurationUtilities, 'Get case URL' ); + + const json = + getIncidentMethod === WebhookMethods.POST && getIncidentJson + ? renderMustacheStringNoEscape(getIncidentJson, { + external: { + system: { + id: JSON.stringify(id), + }, + }, + }) + : null; + + if (json !== null) { + validateJson(json, 'Get case JSON body'); + } + const res = await request({ axios: axiosInstance, url: normalizedUrl, + method: getIncidentMethod, logger, + ...(getIncidentMethod === WebhookMethods.POST ? { data: json } : {}), configurationUtilities, sslOverrides, connectorUsageCollector, @@ -128,6 +149,7 @@ export const createExternalService = ( }); const title = getObjectValueByKeyAsString(res.data, getIncidentResponseExternalTitleKey)!; + return { id, title }; } catch (error) { throw createServiceError(error, `Unable to get case with id ${id}`); @@ -157,6 +179,7 @@ export const createExternalService = ( ); validateJson(json, 'Create case JSON body'); + const res: AxiosResponse = await request({ axios: axiosInstance, url: normalizedUrl, @@ -175,6 +198,7 @@ export const createExternalService = ( requiredAttributesToBeInTheResponse: [createIncidentResponseKey], }); const externalId = getObjectValueByKeyAsString(data, createIncidentResponseKey)!; + const insertedIncident = await getIncident(externalId); logger.debug(`response from webhook action "${actionId}": [HTTP ${status}] ${statusText}`); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/jira/service.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/jira/service.test.ts index 5e98bdc96c0ee..0001d7cf13284 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/jira/service.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/jira/service.test.ts @@ -36,67 +36,14 @@ const configurationUtilities = actionsConfigMock.create(); const issueTypesResponse = createAxiosResponse({ data: { - projects: [ + issueTypes: [ { - issuetypes: [ - { - id: '10006', - name: 'Task', - }, - { - id: '10007', - name: 'Bug', - }, - ], + id: '10006', + name: 'Task', }, - ], - }, -}); - -const fieldsResponse = createAxiosResponse({ - data: { - projects: [ { - issuetypes: [ - { - id: '10006', - name: 'Task', - fields: { - summary: { required: true, schema: { type: 'string' }, fieldId: 'summary' }, - priority: { - required: false, - schema: { type: 'string' }, - fieldId: 'priority', - allowedValues: [ - { - name: 'Highest', - id: '1', - }, - { - name: 'High', - id: '2', - }, - { - name: 'Medium', - id: '3', - }, - { - name: 'Low', - id: '4', - }, - { - name: 'Lowest', - id: '5', - }, - ], - defaultValue: { - name: 'Medium', - id: '3', - }, - }, - }, - }, - ], + id: '10007', + name: 'Bug', }, ], }, @@ -110,30 +57,6 @@ const issueResponse = { const issuesResponse = [issueResponse]; -const mockNewAPI = () => - requestMock.mockImplementationOnce(() => - createAxiosResponse({ - data: { - capabilities: { - 'list-project-issuetypes': - 'https://coolsite.net/rest/capabilities/list-project-issuetypes', - 'list-issuetype-fields': 'https://coolsite.net/rest/capabilities/list-issuetype-fields', - }, - }, - }) - ); - -const mockOldAPI = () => - requestMock.mockImplementationOnce(() => - createAxiosResponse({ - data: { - capabilities: { - navigation: 'https://coolsite.net/rest/capabilities/navigation', - }, - }, - }) - ); - describe('Jira service', () => { let service: ExternalService; let connectorUsageCollector: ConnectorUsageCollector; @@ -347,23 +270,6 @@ describe('Jira service', () => { }); test('it creates the incident correctly without issue type', async () => { - /* The response from Jira when creating an issue contains only the key and the id. - The function makes the following calls when creating an issue: - 1. Get issueTypes to set a default ONLY when incident.issueType is missing - 2. Create the issue. - 3. Get the created issue with all the necessary fields. - */ - // getIssueType mocks - requestMock.mockImplementationOnce(() => - createAxiosResponse({ - data: { - capabilities: { - navigation: 'https://coolsite.net/rest/capabilities/navigation', - }, - }, - }) - ); - // getIssueType mocks requestMock.mockImplementationOnce(() => issueTypesResponse); @@ -419,16 +325,6 @@ describe('Jira service', () => { }); test('removes newline characters and trialing spaces from summary', async () => { - requestMock.mockImplementationOnce(() => - createAxiosResponse({ - data: { - capabilities: { - navigation: 'https://coolsite.net/rest/capabilities/navigation', - }, - }, - }) - ); - // getIssueType mocks requestMock.mockImplementationOnce(() => issueTypesResponse); @@ -800,28 +696,47 @@ describe('Jira service', () => { }); }); - describe('getCapabilities', () => { - test('it should return the capabilities', async () => { - mockOldAPI(); - const res = await service.getCapabilities(); - expect(res).toEqual({ - capabilities: { - navigation: 'https://coolsite.net/rest/capabilities/navigation', + describe('getIssueTypes', () => { + test('it should return the issue types', async () => { + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + issueTypes: issueTypesResponse.data.issueTypes, + }, + }) + ); + + const res = await service.getIssueTypes(); + + expect(res).toEqual([ + { + id: '10006', + name: 'Task', }, - }); + { + id: '10007', + name: 'Bug', + }, + ]); }); test('it should call request with correct arguments', async () => { - mockOldAPI(); + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + issueTypes: issueTypesResponse.data.issueTypes, + }, + }) + ); - await service.getCapabilities(); + await service.getIssueTypes(); - expect(requestMock).toHaveBeenCalledWith({ + expect(requestMock).toHaveBeenLastCalledWith({ axios, logger, method: 'get', configurationUtilities, - url: 'https://coolsite.net/rest/capabilities', + url: 'https://coolsite.net/rest/api/2/issue/createmeta/CK/issuetypes', connectorUsageCollector, }); }); @@ -829,25 +744,12 @@ describe('Jira service', () => { test('it should throw an error', async () => { requestMock.mockImplementation(() => { const error: ResponseError = new Error('An error has occurred'); - error.response = { data: { errors: { capabilities: 'Could not get capabilities' } } }; - throw error; - }); - - await expect(service.getCapabilities()).rejects.toThrow( - '[Action][Jira]: Unable to get capabilities. Error: An error has occurred. Reason: Could not get capabilities' - ); - }); - - test('it should return unknown if the error is a string', async () => { - requestMock.mockImplementation(() => { - const error = new Error('An error has occurred'); - // @ts-ignore - error.response = { data: 'Unauthorized' }; + error.response = { data: { errors: { issuetypes: 'Could not get issue types' } } }; throw error; }); - await expect(service.getCapabilities()).rejects.toThrow( - '[Action][Jira]: Unable to get capabilities. Error: An error has occurred. Reason: unknown: errorResponse.errors was null' + await expect(service.getIssueTypes()).rejects.toThrow( + '[Action][Jira]: Unable to get issue types. Error: An error has occurred. Reason: Could not get issue types' ); }); @@ -856,346 +758,178 @@ describe('Jira service', () => { createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) ); - await expect(service.getCapabilities()).rejects.toThrow( - '[Action][Jira]: Unable to get capabilities. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown: errorResponse was null' - ); - }); - - test('it should throw if the required attributes are not there', async () => { - requestMock.mockImplementation(() => createAxiosResponse({ data: { notRequired: 'test' } })); - - await expect(service.getCapabilities()).rejects.toThrow( - '[Action][Jira]: Unable to get capabilities. Error: Response is missing at least one of the expected fields: capabilities. Reason: unknown: errorResponse was null' + await expect(service.getIssueTypes()).rejects.toThrow( + '[Action][Jira]: Unable to get issue types. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown: errorResponse was null' ); }); - }); - - describe('getIssueTypes', () => { - describe('Old API', () => { - test('it should return the issue types', async () => { - mockOldAPI(); - - requestMock.mockImplementationOnce(() => issueTypesResponse); - const res = await service.getIssueTypes(); - - expect(res).toEqual([ - { - id: '10006', - name: 'Task', - }, - { - id: '10007', - name: 'Bug', + test('it should work with data center response - issueTypes returned in data.values', async () => { + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + values: issueTypesResponse.data.issueTypes, }, - ]); - }); - - test('it should call request with correct arguments', async () => { - mockOldAPI(); - - requestMock.mockImplementationOnce(() => issueTypesResponse); - - await service.getIssueTypes(); - - expect(requestMock).toHaveBeenLastCalledWith({ - axios, - logger, - method: 'get', - configurationUtilities, - url: 'https://coolsite.net/rest/api/2/issue/createmeta?projectKeys=CK&expand=projects.issuetypes.fields', - connectorUsageCollector, - }); - }); - - test('it should throw an error', async () => { - mockOldAPI(); - - requestMock.mockImplementation(() => { - const error: ResponseError = new Error('An error has occurred'); - error.response = { data: { errors: { issuetypes: 'Could not get issue types' } } }; - throw error; - }); - - await expect(service.getIssueTypes()).rejects.toThrow( - '[Action][Jira]: Unable to get issue types. Error: An error has occurred. Reason: Could not get issue types' - ); - }); - - test('it should throw if the request is not a JSON', async () => { - mockOldAPI(); + }) + ); - requestMock.mockImplementation(() => - createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) - ); + await service.getIssueTypes(); - await expect(service.getIssueTypes()).rejects.toThrow( - '[Action][Jira]: Unable to get issue types. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown: errorResponse was null' - ); + expect(requestMock).toHaveBeenLastCalledWith({ + axios, + logger, + method: 'get', + configurationUtilities, + url: 'https://coolsite.net/rest/api/2/issue/createmeta/CK/issuetypes', + connectorUsageCollector, }); }); - describe('New API', () => { - test('it should return the issue types', async () => { - mockNewAPI(); - - requestMock.mockImplementationOnce(() => - createAxiosResponse({ - data: { - values: issueTypesResponse.data.projects[0].issuetypes, - }, - }) - ); - - const res = await service.getIssueTypes(); + }); - expect(res).toEqual([ - { - id: '10006', - name: 'Task', - }, - { - id: '10007', - name: 'Bug', + describe('getFieldsByIssueType', () => { + test('it should return the fields', async () => { + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + fields: [ + { required: true, schema: { type: 'string' }, fieldId: 'summary' }, + { + required: false, + schema: { type: 'string' }, + fieldId: 'priority', + allowedValues: [ + { + name: 'Medium', + id: '3', + }, + ], + defaultValue: { + name: 'Medium', + id: '3', + }, + }, + ], }, - ]); - }); - - test('it should call request with correct arguments', async () => { - mockNewAPI(); - - requestMock.mockImplementationOnce(() => - createAxiosResponse({ - data: { - values: issueTypesResponse.data.projects[0].issuetypes, - }, - }) - ); - - await service.getIssueTypes(); - - expect(requestMock).toHaveBeenLastCalledWith({ - axios, - logger, - method: 'get', - configurationUtilities, - url: 'https://coolsite.net/rest/api/2/issue/createmeta/CK/issuetypes', - connectorUsageCollector, - }); - }); - - test('it should throw an error', async () => { - mockNewAPI(); - - requestMock.mockImplementation(() => { - const error: ResponseError = new Error('An error has occurred'); - error.response = { data: { errors: { issuetypes: 'Could not get issue types' } } }; - throw error; - }); - - await expect(service.getIssueTypes()).rejects.toThrow( - '[Action][Jira]: Unable to get issue types. Error: An error has occurred. Reason: Could not get issue types' - ); - }); - - test('it should throw if the request is not a JSON', async () => { - mockNewAPI(); + }) + ); - requestMock.mockImplementation(() => - createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) - ); + const res = await service.getFieldsByIssueType('10006'); - await expect(service.getIssueTypes()).rejects.toThrow( - '[Action][Jira]: Unable to get issue types. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown: errorResponse was null' - ); + expect(res).toEqual({ + priority: { + required: false, + schema: { type: 'string' }, + allowedValues: [{ id: '3', name: 'Medium' }], + defaultValue: { id: '3', name: 'Medium' }, + }, + summary: { + required: true, + schema: { type: 'string' }, + allowedValues: [], + defaultValue: {}, + }, }); }); - }); - describe('getFieldsByIssueType', () => { - describe('Old API', () => { - test('it should return the fields', async () => { - mockOldAPI(); - - requestMock.mockImplementationOnce(() => fieldsResponse); - - const res = await service.getFieldsByIssueType('10006'); - - expect(res).toEqual({ - priority: { - required: false, - schema: { type: 'string' }, - allowedValues: [ - { id: '1', name: 'Highest' }, - { id: '2', name: 'High' }, - { id: '3', name: 'Medium' }, - { id: '4', name: 'Low' }, - { id: '5', name: 'Lowest' }, + test('it should call request with correct arguments', async () => { + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + fields: [ + { required: true, schema: { type: 'string' }, fieldId: 'summary' }, + { + required: true, + schema: { type: 'string' }, + fieldId: 'priority', + allowedValues: [ + { + name: 'Medium', + id: '3', + }, + ], + defaultValue: { + name: 'Medium', + id: '3', + }, + }, ], - defaultValue: { id: '3', name: 'Medium' }, - }, - summary: { - required: true, - schema: { type: 'string' }, - allowedValues: [], - defaultValue: {}, }, - }); - }); - - test('it should call request with correct arguments', async () => { - mockOldAPI(); - - requestMock.mockImplementationOnce(() => fieldsResponse); + }) + ); - await service.getFieldsByIssueType('10006'); + await service.getFieldsByIssueType('10006'); - expect(requestMock).toHaveBeenLastCalledWith({ - axios, - logger, - method: 'get', - configurationUtilities, - url: 'https://coolsite.net/rest/api/2/issue/createmeta?projectKeys=CK&issuetypeIds=10006&expand=projects.issuetypes.fields', - connectorUsageCollector, - }); + expect(requestMock).toHaveBeenLastCalledWith({ + axios, + logger, + method: 'get', + configurationUtilities, + url: 'https://coolsite.net/rest/api/2/issue/createmeta/CK/issuetypes/10006', }); + }); - test('it should throw an error', async () => { - mockOldAPI(); - - requestMock.mockImplementation(() => { - const error: ResponseError = new Error('An error has occurred'); - error.response = { data: { errors: { fields: 'Could not get fields' } } }; - throw error; - }); - - await expect(service.getFieldsByIssueType('10006')).rejects.toThrow( - '[Action][Jira]: Unable to get fields. Error: An error has occurred. Reason: Could not get fields' - ); + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + const error: ResponseError = new Error('An error has occurred'); + error.response = { data: { errors: { issuetypes: 'Could not get issue types' } } }; + throw error; }); - test('it should throw if the request is not a JSON', async () => { - mockOldAPI(); + await expect(service.getFieldsByIssueType('10006')).rejects.toThrowError( + '[Action][Jira]: Unable to get fields. Error: An error has occurred. Reason: Could not get issue types' + ); + }); - requestMock.mockImplementation(() => - createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) - ); + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); - await expect(service.getFieldsByIssueType('10006')).rejects.toThrow( - '[Action][Jira]: Unable to get fields. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown: errorResponse was null' - ); - }); + await expect(service.getFieldsByIssueType('10006')).rejects.toThrow( + '[Action][Jira]: Unable to get fields. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown: errorResponse was null' + ); }); - describe('New API', () => { - test('it should return the fields', async () => { - mockNewAPI(); - - requestMock.mockImplementationOnce(() => - createAxiosResponse({ - data: { - values: [ - { required: true, schema: { type: 'string' }, fieldId: 'summary' }, - { - required: false, - schema: { type: 'string' }, - fieldId: 'priority', - allowedValues: [ - { - name: 'Medium', - id: '3', - }, - ], - defaultValue: { + test('it should work with data center response - issueTypes returned in data.values', async () => { + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + values: [ + { required: true, schema: { type: 'string' }, fieldId: 'summary' }, + { + required: false, + schema: { type: 'string' }, + fieldId: 'priority', + allowedValues: [ + { name: 'Medium', id: '3', }, + ], + defaultValue: { + name: 'Medium', + id: '3', }, - ], - }, - }) - ); - - const res = await service.getFieldsByIssueType('10006'); - - expect(res).toEqual({ - priority: { - required: false, - schema: { type: 'string' }, - allowedValues: [{ id: '3', name: 'Medium' }], - defaultValue: { id: '3', name: 'Medium' }, - }, - summary: { - required: true, - schema: { type: 'string' }, - allowedValues: [], - defaultValue: {}, + }, + ], }, - }); - }); - - test('it should call request with correct arguments', async () => { - mockNewAPI(); - - requestMock.mockImplementationOnce(() => - createAxiosResponse({ - data: { - values: [ - { required: true, schema: { type: 'string' }, fieldId: 'summary' }, - { - required: true, - schema: { type: 'string' }, - fieldId: 'priority', - allowedValues: [ - { - name: 'Medium', - id: '3', - }, - ], - defaultValue: { - name: 'Medium', - id: '3', - }, - }, - ], - }, - }) - ); - - await service.getFieldsByIssueType('10006'); - - expect(requestMock).toHaveBeenLastCalledWith({ - axios, - logger, - method: 'get', - configurationUtilities, - url: 'https://coolsite.net/rest/api/2/issue/createmeta/CK/issuetypes/10006', - }); - }); - - test('it should throw an error', async () => { - mockNewAPI(); - - requestMock.mockImplementation(() => { - const error: ResponseError = new Error('An error has occurred'); - error.response = { data: { errors: { issuetypes: 'Could not get issue types' } } }; - throw error; - }); - - await expect(service.getFieldsByIssueType('10006')).rejects.toThrowError( - '[Action][Jira]: Unable to get fields. Error: An error has occurred. Reason: Could not get issue types' - ); - }); - - test('it should throw if the request is not a JSON', async () => { - mockNewAPI(); + }) + ); - requestMock.mockImplementation(() => - createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) - ); + const res = await service.getFieldsByIssueType('10006'); - await expect(service.getFieldsByIssueType('10006')).rejects.toThrow( - '[Action][Jira]: Unable to get fields. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown: errorResponse was null' - ); + expect(res).toEqual({ + priority: { + required: false, + schema: { type: 'string' }, + allowedValues: [{ id: '3', name: 'Medium' }], + defaultValue: { id: '3', name: 'Medium' }, + }, + summary: { + required: true, + schema: { type: 'string' }, + allowedValues: [], + defaultValue: {}, + }, }); }); }); @@ -1403,50 +1137,14 @@ describe('Jira service', () => { .mockImplementationOnce(() => createAxiosResponse({ data: { - capabilities: { - 'list-project-issuetypes': - 'https://coolsite.net/rest/capabilities/list-project-issuetypes', - 'list-issuetype-fields': - 'https://coolsite.net/rest/capabilities/list-issuetype-fields', - }, - }, - }) - ) - .mockImplementationOnce(() => - createAxiosResponse({ - data: { - values: issueTypesResponse.data.projects[0].issuetypes, - }, - }) - ) - .mockImplementationOnce(() => - createAxiosResponse({ - data: { - capabilities: { - 'list-project-issuetypes': - 'https://coolsite.net/rest/capabilities/list-project-issuetypes', - 'list-issuetype-fields': - 'https://coolsite.net/rest/capabilities/list-issuetype-fields', - }, - }, - }) - ) - .mockImplementationOnce(() => - createAxiosResponse({ - data: { - capabilities: { - 'list-project-issuetypes': - 'https://coolsite.net/rest/capabilities/list-project-issuetypes', - 'list-issuetype-fields': - 'https://coolsite.net/rest/capabilities/list-issuetype-fields', - }, + issueTypes: issueTypesResponse.data.issueTypes, }, }) ) .mockImplementationOnce(() => createAxiosResponse({ data: { - values: [ + fields: [ { required: true, schema: { type: 'string' }, fieldId: 'summary' }, { required: true, schema: { type: 'string' }, fieldId: 'description' }, { @@ -1471,7 +1169,7 @@ describe('Jira service', () => { .mockImplementationOnce(() => createAxiosResponse({ data: { - values: [ + fields: [ { required: true, schema: { type: 'string' }, fieldId: 'summary' }, { required: true, schema: { type: 'string' }, fieldId: 'description' }, ], @@ -1488,10 +1186,7 @@ describe('Jira service', () => { callMocks(); await service.getFields(); const callUrls = [ - 'https://coolsite.net/rest/capabilities', 'https://coolsite.net/rest/api/2/issue/createmeta/CK/issuetypes', - 'https://coolsite.net/rest/capabilities', - 'https://coolsite.net/rest/capabilities', 'https://coolsite.net/rest/api/2/issue/createmeta/CK/issuetypes/10006', 'https://coolsite.net/rest/api/2/issue/createmeta/CK/issuetypes/10007', ]; @@ -1525,7 +1220,7 @@ describe('Jira service', () => { throw error; }); await expect(service.getFields()).rejects.toThrow( - '[Action][Jira]: Unable to get capabilities. Error: An error has occurred. Reason: Required field' + '[Action][Jira]: Unable to get issue types. Error: An error has occurred. Reason: Required field' ); }); }); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/jira/service.ts b/x-pack/plugins/stack_connectors/server/connector_types/jira/service.ts index 064667558b37e..f8929ce67b68a 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/jira/service.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/jira/service.ts @@ -39,12 +39,9 @@ import * as i18n from './translations'; const VERSION = '2'; const BASE_URL = `rest/api/${VERSION}`; -const CAPABILITIES_URL = `rest/capabilities`; const VIEW_INCIDENT_URL = `browse`; -const createMetaCapabilities = ['list-project-issuetypes', 'list-issuetype-fields']; - export const createExternalService = ( { config, secrets }: ExternalServiceCredentials, logger: Logger, @@ -60,10 +57,7 @@ export const createExternalService = ( const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url; const incidentUrl = `${urlWithoutTrailingSlash}/${BASE_URL}/issue`; - const capabilitiesUrl = `${urlWithoutTrailingSlash}/${CAPABILITIES_URL}`; const commentUrl = `${incidentUrl}/{issueId}/comment`; - const getIssueTypesOldAPIURL = `${urlWithoutTrailingSlash}/${BASE_URL}/issue/createmeta?projectKeys=${projectKey}&expand=projects.issuetypes.fields`; - const getIssueTypeFieldsOldAPIURL = `${urlWithoutTrailingSlash}/${BASE_URL}/issue/createmeta?projectKeys=${projectKey}&issuetypeIds={issueTypeId}&expand=projects.issuetypes.fields`; const getIssueTypesUrl = `${urlWithoutTrailingSlash}/${BASE_URL}/issue/createmeta/${projectKey}/issuetypes`; const getIssueTypeFieldsUrl = `${urlWithoutTrailingSlash}/${BASE_URL}/issue/createmeta/${projectKey}/issuetypes/{issueTypeId}`; const searchUrl = `${urlWithoutTrailingSlash}/${BASE_URL}/search`; @@ -144,9 +138,6 @@ export const createExternalService = ( }, ''); }; - const hasSupportForNewAPI = (capabilities: { capabilities?: {} }) => - createMetaCapabilities.every((c) => Object.keys(capabilities?.capabilities ?? {}).includes(c)); - const normalizeIssueTypes = (issueTypes: Array<{ id: string; name: string }>) => issueTypes.map((type) => ({ id: type.id, name: type.name })); @@ -356,12 +347,12 @@ export const createExternalService = ( } }; - const getCapabilities = async () => { + const getIssueTypes = async () => { try { const res = await request({ axios: axiosInstance, method: 'get', - url: capabilitiesUrl, + url: getIssueTypesUrl, logger, configurationUtilities, connectorUsageCollector, @@ -369,59 +360,11 @@ export const createExternalService = ( throwIfResponseIsNotValid({ res, - requiredAttributesToBeInTheResponse: ['capabilities'], }); - return { ...res.data }; - } catch (error) { - throw new Error( - getErrorMessage( - i18n.NAME, - `Unable to get capabilities. Error: ${error.message}. Reason: ${createErrorMessage( - error.response?.data - )}` - ) - ); - } - }; - - const getIssueTypes = async () => { - const capabilitiesResponse = await getCapabilities(); - const supportsNewAPI = hasSupportForNewAPI(capabilitiesResponse); - try { - if (!supportsNewAPI) { - const res = await request({ - axios: axiosInstance, - method: 'get', - url: getIssueTypesOldAPIURL, - logger, - configurationUtilities, - connectorUsageCollector, - }); - - throwIfResponseIsNotValid({ - res, - }); - - const issueTypes = res.data.projects[0]?.issuetypes ?? []; - return normalizeIssueTypes(issueTypes); - } else { - const res = await request({ - axios: axiosInstance, - method: 'get', - url: getIssueTypesUrl, - logger, - configurationUtilities, - connectorUsageCollector, - }); - - throwIfResponseIsNotValid({ - res, - }); - - const issueTypes = res.data.values; - return normalizeIssueTypes(issueTypes); - } + // Cloud returns issueTypes and Data Center returns values + const { issueTypes, values } = res.data; + return normalizeIssueTypes(issueTypes || values); } catch (error) { throw new Error( getErrorMessage( @@ -435,47 +378,29 @@ export const createExternalService = ( }; const getFieldsByIssueType = async (issueTypeId: string) => { - const capabilitiesResponse = await getCapabilities(); - const supportsNewAPI = hasSupportForNewAPI(capabilitiesResponse); try { - if (!supportsNewAPI) { - const res = await request({ - axios: axiosInstance, - method: 'get', - url: createGetIssueTypeFieldsUrl(getIssueTypeFieldsOldAPIURL, issueTypeId), - logger, - configurationUtilities, - connectorUsageCollector, - }); - - throwIfResponseIsNotValid({ - res, - }); - - const fields = res.data.projects[0]?.issuetypes[0]?.fields || {}; - return normalizeFields(fields); - } else { - const res = await request({ - axios: axiosInstance, - method: 'get', - url: createGetIssueTypeFieldsUrl(getIssueTypeFieldsUrl, issueTypeId), - logger, - configurationUtilities, - }); - - throwIfResponseIsNotValid({ - res, - }); - - const fields = res.data.values.reduce( - (acc: { [x: string]: {} }, value: { fieldId: string }) => ({ - ...acc, - [value.fieldId]: { ...value }, - }), - {} - ); - return normalizeFields(fields); - } + const res = await request({ + axios: axiosInstance, + method: 'get', + url: createGetIssueTypeFieldsUrl(getIssueTypeFieldsUrl, issueTypeId), + logger, + configurationUtilities, + }); + + throwIfResponseIsNotValid({ + res, + }); + + // Cloud returns fields and Data Center returns values + const { fields: rawFields, values } = res.data; + const fields = (rawFields || values).reduce( + (acc: { [x: string]: {} }, value: { fieldId: string }) => ({ + ...acc, + [value.fieldId]: { ...value }, + }), + {} + ); + return normalizeFields(fields); } catch (error) { throw new Error( getErrorMessage( @@ -580,7 +505,6 @@ export const createExternalService = ( createIncident, updateIncident, createComment, - getCapabilities, getIssueTypes, getFieldsByIssueType, getIssues, diff --git a/x-pack/plugins/stack_connectors/server/connector_types/jira/types.ts b/x-pack/plugins/stack_connectors/server/connector_types/jira/types.ts index c975e23b1b783..755726137e412 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/jira/types.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/jira/types.ts @@ -101,7 +101,6 @@ export interface ExternalService { createComment: (params: CreateCommentParams) => Promise; createIncident: (params: CreateIncidentParams) => Promise; getFields: () => Promise; - getCapabilities: () => Promise; getFieldsByIssueType: (issueTypeId: string) => Promise; getIncident: (id: string) => Promise; getIssue: (id: string) => Promise; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/lib/gen_ai/create_gen_ai_dashboard.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/gen_ai/create_gen_ai_dashboard.ts index ee5a3471a2d34..d54ccec0656e7 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/lib/gen_ai/create_gen_ai_dashboard.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/lib/gen_ai/create_gen_ai_dashboard.ts @@ -4,9 +4,9 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; -import { DashboardAttributes } from '@kbn/dashboard-plugin/common'; +import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; +import type { DashboardSavedObjectAttributes } from '@kbn/dashboard-plugin/server'; import { Logger } from '@kbn/logging'; import { getDashboard } from './gen_ai_dashboard'; @@ -30,7 +30,7 @@ export const initDashboard = async ({ error?: OutputError; }> => { try { - await savedObjectsClient.get('dashboard', dashboardId); + await savedObjectsClient.get('dashboard', dashboardId); return { success: true, }; @@ -50,7 +50,7 @@ export const initDashboard = async ({ } try { - await savedObjectsClient.create( + await savedObjectsClient.create( 'dashboard', getDashboard(genAIProvider, dashboardId).attributes, { diff --git a/x-pack/plugins/stack_connectors/server/connector_types/lib/gen_ai/gen_ai_dashboard.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/gen_ai/gen_ai_dashboard.ts index 5805dd7728ccf..efe5fc0c0ca6c 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/lib/gen_ai/gen_ai_dashboard.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/lib/gen_ai/gen_ai_dashboard.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { DashboardAttributes } from '@kbn/dashboard-plugin/common'; +import type { DashboardSavedObjectAttributes } from '@kbn/dashboard-plugin/server'; import { v4 as uuidv4 } from 'uuid'; import { SavedObject } from '@kbn/core-saved-objects-common/src/server_types'; import { OPENAI_TITLE, OPENAI_CONNECTOR_ID } from '../../../../common/openai/constants'; @@ -21,7 +21,7 @@ export const getDashboardTitle = (title: string) => `${title} Token Usage`; export const getDashboard = ( genAIProvider: 'OpenAI' | 'Bedrock' | 'Gemini' | 'Inference', dashboardId: string -): SavedObject => { +): SavedObject => { let attributes = { provider: OPENAI_TITLE, dashboardTitle: getDashboardTitle(OPENAI_TITLE), diff --git a/x-pack/plugins/task_manager/kibana.jsonc b/x-pack/plugins/task_manager/kibana.jsonc index 33edc225e42c1..0e364c7cdaa34 100644 --- a/x-pack/plugins/task_manager/kibana.jsonc +++ b/x-pack/plugins/task_manager/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/task-manager-plugin", - "owner": "@elastic/response-ops", + "owner": [ + "@elastic/response-ops" + ], + "group": "platform", + "visibility": "shared", "plugin": { "id": "taskManager", - "server": true, "browser": false, + "server": true, "configPath": [ "xpack", "task_manager" @@ -15,4 +19,4 @@ "usageCollection" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/task_manager/server/lib/bulk_update_error.ts b/x-pack/plugins/task_manager/server/lib/bulk_update_error.ts new file mode 100644 index 0000000000000..f7e0552e5a738 --- /dev/null +++ b/x-pack/plugins/task_manager/server/lib/bulk_update_error.ts @@ -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. + */ + +export class BulkUpdateError extends Error { + private _statusCode: number; + private _type: string; + + constructor({ + statusCode, + message = 'Bulk update failed with unknown reason', + type, + }: { + statusCode: number; + message?: string; + type: string; + }) { + super(message); + this._statusCode = statusCode; + this._type = type; + } + + public get statusCode() { + return this._statusCode; + } + + public get type() { + return this._type; + } +} + +export function getBulkUpdateStatusCode(error: Error | BulkUpdateError): number | undefined { + if (Boolean(error && error instanceof BulkUpdateError)) { + return (error as BulkUpdateError).statusCode; + } +} + +export function getBulkUpdateErrorType(error: Error | BulkUpdateError): string | undefined { + if (Boolean(error && error instanceof BulkUpdateError)) { + return (error as BulkUpdateError).type; + } +} diff --git a/x-pack/plugins/task_manager/server/lib/create_managed_configuration.test.ts b/x-pack/plugins/task_manager/server/lib/create_managed_configuration.test.ts index 5e0a5ed4f2e67..d453edc8e7003 100644 --- a/x-pack/plugins/task_manager/server/lib/create_managed_configuration.test.ts +++ b/x-pack/plugins/task_manager/server/lib/create_managed_configuration.test.ts @@ -15,6 +15,7 @@ import { import { mockLogger } from '../test_utils'; import { CLAIM_STRATEGY_UPDATE_BY_QUERY, CLAIM_STRATEGY_MGET, TaskManagerConfig } from '../config'; import { MsearchError } from './msearch_error'; +import { BulkUpdateError } from './bulk_update_error'; describe('createManagedConfiguration()', () => { let clock: sinon.SinonFakeTimers; @@ -185,6 +186,17 @@ describe('createManagedConfiguration()', () => { expect(subscription).toHaveBeenNthCalledWith(2, 8); }); + test('should decrease configuration at the next interval when a 500 error is emitted', async () => { + const { subscription, errors$ } = setupScenario(10); + errors$.next(SavedObjectsErrorHelpers.decorateGeneralError(new Error('a'), 'b')); + clock.tick(ADJUST_THROUGHPUT_INTERVAL - 1); + expect(subscription).toHaveBeenCalledTimes(1); + expect(subscription).toHaveBeenNthCalledWith(1, 10); + clock.tick(1); + expect(subscription).toHaveBeenCalledTimes(2); + expect(subscription).toHaveBeenNthCalledWith(2, 8); + }); + test('should decrease configuration at the next interval when a 503 error is emitted', async () => { const { subscription, errors$ } = setupScenario(10); errors$.next(SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError('a', 'b')); @@ -247,6 +259,17 @@ describe('createManagedConfiguration()', () => { expect(subscription).toHaveBeenNthCalledWith(2, 8); }); + test('should decrease configuration at the next interval when an msearch 500 error is emitted', async () => { + const { subscription, errors$ } = setupScenario(10); + errors$.next(new MsearchError(500)); + clock.tick(ADJUST_THROUGHPUT_INTERVAL - 1); + expect(subscription).toHaveBeenCalledTimes(1); + expect(subscription).toHaveBeenNthCalledWith(1, 10); + clock.tick(1); + expect(subscription).toHaveBeenCalledTimes(2); + expect(subscription).toHaveBeenNthCalledWith(2, 8); + }); + test('should decrease configuration at the next interval when an msearch 503 error is emitted', async () => { const { subscription, errors$ } = setupScenario(10); errors$.next(new MsearchError(503)); @@ -258,6 +281,45 @@ describe('createManagedConfiguration()', () => { expect(subscription).toHaveBeenNthCalledWith(2, 8); }); + test('should decrease configuration at the next interval when a bulkPartialUpdate 429 error is emitted', async () => { + const { subscription, errors$ } = setupScenario(10); + errors$.next( + new BulkUpdateError({ statusCode: 429, message: 'test', type: 'too_many_requests' }) + ); + clock.tick(ADJUST_THROUGHPUT_INTERVAL - 1); + expect(subscription).toHaveBeenCalledTimes(1); + expect(subscription).toHaveBeenNthCalledWith(1, 10); + clock.tick(1); + expect(subscription).toHaveBeenCalledTimes(2); + expect(subscription).toHaveBeenNthCalledWith(2, 8); + }); + + test('should decrease configuration at the next interval when a bulkPartialUpdate 500 error is emitted', async () => { + const { subscription, errors$ } = setupScenario(10); + errors$.next( + new BulkUpdateError({ statusCode: 500, message: 'test', type: 'server_error' }) + ); + clock.tick(ADJUST_THROUGHPUT_INTERVAL - 1); + expect(subscription).toHaveBeenCalledTimes(1); + expect(subscription).toHaveBeenNthCalledWith(1, 10); + clock.tick(1); + expect(subscription).toHaveBeenCalledTimes(2); + expect(subscription).toHaveBeenNthCalledWith(2, 8); + }); + + test('should decrease configuration at the next interval when a bulkPartialUpdate 503 error is emitted', async () => { + const { subscription, errors$ } = setupScenario(10); + errors$.next( + new BulkUpdateError({ statusCode: 503, message: 'test', type: 'unavailable' }) + ); + clock.tick(ADJUST_THROUGHPUT_INTERVAL - 1); + expect(subscription).toHaveBeenCalledTimes(1); + expect(subscription).toHaveBeenNthCalledWith(1, 10); + clock.tick(1); + expect(subscription).toHaveBeenCalledTimes(2); + expect(subscription).toHaveBeenNthCalledWith(2, 8); + }); + test('should not change configuration at the next interval when other msearch error is emitted', async () => { const { subscription, errors$ } = setupScenario(10); errors$.next(new MsearchError(404)); @@ -338,6 +400,16 @@ describe('createManagedConfiguration()', () => { expect(subscription).toHaveBeenNthCalledWith(2, 120); }); + test('should increase configuration at the next interval when a 500 error is emitted', async () => { + const { subscription, errors$ } = setupScenario(100); + errors$.next(SavedObjectsErrorHelpers.decorateGeneralError(new Error('a'), 'b')); + clock.tick(ADJUST_THROUGHPUT_INTERVAL - 1); + expect(subscription).toHaveBeenCalledTimes(1); + clock.tick(1); + expect(subscription).toHaveBeenCalledTimes(2); + expect(subscription).toHaveBeenNthCalledWith(2, 120); + }); + test('should increase configuration at the next interval when a 503 error is emitted', async () => { const { subscription, errors$ } = setupScenario(100); errors$.next(SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError('a', 'b')); diff --git a/x-pack/plugins/task_manager/server/lib/create_managed_configuration.ts b/x-pack/plugins/task_manager/server/lib/create_managed_configuration.ts index 8a76029efb8eb..00736f2c36cdb 100644 --- a/x-pack/plugins/task_manager/server/lib/create_managed_configuration.ts +++ b/x-pack/plugins/task_manager/server/lib/create_managed_configuration.ts @@ -13,6 +13,7 @@ import { isEsCannotExecuteScriptError } from './identify_es_error'; import { CLAIM_STRATEGY_MGET, DEFAULT_CAPACITY, MAX_CAPACITY, TaskManagerConfig } from '../config'; import { TaskCost } from '../task'; import { getMsearchStatusCode } from './msearch_error'; +import { getBulkUpdateStatusCode } from './bulk_update_error'; const FLUSH_MARKER = Symbol('flush'); export const ADJUST_THROUGHPUT_INTERVAL = 10 * 1000; @@ -165,9 +166,14 @@ function countErrors(errors$: Observable, countInterval: number): Observa (e) => SavedObjectsErrorHelpers.isTooManyRequestsError(e) || SavedObjectsErrorHelpers.isEsUnavailableError(e) || + SavedObjectsErrorHelpers.isGeneralError(e) || isEsCannotExecuteScriptError(e) || getMsearchStatusCode(e) === 429 || - getMsearchStatusCode(e) === 503 + getMsearchStatusCode(e) === 500 || + getMsearchStatusCode(e) === 503 || + getBulkUpdateStatusCode(e) === 429 || + getBulkUpdateStatusCode(e) === 500 || + getBulkUpdateStatusCode(e) === 503 ) ) ).pipe( diff --git a/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.ts b/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.ts index 517b29a54cd64..6007508451d9e 100644 --- a/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.ts +++ b/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.ts @@ -45,6 +45,7 @@ interface FillPoolStat extends JsonObject { claim_duration: number[]; claim_conflicts: number[]; claim_mismatches: number[]; + claim_stale_tasks: number[]; result_frequency_percent_as_number: FillPoolResult[]; persistence: TaskPersistence[]; } @@ -150,6 +151,7 @@ export function createTaskRunAggregator( const claimDurationQueue = createRunningAveragedStat(runningAverageWindowSize); const claimConflictsQueue = createRunningAveragedStat(runningAverageWindowSize); const claimMismatchesQueue = createRunningAveragedStat(runningAverageWindowSize); + const claimStaleTasksQueue = createRunningAveragedStat(runningAverageWindowSize); const polledTasksByPersistenceQueue = createRunningAveragedStat(runningAverageWindowSize); const taskPollingEvents$: Observable> = combineLatest([ @@ -161,9 +163,8 @@ export function createTaskRunAggregator( isOk(taskEvent.event) ), map((taskEvent: TaskLifecycleEvent) => { - const { result, stats: { tasksClaimed, tasksUpdated, tasksConflicted } = {} } = ( - taskEvent.event as unknown as Ok - ).value; + const { result, stats: { tasksClaimed, tasksUpdated, tasksConflicted, staleTasks } = {} } = + (taskEvent.event as unknown as Ok).value; const duration = (taskEvent?.timing?.stop ?? 0) - (taskEvent?.timing?.start ?? 0); return { polling: { @@ -179,6 +180,9 @@ export function createTaskRunAggregator( isNumber(tasksClaimed) && isNumber(tasksUpdated) ? claimMismatchesQueue(tasksUpdated - tasksClaimed) : claimMismatchesQueue(), + claim_stale_tasks: isNumber(staleTasks) + ? claimStaleTasksQueue(staleTasks) + : claimStaleTasksQueue(), result_frequency_percent_as_number: resultFrequencyQueue(result), }, }; @@ -258,6 +262,7 @@ export function createTaskRunAggregator( claim_duration: [], claim_conflicts: [], claim_mismatches: [], + claim_stale_tasks: [], result_frequency_percent_as_number: [], persistence: [], }, @@ -347,6 +352,7 @@ export function summarizeTaskRunStat( result_frequency_percent_as_number: pollingResultFrequency, claim_conflicts: claimConflicts, claim_mismatches: claimMismatches, + claim_stale_tasks: claimStaleTasks, persistence: pollingPersistence, }, drift, @@ -373,6 +379,7 @@ export function summarizeTaskRunStat( duration: calculateRunningAverage(pollingDuration as number[]), claim_conflicts: calculateRunningAverage(claimConflicts as number[]), claim_mismatches: calculateRunningAverage(claimMismatches as number[]), + claim_stale_tasks: calculateRunningAverage(claimStaleTasks as number[]), result_frequency_percent_as_number: { ...DEFAULT_POLLING_FREQUENCIES, ...calculateFrequency(pollingResultFrequency as FillPoolResult[]), diff --git a/x-pack/plugins/task_manager/server/routes/health.test.ts b/x-pack/plugins/task_manager/server/routes/health.test.ts index e3a7eb278d225..1e06ea91a6fcf 100644 --- a/x-pack/plugins/task_manager/server/routes/health.test.ts +++ b/x-pack/plugins/task_manager/server/routes/health.test.ts @@ -942,6 +942,7 @@ function mockHealthStats(overrides = {}) { claim_conflicts: [0, 100, 75], claim_mismatches: [0, 100, 75], claim_duration: [0, 100, 75], + claim_stale_tasks: [0, 100, 75], result_frequency_percent_as_number: [ FillPoolResult.NoTasksClaimed, FillPoolResult.NoTasksClaimed, diff --git a/x-pack/plugins/task_manager/server/task_claimers/index.ts b/x-pack/plugins/task_manager/server/task_claimers/index.ts index 4b6c8b96d6ca4..178ebacf68cb9 100644 --- a/x-pack/plugins/task_manager/server/task_claimers/index.ts +++ b/x-pack/plugins/task_manager/server/task_claimers/index.ts @@ -40,6 +40,7 @@ export interface ClaimOwnershipResult { tasksClaimed: number; tasksLeftUnclaimed?: number; tasksErrors?: number; + staleTasks?: number; }; docs: ConcreteTaskInstance[]; timing?: TaskTiming; @@ -70,6 +71,7 @@ export function getEmptyClaimOwnershipResult(): ClaimOwnershipResult { tasksUpdated: 0, tasksConflicted: 0, tasksClaimed: 0, + staleTasks: 0, }, docs: [], }; diff --git a/x-pack/plugins/task_manager/server/task_claimers/strategy_mget.test.ts b/x-pack/plugins/task_manager/server/task_claimers/strategy_mget.test.ts index 0d3560c3bec6e..fe44ce9e94c68 100644 --- a/x-pack/plugins/task_manager/server/task_claimers/strategy_mget.test.ts +++ b/x-pack/plugins/task_manager/server/task_claimers/strategy_mget.test.ts @@ -11,6 +11,7 @@ import { v4 as uuidv4 } from 'uuid'; import { filter, take } from 'rxjs'; import { CLAIM_STRATEGY_MGET, DEFAULT_KIBANAS_PER_PARTITION } from '../config'; +import { NO_ASSIGNED_PARTITIONS_WARNING_INTERVAL } from './strategy_mget'; import { TaskStatus, @@ -433,6 +434,7 @@ describe('TaskClaiming', () => { tasksConflicted: 0, tasksErrors: 0, tasksUpdated: 3, + staleTasks: 0, tasksLeftUnclaimed: 3, }); expect(result.docs.length).toEqual(3); @@ -529,6 +531,7 @@ describe('TaskClaiming', () => { tasksErrors: 0, tasksUpdated: 1, tasksLeftUnclaimed: 0, + staleTasks: 0, }); expect(result.docs.length).toEqual(1); }); @@ -640,6 +643,7 @@ describe('TaskClaiming', () => { tasksErrors: 0, tasksUpdated: 1, tasksLeftUnclaimed: 0, + staleTasks: 0, }); expect(result.docs.length).toEqual(1); }); @@ -737,6 +741,7 @@ describe('TaskClaiming', () => { tasksErrors: 0, tasksUpdated: 1, tasksLeftUnclaimed: 0, + staleTasks: 0, }); expect(result.docs.length).toEqual(1); }); @@ -792,6 +797,7 @@ describe('TaskClaiming', () => { tasksClaimed: 0, tasksConflicted: 0, tasksUpdated: 0, + staleTasks: 0, }); expect(result.docs.length).toEqual(0); }); @@ -885,6 +891,7 @@ describe('TaskClaiming', () => { tasksErrors: 0, tasksUpdated: 2, tasksLeftUnclaimed: 0, + staleTasks: 0, }); expect(result.docs.length).toEqual(2); }); @@ -978,6 +985,7 @@ describe('TaskClaiming', () => { tasksErrors: 0, tasksUpdated: 2, tasksLeftUnclaimed: 0, + staleTasks: 0, }); expect(result.docs.length).toEqual(2); }); @@ -1031,7 +1039,7 @@ describe('TaskClaiming', () => { expect(mockApmTrans.end).toHaveBeenCalledWith('success'); expect(taskManagerLogger.debug).toHaveBeenCalledWith( - 'task claimer claimed: 2; stale: 1; conflicts: 1; missing: 0; capacity reached: 0; updateErrors: 0; getErrors: 0; removed: 0;', + 'task claimer claimed: 2; stale: 1; conflicts: 0; missing: 0; capacity reached: 0; updateErrors: 0; getErrors: 0; removed: 0;', { tags: ['taskClaiming', 'claimAvailableTasksMget'] } ); @@ -1067,10 +1075,11 @@ describe('TaskClaiming', () => { expect(result.stats).toEqual({ tasksClaimed: 2, - tasksConflicted: 1, + tasksConflicted: 0, tasksErrors: 0, tasksUpdated: 2, tasksLeftUnclaimed: 0, + staleTasks: 1, }); expect(result.docs.length).toEqual(2); }); @@ -1197,6 +1206,7 @@ describe('TaskClaiming', () => { tasksErrors: 0, tasksUpdated: 4, tasksLeftUnclaimed: 0, + staleTasks: 0, }); expect(result.docs.length).toEqual(4); }); @@ -1330,19 +1340,20 @@ describe('TaskClaiming', () => { tasksErrors: 1, tasksUpdated: 3, tasksLeftUnclaimed: 0, + staleTasks: 0, }); expect(result.docs.length).toEqual(3); }); - test('should assign startedAt value if bulkGet returns task with null startedAt', async () => { + test('should skip tasks where bulkGet returns a newer task document than the bulkPartialUpdate', async () => { const store = taskStoreMock.create({ taskManagerId: 'test-test' }); store.convertToSavedObjectIds.mockImplementation((ids) => ids.map((id) => `task:${id}`)); const fetchedTasks = [ - mockInstance({ id: `id-1`, taskType: 'report' }), - mockInstance({ id: `id-2`, taskType: 'report' }), - mockInstance({ id: `id-3`, taskType: 'yawn' }), - mockInstance({ id: `id-4`, taskType: 'report' }), + mockInstance({ id: `id-1`, taskType: 'report', version: '123' }), + mockInstance({ id: `id-2`, taskType: 'report', version: '123' }), + mockInstance({ id: `id-3`, taskType: 'yawn', version: '123' }), + mockInstance({ id: `id-4`, taskType: 'report', version: '123' }), ]; const { versionMap, docLatestVersions } = getVersionMapsFromTasks(fetchedTasks); @@ -1355,7 +1366,7 @@ describe('TaskClaiming', () => { ); store.bulkGet.mockResolvedValueOnce([ asOk({ ...fetchedTasks[0], startedAt: new Date() }), - asOk(fetchedTasks[1]), + asOk({ ...fetchedTasks[1], startedAt: new Date(), version: 'abc' }), asOk({ ...fetchedTasks[2], startedAt: new Date() }), asOk({ ...fetchedTasks[3], startedAt: new Date() }), ]); @@ -1389,11 +1400,11 @@ describe('TaskClaiming', () => { expect(mockApmTrans.end).toHaveBeenCalledWith('success'); expect(taskManagerLogger.debug).toHaveBeenCalledWith( - 'task claimer claimed: 4; stale: 0; conflicts: 0; missing: 0; capacity reached: 0; updateErrors: 0; getErrors: 0; removed: 0;', + 'task claimer claimed: 3; stale: 0; conflicts: 1; missing: 0; capacity reached: 0; updateErrors: 0; getErrors: 0; removed: 0;', { tags: ['taskClaiming', 'claimAvailableTasksMget'] } ); expect(taskManagerLogger.warn).toHaveBeenCalledWith( - 'Task id-2 has a null startedAt value, setting to current time - ownerId null, status idle', + 'Task id-2 was modified during the claiming phase, skipping until the next claiming cycle.', { tags: ['taskClaiming', 'claimAvailableTasksMget'] } ); @@ -1453,13 +1464,14 @@ describe('TaskClaiming', () => { expect(store.bulkGet).toHaveBeenCalledWith(['id-1', 'id-2', 'id-3', 'id-4']); expect(result.stats).toEqual({ - tasksClaimed: 4, - tasksConflicted: 0, + tasksClaimed: 3, + tasksConflicted: 1, tasksErrors: 0, - tasksUpdated: 4, + tasksUpdated: 3, tasksLeftUnclaimed: 0, + staleTasks: 0, }); - expect(result.docs.length).toEqual(4); + expect(result.docs.length).toEqual(3); for (const r of result.docs) { expect(r.startedAt).not.toBeNull(); } @@ -1699,6 +1711,7 @@ describe('TaskClaiming', () => { tasksErrors: 1, tasksUpdated: 3, tasksLeftUnclaimed: 0, + staleTasks: 0, }); expect(result.docs.length).toEqual(3); }); @@ -1829,6 +1842,7 @@ describe('TaskClaiming', () => { tasksErrors: 0, tasksUpdated: 3, tasksLeftUnclaimed: 0, + staleTasks: 0, }); expect(result.docs.length).toEqual(3); }); @@ -2247,6 +2261,129 @@ describe('TaskClaiming', () => { } `); }); + + test(`it should log warning on interval when the node has no assigned partitions`, async () => { + // Reset the warning timer by advancing more + fakeTimer.tick(NO_ASSIGNED_PARTITIONS_WARNING_INTERVAL); + + jest.spyOn(taskPartitioner, 'getPartitions').mockResolvedValue([]); + const taskManagerId = uuidv4(); + const definitions = new TaskTypeDictionary(mockLogger()); + definitions.registerTaskDefinitions({ + foo: { + title: 'foo', + createTaskRunner: jest.fn(), + }, + bar: { + title: 'bar', + createTaskRunner: jest.fn(), + }, + }); + await testClaimAvailableTasks({ + storeOpts: { + taskManagerId, + definitions, + }, + taskClaimingOpts: {}, + claimingOpts: { + claimOwnershipUntil: new Date(), + }, + }); + + expect(taskManagerLogger.warn).toHaveBeenCalledWith( + 'Background task node "test" has no assigned partitions, claiming against all partitions', + { tags: ['taskClaiming', 'claimAvailableTasksMget'] } + ); + + taskManagerLogger.warn.mockReset(); + fakeTimer.tick(NO_ASSIGNED_PARTITIONS_WARNING_INTERVAL - 500); + + await testClaimAvailableTasks({ + storeOpts: { + taskManagerId, + definitions, + }, + taskClaimingOpts: {}, + claimingOpts: { + claimOwnershipUntil: new Date(), + }, + }); + + expect(taskManagerLogger.warn).not.toHaveBeenCalled(); + + fakeTimer.tick(500); + + await testClaimAvailableTasks({ + storeOpts: { + taskManagerId, + definitions, + }, + taskClaimingOpts: {}, + claimingOpts: { + claimOwnershipUntil: new Date(), + }, + }); + + expect(taskManagerLogger.warn).toHaveBeenCalledWith( + 'Background task node "test" has no assigned partitions, claiming against all partitions', + { tags: ['taskClaiming', 'claimAvailableTasksMget'] } + ); + }); + + test(`it should log a message after the node no longer has no assigned partitions`, async () => { + // Reset the warning timer by advancing more + fakeTimer.tick(NO_ASSIGNED_PARTITIONS_WARNING_INTERVAL); + + jest.spyOn(taskPartitioner, 'getPartitions').mockResolvedValue([]); + const taskManagerId = uuidv4(); + const definitions = new TaskTypeDictionary(mockLogger()); + definitions.registerTaskDefinitions({ + foo: { + title: 'foo', + createTaskRunner: jest.fn(), + }, + bar: { + title: 'bar', + createTaskRunner: jest.fn(), + }, + }); + await testClaimAvailableTasks({ + storeOpts: { + taskManagerId, + definitions, + }, + taskClaimingOpts: {}, + claimingOpts: { + claimOwnershipUntil: new Date(), + }, + }); + + expect(taskManagerLogger.warn).toHaveBeenCalledWith( + 'Background task node "test" has no assigned partitions, claiming against all partitions', + { tags: ['taskClaiming', 'claimAvailableTasksMget'] } + ); + + taskManagerLogger.warn.mockReset(); + jest.spyOn(taskPartitioner, 'getPartitions').mockResolvedValue([1, 2, 3]); + fakeTimer.tick(500); + + await testClaimAvailableTasks({ + storeOpts: { + taskManagerId, + definitions, + }, + taskClaimingOpts: {}, + claimingOpts: { + claimOwnershipUntil: new Date(), + }, + }); + + expect(taskManagerLogger.warn).not.toHaveBeenCalled(); + expect(taskManagerLogger.info).toHaveBeenCalledWith( + `Background task node "${taskPartitioner.getPodName()}" now claiming with assigned partitions`, + { tags: ['taskClaiming', 'claimAvailableTasksMget'] } + ); + }); }); describe('task events', () => { diff --git a/x-pack/plugins/task_manager/server/task_claimers/strategy_mget.ts b/x-pack/plugins/task_manager/server/task_claimers/strategy_mget.ts index 4b7e5ec6b3691..16d9ba5c7fae7 100644 --- a/x-pack/plugins/task_manager/server/task_claimers/strategy_mget.ts +++ b/x-pack/plugins/task_manager/server/task_claimers/strategy_mget.ts @@ -195,15 +195,15 @@ async function claimAvailableTasks(opts: TaskClaimerOpts): Promise = {}; + let conflicts = 0; let bulkUpdateErrors = 0; let bulkGetErrors = 0; const updateResults = await taskStore.bulkPartialUpdate(taskUpdates); for (const updateResult of updateResults) { if (isOk(updateResult)) { - updatedTaskIds.push(updateResult.value.id); + updatedTasks[updateResult.value.id] = updateResult.value; } else { const { id, type, error, status } = updateResult.error; @@ -218,29 +218,23 @@ async function claimAvailableTasks(opts: TaskClaimerOpts): Promise( - (acc, task) => { - if (isOk(task)) { - acc.push(task.value); - } else { - const { id, type, error } = task.error; - logger.error(`Error getting full task ${id}:${type} during claim: ${error.message}`); - bulkGetErrors++; - } - return acc; - }, - [] - ); - - // Look for tasks that have a null startedAt value, log them and manually set a startedAt field - for (const task of fullTasksToRun) { - if (task.startedAt == null) { + const fullTasksToRun = (await taskStore.bulkGet(Object.keys(updatedTasks))).reduce< + ConcreteTaskInstance[] + >((acc, task) => { + if (isOk(task) && task.value.version !== updatedTasks[task.value.id].version) { logger.warn( - `Task ${task.id} has a null startedAt value, setting to current time - ownerId ${task.ownerId}, status ${task.status}` + `Task ${task.value.id} was modified during the claiming phase, skipping until the next claiming cycle.` ); - task.startedAt = now; + conflicts++; + } else if (isOk(task)) { + acc.push(task.value); + } else { + const { id, type, error } = task.error; + logger.error(`Error getting full task ${id}:${type} during claim: ${error.message}`); + bulkGetErrors++; } - } + return acc; + }, []); // separate update for removed tasks; shouldn't happen often, so unlikely // a performance concern, and keeps the rest of the logic simpler @@ -288,6 +282,7 @@ async function claimAvailableTasks(opts: TaskClaimerOpts): Promise; } +let lastPartitionWarningLog: number | undefined; +export const NO_ASSIGNED_PARTITIONS_WARNING_INTERVAL = 60000; + async function searchAvailableTasks({ definitions, taskTypes, @@ -325,10 +323,22 @@ async function searchAvailableTasks({ definitions, }); const partitions = await taskPartitioner.getPartitions(); - if (partitions.length === 0) { + if ( + partitions.length === 0 && + (lastPartitionWarningLog == null || + lastPartitionWarningLog <= Date.now() - NO_ASSIGNED_PARTITIONS_WARNING_INTERVAL) + ) { logger.warn( `Background task node "${taskPartitioner.getPodName()}" has no assigned partitions, claiming against all partitions` ); + lastPartitionWarningLog = Date.now(); + } + + if (partitions.length !== 0 && lastPartitionWarningLog) { + lastPartitionWarningLog = undefined; + logger.info( + `Background task node "${taskPartitioner.getPodName()}" now claiming with assigned partitions` + ); } const sort: NonNullable = getClaimSort(definitions); diff --git a/x-pack/plugins/task_manager/server/task_store.test.ts b/x-pack/plugins/task_manager/server/task_store.test.ts index f1374f6d27b76..cbb1c44dde3fc 100644 --- a/x-pack/plugins/task_manager/server/task_store.test.ts +++ b/x-pack/plugins/task_manager/server/task_store.test.ts @@ -1020,7 +1020,10 @@ describe('TaskStore', () => { refresh: false, }); - expect(result).toEqual([asOk(task)]); + expect(result).toEqual([ + // New version returned after update + asOk({ ...task, version: 'Wzg0LDFd' }), + ]); }); test(`should perform partial update with minimal fields`, async () => { @@ -1062,7 +1065,8 @@ describe('TaskStore', () => { refresh: false, }); - expect(result).toEqual([asOk(task)]); + // New version returned after update + expect(result).toEqual([asOk({ ...task, version: 'Wzg0LDFd' })]); }); test(`should perform partial update with no version`, async () => { @@ -1100,7 +1104,8 @@ describe('TaskStore', () => { refresh: false, }); - expect(result).toEqual([asOk(task)]); + // New version returned after update + expect(result).toEqual([asOk({ ...task, version: 'Wzg0LDFd' })]); }); test(`should gracefully handle errors within the response`, async () => { @@ -1183,7 +1188,8 @@ describe('TaskStore', () => { }); expect(result).toEqual([ - asOk(task1), + // New version returned after update + asOk({ ...task1, version: 'Wzg0LDFd' }), asErr({ type: 'task', id: '45343254', @@ -1267,7 +1273,8 @@ describe('TaskStore', () => { }); expect(result).toEqual([ - asOk(task1), + // New version returned after update + asOk({ ...task1, version: 'Wzg0LDFd' }), asErr({ type: 'task', id: 'unknown', @@ -1290,6 +1297,62 @@ describe('TaskStore', () => { ); expect(await firstErrorPromise).toMatchInlineSnapshot(`[Error: Failure]`); }); + + test('pushes errors returned by the saved objects client to errors$', async () => { + const task = { + id: '324242', + version: 'WzQsMV0=', + attempts: 3, + }; + + const firstErrorPromise = store.errors$.pipe(first()).toPromise(); + + esClient.bulk.mockResolvedValue({ + errors: true, + items: [ + { + update: { + _id: '1', + _index: 'test-index', + status: 403, + error: { reason: 'Error reason', type: 'cluster_block_exception' }, + }, + }, + ], + took: 10, + }); + + await store.bulkPartialUpdate([task]); + + expect(await firstErrorPromise).toMatchInlineSnapshot(`[Error: Error reason]`); + }); + + test('pushes errors for the malformed responses to errors$', async () => { + const task = { + id: '324242', + version: 'WzQsMV0=', + attempts: 3, + }; + + const firstErrorPromise = store.errors$.pipe(first()).toPromise(); + + esClient.bulk.mockResolvedValue({ + errors: false, + items: [ + { + update: { + _index: 'test-index', + status: 200, + }, + }, + ], + took: 10, + }); + + await store.bulkPartialUpdate([task]); + + expect(await firstErrorPromise).toMatchInlineSnapshot(`[Error: malformed response]`); + }); }); describe('remove', () => { diff --git a/x-pack/plugins/task_manager/server/task_store.ts b/x-pack/plugins/task_manager/server/task_store.ts index 2b3440e87c0f8..0946c5c18d328 100644 --- a/x-pack/plugins/task_manager/server/task_store.ts +++ b/x-pack/plugins/task_manager/server/task_store.ts @@ -26,7 +26,7 @@ import { ElasticsearchClient, } from '@kbn/core/server'; -import { decodeRequestVersion } from '@kbn/core-saved-objects-base-server-internal'; +import { decodeRequestVersion, encodeVersion } from '@kbn/core-saved-objects-base-server-internal'; import { RequestTimeoutsConfig } from './config'; import { asOk, asErr, Result } from './lib/result_type'; @@ -48,6 +48,7 @@ import { claimSort } from './queries/mark_available_tasks_as_claimed'; import { MAX_PARTITIONS } from './lib/task_partitioner'; import { ErrorOutput } from './lib/bulk_operation_buffer'; import { MsearchError } from './lib/msearch_error'; +import { BulkUpdateError } from './lib/bulk_update_error'; export interface StoreOpts { esClient: ElasticsearchClient; @@ -386,11 +387,19 @@ export class TaskStore { } return result.items.map((item) => { + const malformedResponseType = 'malformed response'; + if (!item.update || !item.update._id) { + const err = new BulkUpdateError({ + message: malformedResponseType, + type: malformedResponseType, + statusCode: 500, + }); + this.errors$.next(err); return asErr({ type: 'task', id: 'unknown', - error: { type: 'malformed response' }, + error: { type: malformedResponseType }, }); } @@ -399,6 +408,12 @@ export class TaskStore { : item.update._id; if (item.update?.error) { + const err = new BulkUpdateError({ + message: item.update.error.reason, + type: item.update.error.type, + statusCode: item.update.status, + }); + this.errors$.next(err); return asErr({ type: 'task', id: docId, @@ -412,6 +427,7 @@ export class TaskStore { return asOk({ ...doc, id: docId, + version: encodeVersion(item.update._seq_no, item.update._primary_term), }); }); } diff --git a/x-pack/plugins/telemetry_collection_xpack/kibana.jsonc b/x-pack/plugins/telemetry_collection_xpack/kibana.jsonc index 5e0675a9e12f8..c5731e427656c 100644 --- a/x-pack/plugins/telemetry_collection_xpack/kibana.jsonc +++ b/x-pack/plugins/telemetry_collection_xpack/kibana.jsonc @@ -1,13 +1,17 @@ { "type": "plugin", "id": "@kbn/telemetry-collection-xpack-plugin", - "owner": "@elastic/kibana-core", + "owner": [ + "@elastic/kibana-core" + ], + "group": "platform", + "visibility": "private", "plugin": { "id": "telemetryCollectionXpack", - "server": true, "browser": false, + "server": true, "requiredPlugins": [ "telemetryCollectionManager" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/telemetry_collection_xpack/server/index.ts b/x-pack/plugins/telemetry_collection_xpack/server/index.ts index 85a3adc243236..c0226d5eea265 100644 --- a/x-pack/plugins/telemetry_collection_xpack/server/index.ts +++ b/x-pack/plugins/telemetry_collection_xpack/server/index.ts @@ -5,8 +5,6 @@ * 2.0. */ -export type { ESLicense } from './telemetry_collection'; - // This exports static code and TypeScript types, // as well as, Kibana Platform `plugin()` initializer. diff --git a/x-pack/plugins/threat_intelligence/kibana.jsonc b/x-pack/plugins/threat_intelligence/kibana.jsonc index 35077b11facac..b4c5424e51d84 100644 --- a/x-pack/plugins/threat_intelligence/kibana.jsonc +++ b/x-pack/plugins/threat_intelligence/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/threat-intelligence-plugin", - "owner": "@elastic/security-threat-hunting-investigations", + "owner": [ + "@elastic/security-threat-hunting-investigations" + ], + "group": "security", + "visibility": "private", "description": "Elastic threat intelligence helps you see if you are open to or have been subject to current or historical known threats", "plugin": { "id": "threatIntelligence", - "server": true, "browser": true, + "server": true, "requiredPlugins": [ "cases", "data", @@ -24,4 +28,4 @@ "kibanaReact" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/timelines/kibana.jsonc b/x-pack/plugins/timelines/kibana.jsonc index 8855284d024af..368c570711bd6 100644 --- a/x-pack/plugins/timelines/kibana.jsonc +++ b/x-pack/plugins/timelines/kibana.jsonc @@ -1,11 +1,15 @@ { "type": "plugin", "id": "@kbn/timelines-plugin", - "owner": "@elastic/security-threat-hunting-investigations", + "owner": [ + "@elastic/security-threat-hunting-investigations" + ], + "group": "security", + "visibility": "private", "plugin": { "id": "timelines", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "timelines" @@ -24,4 +28,4 @@ "common" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/transform/kibana.jsonc b/x-pack/plugins/transform/kibana.jsonc index 1f8ab0fe72f40..8c81d44c21bc4 100644 --- a/x-pack/plugins/transform/kibana.jsonc +++ b/x-pack/plugins/transform/kibana.jsonc @@ -1,12 +1,16 @@ { "type": "plugin", "id": "@kbn/transform-plugin", - "owner": "@elastic/ml-ui", + "owner": [ + "@elastic/ml-ui" + ], + "group": "platform", + "visibility": "private", "description": "This plugin provides access to the transforms features provided by Elastic. Transforms enable you to convert existing Elasticsearch indices into summarized indices, which provide opportunities for new insights and analytics.", "plugin": { "id": "transform", - "server": true, "browser": true, + "server": true, "configPath": [ "xpack", "transform" @@ -26,7 +30,7 @@ "charts", "savedObjectsFinder", "savedObjectsManagement", - "contentManagement", + "contentManagement" ], "optionalPlugins": [ "dataViewEditor", @@ -39,10 +43,10 @@ "esUiShared", "discover", "kibanaUtils", - "kibanaReact", + "kibanaReact" ], "extraPublicDirs": [ "common" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx index ab2865b85eb8a..2c078f93627cc 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx @@ -111,7 +111,7 @@ export const CreateTransformWizardContext = createContext<{ export const Wizard: FC = React.memo(({ cloneConfig, searchItems }) => { const { showNodeInfo } = useEnabledFeatures(); const appDependencies = useAppDependencies(); - const { uiSettings, data, fieldFormats, charts } = appDependencies; + const { uiSettings, data, fieldFormats, charts, theme } = appDependencies; const { dataView } = searchItems; // The current WIZARD_STEP @@ -247,6 +247,7 @@ export const Wizard: FC = React.memo(({ cloneConfig, searchItems }) fieldStatsServices={fieldStatsServices} timeRangeMs={stepDefineState.timeRangeMs} dslQuery={transformConfig.source.query} + theme={theme} > ( { version: '1', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because permissions will be checked by elasticsearch', + }, + }, validate: { request: { params: transformIdParamSchema, diff --git a/x-pack/plugins/transform/server/routes/api/delete_transforms/register_route.ts b/x-pack/plugins/transform/server/routes/api/delete_transforms/register_route.ts index 20c169c79dda0..3a7af3313175a 100644 --- a/x-pack/plugins/transform/server/routes/api/delete_transforms/register_route.ts +++ b/x-pack/plugins/transform/server/routes/api/delete_transforms/register_route.ts @@ -34,6 +34,13 @@ export function registerRoute(routeDependencies: RouteDependencies) { .addVersion( { version: '1', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because permissions will be checked by elasticsearch', + }, + }, validate: { request: { body: deleteTransformsRequestSchema, diff --git a/x-pack/plugins/transform/server/routes/api/field_histograms/register_route.ts b/x-pack/plugins/transform/server/routes/api/field_histograms/register_route.ts index c3fe803ba2366..03cee7befb151 100644 --- a/x-pack/plugins/transform/server/routes/api/field_histograms/register_route.ts +++ b/x-pack/plugins/transform/server/routes/api/field_histograms/register_route.ts @@ -23,6 +23,13 @@ export function registerRoute({ router, getLicense }: RouteDependencies) { .addVersion( { version: '1', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because permissions will be checked by elasticsearch', + }, + }, validate: { request: { params: dataViewTitleSchema, diff --git a/x-pack/plugins/transform/server/routes/api/reauthorize_transforms/register_route.ts b/x-pack/plugins/transform/server/routes/api/reauthorize_transforms/register_route.ts index 2826820f8d232..55d4f66e7dc90 100644 --- a/x-pack/plugins/transform/server/routes/api/reauthorize_transforms/register_route.ts +++ b/x-pack/plugins/transform/server/routes/api/reauthorize_transforms/register_route.ts @@ -32,6 +32,13 @@ export function registerRoute(routeDependencies: RouteDependencies) { .addVersion( { version: '1', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because permissions will be checked by elasticsearch', + }, + }, validate: { request: { body: reauthorizeTransformsRequestSchema, diff --git a/x-pack/plugins/transform/server/routes/api/reset_transforms/register_route.ts b/x-pack/plugins/transform/server/routes/api/reset_transforms/register_route.ts index 5a239f0767fa4..de6c3e8408f62 100644 --- a/x-pack/plugins/transform/server/routes/api/reset_transforms/register_route.ts +++ b/x-pack/plugins/transform/server/routes/api/reset_transforms/register_route.ts @@ -33,6 +33,13 @@ export function registerRoute({ router, getLicense }: RouteDependencies) { .addVersion( { version: '1', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because permissions will be checked by elasticsearch', + }, + }, validate: { request: { body: resetTransformsRequestSchema, diff --git a/x-pack/plugins/transform/server/routes/api/schedule_now_transforms/register_route.ts b/x-pack/plugins/transform/server/routes/api/schedule_now_transforms/register_route.ts index 75000050ddebb..a19f0d0ef61af 100644 --- a/x-pack/plugins/transform/server/routes/api/schedule_now_transforms/register_route.ts +++ b/x-pack/plugins/transform/server/routes/api/schedule_now_transforms/register_route.ts @@ -33,6 +33,13 @@ export function registerRoute({ router, getLicense }: RouteDependencies) { .addVersion( { version: '1', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because permissions will be checked by elasticsearch', + }, + }, validate: { request: { body: scheduleNowTransformsRequestSchema, diff --git a/x-pack/plugins/transform/server/routes/api/start_transforms/register_route.ts b/x-pack/plugins/transform/server/routes/api/start_transforms/register_route.ts index 1ba1a0f098a78..ddff95471711f 100644 --- a/x-pack/plugins/transform/server/routes/api/start_transforms/register_route.ts +++ b/x-pack/plugins/transform/server/routes/api/start_transforms/register_route.ts @@ -33,6 +33,13 @@ export function registerRoute({ router, getLicense }: RouteDependencies) { .addVersion( { version: '1', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because permissions will be checked by elasticsearch', + }, + }, validate: { request: { body: startTransformsRequestSchema, diff --git a/x-pack/plugins/transform/server/routes/api/stop_transforms/register_route.ts b/x-pack/plugins/transform/server/routes/api/stop_transforms/register_route.ts index 5fdc5a97dd55a..bb1c73482f525 100644 --- a/x-pack/plugins/transform/server/routes/api/stop_transforms/register_route.ts +++ b/x-pack/plugins/transform/server/routes/api/stop_transforms/register_route.ts @@ -33,6 +33,13 @@ export function registerRoute({ router, getLicense }: RouteDependencies) { .addVersion( { version: '1', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because permissions will be checked by elasticsearch', + }, + }, validate: { request: { body: stopTransformsRequestSchema, diff --git a/x-pack/plugins/transform/server/routes/api/transforms_all/register_route.ts b/x-pack/plugins/transform/server/routes/api/transforms_all/register_route.ts index 29cbd34bffd68..9bd95b02d60ae 100644 --- a/x-pack/plugins/transform/server/routes/api/transforms_all/register_route.ts +++ b/x-pack/plugins/transform/server/routes/api/transforms_all/register_route.ts @@ -32,6 +32,13 @@ export function registerRoute({ router, getLicense }: RouteDependencies) { .addVersion( { version: '1', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because permissions will be checked by elasticsearch', + }, + }, validate: false, }, async (ctx, request, response) => { diff --git a/x-pack/plugins/transform/server/routes/api/transforms_create/register_route.ts b/x-pack/plugins/transform/server/routes/api/transforms_create/register_route.ts index 7cfb0dc90a410..82df03077111b 100644 --- a/x-pack/plugins/transform/server/routes/api/transforms_create/register_route.ts +++ b/x-pack/plugins/transform/server/routes/api/transforms_create/register_route.ts @@ -43,6 +43,13 @@ export function registerRoute(routeDependencies: RouteDependencies) { .addVersion( { version: '1', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because permissions will be checked by elasticsearch', + }, + }, validate: { request: { params: transformIdParamSchema, diff --git a/x-pack/plugins/transform/server/routes/api/transforms_nodes/register_route.ts b/x-pack/plugins/transform/server/routes/api/transforms_nodes/register_route.ts index 701306ff6481c..f63e3412e24d8 100644 --- a/x-pack/plugins/transform/server/routes/api/transforms_nodes/register_route.ts +++ b/x-pack/plugins/transform/server/routes/api/transforms_nodes/register_route.ts @@ -27,6 +27,13 @@ export function registerRoute({ router, getLicense }: RouteDependencies) { .addVersion( { version: '1', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because permissions will be checked by elasticsearch', + }, + }, validate: false, }, async (ctx, request, response) => { diff --git a/x-pack/plugins/transform/server/routes/api/transforms_preview/register_route.ts b/x-pack/plugins/transform/server/routes/api/transforms_preview/register_route.ts index 21a902e575b04..386e50296317d 100644 --- a/x-pack/plugins/transform/server/routes/api/transforms_preview/register_route.ts +++ b/x-pack/plugins/transform/server/routes/api/transforms_preview/register_route.ts @@ -31,6 +31,13 @@ export function registerRoute({ router, getLicense }: RouteDependencies) { .addVersion( { version: '1', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because permissions will be checked by elasticsearch', + }, + }, validate: { request: { body: postTransformsPreviewRequestSchema, diff --git a/x-pack/plugins/transform/server/routes/api/transforms_single/register_route.ts b/x-pack/plugins/transform/server/routes/api/transforms_single/register_route.ts index 30b54b3847992..bdec2e5bd2836 100644 --- a/x-pack/plugins/transform/server/routes/api/transforms_single/register_route.ts +++ b/x-pack/plugins/transform/server/routes/api/transforms_single/register_route.ts @@ -30,6 +30,13 @@ export function registerRoute({ router, getLicense }: RouteDependencies) { .addVersion( { version: '1', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because permissions will be checked by elasticsearch', + }, + }, validate: { request: { params: transformIdParamSchema, diff --git a/x-pack/plugins/transform/server/routes/api/transforms_stats_all/register_route.ts b/x-pack/plugins/transform/server/routes/api/transforms_stats_all/register_route.ts index 3136163cf99f5..40f21ed1f84b5 100644 --- a/x-pack/plugins/transform/server/routes/api/transforms_stats_all/register_route.ts +++ b/x-pack/plugins/transform/server/routes/api/transforms_stats_all/register_route.ts @@ -37,6 +37,13 @@ export function registerRoute({ router, getLicense }: RouteDependencies) { >( { version: '1', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because permissions will be checked by elasticsearch', + }, + }, validate: { request: { query: getTransformStatsQuerySchema, diff --git a/x-pack/plugins/transform/server/routes/api/transforms_stats_single/register_route.ts b/x-pack/plugins/transform/server/routes/api/transforms_stats_single/register_route.ts index 5e784506ae57a..29178398ea631 100644 --- a/x-pack/plugins/transform/server/routes/api/transforms_stats_single/register_route.ts +++ b/x-pack/plugins/transform/server/routes/api/transforms_stats_single/register_route.ts @@ -34,6 +34,13 @@ export function registerRoute({ router, getLicense }: RouteDependencies) { .addVersion( { version: '1', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because permissions will be checked by elasticsearch', + }, + }, validate: { request: { params: transformIdParamSchema, diff --git a/x-pack/plugins/transform/server/routes/api/transforms_update/register_route.ts b/x-pack/plugins/transform/server/routes/api/transforms_update/register_route.ts index 60719e68d596c..916b68a369098 100644 --- a/x-pack/plugins/transform/server/routes/api/transforms_update/register_route.ts +++ b/x-pack/plugins/transform/server/routes/api/transforms_update/register_route.ts @@ -35,6 +35,13 @@ export function registerRoute({ router, getLicense }: RouteDependencies) { .addVersion( { version: '1', + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because permissions will be checked by elasticsearch', + }, + }, validate: { request: { params: transformIdParamSchema, diff --git a/x-pack/plugins/translations/kibana.jsonc b/x-pack/plugins/translations/kibana.jsonc index 910429b866de4..a72b43152d4a2 100644 --- a/x-pack/plugins/translations/kibana.jsonc +++ b/x-pack/plugins/translations/kibana.jsonc @@ -1,14 +1,18 @@ { "type": "plugin", "id": "@kbn/translations-plugin", - "owner": "@elastic/kibana-localization", + "owner": [ + "@elastic/kibana-localization" + ], + "group": "platform", + "visibility": "private", "plugin": { "id": "translations", - "server": true, "browser": false, + "server": true, "configPath": [ "x-pack", "translations" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 54f56d8eb8658..513132e18b82a 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -1,5 +1,80 @@ { - "formats": {}, + "formats": { + "number": { + "currency": { + "style": "currency" + }, + "percent": { + "style": "percent" + } + }, + "date": { + "short": { + "month": "numeric", + "day": "numeric", + "year": "2-digit" + }, + "medium": { + "month": "short", + "day": "numeric", + "year": "numeric" + }, + "long": { + "month": "long", + "day": "numeric", + "year": "numeric" + }, + "full": { + "weekday": "long", + "month": "long", + "day": "numeric", + "year": "numeric" + } + }, + "time": { + "short": { + "hour": "numeric", + "minute": "numeric" + }, + "medium": { + "hour": "numeric", + "minute": "numeric", + "second": "numeric" + }, + "long": { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short" + }, + "full": { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short" + } + }, + "relative": { + "years": { + "style": "long" + }, + "months": { + "style": "long" + }, + "days": { + "style": "long" + }, + "hours": { + "style": "long" + }, + "minutes": { + "style": "long" + }, + "seconds": { + "style": "long" + } + } + }, "messages": { "advancedSettings.advancedSettingsLabel": "Paramètres avancés", "advancedSettings.featureCatalogueTitle": "Personnalisez votre expérience Kibana : modifiez le format de date, activez le mode sombre, et bien plus encore.", @@ -8,7 +83,7 @@ "aiAssistantManagementSelection.aiAssistantSelectionPage.obsAssistant.manageSettingsButtonLabel": "Gérer les paramètres", "aiAssistantManagementSelection.aiAssistantSelectionPage.observabilityAi.thisFeatureIsDisabledCallOutLabel": "Cette fonctionnalité est désactivée.", "aiAssistantManagementSelection.aiAssistantSelectionPage.observabilityLabel": "Assistant d'IA Elastic pour Observability", - "aiAssistantManagementSelection.aiAssistantSelectionPage.securityAi.thisFeatureIsDisabledCallOutLabel": "Cette fonctionnalité est désactivée. Elle peut être activée dans Espaces > Fonctionnalités.", + "aiAssistantManagementSelection.aiAssistantSelectionPage.securityAi.thisFeatureIsDisabledCallOutLabel": "Cette fonctionnalité est désactivée. Vous pouvez l'activer à partir de Espaces > Fonctionnalités.", "aiAssistantManagementSelection.aiAssistantSelectionPage.securityAssistant.documentationLinkDescription": "Pour en savoir plus, consultez notre {documentation}.", "aiAssistantManagementSelection.aiAssistantSelectionPage.securityAssistant.manageSettingsButtonLabel": "Gérer les paramètres", "aiAssistantManagementSelection.aiAssistantSelectionPage.securityLabel": "Assistant d'IA Elastic pour Security", @@ -27,6 +102,41 @@ "aiAssistantManagementSelection.preferredAIAssistantTypeSettingValueNever": "Nulle part", "aiAssistantManagementSelection.preferredAIAssistantTypeSettingValueObservability": "Partout", "alertingTypes.builtinActionGroups.recovered": "Récupéré", + "alertsGrouping.unit": "{totalCount, plural, =1 {alerte} other {alertes}}", + "alertsUIShared.actiActionsonNotifyWhen.forEachOption": "Pour chaque alerte", + "alertsUIShared.actiActionsonNotifyWhen.summaryOption": "Résumé des alertes", + "alertsUIShared.actionForm.actionGroupRecoveredMessage": "Récupéré", + "alertsUIShared.actionVariables.alertActionGroupLabel": "Groupe d'actions de l'alerte ayant programmé les actions pour la règle.", + "alertsUIShared.actionVariables.alertActionGroupNameLabel": "Nom lisible par l'utilisateur du groupe d'actions de l'alerte ayant programmé les actions pour la règle.", + "alertsUIShared.actionVariables.alertConsecutiveMatchesLabel": "Le nombre de courses consécutives qui remplissent les conditions de la règle.", + "alertsUIShared.actionVariables.alertFlappingLabel": "Indicateur sur l'alerte spécifiant si le statut de l'alerte change fréquemment.", + "alertsUIShared.actionVariables.alertIdLabel": "ID de l'alerte ayant programmé les actions pour la règle.", + "alertsUIShared.actionVariables.alertUuidLabel": "UUID de l'alerte ayant programmé les actions pour la règle.", + "alertsUIShared.actionVariables.allAlertsCountLabel": "Décompte de toutes les alertes.", + "alertsUIShared.actionVariables.allAlertsDataLabel": "Tableau d'objets pour toutes les alertes.", + "alertsUIShared.actionVariables.dateLabel": "Date à laquelle la règle a programmé l'action.", + "alertsUIShared.actionVariables.kibanaBaseUrlLabel": "Valeur server.publicBaseUrl configurée ou chaîne vide si elle n'est pas configurée.", + "alertsUIShared.actionVariables.legacyAlertActionGroupLabel": "Cet élément a été déclassé au profit de {variable}.", + "alertsUIShared.actionVariables.legacyAlertActionGroupNameLabel": "Cet élément a été déclassé au profit de {variable}.", + "alertsUIShared.actionVariables.legacyAlertIdLabel": "Cet élément a été déclassé au profit de {variable}.", + "alertsUIShared.actionVariables.legacyAlertInstanceIdLabel": "Cet élément a été déclassé au profit de {variable}.", + "alertsUIShared.actionVariables.legacyAlertNameLabel": "Cet élément a été déclassé au profit de {variable}.", + "alertsUIShared.actionVariables.legacyParamsLabel": "Cet élément a été déclassé au profit de {variable}.", + "alertsUIShared.actionVariables.legacySpaceIdLabel": "Cet élément a été déclassé au profit de {variable}.", + "alertsUIShared.actionVariables.legacyTagsLabel": "Cet élément a été déclassé au profit de {variable}.", + "alertsUIShared.actionVariables.newAlertsCountLabel": "Décompte des nouvelles alertes.", + "alertsUIShared.actionVariables.newAlertsDataLabel": "Tableau d'objets pour les nouvelles alertes.", + "alertsUIShared.actionVariables.ongoingAlertsCountLabel": "Décompte des alertes en cours.", + "alertsUIShared.actionVariables.ongoingAlertsDataLabel": "Tableau d'objets pour les alertes en cours.", + "alertsUIShared.actionVariables.recoveredAlertsCountLabel": "Décompte des alertes récupérées.", + "alertsUIShared.actionVariables.recoveredAlertsDataLabel": "Tableau d'objets pour les alertes récupérées.", + "alertsUIShared.actionVariables.ruleIdLabel": "ID de la règle.", + "alertsUIShared.actionVariables.ruleNameLabel": "Nom de la règle.", + "alertsUIShared.actionVariables.ruleParamsLabel": "Les paramètres de la règle.", + "alertsUIShared.actionVariables.ruleSpaceIdLabel": "ID d'espace de la règle.", + "alertsUIShared.actionVariables.ruleTagsLabel": "Balises de la règle.", + "alertsUIShared.actionVariables.ruleTypeLabel": "Type de règle.", + "alertsUIShared.actionVariables.ruleUrlLabel": "L'URL d'accès à la règle qui a généré l'alerte. La chaîne sera vide si server.publicBaseUrl n'est pas configuré.", "alertsUIShared.alertFieldsTable.field": "Champ", "alertsUIShared.alertFieldsTable.filter.placeholder": "Filtre par Champ, Valeur ou Description...", "alertsUIShared.alertFieldsTable.value": "Valeur", @@ -34,6 +144,8 @@ "alertsUIShared.alertFilterControls.defaultControlDisplayNames.rule": "Règle", "alertsUIShared.alertFilterControls.defaultControlDisplayNames.status": "Statut", "alertsUIShared.alertFilterControls.defaultControlDisplayNames.tags": "Balises", + "alertsUIShared.checkActionTypeEnabled.actionTypeDisabledByConfigMessage": "Ce connecteur est désactivé par la configuration de Kibana.", + "alertsUIShared.checkActionTypeEnabled.actionTypeDisabledByLicenseMessage": "Ce connecteur requiert une licence {minimumLicenseRequired}.", "alertsUIShared.component.alertsSearchBar.placeholder": "Alertes de recherche (par exemple, kibana.alert.evaluation.threshold > 75)", "alertsUIShared.components.addMessageVariables.addRuleVariableTitle": "Ajouter une variable", "alertsUIShared.components.addMessageVariables.addVariablePopoverButton": "Ajouter une variable", @@ -54,10 +166,11 @@ "alertsUIShared.components.ruleTypeModal.noRuleTypesErrorTitle": "Aucun type de règles trouvé", "alertsUIShared.components.ruleTypeModal.searchPlaceholder": "Recherche", "alertsUIShared.components.ruleTypeModal.title": "Sélectionner le type de règle", + "alertsUIShared.disabledActionsWarningTitle": "Cette règle possède des actions qui sont désactivées", "alertsUIShared.filterGroup.contextMenu.reset": "Réinitialiser les contrôles", "alertsUIShared.filterGroup.contextMenu.resetTooltip": "Réinitialiser les contrôles aux paramètres d'usine", "alertsUIShared.filterGroup.filtersChangedBanner": "Les contrôles de filtre ont changé", - "alertsUIShared.filterGroup.filtersChangedTitle": "Les nouveaux contrôles de filtre de cette page sont différents de ceux que vous avez précédemment enregistrés. Vous pouvez enregistrer les modifications ou les ignorer.\n Si vous quittez cette fenêtre, ces modifications seront automatiquement ignorées", + "alertsUIShared.filterGroup.filtersChangedTitle": "Les nouveaux contrôles de filtre de cette page sont différents de ceux que vous avez précédemment enregistrés. Vous pouvez enregistrer les modifications ou les ignorer. Si vous quittez cette fenêtre, ces modifications seront automatiquement ignorées", "alertsUIShared.filterGroup.groupMenuTitle": "Menu de groupe de filtres", "alertsUIShared.filtersGroup.contextMenu.addControls": "Ajouter des contrôles", "alertsUIShared.filtersGroup.contextMenu.addControls.maxLimit": "Un maximum de 4 contrôles peut être ajouté.", @@ -68,8 +181,24 @@ "alertsUIShared.filtersGroup.discardChanges": "Abandonner les modifications", "alertsUIShared.filtersGroup.pendingChanges": "Enregistrer les modifications en attente", "alertsUIShared.filtersGroup.urlParam.arrayError": "Les paramètres d'URL du filtre de page doivent être un tableau", + "alertsUIShared.healthCheck.actionText": "En savoir plus.", + "alertsUIShared.healthCheck.alertsErrorText": "Pour créer une règle, vous devez activer les plug-ins d'alerting et d'actions.", + "alertsUIShared.healthCheck.alertsErrorTitle": "Vous devez activer Alerting et Actions", + "alertsUIShared.healthCheck.apiKeysAndEncryptionErrorText": "Vous devez activer les clés d'API et configurer une clé de chiffrement pour utiliser Alerting.", + "alertsUIShared.healthCheck.apiKeysDisabledErrorText": "Vous devez activer les clés d'API pour utiliser Alerting.", + "alertsUIShared.healthCheck.apiKeysDisabledErrorTitle": "Configuration supplémentaire requise", + "alertsUIShared.healthCheck.encryptionErrorText": "Vous devez configurer une clé de chiffrement pour utiliser Alerting.", + "alertsUIShared.healthCheck.encryptionErrorTitle": "Configuration supplémentaire requise", + "alertsUIShared.healthCheck.healthCheck.apiKeysAndEncryptionErrorTitle": "Configuration supplémentaire requise", + "alertsUIShared.hooks.useAlertDataView.fetchErrorMessage": "Impossible de charger la vue des données de l'alerte", + "alertsUIShared.hooks.useFindAlertsQuery.unableToFetchAlertsGroupingAggregations": "Impossible de récupérer les agrégations de groupement d'alertes", + "alertsUIShared.hooks.useFindAlertsQuery.unableToFindAlertsQueryMessage": "Impossible de trouver les alertes", "alertsUIShared.hooks.useLoadRuleTypesQuery.unableToLoadRuleTypesMessage": "Impossible de charger les types de règles", "alertsUIShared.hooks.useRuleAADFields.errorMessage": "Impossible de charger les champs d'alerte par type de règle", + "alertsUIShared.licenseCheck.actionTypeDisabledByConfigMessageTitle": "Cette fonctionnalité est désactivée par la configuration de Kibana.", + "alertsUIShared.licenseCheck.actionTypeDisabledByLicenseLinkTitle": "Afficher les options de licence", + "alertsUIShared.licenseCheck.actionTypeDisabledByLicenseMessageDescription": "Pour réactiver cette action, veuillez mettre à niveau votre licence.", + "alertsUIShared.licenseCheck.actionTypeDisabledByLicenseMessageTitle": "Cette fonctionnalité requiert une licence {minimumLicenseRequired}.", "alertsUIShared.maintenanceWindowCallout.fetchError": "La vérification visant à déterminer si les fenêtres de maintenance sont actives a échoué", "alertsUIShared.maintenanceWindowCallout.fetchErrorDescription": "Les notifications de règle sont arrêtées lorsque les fenêtres de maintenance sont en cours d'exécution.", "alertsUIShared.maintenanceWindowCallout.maintenanceWindowActive": "{activeWindowCount, plural, one {Une fenêtre de maintenance est} other {Des fenêtres de maintenance sont}} en cours d'exécution pour des règles de {categories}", @@ -89,20 +218,74 @@ "alertsUIShared.producerDisplayNames.slo": "SLO", "alertsUIShared.producerDisplayNames.stackAlerts": "Alertes de la suite", "alertsUIShared.producerDisplayNames.uptime": "Synthetics et Uptime", - "alertsUIShared.ruleForm.error.belowMinimumAlertDelayText": "Le délai d’alerte doit être supérieur à 1.", + "alertsUIShared.ruleActionsAlertsFilter.ActionAlertsFilterQueryPlaceholder": "Filtrer les alertes à l'aide de la syntaxe KQL", + "alertsUIShared.ruleActionsAlertsFilter.ActionAlertsFilterQueryToggleLabel": "Si l'alerte correspond à une requête", + "alertsUIShared.ruleActionsItem.actionErrorToolTip": "L’action contient des erreurs.", + "alertsUIShared.ruleActionsItem.actionUnableToLoadConnectorTitle": "Créez un connecteur et réessayez. Si vous ne parvenez pas à créer un connecteur, contactez votre administrateur système.", + "alertsUIShared.ruleActionsItem.actionUseAadTemplateFieldsLabel": "Utiliser les champs de modèle de l'index des alertes", + "alertsUIShared.ruleActionsItem.actionWarningsTitle": "1 avertissement", + "alertsUIShared.ruleActionsItem.existingAlertActionTypeEditTitle": "{actionConnectorName}", + "alertsUIShared.ruleActionsItem.runWhenGroupTitle": "Exécuter lorsque {groupName}", + "alertsUIShared.ruleActionsItem.summaryGroupTitle": "Résumé des alertes", + "alertsUIShared.ruleActionsNotifyWhen.actionFrequencyLabel": "Fréquence d'action", + "alertsUIShared.ruleActionsNotifyWhen.frequencyNotifyWhen.label": "Exécuter chaque", + "alertsUIShared.ruleActionsNotifyWhen.notifyWhenThrottleWarning": "Les intervalles d'action personnalisés ne peuvent pas être plus courts que l'intervalle de vérification de la règle", + "alertsUIShared.ruleActionsNotifyWhen.onActionGroupChange.description": "Actions exécutées si le statut de l'alerte change.", + "alertsUIShared.ruleActionsNotifyWhen.onActionGroupChange.display": "Lors de changements de statut", + "alertsUIShared.ruleActionsNotifyWhen.onActionGroupChange.label": "Lors de changements de statut", + "alertsUIShared.ruleActionsNotifyWhen.onActiveAlert.description": "Les actions sont exécutées si les conditions de règle sont remplies.", + "alertsUIShared.ruleActionsNotifyWhen.onActiveAlert.display": "Selon les intervalles de vérification", + "alertsUIShared.ruleActionsNotifyWhen.onActiveAlert.label": "Selon les intervalles de vérification", + "alertsUIShared.ruleActionsNotifyWhen.onThrottleInterval.description": "Les actions sont exécutées si les conditions de règle sont remplies.", + "alertsUIShared.ruleActionsNotifyWhen.onThrottleInterval.display": "Selon des intervalles d'action personnalisés", + "alertsUIShared.ruleActionsNotifyWhen.onThrottleInterval.label": "Selon des intervalles d'action personnalisés", + "alertsUIShared.ruleActionsNotifyWhen.summaryOrRulePerSelectRoleDescription": "Sélection du type de fréquence d'action", + "alertsUIShared.ruleActionsSetting.actionGroupNotSupported": "{actionGroupName} (non pris en charge actuellement)", + "alertsUIShared.ruleActionsSetting.actionGroupRunWhen": "Exécuter quand", + "alertsUIShared.ruleActionsSystemActionsItem.deleteActionAriaLabel": "supprimer l'action", + "alertsUIShared.ruleForm.actionsForm.publicBaseUrl": "server.publicBaseUrl n'est pas défini. Les URL générées seront relatives ou vides.", + "alertsUIShared.ruleForm.actionsForm.requiredFilterQuery": "Une requête personnalisée est requise.", + "alertsUIShared.ruleForm.actionTypeModalEmptyText": "Essayez une autre recherche ou modifiez vos paramètres de filtrage.", + "alertsUIShared.ruleForm.actionTypeModalEmptyTitle": "Aucun connecteur trouvé", + "alertsUIShared.ruleForm.actionTypeModalFilterAll": "Tous", + "alertsUIShared.ruleForm.actionTypeModalTitle": "Sélectionner un connecteur", + "alertsUIShared.ruleForm.circuitBreakerHideFullErrorText": "Masquer l'erreur en intégralité", + "alertsUIShared.ruleForm.circuitBreakerSeeFullErrorText": "Afficher l'erreur en intégralité", + "alertsUIShared.ruleForm.confirmRuleSaveCancelButtonText": "Annuler", + "alertsUIShared.ruleForm.confirmRuleSaveConfirmButtonText": "Enregistrer la règle", + "alertsUIShared.ruleForm.confirmRuleSaveMessageText": "Vous pouvez ajouter une action à tout moment.", + "alertsUIShared.ruleForm.confirmRuleSaveTitle": "Enregistrer la règle ne contenant aucune action ?", + "alertsUIShared.ruleForm.createErrorText": "Impossible de créer une règle.", + "alertsUIShared.ruleForm.createSuccessText": "Création de la règle \"{ruleName}\" effectuée", + "alertsUIShared.ruleForm.editErrorText": "Impossible de mettre à jour la règle.", + "alertsUIShared.ruleForm.editSuccessText": "Mise à jour de \"{ruleName}\" effectuée", + "alertsUIShared.ruleForm.error.belowMinimumAlertDelayText": "Le délai d'alerte doit être supérieur ou égal à 1.", "alertsUIShared.ruleForm.error.belowMinimumText": "L'intervalle doit être au minimum de {minimum}.", "alertsUIShared.ruleForm.error.requiredConsumerText": "La portée est requise.", "alertsUIShared.ruleForm.error.requiredIntervalText": "L'intervalle de vérification est requis.", "alertsUIShared.ruleForm.error.requiredNameText": "Le nom est requis.", "alertsUIShared.ruleForm.error.requiredRuleTypeIdText": "Le type de règle est requis.", "alertsUIShared.ruleForm.intervalWarningText": "Des intervalles inférieurs à {minimum} ne sont pas recommandés pour des raisons de performances.", + "alertsUIShared.ruleForm.modalSearchClearFiltersText": "Effacer les filtres", + "alertsUIShared.ruleForm.modalSearchPlaceholder": "Recherche", + "alertsUIShared.ruleForm.returnTitle": "Renvoyer", + "alertsUIShared.ruleForm.routeParamsErrorText": "Une erreur s'est produite lors du chargement du formulaire de la règle. Veuillez vérifier que le chemin est correct.", + "alertsUIShared.ruleForm.routeParamsErrorTitle": "Impossible de charger le formulaire de règle", "alertsUIShared.ruleForm.ruleActions.addActionText": "Ajouter une action", + "alertsUIShared.ruleForm.ruleActionsAlertsFilterTimeframeTimezoneLabel": "Fuseau horaire", + "alertsUIShared.ruleForm.ruleActionsAlertsFilterTimeframeToggleLabel": "Si l'alerte est générée pendant l'intervalle de temps", + "alertsUIShared.ruleForm.ruleActionsAlertsFilterTimeframeWeekdays": "Jours de la semaine", + "alertsUIShared.ruleForm.ruleActionsNoPermissionDescription": "Pour modifier les règles, vous devez avoir un accès en lecture aux actions et aux connecteurs.", + "alertsUIShared.ruleForm.ruleActionsNoPermissionTitle": "Privilèges manquants pour les actions et les connecteurs", + "alertsUIShared.ruleForm.ruleActionsTitle": "Actions", "alertsUIShared.ruleForm.ruleAlertDelay.alertDelayHelpText": "Une alerte n'est déclenchée que si le nombre spécifié d'exécutions consécutives remplit les conditions de la règle.", "alertsUIShared.ruleForm.ruleAlertDelay.alertDelayTitlePrefix": "Après une alerte", "alertsUIShared.ruleForm.ruleAlertDelay.alertDelayTitleSuffix": "correspondances consécutives", "alertsUIShared.ruleForm.ruleDefinition.advancedOptionsTitle": "Options avancées", "alertsUIShared.ruleForm.ruleDefinition.alertDelayDescription": "Définir le nombre d’exécutions consécutives pour lesquelles cette règle doit répondre aux conditions d'alerte avant qu'une alerte ne se déclenche", "alertsUIShared.ruleForm.ruleDefinition.alertDelayTitle": "Délai d'alerte", + "alertsUIShared.ruleForm.ruleDefinition.alertFlappingDetectionDescription": "Détectez les alertes qui passent rapidement de l'état actif à l'état récupéré et réduisez le bruit non souhaité de ces alertes instables", + "alertsUIShared.ruleForm.ruleDefinition.alertFlappingDetectionTitle": "Détection de bagotement d'alerte", "alertsUIShared.ruleForm.ruleDefinition.docLinkTitle": "Afficher la documentation", "alertsUIShared.ruleForm.ruleDefinition.loadingRuleTypeParamsTitle": "Chargement des paramètres de types de règles", "alertsUIShared.ruleForm.ruleDefinition.scheduleDescriptionText": "Définir la fréquence de vérification des conditions de l'alerte", @@ -110,10 +293,18 @@ "alertsUIShared.ruleForm.ruleDefinition.scheduleTooltipText": "Les vérifications sont mises en file d'attente ; elles seront exécutées au plus près de la valeur définie, en fonction de la capacité.", "alertsUIShared.ruleForm.ruleDefinition.scopeDescriptionText": "Sélectionnez les applications auxquelles associer le privilège de rôle correspondant", "alertsUIShared.ruleForm.ruleDefinition.scopeTitle": "Portée de la règle", + "alertsUIShared.ruleForm.ruleDefinitionTitle": "Définition de la règle", "alertsUIShared.ruleForm.ruleDetails.description": "Définissez un nom et des balises pour votre règle.", + "alertsUIShared.ruleForm.ruleDetails.ruleNameInputButtonAriaLabel": "Enregistrer le nom de la règle", "alertsUIShared.ruleForm.ruleDetails.ruleNameInputTitle": "Nom de règle", "alertsUIShared.ruleForm.ruleDetails.ruleTagsInputTitle": "Balises", + "alertsUIShared.ruleForm.ruleDetails.ruleTagsPlaceholder": "Ajouter des balises", "alertsUIShared.ruleForm.ruleDetails.title": "Nom et balises de la règle", + "alertsUIShared.ruleForm.ruleDetailsTitle": "Détails de la règle", + "alertsUIShared.ruleForm.ruleFormCancelModalCancel": "Annuler", + "alertsUIShared.ruleForm.ruleFormCancelModalConfirm": "Abandonner les modifications", + "alertsUIShared.ruleForm.ruleFormCancelModalDescription": "Vous ne pouvez pas récupérer de modifications non enregistrées.", + "alertsUIShared.ruleForm.ruleFormCancelModalTitle": "Abandonner les modifications non enregistrées apportées à la règle ?", "alertsUIShared.ruleForm.ruleFormConsumerSelection.apm": "APM et expérience utilisateur", "alertsUIShared.ruleForm.ruleFormConsumerSelection.consumerSelectComboBoxTitle": "Sélectionner une portée", "alertsUIShared.ruleForm.ruleFormConsumerSelection.consumerSelectTitle": "Visibilité du rôle", @@ -122,7 +313,53 @@ "alertsUIShared.ruleForm.ruleFormConsumerSelection.slo": "SLO", "alertsUIShared.ruleForm.ruleFormConsumerSelection.stackAlerts": "Règles de la Suite Elastic", "alertsUIShared.ruleForm.ruleFormConsumerSelection.uptime": "Synthetics et Uptime", + "alertsUIShared.ruleForm.ruleNotFoundErrorText": "Une erreur s'est produite lors du chargement de la règle. Veuillez vous assurer que la règle existe et que vous y avez accès.", + "alertsUIShared.ruleForm.ruleNotFoundErrorTitle": "Impossible de charger la règle", + "alertsUIShared.ruleForm.rulePage.ruleNameAriaLabelText": "Modifier le nom de la règle", + "alertsUIShared.ruleForm.rulePageFooter.cancelText": "Annuler", + "alertsUIShared.ruleForm.rulePageFooter.createText": "Créer une règle", + "alertsUIShared.ruleForm.rulePageFooter.saveText": "Enregistrer la règle", + "alertsUIShared.ruleForm.rulePageFooter.showRequestText": "Afficher la requête", "alertsUIShared.ruleForm.ruleSchedule.scheduleTitlePrefix": "Chaque", + "alertsUIShared.ruleForm.ruleTypeNotFoundErrorText": "Une erreur s'est produite lors du chargement du type de règle. Veuillez vous assurer que vous avez accès au type de règle sélectionné.", + "alertsUIShared.ruleForm.ruleTypeNotFoundErrorTitle": "Impossible de charger le type de règle", + "alertsUIShared.ruleForm.showRequestModal.headerTitle": "{requestType} requête de règle d'alerte", + "alertsUIShared.ruleForm.showRequestModal.headerTitleCreate": "Créer", + "alertsUIShared.ruleForm.showRequestModal.headerTitleEdit": "Modifier", + "alertsUIShared.ruleForm.showRequestModal.somethingWentWrongDescription": "Désolé, un problème est survenu.", + "alertsUIShared.ruleForm.showRequestModal.subheadingTitle": "La requête Kibana va {requestType} cette règle.", + "alertsUIShared.ruleForm.showRequestModal.subheadingTitleCreate": "créer", + "alertsUIShared.ruleForm.showRequestModal.subheadingTitleEdit": "modifier", + "alertsUIShared.ruleSettingsFlappingForm.flappingExternalLinkLabel": "Qu'est-ce que c'est ?", + "alertsUIShared.ruleSettingsFlappingForm.flappingLabel": "Détection des éléments instables", + "alertsUIShared.ruleSettingsFlappingForm.flappingOffContentRules": "Règles", + "alertsUIShared.ruleSettingsFlappingForm.flappingOffContentSettings": "Paramètres", + "alertsUIShared.ruleSettingsFlappingForm.flappingOffPopoverContent": "Accédez à {rules} > {settings} pour activer la détection d'instabilité pour l'ensemble des règles d'un espace. Vous pouvez ensuite personnaliser la période d'analyse et les valeurs seuils de chaque règle.", + "alertsUIShared.ruleSettingsFlappingForm.flappingOverrideConfiguration": "Personnaliser la configuration", + "alertsUIShared.ruleSettingsFlappingForm.offLabel": "DÉSACTIVÉ", + "alertsUIShared.ruleSettingsFlappingForm.onLabel": "ACTIVÉ", + "alertsUIShared.ruleSettingsFlappingForm.overrideLabel": "Personnalisé", + "alertsUIShared.ruleSettingsFlappingInputsProps.lookBackWindowHelp": "Nombre minimal d'exécutions pour lesquelles le seuil doit être atteint.", + "alertsUIShared.ruleSettingsFlappingInputsProps.lookBackWindowLabel": "Fenêtre d'historique d'exécution de la règle", + "alertsUIShared.ruleSettingsFlappingInputsProps.statusChangeThresholdHelp": "Nombre minimal de fois où une alerte doit changer d'état dans la fenêtre d'historique.", + "alertsUIShared.ruleSettingsFlappingInputsProps.statusChangeThresholdLabel": "Seuil de modification du statut d'alerte", + "alertsUIShared.ruleSettingsFlappingMessage.flappingOffMessage": "La détection de bagotement d'alerte est désactivée. Les alertes seront générées en fonction de l'intervalle de la règle, ce qui peut entraîner des volumes d'alertes plus importants.", + "alertsUIShared.ruleSettingsFlappingMessage.flappingSettingsDescription": "Cette règle détecte qu'une alerte est instable si son statut change au moins {statusChangeThreshold} au cours des derniers/dernières {lookBackWindow}.", + "alertsUIShared.ruleSettingsFlappingMessage.lookBackWindowLabelRuleRuns": "{amount, number} règle {amount, plural, one {exécute} other {exécutent}}", + "alertsUIShared.ruleSettingsFlappingMessage.spaceFlappingSettingsDescription": "L'ensemble des règles (de cet espace) détecte qu'une alerte est instable lorsque son statut change au moins {statusChangeThreshold} au cours des derniers/dernières {lookBackWindow}.", + "alertsUIShared.ruleSettingsFlappingMessage.statusChangeThresholdTimes": "{amount, number} {amount, plural, other {fois}}", + "alertsUIShared.ruleSettingsFlappingTitleTooltip.flappingOffContentRules": "Règles", + "alertsUIShared.ruleSettingsFlappingTitleTooltip.flappingOffContentSettings": "Paramètres", + "alertsUIShared.ruleSettingsFlappingTitleTooltip.flappingTitlePopover1": "Lorsque la {flappingDetection} est activée, les alertes qui passent rapidement de l'état actif à l'état récupéré sont identifiées comme \"instables\" et les notifications sont réduites.", + "alertsUIShared.ruleSettingsFlappingTitleTooltip.flappingTitlePopover2": "Le statut {alertStatus} définit une période (nombre minimum d'exécutions) utilisée dans l'algorithme de détection.", + "alertsUIShared.ruleSettingsFlappingTitleTooltip.flappingTitlePopover3": "Le paramètre {lookBack} indique le nombre minimum de fois que les alertes doivent changer d'état au cours de la période seuil pour être considérées comme des alertes instables.", + "alertsUIShared.ruleSettingsFlappingTitleTooltip.flappingTitlePopover4": "Accédez à {rules} > {settings} pour activer la détection d'instabilité pour l'ensemble des règles d'un espace. Vous pouvez ensuite personnaliser la période d'analyse et les valeurs seuils de chaque règle.", + "alertsUIShared.ruleSettingsFlappingTitleTooltip.flappingTitlePopoverAlertStatus": "seuil de modification du statut d'alerte", + "alertsUIShared.ruleSettingsFlappingTitleTooltip.flappingTitlePopoverFlappingDetection": "détection des éléments instables", + "alertsUIShared.ruleSettingsFlappingTitleTooltip.flappingTitlePopoverLookBack": "fenêtre d'historique d'exécution de la règle", + "alertsUIShared.ruleSettingsFlappingTitleTooltip.tooltipTitle": "Détection de bagotement d'alerte", + "alertsUIShared.technicalPreviewBadgeDescription": "Cette fonctionnalité est en version d'évaluation technique et pourra être modifiée ou retirée complètement dans une future version. Elastic s'efforcera de corriger tout problème, mais les fonctionnalités des versions d'évaluation technique ne sont pas soumises aux SLA de support des fonctionnalités officielles en disponibilité générale.", + "alertsUIShared.technicalPreviewBadgeLabel": "Version d'évaluation technique", "alertsUIShared.timeUnits.dayLabel": "{timeValue, plural, one {jour} other {jours}}", "alertsUIShared.timeUnits.hourLabel": "{timeValue, plural, one {heure} other {heures}}", "alertsUIShared.timeUnits.minuteLabel": "{timeValue, plural, one {minute} other {minutes}}", @@ -143,6 +380,11 @@ "autocomplete.seeDocumentation": "Consultez la documentation", "autocomplete.selectField": "Veuillez d'abord sélectionner un champ...", "autocomplete.showValueListModal": "Afficher la liste de valeurs", + "avcBanner.body": "Elastic Security passe avec brio le test de protection contre les malwares réalisé par AV-Comparatives", + "avcBanner.readTheBlog.link": "Lire le blog", + "avcBanner.title": "Protection à 100 % sans aucun faux positif.", + "bfetch.advancedSettings.disableBfetchCompressionDeprecation": "Ce paramètre est déclassé et sera supprimé dans la version 9.0 de Kibana.", + "bfetch.advancedSettings.disableBfetchDeprecation": "Ce paramètre est déclassé et sera supprimé dans la version 9.0 de Kibana.", "bfetch.disableBfetch": "Désactiver la mise en lots de requêtes", "bfetch.disableBfetchCompression": "Désactiver la compression par lots", "bfetch.disableBfetchCompressionDesc": "Vous pouvez désactiver la compression par lots. Cela permet de déboguer des requêtes individuelles, mais augmente la taille des réponses.", @@ -246,7 +488,7 @@ "cloud.deploymentDetails.modal.closeButtonLabel": "Fermer", "cloud.deploymentDetails.modal.learnMoreButtonLabel": "En savoir plus", "coloring.colorMapping.assignments.autoAssignedTermAriaLabel": "Cette couleur sera automatiquement affectée au premier terme qui ne correspond pas à toutes les autres affectations", - "coloring.colorMapping.assignments.autoAssignedTermPlaceholder": "Affecté automatiquement", + "coloring.colorMapping.assignments.autoAssignedTermPlaceholder": "Terme d'affectation automatique", "coloring.colorMapping.assignments.deleteAssignmentButtonLabel": "Supprimer cette affectation", "coloring.colorMapping.assignments.duplicateCategoryWarning": "Une autre couleur a déjà été affectée à cette catégorie. Seule la première affectation correspondante sera utilisée.", "coloring.colorMapping.colorChangesModal.categoricalModeDescription": "Basculer en mode de catégorie conduira à l'abandon de toutes vos modifications de couleurs personnalisées", @@ -277,7 +519,7 @@ "coloring.colorMapping.container.mapCurrentValuesButtonLabel": "Ajouter tous les termes non affectés", "coloring.colorMapping.container.mappingAssignmentHeader": "Affectations de couleurs", "coloring.colorMapping.container.mapValueButtonLabel": "Ajouter tous les termes non affectés", - "coloring.colorMapping.container.mapValuesPromptDescription.mapValuesPromptDetail": "Ajoutez de nouvelles affectations pour commencer à associer des termes de vos données à des couleurs spécifiques.", + "coloring.colorMapping.container.mapValuesPromptDescription.mapValuesPromptDetail": "Ajoutez une nouvelle affectation pour associer manuellement des termes à des couleurs spécifiées.", "coloring.colorMapping.container.OpenAdditionalActionsButtonLabel": "Ouvrir les actions d'affectation supplémentaires", "coloring.colorMapping.container.unassignedTermsMode.ReuseColorsLabel": "Palette de couleurs", "coloring.colorMapping.container.unassignedTermsMode.ReuseGradientLabel": "Gradient", @@ -324,10 +566,14 @@ "console.autocompleteSuggestions.endpointLabel": "point de terminaison", "console.autocompleteSuggestions.methodLabel": "méthode", "console.autocompleteSuggestions.paramLabel": "param", + "console.closeFullscreenButton": "Fermer l'affichage pleine page", "console.consoleDisplayName": "Console", "console.consoleMenu.copyAsCurlFailedMessage": "Impossible de copier la requête en tant que cURL", "console.consoleMenu.copyAsCurlMessage": "Requête copiée en tant que cURL", - "console.deprecations.enabled.manualStepOneMessage": "Ouvrez le fichier de configuration kibana.yml.", + "console.consoleMenu.copyAsFailedMessage": "{requestsCount, plural, one {Une requête n'a pas pu être copiée} other {Plusieurs requêtes n'ont pas pu être copiées}} dans le presse-papiers", + "console.consoleMenu.copyAsSuccessMessage": "{requestsCount, plural, one {Une requête copiée} other {Plusieurs requêtes copiées}} dans le presse-papiers en tant que {language}", + "console.consoleMenu.missingDocumentationPage": "La page de documentation n'est pas encore disponible pour cette API.", + "console.deprecations.enabled.manualStepOneMessage": "Ouvrir le fichier de configuration kibana.yml.", "console.deprecations.enabled.manualStepTwoMessage": "Remplacez le paramètre \"console.enabled\" par \"console.ui.enabled\".", "console.deprecations.enabledMessage": "Pour empêcher les utilisateurs d'accéder à l'interface utilisateur de la console, utilisez le paramètre \"console.ui.enabled\" au lieu de \"console.enabled\".", "console.deprecations.enabledTitle": "Le paramètre \"console.enabled\" est déclassé", @@ -343,12 +589,39 @@ "console.deprecations.proxyFilterTitle": "Le paramètre \"console.proxyFilter\" est déclassé", "console.devToolsDescription": "Plutôt que l’interface cURL, utilisez une interface JSON pour exploiter vos données dans la console.", "console.devToolsTitle": "Interagir avec l'API Elasticsearch", + "console.editor.adjustPanelSizeAriaLabel": "Utilisez les flèches gauche et droite pour ajuster la taille des panneaux", + "console.editor.clearConsoleInputButton": "Effacer cette entrée", + "console.editor.clearConsoleOutputButton": "Effacer cette sortie", "console.embeddableConsole.customScreenReaderAnnouncement": "Il y a un nouveau repère de région nommé {landmarkHeading} avec des commandes de niveau de page à la fin du document.", - "console.embeddableConsole.landmarkHeading": "Console développeur", + "console.embeddableConsole.landmarkHeading": "Console développeur. Appuyez sur Entrée pour modifier. Une fois terminé, appuyez sur Échap pour arrêter la modification.", "console.embeddableConsole.title": "Console", - "console.historyPage.applyHistoryButtonLabel": "Appliquer", - "console.historyPage.clearHistoryButtonLabel": "Effacer", + "console.exportButton": "Exporter les requêtes", + "console.exportButtonTooltipLabel": "Exporter toutes les requêtes de la console vers un fichier TXT", + "console.helpButtonTooltipContent": "Aide", + "console.helpPopover.aboutConsoleButtonAriaLabel": "À propos du lien de la console", + "console.helpPopover.aboutConsoleLabel": "À propos de la console", + "console.helpPopover.aboutQueryDSLButtonAriaLabel": "À propos du lien QueryDSL", + "console.helpPopover.aboutQueryDSLLabel": "À propos de Query DSL", + "console.helpPopover.description": "La console est une interface utilisateur interactive qui vous permet d'appeler les API Elasticsearch et Kibana et d'afficher leurs réponses. Avec la syntaxe Query DSL et REST API, recherchez vos données, gérez les paramètres et bien plus encore.", + "console.helpPopover.rerunTourButtonAriaLabel": "Bouton de réexécution de la présentation des fonctionnalités", + "console.helpPopover.rerunTourLabel": "Réexécution de la présentation des fonctionnalités", + "console.helpPopover.title": "Console Elastic", + "console.historyPage.addAndRunButtonLabel": "Ajouter et exécuter", + "console.historyPage.applyHistoryButtonLabel": "Ajouter", + "console.historyPage.clearHistoryButtonLabel": "Effacer l'ensemble de l'historique", + "console.historyPage.emptyPromptBody": "Ce panneau d'historique affichera toutes les requêtes passées que vous avez exécutées, cela vous permettra de les examiner et de les réutiliser.", + "console.historyPage.emptyPromptFooterLabel": "Envie d'en savoir plus ?", + "console.historyPage.emptyPromptFooterLink": "Lire la documentation de la console", + "console.historyPage.emptyPromptTitle": "Aucune requête pour le moment", + "console.historyPage.monaco.noHistoryTextMessage": "# Aucun historique à afficher", + "console.historyPage.pageDescription": "Revoyez et réutilisez vos requêtes antérieures", "console.historyPage.pageTitle": "Historique", + "console.importButtonLabel": "Importer les requêtes", + "console.importButtonTooltipLabel": "Importer des requêtes dans l'éditeur depuis un fichier", + "console.importConfirmModal.body": "L'importation de ce fichier remplacera toutes les requêtes actuelles dans l'éditeur.", + "console.importConfirmModal.cancelButton": "Annuler", + "console.importConfirmModal.confirmButton": "Importer et remplacer", + "console.importConfirmModal.title": "Importer et remplacer les requêtes ?", "console.keyboardCommandActionLabel.autoIndent": "Appliquer les indentations", "console.keyboardCommandActionLabel.moveToLine": "Déplacer le curseur sur une ligne", "console.keyboardCommandActionLabel.moveToNextRequestEdge": "Accéder au début ou à la fin de la requête suivante", @@ -358,34 +631,130 @@ "console.loadingError.buttonLabel": "Recharger la console", "console.loadingError.message": "Essayez de recharger pour obtenir les données les plus récentes.", "console.loadingError.title": "Impossible de charger la console", + "console.monaco.loadFromDataUnrecognizedUrlErrorMessage": "Seules les URL avec le domaine Elastic (www.elastic.co) peuvent être chargées dans la console.", + "console.monaco.loadFromDataUriErrorMessage": "Impossible de charger les données du paramètre de requête load_from dans l'URL", + "console.monaco.outputTextarea": "Outils de développement de la console - Sortie", + "console.monaco.requestOptions.autoIndentButtonLabel": "Retrait automatique", + "console.monaco.requestOptions.copyAsUrlButtonLabel": "Copier en tant que", + "console.monaco.requestOptions.openDocumentationButtonLabel": "Ouvrir la référence d'API", + "console.monaco.sendRequestButtonTooltipAriaLabel": "Cliquer pour envoyer la requête", + "console.monaco.sendRequestButtonTooltipContent": "Cliquer pour envoyer la requête", "console.notification.clearHistory": "Effacer l'historique", "console.notification.disableSavingToHistory": "Désactiver l'enregistrement", + "console.notification.error.failedToReadFile": "Impossible de lire le fichier sélectionné.", + "console.notification.error.fileImportNoContent": "Le fichier sélectionné ne semble pas avoir de contenu. Sélectionnez un autre fichier.", + "console.notification.error.fileTooBigMessage": "La taille du fichier dépasse la limite de 2 Mo.", + "console.notification.fileImportedSuccessfully": "Le fichier sélectionné a été importé avec succès.", + "console.notification.monaco.error.couldNotSaveRequestTitle": "Impossible d'enregistrer la requête dans l'historique de la console.", + "console.notification.monaco.error.historyQuotaReachedMessage": "L'historique des requêtes est arrivé à saturation. Effacez l'historique de la console ou désactivez l'enregistrement de nouvelles requêtes.", + "console.notification.monaco.error.nonSupportedRequest": "La requête sélectionnée n'est pas valide.", + "console.notification.monaco.error.noRequestSelectedTitle": "Aucune requête sélectionnée. Sélectionnez une requête en positionnant le curseur dessus.", + "console.notification.monaco.error.unknownErrorTitle": "Erreur de requête inconnue", + "console.openFullscreenButton": "Ouvrir cette console en affichage pleine page", + "console.outputEmptyState.description": "Lorsque vous exécutez une requête dans le panneau de saisie, la réponse de sortie s'affichera ici.", + "console.outputEmptyState.docsLink": "Lire la documentation de la Console", + "console.outputEmptyState.learnMore": "Envie d'en savoir plus ?", + "console.outputEmptyState.title": "Entrer une nouvelle requête", + "console.outputPanel.copyOutputButtonTooltipAriaLabel": "Cliquez pour copier dans le presse-papier", + "console.outputPanel.copyOutputButtonTooltipContent": "Cliquez pour copier dans le presse-papier", + "console.outputPanel.copyOutputToast": "Sortie sélectionnée copiée dans le presse-papiers", + "console.outputPanel.copyOutputToastFailedMessage": "Impossible de copier la sortie sélectionnée dans le presse-papiers", "console.pageHeading": "Console", "console.requestInProgressBadgeText": "Requête en cours", "console.requestOptions.autoIndentButtonLabel": "Appliquer les indentations", "console.requestOptions.copyAsUrlButtonLabel": "Copier la commande cURL", "console.requestOptions.openDocumentationButtonLabel": "Afficher la documentation", "console.requestOptionsButtonAriaLabel": "Options de requête", + "console.requestPanel.contextMenu.defaultSelectedLanguage": "Définir par défaut", + "console.requestPanel.contextMenu.languageSelectorModalCancel": "Annuler", + "console.requestPanel.contextMenu.languageSelectorModalCopy": "Copier le code", + "console.requestPanel.contextMenu.languageSelectorModalTitle": "Sélectionner une langue", "console.requestTimeElapasedBadgeTooltipContent": "Temps écoulé", + "console.settingsPage.autocompleteRefreshSettingsDescription": "La console actualise les suggestions de saisie semi-automatique en interrogeant Elasticsearch. Utilisez des actualisations moins fréquentes pour réduire les coûts de bande passante.", + "console.settingsPage.autocompleteRefreshSettingsLabel": "Actualisation de la saisie semi-automatique", + "console.settingsPage.autocompleteSettingsLabel": "Saisie semi-automatique", "console.settingsPage.dataStreamsLabelText": "Flux de données", - "console.settingsPage.enableAccessibilityOverlayLabel": "Activer la superposition d’accessibilité", - "console.settingsPage.enableKeyboardShortcutsLabel": "Activer les raccourcis clavier", + "console.settingsPage.displaySettingsLabel": "Affichage", + "console.settingsPage.enableAccessibilityOverlayLabel": "Superposition d’accessibilité", + "console.settingsPage.enableKeyboardShortcutsLabel": "Raccourcis clavier", "console.settingsPage.fieldsLabelText": "Champs", "console.settingsPage.fontSizeLabel": "Taille de la police", + "console.settingsPage.generalSettingsLabel": "Paramètres généraux", "console.settingsPage.indicesAndAliasesLabelText": "Index et alias", + "console.settingsPage.manualRefreshLabel": "Actualiser manuellement les suggestions de saisie semi-automatique", + "console.settingsPage.offLabel": "Désactivé", + "console.settingsPage.onLabel": "Activé", + "console.settingsPage.pageDescription": "Personnalisez la console en fonction de votre workflow.", "console.settingsPage.pageTitle": "Paramètres de la console", - "console.settingsPage.refreshButtonLabel": "Actualiser les suggestions de saisie semi-automatique", + "console.settingsPage.refreshButtonLabel": "Actualiser", "console.settingsPage.refreshingDataLabel": "Fréquence d'actualisation", "console.settingsPage.refreshInterval.everyHourTimeInterval": "Toutes les heures", "console.settingsPage.refreshInterval.everyNMinutesTimeInterval": "Toutes les {value} {value, plural, one {minute} other {minutes}}", "console.settingsPage.refreshInterval.onceTimeInterval": "Une fois, au chargement de la console", "console.settingsPage.saveRequestsToHistoryLabel": "Enregistrer les requêtes dans l'historique", "console.settingsPage.templatesLabelText": "Modèles", - "console.settingsPage.tripleQuotesMessage": "Utiliser des guillemets triples dans la sortie", + "console.settingsPage.tripleQuotesMessage": "Guillemets triples dans la sortie", + "console.settingsPage.wrapLongLinesLabel": "Formater les longues lignes", + "console.shortcutKeys.keyAltOption": "Alt/Option", + "console.shortcutKeys.keyCtrlCmd": "Ctrl/Cmd", + "console.shortcutKeys.keyDownArrow": "Flèche vers le bas", + "console.shortcutKeys.keyEnter": "Entrée", + "console.shortcutKeys.keyEsc": "Échap", + "console.shortcutKeys.keyI": "I", + "console.shortcutKeys.keyL": "L", + "console.shortcutKeys.keyO": "O", + "console.shortcutKeys.keyOption": "Option", + "console.shortcutKeys.keyShift": "Déplacer", + "console.shortcutKeys.keySlash": "/", + "console.shortcutKeys.keySpace": "Espace", + "console.shortcutKeys.keyTab": "Onglet", + "console.shortcutKeys.keyUpArrow": "Flèche vers le haut", + "console.shortcuts.alternativeKeysOrDivider": "ou", + "console.shortcuts.autocompleteShortcutsSubtitle": "Raccourcis du menu de saisie semi-automatique", + "console.shortcuts.navigationShortcutsSubtitle": "Raccourcis de navigation", + "console.shortcuts.requestShortcutsSubtitle": "Demander des raccourcis", + "console.shortcutsButtonAriaLabel": "Raccourcis clavier", + "console.topNav.configTabDescription": "Config", + "console.topNav.configTabLabel": "Config", "console.topNav.historyTabDescription": "Historique", "console.topNav.historyTabLabel": "Historique", - "console.variablesPage.addButtonLabel": "Ajouter", + "console.topNav.shellTabDescription": "Shell", + "console.topNav.shellTabLabel": "Shell", + "console.tour.completeTourButton": "Terminé", + "console.tour.configStepContent": "Ajustez les paramètres de votre console et créez des variables pour personnaliser votre workflow.", + "console.tour.configStepTitle": "Personnaliser votre boîte à outils", + "console.tour.editorStepContent": "Saisissez une requête dans ce volet d'entrée et consultez la réponse dans le volet de sortie adjacent. Pour en savoir plus, consultez la {queryDslDocs}.", + "console.tour.editorStepTitle": "Lancez-vous dans les requêtes", + "console.tour.filesStepContent": "Exportez facilement vos requêtes de console vers un fichier ou importez celles que vous avez enregistrées précédemment.", + "console.tour.filesStepTitle": "Gérer les fichiers de la console", + "console.tour.historyStepContent": "Le panneau d'historique conserve une trace de vos requêtes antérieures, ce qui facilite leur révision et leur réexécution.", + "console.tour.historyStepTitle": "Consulter les requêtes antérieures", + "console.tour.nextStepButton": "Suivant", + "console.tour.shellStepContent": "La console est une interface utilisateur interactive qui vous permet d'appeler les API Elasticsearch et Kibana et d'afficher leurs réponses. Utilisez la syntaxe Query DSL pour rechercher vos données, gérer les paramètres et bien plus encore.", + "console.tour.shellStepTitle": "Bienvenue dans la Console", + "console.tour.skipTourButton": "Ignorer la visite", + "console.variablesButton": "Variables", + "console.variablesPage.addButtonLabel": "Ajouter une variable", + "console.variablesPage.addNew.cancelButton": "Annuler", + "console.variablesPage.addNew.submitButton": "Enregistrer les modifications", + "console.variablesPage.addNewVariableTitle": "Ajouter une nouvelle variable", + "console.variablesPage.deleteModal.cancelButtonText": "Annuler", + "console.variablesPage.deleteModal.confirmButtonText": "Supprimer la variable", + "console.variablesPage.deleteModal.description": "La suppression d'une variable est une opération irréversible.", + "console.variablesPage.deleteModal.title": "Voulez-vous vraiment continuer ?", + "console.variablesPage.editVariableForm.title": "Modifier la variable", + "console.variablesPage.form.namePlaceholderLabel": "exampleName", + "console.variablesPage.form.valueFieldLabel": "Valeur", + "console.variablesPage.form.valuePlaceholderLabel": "exampleValue", + "console.variablesPage.form.valueRequiredLabel": "La valeur est requise", + "console.variablesPage.form.variableNameFieldLabel": "Nom de la variable", + "console.variablesPage.form.variableNameInvalidLabel": "Seuls les lettres, les chiffres et les traits de soulignement sont autorisés", + "console.variablesPage.form.variableNameRequiredLabel": "Ceci est un champ requis", + "console.variablesPage.pageDescription": "Définissez des paramètres fictifs réutilisables pour les valeurs dynamiques dans vos requêtes.", "console.variablesPage.pageTitle": "Variables", + "console.variablesPage.table.noItemsMessage": "Aucune variable n'a encore été ajoutée", + "console.variablesPage.variablesTable.columns.deleteButton": "Supprimer {variable}", + "console.variablesPage.variablesTable.columns.editButton": "Modifier {variable}", "console.variablesPage.variablesTable.columns.valueHeader": "Valeur", "console.variablesPage.variablesTable.columns.variableHeader": "Nom de la variable", "contentManagement.contentEditor.activity.createdByLabelText": "Créé par", @@ -402,8 +771,25 @@ "contentManagement.contentEditor.metadataForm.readOnlyToolTip": "Veuillez contacter votre administrateur pour modifier ces détails", "contentManagement.contentEditor.metadataForm.tagsLabel": "Balises", "contentManagement.contentEditor.saveButtonLabel": "Mettre à jour {entityName}", + "contentManagement.contentEditor.viewsStats.noViewsTip": "Les vues sont comptées chaque fois qu'un utilisateur ouvre un tableau de bord", + "contentManagement.contentEditor.viewsStats.noViewsTipAriaLabel": "Informations supplémentaires", + "contentManagement.contentEditor.viewsStats.viewsLabel": "Vues", + "contentManagement.contentEditor.viewsStats.viewsLastNDaysLabel": "Vues ({n} derniers jours)", + "contentManagement.contentEditor.viewsStats.weekOfLabel": "Semaine du {date}", + "contentManagement.favorites.addFavoriteError": "Erreur lors de l'ajout aux Éléments avec étoiles", + "contentManagement.favorites.defaultEntityName": "élément", + "contentManagement.favorites.defaultEntityNamePlural": "éléments", + "contentManagement.favorites.favoriteButtonLabel": "Ajouter aux Éléments avec étoiles", + "contentManagement.favorites.noFavoritesIllustrationAlt": "Aucune illustration d'élément avec étoiles", + "contentManagement.favorites.noFavoritesMessageBody": "Suivez vos {entityNamePlural} les plus importants en les ajoutant à votre liste **En vedette**. Cliquez sur l'**icône étoile** **{starIcon}** située à côté d'un nom {entityName} afin qu'il s'affiche dans cet onglet.", + "contentManagement.favorites.noFavoritesMessageHeading": "Vous n'avez ajouté d'étoile à aucun {entityNamePlural}", + "contentManagement.favorites.noMatchingFavoritesMessageHeading": "Aucun {entityNamePlural} comportant une étoile ne correspond à votre recherche", + "contentManagement.favorites.removeFavoriteError": "Erreur lors de la suppression des Éléments avec étoiles", + "contentManagement.favorites.unfavoriteButtonLabel": "Supprimer des Éléments avec étoiles", "contentManagement.inspector.metadataForm.unableToSaveDangerMessage": "Impossible d'enregistrer {entityName}", "contentManagement.tableList.actionsDisabledLabel": "Actions désactivées pour cet élément", + "contentManagement.tableList.contentEditor.activityLabel": "Activité", + "contentManagement.tableList.contentEditor.activityLabelHelpText": "Les données liées à l'activité sont générées automatiquement et ne peuvent pas être mises à jour.", "contentManagement.tableList.createdByColumnTitle": "Créateur", "contentManagement.tableList.lastUpdatedColumnTitle": "Dernière mise à jour", "contentManagement.tableList.listing.createNewItemButtonLabel": "Créer {entityName}", @@ -428,6 +814,10 @@ "contentManagement.tableList.listing.tableSortSelect.headerLabel": "Trier par", "contentManagement.tableList.listing.tableSortSelect.nameAscLabel": "Nom A-Z", "contentManagement.tableList.listing.tableSortSelect.nameDescLabel": "Nom Z-A", + "contentManagement.tableList.listing.tableSortSelect.recentlyAccessedLabel": "Récemment consulté", + "contentManagement.tableList.listing.tableSortSelect.recentlyAccessedTip": "Les informations récemment consultées sont stockées localement dans votre navigateur et ne sont visibles que par vous.", + "contentManagement.tableList.listing.tableSortSelect.recentlyAccessedTipAriaLabel": "Informations supplémentaires", + "contentManagement.tableList.listing.tableSortSelect.sortingOptionsAriaLabel": "Options de tri", "contentManagement.tableList.listing.tableSortSelect.updatedAtAscLabel": "Mise à jour la moins récente", "contentManagement.tableList.listing.tableSortSelect.updatedAtDescLabel": "Mise à jour récente", "contentManagement.tableList.listing.unableToDeleteDangerMessage": "Impossible de supprimer la/le/les {entityName}(s)", @@ -438,6 +828,8 @@ "contentManagement.tableList.listing.userFilter.noCreators": "Aucun créateur", "contentManagement.tableList.mainColumnName": "Nom, description, balises", "contentManagement.tableList.managedItemNoEdit": "Elastic gère cet objet. Clonez-le pour effectuer des modifications.", + "contentManagement.tableList.tabsFilter.allTabLabel": "Tous", + "contentManagement.tableList.tabsFilter.favoriteTabLabel": "Éléments avec étoiles", "contentManagement.tableList.tagBadge.buttonLabel": "Bouton de balise {tagName}.", "contentManagement.tableList.tagFilterPanel.clearSelectionButtonLabelLabel": "Effacer la sélection", "contentManagement.tableList.tagFilterPanel.doneButtonLabel": "Terminé", @@ -447,12 +839,17 @@ "contentManagement.userProfiles.managedAvatarTip.avatarLabel": "Géré", "contentManagement.userProfiles.managedAvatarTip.avatarTooltip": "Cette entité {entityName} est créée et gérée par Elastic. Clonez-le pour effectuer des modifications.", "contentManagement.userProfiles.managedAvatarTip.defaultEntityName": "objet", - "contentManagement.userProfiles.noCreatorTip": "Les créateurs sont assignés lors de la création des objets (version 8.14 et versions ultérieures)", - "contentManagement.userProfiles.noUpdaterTip": "Le champ Mis à jour par est défini lors de la mise à jour des objets (version 8.14 et versions ultérieures)", + "contentManagement.userProfiles.noCreatorTip": "Les créateurs sont assignés lors de la création des objets", + "contentManagement.userProfiles.noUpdaterTip": "Le champ Mis à jour par est défini lors de la mise à jour des objets", + "controls.blockingError": "Une erreur s'est produite lors du chargement de ce contrôle.", + "controls.controlFactoryRegistry.factoryAlreadyExistsError": "Une usine de contrôle pour le type : {key} est déjà enregistrée.", + "controls.controlFactoryRegistry.factoryNotFoundError": "Aucune usine de contrôle n'a été trouvée pour le type : {key}", "controls.controlGroup.ariaActions.moveControlButtonAction": "Déplacer le contrôle {controlTitle}", + "controls.controlGroup.displayName": "Contrôles", "controls.controlGroup.floatingActions.clearTitle": "Effacer", "controls.controlGroup.floatingActions.editTitle": "Modifier", "controls.controlGroup.floatingActions.removeTitle": "Supprimer", + "controls.controlGroup.manageControl": "Modifier les paramètres du contrôle", "controls.controlGroup.manageControl.cancelTitle": "Annuler", "controls.controlGroup.manageControl.controlTypeSettings.formGroupDescription": "Paramètres personnalisés pour votre contrôle {controlType}.", "controls.controlGroup.manageControl.controlTypeSettings.formGroupTitle": "Paramètres {controlType}", @@ -461,7 +858,9 @@ "controls.controlGroup.manageControl.dataSource.controlTypeErrorMessage.rangeSlider": "Les curseurs ne sont compatibles qu'avec les champs de numéros.", "controls.controlGroup.manageControl.dataSource.controlTypErrorMessage.noField": "Sélectionnez d'abord un champ.", "controls.controlGroup.manageControl.dataSource.controlTypesTitle": "Type de contrôle", + "controls.controlGroup.manageControl.dataSource.dataViewListErrorTitle": "Erreur lors du chargement des vues de données", "controls.controlGroup.manageControl.dataSource.dataViewTitle": "Vue de données", + "controls.controlGroup.manageControl.dataSource.fieldListErrorTitle": "Erreur lors du chargement de la liste des champs", "controls.controlGroup.manageControl.dataSource.fieldTitle": "Champ", "controls.controlGroup.manageControl.dataSource.formGroupDescription": "Sélectionnez la vue de données et le champ pour lesquels vous voulez créer un contrôle.", "controls.controlGroup.manageControl.dataSource.formGroupTitle": "Source de données", @@ -505,13 +904,14 @@ "controls.controlGroup.management.showApplySelections.tooltip": "Si cette option est désactivée, les sélections de contrôle ne seront appliquées qu'après avoir cliqué sur \"Appliquer\".", "controls.controlGroup.management.validate.title": "Valider les sélections utilisateur", "controls.controlGroup.management.validate.tooltip": "Mettez en évidence les sélections de contrôle qui n'aboutissent à aucune donnée.", + "controls.dataControl.fieldNotFound": "Impossible de localiser le champ : {fieldName}", "controls.frame.error.message": "Une erreur s'est produite. Voir plus", - "controls.optionsList.control.dateSeparator": "; ", + "controls.optionsList.control.dateSeparator": ";", "controls.optionsList.control.excludeExists": "NE PAS", "controls.optionsList.control.invalidSelectionWarningLabel": "{invalidSelectionCount} {invalidSelectionCount, plural, one {sélection ne renvoie} other {sélections ne renvoient}} aucun résultat.", "controls.optionsList.control.negate": "NON", "controls.optionsList.control.placeholder": "N'importe lequel", - "controls.optionsList.control.separator": ", ", + "controls.optionsList.control.separator": ",", "controls.optionsList.controlAndPopover.exists": "{negate, plural, one {Existe} other {Existent}}", "controls.optionsList.displayName": "Liste des options", "controls.optionsList.editor.additionalSettingsTitle": "Paramètres supplémentaires", @@ -549,6 +949,7 @@ "controls.optionsList.popover.loadingMore": "Chargement d'options supplémentaires...", "controls.optionsList.popover.prefixSearchPlaceholder": "Commence par...", "controls.optionsList.popover.selectedOptionsTitle": "Afficher uniquement les options sélectionnées", + "controls.optionsList.popover.selectionError": "Une erreur s'est produite lors de la création de votre sélection", "controls.optionsList.popover.selectionsEmpty": "Vous n'avez pas de sélections", "controls.optionsList.popover.sortBy.alphabetical": "Par ordre alphabétique", "controls.optionsList.popover.sortBy.date": "Par date", @@ -565,6 +966,7 @@ "controls.rangeSlider.control.invalidSelectionWarningLabel": "La plage sélectionnée n'a donné aucun résultat.", "controls.rangeSlider.editor.stepSizeTitle": "Taille de l'étape", "controls.rangeSlider.popover.noAvailableDataHelpText": "Il n'y a aucune donnée à afficher. Ajustez la plage temporelle et les filtres.", + "controls.rangeSliderControl.displayName": "Curseur de plage", "controls.timeSlider.nextLabel": "Fenêtre temporelle suivante", "controls.timeSlider.pauseLabel": "Pause", "controls.timeSlider.playButtonTooltip.disabled": "\"Appliquer automatiquement les sélections\" est désactivé dans les paramètres du contrôle.", @@ -572,11 +974,13 @@ "controls.timeSlider.previousLabel": "Fenêtre temporelle précédente", "controls.timeSlider.settings.pinStart": "Épingler le début", "controls.timeSlider.settings.unpinStart": "Désépingler le début", + "controls.timesliderControl.displayName": "Curseur temporel", "core.application.appContainer.loadingAriaLabel": "Chargement de l'application", "core.application.appContainer.plainSpinner.loadingAriaLabel": "Chargement de l'application", "core.application.appNotFound.pageDescription": "Aucune application détectée pour cette URL. Revenez en arrière ou sélectionnez une application dans le menu.", "core.application.appNotFound.title": "Application introuvable", "core.application.appRenderError.defaultTitle": "Erreur d'application", + "core.chrome.euiDevProviderWarning": "Les composants Kibana doivent être intégrés dans un fournisseur React Context pour obtenir l'ensemble des fonctionnalités et une prise en charge adéquate des thèmes. Voir {link}.", "core.chrome.legacyBrowserWarning": "Votre navigateur ne satisfait pas aux exigences de sécurité de Kibana.", "core.deprecations.deprecations.fetchFailed.manualStepOneMessage": "Vérifiez le message d'erreur dans les logs de serveur Kibana.", "core.deprecations.deprecations.fetchFailedMessage": "Impossible d'extraire les informations de déclassement pour le plug-in {domainId}.", @@ -620,7 +1024,9 @@ "core.euiCodeBlockCopy.copy": "Copier", "core.euiCodeBlockFullScreen.fullscreenCollapse": "Réduire", "core.euiCodeBlockFullScreen.fullscreenExpand": "Développer", + "core.euiCollapsedItemActions.allActions": "Toutes les actions, ligne {index}", "core.euiCollapsedItemActions.allActionsDisabled": "Les actions individuelles sont désactivées lorsque plusieurs lignes sont sélectionnées.", + "core.euiCollapsedItemActions.allActionsTooltip": "Toutes les actions", "core.euiCollapsedNavButton.ariaLabelButtonIcon": "{title}, menu de navigation rapide", "core.euiCollapsibleNavBeta.ariaLabel": "Menu du site", "core.euiCollapsibleNavButton.ariaLabelClose": "Fermer la navigation", @@ -642,6 +1048,7 @@ "core.euiColumnActions.moveLeft": "Déplacer vers la gauche", "core.euiColumnActions.moveRight": "Déplacer vers la droite", "core.euiColumnActions.sort": "Trier {schemaLabel}", + "core.euiColumnActions.unsort": "Ne pas trier {schemaLabel}", "core.euiColumnSelector.button": "Colonnes", "core.euiColumnSelector.dragHandleAriaLabel": "Faire glisser la poignée", "core.euiColumnSelector.hideAll": "Tout masquer", @@ -674,9 +1081,11 @@ "core.euiDataGrid.screenReaderNotice": "Cette cellule contient du contenu interactif.", "core.euiDataGridCell.expansionEnterPrompt": "Appuyez sur Entrée pour développer cette cellule.", "core.euiDataGridCell.focusTrapEnterPrompt": "Appuyez sur Entrée pour interagir avec le contenu de cette cellule.", + "core.euiDataGridCell.focusTrapExitPrompt": "Vous avez quitté le contenu de la cellule.", "core.euiDataGridCell.position": "{columnName}, colonne {columnIndex}, ligne {rowIndex}", "core.euiDataGridCellActions.expandButtonTitle": "Cliquez ou appuyez sur Entrée pour interagir avec le contenu de la cellule.", - "core.euiDataGridHeaderCell.actionsButtonAriaLabel": "{title}. Cliquez pour afficher les actions d'en-tête de colonne", + "core.euiDataGridHeaderCell.actionsButtonAriaLabel": "{title}. Cliquez pour afficher les actions d'en-tête de colonne.", + "core.euiDataGridHeaderCell.actionsEnterKeyInstructions": "Appuyez sur la touche Entrée pour afficher les actions de cette colonne", "core.euiDataGridHeaderCell.actionsPopoverScreenReaderText": "Pour naviguer dans la liste des actions de la colonne, appuyez sur la touche Tab ou sur les flèches vers le haut et vers le bas.", "core.euiDataGridHeaderCell.sortedByAscendingFirst": "Trié par {columnId}, ordre croissant", "core.euiDataGridHeaderCell.sortedByAscendingMultiple": ", puis par {columnId}, ordre croissant", @@ -711,21 +1120,21 @@ "core.euiDisplaySelector.densityLabel": "Densité", "core.euiDisplaySelector.labelAuto": "Ajustement automatique", "core.euiDisplaySelector.labelCompact": "Compact", - "core.euiDisplaySelector.labelCustom": "Personnalisé", "core.euiDisplaySelector.labelExpanded": "Étendu", "core.euiDisplaySelector.labelNormal": "Normal", - "core.euiDisplaySelector.labelSingle": "Unique", - "core.euiDisplaySelector.lineCountLabel": "Sous-lignes par ligne", "core.euiDisplaySelector.resetButtonText": "Réinitialiser à la valeur par défaut", "core.euiDisplaySelector.rowHeightLabel": "Hauteur de la ligne", "core.euiDualRange.sliderScreenReaderInstructions": "Vous êtes dans un curseur de plage personnalisé. Utilisez les flèches vers le haut et vers le bas pour modifier la valeur minimale. Appuyez sur Tabulation pour interagir avec la valeur maximale.", "core.euiErrorBoundary.error": "Erreur", - "core.euiExternalLinkIcon.newTarget.screenReaderOnlyText": "(s’ouvre dans un nouvel onglet ou une nouvelle fenêtre)", + "core.euiExternalLinkIcon.externalTarget.screenReaderOnlyText": "(externe)", + "core.euiExternalLinkIcon.newTarget.screenReaderOnlyText": "(externe, s'ouvre dans un nouvel onglet ou une nouvelle fenêtre)", "core.euiFieldPassword.maskPassword": "Masquer le mot de passe", "core.euiFieldPassword.showPassword": "Afficher le mot de passe en texte brut. Remarque : votre mot de passe sera visible à l'écran.", + "core.euiFieldSearch.clearSearchButtonLabel": "Effacer la recherche", "core.euiFilePicker.filesSelected": "{fileCount} fichiers sélectionnés", "core.euiFilePicker.promptText": "Sélectionner ou glisser-déposer un fichier", "core.euiFilePicker.removeSelected": "Supprimer", + "core.euiFilePicker.removeSelectedAriaLabel": "Supprimer les fichiers sélectionnés", "core.euiFilterButton.filterBadgeActiveAriaLabel": "{count} filtres actifs", "core.euiFilterButton.filterBadgeAvailableAriaLabel": "{count} filtres disponibles", "core.euiFlyout.screenReaderFixedHeaders": "Vous pouvez quand même continuer à parcourir les en-têtes de page à l'aide de la touche Tabulation en plus de la boîte de dialogue.", @@ -856,6 +1265,10 @@ "core.euiRecentlyUsed.legend": "Plages de dates récemment utilisées", "core.euiRefreshInterval.fullDescriptionOff": "L'actualisation est désactivée, intervalle défini sur {optionValue} {optionText}.", "core.euiRefreshInterval.fullDescriptionOn": "L'actualisation est activée, intervalle défini sur {optionValue} {optionText}.", + "core.euiRefreshInterval.toggleAriaLabel": "Basculer sur l'actualisation", + "core.euiRefreshInterval.toggleLabel": "Actualiser toutes les", + "core.euiRefreshInterval.unitsAriaLabel": "Unités d'intervalle d'actualisation", + "core.euiRefreshInterval.valueAriaLabel": "Valeur d'intervalle d'actualisation", "core.euiRelativeTab.dateInputError": "Doit être une plage valide", "core.euiRelativeTab.fullDescription": "L'unité peut être modifiée. Elle est actuellement définie sur {unit}.", "core.euiRelativeTab.numberInputError": "Doit être >= 0.", @@ -977,6 +1390,8 @@ "core.notifications.errorToast.closeModal": "Fermer", "core.notifications.globalToast.ariaLabel": "Liste de messages de notification", "core.notifications.unableUpdateUISettingNotificationMessageTitle": "Impossible de mettre à jour le paramètre de l'interface utilisateur", + "core.overlays.confirm.cancelButton": "Annuler", + "core.overlays.confirm.okButton": "Confirmer", "core.savedObjects.deprecations.unknownTypes.manualSteps.1": "Activez les plug-ins désactivés, puis redémarrez Kibana.", "core.savedObjects.deprecations.unknownTypes.manualSteps.2": "Si aucun plug-in n'est désactivé ou si leur activation ne résout pas le problème, supprimez les documents.", "core.savedObjects.deprecations.unknownTypes.message": "{objectCount, plural, one {# objet} other {# objets}} de type inconnu {objectCount, plural, one {a été trouvé} other {ont été trouvés}} dans les indices du système Kibana. La mise à niveau avec des types savedObject inconnus n'est plus compatible. Pour assurer la réussite des mises à niveau à l'avenir, réactivez les plug-ins ou supprimez ces documents dans les indices de Kibana", @@ -1016,7 +1431,7 @@ "core.ui_settings.params.darkMode.options.disabled": "Désactivé", "core.ui_settings.params.darkMode.options.enabled": "Activé", "core.ui_settings.params.darkMode.options.system": "Synchroniser avec le système", - "core.ui_settings.params.darkModeText": "Le thème de l'interface utilisateur que l'interface utilisateur de Kibana doit utiliser. La valeur \"activé\" ou \"désactivé\" permet d'activer ou de désactiver le thème sombre. Définissez sur \"système\" pour que le thème de l'interface utilisateur de Kibana suive le thème du système. Vous devez actualiser la page pour que ce paramètre s’applique.", + "core.ui_settings.params.darkModeText": "Le thème de l'interface utilisateur que l'interface utilisateur de Kibana doit utiliser. Réglez l'option sur \"Activé\" pour activer le thème sombre ou sur \"Désactivé\" pour le désactiver. Définissez l'option sur \"Synchroniser avec le système\" pour que le thème de l'interface utilisateur de Kibana suive le thème du système. Vous devez recharger la page pour que ce paramètre s'applique.", "core.ui_settings.params.darkModeTitle": "Mode sombre", "core.ui_settings.params.dateFormat.dayOfWeekText": "Le premier jour de la semaine", "core.ui_settings.params.dateFormat.dayOfWeekTitle": "Jour de la semaine", @@ -1038,15 +1453,15 @@ "core.ui_settings.params.hideAnnouncements": "Masquer les annonces", "core.ui_settings.params.hideAnnouncementsText": "Arrêtez d’afficher les messages et les visites guidées qui mettent en avant les nouvelles fonctionnalités.", "core.ui_settings.params.notifications.banner.markdownLinkText": "Markdown pris en charge", - "core.ui_settings.params.notifications.bannerLifetimeText": "La durée en millisecondes durant laquelle une notification de bannière s'affiche à l'écran. ", + "core.ui_settings.params.notifications.bannerLifetimeText": "La durée en millisecondes durant laquelle une notification de bannière s'affiche à l'écran.", "core.ui_settings.params.notifications.bannerLifetimeTitle": "Durée des notifications de bannière", "core.ui_settings.params.notifications.bannerText": "Une bannière personnalisée à des fins de notification temporaire de l’ensemble des utilisateurs. {markdownLink}.", "core.ui_settings.params.notifications.bannerTitle": "Notification de bannière personnalisée", - "core.ui_settings.params.notifications.errorLifetimeText": "La durée en millisecondes durant laquelle une notification d'erreur s'affiche à l'écran. ", + "core.ui_settings.params.notifications.errorLifetimeText": "La durée en millisecondes durant laquelle une notification d'erreur s'affiche à l'écran.", "core.ui_settings.params.notifications.errorLifetimeTitle": "Durée des notifications d'erreur", - "core.ui_settings.params.notifications.infoLifetimeText": "La durée en millisecondes durant laquelle une notification d'information s'affiche à l'écran. ", + "core.ui_settings.params.notifications.infoLifetimeText": "La durée en millisecondes durant laquelle une notification d'information s'affiche à l'écran.", "core.ui_settings.params.notifications.infoLifetimeTitle": "Durée des notifications d'information", - "core.ui_settings.params.notifications.warningLifetimeText": "La durée en millisecondes durant laquelle une notification d'avertissement s'affiche à l'écran. ", + "core.ui_settings.params.notifications.warningLifetimeText": "La durée en millisecondes durant laquelle une notification d'avertissement s'affiche à l'écran.", "core.ui_settings.params.notifications.warningLifetimeTitle": "Durée des notifications d'avertissement", "core.ui_settings.params.storeUrlText": "L'URL peut parfois devenir trop longue pour être gérée par certains navigateurs. Pour pallier ce problème, nous testons actuellement le stockage de certaines parties de l'URL dans le stockage de session. N’hésitez pas à nous faire part de vos commentaires.", "core.ui_settings.params.storeUrlTitle": "Stocker les URL dans le stockage de session", @@ -1093,7 +1508,9 @@ "core.ui.primaryNav.cloud.linkToProject": "Gérer le projet", "core.ui.primaryNav.cloud.projectLabel": "Projet", "core.ui.primaryNav.goToHome.ariaLabel": "Accéder à la page d’accueil", + "core.ui.primaryNav.header.toggleNavAriaLabel": "Activer/Désactiver la navigation principale", "core.ui.primaryNav.pinnedLinksAriaLabel": "Liens épinglés", + "core.ui.primaryNav.project.toggleNavAriaLabel": "Activer/Désactiver la navigation principale", "core.ui.primaryNav.screenReaderLabel": "Principale", "core.ui.primaryNavSection.screenReaderLabel": "Liens de navigation principale, {category}", "core.ui.publicBaseUrlWarning.configRecommendedDescription": "Dans un environnement de production, il est recommandé de configurer {configKey}.", @@ -1189,7 +1606,7 @@ "customIntegrationsPackage.create.configureIntegrationDescription.helper": "Elastic crée une intégration pour rationaliser la connexion de vos données de log dans la Suite Elastic.", "customIntegrationsPackage.create.dataset.helper": "Tout en minuscules, 100 caractères maximum, les caractères spéciaux seront remplacés par \"_\".", "customIntegrationsPackage.create.dataset.name": "Nom de l’ensemble de données", - "customIntegrationsPackage.create.dataset.name.tooltip": "Le nom de l'ensemble de données associé à cette intégration. Ceci fera partie du nom du flux de données Elasticsearch ", + "customIntegrationsPackage.create.dataset.name.tooltip": "Le nom de l'ensemble de données associé à cette intégration. Ceci fera partie du nom du flux de données Elasticsearch", "customIntegrationsPackage.create.dataset.name.tooltipPrefixMessage": "Ce nom aura pour préfixe {prefixValue}, par ex. {prefixedDatasetName}", "customIntegrationsPackage.create.dataset.placeholder": "Nommez votre intégration de l'ensemble de données", "customIntegrationsPackage.create.errorCallout.authorization.description": "Cet utilisateur ne dispose pas d'autorisations pour créer une intégration.", @@ -1213,9 +1630,6 @@ "dashboard.actions.toggleExpandPanelMenuItem.notExpandedDisplayName": "Maximiser", "dashboard.addPanel.newEmbeddableAddedSuccessMessageTitle": "{savedObjectName} a été ajouté.", "dashboard.addPanel.newEmbeddableWithNoTitleAddedSuccessMessageTitle": "Un panneau a été ajouté", - "dashboard.appLeaveConfirmModal.cancelButtonLabel": "Annuler", - "dashboard.appLeaveConfirmModal.unsavedChangesSubtitle": "Quitter Dashboard sans enregistrer ?", - "dashboard.appLeaveConfirmModal.unsavedChangesTitle": "Modifications non enregistrées", "dashboard.badge.readOnly.text": "Lecture seule", "dashboard.badge.readOnly.tooltip": "Impossible d'enregistrer les tableaux de bord", "dashboard.createConfirmModal.cancelButtonLabel": "Annuler", @@ -1235,6 +1649,7 @@ "dashboard.editingToolbar.controlsButtonTitle": "Contrôles", "dashboard.editingToolbar.editControlGroupButtonTitle": "Paramètres", "dashboard.editingToolbar.onlyOneTimeSliderControlMsg": "Le groupe de contrôle contient déjà un contrôle de curseur temporel.", + "dashboard.editorMenu.addPanelFlyout.searchLabelText": "champ de recherche pour les panneaux", "dashboard.editorMenu.deprecatedTag": "Déclassé", "dashboard.embeddableApi.showSettings.flyout.applyButtonTitle": "Appliquer", "dashboard.embeddableApi.showSettings.flyout.cancelButtonTitle": "Annuler", @@ -1248,6 +1663,7 @@ "dashboard.embeddableApi.showSettings.flyout.form.panelTitleInputAriaLabel": "Modifier le titre du tableau de bord", "dashboard.embeddableApi.showSettings.flyout.form.storeTimeWithDashboardFormRowHelpText": "Le filtre temporel est défini sur l’option sélectionnée chaque fois que ce tableau de bord est chargé.", "dashboard.embeddableApi.showSettings.flyout.form.storeTimeWithDashboardFormRowLabel": "Enregistrer la plage temporelle avec le tableau de bord", + "dashboard.embeddableApi.showSettings.flyout.form.syncColorsBetweenPanelsSwitchHelp": "Valide uniquement pour les palettes {default} et {compatibility}", "dashboard.embeddableApi.showSettings.flyout.form.syncColorsBetweenPanelsSwitchLabel": "Synchroniser les palettes de couleur de tous les panneaux", "dashboard.embeddableApi.showSettings.flyout.form.syncCursorBetweenPanelsSwitchLabel": "Synchroniser le curseur de tous les panneaux", "dashboard.embeddableApi.showSettings.flyout.form.syncTooltipsBetweenPanelsSwitchLabel": "Synchroniser les infobulles de tous les panneaux", @@ -1298,8 +1714,14 @@ "dashboard.listing.unsaved.unsavedChangesTitle": "Vous avez des modifications non enregistrées dans le {dash} suivant :", "dashboard.loadingError.dashboardGridErrorMessage": "Impossible de charger le tableau de bord : {message}", "dashboard.loadURLError.PanelTooOld": "Impossible de charger les panneaux à partir d'une URL créée dans une version antérieure à 7.3", + "dashboard.managedContentBadge.ariaLabel": "Elastic gère ce tableau de bord. Dupliquez-le pour apporter des modifications.", + "dashboard.managedContentPopoverButton": "Elastic gère ce tableau de bord. {Duplicate}-le pour y apporter des modifications.", + "dashboard.managedContentPopoverButtonText": "Dupliquer", + "dashboard.managedContentPopoverFooterText": "Cliquez ici pour dupliquer ce tableau de bord", "dashboard.noMatchRoute.bannerText": "L'application de tableau de bord ne reconnaît pas ce chemin : {route}.", "dashboard.noMatchRoute.bannerTitleText": "Page introuvable", + "dashboard.palettes.defaultPaletteLabel": "Par défaut", + "dashboard.palettes.kibanaPaletteLabel": "Compatibilité", "dashboard.panel.AddToLibrary": "Enregistrer dans la bibliothèque", "dashboard.panel.addToLibrary.errorMessage": "Une erreur s'est produite lors de l'ajout du panneau {panelTitle} à la bibliothèque", "dashboard.panel.addToLibrary.successMessage": "Le panneau {panelTitle} a été ajouté à la bibliothèque", @@ -1333,7 +1755,7 @@ "dashboard.renderer.404Body": "Désolé, le tableau de bord que vous recherchez est introuvable. Elle a peut-être été retirée ou renommée, ou peut-être qu'elle n'a jamais existé.", "dashboard.renderer.404Title": "Tableau de bord introuvable", "dashboard.resetChangesConfirmModal.confirmButtonLabel": "Réinitialiser le tableau de bord", - "dashboard.resetChangesConfirmModal.resetChangesDescription": "Ce tableau de bord va revenir à son dernier état d'enregistrement. Vous risquez de perdre les modifications apportées aux filtres et aux requêtes.", + "dashboard.resetChangesConfirmModal.resetChangesDescription": "Ce tableau de bord va revenir à son dernier état d'enregistrement. Vous risquez de perdre les modifications apportées aux filtres et aux requêtes.", "dashboard.resetChangesConfirmModal.resetChangesTitle": "Réinitialiser le tableau de bord ?", "dashboard.savedDashboard.newDashboardTitle": "Nouveau tableau de bord", "dashboard.share.defaultDashboardTitle": "Tableau de bord [{date}]", @@ -1342,6 +1764,7 @@ "dashboard.solutionToolbar.addPanelButtonLabel": "Créer une visualisation", "dashboard.solutionToolbar.addPanelFlyout.cancelButtonText": "Fermer", "dashboard.solutionToolbar.addPanelFlyout.headingText": "Ajouter un panneau", + "dashboard.solutionToolbar.addPanelFlyout.loadingErrorDescription": "Une erreur est survenue lors du chargement des panneaux du tableau de bord disponibles pour la sélection", "dashboard.solutionToolbar.addPanelFlyout.noResultsDescription": "Aucun type de panneaux trouvé", "dashboard.solutionToolbar.editorMenuButtonLabel": "Ajouter un panneau", "dashboard.solutionToolbar.quickCreateButtonGroupLegend": "Raccourcis vers les types de visualisation populaires", @@ -1372,7 +1795,7 @@ "dashboard.topNave.viewModeInteractiveSaveButtonAriaLabel": "dupliquer", "dashboard.topNave.viewModeInteractiveSaveConfigDescription": "Créer une copie du tableau de bord", "dashboard.unsavedChangesBadge": "Modifications non enregistrées", - "dashboard.unsavedChangesBadgeToolTipContent": " Vous avez des modifications non enregistrées dans ce tableau de bord. Pour supprimer cette étiquette, enregistrez le tableau de bord.", + "dashboard.unsavedChangesBadgeToolTipContent": "Vous avez des modifications non enregistrées dans ce tableau de bord. Pour supprimer cette étiquette, enregistrez le tableau de bord.", "dashboard.viewmodeBackup.error": "Une erreur s'est produite lors de la sauvegarde du mode d'affichage : {message}", "data.advancedSettings.autocompleteIgnoreTimerange": "Utiliser la plage temporelle", "data.advancedSettings.autocompleteIgnoreTimerangeText": "Désactivez cette propriété pour obtenir des suggestions de saisie semi-automatique depuis l’intégralité de l’ensemble de données plutôt que depuis la plage temporelle définie. {learnMoreLink}", @@ -1390,7 +1813,7 @@ "data.advancedSettings.courier.requestPreferenceCustom": "Personnalisée", "data.advancedSettings.courier.requestPreferenceNone": "Aucune", "data.advancedSettings.courier.requestPreferenceSessionId": "ID session", - "data.advancedSettings.courier.requestPreferenceText": "Permet de définir quelles partitions doivent gérer les requêtes de recherche.\n
    \n
  • {sessionId} : limite les opérations pour exécuter toutes les requêtes de recherche sur les mêmes partitions.\n Cela a l'avantage de réutiliser les caches de partition pour toutes les requêtes.
  • \n
  • {custom} : permet de définir une valeur de préférence.\n Utilisez courier:customRequestPreference pour personnaliser votre valeur de préférence.
  • \n
  • {none} : permet de ne pas définir de préférence.\n Cela peut permettre de meilleures performances, car les requêtes peuvent être réparties entre toutes les copies de partition.\n Cependant, les résultats peuvent être incohérents, les différentes partitions pouvant se trouver dans différents états d'actualisation.
  • \n
", + "data.advancedSettings.courier.requestPreferenceText": "Permet de définir quelles partitions doivent gérer les requêtes de recherche.
  • {sessionId} : limite les opérations pour exécuter toutes les requêtes de recherche sur les mêmes partitions. Cela a l'avantage de réutiliser les caches de partition pour toutes les requêtes.
  • {custom} : permet de définir une valeur de préférence. Utilisez courier:customRequestPreference pour personnaliser votre valeur de préférence.
  • {none} : permet de ne pas définir de préférence. Cela peut permettre de meilleures performances, car les requêtes peuvent être réparties entre toutes les copies de partition. Cependant, les résultats peuvent être incohérents, les différentes partitions pouvant se trouver dans différents états d'actualisation.
", "data.advancedSettings.courier.requestPreferenceTitle": "Préférence de requête", "data.advancedSettings.defaultIndexText": "Utilisé par Discover et Visualisations lorsqu'une vue de données n'est pas définie.", "data.advancedSettings.defaultIndexTitle": "Vue de données par défaut", @@ -1398,7 +1821,7 @@ "data.advancedSettings.docTableHighlightTitle": "Mettre les résultats en surbrillance", "data.advancedSettings.histogram.barTargetText": "Tente de générer ce nombre de compartiments lorsque l’intervalle \"auto\" est utilisé dans des histogrammes numériques et de date.", "data.advancedSettings.histogram.barTargetTitle": "Nombre de compartiments cible", - "data.advancedSettings.histogram.maxBarsText": "\n Limite la densité des histogrammes numériques et de date dans tout Kibana\n pour de meilleures performances à l’aide d’une requête de test. Si la requête de test génère trop de compartiments,\n l'intervalle entre les compartiments est augmenté. Ce paramètre s'applique séparément\n pour chaque agrégation d'histogrammes et ne s'applique pas aux autres types d'agrégations.\n Pour identifier la valeur maximale de ce paramètre, divisez la valeur \"search.max_buckets\" d'Elasticsearch\n par le nombre maximal d'agrégations dans chaque visualisation.\n ", + "data.advancedSettings.histogram.maxBarsText": "Avec une requête de test, limite la densité des histogrammes de dates et de nombres sur Kibana pour garantir de meilleures performances. Si la requête de test génère trop de compartiments, l'intervalle entre les compartiments est augmenté. Ce paramètre s'applique séparément pour chaque agrégation d'histogrammes et ne s'applique pas aux autres types d'agrégations. Pour trouver la valeur maximale de ce paramètre, divisez la valeur \"search.max_buckets\" d'Elasticsearch par le nombre maximal d'agrégations dans chaque visualisation.", "data.advancedSettings.histogram.maxBarsTitle": "Nombre maximal de compartiments", "data.advancedSettings.historyLimitText": "Le nombre de valeurs les plus récentes qui s’affichent pour les champs associés à un historique (par exemple, les entrées de requête).", "data.advancedSettings.historyLimitTitle": "Limite d'historique", @@ -1533,7 +1956,7 @@ "data.search.aggs.buckets.dateHistogram.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", "data.search.aggs.buckets.dateHistogram.dropPartials.help": "Spécifie l'utilisation ou non de drop_partials pour cette agrégation.", "data.search.aggs.buckets.dateHistogram.enabled.help": "Spécifie si cette agrégation doit être activée.", - "data.search.aggs.buckets.dateHistogram.extendedBounds.help": "Avec le paramètre extended_bounds, il est désormais possible de \"forcer\" l'agrégation d'histogrammes à démarrer la conception des compartiments sur une valeur minimale spécifique et à continuer jusqu'à une valeur maximale. ", + "data.search.aggs.buckets.dateHistogram.extendedBounds.help": "Avec le paramètre extended_bounds, il est désormais possible de \"forcer\" l'agrégation d'histogrammes à démarrer la conception des compartiments sur une valeur minimale spécifique et à continuer jusqu'à une valeur maximale.", "data.search.aggs.buckets.dateHistogram.extendToTimeRange.help": "Définit automatiquement les limites étendues sur la plage temporelle appliquée actuellement. Est ignoré si extended_bounds est défini", "data.search.aggs.buckets.dateHistogram.field.help": "Champ à utiliser pour cette agrégation", "data.search.aggs.buckets.dateHistogram.format.help": "Format à utiliser pour cette agrégation", @@ -1590,7 +2013,7 @@ "data.search.aggs.buckets.histogram.autoExtendBounds.help": "Définissez cette option comme vraie pour étendre les limites au domaine des données. Cela permet de s’assurer que chaque compartiment d'intervalle compris dans ces limites créera une ligne de tableau distincte.", "data.search.aggs.buckets.histogram.customLabel.help": "Représente une étiquette personnalisée pour cette agrégation", "data.search.aggs.buckets.histogram.enabled.help": "Spécifie si cette agrégation doit être activée.", - "data.search.aggs.buckets.histogram.extendedBounds.help": "Avec le paramètre extended_bounds, il est désormais possible de \"forcer\" l'agrégation d'histogrammes à démarrer la conception des compartiments sur une valeur minimale spécifique et à continuer jusqu'à une valeur maximale. ", + "data.search.aggs.buckets.histogram.extendedBounds.help": "Avec le paramètre extended_bounds, il est désormais possible de \"forcer\" l'agrégation d'histogrammes à démarrer la conception des compartiments sur une valeur minimale spécifique et à continuer jusqu'à une valeur maximale.", "data.search.aggs.buckets.histogram.field.help": "Champ à utiliser pour cette agrégation", "data.search.aggs.buckets.histogram.hasExtendedBounds.help": "Spécifie l'utilisation ou non de has_extended_bounds pour cette agrégation.", "data.search.aggs.buckets.histogram.id.help": "ID pour cette agrégation", @@ -2223,6 +2646,8 @@ "data.searchSessionIndicator.canceledTooltipText": "La session de recherche s'est arrêtée", "data.searchSessionIndicator.canceledWhenText": "Arrêtée {when}", "data.searchSessionIndicator.continueInBackgroundButtonText": "Enregistrer la session", + "data.searchSessionIndicator.deprecationWarning.textParagraphOne": "Les sessions de recherche sont obsolètes et seront supprimées dans une future version.", + "data.searchSessionIndicator.deprecationWarning.title": "Déclassé dans la version 8.15.0", "data.searchSessionIndicator.disabledDueToDisabledGloballyMessage": "Vous ne disposez pas d'autorisations pour gérer les sessions de recherche", "data.searchSessionIndicator.disabledDueToTimeoutMessage": "Les résultats de la session de recherche ont expiré.", "data.searchSessionIndicator.loadingInTheBackgroundDescriptionText": "Vous pouvez retourner aux résultats terminés à partir de la page Gestion", @@ -2320,6 +2745,7 @@ "discover.advancedSettings.disableDocumentExplorerDescription": "Désactivez cette option pour utiliser le nouveau {documentExplorerDocs} au lieu de la vue classique. l'explorateur de documents offre un meilleur tri des données, des colonnes redimensionnables et une vue en plein écran.", "discover.advancedSettings.discover.disableDocumentExplorerDeprecation": "Ce paramètre est déclassé et sera supprimé dans la version 9.0 de Kibana.", "discover.advancedSettings.discover.fieldStatisticsLinkText": "Vue des statistiques de champ", + "discover.advancedSettings.discover.maxCellHeightDeprecation": "Ce paramètre est déclassé et sera supprimé dans la version 9.0 de Kibana.", "discover.advancedSettings.discover.modifyColumnsOnSwitchText": "Supprimez les colonnes qui ne sont pas disponibles dans la nouvelle vue de données.", "discover.advancedSettings.discover.modifyColumnsOnSwitchTitle": "Modifier les colonnes en cas de changement des vues de données", "discover.advancedSettings.discover.multiFieldsLinkText": "champs multiples", @@ -2378,6 +2804,13 @@ "discover.context.unableToLoadDocumentDescription": "Impossible de charger les documents", "discover.contextViewRoute.errorMessage": "Aucune donnée correspondante pour l'ID {dataViewId}", "discover.contextViewRoute.errorTitle": "Une erreur s'est produite", + "discover.customControl.degradedDocArialLabel": "Accéder aux documents dégradés", + "discover.customControl.degradedDocDisabled": "La détection des champs de documents dégradés est désactivée pour cette recherche. Cliquez pour ajouter une {directive} à votre requête ES|QL.", + "discover.customControl.degradedDocNotPresent": "Tous les champs de ce document ont été analysés correctement", + "discover.customControl.degradedDocPresent": "Ce document n'a pas pu être analysé correctement. Tous les champs n'ont pas été remplis correctement.", + "discover.customControl.stacktrace.available": "Traces d'appel disponibles", + "discover.customControl.stacktrace.notAvailable": "Traces d'appel indisponibles", + "discover.customControl.stacktraceArialLabel": "Accès aux traces d'appel disponibles", "discover.discoverBreadcrumbTitle": "Discover", "discover.discoverDefaultSearchSessionName": "Discover", "discover.discoverDescription": "Explorez vos données de manière interactive en interrogeant et en filtrant des documents bruts.", @@ -2424,18 +2857,22 @@ "discover.docTable.tableRow.viewSingleDocumentLinkText": "Afficher un seul document", "discover.docTable.tableRow.viewSurroundingDocumentsLinkText": "Afficher les documents alentour", "discover.documentsAriaLabel": "Documents", + "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", + "discover.errorCalloutESQLReferenceButtonLabel": "Ouvrir la référence ES|QL", "discover.errorCalloutShowErrorMessage": "Afficher les détails", "discover.esqlMode.selectedColumnsCallout": "Affichage de {selectedColumnsNumber} champs sur {esqlQueryColumnsNumber}. Ajoutez-en d’autres depuis la liste des champs disponibles.", - "discover.esqlToDataViewTransitionModal.closeButtonLabel": "Basculer sans sauvegarder", - "discover.esqlToDataViewTransitionModal.dismissButtonLabel": "Ne plus afficher cet avertissement", + "discover.esqlToDataViewTransitionModal.closeButtonLabel": "Annuler et changer", + "discover.esqlToDataViewTransitionModal.dismissButtonLabel": "Ne plus me demander", + "discover.esqlToDataViewTransitionModal.feedbackLink": "Soumettre des commentaires ES|QL", "discover.esqlToDataViewTransitionModal.saveButtonLabel": "Sauvegarder et basculer", - "discover.esqlToDataViewTransitionModal.title": "Votre requête sera supprimée", - "discover.esqlToDataviewTransitionModalBody": "Modifier la vue de données supprime la requête ES|QL en cours. Sauvegardez cette recherche pour ne pas perdre de travail.", + "discover.esqlToDataViewTransitionModal.title": "Modifications non enregistrées", + "discover.esqlToDataviewTransitionModalBody": "Un changement de vue de données supprime la requête ES|QL en cours. Sauvegardez cette recherche pour éviter de perdre votre travail.", "discover.fieldChooser.availableFieldsTooltip": "Champs disponibles pour l'affichage dans le tableau.", "discover.fieldChooser.discoverField.addFieldTooltip": "Ajouter le champ en tant que colonne", "discover.fieldChooser.discoverField.removeFieldTooltip": "Supprimer le champ du tableau", @@ -2465,6 +2902,7 @@ "discover.loadingDocuments": "Chargement des documents", "discover.loadingResults": "Chargement des résultats", "discover.localMenu.alertsDescription": "Alertes", + "discover.localMenu.esqlTooltipLabel": "ES|QL est le nouveau langage de requête canalisé puissant d'Elastic.", "discover.localMenu.fallbackReportTitle": "Recherche Discover sans titre", "discover.localMenu.inspectTitle": "Inspecter", "discover.localMenu.localMenu.alertsTitle": "Alertes", @@ -2479,7 +2917,21 @@ "discover.localMenu.saveTitle": "Enregistrer", "discover.localMenu.shareSearchDescription": "Partager la recherche", "discover.localMenu.shareTitle": "Partager", + "discover.localMenu.switchToClassicTitle": "Basculer vers le classique", + "discover.localMenu.switchToClassicTooltipLabel": "Passez à la syntaxe KQL ou Lucene.", + "discover.localMenu.tryESQLTitle": "Essayer ES|QL", + "discover.logLevelLabels.alert": "Alerte", + "discover.logLevelLabels.critical": "Critique", + "discover.logLevelLabels.debug": "Déboguer", + "discover.logLevelLabels.emergency": "Urgence", + "discover.logLevelLabels.error": "Erreur", + "discover.logLevelLabels.fatal": "Fatal", + "discover.logLevelLabels.info": "Infos", + "discover.logLevelLabels.notice": "Notification", + "discover.logLevelLabels.trace": "Trace", + "discover.logLevelLabels.warning": "Avertissement", "discover.logs.dataTable.header.popover.content": "Contenu", + "discover.logs.dataTable.header.popover.json": "JSON", "discover.logs.dataTable.header.popover.resource": "Ressource", "discover.logs.flyoutDetail.value.hover.filterFor": "Filtrer sur cette {value}", "discover.logs.flyoutDetail.value.hover.filterOut": "Exclure cette {value}", @@ -2564,7 +3016,7 @@ "discover.viewAlert.alertRuleFetchErrorTitle": "Erreur lors de la récupération de la règle d'alerte", "discover.viewAlert.dataViewErrorText": "Échec de la vue des données de la règle d'alerte avec l'ID {alertId}.", "discover.viewAlert.dataViewErrorTitle": "Erreur lors de la récupération de la vue de données", - "discover.viewAlert.documentsMayVaryInfoDescription": "Les documents affichés peuvent différer de ceux ayant déclenché l'alerte.\n Des documents ont peut-être été ajoutés ou supprimés.", + "discover.viewAlert.documentsMayVaryInfoDescription": "Les documents affichés peuvent différer de ceux ayant déclenché l'alerte. Des documents ont peut-être été ajoutés ou supprimés.", "discover.viewAlert.documentsMayVaryInfoTitle": "Les documents affichés peuvent varier", "discover.viewAlert.searchSourceErrorTitle": "Erreur lors de la récupération de la source de recherche", "discover.viewModes.document.label": "Documents", @@ -2572,7 +3024,7 @@ "discover.viewModes.patternAnalysis.label": "Modèles {patternCount}", "domDragDrop.announce.cancelled": "Mouvement annulé. {label} revenu à sa position initiale", "domDragDrop.announce.cancelledItem": "Mouvement annulé. {label} revenu au groupe {groupLabel} à la position {position}", - "domDragDrop.announce.combine.short": " Maintenir la touche Contrôle enfoncée pour combiner", + "domDragDrop.announce.combine.short": "Maintenir la touche Contrôle enfoncée pour combiner", "domDragDrop.announce.dropped.combineCompatible": "Combinaisons de {label} dans le {groupLabel} vers {dropLabel} dans le groupe {dropGroupLabel} à la position {dropPosition} dans le calque {dropLayerNumber}", "domDragDrop.announce.dropped.combineIncompatible": "Conversion de {label} en {nextLabel} dans le groupe {groupLabel} à la position {position} et combinaison avec {dropLabel} dans le groupe {dropGroupLabel} à la position {dropPosition} dans le calque {dropLayerNumber}", "domDragDrop.announce.dropped.duplicated": "{label} dupliqué dans le groupe {groupLabel} à la position {position} dans le calque {layerNumber}", @@ -2586,7 +3038,7 @@ "domDragDrop.announce.dropped.swapIncompatible": "Conversion de {label} en {nextLabel} dans le groupe {groupLabel} à la position {position} dans le calque {layerNumber} et permuté avec {dropLabel} dans le groupe {dropGroupLabel} à la position {dropPosition} dans le calque {dropLayerNumber}", "domDragDrop.announce.droppedDefault": "Ajout de {label} dans le groupe {dropGroupLabel} à la position {position} dans le calque {dropLayerNumber}", "domDragDrop.announce.droppedNoPosition": "{label} ajouté à {dropLabel}", - "domDragDrop.announce.duplicate.short": " Maintenez la touche Alt ou Option enfoncée pour dupliquer.", + "domDragDrop.announce.duplicate.short": "Maintenez la touche Alt ou Option enfoncée pour dupliquer.", "domDragDrop.announce.duplicated.combine": "Combinaison de {dropLabel} avec {label} dans {groupLabel} à la position {position} dans le calque {dropLayerNumber}", "domDragDrop.announce.duplicated.replace": "Remplacement de {dropLabel} par {label} dans {groupLabel} à la position {position} dans le calque {dropLayerNumber}", "domDragDrop.announce.duplicated.replaceDuplicateCompatible": "Remplacement de {dropLabel} par une copie de {label} dans {groupLabel} à la position {position} dans le calque {dropLayerNumber}", @@ -2615,7 +3067,7 @@ "domDragDrop.announce.selectedTarget.replaceMain": "Vous faites glisser {label} à partir de {groupLabel} à la position {position} dans le calque {layerNumber} sur {dropLabel} à partir du groupe {dropGroupLabel} à la position {dropPosition} dans le calque {dropLayerNumber}. Appuyez sur la barre d'espace ou sur Entrée pour remplacer {dropLabel} par {label}.{duplicateCopy}{swapCopy}{combineCopy}", "domDragDrop.announce.selectedTarget.swapCompatible": "Permutation de {label} dans le groupe {groupLabel} à la position {position} dans le calque {layerNumber} et de {dropLabel} dans le groupe {dropGroupLabel} à la position {dropPosition} dans le calque {dropLayerNumber}. Maintenir la touche Maj enfoncée tout en appuyant sur la barre d'espace ou sur Entrée pour permuter", "domDragDrop.announce.selectedTarget.swapIncompatible": "Conversion de {label} en {nextLabel} dans le groupe {groupLabel} à la position {position} dans le calque {layerNumber} et permutation avec {dropLabel} dans le groupe {dropGroupLabel} à la position {dropPosition} dans le calque {dropLayerNumber}. Maintenir la touche Maj enfoncée tout en appuyant sur la barre d'espace ou sur Entrée pour permuter", - "domDragDrop.announce.swap.short": " Maintenez la touche Maj enfoncée pour permuter.", + "domDragDrop.announce.swap.short": "Maintenez la touche Maj enfoncée pour permuter.", "domDragDrop.dropTargets.altOption": "Alt/Option", "domDragDrop.dropTargets.combine": "Combiner", "domDragDrop.dropTargets.control": "Contrôler", @@ -2659,6 +3111,37 @@ "embeddableApi.selectRangeTrigger.title": "Sélection de la plage", "embeddableApi.valueClickTrigger.description": "Un point de données cliquable sur la visualisation", "embeddableApi.valueClickTrigger.title": "Clic unique", + "embeddableExamples.dataTable.ariaLabel": "Tableau de données", + "embeddableExamples.dataTable.noDataViewError": "Au moins une vue de données est requise pour utiliser l'exemple de table de données.", + "embeddableExamples.euiMarkdownEditor.displayNameAriaLabel": "Markdown EUI", + "embeddableExamples.euiMarkdownEditor.embeddableAriaLabel": "Éditeur de markdown du tableau de bord", + "embeddableExamples.savedbook.addBookAction.displayName": "Livre", + "embeddableExamples.savedbook.editBook.displayName": "livre", + "embeddableExamples.savedBook.editor.addToLibrary": "Enregistrer dans la bibliothèque", + "embeddableExamples.savedBook.editor.authorLabel": "Auteur", + "embeddableExamples.savedBook.editor.cancel": "Abandonner les modifications", + "embeddableExamples.savedBook.editor.create": "Créer un livre", + "embeddableExamples.savedBook.editor.editTitle": "Modifier un livre", + "embeddableExamples.savedBook.editor.newTitle": "Créer un nouveau livre", + "embeddableExamples.savedBook.editor.pagesLabel": "Nombre de pages", + "embeddableExamples.savedBook.editor.save": "Conserver les modifications", + "embeddableExamples.savedBook.editor.synopsisLabel": "Synopsis", + "embeddableExamples.savedBook.editor.titleLabel": "Titre", + "embeddableExamples.savedBook.libraryCallout": "Enregistré dans la bibliothèque", + "embeddableExamples.savedBook.noLibraryCallout": "Non enregistré dans la bibliothèque", + "embeddableExamples.savedBook.numberOfPages": "{numberOfPages} pages", + "embeddableExamples.search.dataViewName": "{dataViewName}", + "embeddableExamples.search.noDataViewError": "Veuillez installer une vue de données pour visualiser cet exemple", + "embeddableExamples.search.result": "{count, plural, one {document trouvé} other {documents trouvés}}", + "embeddableExamples.unifiedFieldList.displayName": "Liste des champs", + "embeddableExamples.unifiedFieldList.noDefaultDataViewErrorMessage": "La liste de champs doit être utilisée avec au moins une vue de données présente", + "embeddableExamples.unifiedFieldList.selectDataViewMessage": "Veuillez sélectionner une vue de données", + "esql.advancedSettings.enableESQLDescription": "Ce paramètre active ES|QL dans Kibana. En le désactivant, vous cacherez l'interface utilisateur ES|QL de diverses applications. Cependant, les utilisateurs pourront accéder aux recherches enregistrées ES|QL, en plus des visualisations, etc.", + "esql.advancedSettings.enableESQLTitle": "Activer ES|QL", + "esql.triggers.updateEsqlQueryTrigger": "Mettre à jour la requête ES|QL", + "esql.triggers.updateEsqlQueryTriggerDescription": "Mettre à jour la requête ES|QL en utilisant une nouvelle requête", + "esql.updateESQLQueryLabel": "Mettre à jour la requête ES|QL dans l’éditeur", + "esqlDataGrid.openInDiscoverLabel": "Ouvrir dans Discover", "esqlEditor.query.aborted": "La demande a été annulée", "esqlEditor.query.cancel": "Annuler", "esqlEditor.query.collapseLabel": "Réduire", @@ -2669,6 +3152,8 @@ "esqlEditor.query.expandLabel": "Développer", "esqlEditor.query.feedback": "Commentaires", "esqlEditor.query.hideQueriesLabel": "Masquer les recherches récentes", + "esqlEditor.query.limitInfo": "LIMITE : {limit} lignes", + "esqlEditor.query.limitInfoReduced": "LIMITE : {limit}", "esqlEditor.query.lineCount": "{count} {count, plural, one {ligne} other {lignes}}", "esqlEditor.query.lineNumber": "Ligne {lineNumber}", "esqlEditor.query.querieshistory.error": "La requête a échouée", @@ -2677,10 +3162,12 @@ "esqlEditor.query.querieshistoryRun": "Exécuter la requête", "esqlEditor.query.querieshistoryTable": "Tableau d'historique des recherches", "esqlEditor.query.recentQueriesColumnLabel": "Recherches récentes", + "esqlEditor.query.refreshLabel": "Actualiser", "esqlEditor.query.runQuery": "Exécuter la requête", "esqlEditor.query.showQueriesLabel": "Afficher les recherches récentes", "esqlEditor.query.submitFeedback": "Soumettre un commentaire", "esqlEditor.query.timeRanColumnLabel": "Temps exécuté", + "esqlEditor.query.timestampDetected": "{detectedTimestamp} trouvé", "esqlEditor.query.timestampNotDetected": "@timestamp non trouvé", "esqlEditor.query.warningCount": "{count} {count, plural, one {avertissement} other {avertissements}}", "esqlEditor.query.warningsTitle": "Avertissements", @@ -2800,6 +3287,7 @@ "eventAnnotation.rangeAnnotation.args.time": "Horodatage de l'annotation", "eventAnnotation.rangeAnnotation.description": "Configurer l'annotation manuelle", "eventAnnotationCommon.manualAnnotation.defaultAnnotationLabel": "Événement", + "eventAnnotationCommon.manualAnnotation.defaultRangeAnnotationLabel": "Plage d'événements", "eventAnnotationComponents.eventAnnotationGroup.metadata.name": "Groupes d’annotations", "eventAnnotationComponents.eventAnnotationGroup.savedObjectFinder.emptyCTA": "Créer un calque d’annotations", "eventAnnotationComponents.eventAnnotationGroup.savedObjectFinder.emptyPromptDescription": "Il n’y a actuellement aucune annotation disponible à sélectionner depuis la bibliothèque. Créez un nouveau calque pour ajouter des annotations.", @@ -2919,12 +3407,23 @@ "exceptionList-components.exceptions.exceptionItem.card.metaDetailsBy": "par", "exceptionList-components.exceptions.exceptionItem.card.showCommentsLabel": "Afficher {comments, plural, =1 {commentaire} other {commentaires}} ({comments})", "exceptionList-components.exceptions.exceptionItem.card.updatedLabel": "Mis à jour", + "exceptionList-components.partialCodeSignatureCallout.body": "Veuillez vérifier les valeurs des champs, car vos critères de filtrage peuvent être incomplets. Nous recommandons d'inclure à la fois le nom du signataire et le statut de confiance (en utilisant l'opérateur « AND ») pour éviter d'éventuelles failles de sécurité.", + "exceptionList-components.partialCodeSignatureCallout.title": "Veuillez examiner vos entrées", "exceptionList-components.wildcardWithWrongOperatorCallout.body": "L'utilisation de \"*\" ou de \"?\" dans la valeur avec l'opérateur \"is\" peut rendre l'entrée inefficace. Remplacez {operator} par \"{matches}\" pour que les caractères génériques s'exécutent correctement.", "exceptionList-components.wildcardWithWrongOperatorCallout.changeTheOperator": "Changer d'opérateur", "exceptionList-components.wildcardWithWrongOperatorCallout.matches": "correspond à", "exceptionList-components.wildcardWithWrongOperatorCallout.title": "Veuillez examiner vos entrées", "expandableFlyout.previewSection.backButton": "Retour", "expandableFlyout.previewSection.closeButton": "Fermer", + "expandableFlyout.renderMenu.flyoutResizeButton": "Réinitialiser la taille", + "expandableFlyout.renderMenu.flyoutResizeTitle": "Taille du menu volant", + "expandableFlyout.settingsMenu.flyoutTypeTitle": "Type de menu volant", + "expandableFlyout.settingsMenu.overlayMode": "Superposer", + "expandableFlyout.settingsMenu.overlayTooltip": "Affiche le menu volant sur la page", + "expandableFlyout.settingsMenu.popoverButton": "Paramètres du menu volant", + "expandableFlyout.settingsMenu.popoverTitle": "Paramètres du menu volant", + "expandableFlyout.settingsMenu.pushMode": "Déploiement", + "expandableFlyout.settingsMenu.pushTooltip": "Affiche le menu volant à côté de la page", "expressionError.errorComponent.description": "Échec de l'expression avec le message :", "expressionError.errorComponent.title": "Oups ! Échec de l'expression", "expressionError.renderer.debug.displayName": "Déboguer", @@ -3034,6 +3533,7 @@ "expressionMetricVis.function.dimension.timeField": "Champ temporel", "expressionMetricVis.function.help": "Visualisation de l'indicateur", "expressionMetricVis.function.icon.help": "Fournit une icône de visualisation statique.", + "expressionMetricVis.function.iconAlign.help": "L'alignement de l'icône.", "expressionMetricVis.function.inspectorTableId.help": "ID pour le tableau de l'inspecteur", "expressionMetricVis.function.max.help.": "La dimension contenant la valeur maximale.", "expressionMetricVis.function.metric.help": "L’indicateur principal.", @@ -3044,7 +3544,10 @@ "expressionMetricVis.function.secondaryMetric.help": "L’indicateur secondaire (affiché au-dessus de l’indicateur principal).", "expressionMetricVis.function.secondaryPrefix.help": "Texte facultatif à afficher avant secondaryMetric.", "expressionMetricVis.function.subtitle.help": "Le sous-titre pour un indicateur unique. Remplacé si breakdownBy est spécifié.", + "expressionMetricVis.function.titlesTextAlign.help": "L'alignement du titre et du sous-titre.", "expressionMetricVis.function.trendline.help": "Configuration de la courbe de tendance facultative", + "expressionMetricVis.function.valueFontSize.help": "La taille de la police de valeur.", + "expressionMetricVis.function.valuesTextAlign.help": "L'alignement des indicateurs primaires et secondaires.", "expressionMetricVis.trendA11yDescription": "Graphique linéaire affichant la tendance de l'indicateur principal sur la durée.", "expressionMetricVis.trendA11yTitle": "{dataTitle} sur la durée.", "expressionMetricVis.trendline.function.breakdownBy.help": "La dimension contenant les étiquettes des sous-catégories.", @@ -3841,7 +4344,7 @@ "home.tutorials.ciscoLogs.longDescription": "Il s'agit d'un module pour les logs de dispositifs réseau Cisco (ASA, FTD, IOS, Nexus). Il inclut les ensembles de fichiers suivants pour la réception des logs par le biais de Syslog ou d'un ficher. [En savoir plus]({learnMoreLink}).", "home.tutorials.ciscoLogs.nameTitle": "Logs Cisco", "home.tutorials.ciscoLogs.shortDescription": "Collectez et analysez les logs à partir des périphériques réseau Cisco avec Filebeat.", - "home.tutorials.cloudwatchLogs.longDescription": "Collectez les logs Cloudwatch en déployant Functionbeat à des fins d'exécution en tant que fonction AWS Lambda. [En savoir plus]({learnMoreLink}).", + "home.tutorials.cloudwatchLogs.longDescription": "Collectez les logs Cloudwatch en déployant Functionbeat à des fins d'exécution en tant que fonction AWS Lambda.", "home.tutorials.cloudwatchLogs.nameTitle": "Logs Cloudwatch AWS", "home.tutorials.cloudwatchLogs.shortDescription": "Collectez et analysez les logs à partir d'AWS Cloudwatch avec Functionbeat.", "home.tutorials.cockroachdbMetrics.artifacts.dashboards.linkLabel": "Tableau de bord des indicateurs CockroachDB", @@ -3859,16 +4362,16 @@ "home.tutorials.common.auditbeatCloudInstructions.config.rpmTitle": "Modifier la configuration", "home.tutorials.common.auditbeatCloudInstructions.config.windowsTextPre": "Modifiez {path} afin de définir les informations de connexion pour Elastic Cloud :", "home.tutorials.common.auditbeatCloudInstructions.config.windowsTitle": "Modifier la configuration", - "home.tutorials.common.auditbeatInstructions.config.debTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur `elastic`, {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer le SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte digitale dans {esCertFingerprintTemplate}.\n\n> **_Important :_** n'employez pas l'utilisateur `elastic` intégré pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", + "home.tutorials.common.auditbeatInstructions.config.debTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur \"elastic\", {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte dans {esCertFingerprintTemplate}. > **_Important :_** N'utilisez pas l'utilisateur intégré `elastic` pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", "home.tutorials.common.auditbeatInstructions.config.debTextPre": "Modifiez {path} afin de définir les informations de connexion :", "home.tutorials.common.auditbeatInstructions.config.debTitle": "Modifier la configuration", - "home.tutorials.common.auditbeatInstructions.config.osxTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur `elastic`, {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer le SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte digitale dans {esCertFingerprintTemplate}.\n\n> **_Important :_** n'employez pas l'utilisateur `elastic` intégré pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", + "home.tutorials.common.auditbeatInstructions.config.osxTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur \"elastic\", {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte dans {esCertFingerprintTemplate}. > **_Important :_** N'utilisez pas l'utilisateur intégré `elastic` pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", "home.tutorials.common.auditbeatInstructions.config.osxTextPre": "Modifiez {path} afin de définir les informations de connexion :", "home.tutorials.common.auditbeatInstructions.config.osxTitle": "Modifier la configuration", - "home.tutorials.common.auditbeatInstructions.config.rpmTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur `elastic`, {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer le SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte digitale dans {esCertFingerprintTemplate}.\n\n> **_Important :_** n'employez pas l'utilisateur `elastic` intégré pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", + "home.tutorials.common.auditbeatInstructions.config.rpmTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur \"elastic\", {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte dans {esCertFingerprintTemplate}. > **_Important :_** N'utilisez pas l'utilisateur intégré `elastic` pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", "home.tutorials.common.auditbeatInstructions.config.rpmTextPre": "Modifiez {path} afin de définir les informations de connexion :", "home.tutorials.common.auditbeatInstructions.config.rpmTitle": "Modifier la configuration", - "home.tutorials.common.auditbeatInstructions.config.windowsTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur `elastic`, {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer le SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte digitale dans {esCertFingerprintTemplate}.\n\n > **_Important :_** n'employez pas l'utilisateur `elastic` intégré pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", + "home.tutorials.common.auditbeatInstructions.config.windowsTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur \"elastic\", {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte dans {esCertFingerprintTemplate}. > **_Important :_** N'utilisez pas l'utilisateur intégré `elastic` pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", "home.tutorials.common.auditbeatInstructions.config.windowsTextPre": "Modifiez {path} afin de définir les informations de connexion :", "home.tutorials.common.auditbeatInstructions.config.windowsTitle": "Modifier la configuration", "home.tutorials.common.auditbeatInstructions.install.debTextPost": "Vous cherchez les packages 32 bits ? Consultez la [page de téléchargement]({linkUrl}).", @@ -3880,7 +4383,7 @@ "home.tutorials.common.auditbeatInstructions.install.rpmTextPre": "Vous utilisez Auditbeat pour la première fois ? Consultez le [guide de démarrage rapide]({linkUrl}).", "home.tutorials.common.auditbeatInstructions.install.rpmTitle": "Télécharger et installer Auditbeat", "home.tutorials.common.auditbeatInstructions.install.windowsTextPost": "Modifiez les paramètres sous {propertyName} dans le fichier {auditbeatPath} afin de pointer vers votre installation Elasticsearch.", - "home.tutorials.common.auditbeatInstructions.install.windowsTextPre": "Vous utilisez Auditbeat pour la première fois ? Consultez le [guide de démarrage rapide]({guideLinkUrl}).\n 1. Téléchargez le fichier .zip Auditbeat pour Windows via la page [Télécharger]({auditbeatLinkUrl}).\n 2. Extrayez le contenu du fichier compressé sous {folderPath}.\n 3. Renommez le répertoire `{directoryName}` en `Auditbeat`.\n 4. Ouvrez une invite PowerShell en tant qu'administrateur (faites un clic droit sur l'icône PowerShell et sélectionnez **Exécuter en tant qu'administrateur**). Si vous exécutez Windows XP, vous devrez peut-être télécharger et installer PowerShell.\n 5. Dans l'invite PowerShell, exécutez les commandes suivantes afin d'installer Auditbeat en tant que service Windows.", + "home.tutorials.common.auditbeatInstructions.install.windowsTextPre": "Vous utilisez Auditbeat pour la première fois ? Consultez le [guide de démarrage rapide]({guideLinkUrl}). 1. Téléchargez le fichier .zip Auditbeat pour Windows via la page [Télécharger]({auditbeatLinkUrl}). 2. Extrayez le contenu du fichier compressé sous {folderPath}. 3. Renommez le répertoire `{directoryName}` en `Auditbeat`. 4. Ouvrez une invite PowerShell en tant qu'administrateur (faites un clic droit sur l'icône PowerShell et sélectionnez **Exécuter en tant qu'administrateur**). Si vous exécutez Windows XP, vous devrez peut-être télécharger et installer PowerShell. 5. Dans l'invite PowerShell, exécutez les commandes suivantes afin d'installer Auditbeat en tant que service Windows.", "home.tutorials.common.auditbeatInstructions.install.windowsTitle": "Télécharger et installer Auditbeat", "home.tutorials.common.auditbeatInstructions.start.debTextPre": "La commande `setup` charge les tableaux de bord Kibana. Si les tableaux de bord sont déjà configurés, omettez cette commande.", "home.tutorials.common.auditbeatInstructions.start.debTitle": "Lancer Auditbeat", @@ -3916,16 +4419,16 @@ "home.tutorials.common.filebeatEnableInstructions.windowsTextPost": "Modifiez les paramètres dans le fichier `modules.d/{moduleName}.yml`. Vous devez activer au moins un ensemble de fichiers.", "home.tutorials.common.filebeatEnableInstructions.windowsTextPre": "Dans le dossier {path}, exécutez la commande suivante :", "home.tutorials.common.filebeatEnableInstructions.windowsTitle": "Activer et configurer le module {moduleName}", - "home.tutorials.common.filebeatInstructions.config.debTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur `elastic`, {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer le SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte digitale dans {esCertFingerprintTemplate}.\n\n> **_Important :_** n'employez pas l'utilisateur `elastic` intégré pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", + "home.tutorials.common.filebeatInstructions.config.debTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur \"elastic\", {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte dans {esCertFingerprintTemplate}. > **_Important :_** N'utilisez pas l'utilisateur intégré `elastic` pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", "home.tutorials.common.filebeatInstructions.config.debTextPre": "Modifiez {path} afin de définir les informations de connexion :", "home.tutorials.common.filebeatInstructions.config.debTitle": "Modifier la configuration", - "home.tutorials.common.filebeatInstructions.config.osxTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur `elastic`, {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer le SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte digitale dans {esCertFingerprintTemplate}.\n\n> **_Important :_** n'employez pas l'utilisateur `elastic` intégré pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", + "home.tutorials.common.filebeatInstructions.config.osxTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur \"elastic\", {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte dans {esCertFingerprintTemplate}. > **_Important :_** N'utilisez pas l'utilisateur intégré `elastic` pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", "home.tutorials.common.filebeatInstructions.config.osxTextPre": "Modifiez {path} afin de définir les informations de connexion :", "home.tutorials.common.filebeatInstructions.config.osxTitle": "Modifier la configuration", - "home.tutorials.common.filebeatInstructions.config.rpmTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur `elastic`, {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer le SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte digitale dans {esCertFingerprintTemplate}.\n\n> **_Important :_** n'employez pas l'utilisateur `elastic` intégré pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", + "home.tutorials.common.filebeatInstructions.config.rpmTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur \"elastic\", {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte dans {esCertFingerprintTemplate}. > **_Important :_** N'utilisez pas l'utilisateur intégré `elastic` pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", "home.tutorials.common.filebeatInstructions.config.rpmTextPre": "Modifiez {path} afin de définir les informations de connexion :", "home.tutorials.common.filebeatInstructions.config.rpmTitle": "Modifier la configuration", - "home.tutorials.common.filebeatInstructions.config.windowsTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur `elastic`, {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer le SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte digitale dans {esCertFingerprintTemplate}.\n\n> **_Important :_** n'employez pas l'utilisateur `elastic` intégré pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", + "home.tutorials.common.filebeatInstructions.config.windowsTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur \"elastic\", {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte dans {esCertFingerprintTemplate}. > **_Important :_** N'utilisez pas l'utilisateur intégré `elastic` pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", "home.tutorials.common.filebeatInstructions.config.windowsTextPre": "Modifiez {path} afin de définir les informations de connexion :", "home.tutorials.common.filebeatInstructions.config.windowsTitle": "Modifier la configuration", "home.tutorials.common.filebeatInstructions.install.debTextPost": "Vous cherchez les packages 32 bits ? Consultez la [page de téléchargement]({linkUrl}).", @@ -3937,7 +4440,7 @@ "home.tutorials.common.filebeatInstructions.install.rpmTextPre": "Vous utilisez Filebeat pour la première fois ? Consultez le [guide de démarrage rapide]({linkUrl}).", "home.tutorials.common.filebeatInstructions.install.rpmTitle": "Télécharger et installer Filebeat", "home.tutorials.common.filebeatInstructions.install.windowsTextPost": "Modifiez les paramètres sous {propertyName} dans le fichier {filebeatPath} afin de pointer vers votre installation Elasticsearch.", - "home.tutorials.common.filebeatInstructions.install.windowsTextPre": "Vous utilisez Filebeat pour la première fois ? Consultez le [guide de démarrage rapide]({guideLinkUrl}).\n 1. Téléchargez le fichier .zip Filebeat pour Windows via la page [Télécharger]({filebeatLinkUrl}).\n 2. Extrayez le contenu du fichier compressé sous {folderPath}.\n 3. Renommez le répertoire `{directoryName}` en `Filebeat`.\n 4. Ouvrez une invite PowerShell en tant qu'administrateur (faites un clic droit sur l'icône PowerShell et sélectionnez **Exécuter en tant qu'administrateur**). Si vous exécutez Windows XP, vous devrez peut-être télécharger et installer PowerShell.\n 5. Dans l'invite PowerShell, exécutez les commandes suivantes afin d'installer Filebeat en tant que service Windows.", + "home.tutorials.common.filebeatInstructions.install.windowsTextPre": "Vous utilisez Filebeat pour la première fois ? Consultez le [guide de démarrage rapide]({guideLinkUrl}). 1. Téléchargez le fichier .zip Filebeat pour Windows via la page [Télécharger]({filebeatLinkUrl}). 2. Extrayez le contenu du fichier compressé sous {folderPath}. 3. Renommez le répertoire `{directoryName}` en `Filebeat`. 4. Ouvrez une invite PowerShell en tant qu'administrateur (faites un clic droit sur l'icône PowerShell et sélectionnez **Exécuter en tant qu'administrateur**). Si vous exécutez Windows XP, vous devrez peut-être télécharger et installer PowerShell. 5. Dans l'invite PowerShell, exécutez les commandes suivantes afin d'installer Filebeat en tant que service Windows.", "home.tutorials.common.filebeatInstructions.install.windowsTitle": "Télécharger et installer Filebeat", "home.tutorials.common.filebeatInstructions.start.debTextPre": "La commande `setup` charge les tableaux de bord Kibana. Si les tableaux de bord sont déjà configurés, omettez cette commande.", "home.tutorials.common.filebeatInstructions.start.debTitle": "Lancer Filebeat", @@ -3966,10 +4469,10 @@ "home.tutorials.common.functionbeatEnableOnPremInstructions.defaultTitle": "Configurer le groupe de logs Cloudwatch", "home.tutorials.common.functionbeatEnableOnPremInstructionsOSXLinux.textPre": "Modifiez les paramètres dans le fichier `functionbeat.yml`.", "home.tutorials.common.functionbeatEnableOnPremInstructionsWindows.textPre": "Modifiez les paramètres dans le fichier {path}.", - "home.tutorials.common.functionbeatInstructions.config.osxTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur `elastic`, {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer le SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte digitale dans {esCertFingerprintTemplate}.\n\n > **_Important :_** n'employez pas l'utilisateur `elastic` intégré pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", + "home.tutorials.common.functionbeatInstructions.config.osxTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur \"elastic\", {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte dans {esCertFingerprintTemplate}. > **_Important :_** N'utilisez pas l'utilisateur intégré `elastic` pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", "home.tutorials.common.functionbeatInstructions.config.osxTextPre": "Modifiez {path} afin de définir les informations de connexion :", "home.tutorials.common.functionbeatInstructions.config.osxTitle": "Configurer le cluster Elastic", - "home.tutorials.common.functionbeatInstructions.config.windowsTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur `elastic`, {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer le SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte digitale dans {esCertFingerprintTemplate}.\n\n > **_Important :_** n'employez pas l'utilisateur `elastic` intégré pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", + "home.tutorials.common.functionbeatInstructions.config.windowsTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur \"elastic\", {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte dans {esCertFingerprintTemplate}. > **_Important :_** N'utilisez pas l'utilisateur intégré `elastic` pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", "home.tutorials.common.functionbeatInstructions.config.windowsTextPre": "Modifiez {path} afin de définir les informations de connexion :", "home.tutorials.common.functionbeatInstructions.config.windowsTitle": "Modifier la configuration", "home.tutorials.common.functionbeatInstructions.deploy.osxTextPre": "Ceci permet d'installer Functionbeat en tant que fonction Lambda. La commande `setup` vérifie la configuration d'Elasticsearch et charge le modèle d'indexation Kibana. L'omission de cette commande est normalement sans risque.", @@ -3980,7 +4483,7 @@ "home.tutorials.common.functionbeatInstructions.install.linuxTitle": "Télécharger et installer Functionbeat", "home.tutorials.common.functionbeatInstructions.install.osxTextPre": "Vous utilisez Functionbeat pour la première fois ? Consultez le [guide de démarrage rapide]({link}).", "home.tutorials.common.functionbeatInstructions.install.osxTitle": "Télécharger et installer Functionbeat", - "home.tutorials.common.functionbeatInstructions.install.windowsTextPre": "Vous utilisez Functionbeat pour la première fois ? Consultez le [guide de démarrage rapide]({functionbeatLink}).\n 1. Téléchargez le fichier .zip Functionbeat pour Windows via la page [Télécharger]({elasticLink}).\n 2. Extrayez le contenu du fichier compressé sous {folderPath}.\n 3. Renommez le répertoire `{directoryName}` en `Functionbeat`.\n 4. Ouvrez une invite PowerShell en tant qu'administrateur (faites un clic droit sur l'icône PowerShell et sélectionnez **Exécuter en tant qu'administrateur**). Si vous exécutez Windows XP, vous devrez peut-être télécharger et installer PowerShell.\n 5. Depuis l'invite PowerShell, accédez au répertoire Functionbeat :", + "home.tutorials.common.functionbeatInstructions.install.windowsTextPre": "Vous utilisez Functionbeat pour la première fois ? Consultez le [guide de démarrage rapide]({functionbeatLink}). 1. Téléchargez le fichier .zip Functionbeat pour Windows via la page [Télécharger]({elasticLink}). 2. Extrayez le contenu du fichier compressé sous {folderPath}. 3. Renommez le répertoire `{directoryName}` en `Functionbeat`. 4. Ouvrez une invite PowerShell en tant qu'administrateur (faites un clic droit sur l'icône PowerShell et sélectionnez **Exécuter en tant qu'administrateur**). Si vous exécutez Windows XP, vous devrez peut-être télécharger et installer PowerShell. 5. Depuis l'invite PowerShell, accédez au répertoire Functionbeat :", "home.tutorials.common.functionbeatInstructions.install.windowsTitle": "Télécharger et installer Functionbeat", "home.tutorials.common.functionbeatStatusCheck.buttonLabel": "Vérifier les données", "home.tutorials.common.functionbeatStatusCheck.errorText": "Aucune donnée n'a encore été reçue de Functionbeat.", @@ -4010,16 +4513,16 @@ "home.tutorials.common.heartbeatEnableOnPremInstructions.osxTextPre": "Modifiez le paramètre `heartbeat.monitors` dans le fichier `heartbeat.yml`.", "home.tutorials.common.heartbeatEnableOnPremInstructions.rpmTextPre": "Modifiez le paramètre `heartbeat.monitors` dans le fichier `heartbeat.yml`.", "home.tutorials.common.heartbeatEnableOnPremInstructions.windowsTextPre": "Modifiez le paramètre `heartbeat.monitors` dans le fichier `heartbeat.yml`.", - "home.tutorials.common.heartbeatInstructions.config.debTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur `elastic`, {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer le SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte digitale dans {esCertFingerprintTemplate}.\n\n> **_Important :_** n'employez pas l'utilisateur `elastic` intégré pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", + "home.tutorials.common.heartbeatInstructions.config.debTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur \"elastic\", {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte dans {esCertFingerprintTemplate}. > **_Important :_** N'utilisez pas l'utilisateur intégré `elastic` pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", "home.tutorials.common.heartbeatInstructions.config.debTextPre": "Modifiez {path} afin de définir les informations de connexion :", "home.tutorials.common.heartbeatInstructions.config.debTitle": "Modifier la configuration", - "home.tutorials.common.heartbeatInstructions.config.osxTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur `elastic`, {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer le SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte digitale dans {esCertFingerprintTemplate}.\n\n> **_Important :_** n'employez pas l'utilisateur `elastic` intégré pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", + "home.tutorials.common.heartbeatInstructions.config.osxTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur \"elastic\", {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte dans {esCertFingerprintTemplate}. > **_Important :_** N'utilisez pas l'utilisateur intégré `elastic` pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", "home.tutorials.common.heartbeatInstructions.config.osxTextPre": "Modifiez {path} afin de définir les informations de connexion :", "home.tutorials.common.heartbeatInstructions.config.osxTitle": "Modifier la configuration", - "home.tutorials.common.heartbeatInstructions.config.rpmTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur `elastic`, {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer le SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte digitale dans {esCertFingerprintTemplate}.\n\n> **_Important :_** n'employez pas l'utilisateur `elastic` intégré pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", + "home.tutorials.common.heartbeatInstructions.config.rpmTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur \"elastic\", {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte dans {esCertFingerprintTemplate}. > **_Important :_** N'utilisez pas l'utilisateur intégré `elastic` pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", "home.tutorials.common.heartbeatInstructions.config.rpmTextPre": "Modifiez {path} afin de définir les informations de connexion :", "home.tutorials.common.heartbeatInstructions.config.rpmTitle": "Modifier la configuration", - "home.tutorials.common.heartbeatInstructions.config.windowsTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur `elastic`, {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer le SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte digitale dans {esCertFingerprintTemplate}.\n\n > **_Important :_** n'employez pas l'utilisateur `elastic` intégré pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", + "home.tutorials.common.heartbeatInstructions.config.windowsTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur \"elastic\", {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte dans {esCertFingerprintTemplate}. > **_Important :_** N'utilisez pas l'utilisateur intégré `elastic` pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", "home.tutorials.common.heartbeatInstructions.config.windowsTextPre": "Modifiez {path} afin de définir les informations de connexion :", "home.tutorials.common.heartbeatInstructions.config.windowsTitle": "Modifier la configuration", "home.tutorials.common.heartbeatInstructions.install.debTextPost": "Vous cherchez les packages 32 bits ? Consultez la [page de téléchargement]({link}).", @@ -4030,7 +4533,7 @@ "home.tutorials.common.heartbeatInstructions.install.rpmTextPost": "Vous cherchez les packages 32 bits ? Consultez la [page de téléchargement]({link}).", "home.tutorials.common.heartbeatInstructions.install.rpmTextPre": "Vous utilisez Heartbeat pour la première fois ? Consultez le [guide de démarrage rapide]({link}).", "home.tutorials.common.heartbeatInstructions.install.rpmTitle": "Télécharger et installer Heartbeat", - "home.tutorials.common.heartbeatInstructions.install.windowsTextPre": "Vous utilisez Heartbeat pour la première fois ? Consultez le [guide de démarrage rapide]({heartbeatLink}).\n 1. Téléchargez le fichier .zip Heartbeat pour Windows via la page [Télécharger]({elasticLink}).\n 2. Extrayez le contenu du fichier compressé sous {folderPath}.\n 3. Renommez le répertoire `{directoryName}` en `Heartbeat`.\n 4. Ouvrez une invite PowerShell en tant qu'administrateur (faites un clic droit sur l'icône PowerShell et sélectionnez **Exécuter en tant qu'administrateur**). Si vous exécutez Windows XP, vous devrez peut-être télécharger et installer PowerShell.\n 5. Dans l'invite PowerShell, exécutez les commandes suivantes afin d'installer Heartbeat en tant que service Windows.", + "home.tutorials.common.heartbeatInstructions.install.windowsTextPre": "Vous utilisez Heartbeat pour la première fois ? Consultez le [guide de démarrage rapide]({heartbeatLink}). 1. Téléchargez le fichier .zip Heartbeat pour Windows via la page [Télécharger]({elasticLink}). 2. Extrayez le contenu du fichier compressé sous {folderPath}. 3. Renommez le répertoire `{directoryName}` en `Heartbeat`. 4. Ouvrez une invite PowerShell en tant qu'administrateur (faites un clic droit sur l'icône PowerShell et sélectionnez **Exécuter en tant qu'administrateur**). Si vous exécutez Windows XP, vous devrez peut-être télécharger et installer PowerShell. 5. Dans l'invite PowerShell, exécutez les commandes suivantes afin d'installer Heartbeat en tant que service Windows.", "home.tutorials.common.heartbeatInstructions.install.windowsTitle": "Télécharger et installer Heartbeat", "home.tutorials.common.heartbeatInstructions.start.debTextPre": "La commande `setup` charge le modèle d'indexation Kibana.", "home.tutorials.common.heartbeatInstructions.start.debTitle": "Lancer Heartbeat", @@ -4049,9 +4552,9 @@ "home.tutorials.common.logstashInstructions.install.java.osxTitle": "Télécharger et installer l'environnement d'exécution Java", "home.tutorials.common.logstashInstructions.install.java.windowsTextPre": "Suivez les instructions d'installation [ici]({link}).", "home.tutorials.common.logstashInstructions.install.java.windowsTitle": "Télécharger et installer l'environnement d'exécution Java", - "home.tutorials.common.logstashInstructions.install.logstash.osxTextPre": "Vous utilisez Logstash pour la première fois ? Consultez le [guide de démarrage rapide]({link}).", + "home.tutorials.common.logstashInstructions.install.logstash.osxTextPre": "Vous utilisez Logstash pour la première fois ? Consultez le [guide de démarrage rapide]({link}).", "home.tutorials.common.logstashInstructions.install.logstash.osxTitle": "Télécharger et installer Logstash", - "home.tutorials.common.logstashInstructions.install.logstash.windowsTextPre": "Vous utilisez Logstash pour la première fois ? Consultez le [guide de démarrage rapide]({logstashLink}).\n 1. [Téléchargez]({elasticLink}) le fichier .zip Logstash pour Windows.\n 2. Extrayez le contenu du fichier compressé.", + "home.tutorials.common.logstashInstructions.install.logstash.windowsTextPre": "Vous utilisez Logstash pour la première fois ? Consultez le [guide de démarrage rapide]({logstashLink}). 1. [Téléchargez]({elasticLink}) le fichier .zip Logstash pour Windows. 2. Extrayez le contenu du fichier compressé.", "home.tutorials.common.logstashInstructions.install.logstash.windowsTitle": "Télécharger et installer Logstash", "home.tutorials.common.metricbeat.cloudInstructions.gettingStarted.title": "Commencer", "home.tutorials.common.metricbeat.premCloudInstructions.gettingStarted.title": "Commencer", @@ -4074,16 +4577,16 @@ "home.tutorials.common.metricbeatEnableInstructions.windowsTextPost": "Modifiez les paramètres dans le fichier `modules.d/{moduleName}.yml`.", "home.tutorials.common.metricbeatEnableInstructions.windowsTextPre": "Dans le dossier {path}, exécutez la commande suivante :", "home.tutorials.common.metricbeatEnableInstructions.windowsTitle": "Activer et configurer le module {moduleName}", - "home.tutorials.common.metricbeatInstructions.config.debTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur `elastic`, {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer le SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte digitale dans {esCertFingerprintTemplate}.\n\n > **_Important :_** n'employez pas l'utilisateur `elastic` intégré pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", + "home.tutorials.common.metricbeatInstructions.config.debTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur \"elastic\", {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte dans {esCertFingerprintTemplate}. > **_Important :_** N'utilisez pas l'utilisateur intégré `elastic` pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", "home.tutorials.common.metricbeatInstructions.config.debTextPre": "Modifiez {path} afin de définir les informations de connexion :", "home.tutorials.common.metricbeatInstructions.config.debTitle": "Modifier la configuration", - "home.tutorials.common.metricbeatInstructions.config.osxTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur `elastic`, {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer le SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte digitale dans {esCertFingerprintTemplate}.\n\n > **_Important :_** n'employez pas l'utilisateur `elastic` intégré pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", + "home.tutorials.common.metricbeatInstructions.config.osxTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur \"elastic\", {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte dans {esCertFingerprintTemplate}. > **_Important :_** N'utilisez pas l'utilisateur intégré `elastic` pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", "home.tutorials.common.metricbeatInstructions.config.osxTextPre": "Modifiez {path} afin de définir les informations de connexion :", "home.tutorials.common.metricbeatInstructions.config.osxTitle": "Modifier la configuration", - "home.tutorials.common.metricbeatInstructions.config.rpmTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur `elastic`, {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer le SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte digitale dans {esCertFingerprintTemplate}.\n\n > **_Important :_** n'employez pas l'utilisateur `elastic` intégré pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", + "home.tutorials.common.metricbeatInstructions.config.rpmTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur \"elastic\", {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte dans {esCertFingerprintTemplate}. > **_Important :_** N'utilisez pas l'utilisateur intégré `elastic` pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", "home.tutorials.common.metricbeatInstructions.config.rpmTextPre": "Modifiez {path} afin de définir les informations de connexion :", "home.tutorials.common.metricbeatInstructions.config.rpmTitle": "Modifier la configuration", - "home.tutorials.common.metricbeatInstructions.config.windowsTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur `elastic`, {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer le SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte digitale dans {esCertFingerprintTemplate}.\n\n > **_Important :_** n'employez pas l'utilisateur `elastic` intégré pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", + "home.tutorials.common.metricbeatInstructions.config.windowsTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur \"elastic\", {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte dans {esCertFingerprintTemplate}. > **_Important :_** N'utilisez pas l'utilisateur intégré `elastic` pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", "home.tutorials.common.metricbeatInstructions.config.windowsTextPre": "Modifiez {path} afin de définir les informations de connexion :", "home.tutorials.common.metricbeatInstructions.config.windowsTitle": "Modifier la configuration", "home.tutorials.common.metricbeatInstructions.install.debTextPost": "Vous cherchez les packages 32 bits ? Consultez la [page de téléchargement]({link}).", @@ -4095,7 +4598,7 @@ "home.tutorials.common.metricbeatInstructions.install.rpmTextPre": "Vous utilisez Metricbeat pour la première fois ? Consultez le [guide de démarrage rapide]({link}).", "home.tutorials.common.metricbeatInstructions.install.rpmTitle": "Télécharger et installer Metricbeat", "home.tutorials.common.metricbeatInstructions.install.windowsTextPost": "Modifiez les paramètres sous `output.elasticsearch` dans le fichier {path} afin de pointer vers votre installation Elasticsearch.", - "home.tutorials.common.metricbeatInstructions.install.windowsTextPre": "Vous utilisez Metricbeat pour la première fois ? Consultez le [guide de démarrage rapide]({metricbeatLink}).\n 1. Téléchargez le fichier .zip Metricbeat pour Windows via la page [Télécharger]({elasticLink}).\n 2. Extrayez le contenu du fichier compressé sous {folderPath}.\n 3. Renommez le répertoire `{directoryName}` en `Metricbeat`.\n 4. Ouvrez une invite PowerShell en tant qu'administrateur (faites un clic droit sur l'icône PowerShell et sélectionnez **Exécuter en tant qu'administrateur**). Si vous exécutez Windows XP, vous devrez peut-être télécharger et installer PowerShell.\n 5. Dans l'invite PowerShell, exécutez les commandes suivantes afin d'installer Metricbeat en tant que service Windows.", + "home.tutorials.common.metricbeatInstructions.install.windowsTextPre": "Vous utilisez Metricbeat pour la première fois ? Consultez le [guide de démarrage rapide]({metricbeatLink}). 1. Téléchargez le fichier .zip Metricbeat pour Windows via la page [Télécharger]({elasticLink}). 2. Extrayez le contenu du fichier compressé sous {folderPath}. 3. Renommez le répertoire `{directoryName}` en `Metricbeat`. 4. Ouvrez une invite PowerShell en tant qu'administrateur (faites un clic droit sur l'icône PowerShell et sélectionnez **Exécuter en tant qu'administrateur**). Si vous exécutez Windows XP, vous devrez peut-être télécharger et installer PowerShell. 5. Dans l'invite PowerShell, exécutez les commandes suivantes afin d'installer Metricbeat en tant que service Windows.", "home.tutorials.common.metricbeatInstructions.install.windowsTitle": "Télécharger et installer Metricbeat", "home.tutorials.common.metricbeatInstructions.start.debTextPre": "La commande `setup` charge les tableaux de bord Kibana. Si les tableaux de bord sont déjà configurés, omettez cette commande.", "home.tutorials.common.metricbeatInstructions.start.debTitle": "Lancer Metricbeat", @@ -4110,20 +4613,20 @@ "home.tutorials.common.metricbeatStatusCheck.successText": "Des données ont été reçues de ce module.", "home.tutorials.common.metricbeatStatusCheck.text": "Vérifier que des données sont reçues du module Metricbeat `{moduleName}`", "home.tutorials.common.metricbeatStatusCheck.title": "Statut du module", - "home.tutorials.common.premCloudInstructions.option1.textPre": "Rendez-vous sur [Elastic Cloud]({link}). Enregistrez-vous si vous n'avez pas encore de compte. Un essai gratuit de 14 jours est disponible.\n\nConnectez-vous à la console Elastic Cloud.\n\nPour créer un cluster, dans la console Elastic Cloud :\n 1. Sélectionnez **Créer un déploiement** et spécifiez le **Nom du déploiement**.\n 2. Modifiez les autres options de déploiement selon les besoins (sinon, les valeurs par défaut sont très bien pour commencer).\n 3. Cliquer sur **Créer un déploiement**\n 4. Attendre la fin de la création du déploiement\n 5. Accéder à la nouvelle instance cloud Kibana et suivre les instructions de la page d'accueil de Kibana", + "home.tutorials.common.premCloudInstructions.option1.textPre": "Rendez-vous sur [Elastic Cloud]({link}). Enregistrez-vous si vous n'avez pas encore de compte. Un essai gratuit de 14 jours est disponible. Connectez-vous à la console Elastic Cloud Pour créer un cluster, il suffit de : 1. Sélectionner **Créer un déploiement** et spécifier le **Nom du déploiement** 2. Modifier les autres options de déploiement selon les besoins (sinon, les valeurs par défaut sont très bien pour commencer) 3. Cliquer sur **Créer un déploiement** 4. Attendre la fin de la création du déploiement 5. Accéder à la nouvelle instance cloud Kibana et suivre les instructions de la page d'accueil de Kibana", "home.tutorials.common.premCloudInstructions.option1.title": "Option 1 : essayer dans Elastic Cloud", - "home.tutorials.common.premCloudInstructions.option2.textPre": "Si vous exécutez cette instance Kibana sur une instance Elasticsearch hébergée, passez à la configuration manuelle.\n\nEnregistrez le point de terminaison **Elasticsearch** en tant que {urlTemplate} et le cluster **Mot de passe** en tant que {passwordTemplate} pour les conserver.", + "home.tutorials.common.premCloudInstructions.option2.textPre": "Si vous exécutez cette instance Kibana sur une instance Elasticsearch hébergée, passez à la configuration manuelle. Enregistrez le point de terminaison **Elasticsearch** en tant que {urlTemplate} et le cluster **Mot de passe** en tant que {passwordTemplate} pour les conserver.", "home.tutorials.common.premCloudInstructions.option2.title": "Option 2 : connecter un Kibana local à une instance cloud", "home.tutorials.common.winlogbeat.cloudInstructions.gettingStarted.title": "Premiers pas", "home.tutorials.common.winlogbeat.premCloudInstructions.gettingStarted.title": "Commencer", "home.tutorials.common.winlogbeat.premInstructions.gettingStarted.title": "Commencer", "home.tutorials.common.winlogbeatCloudInstructions.config.windowsTextPre": "Modifiez {path} afin de définir les informations de connexion pour Elastic Cloud :", "home.tutorials.common.winlogbeatCloudInstructions.config.windowsTitle": "Modifier la configuration", - "home.tutorials.common.winlogbeatInstructions.config.windowsTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur `elastic`, {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer le SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte digitale dans {esCertFingerprintTemplate}.\n\n > **_Important :_** n'employez pas l'utilisateur `elastic` intégré pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", + "home.tutorials.common.winlogbeatInstructions.config.windowsTextPostMarkdown": "Où {passwordTemplate} est le mot de passe de l'utilisateur \"elastic\", {esUrlTemplate} est l'URL d'Elasticsearch et {kibanaUrlTemplate} est l'URL de Kibana. Pour [configurer SSL]({configureSslUrl}) avec le certificat par défaut généré par Elasticsearch, ajoutez son empreinte dans {esCertFingerprintTemplate}. > **_Important :_** N'utilisez pas l'utilisateur intégré `elastic` pour sécuriser les clients dans un environnement de production. À la place, configurez des utilisateurs autorisés ou des clés d'API, et n'exposez pas les mots de passe dans les fichiers de configuration. [Learn more]({linkUrl}).", "home.tutorials.common.winlogbeatInstructions.config.windowsTextPre": "Modifiez {path} afin de définir les informations de connexion :", "home.tutorials.common.winlogbeatInstructions.config.windowsTitle": "Modifier la configuration", "home.tutorials.common.winlogbeatInstructions.install.windowsTextPost": "Modifiez les paramètres sous `output.elasticsearch` dans le fichier {path} afin de pointer vers votre installation Elasticsearch.", - "home.tutorials.common.winlogbeatInstructions.install.windowsTextPre": "Vous utilisez Winlogbeat pour la première fois ? Consultez le [guide de démarrage rapide]({winlogbeatLink}).\n 1. Téléchargez le fichier .zip Winlogbeat pour Windows via la page [Télécharger]({elasticLink}).\n 2. Extrayez le contenu du fichier compressé sous {folderPath}.\n 3. Renommez le répertoire `{directoryName}` en `Winlogbeat`.\n 4. Ouvrez une invite PowerShell en tant qu'administrateur (faites un clic droit sur l'icône PowerShell et sélectionnez **Exécuter en tant qu'administrateur**). Si vous exécutez Windows XP, vous devrez peut-être télécharger et installer PowerShell.\n 5. Dans l'invite PowerShell, exécutez les commandes suivantes afin d'installer Winlogbeat en tant que service Windows.", + "home.tutorials.common.winlogbeatInstructions.install.windowsTextPre": "Vous utilisez Winlogbeat pour la première fois ? Consultez le [guide de démarrage rapide]({winlogbeatLink}). 1. Téléchargez le fichier .zip Winlogbeat pour Windows via la page [Télécharger]({elasticLink}). 2. Extrayez le contenu du fichier compressé sous {folderPath}. 3. Renommez le répertoire `{directoryName}` en `Winlogbeat`. 4. Ouvrez une invite PowerShell en tant qu'administrateur (faites un clic droit sur l'icône PowerShell et sélectionnez **Exécuter en tant qu'administrateur**). Si vous exécutez Windows XP, vous devrez peut-être télécharger et installer PowerShell. 5. Dans l'invite PowerShell, exécutez les commandes suivantes afin d'installer Winlogbeat en tant que service Windows.", "home.tutorials.common.winlogbeatInstructions.install.windowsTitle": "Télécharger et installer Winlogbeat", "home.tutorials.common.winlogbeatInstructions.start.windowsTextPre": "La commande `setup` charge les tableaux de bord Kibana. Si les tableaux de bord sont déjà configurés, omettez cette commande.", "home.tutorials.common.winlogbeatInstructions.start.windowsTitle": "Lancer Winlogbeat", @@ -4153,7 +4656,7 @@ "home.tutorials.couchdbMetrics.nameTitle": "Indicateurs CouchDB", "home.tutorials.couchdbMetrics.shortDescription": "Collectez les indicateurs à partir des serveurs CouchDB avec Metricbeat.", "home.tutorials.crowdstrikeLogs.artifacts.dashboards.linkLabel": "Application Security", - "home.tutorials.crowdstrikeLogs.longDescription": "Il s'agit du module Filebeat pour CrowdStrike Falcon utilisant le [connecteur SIEM](https://www.crowdstrike.com/blog/tech-center/integrate-with-your-siem) Falcon. Ce module collecte ces données, les convertit en ECS et les ingère pour les afficher dans le SIEM. Par défaut, le connecteur SIEM Falcon génère les données d'événement de l'API de streaming Falcon au format JSON. [En savoir plus]({learnMoreLink}).", + "home.tutorials.crowdstrikeLogs.longDescription": "Il s'agit du module Filebeat pour CrowdStrike Falcon utilisant le [connecteur SIEM](https://www.crowdstrike.com/blog/tech-center/integrate-with-your-siem) Falcon. Ce module collecte ces données, les convertit en ECS et les ingère pour les afficher dans le SIEM. Par défaut, le connecteur SIEM Falcon génère les données d'événement de l'API de streaming Falcon au format JSON. [En savoir plus]({learnMoreLink}).", "home.tutorials.crowdstrikeLogs.nameTitle": "Logs CrowdStrike", "home.tutorials.crowdstrikeLogs.shortDescription": "Collectez et analysez les logs à partir de CrowdStrike Falcon à l'aide du Falcon SIEM Connector avec Filebeat.", "home.tutorials.cylanceLogs.artifacts.dashboards.linkLabel": "Application Security", @@ -4352,7 +4855,7 @@ "home.tutorials.o365Logs.nameTitle": "Logs Office 365", "home.tutorials.o365Logs.shortDescription": "Collectez et analysez les logs à partir d'Office 365 avec Filebeat.", "home.tutorials.oktaLogs.artifacts.dashboards.linkLabel": "Aperçu d'Okta", - "home.tutorials.oktaLogs.longDescription": "Le module Okta collecte les événements de l'[API Okta](https://developer.okta.com/docs/reference/). Plus précisément, il prend en charge la lecture depuis l'[API de log système Okta](https://developer.okta.com/docs/reference/api/system-log/). [En savoir plus]({learnMoreLink}).", + "home.tutorials.oktaLogs.longDescription": "Le module Okta collecte les événements de l'[API Okta](https://developer.okta.com/docs/reference/). Plus précisément, il prend en charge la lecture depuis l'[API de log système Okta](https://developer.okta.com/docs/reference/api/system-log/). [En savoir plus]({learnMoreLink}).", "home.tutorials.oktaLogs.nameTitle": "Logs Okta", "home.tutorials.oktaLogs.shortDescription": "Collectez et analysez les logs à partir de l'API Okta avec Filebeat.", "home.tutorials.openmetricsMetrics.longDescription": "Le module Metricbeat `openmetrics` récupère des indicateurs depuis un point de terminaison fournissant des indicateurs au format OpenMetrics. [En savoir plus]({learnMoreLink}).", @@ -4363,7 +4866,7 @@ "home.tutorials.oracleMetrics.nameTitle": "Indicateurs Oracle", "home.tutorials.oracleMetrics.shortDescription": "Collectez les indicateurs à partir de serveurs Oracle avec Metricbeat.", "home.tutorials.osqueryLogs.artifacts.dashboards.linkLabel": "Pack de conformité osquery", - "home.tutorials.osqueryLogs.longDescription": "Le module collecte et décode les logs de résultats écrits par [osqueryd](https://osquery.readthedocs.io/en/latest/introduction/using-osqueryd/) au format JSON. Pour configurer `osqueryd`, suivez les instructions d'installation d'osquery pour votre système d'exploitation et configurez le pilote de logging `filesystem` (celui par défaut). Assurez-vous que les horodatages UTC sont activés. [En savoir plus]({learnMoreLink}).", + "home.tutorials.osqueryLogs.longDescription": "Le module collecte et décode les logs de résultats écrits par [osqueryd](https://osquery.readthedocs.io/en/latest/introduction/using-osqueryd/) au format JSON. Pour configurer `osqueryd`, suivez les instructions d'installation d'osquery pour votre système d'exploitation et configurez le pilote de logging `filesystem` (celui par défaut). Assurez-vous que les horodatages UTC sont activés. [En savoir plus]({learnMoreLink}).", "home.tutorials.osqueryLogs.nameTitle": "Logs osquery", "home.tutorials.osqueryLogs.shortDescription": "Collectez et analysez les logs à partir d'Osquery avec Filebeat.", "home.tutorials.panwLogs.artifacts.dashboards.linkLabel": "Flux de réseau PANW", @@ -4427,7 +4930,7 @@ "home.tutorials.statsdMetrics.nameTitle": "Indicateurs statsd", "home.tutorials.statsdMetrics.shortDescription": "Collectez les indicateurs à partir de serveurs Statsd avec Metricbeat.", "home.tutorials.suricataLogs.artifacts.dashboards.linkLabel": "Aperçu des événements Suricata", - "home.tutorials.suricataLogs.longDescription": "Il s'agit d'un module pour le log IDS/IPS/NSM Suricata. Il analyse les logs qui sont au [format JSON Suricata Eve](https://suricata.readthedocs.io/en/latest/output/eve/eve-json-format.html). [En savoir plus]({learnMoreLink}).", + "home.tutorials.suricataLogs.longDescription": "Il s'agit d'un module pour le log IDS/IPS/NSM Suricata. Il analyse les logs qui sont au [format JSON Suricata Eve](https://suricata.readthedocs.io/en/latest/output/eve/eve-json-format.html). [En savoir plus]({learnMoreLink}).", "home.tutorials.suricataLogs.nameTitle": "Logs Suricata", "home.tutorials.suricataLogs.shortDescription": "Collectez et analysez les logs à partir de Suricata IDS/IPS/NSM avec Filebeat.", "home.tutorials.systemLogs.artifacts.dashboards.linkLabel": "Tableau de bord Syslog système", @@ -4450,7 +4953,7 @@ "home.tutorials.traefikMetrics.nameTitle": "Indicateurs Traefik", "home.tutorials.traefikMetrics.shortDescription": "Collectez les indicateurs à partir de Traefik avec Metricbeat.", "home.tutorials.uptimeMonitors.artifacts.dashboards.linkLabel": "Application Uptime", - "home.tutorials.uptimeMonitors.longDescription": "Monitorez la disponibilité des services grâce à une détection active. À partir d'une liste d'URL, Heartbeat pose cette question toute simple : Êtes-vous actif ? [En savoir plus]({learnMoreLink}).", + "home.tutorials.uptimeMonitors.longDescription": "Monitorez la disponibilité des services grâce à une détection active. À partir d'une liste d'URL, Heartbeat pose cette question toute simple : Êtes-vous actif ? [En savoir plus]({learnMoreLink}).", "home.tutorials.uptimeMonitors.nameTitle": "Monitorings Uptime", "home.tutorials.uptimeMonitors.shortDescription": "Surveillez la disponibilité des services avec Heartbeat.", "home.tutorials.uwsgiMetrics.artifacts.dashboards.linkLabel": "Tableau de bord des indicateurs uWSGI", @@ -4470,7 +4973,7 @@ "home.tutorials.windowsMetrics.nameTitle": "Indicateurs Windows", "home.tutorials.windowsMetrics.shortDescription": "Collectez les indicateurs à partir de Windows avec Metricbeat.", "home.tutorials.zeekLogs.artifacts.dashboards.linkLabel": "Aperçu de Zeek", - "home.tutorials.zeekLogs.longDescription": "Il s'agit d'un module pour Zeek, anciennement appelé Bro. Il analyse les logs qui sont au [format JSON Zeek](https://www.zeek.org/manual/release/logs/index.html). [En savoir plus]({learnMoreLink}).", + "home.tutorials.zeekLogs.longDescription": "Il s'agit d'un module pour Zeek, anciennement appelé Bro. Il analyse les logs qui sont au [format JSON Zeek](https://www.zeek.org/manual/release/logs/index.html). [En savoir plus]({learnMoreLink}).", "home.tutorials.zeekLogs.nameTitle": "Logs Zeek", "home.tutorials.zeekLogs.shortDescription": "Collectez et analysez les logs à partir de la sécurité réseau Zeek avec Filebeat.", "home.tutorials.zookeeperMetrics.artifacts.application.label": "Discover", @@ -4580,6 +5083,9 @@ "indexPatternEditor.rollup.uncaughtError": "Erreur de vue de données de cumul : {error}", "indexPatternEditor.rollupDataView.createIndex.noMatchError": "Erreur de vue de données de cumul : doit correspondre à un index de cumul", "indexPatternEditor.rollupDataView.createIndex.tooManyMatchesError": "Erreur de vue de données de cumul : ne peut correspondre qu’à un index de cumul", + "indexPatternEditor.rollupDataView.deprecationWarning.downsamplingLink": "Sous-échantillonnage", + "indexPatternEditor.rollupDataView.deprecationWarning.textParagraphOne": "Les cumuls sont obsolètes et seront supprimés dans une version ultérieure. {downsamplingLink} peut être utilisé comme solution de rechange.", + "indexPatternEditor.rollupIndexPattern.deprecationWarning.title": "Déclassé dans la version 8.11.0", "indexPatternEditor.saved": "Enregistré", "indexPatternEditor.status.matchAnyLabel.matchAnyDetail": "Votre modèle d'indexation peut correspondre à {sourceCount, plural, one {# source} other {# sources} }.", "indexPatternEditor.status.noSystemIndicesLabel": "Aucun flux de données, index ni alias d'index ne correspond à votre modèle d'indexation.", @@ -4670,7 +5176,7 @@ "indexPatternFieldEditor.editor.form.formatTitle": "Définir le format", "indexPatternFieldEditor.editor.form.nameAriaLabel": "Champ Nom", "indexPatternFieldEditor.editor.form.nameLabel": "Nom", - "indexPatternFieldEditor.editor.form.popularityDescription": "Définissez la popularité pour que le champ apparaisse plus haut ou plus bas dans la liste des champs. Par défaut, Discover classe les champs du plus souvent sélectionné au moins souvent sélectionné.", + "indexPatternFieldEditor.editor.form.popularityDescription": "Définissez la popularité pour que le champ apparaisse plus haut ou plus bas dans la liste des champs. Par défaut, Discover classe les champs du plus souvent sélectionné au moins souvent sélectionné.", "indexPatternFieldEditor.editor.form.popularityLabel": "Popularité", "indexPatternFieldEditor.editor.form.popularityTitle": "Définir la popularité", "indexPatternFieldEditor.editor.form.runtimeType.placeholderLabel": "Sélectionner un type", @@ -5074,7 +5580,7 @@ "inspector.requests.clustersTabLabel": "Clusters et partitions", "inspector.requests.copyToClipboardLabel": "Copier dans le presse-papiers", "inspector.requests.descriptionRowIconAriaLabel": "Description", - "inspector.requests.failedLabel": " (échec)", + "inspector.requests.failedLabel": "(échec)", "inspector.requests.noRequestsLoggedDescription.elementHasNotLoggedAnyRequestsText": "L'élément n'a pas (encore) consigné de requêtes.", "inspector.requests.noRequestsLoggedDescription.whatDoesItUsuallyMeanText": "Cela signifie généralement qu'il n'était pas nécessaire de récupérer des données ou que l'élément n'a pas encore commencé à récupérer des données.", "inspector.requests.noRequestsLoggedTitle": "Aucune requête consignée", @@ -5162,7 +5668,12 @@ "interactiveSetup.verificationCodeForm.submitButton": "{isSubmitting, select, true{Vérification…} other{Vérifier}}", "interactiveSetup.verificationCodeForm.submitErrorTitle": "Vérification du code impossible", "interactiveSetup.verificationCodeForm.title": "Vérification requise", + "kbn-esql-validation-autocomplete.esql.autocomplete.addDateHistogram": "Ajouter un histogramme des dates", + "kbn-esql-validation-autocomplete.esql.autocomplete.addDateHistogramDetail": "Ajouter un histogramme des dates en utilisant bucket()", + "kbn-esql-validation-autocomplete.esql.autocomplete.allStarConstantDoc": "Tous (*)", "kbn-esql-validation-autocomplete.esql.autocomplete.aPatternString": "Une chaîne modèle", + "kbn-esql-validation-autocomplete.esql.autocomplete.chooseFromTimePicker": "Cliquez pour choisir", + "kbn-esql-validation-autocomplete.esql.autocomplete.chooseFromTimePickerLabel": "Choisissez parmi le sélecteur de période", "kbn-esql-validation-autocomplete.esql.autocomplete.colonDoc": "Deux points (:)", "kbn-esql-validation-autocomplete.esql.autocomplete.commaDoc": "Virgule (,)", "kbn-esql-validation-autocomplete.esql.autocomplete.constantDefinition": "Constant", @@ -5173,11 +5684,15 @@ "kbn-esql-validation-autocomplete.esql.autocomplete.integrationDefinition": "Intégration", "kbn-esql-validation-autocomplete.esql.autocomplete.listDoc": "Liste d'éléments (…)", "kbn-esql-validation-autocomplete.esql.autocomplete.matchingFieldDefinition": "Utiliser pour correspondance avec {matchingField} de la politique", + "kbn-esql-validation-autocomplete.esql.autocomplete.namedParamDefinition": "Paramètre nommé", "kbn-esql-validation-autocomplete.esql.autocomplete.newVarDoc": "Définir une nouvelle variable", "kbn-esql-validation-autocomplete.esql.autocomplete.noPoliciesLabel": "Pas de stratégie disponible", "kbn-esql-validation-autocomplete.esql.autocomplete.noPoliciesLabelsFound": "Cliquez pour créer", "kbn-esql-validation-autocomplete.esql.autocomplete.pipeDoc": "Barre verticale (|)", "kbn-esql-validation-autocomplete.esql.autocomplete.semiColonDoc": "Point-virgule (;)", + "kbn-esql-validation-autocomplete.esql.autocomplete.sourceDefinition": "{type}", + "kbn-esql-validation-autocomplete.esql.autocomplete.timeSystemParamEnd": "L'heure de fin à partir du sélecteur de date", + "kbn-esql-validation-autocomplete.esql.autocomplete.timeSystemParamStart": "L'heure de début à partir du sélecteur de date", "kbn-esql-validation-autocomplete.esql.autocomplete.valueDefinition": "Valeur littérale", "kbn-esql-validation-autocomplete.esql.autocomplete.variableDefinition": "Variable spécifiée par l'utilisateur dans la requête ES|QL", "kbn-esql-validation-autocomplete.esql.definition.addDoc": "Ajouter (+)", @@ -5188,7 +5703,7 @@ "kbn-esql-validation-autocomplete.esql.definition.greaterThanDoc": "Supérieur à", "kbn-esql-validation-autocomplete.esql.definition.greaterThanOrEqualToDoc": "Supérieur ou égal à", "kbn-esql-validation-autocomplete.esql.definition.inDoc": "Teste si la valeur d'une expression est contenue dans une liste d'autres expressions", - "kbn-esql-validation-autocomplete.esql.definition.infoDoc": "Afficher des informations sur le nœud ES actuel", + "kbn-esql-validation-autocomplete.esql.definition.infoDoc": "Spécifier les modificateurs de tri des colonnes", "kbn-esql-validation-autocomplete.esql.definition.isNotNullDoc": "Prédicat pour la comparaison NULL : renvoie \"true\" si la valeur n'est pas NULL", "kbn-esql-validation-autocomplete.esql.definition.isNullDoc": "Prédicat pour la comparaison NULL : renvoie \"true\" si la valeur est NULL", "kbn-esql-validation-autocomplete.esql.definition.lessThanDoc": "Inférieur à", @@ -5205,13 +5720,15 @@ "kbn-esql-validation-autocomplete.esql.definitions.acos": "Renvoie l'arc cosinus de `n` sous forme d'angle, exprimé en radians.", "kbn-esql-validation-autocomplete.esql.definitions.appendSeparatorDoc": "Le ou les caractères qui séparent les champs ajoutés. A pour valeur par défaut une chaîne vide (\"\").", "kbn-esql-validation-autocomplete.esql.definitions.asDoc": "En tant que", - "kbn-esql-validation-autocomplete.esql.definitions.asin": "Renvoie l'arc sinus de l'entrée\nexpression numérique sous forme d'angle, exprimée en radians.", - "kbn-esql-validation-autocomplete.esql.definitions.atan": "Renvoie l'arc tangente de l'entrée\nexpression numérique sous forme d'angle, exprimée en radians.", - "kbn-esql-validation-autocomplete.esql.definitions.atan2": "L'angle entre l'axe positif des x et le rayon allant de\nl'origine au point (x , y) dans le plan cartésien, exprimée en radians.", + "kbn-esql-validation-autocomplete.esql.definitions.asin": "Renvoie l'arc sinus de l'expression numérique sous forme d'angle, exprimé en radians.", + "kbn-esql-validation-autocomplete.esql.definitions.atan": "Renvoie l'arc tangente de l'expression numérique sous forme d'angle, exprimé en radians.", + "kbn-esql-validation-autocomplete.esql.definitions.atan2": "L'angle entre l'axe positif des x et le rayon allant de l'origine au point (x , y) dans le plan cartésien, exprimé en radians.", "kbn-esql-validation-autocomplete.esql.definitions.autoBucketDoc": "Groupement automatique des dates en fonction d'une plage et d'un compartiment cible donnés.", + "kbn-esql-validation-autocomplete.esql.definitions.avg": "La moyenne d'un champ numérique.", "kbn-esql-validation-autocomplete.esql.definitions.byDoc": "Par", "kbn-esql-validation-autocomplete.esql.definitions.case": "Accepte les paires de conditions et de valeurs. La fonction renvoie la valeur correspondant à la première condition évaluée à `true` (vraie). Si le nombre d'arguments est impair, le dernier argument est la valeur par défaut qui est renvoyée si aucune condition ne correspond.", - "kbn-esql-validation-autocomplete.esql.definitions.cbrt": "Renvoie la racine cubique d'un nombre. La valeur de renvoi est toujours un double, quelle que soit la valeur numérique de l'entrée.\nLa racine cubique de l’infini est nulle.", + "kbn-esql-validation-autocomplete.esql.definitions.categorize": "Catégorise les messages texte.", + "kbn-esql-validation-autocomplete.esql.definitions.cbrt": "Renvoie la racine cubique d'un nombre. La valeur de renvoi est toujours un double, quelle que soit la valeur numérique de l'entrée. La racine cubique de l’infini est nulle.", "kbn-esql-validation-autocomplete.esql.definitions.ccqAnyDoc": "L'enrichissement a lieu sur n'importe quel cluster", "kbn-esql-validation-autocomplete.esql.definitions.ccqCoordinatorDoc": "L'enrichissement a lieu sur le cluster de coordination qui reçoit une requête ES|QL", "kbn-esql-validation-autocomplete.esql.definitions.ccqModeDoc": "Mode de requête inter-clusters", @@ -5221,8 +5738,10 @@ "kbn-esql-validation-autocomplete.esql.definitions.coalesce": "Renvoie le premier de ses arguments qui n'est pas nul. Si tous les arguments sont nuls, `null` est renvoyé.", "kbn-esql-validation-autocomplete.esql.definitions.concat": "Concatène deux ou plusieurs chaînes.", "kbn-esql-validation-autocomplete.esql.definitions.cos": "Renvoie le cosinus d'un angle.", - "kbn-esql-validation-autocomplete.esql.definitions.cosh": "Renvoie le cosinus hyperbolique d'un angle.", - "kbn-esql-validation-autocomplete.esql.definitions.date_diff": "Soustrait le `startTimestamp` du `endTimestamp` et renvoie la différence en multiples d'`unité`.\nSi `startTimestamp` est postérieur à `endTimestamp`, des valeurs négatives sont renvoyées.", + "kbn-esql-validation-autocomplete.esql.definitions.cosh": "Renvoie le cosinus hyperbolique d'un nombre.", + "kbn-esql-validation-autocomplete.esql.definitions.count": "Renvoie le nombre total de valeurs en entrée.", + "kbn-esql-validation-autocomplete.esql.definitions.count_distinct": "Renvoie le nombre approximatif de valeurs distinctes.", + "kbn-esql-validation-autocomplete.esql.definitions.date_diff": "Soustrait le `startTimestamp` du `endTimestamp` et renvoie la différence en multiples d'`unité`. Si `startTimestamp` est postérieur à `endTimestamp`, des valeurs négatives sont renvoyées.", "kbn-esql-validation-autocomplete.esql.definitions.date_extract": "Extrait des parties d'une date, telles que l'année, le mois, le jour, l'heure.", "kbn-esql-validation-autocomplete.esql.definitions.date_format": "Renvoie une représentation sous forme de chaîne d'une date dans le format fourni.", "kbn-esql-validation-autocomplete.esql.definitions.date_parse": "Renvoie une date en analysant le deuxième argument selon le format spécifié dans le premier argument.", @@ -5251,34 +5770,44 @@ "kbn-esql-validation-autocomplete.esql.definitions.ends_with": "Renvoie une valeur booléenne qui indique si une chaîne de mots-clés se termine par une autre chaîne.", "kbn-esql-validation-autocomplete.esql.definitions.enrichDoc": "Enrichissez le tableau à l'aide d'un autre tableau. Avant de pouvoir utiliser l'enrichissement, vous devez créer et exécuter une politique d'enrichissement.", "kbn-esql-validation-autocomplete.esql.definitions.evalDoc": "Calcule une expression et place la valeur résultante dans un champ de résultats de recherche.", + "kbn-esql-validation-autocomplete.esql.definitions.exp": "Renvoie la valeur de \"e\" élevée à la puissance d'un nombre donné.", "kbn-esql-validation-autocomplete.esql.definitions.floor": "Arrondir un nombre à l'entier inférieur.", "kbn-esql-validation-autocomplete.esql.definitions.from_base64": "Décodez une chaîne base64.", "kbn-esql-validation-autocomplete.esql.definitions.fromDoc": "Récupère les données à partir d'un ou plusieurs flux de données, index ou alias. Dans une requête ou une sous-requête, vous devez utiliser d'abord la commande from, et cette dernière ne nécessite pas de barre verticale au début. Par exemple, pour récupérer des données d'un index :", - "kbn-esql-validation-autocomplete.esql.definitions.greatest": "Renvoie la valeur maximale de plusieurs colonnes. Similaire à `MV_MAX`\nsauf que ceci est destiné à une exécution sur plusieurs colonnes à la fois.", + "kbn-esql-validation-autocomplete.esql.definitions.greatest": "Renvoie la valeur maximale de plusieurs colonnes. Cette fonction est similaire à `MV_MAX`. Toutefois, elle est destinée à être exécutée sur plusieurs colonnes à la fois.", "kbn-esql-validation-autocomplete.esql.definitions.grokDoc": "Extrait de multiples valeurs de chaîne à partir d'une entrée de chaîne unique, suivant un modèle", + "kbn-esql-validation-autocomplete.esql.definitions.hypot": "Renvoie l'hypoténuse de deux nombres. La valeur de renvoi est toujours un double, quelle que soit la valeur numérique de l'entrée. Les hypoténuses des infinis sont nulles.", + "kbn-esql-validation-autocomplete.esql.definitions.inlineStatsDoc": "Calcule un résultat agrégé et fusionne ce résultat dans le flux de données d'entrée. Sans la clause facultative `BY`, cela produira un résultat unique qui sera ajouté à chaque ligne. Avec une clause `BY`, cela produira un résultat par regroupement et fusionnera le résultat dans le flux en fonction des clés de groupe correspondantes.", "kbn-esql-validation-autocomplete.esql.definitions.ip_prefix": "Tronque une adresse IP à une longueur de préfixe donnée.", "kbn-esql-validation-autocomplete.esql.definitions.keepDoc": "Réarrange les champs dans le tableau d'entrée en appliquant les clauses \"KEEP\" dans les champs", "kbn-esql-validation-autocomplete.esql.definitions.least": "Renvoie la valeur minimale de plusieurs colonnes. Cette fonction est similaire à `MV_MIN`. Toutefois, elle est destinée à être exécutée sur plusieurs colonnes à la fois.", "kbn-esql-validation-autocomplete.esql.definitions.left": "Renvoie la sous-chaîne qui extrait la 'longueur' des caractères de la 'chaîne' en partant de la gauche.", "kbn-esql-validation-autocomplete.esql.definitions.length": "Renvoie la longueur des caractères d'une chaîne.", "kbn-esql-validation-autocomplete.esql.definitions.limitDoc": "Renvoie les premiers résultats de recherche, dans l'ordre de recherche, en fonction de la \"limite\" spécifiée.", - "kbn-esql-validation-autocomplete.esql.definitions.locate": "Renvoie un entier qui indique la position d'une sous-chaîne de mots-clés dans une autre chaîne", - "kbn-esql-validation-autocomplete.esql.definitions.log": "Renvoie le logarithme d'une valeur dans une base. La valeur de renvoi est toujours un double, quelle que soit la valeur numérique de l'entrée.\n\nLes journaux de zéros, de nombres négatifs et de base 1 renvoient `null` ainsi qu'un avertissement.", - "kbn-esql-validation-autocomplete.esql.definitions.log10": "Renvoie le logarithme d'une valeur en base 10. La valeur de renvoi est toujours un double, quelle que soit la valeur numérique de l'entrée.\n\nLes logs de 0 et de nombres négatifs renvoient `null` ainsi qu'un avertissement.", + "kbn-esql-validation-autocomplete.esql.definitions.locate": "Renvoie un entier qui indique la position d'une sous-chaîne de mots-clés dans une autre chaîne. Renvoie `0` si la sous-chaîne ne peut pas être trouvée. Notez que les positions des chaînes commencent à partir de `1`.", + "kbn-esql-validation-autocomplete.esql.definitions.log": "Renvoie le logarithme d'une valeur dans une base. La valeur de renvoi est toujours un double, quelle que soit la valeur numérique de l'entrée. Les journaux de zéros, de nombres négatifs et de base 1 renvoient `null` ainsi qu'un avertissement.", + "kbn-esql-validation-autocomplete.esql.definitions.log10": "Renvoie le logarithme d'une valeur en base 10. La valeur de renvoi est toujours un double, quelle que soit la valeur numérique de l'entrée. Les logs de 0 et de nombres négatifs renvoient `null` ainsi qu'un avertissement.", "kbn-esql-validation-autocomplete.esql.definitions.ltrim": "Retire les espaces au début des chaînes.", + "kbn-esql-validation-autocomplete.esql.definitions.max": "La valeur maximale d'un champ.", + "kbn-esql-validation-autocomplete.esql.definitions.median": "La valeur qui est supérieure à la moitié de toutes les valeurs et inférieure à la moitié de toutes les valeurs, également connue sous le nom de `PERCENTILE` 50.", + "kbn-esql-validation-autocomplete.esql.definitions.median_absolute_deviation": "Renvoie l'écart absolu médian, une mesure de la variabilité. Il s'agit d'un indicateur robuste, ce qui signifie qu'il est utile pour décrire des données qui peuvent présenter des valeurs aberrantes ou ne pas être normalement distribuées. Pour de telles données, il peut être plus descriptif que l'écart-type. Il est calculé comme la médiane de chaque écart de point de données par rapport à la médiane de l'ensemble de l'échantillon. Autrement dit, pour une variable aléatoire `X`, l'écart absolu médian est `median(|median(X) - X|)`.", "kbn-esql-validation-autocomplete.esql.definitions.metadataDoc": "Métadonnées", "kbn-esql-validation-autocomplete.esql.definitions.metricsDoc": "Une commande source spécifique aux indicateurs, utilisez-la pour charger des données à partir des index de TSDB. Similaire à la commande STATS : calcule les statistiques agrégées, telles que la moyenne, le décompte et la somme, sur l'ensemble des résultats de recherche entrants. Si elle est utilisée sans clause BY, une seule ligne est renvoyée, qui est l'agrégation de tout l'ensemble des résultats de recherche entrants. Lorsque vous utilisez une clause BY, une ligne est renvoyée pour chaque valeur distincte dans le champ spécifié dans la clause BY. La commande renvoie uniquement les champs dans l'agrégation, et vous pouvez utiliser un large éventail de fonctions statistiques avec la commande stats. Lorsque vous effectuez plusieurs agrégations, séparez chacune d'entre elle par une virgule.", + "kbn-esql-validation-autocomplete.esql.definitions.min": "La valeur minimale d'un champ.", "kbn-esql-validation-autocomplete.esql.definitions.mv_append": "Concatène les valeurs de deux champs à valeurs multiples.", "kbn-esql-validation-autocomplete.esql.definitions.mv_avg": "Convertit un champ multivalué en un champ à valeur unique comprenant la moyenne de toutes les valeurs.", "kbn-esql-validation-autocomplete.esql.definitions.mv_concat": "Convertit une expression de type chaîne multivalué en une colonne à valeur unique comprenant la concaténation de toutes les valeurs, séparées par un délimiteur.", "kbn-esql-validation-autocomplete.esql.definitions.mv_count": "Convertit une expression multivaluée en une colonne à valeur unique comprenant le total du nombre de valeurs.", "kbn-esql-validation-autocomplete.esql.definitions.mv_dedupe": "Supprime les valeurs en doublon d'un champ multivalué.", - "kbn-esql-validation-autocomplete.esql.definitions.mv_first": "Convertit une expression multivaluée en une colonne à valeur unique comprenant la\npremière valeur. Ceci est particulièrement utile pour lire une fonction qui émet\ndes colonnes multivaluées dans un ordre connu, comme `SPLIT`.\n\nL'ordre dans lequel les champs multivalués sont lus à partir\ndu stockage sous-jacent n'est pas garanti. Il est *souvent* ascendant, mais ne vous y\nfiez pas. Si vous avez besoin de la valeur minimale, utilisez `MV_MIN` au lieu de\n`MV_FIRST`. `MV_MIN` comporte des optimisations pour les valeurs triées, il n'y a donc aucun\navantage en matière de performances pour `MV_FIRST`.", - "kbn-esql-validation-autocomplete.esql.definitions.mv_last": "Convertit une expression multivaluée en une colonne à valeur unique comprenant la dernière\nvaleur. Ceci est particulièrement utile pour lire une fonction qui émet des champs multivalués\ndans un ordre connu, comme `SPLIT`.\n\nL'ordre dans lequel les champs multivalués sont lus à partir\ndu stockage sous-jacent n'est pas garanti. Il est *souvent* ascendant, mais ne vous y\nfiez pas. Si vous avez besoin de la valeur maximale, utilisez `MV_MAX` au lieu de\n`MV_LAST`. `MV_MAX` comporte des optimisations pour les valeurs triées, il n'y a donc aucun\navantage en matière de performances pour `MV_LAST`.", + "kbn-esql-validation-autocomplete.esql.definitions.mv_first": "Convertit une expression multivaluée en une colonne à valeur unique comprenant la première valeur. Ceci est particulièrement utile pour lire une fonction qui émet des colonnes multivaluées dans un ordre connu, comme `SPLIT`.", + "kbn-esql-validation-autocomplete.esql.definitions.mv_last": "Convertit une expression multivaluée en une colonne à valeur unique comprenant la dernière valeur. Ceci est particulièrement utile pour lire une fonction qui émet des colonnes multivaluées dans un ordre connu, comme `SPLIT`.", "kbn-esql-validation-autocomplete.esql.definitions.mv_max": "Convertit une expression multivaluée en une colonne à valeur unique comprenant la valeur maximale.", "kbn-esql-validation-autocomplete.esql.definitions.mv_median": "Convertit un champ multivalué en un champ à valeur unique comprenant la valeur médiane.", + "kbn-esql-validation-autocomplete.esql.definitions.mv_median_absolute_deviation": "Convertit un champ multivalué en un champ à valeur unique comprenant l'écart absolu médian. Il est calculé comme la médiane de chaque écart de point de données par rapport à la médiane de l'ensemble de l'échantillon. Autrement dit, pour une variable aléatoire `X`, l'écart absolu médian est `median(|median(X) - X|)`.", "kbn-esql-validation-autocomplete.esql.definitions.mv_min": "Convertit une expression multivaluée en une colonne à valeur unique comprenant la valeur minimale.", - "kbn-esql-validation-autocomplete.esql.definitions.mv_slice": "Renvoie un sous-ensemble du champ multivalué en utilisant les valeurs d'index de début et de fin.", + "kbn-esql-validation-autocomplete.esql.definitions.mv_percentile": "Convertit un champ multivalué en un champ à valeur unique comprenant la valeur à laquelle un certain pourcentage des valeurs observées se produit.", + "kbn-esql-validation-autocomplete.esql.definitions.mv_pseries_weighted_sum": "Convertit une expression multivaluée en une colonne à valeur unique en multipliant chaque élément de la liste d'entrée par le terme correspondant dans P-Series et en calculant la somme.", + "kbn-esql-validation-autocomplete.esql.definitions.mv_slice": "Renvoie un sous-ensemble du champ multivalué en utilisant les valeurs d'index de début et de fin. Ceci est particulièrement utile pour lire une fonction qui émet des colonnes multivaluées dans un ordre connu, comme `SPLIT` ou `MV_SORT`.", "kbn-esql-validation-autocomplete.esql.definitions.mv_sort": "Trie une expression multivaluée par ordre lexicographique.", "kbn-esql-validation-autocomplete.esql.definitions.mv_sum": "Convertit un champ multivalué en un champ à valeur unique comprenant la somme de toutes les valeurs.", "kbn-esql-validation-autocomplete.esql.definitions.mv_zip": "Combine les valeurs de deux champs multivalués avec un délimiteur qui les relie.", @@ -5287,53 +5816,63 @@ "kbn-esql-validation-autocomplete.esql.definitions.onDoc": "Activé", "kbn-esql-validation-autocomplete.esql.definitions.pi": "Renvoie Pi, le rapport entre la circonférence et le diamètre d'un cercle.", "kbn-esql-validation-autocomplete.esql.definitions.pow": "Renvoie la valeur d’une `base` élevée à la puissance d’un `exposant`.", + "kbn-esql-validation-autocomplete.esql.definitions.qstr": "Exécute une requête de chaîne de requête. Renvoie true si la chaîne de requête fournie correspond à la ligne.", "kbn-esql-validation-autocomplete.esql.definitions.renameDoc": "Attribue un nouveau nom à une ancienne colonne", "kbn-esql-validation-autocomplete.esql.definitions.repeat": "Renvoie une chaîne construite par la concaténation de la `chaîne` avec elle-même, le `nombre` de fois spécifié.", - "kbn-esql-validation-autocomplete.esql.definitions.replace": "La fonction remplace dans la chaîne `str` toutes les correspondances avec l'expression régulière `regex`\npar la chaîne de remplacement `newStr`.", - "kbn-esql-validation-autocomplete.esql.definitions.right": "Renvoie la sous-chaîne qui extrait la 'longueur' des caractères de 'str' en partant de la droite.", - "kbn-esql-validation-autocomplete.esql.definitions.round": "Arrondit un nombre au nombre spécifié de décimales.\nLa valeur par défaut est 0, qui renvoie l'entier le plus proche. Si le\nnombre de décimales spécifié est négatif, la fonction arrondit au nombre de décimales à gauche\nde la virgule.", + "kbn-esql-validation-autocomplete.esql.definitions.replace": "La fonction remplace dans la chaîne `str` toutes les correspondances avec l'expression régulière `regex` par la chaîne de remplacement `newStr`.", + "kbn-esql-validation-autocomplete.esql.definitions.reverse": "Renvoie une nouvelle chaîne représentant la chaîne d'entrée dans l'ordre inverse.", + "kbn-esql-validation-autocomplete.esql.definitions.right": "Renvoie la sous-chaîne qui extrait la longueur des caractères de `str` en partant de la droite.", + "kbn-esql-validation-autocomplete.esql.definitions.round": "Arrondit un nombre au nombre spécifié de décimales. La valeur par défaut est 0, qui renvoie l'entier le plus proche. Si le nombre de décimales spécifié est négatif, la fonction arrondit au nombre de décimales à gauche.", "kbn-esql-validation-autocomplete.esql.definitions.rowDoc": "Renvoie une ligne contenant une ou plusieurs colonnes avec les valeurs que vous spécifiez. Cette commande peut s'avérer utile pour les tests.", "kbn-esql-validation-autocomplete.esql.definitions.rtrim": "Supprime les espaces à la fin des chaînes.", "kbn-esql-validation-autocomplete.esql.definitions.showDoc": "Renvoie des informations sur le déploiement et ses capacités", - "kbn-esql-validation-autocomplete.esql.definitions.signum": "Renvoie le signe du nombre donné.\nRenvoie `-1` pour les nombres négatifs, `0` pour `0` et `1` pour les nombres positifs.", - "kbn-esql-validation-autocomplete.esql.definitions.sin": "Renvoie la fonction trigonométrique sinusoïdale d'un angle.", - "kbn-esql-validation-autocomplete.esql.definitions.sinh": "Renvoie le sinus hyperbolique d'un angle.", + "kbn-esql-validation-autocomplete.esql.definitions.signum": "Renvoie le signe du nombre donné. Renvoie `-1` pour les nombres négatifs, `0` pour `0` et `1` pour les nombres positifs.", + "kbn-esql-validation-autocomplete.esql.definitions.sin": "Renvoie le sinus d'un angle.", + "kbn-esql-validation-autocomplete.esql.definitions.sinh": "Renvoie le sinus hyperbolique d'un nombre.", "kbn-esql-validation-autocomplete.esql.definitions.sortDoc": "Trie tous les résultats en fonction des champs spécifiés. Par défaut, les valeurs null sont considérées comme supérieures à toutes les autres valeurs. Avec l'ordre de tri croissant, les valeurs null sont classées en dernier. Avec l'ordre de tri décroissant, elles sont classées en premier. Pour modifier cet ordre, utilisez NULLS FIRST ou NULLS LAST", + "kbn-esql-validation-autocomplete.esql.definitions.space": "Renvoie une chaîne composée d'espaces nombre (`number`).", "kbn-esql-validation-autocomplete.esql.definitions.split": "Divise une chaîne de valeur unique en plusieurs chaînes.", - "kbn-esql-validation-autocomplete.esql.definitions.sqrt": "Renvoie la racine carrée d'un nombre. La valeur de renvoi est toujours un double, quelle que soit la valeur numérique de l'entrée.\nLes racines carrées des nombres négatifs et des infinis sont nulles.", - "kbn-esql-validation-autocomplete.esql.definitions.st_contains": "Renvoie si la première géométrie contient la deuxième géométrie.\nIl s'agit de l'inverse de la fonction `ST_WITHIN`.", - "kbn-esql-validation-autocomplete.esql.definitions.st_disjoint": "Renvoie si les deux géométries ou colonnes géométriques sont disjointes.\nIl s'agit de l'inverse de la fonction `ST_INTERSECTS`.\nEn termes mathématiques : ST_Disjoint(A, B) ⇔ A ⋂ B = ∅", - "kbn-esql-validation-autocomplete.esql.definitions.st_distance": "Calcule la distance entre deux points.\nPour les géométries cartésiennes, c’est la distance pythagoricienne dans les mêmes unités que les coordonnées d'origine.\nPour les géométries géographiques, c’est la distance circulaire le long du grand cercle en mètres.", - "kbn-esql-validation-autocomplete.esql.definitions.st_intersects": "Renvoie `true` (vrai) si deux géométries se croisent.\nElles se croisent si elles ont un point commun, y compris leurs points intérieurs\n(les points situés le long des lignes ou dans des polygones).\nIl s'agit de l'inverse de la fonction `ST_DISJOINT`.\nEn termes mathématiques : ST_Intersects(A, B) ⇔ A ⋂ B ≠ ∅", - "kbn-esql-validation-autocomplete.esql.definitions.st_within": "Renvoie si la première géométrie est à l'intérieur de la deuxième géométrie.\nIl s'agit de l'inverse de la fonction `ST_CONTAINS`.", - "kbn-esql-validation-autocomplete.esql.definitions.st_x": "Extrait la coordonnée `x` du point fourni.\nSi les points sont de type `geo_point`, cela revient à extraire la valeur de la `longitude`.", - "kbn-esql-validation-autocomplete.esql.definitions.st_y": "Extrait la coordonnée `y` du point fourni.\nSi les points sont de type `geo_point`, cela revient à extraire la valeur de la `latitude`.", + "kbn-esql-validation-autocomplete.esql.definitions.sqrt": "Renvoie la racine carrée d'un nombre. La valeur de renvoi est toujours un double, quelle que soit la valeur numérique de l'entrée. Les racines carrées des nombres négatifs et des infinis sont nulles.", + "kbn-esql-validation-autocomplete.esql.definitions.st_centroid_agg": "Calcule le centroïde spatial sur un champ avec un type de géométrie de point spatial.", + "kbn-esql-validation-autocomplete.esql.definitions.st_contains": "Renvoie si la première géométrie contient la deuxième géométrie. Il s'agit de l'inverse de la fonction `ST_WITHIN`.", + "kbn-esql-validation-autocomplete.esql.definitions.st_disjoint": "Renvoie si les deux géométries ou colonnes géométriques sont disjointes. Il s'agit de l'inverse de la fonction `ST_INTERSECTS`. En termes mathématiques : ST_Disjoint(A, B) ⇔ A ⋂ B = ∅", + "kbn-esql-validation-autocomplete.esql.definitions.st_distance": "Calcule la distance entre deux points. Pour les géométries cartésiennes, c’est la distance pythagoricienne dans les mêmes unités que les coordonnées d'origine. Pour les géométries géographiques, c’est la distance circulaire le long du grand cercle en mètres.", + "kbn-esql-validation-autocomplete.esql.definitions.st_intersects": "Renvoie `true` (vrai) si deux géométries se croisent. Elles se croisent si elles ont un point commun, y compris leurs points intérieurs (points le long de lignes ou à l'intérieur de polygones). Il s'agit de l'inverse de la fonction `ST_DISJOINT`. En termes mathématiques : ST_Intersects(A, B) ⇔ A ⋂ B ≠ ∅", + "kbn-esql-validation-autocomplete.esql.definitions.st_within": "Renvoie si la première géométrie est à l'intérieur de la deuxième géométrie. Il s'agit de l'inverse de la fonction `ST_CONTAINS`.", + "kbn-esql-validation-autocomplete.esql.definitions.st_x": "Extrait la coordonnée `x` du point fourni. Si les points sont de type `geo_point`, cela revient à extraire la valeur de la `longitude`.", + "kbn-esql-validation-autocomplete.esql.definitions.st_y": "Extrait la coordonnée `y` du point fourni. Si les points sont de type `geo_point`, cela revient à extraire la valeur de la `latitude`.", "kbn-esql-validation-autocomplete.esql.definitions.starts_with": "Renvoie un booléen qui indique si une chaîne de mot-clés débute par une autre chaîne.", "kbn-esql-validation-autocomplete.esql.definitions.statsDoc": "Calcule les statistiques agrégées, telles que la moyenne, le décompte et la somme, sur l'ensemble des résultats de recherche entrants. Comme pour l'agrégation SQL, si la commande stats est utilisée sans clause BY, une seule ligne est renvoyée, qui est l'agrégation de tout l'ensemble des résultats de recherche entrants. Lorsque vous utilisez une clause BY, une ligne est renvoyée pour chaque valeur distincte dans le champ spécifié dans la clause BY. La commande stats renvoie uniquement les champs dans l'agrégation, et vous pouvez utiliser un large éventail de fonctions statistiques avec la commande stats. Lorsque vous effectuez plusieurs agrégations, séparez chacune d'entre elle par une virgule.", - "kbn-esql-validation-autocomplete.esql.definitions.substring": "Renvoie la sous-chaîne d'une chaîne, délimitée en fonction d'une position de départ et d'une longueur facultative", - "kbn-esql-validation-autocomplete.esql.definitions.tan": "Renvoie la fonction trigonométrique Tangente d'un angle.", - "kbn-esql-validation-autocomplete.esql.definitions.tanh": "Renvoie la fonction hyperbolique Tangente d'un angle.", + "kbn-esql-validation-autocomplete.esql.definitions.substring": "Renvoie la sous-chaîne d'une chaîne, délimitée en fonction d'une position de départ et d'une longueur facultative.", + "kbn-esql-validation-autocomplete.esql.definitions.sum": "La somme d'une expression numérique.", + "kbn-esql-validation-autocomplete.esql.definitions.tan": "Renvoie la tangente d'un angle.", + "kbn-esql-validation-autocomplete.esql.definitions.tanh": "Renvoie la tangente hyperbolique d'un nombre.", "kbn-esql-validation-autocomplete.esql.definitions.tau": "Renvoie le rapport entre la circonférence et le rayon d'un cercle.", "kbn-esql-validation-autocomplete.esql.definitions.to_base64": "Encode une chaîne en chaîne base64.", - "kbn-esql-validation-autocomplete.esql.definitions.to_boolean": "Convertit une valeur d'entrée en une valeur booléenne.\nUne chaîne de valeur *true* sera convertie, sans tenir compte de la casse, en une valeur booléenne *true*.\nPour toute autre valeur, y compris une chaîne vide, la fonction renverra *false*.\nLa valeur numérique *0* sera convertie en *false*, toute autre valeur sera convertie en *true*.", - "kbn-esql-validation-autocomplete.esql.definitions.to_cartesianpoint": "Convertit la valeur d'une entrée en une valeur `cartesian_point`.\nUne chaîne ne sera convertie avec succès que si elle respecte le format WKT Point.", - "kbn-esql-validation-autocomplete.esql.definitions.to_cartesianshape": "Convertit une valeur d'entrée en une valeur `cartesian_shape`.\nUne chaîne ne sera convertie avec succès que si elle respecte le format WKT.", - "kbn-esql-validation-autocomplete.esql.definitions.to_datetime": "Convertit une valeur d'entrée en une valeur de date.\nUne chaîne ne sera convertie efficacement que si elle respecte le format `yyyy-MM-dd'T'HH:mm:ss.SSS'Z'`.\nPour convertir des dates vers d'autres formats, utilisez `DATE_PARSE`.", + "kbn-esql-validation-autocomplete.esql.definitions.to_boolean": "Convertit une valeur d'entrée en une valeur booléenne. Une chaîne de valeur *true* sera convertie, sans tenir compte de la casse, en une valeur booléenne *true*. Pour toute autre valeur, y compris une chaîne vide, la fonction renverra *false*. La valeur numérique *0* sera convertie en *false*, toute autre valeur sera convertie en *true*.", + "kbn-esql-validation-autocomplete.esql.definitions.to_cartesianpoint": "Convertit la valeur d'une entrée en une valeur `cartesian_point`. Une chaîne ne sera convertie avec succès que si elle respecte le format WKT Point.", + "kbn-esql-validation-autocomplete.esql.definitions.to_cartesianshape": "Convertit une valeur d'entrée en une valeur `cartesian_shape`. Une chaîne ne sera convertie que si elle respecte le format WKT.", + "kbn-esql-validation-autocomplete.esql.definitions.to_date_nanos": "Convertit une entrée en une valeur de date de résolution nanoseconde (ou date_nanos).", + "kbn-esql-validation-autocomplete.esql.definitions.to_dateperiod": "Convertit une valeur d'entrée en une valeur `date_period`.", + "kbn-esql-validation-autocomplete.esql.definitions.to_datetime": "Convertit une valeur d'entrée en une valeur de date. Une chaîne ne sera convertie que si elle respecte le format `yyyy-MM-dd'T'HH:mm:ss.SSS'Z'`. Pour convertir des dates vers d'autres formats, utilisez `DATE_PARSE`.", "kbn-esql-validation-autocomplete.esql.definitions.to_degrees": "Convertit un nombre en radians en degrés.", - "kbn-esql-validation-autocomplete.esql.definitions.to_double": "Convertit une valeur d'entrée en une valeur double. Si le paramètre d'entrée est de type date,\nsa valeur sera interprétée en millisecondes depuis l'heure Unix,\nconvertie en double. Le booléen *true* sera converti en double *1.0*, et *false* en *0.0*.", - "kbn-esql-validation-autocomplete.esql.definitions.to_geopoint": "Convertit une valeur d'entrée en une valeur `geo_point`.\nUne chaîne ne sera convertie avec succès que si elle respecte le format WKT Point.", - "kbn-esql-validation-autocomplete.esql.definitions.to_geoshape": "Convertit une valeur d'entrée en une valeur `geo_shape`.\nUne chaîne ne sera convertie avec succès que si elle respecte le format WKT.", - "kbn-esql-validation-autocomplete.esql.definitions.to_integer": "Convertit une valeur d'entrée en une valeur entière.\nSi le paramètre d'entrée est de type date, sa valeur sera interprétée en millisecondes\ndepuis l'heure Unix, convertie en entier.\nLe booléen *true* sera converti en entier *1*, et *false* en *0*.", + "kbn-esql-validation-autocomplete.esql.definitions.to_double": "Convertit une valeur d'entrée en une valeur double. Si le paramètre d'entrée est de type date, sa valeur sera interprétée en millisecondes depuis l'heure Unix, convertie en double. Le booléen *true* sera converti en double *1.0*, et *false* en *0.0*.", + "kbn-esql-validation-autocomplete.esql.definitions.to_geopoint": "Convertit une valeur d'entrée en une valeur `geo_point`. Une chaîne ne sera convertie avec succès que si elle respecte le format WKT Point.", + "kbn-esql-validation-autocomplete.esql.definitions.to_geoshape": "Convertit une valeur d'entrée en une valeur `geo_shape`. Une chaîne ne sera convertie avec succès que si elle respecte le format WKT.", + "kbn-esql-validation-autocomplete.esql.definitions.to_integer": "Convertit une valeur d'entrée en une valeur entière. Si le paramètre d'entrée est de type date, sa valeur sera interprétée en millisecondes depuis l'heure Unix, convertie en nombre entier. Le booléen *true* sera converti en entier *1*, et *false* en *0*.", "kbn-esql-validation-autocomplete.esql.definitions.to_ip": "Convertit une chaîne d'entrée en valeur IP.", - "kbn-esql-validation-autocomplete.esql.definitions.to_long": "Convertit une valeur d'entrée en une valeur longue. Si le paramètre d'entrée est de type date,\nsa valeur sera interprétée en millisecondes depuis l'heure Unix, convertie en valeur longue.\nLe booléen *true* sera converti en valeur longue *1*, et *false* en *0*.", + "kbn-esql-validation-autocomplete.esql.definitions.to_long": "Convertit une valeur d'entrée en une valeur longue. Si le paramètre d'entrée est de type date, sa valeur sera interprétée en millisecondes depuis l'heure Unix, convertie en valeur longue. Le booléen *true* sera converti en valeur longue *1*, et *false* en *0*.", "kbn-esql-validation-autocomplete.esql.definitions.to_lower": "Renvoie une nouvelle chaîne représentant la chaîne d'entrée convertie en minuscules.", "kbn-esql-validation-autocomplete.esql.definitions.to_radians": "Convertit un nombre en degrés en radians.", "kbn-esql-validation-autocomplete.esql.definitions.to_string": "Convertit une valeur d'entrée en une chaîne.", - "kbn-esql-validation-autocomplete.esql.definitions.to_unsigned_long": "Convertit une valeur d'entrée en une valeur longue non signée. Si le paramètre d'entrée est de type date,\nsa valeur sera interprétée en millisecondes depuis l'heure Unix, convertie en valeur longue non signée.\nLe booléen *true* sera converti en valeur longue non signée *1*, et *false* en *0*.", + "kbn-esql-validation-autocomplete.esql.definitions.to_timeduration": "Convertit une valeur d'entrée en valeur `time_duration`.", + "kbn-esql-validation-autocomplete.esql.definitions.to_unsigned_long": "Convertit une valeur d'entrée en une valeur longue non signée. Si le paramètre d'entrée est de type date, sa valeur sera interprétée en millisecondes depuis l'heure Unix, convertie en valeur longue non signée. Le booléen *true* sera converti en valeur longue non signée *1*, et *false* en *0*.", "kbn-esql-validation-autocomplete.esql.definitions.to_upper": "Renvoie une nouvelle chaîne représentant la chaîne d'entrée convertie en majuscules.", "kbn-esql-validation-autocomplete.esql.definitions.to_version": "Convertit une chaîne d'entrée en une valeur de version.", + "kbn-esql-validation-autocomplete.esql.definitions.top": "Collecte les valeurs les plus hautes d'un champ. Inclut les valeurs répétées.", "kbn-esql-validation-autocomplete.esql.definitions.trim": "Supprime les espaces de début et de fin d'une chaîne.", - "kbn-esql-validation-autocomplete.esql.definitions.values": "Renvoie toutes les valeurs d'un groupe dans un tableau.", + "kbn-esql-validation-autocomplete.esql.definitions.values": "Renvoie toutes les valeurs d’un groupe dans un champ multivalué. L'ordre des valeurs renvoyées n'est pas garanti. Si vous avez besoin que les valeurs renvoyées soient dans l'ordre, utilisez `esql-mv_sort`.", + "kbn-esql-validation-autocomplete.esql.definitions.weighted_avg": "La moyenne pondérée d'une expression numérique.", "kbn-esql-validation-autocomplete.esql.definitions.whereDoc": "Utilise \"predicate-expressions\" pour filtrer les résultats de recherche. Une expression predicate, lorsqu'elle est évaluée, renvoie TRUE ou FALSE. La commande where renvoie uniquement les résultats qui donnent la valeur TRUE. Par exemple, pour filtrer les résultats pour une valeur de champ spécifique", "kbn-esql-validation-autocomplete.esql.definitions.withDoc": "Avec", "kbn-esql-validation-autocomplete.esql.divide.warning.divideByZero": "Impossible de diviser par zéro : {left}/{right}", @@ -5350,7 +5889,10 @@ "kbn-esql-validation-autocomplete.esql.validation.metadataBracketsDeprecation": "Les crochets \"[]\" doivent être supprimés de la déclaration FROM METADATA", "kbn-esql-validation-autocomplete.esql.validation.missingFunction": "Fonction inconnue [{name}]", "kbn-esql-validation-autocomplete.esql.validation.noAggFunction": "Au moins une fonction d'agrégation requise dans [{command}], [{expression}] trouvée", + "kbn-esql-validation-autocomplete.esql.validation.noCombinationOfAggAndNonAggValues": "Impossible de combiner les valeurs agrégées et non agrégées dans [{commandName}], [{expression}] trouvée", "kbn-esql-validation-autocomplete.esql.validation.noNestedArgumentSupport": "Les paramètres de la fonction agrégée doivent être un attribut, un littéral ou une fonction non agrégée ; trouvé [{name}] de type [{argType}]", + "kbn-esql-validation-autocomplete.esql.validation.statsNoAggFunction": "Au moins une fonction d'agrégation requise dans [{commandName}], [{expression}] trouvée", + "kbn-esql-validation-autocomplete.esql.validation.statsNoArguments": "[{commandName}] doit contenir au moins une expression d'agrégation ou de regroupement", "kbn-esql-validation-autocomplete.esql.validation.typeOverwrite": "La colonne [{field}] de type {fieldType} a été écrasée par un nouveau type : {newType}", "kbn-esql-validation-autocomplete.esql.validation.unknowAggregateFunction": "Attendait une fonction ou un groupe agrégé mais a obtenu [{value}] de type [{type}]", "kbn-esql-validation-autocomplete.esql.validation.unknownColumn": "Colonne inconnue[{name}]", @@ -5375,6 +5917,18 @@ "kbn-esql-validation-autocomplete.esql.validation.wrongArgumentType": "L'argument de [{name}] doit être [{argType}], valeur [{value}] trouvée de type [{givenType}]", "kbn-esql-validation-autocomplete.esql.validation.wrongDissectOptionArgumentType": "Valeur non valide pour DISSECT append_separator : une chaîne était attendue mais il s'agissait de [{value}]", "kbn-esql-validation-autocomplete.esql.validation.wrongMetadataArgumentType": "Le champ de métadonnées [{value}] n'est pas disponible. Les champs de métadonnées disponibles sont : [{availableFields}]", + "kbn-esql-validation-autocomplete.recommendedQueries.aggregateExample.description": "Agrégation des quantités", + "kbn-esql-validation-autocomplete.recommendedQueries.aggregateExample.label": "Agréger avec STATS", + "kbn-esql-validation-autocomplete.recommendedQueries.caseExample.description": "Conditionnel", + "kbn-esql-validation-autocomplete.recommendedQueries.caseExample.label": "Créer un élément conditionnel avec CASE", + "kbn-esql-validation-autocomplete.recommendedQueries.dateHistogram.description": "Agrégation des totaux au fil du temps", + "kbn-esql-validation-autocomplete.recommendedQueries.dateHistogram.label": "Créer un histogramme de date", + "kbn-esql-validation-autocomplete.recommendedQueries.dateIntervals.description": "Agrégation des totaux au fil du temps", + "kbn-esql-validation-autocomplete.recommendedQueries.dateIntervals.label": "Créez des intervalles de temps de 5 minutes avec EVAL", + "kbn-esql-validation-autocomplete.recommendedQueries.lastHour.description": "Un exemple plus complexe", + "kbn-esql-validation-autocomplete.recommendedQueries.lastHour.label": "Nombre total par rapport au nombre de la dernière heure", + "kbn-esql-validation-autocomplete.recommendedQueries.sortByTime.description": "Trier par heure", + "kbn-esql-validation-autocomplete.recommendedQueries.sortByTime.label": "Trier par heure", "kbnConfig.deprecations.conflictSetting.manualStepOneMessage": "Assurez-vous que \"{fullNewPath}\" contient la valeur correcte dans le fichier de configuration, l'indicateur CLI ou la variable d'environnement (dans Docker uniquement).", "kbnConfig.deprecations.conflictSetting.manualStepTwoMessage": "Supprimez \"{fullOldPath}\" de la configuration.", "kbnConfig.deprecations.conflictSettingMessage": "Le paramètre \"{fullOldPath}\" a été remplacé par \"{fullNewPath}\". Cependant, les deux clés sont présentes. Ignorer \"{fullOldPath}\"", @@ -5385,9 +5939,10 @@ "kbnConfig.deprecations.replacedSettingMessage": "Le paramètre \"{fullOldPath}\" a été remplacé par \"{fullNewPath}\".", "kbnConfig.deprecations.unusedSetting.manualStepOneMessage": "Retirez \"{fullPath}\" dans le fichier de configuration Kibana, l'indicateur CLI ou la variable d'environnement (dans Docker uniquement).", "kbnConfig.deprecations.unusedSettingMessage": "Vous n’avez plus besoin de configurer \"{fullPath}\".", + "kbnGridLayout.row.toggleCollapse": "Basculer vers la réduction", "kibana_utils.history.savedObjectIsMissingNotificationMessage": "L'objet enregistré est manquant.", "kibana_utils.stateManagement.stateHash.unableToRestoreUrlErrorMessage": "Impossible de restaurer complètement l'URL. Assurez-vous d'utiliser la fonctionnalité de partage.", - "kibana_utils.stateManagement.stateHash.unableToStoreHistoryInSessionErrorMessage": "Kibana n'est pas en mesure de stocker des éléments d'historique dans votre session, car le stockage est arrivé à saturation et il ne semble pas y avoir d'éléments pouvant être supprimés sans risque.\n\nCe problème peut généralement être corrigé en passant à un nouvel onglet, mais il peut être causé par un problème plus important. Si ce message s'affiche régulièrement, veuillez nous en faire part sur {gitHubIssuesUrl}.", + "kibana_utils.stateManagement.stateHash.unableToStoreHistoryInSessionErrorMessage": "Kibana n'est pas en mesure de stocker des éléments d'historique dans votre session, car le stockage est arrivé à saturation et il ne semble pas y avoir d'éléments pouvant être supprimés sans risque. Ce problème peut généralement être corrigé en passant à un nouvel onglet, mais il peut être causé par un problème plus important. Si ce message s'affiche régulièrement, veuillez nous en faire part sur {gitHubIssuesUrl}.", "kibana_utils.stateManagement.url.restoreUrlErrorTitle": "Erreur lors de la restauration de l'état depuis l'URL.", "kibana_utils.stateManagement.url.saveStateInUrlErrorTitle": "Erreur lors de l'enregistrement de l'état dans l'URL.", "kibana-react.dualRangeControl.maxInputAriaLabel": "Maximum de la plage", @@ -5426,263 +5981,315 @@ "kibanaOverview.more.title": "Toujours plus avec Elastic", "kibanaOverview.news.title": "Nouveautés", "languageDocumentation.documentationESQL.abs": "ABS", - "languageDocumentation.documentationESQL.abs.markdown": "\n\n ### ABS\n Renvoie la valeur absolue.\n\n ````\n Numéro ROW = -1.0 \n | EVAL abs_number = ABS(number)\n ````\n ", + "languageDocumentation.documentationESQL.abs.markdown": " ### ABS Renvoie la valeur absolue. ``` ROW number = -1.0 | EVAL abs_number = ABS(number) ```", "languageDocumentation.documentationESQL.acos": "ACOS", - "languageDocumentation.documentationESQL.acos.markdown": "\n\n ### ACOS\n Renvoie l'arc cosinus de `n` sous forme d'angle, exprimé en radians.\n\n ````\n ROW a=.9\n | EVAL acos=ACOS(a)\n ````\n ", + "languageDocumentation.documentationESQL.acos.markdown": " ### ACOS Renvoie l'arc cosinus de `n` sous forme d'angle, exprimé en radians. ``` ROW a=.9 | EVAL acos=ACOS(a) ```", "languageDocumentation.documentationESQL.aggregationFunctions": "Fonctions d'agrégation", "languageDocumentation.documentationESQL.aggregationFunctionsDocumentationESQLDescription": "Ces fonctions peuvent être utilisées avec STATS...BY :", "languageDocumentation.documentationESQL.asin": "ASIN", - "languageDocumentation.documentationESQL.asin.markdown": "\n\n ### ASIN\n Renvoie l'arc sinus de l'entrée\n expression numérique sous forme d'angle, exprimée en radians.\n\n ````\n ROW a=.9\n | EVAL asin=ASIN(a)\n ````\n ", + "languageDocumentation.documentationESQL.asin.markdown": " ### ASIN Renvoie l'arc sinus de l'expression numérique sous forme d'angle, exprimé en radians. ``` ROW a=.9 | EVAL asin=ASIN(a) ```", "languageDocumentation.documentationESQL.atan": "ATAN", - "languageDocumentation.documentationESQL.atan.markdown": "\n\n ### ATAN\n Renvoie l'arc tangente de l'entrée\n expression numérique sous forme d'angle, exprimée en radians.\n\n ````\n ROW a=.12.9\n | EVAL atan=ATAN(a)\n ````\n ", + "languageDocumentation.documentationESQL.atan.markdown": " ### ATAN Renvoie l'arc tangente de l'expression numérique sous forme d'angle, exprimé en radians. ``` ROW a=12.9 | EVAL atan=ATAN(a) ```", "languageDocumentation.documentationESQL.atan2": "ATAN2", - "languageDocumentation.documentationESQL.atan2.markdown": "\n\n ### ATAN2\n L'angle entre l'axe positif des x et le rayon allant de\n l'origine au point (x , y) dans le plan cartésien, exprimée en radians.\n\n ````\n ROW y=12.9, x=.6\n | EVAL atan2=ATAN2(y, x)\n ````\n ", + "languageDocumentation.documentationESQL.atan2.markdown": " ### ATAN2 L'angle entre l'axe positif des x et le rayon allant de l'origine au point (x , y) dans le plan cartésien, exprimé en radians. ``` ROW y=12.9, x=.6 | EVAL atan2=ATAN2(y, x) ```", "languageDocumentation.documentationESQL.autoBucketFunction": "COMPARTIMENT", - "languageDocumentation.documentationESQL.autoBucketFunction.markdown": "### COMPARTIMENT\nCréer des groupes de valeurs, des compartiments (\"buckets\"), à partir d'une entrée d'un numéro ou d'un horodatage. La taille des compartiments peut être fournie directement ou choisie selon une plage de valeurs et de décompte recommandée.\n\n`BUCKET` a deux modes de fonctionnement : \n\n1. Dans lequel la taille du compartiment est calculée selon la recommandation de décompte d'un compartiment (quatre paramètres) et une plage.\n2. Dans lequel la taille du compartiment est fournie directement (deux paramètres).\n\nAvec un nombre cible de compartiments, le début d'une plage et la fin d'une plage, `BUCKET` choisit une taille de compartiment appropriée afin de générer le nombre cible de compartiments ou moins.\n\nPar exemple, demander jusqu'à 20 compartiments pour une année organisera les données en intervalles mensuels :\n\n````\nFROM employees\n| WHERE hire_date >= \"1985-01-01T00:00:00Z\" AND hire_date < \"1986-01-01T00:00:00Z\"\n| STATS hire_date = MV_SORT(VALUES(hire_date)) BY month = BUCKET(hire_date, 20, \"1985-01-01T00:00:00Z\", \"1986-01-01T00:00:00Z\")\n| SORT hire_date\n````\n\n**REMARQUE** : Le but n'est pas de fournir le nombre précis de compartiments, mais plutôt de sélectionner une plage qui fournit, tout au plus, le nombre cible de compartiments.\n\nVous pouvez combiner `BUCKET` avec une agrégation pour créer un histogramme :\n\n````\nFROM employees\n| WHERE hire_date >= \"1985-01-01T00:00:00Z\" AND hire_date < \"1986-01-01T00:00:00Z\"\n| STATS hires_per_month = COUNT(*) BY month = BUCKET(hire_date, 20, \"1985-01-01T00:00:00Z\", \"1986-01-01T00:00:00Z\")\n| SORT month\n````\n\n**REMARQUE** : `BUCKET` ne crée pas de compartiments qui ne correspondent à aucun document. C'est pourquoi, dans l'exemple précédent, il manque 1985-03-01 ainsi que d'autres dates.\n\nDemander d'autres compartiments peut résulter en une plage réduite. Par exemple, demander jusqu'à 100 compartiments en un an résulte en des compartiments hebdomadaires :\n\n````\nFROM employees\n| WHERE hire_date >= \"1985-01-01T00:00:00Z\" AND hire_date < \"1986-01-01T00:00:00Z\"\n| STATS hires_per_week = COUNT(*) BY week = BUCKET(hire_date, 100, \"1985-01-01T00:00:00Z\", \"1986-01-01T00:00:00Z\")\n| SORT week\n````\n\n**REMARQUE** : `AUTO_BUCKET` ne filtre aucune ligne. Il n'utilise que la plage fournie pour choisir une taille de compartiment appropriée. Pour les lignes dont la valeur se situe en dehors de la plage, il renvoie une valeur de compartiment qui correspond à un compartiment situé en dehors de la plage. Associez `BUCKET` à `WHERE` pour filtrer les lignes.\n\nSi la taille de compartiment désirée est connue à l'avance, fournissez-la comme second argument, en ignorant la plage :\n\n````\nFROM employees\n| WHERE hire_date >= \"1985-01-01T00:00:00Z\" AND hire_date < \"1986-01-01T00:00:00Z\"\n| STATS hires_per_week = COUNT(*) BY week = BUCKET(hire_date, 1 week)\n| SORT week\n````\n\n**REMARQUE** : Lorsque vous fournissez la taille du compartiment comme second argument, ce dernier doit être une période temporelle ou une durée.\n\n`BUCKET` peut également être utilisé pour des champs numériques. Par exemple, pour créer un histogramme de salaire :\n\n````\nFROM employees\n| STATS COUNT(*) by bs = BUCKET(salary, 20, 25324, 74999)\n| SORT bs\n````\n\nContrairement à l'exemple précédent qui filtre intentionnellement sur une plage temporelle, vous n'avez pas souvent besoin de filtrer sur une plage numérique. Vous devez trouver les valeurs min et max séparément. ES|QL n'a pas encore de façon aisée d'effectuer cette opération automatiquement.\n\nLa plage peut être ignorée si la taille désirée de compartiment est connue à l'avance. Fournissez-la simplement comme second argument :\n\n````\nFROM employees\n| WHERE hire_date >= \"1985-01-01T00:00:00Z\" AND hire_date < \"1986-01-01T00:00:00Z\"\n| STATS c = COUNT(1) BY b = BUCKET(salary, 5000.)\n| SORT b\n````\n\n**REMARQUE** : Lorsque vous fournissez la taille du compartiment comme second argument, elle doit être de type à **virgule flottante**.\n\nVoici un exemple sur comment créer des compartiments horaires pour les dernières 24 heures, et calculer le nombre d'événements par heure :\n\n````\nFROM sample_data\n| WHERE @timestamp >= NOW() - 1 day and @timestamp < NOW()\n| STATS COUNT(*) BY bucket = BUCKET(@timestamp, 25, NOW() - 1 day, NOW())\n````\n\nVoici un exemple permettant de créer des compartiments mensuels pour l'année 1985, et calculer le salaire moyen par mois d'embauche :\n\n````\nFROM employees\n| WHERE hire_date >= \"1985-01-01T00:00:00Z\" AND hire_date < \"1986-01-01T00:00:00Z\"\n| STATS AVG(salary) BY bucket = BUCKET(hire_date, 20, \"1985-01-01T00:00:00Z\", \"1986-01-01T00:00:00Z\")\n| SORT bucket\n````\n\n`BUCKET` peut être utilisé pour les parties de groupage et d'agrégation de la commande `STATS …​ BY ...`, tant que la partie d'agrégation de la fonction est **référencée par un alias défini dans la partie de groupage**, ou que celle-ci est invoquée avec exactement la même expression.\n\nPar exemple :\n\n````\nFROM employees\n| STATS s1 = b1 + 1, s2 = BUCKET(salary / 1000 + 999, 50.) + 2 BY b1 = BUCKET(salary / 100 + 99, 50.), b2 = BUCKET(salary / 1000 + 999, 50.)\n| SORT b1, b2\n| KEEP s1, b1, s2, b2\n````\n ", + "languageDocumentation.documentationESQL.autoBucketFunction.markdown": "### BUCKET Crée des groupes de valeurs et des compartiments (\"buckets\"), à partir d'une entrée numérique ou d'un horodatage. La taille des compartiments peut être fournie directement ou choisie selon une plage de valeurs et de décompte recommandée. `BUCKET` a deux modes de fonctionnement : 1. Dans lequel la taille du compartiment est calculée selon la recommandation de décompte d'un compartiment (quatre paramètres) et une plage. 2. Dans lequel la taille du compartiment est fournie directement (deux paramètres). Avec un nombre cible de compartiments, le début d'une plage et la fin d'une plage, `BUCKET` choisit une taille de compartiment appropriée afin de générer le nombre cible de compartiments ou moins. Par exemple, demander jusqu'à 20 compartiments pour une année organisera les données en intervalles mensuels : ``` FROM employees | WHERE hire_date >= \"1985-01-01T00:00:00Z\" AND hire_date < \"1986-01-01T00:00:00Z\" | 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 ``` **REMARQUE** : Le but n'est pas de fournir le nombre précis de compartiments, mais plutôt de sélectionner une plage qui fournit, tout au plus, le nombre cible de compartiments. Vous pouvez combiner `BUCKET` avec une agrégation pour créer un histogramme : ``` FROM employees | WHERE hire_date >= \"1985-01-01T00:00:00Z\" AND hire_date < \"1986-01-01T00:00:00Z\" | STATS hires_per_month = COUNT(*) BY month = BUCKET(hire_date, 20, \"1985-01-01T00:00:00Z\", \"1986-01-01T00:00:00Z\") | SORT month ``` **REMARQUE** : `BUCKET` ne crée pas de compartiments qui ne correspondent à aucun document. C'est pourquoi, dans l'exemple précédent, il manque 1985-03-01 ainsi que d'autres dates. Demander d'autres compartiments peut résulter en une plage réduite. Par exemple, demander jusqu'à 100 compartiments en un an résulte en des compartiments hebdomadaires : ``` FROM employees | WHERE hire_date >= \"1985-01-01T00:00:00Z\" AND hire_date < \"1986-01-01T00:00:00Z\" | STATS hires_per_week = COUNT(*) BY week = BUCKET(hire_date, 100, \"1985-01-01T00:00:00Z\", \"1986-01-01T00:00:00Z\") | SORT week ``` **REMARQUE** : `BUCKET` ne filtre aucune ligne. Il n'utilise que la plage fournie pour choisir une taille de compartiment appropriée. Pour les lignes dont la valeur se situe en dehors de la plage, il renvoie une valeur de compartiment qui correspond à un compartiment situé en dehors de la plage. Associez `BUCKET` à `WHERE` pour filtrer les lignes. Si la taille de compartiment désirée est connue à l'avance, fournissez-la comme second argument, en ignorant la plage : ``` FROM employees | WHERE hire_date >= \"1985-01-01T00:00:00Z\" AND hire_date < \"1986-01-01T00:00:00Z\" | STATS hires_per_week = COUNT(*) BY week = BUCKET(hire_date, 1 week) | SORT week ``` **REMARQUE** : Lorsque vous fournissez la taille du compartiment comme second argument, ce dernier doit être une période temporelle ou une durée. `BUCKET` peut également être utilisé pour des champs numériques. Par exemple, pour créer un histogramme de salaire : ``` FROM employees | STATS COUNT(*) by bs = BUCKET(salary, 20, 25324, 74999) | SORT bs ``` Contrairement à l'exemple précédent qui filtre intentionnellement sur une plage temporelle, vous n'avez pas souvent besoin de filtrer sur une plage numérique. Vous devez trouver les valeurs min et max séparément. ES|QL n'a pas encore de façon aisée d'effectuer cette opération automatiquement. La plage peut être ignorée si la taille désirée de compartiment est connue à l'avance. Fournissez-la simplement comme second argument : ``` FROM employees | WHERE hire_date >= \"1985-01-01T00:00:00Z\" AND hire_date < \"1986-01-01T00:00:00Z\" | STATS c = COUNT(1) BY b = BUCKET(salary, 5000.) | SORT b ``` **REMARQUE** : Lorsque vous fournissez la taille du compartiment comme second argument, elle doit être de type à **virgule flottante**. Voici un exemple sur comment créer des compartiments horaires pour les dernières 24 heures, et calculer le nombre d'événements par heure : ``` FROM sample_data | WHERE @timestamp >= NOW() - 1 day and @timestamp < NOW() | STATS COUNT(*) BY bucket = BUCKET(@timestamp, 25, NOW() - 1 day, NOW()) ``` Voici un exemple sur comment créer des compartiments mensuels pour l'année 1985, et calculer le salaire moyen par mois d'embauche : ``` FROM employees | WHERE hire_date >= \"1985-01-01T00:00:00Z\" AND hire_date < \"1986-01-01T00:00:00Z\" | STATS AVG(salary) BY bucket = BUCKET(hire_date, 20, \"1985-01-01T00:00:00Z\", \"1986-01-01T00:00:00Z\") | SORT bucket ``` `BUCKET` peut être utilisé à la fois dans la partie agrégation et dans la partie regroupement de la commande `STATS ... BY ...`, tant que la partie d'agrégation de la fonction est **référencée par un alias défini dans la partie de groupage**, ou que celle-ci est invoquée avec exactement la même expression. Par exemple : ``` FROM employees | STATS s1 = b1 + 1, s2 = BUCKET(salary / 1000 + 999, 50.) + 2 BY b1 = BUCKET(salary / 100 + 99, 50.), b2 = BUCKET(salary / 1000 + 999, 50.) | SORT b1, b2 | KEEP s1, b1, s2, b2 ```", + "languageDocumentation.documentationESQL.avg": "AVG", + "languageDocumentation.documentationESQL.avg.markdown": " ### AVG La moyenne d'un champ numérique. ``` FROM employees | STATS AVG(height) ```", "languageDocumentation.documentationESQL.binaryOperators": "Opérateurs binaires", - "languageDocumentation.documentationESQL.binaryOperators.markdown": "### Opérateurs binaires\nLes opérateurs de comparaison binaire suivants sont pris en charge :\n\n* égalité : `==`\n* inégalité : `!=`\n* inférieur à : `<`\n* inférieur ou égal à : `<=`\n* supérieur à : `>`\n* supérieur ou égal à : `>=`\n* ajouter : `+`\n* soustraire : `-`\n* multiplier par : `*`\n* diviser par : `/`\n* module : `%`\n ", + "languageDocumentation.documentationESQL.binaryOperators.markdown": "### Opérateurs binaires Les opérateurs de comparaison binaires suivants sont pris en charge : * égalité : `==` * inégalité : `!=` * inférieur à : `<` * inférieur ou égal à : `<=` * supérieur à : `>` * supérieur ou égal à : `>=` * ajouter : `+` * soustraire : `-` * multiplier : `*` * diviser par : `/` * modulo : `%`", "languageDocumentation.documentationESQL.booleanOperators": "Opérateurs booléens", - "languageDocumentation.documentationESQL.booleanOperators.markdown": "### Opérateurs booléens\nLes opérateurs booléens suivants sont pris en charge :\n\n* `AND`\n* `OR`\n* `NOT`\n ", + "languageDocumentation.documentationESQL.booleanOperators.markdown": "### Opérateurs booléens Les opérateurs booléens suivants sont pris en charge : * `AND` * `OR` * `NOT`", "languageDocumentation.documentationESQL.bucket": "COMPARTIMENT", - "languageDocumentation.documentationESQL.bucket.markdown": "\n\n ### COMPARTIMENT\n Créer des groupes de valeurs, des compartiments (\"buckets\"), à partir d'une entrée d'un numéro ou d'un horodatage.\n La taille des compartiments peut être fournie directement ou choisie selon une plage de valeurs et de décompte recommandée.\n\n ````\n FROM employees\n | WHERE hire_date >= \"1985-01-01T00:00:00Z\" AND hire_date < \"1986-01-01T00:00:00Z\"\n | STATS hire_date = MV_SORT(VALUES(hire_date)) BY month = BUCKET(hire_date, 20, \"1985-01-01T00:00:00Z\", \"1986-01-01T00:00:00Z\")\n | SORT hire_date\n ````\n ", + "languageDocumentation.documentationESQL.bucket.markdown": " ### BUCKET Crée des groupes de valeurs et des compartiments (\"buckets\"), à partir d'une entrée numérique ou d'un horodatage. La taille des compartiments peut être fournie directement ou choisie selon une plage de valeurs et de décompte recommandée. ``` FROM employees | WHERE hire_date >= \"1985-01-01T00:00:00Z\" AND hire_date < \"1986-01-01T00:00:00Z\" | 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 ```", "languageDocumentation.documentationESQL.case": "CASE", - "languageDocumentation.documentationESQL.case.markdown": "\n\n ### CAS\n Accepte les paires de conditions et de valeurs. La fonction renvoie la valeur qui\n appartient à la première condition étant évaluée comme `true`.\n\n Si le nombre d'arguments est impair, le dernier argument est la valeur par défaut qui est\n renvoyée si aucune condition ne correspond. Si le nombre d'arguments est pair, et\n qu'aucune condition ne correspond, la fonction renvoie `null`.\n\n ````\n FROM employees\n | EVAL type = CASE(\n languages <= 1, \"monolingual\",\n languages <= 2, \"bilingual\",\n \"polyglot\")\n | KEEP emp_no, languages, type\n ````\n ", + "languageDocumentation.documentationESQL.case.markdown": " ### CASE Accepte les paires de conditions et de valeurs. La fonction renvoie la valeur correspondant à la première condition évaluée à `true` (vraie). Si le nombre d'arguments est impair, le dernier argument est la valeur par défaut qui est renvoyée si aucune condition ne correspond. Si le nombre d'arguments est pair, et qu'aucune condition ne correspond, la fonction renvoie `null`. ``` FROM employees | EVAL type = CASE( languages <= 1, \"monolingual\", languages <= 2, \"bilingual\", \"polyglot\") | KEEP emp_no, languages, type ```", "languageDocumentation.documentationESQL.castOperator": "Cast (::)", - "languageDocumentation.documentationESQL.castOperator.markdown": "### CAST (`::`)\nL'opérateur `::` fournit une syntaxe alternative pratique au type de converstion de fonction `TO_`.\n\nExemple :\n````\nROW ver = CONCAT((\"0\"::INT + 1)::STRING, \".2.3\")::VERSION\n````\n ", + "languageDocumentation.documentationESQL.castOperator.markdown": "### CAST (`::`) L'opérateur `::` fournit une autre syntaxe pratique pour les fonctions de conversion de type `TO_`. Exemple : ``` ROW ver = CONCAT((\"0\"::INT + 1)::STRING, \".2.3\")::VERSION ```", "languageDocumentation.documentationESQL.cbrt": "CBRT", - "languageDocumentation.documentationESQL.cbrt.markdown": "\n\n ### CBRT\n Renvoie la racine cubique d'un nombre. La valeur de renvoi est toujours un double, quelle que soit la valeur numérique de l'entrée.\n La racine cubique de l’infini est nulle.\n\n ````\n ROW d = 1000.0\n | EVAL c = cbrt(d)\n ````\n ", + "languageDocumentation.documentationESQL.cbrt.markdown": " ### CBRT Renvoie la racine cubique d'un nombre. La valeur de renvoi est toujours un double, quelle que soit la valeur numérique de l'entrée. La racine cubique de l'infini est nulle. ``` ROW d = 1000.0 | EVAL c = cbrt(d) ```", "languageDocumentation.documentationESQL.ceil": "CEIL", - "languageDocumentation.documentationESQL.ceil.markdown": "\n\n ### CEIL\n Arrondir un nombre à l'entier supérieur.\n\n ```\n ROW a=1.8\n | EVAL a=CEIL(a)\n ```\n Remarque : Il s'agit d'un noop pour `long` (y compris non signé) et `integer`. Pour `double`, la fonction choisit la valeur `double` la plus proche de l'entier, de manière similaire à la méthode Math.ceil.\n ", + "languageDocumentation.documentationESQL.ceil.markdown": " ### CEIL Arrondit un nombre à l'entier supérieur. ``` ROW a=1.8 | EVAL a=CEIL(a) ``` Remarque : Il s'agit d'un noop pour `long` (y compris non signé) et `integer`. Pour `double`, la fonction choisit la valeur `double` la plus proche de l'entier, de manière similaire à la méthode Math.ceil.", "languageDocumentation.documentationESQL.cidr_match": "CIDR_MATCH", - "languageDocumentation.documentationESQL.cidr_match.markdown": "\n\n ### CIDR_MATCH\n Renvoie `true` si l'IP fournie est contenue dans l'un des blocs CIDR fournis.\n\n ````\n FROM hosts \n | WHERE CIDR_MATCH(ip1, \"127.0.0.2/32\", \"127.0.0.3/32\") \n | KEEP card, host, ip0, ip1\n ````\n ", + "languageDocumentation.documentationESQL.cidr_match.markdown": " ### CIDR_MATCH Renvoie `true` si l'IP fournie est contenue dans l'un des blocs CIDR fournis. ``` FROM hosts | WHERE CIDR_MATCH(ip1, \"127.0.0.2/32\", \"127.0.0.3/32\") | KEEP card, host, ip0, ip1 ```", "languageDocumentation.documentationESQL.coalesce": "COALESCE", - "languageDocumentation.documentationESQL.coalesce.markdown": "\n\n ### COALESCE\n Renvoie le premier de ses arguments qui n'est pas nul. Si tous les arguments sont nuls, `null` est renvoyé.\n\n ````\n ROW a=null, b=\"b\"\n | EVAL COALESCE(a, b)\n ````\n ", + "languageDocumentation.documentationESQL.coalesce.markdown": " ### COALESCE Renvoie le premier de ses arguments qui n'est pas nul. Si tous les arguments sont nuls, `null` est renvoyé. ``` ROW a=null, b=\"b\" | EVAL COALESCE(a, b) ```", "languageDocumentation.documentationESQL.commandsDescription": "Une commande source produit un tableau, habituellement avec des données issues d'Elasticsearch. ES|QL est compatible avec les commandes sources suivantes.", "languageDocumentation.documentationESQL.concat": "CONCAT", - "languageDocumentation.documentationESQL.concat.markdown": "\n\n ### CONCAT\n Concatène deux ou plusieurs chaînes.\n\n ```\n FROM employees\n | KEEP first_name, last_name\n | EVAL fullname = CONCAT(first_name, \" \", last_name)\n ````\n ", + "languageDocumentation.documentationESQL.concat.markdown": " ### CONCAT Concatène deux ou plusieurs chaînes. ``` FROM employees | KEEP first_name, last_name | EVAL fullname = CONCAT(first_name, \" \", last_name) ```", "languageDocumentation.documentationESQL.cos": "COS", - "languageDocumentation.documentationESQL.cos.markdown": "\n\n ### COS\n Renvoie le cosinus d'un angle.\n\n ````\n ROW a=1.8 \n | EVAL cos=COS(a)\n ````\n ", + "languageDocumentation.documentationESQL.cos.markdown": " ### COS Renvoie le cosinus d'un angle. ``` ROW a=1.8 | EVAL cos=COS(a) ```", "languageDocumentation.documentationESQL.cosh": "COSH", - "languageDocumentation.documentationESQL.cosh.markdown": "\n\n ### COSH\n Renvoie le cosinus hyperbolique d'un angle.\n\n ````\n ROW a=1.8 \n | EVAL cosh=COSH(a)\n ```\n ", + "languageDocumentation.documentationESQL.cosh.markdown": " ### COSH Renvoie le cosinus hyperbolique d'un nombre. ``` ROW a=1.8 | EVAL cosh=COSH(a) ```", + "languageDocumentation.documentationESQL.count": "COUNT", + "languageDocumentation.documentationESQL.count_distinct": "COUNT_DISTINCT", + "languageDocumentation.documentationESQL.count_distinct.markdown": " ### COUNT_DISTINCT Renvoie le nombre approximatif de valeurs distinctes. ``` FROM hosts | STATS COUNT_DISTINCT(ip0), COUNT_DISTINCT(ip1) ```", + "languageDocumentation.documentationESQL.count.markdown": " ### COUNT Renvoie le nombre total de valeurs en entrée. ``` FROM employees | STATS COUNT(height) ```", "languageDocumentation.documentationESQL.date_diff": "DATE_DIFF", - "languageDocumentation.documentationESQL.date_diff.markdown": "\n\n ### DATE_DIFF\n Soustrait le `startTimestamp` du `endTimestamp` et renvoie la différence en multiples `d'unité`.\n Si `startTimestamp` est postérieur à `endTimestamp`, des valeurs négatives sont renvoyées.\n\n ````\n ROW date1 = TO_DATETIME(\"2023-12-02T11:00:00.000Z\"), date2 = TO_DATETIME(\"2023-12-02T11:00:00.001Z\")\n | EVAL dd_ms = DATE_DIFF(\"microseconds\", date1, date2)\n ````\n ", + "languageDocumentation.documentationESQL.date_diff.markdown": " ### DATE_DIFF Soustrait le `startTimestamp` du `endTimestamp` et renvoie la différence en multiples d'unité (`unit`). Si `startTimestamp` est postérieur à `endTimestamp`, des valeurs négatives sont renvoyées. ``` ROW date1 = TO_DATETIME(\"2023-12-02T11:00:00.000Z\"), date2 = TO_DATETIME(\"2023-12-02T11:00:00.001Z\") | EVAL dd_ms = DATE_DIFF(\"microseconds\", date1, date2) ```", "languageDocumentation.documentationESQL.date_extract": "DATE_EXTRACT", - "languageDocumentation.documentationESQL.date_extract.markdown": "\n\n ### DATE_EXTRACT\n Extrait des parties d'une date, telles que l'année, le mois, le jour, l'heure.\n\n ````\n ROW date = DATE_PARSE(\"yyyy-MM-dd\", \"2022-05-06\")\n | EVAL year = DATE_EXTRACT(\"year\", date)\n ````\n ", + "languageDocumentation.documentationESQL.date_extract.markdown": " ### DATE_EXTRACT Extrait des parties d'une date, telles que l'année, le mois, le jour, l'heure. ``` ROW date = DATE_PARSE(\"yyyy-MM-dd\", \"2022-05-06\") | EVAL year = DATE_EXTRACT(\"year\", date) ```", "languageDocumentation.documentationESQL.date_format": "DATE_FORMAT", - "languageDocumentation.documentationESQL.date_format.markdown": "\n\n ### DATE_FORMAT\n Renvoie une représentation sous forme de chaîne d'une date dans le format fourni.\n\n ````\n FROM employees\n | KEEP first_name, last_name, hire_date\n | EVAL hired = DATE_FORMAT(\"YYYY-MM-dd\", hire_date)\n ````\n ", + "languageDocumentation.documentationESQL.date_format.markdown": " ### DATE_FORMAT Renvoie une représentation sous forme de chaîne d'une date dans le format fourni. ``` FROM employees | KEEP first_name, last_name, hire_date | EVAL hired = DATE_FORMAT(\"yyyy-MM-dd\", hire_date) ```", "languageDocumentation.documentationESQL.date_parse": "DATE_PARSE", - "languageDocumentation.documentationESQL.date_parse.markdown": "\n\n ### DATE_PARSE\n Renvoie une date en analysant le deuxième argument selon le format spécifié dans le premier argument.\n\n ````\n ROW date_string = \"2022-05-06\"\n | EVAL date = DATE_PARSE(\"yyyy-MM-dd\", date_string)\n ````\n ", + "languageDocumentation.documentationESQL.date_parse.markdown": " ### DATE_PARSE Renvoie une date en analysant le deuxième argument selon le format spécifié dans le premier argument. ``` ROW date_string = \"2022-05-06\" | EVAL date = DATE_PARSE(\"yyyy-MM-dd\", date_string) ```", "languageDocumentation.documentationESQL.date_trunc": "DATE_TRUNC", - "languageDocumentation.documentationESQL.date_trunc.markdown": "\n\n ### DATE_TRUNC\n Arrondit une date à l'intervalle le plus proche.\n\n ```\n FROM employees\n | KEEP first_name, last_name, hire_date\n | EVAL year_hired = DATE_TRUNC(1 year, hire_date)\n ````\n ", + "languageDocumentation.documentationESQL.date_trunc.markdown": " ### DATE_TRUNC Arrondit une date à l'intervalle le plus proche. ``` FROM employees | KEEP first_name, last_name, hire_date | EVAL year_hired = DATE_TRUNC(1 year, hire_date) ```", "languageDocumentation.documentationESQL.dissect": "DISSECT", - "languageDocumentation.documentationESQL.dissect.markdown": "### DISSECT\n`DISSECT` vous permet d'extraire des données structurées d'une chaîne. `DISSECT` compare la chaîne à un modèle basé sur les délimiteurs, et extrait les clés indiquées en tant que colonnes.\n\nPour obtenir la syntaxe des modèles \"dissect\", consultez [la documentation relative au processeur \"dissect\"](https://www.elastic.co/guide/en/elasticsearch/reference/current/dissect-processor.html).\n\n```\nROW a = \"1953-01-23T12:15:00Z - some text - 127.0.0.1\"\n| DISSECT a \"%'{Y}-%{M}-%{D}T%{h}:%{m}:%{s}Z - %{msg} - %{ip}'\"\n```` ", + "languageDocumentation.documentationESQL.dissect.markdown": "### DISSECT `DISSECT` vous permet d'extraire des données structurées d'une chaîne. `DISSECT` compare la chaîne à un modèle basé sur les délimiteurs, et extrait les clés indiquées en tant que colonnes. Pour obtenir la syntaxe des modèles \"dissect\", consultez la [documentation relative au processeur \"dissect\"](https://www.elastic.co/guide/en/elasticsearch/reference/current/dissect-processor.html). ``` ROW a = \"1953-01-23T12:15:00Z - some text - 127.0.0.1\" | DISSECT a \"%'{Y}-%{M}-%{D}T%{h}:%{m}:%{s}Z - %{msg} - %{ip}'\" ```", "languageDocumentation.documentationESQL.drop": "DROP", - "languageDocumentation.documentationESQL.drop.markdown": "### DROP\nAfin de supprimer certaines colonnes d'un tableau, utilisez `DROP` :\n \n```\nFROM employees\n| DROP height\n```\n\nPlutôt que de spécifier chaque colonne par son nom, vous pouvez utiliser des caractères génériques pour supprimer toutes les colonnes dont le nom correspond à un modèle :\n\n```\nFROM employees\n| DROP height*\n````\n ", + "languageDocumentation.documentationESQL.drop.markdown": "### DROP Afin de supprimer certaines colonnes d'un tableau, utilisez `DROP` : ``` FROM employees | DROP height ``` Plutôt que de spécifier chaque colonne par son nom, vous pouvez utiliser des caractères génériques pour supprimer toutes les colonnes dont le nom correspond à un modèle : ``` FROM employees | DROP height* ```", "languageDocumentation.documentationESQL.e": "E", - "languageDocumentation.documentationESQL.e.markdown": "\n\n ### E\n Retourne le nombre d'Euler.\n\n ````\n ROW E()\n ````\n ", + "languageDocumentation.documentationESQL.e.markdown": " ### E Renvoie le nombre d'Euler. ``` ROW E() ```", "languageDocumentation.documentationESQL.ends_with": "ENDS_WITH", - "languageDocumentation.documentationESQL.ends_with.markdown": "\n\n ### ENDS_WITH\n Renvoie une valeur booléenne qui indique si une chaîne de mots-clés se termine par une autre chaîne.\n\n ````\n FROM employees\n | KEEP last_name\n | EVAL ln_E = ENDS_WITH(last_name, \"d\")\n ````\n ", + "languageDocumentation.documentationESQL.ends_with.markdown": " ### ENDS_WITH Renvoie une valeur booléenne qui indique si une chaîne de mots-clés se termine par une autre chaîne. ``` FROM employees | KEEP last_name | EVAL ln_E = ENDS_WITH(last_name, \"d\") ```", "languageDocumentation.documentationESQL.enrich": "ENRICH", - "languageDocumentation.documentationESQL.enrich.markdown": "### ENRICH\nVous pouvez utiliser `ENRICH` pour ajouter les données de vos index existants aux enregistrements entrants. Une fonction similaire à l'[enrichissement par ingestion](https://www.elastic.co/guide/en/elasticsearch/reference/current/ingest-enriching-data.html), mais qui fonctionne au moment de la requête.\n\n```\nROW language_code = \"1\"\n| ENRICH languages_policy\n```\n\n`ENRICH` requiert l'exécution d'une [politique d'enrichissement](https://www.elastic.co/guide/en/elasticsearch/reference/current/ingest-enriching-data.html#enrich-policy). La politique d'enrichissement définit un champ de correspondance (un champ clé) et un ensemble de champs d'enrichissement.\n\n`ENRICH` recherche les enregistrements dans l'[index d'enrichissement](https://www.elastic.co/guide/en/elasticsearch/reference/current/ingest-enriching-data.html#enrich-index) en se basant sur la valeur du champ de correspondance. La clé de correspondance dans l'ensemble de données d'entrée peut être définie en utilisant `ON `. Si elle n'est pas spécifiée, la correspondance sera effectuée sur un champ portant le même nom que le champ de correspondance défini dans la politique d'enrichissement.\n\n```\nROW a = \"1\"\n| ENRICH languages_policy ON a\n```\n\nVous pouvez indiquer quels attributs (parmi ceux définis comme champs d'enrichissement dans la politique) doivent être ajoutés au résultat, en utilisant la syntaxe `WITH , ...`.\n\n```\nROW a = \"1\"\n| ENRICH languages_policy ON a WITH language_name\n```\n\nLes attributs peuvent également être renommés à l'aide de la syntaxe `WITH new_name=`\n\n```\nROW a = \"1\"\n| ENRICH languages_policy ON a WITH name = language_name\n````\n\nPar défaut (si aucun `WITH` n'est défini), `ENRICH` ajoute au résultat tous les champs d'enrichissement définis dans la politique d'enrichissement.\n\nEn cas de collision de noms, les champs nouvellement créés remplacent les champs existants.\n ", + "languageDocumentation.documentationESQL.enrich.markdown": "### ENRICH Vous pouvez utiliser `ENRICH` pour ajouter les données de vos index existants aux enregistrements entrants. Cette fonction est similaire à [\"ingest enrich\"](https://www.elastic.co/guide/en/elasticsearch/reference/current/ingest-enriching-data.html), mais agit au moment de la requête. ``` ROW language_code = \"1\" | ENRICH languages_policy ``` `ENRICH` nécessite l'exécution d'une [politique d'enrichissement](https://www.elastic.co/guide/en/elasticsearch/reference/current/ingest-enriching-data.html#enrich-policy). La politique d'enrichissement définit un champ de correspondance (un champ clé) et un ensemble de champs d'enrichissement. `ENRICH` recherchera les enregistrements dans l'[index d'enrichissement](https://www.elastic.co/guide/en/elasticsearch/reference/current/ingest-enriching-data.html#enrich-index) en se basant sur la valeur du champ de correspondance. La clé de correspondance dans l'ensemble de données d'entrée peut être définie en utilisant `ON `. Si elle n'est pas spécifiée, la correspondance sera effectuée sur un champ portant le même nom que le champ de correspondance défini dans la politique d'enrichissement. ``` ROW a = \"1\" | ENRICH languages_policy ON a ``` Vous pouvez indiquer quels attributs (parmi ceux définis comme champs d'enrichissement dans la politique) doivent être ajoutés au résultat, en utilisant la syntaxe `WITH , ...`. ``` ROW a = \"1\" | ENRICH languages_policy ON a WITH language_name ``` Les attributs peuvent également être renommés à l'aide de `WITH new_name=` ``` ROW a = \"1\" | ENRICH languages_policy ON a WITH name = language_name ``` Par défaut (si aucun `WITH` n'est défini), `ENRICH` ajoute au résultat tous les champs d'enrichissement définis dans la politique d'enrichissement. En cas de collision de noms, les champs nouvellement créés remplacent les champs existants.", "languageDocumentation.documentationESQL.eval": "EVAL", - "languageDocumentation.documentationESQL.eval.markdown": "### EVAL\n`EVAL` permet d'ajouter des colonnes :\n\n````\nFROM employees\n| KEEP first_name, last_name, height\n| EVAL height_feet = height * 3.281, height_cm = height * 100\n````\n\nSi la colonne indiquée existe déjà, la colonne existante sera supprimée et la nouvelle colonne sera ajoutée au tableau :\n\n````\nFROM employees\n| KEEP first_name, last_name, height\n| EVAL height = height * 3.281\n````\n\n#### Fonctions\n`EVAL` prend en charge diverses fonctions de calcul des valeurs. Pour en savoir plus, consultez les fonctions.\n ", + "languageDocumentation.documentationESQL.eval.markdown": "### EVAL `EVAL` permet d'ajouter des colonnes : ``` FROM employees | KEEP first_name, last_name, height | EVAL height_feet = height * 3.281, height_cm = height * 100 ``` Si la colonne indiquée existe déjà, la colonne existante sera supprimée et la nouvelle colonne sera ajoutée au tableau : ``` FROM employees | KEEP first_name, last_name, height | EVAL height = height * 3.281 ``` #### Fonctions `EVAL` prend en charge diverses fonctions de calcul des valeurs. Pour en savoir plus, consultez les fonctions.", + "languageDocumentation.documentationESQL.exp": "EXP", + "languageDocumentation.documentationESQL.exp.markdown": " ### EXP Renvoie la valeur de \"e\" élevée à la puissance d'un nombre donné. ``` ROW d = 5.0 | EVAL s = EXP(d) ```", "languageDocumentation.documentationESQL.floor": "FLOOR", - "languageDocumentation.documentationESQL.floor.markdown": "\n\n ### FLOOR\n Arrondir un nombre à l'entier inférieur.\n\n ````\n ROW a=1.8\n | EVAL a=FLOOR(a)\n ````\n Remarque : Il s'agit d'un noop pour `long` (y compris non signé) et `integer`.\n Pour `double`, la fonction choisit la valeur `double` la plus proche de l'entier,\n de manière similaire à Math.floor.\n ", + "languageDocumentation.documentationESQL.floor.markdown": " ### FLOOR Arrondit un nombre à l'entier inférieur. ``` ROW a=1.8 | EVAL a=FLOOR(a) ``` Remarque : Il s'agit d'un noop pour `long` (y compris non signé) et `integer`. Pour `double`, la fonction choisit la valeur `double` la plus proche de l'entier, de manière similaire à la méthode Math.floor.", "languageDocumentation.documentationESQL.from": "FROM", "languageDocumentation.documentationESQL.from_base64": "FROM_BASE64", - "languageDocumentation.documentationESQL.from_base64.markdown": "\n\n ### FROM_BASE64\n Décodez une chaîne base64.\n\n ````\n row a = \"ZWxhc3RpYw==\" \n | eval d = from_base64(a)\n ````\n ", - "languageDocumentation.documentationESQL.from.markdown": "### FROM\nLa commande source `FROM` renvoie un tableau contenant jusqu'à 10 000 documents issus d'un flux de données, d'un index ou d'un alias. Chaque ligne du tableau obtenu correspond à un document. Chaque colonne correspond à un champ et est accessible par le nom de ce champ.\n\n````\nFROM employees\n````\n\nVous pouvez utiliser des [calculs impliquant des dates](https://www.elastic.co/guide/en/elasticsearch/reference/current/api-conventions.html#api-date-math-index-names) pour désigner les indices, les alias et les flux de données. Cela peut s'avérer utile pour les données temporelles.\n\nUtilisez des listes séparées par des virgules ou des caractères génériques pour rechercher plusieurs flux de données, indices ou alias :\n\n````\nFROM employees-00001,employees-*\n````\n\n#### Métadonnées\n\nES|QL peut accéder aux champs de métadonnées suivants :\n\n* `_index` : l'index auquel appartient le document. Le champ est du type `keyword`.\n* `_id` : l'identifiant du document source. Le champ est du type `keyword`.\n* `_id` : la version du document source. Le champ est du type `long`.\n\nUtilisez la directive `METADATA` pour activer les champs de métadonnées :\n\n````\nFROM index [METADATA _index, _id]\n````\n\nLes champs de métadonnées ne sont disponibles que si la source des données est un index. Par conséquent, `FROM` est la seule commande source qui prend en charge la directive `METADATA`.\n\nUne fois activés, les champs sont disponibles pour les commandes de traitement suivantes, tout comme les autres champs de l'index :\n\n````\nFROM ul_logs, apps [METADATA _index, _version]\n| WHERE id IN (13, 14) AND _version == 1\n| EVAL key = CONCAT(_index, \"_\", TO_STR(id))\n| SORT id, _index\n| KEEP id, _index, _version, key\n````\n\nDe même, comme pour les champs d'index, une fois l'agrégation effectuée, un champ de métadonnées ne sera plus accessible aux commandes suivantes, sauf s'il est utilisé comme champ de regroupement :\n\n````\nFROM employees [METADATA _index, _id]\n| STATS max = MAX(emp_no) BY _index\n````\n ", + "languageDocumentation.documentationESQL.from_base64.markdown": " ### FROM_BASE64 Décode une chaîne base64. ``` row a = \"ZWxhc3RpYw==\" | eval d = from_base64(a) ```", + "languageDocumentation.documentationESQL.from.markdown": "### FROM La commande source `FROM` renvoie un tableau contenant jusqu'à 10 000 documents issus d'un flux de données, d'un index ou d'un alias. Chaque ligne du tableau obtenu correspond à un document. Chaque colonne correspond à un champ et est accessible par le nom de ce champ. ``` FROM employees ``` Vous pouvez utiliser des [calculs impliquant des dates](https://www.elastic.co/guide/en/elasticsearch/reference/current/api-conventions.html#api-date-math-index-names) pour désigner les indices, les alias et les flux de données. Cela peut s'avérer utile pour les données temporelles. Utilisez des listes séparées par des virgules ou des caractères génériques pour rechercher plusieurs flux de données, indices ou alias : ``` FROM employees-00001,employees-* ``` #### Métadonnées ES|QL peut accéder aux champs de métadonnées suivants : * `_index` : l'index auquel appartient le document. Le champ est du type `keyword`. * `_id` : l'identifiant du document source. Le champ est du type `keyword`. * `_version` : la version du document source. Le champ est du type `long`. Utilisez la directive `METADATA` pour activer les champs de métadonnées : ``` FROM index [METADATA _index, _id] ``` Les champs de métadonnées ne sont disponibles que si la source des données est un index. Par conséquent, `FROM` est la seule commande source qui prend en charge la directive `METADATA`. Une fois activés, les champs sont disponibles pour les commandes de traitement suivantes, tout comme les autres champs de l'index : ``` FROM ul_logs, apps [METADATA _index, _version] | WHERE id IN (13, 14) AND _version == 1 | EVAL key = CONCAT(_index, \"_\", TO_STR(id)) | SORT id, _index | KEEP id, _index, _version, key ``` De même, comme pour les champs d'index, une fois l'agrégation effectuée, un champ de métadonnées ne sera plus accessible aux commandes suivantes, sauf s'il est utilisé comme champ de regroupement : ``` FROM employees [METADATA _index, _id] | STATS max = MAX(emp_no) BY _index ```", "languageDocumentation.documentationESQL.functions": "Fonctions", "languageDocumentation.documentationESQL.functionsDocumentationESQLDescription": "Les fonctions sont compatibles avec \"ROW\" (Ligne), \"EVAL\" (Évaluation) et \"WHERE\" (Où).", "languageDocumentation.documentationESQL.greatest": "GREATEST", - "languageDocumentation.documentationESQL.greatest.markdown": "\n\n ### GREATEST\n Renvoie la valeur maximale de plusieurs colonnes. Similaire à `MV_MAX`\n sauf que ceci est destiné à une exécution sur plusieurs colonnes à la fois.\n\n ````\n ROW a = 10, b = 20\n | EVAL g = GREATEST(a, b)\n ````\n Remarque : Lorsque cette fonction est exécutée sur les champs `keyword` ou `text`, elle renvoie la dernière chaîne dans l'ordre alphabétique. Lorsqu'elle est exécutée sur des colonnes `boolean`, elle renvoie `true` si l'une des valeurs l'est.\n ", + "languageDocumentation.documentationESQL.greatest.markdown": " ### GREATEST Renvoie la valeur maximale de plusieurs colonnes. Cette fonction est similaire à `MV_MAX`. Toutefois, elle est destinée à être exécutée sur plusieurs colonnes à la fois. ``` ROW a = 10, b = 20 | EVAL g = GREATEST(a, b) ``` Remarque : Lorsque cette fonction est exécutée sur les champs `keyword` ou `text`, elle renvoie la dernière chaîne dans l'ordre alphabétique. Lorsqu'elle est exécutée sur des colonnes `boolean`, elle renvoie `true` si l'une des valeurs l'est.", "languageDocumentation.documentationESQL.grok": "GROK", - "languageDocumentation.documentationESQL.grok.markdown": "### GROK\n`GROK` vous permet d'extraire des données structurées d'une chaîne. `GROK` compare la chaîne à des modèles, sur la base d’expressions régulières, et extrait les modèles indiqués en tant que colonnes.\n\nPour obtenir la syntaxe des modèles \"grok\", consultez [la documentation relative au processeur \"grok\"](https://www.elastic.co/guide/en/elasticsearch/reference/current/grok-processor.html).\n\n````\nROW a = \"12 15.5 15.6 true\"\n| GROK a \"%'{NUMBER:b:int}' %'{NUMBER:c:float}' %'{NUMBER:d:double}' %'{WORD:e:boolean}'\"\n````\n ", + "languageDocumentation.documentationESQL.grok.markdown": "### GROK `GROK` vous permet d'extraire des données structurées d'une chaîne. `GROK` compare la chaîne à des modèles, sur la base d'expressions régulières, et extrait les modèles indiqués en tant que colonnes. Pour obtenir la syntaxe des modèles \"grok\", consultez la [documentation relative au processeur \"grok\"](https://www.elastic.co/guide/en/elasticsearch/reference/current/grok-processor.html). ``` ROW a = \"12 15.5 15.6 true\" | GROK a \"%'{NUMBER:b:int}' %'{NUMBER:c:float}' %'{NUMBER:d:double}' %'{WORD:e:boolean}'\" ```", "languageDocumentation.documentationESQL.groupingFunctions": "Fonctions de groupage", "languageDocumentation.documentationESQL.groupingFunctionsDocumentationESQLDescription": "Ces fonctions de regroupement peuvent être utilisées avec `STATS...BY` :", + "languageDocumentation.documentationESQL.hypot": "HYPOT", + "languageDocumentation.documentationESQL.hypot.markdown": " ### HYPOT Renvoie l'hypoténuse de deux nombres. La valeur de renvoi est toujours un double, quelle que soit la valeur numérique de l'entrée. Les hypoténuses des infinis sont nulles. ``` ROW a = 3.0, b = 4.0 | EVAL c = HYPOT(a, b) ```", "languageDocumentation.documentationESQL.inOperator": "IN", - "languageDocumentation.documentationESQL.inOperator.markdown": "### IN\nL'opérateur `IN` permet de tester si un champ ou une expression est égal à un élément d'une liste de littéraux, de champs ou d'expressions :\n\n````\nROW a = 1, b = 4, c = 3\n| WHERE c-a IN (3, b / 2, a)\n````\n ", + "languageDocumentation.documentationESQL.inOperator.markdown": "### IN L'opérateur `IN` permet de tester si un champ ou une expression sont égaux à un élément d'une liste de littéraux, de champs ou d'expressions : ``` ROW a = 1, b = 4, c = 3 | WHERE c-a IN (3, b / 2, a) ```", "languageDocumentation.documentationESQL.ip_prefix": "IP_PREFIX", - "languageDocumentation.documentationESQL.ip_prefix.markdown": "\n\n ### IP_PREFIX\n Tronque une adresse IP à une longueur de préfixe donnée.\n\n ````\n row ip4 = to_ip(\"1.2.3.4\"), ip6 = to_ip(\"fe80::cae2:65ff:fece:feb9\")\n | eval ip4_prefix = ip_prefix(ip4, 24, 0), ip6_prefix = ip_prefix(ip6, 0, 112);\n ````\n ", + "languageDocumentation.documentationESQL.ip_prefix.markdown": " ### IP_PREFIX Tronque une adresse IP à une longueur de préfixe donnée. ``` row ip4 = to_ip(\"1.2.3.4\"), ip6 = to_ip(\"fe80::cae2:65ff:fece:feb9\") | eval ip4_prefix = ip_prefix(ip4, 24, 0), ip6_prefix = ip_prefix(ip6, 0, 112); ```", "languageDocumentation.documentationESQL.keep": "KEEP", - "languageDocumentation.documentationESQL.keep.markdown": "### KEEP\nLa commande `KEEP` permet de définir les colonnes qui seront renvoyées et l'ordre dans lequel elles le seront.\n\nPour limiter les colonnes retournées, utilisez une liste de noms de colonnes séparés par des virgules. Les colonnes sont renvoyées dans l'ordre indiqué :\n \n````\nFROM employees\n| KEEP first_name, last_name, height\n````\n\nPlutôt que de spécifier chaque colonne par son nom, vous pouvez utiliser des caractères génériques pour renvoyer toutes les colonnes dont le nom correspond à un modèle :\n\n````\nFROM employees\n| KEEP h*\n````\n\nLe caractère générique de l'astérisque (\"*\") placé de manière isolée transpose l'ensemble des colonnes qui ne correspondent pas aux autres arguments. La requête suivante renverra en premier lieu toutes les colonnes dont le nom commence par un h, puis toutes les autres colonnes :\n\n````\nFROM employees\n| KEEP h*, *\n````\n ", + "languageDocumentation.documentationESQL.keep.markdown": "### KEEP La commande `KEEP` permet de définir les colonnes qui seront renvoyées et l'ordre dans lequel elles le seront. Pour limiter les colonnes retournées, utilisez une liste de noms de colonnes séparés par des virgules. Les colonnes sont renvoyées dans l'ordre indiqué : ``` FROM employees | KEEP first_name, last_name, height ``` Plutôt que de spécifier chaque colonne par son nom, vous pouvez utiliser des caractères génériques pour renvoyer toutes les colonnes dont le nom correspond à un modèle : ``` FROM employees | KEEP h* ``` Le caractère générique de l'astérisque (`*`) placé de manière isolée transpose l'ensemble des colonnes qui ne correspondent pas aux autres arguments. La requête suivante renverra en premier lieu toutes les colonnes dont le nom commence par un \"h\", puis toutes les autres colonnes : ``` FROM employees | KEEP h*, * ```", "languageDocumentation.documentationESQL.least": "LEAST", - "languageDocumentation.documentationESQL.least.markdown": "\n\n ### LEAST\n Renvoie la valeur minimale de plusieurs colonnes. Cette fonction est similaire à `MV_MIN`. Toutefois, elle est destinée à être exécutée sur plusieurs colonnes à la fois.\n\n ````\n ROW a = 10, b = 20\n | EVAL l = LEAST(a, b)\n ````\n ", + "languageDocumentation.documentationESQL.least.markdown": " ### LEAST Renvoie la valeur minimale de plusieurs colonnes. Cette fonction est similaire à `MV_MIN`. Toutefois, elle est destinée à être exécutée sur plusieurs colonnes à la fois. ``` ROW a = 10, b = 20 | EVAL l = LEAST(a, b) ```", "languageDocumentation.documentationESQL.left": "LEFT", - "languageDocumentation.documentationESQL.left.markdown": "\n\n ### LEFT\n Renvoie la sous-chaîne qui extrait la \"longueur\" des caractères de la \"chaîne\" en partant de la gauche.\n\n ````\n FROM employees\n | KEEP last_name\n | EVAL left = LEFT(last_name, 3)\n | SORT last_name ASC\n | LIMIT 5\n ````\n ", + "languageDocumentation.documentationESQL.left.markdown": " ### LEFT Renvoie la sous-chaîne qui extrait la \"longueur\" des caractères de la \"chaîne\" en partant de la gauche. ``` FROM employees | KEEP last_name | EVAL left = LEFT(last_name, 3) | SORT last_name ASC | LIMIT 5 ```", "languageDocumentation.documentationESQL.length": "LENGHT", - "languageDocumentation.documentationESQL.length.markdown": "\n\n ### LENGTH\n Renvoie la longueur des caractères d'une chaîne.\n\n ````\n FROM employees\n | KEEP first_name, last_name\n | EVAL fn_length = LENGTH(first_name)\n ````\n ", + "languageDocumentation.documentationESQL.length.markdown": " ### LENGTH Renvoie la longueur des caractères d'une chaîne. ``` FROM employees | KEEP first_name, last_name | EVAL fn_length = LENGTH(first_name) ```", "languageDocumentation.documentationESQL.limit": "LIMIT", - "languageDocumentation.documentationESQL.limit.markdown": "### LIMIT\nLa commande de traitement `LIMIT` permet de restreindre le nombre de lignes :\n \n````\nFROM employees\n| LIMIT 5\n````\n ", + "languageDocumentation.documentationESQL.limit.markdown": "### LIMIT La commande de traitement `LIMIT` permet de restreindre le nombre de lignes : ``` FROM employees | LIMIT 5 ```", "languageDocumentation.documentationESQL.locate": "LOCATE", - "languageDocumentation.documentationESQL.locate.markdown": "\n\n ### LOCATE\n Renvoie un entier qui indique la position d'une sous-chaîne de mots-clés dans une autre chaîne\n\n ````\n row a = \"hello\"\n | eval a_ll = locate(a, \"ll\")\n ````\n ", + "languageDocumentation.documentationESQL.locate.markdown": " ### LOCATE Renvoie un entier qui indique la position d'une sous-chaîne de mots-clés dans une autre chaîne. Renvoie `0` si la sous-chaîne ne peut pas être trouvée. Notez que les positions des chaînes commencent à partir de `1`. ``` row a = \"hello\" | eval a_ll = locate(a, \"ll\") ```", "languageDocumentation.documentationESQL.log": "LOG", - "languageDocumentation.documentationESQL.log.markdown": "\n\n ### LOG\n Renvoie le logarithme d'une valeur dans une base. La valeur de renvoi est toujours un double, quelle que soit la valeur numérique de l'entrée.\n\n Les journaux de zéros, de nombres négatifs et de base 1 renvoient `null` ainsi qu'un avertissement.\n\n ````\n ROW base = 2.0, value = 8.0\n | EVAL s = LOG(base, value)\n ````\n ", + "languageDocumentation.documentationESQL.log.markdown": " ### LOG Renvoie le logarithme d'une valeur dans une base. La valeur de renvoi est toujours un double, quelle que soit la valeur numérique de l'entrée. Les logs de zéros, de nombres négatifs et de base 1 renvoient `null` ainsi qu'un avertissement. ``` ROW base = 2.0, value = 8.0 | EVAL s = LOG(base, value) ```", "languageDocumentation.documentationESQL.log10": "LOG10", - "languageDocumentation.documentationESQL.log10.markdown": "\n\n ### LOG10\n Renvoie le logarithme d'une valeur en base 10. La valeur de renvoi est toujours un double, quelle que soit la valeur numérique de l'entrée.\n\n Les logs de 0 et de nombres négatifs renvoient `null` ainsi qu'un avertissement.\n\n ````\n ROW d = 1000.0 \n | EVAL s = LOG10(d)\n ````\n ", + "languageDocumentation.documentationESQL.log10.markdown": " ### LOG10 Renvoie le logarithme d'une valeur en base 10. La valeur de renvoi est toujours un double, quelle que soit la valeur numérique de l'entrée. Les logs de 0 et de nombres négatifs renvoient `null` ainsi qu'un avertissement. ``` ROW d = 1000.0 | EVAL s = LOG10(d) ```", "languageDocumentation.documentationESQL.ltrim": "LTRIM", - "languageDocumentation.documentationESQL.ltrim.markdown": "\n\n ### LTRIM\n Retire les espaces au début des chaînes.\n\n ````\n ROW message = \" some text \", color = \" red \"\n | EVAL message = LTRIM(message)\n | EVAL color = LTRIM(color)\n | EVAL message = CONCAT(\"'\", message, \"'\")\n | EVAL color = CONCAT(\"'\", color, \"'\")\n ````\n ", - "languageDocumentation.documentationESQL.markdown": "## ES|QL\n\nUne requête ES|QL (langage de requête Elasticsearch) se compose d'une série de commandes, séparées par une barre verticale : `|`. Chaque requête commence par une **commande source**, qui produit un tableau, habituellement avec des données issues d'Elasticsearch. \n\nUne commande source peut être suivie d'une ou plusieurs **commandes de traitement**. Les commandes de traitement peuvent modifier le tableau de sortie de la commande précédente en ajoutant, supprimant ou modifiant les lignes et les colonnes.\n\n````\nsource-command\n| processing-command1\n| processing-command2\n````\n\nLe résultat d'une requête est le tableau produit par la dernière commande de traitement. \n ", + "languageDocumentation.documentationESQL.ltrim.markdown": " ### LTRIM Supprime les espaces au début d'une chaîne. ``` ROW message = \" some text \", color = \" red \" | EVAL message = LTRIM(message) | EVAL color = LTRIM(color) | EVAL message = CONCAT(\"'\", message, \"'\") | EVAL color = CONCAT(\"'\", color, \"'\") ```", + "languageDocumentation.documentationESQL.markdown": "Une requête ES|QL (langage de requête Elasticsearch) se compose d'une série de commandes, séparées par une barre verticale : `|`. Chaque requête commence par une **commande source**, qui produit un tableau, habituellement avec des données issues d'Elasticsearch. Une commande source peut être suivie d'une ou plusieurs **commandes de traitement**. Les commandes de traitement peuvent modifier le tableau de sortie de la commande précédente en ajoutant, supprimant ou modifiant les lignes et les colonnes. ``` source-command | processing-command1 | processing-command2 ``` Le résultat d'une requête est le tableau produit par la dernière commande de traitement.", + "languageDocumentation.documentationESQL.max": "MAX", + "languageDocumentation.documentationESQL.max.markdown": " ### MAX La valeur maximale d'un champ. ``` FROM employees | STATS MAX(languages) ```", + "languageDocumentation.documentationESQL.median": "MEDIAN", + "languageDocumentation.documentationESQL.median_absolute_deviation": "MEDIAN_ABSOLUTE_DEVIATION", + "languageDocumentation.documentationESQL.median_absolute_deviation.markdown": " ### MEDIAN_ABSOLUTE_DEVIATION Renvoie l'écart absolu médian, une mesure de la variabilité. Il s'agit d'un indicateur robuste, ce qui signifie qu'il est utile pour décrire des données qui peuvent présenter des valeurs aberrantes ou ne pas être normalement distribuées. Pour de telles données, il peut être plus descriptif que l'écart-type. Il est calculé comme la médiane de chaque écart de point de données par rapport à la médiane de l'ensemble de l'échantillon. Autrement dit, pour une variable aléatoire `X`, l'écart absolu médian est `median(|median(X) - X|)`. ``` FROM employees | STATS MEDIAN(salary), MEDIAN_ABSOLUTE_DEVIATION(salary) ``` Remarque : Comme `PERCENTILE`, `MEDIAN_ABSOLUTE_DEVIATION` est généralement approximatif.", + "languageDocumentation.documentationESQL.median.markdown": " ### MEDIAN La valeur qui est supérieure à la moitié de toutes les valeurs et inférieure à la moitié de toutes les valeurs, également connue sous le nom de `PERCENTILE` 50 %. ``` FROM employees | STATS MEDIAN(salary), PERCENTILE(salary, 50) ``` Remarque : Comme `PERCENTILE`, `MEDIAN` est généralement approximatif.", + "languageDocumentation.documentationESQL.min": "MIN", + "languageDocumentation.documentationESQL.min.markdown": " ### MIN La valeur minimale d'un champ. ``` FROM employees | STATS MIN(languages) ```", "languageDocumentation.documentationESQL.mv_append": "MV_APPEND", - "languageDocumentation.documentationESQL.mv_append.markdown": "\n\n ### MV_APPEND\n Concatène les valeurs de deux champs à valeurs multiples.\n\n ", + "languageDocumentation.documentationESQL.mv_append.markdown": " ### MV_APPEND Concatène les valeurs de deux champs à valeurs multiples.", "languageDocumentation.documentationESQL.mv_avg": "MV_AVG", - "languageDocumentation.documentationESQL.mv_avg.markdown": "\n\n ### MV_AVG\n Convertit un champ multivalué en un champ à valeur unique comprenant la moyenne de toutes les valeurs.\n\n ````\n ROW a=[3, 5, 1, 6]\n | EVAL avg_a = MV_AVG(a)\n ````\n ", + "languageDocumentation.documentationESQL.mv_avg.markdown": " ### MV_AVG Convertit un champ multivalué en un champ à valeur unique comprenant la moyenne de toutes les valeurs. ``` ROW a=[3, 5, 1, 6] | EVAL avg_a = MV_AVG(a) ```", "languageDocumentation.documentationESQL.mv_concat": "MV_CONCAT", - "languageDocumentation.documentationESQL.mv_concat.markdown": "\n\n ### MV_CONCAT\n Convertit une expression de type chaîne multivalué en une colonne à valeur unique comprenant la concaténation de toutes les valeurs, séparées par un délimiteur.\n\n ````\n ROW a=[\"foo\", \"zoo\", \"bar\"]\n | EVAL j = MV_CONCAT(a, \", \")\n ````\n ", + "languageDocumentation.documentationESQL.mv_concat.markdown": " ### MV_CONCAT Convertit une expression de type chaîne multivaluée en une colonne à valeur unique comprenant la concaténation de toutes les valeurs, séparées par un délimiteur. ``` ROW a=[\"foo\", \"zoo\", \"bar\"] | EVAL j = MV_CONCAT(a, \", \") ```", "languageDocumentation.documentationESQL.mv_count": "MV_COUNT", - "languageDocumentation.documentationESQL.mv_count.markdown": "\n\n ### MV_COUNT\n Convertit une expression multivaluée en une colonne à valeur unique comprenant le total du nombre de valeurs.\n\n ````\n ROW a=[\"foo\", \"zoo\", \"bar\"]\n | EVAL count_a = MV_COUNT(a)\n ````\n ", + "languageDocumentation.documentationESQL.mv_count.markdown": " ### MV_COUNT Convertit une expression multivaluée en une colonne à valeur unique comprenant un décompte du nombre de valeurs. ``` ROW a=[\"foo\", \"zoo\", \"bar\"] | EVAL count_a = MV_COUNT(a) ```", "languageDocumentation.documentationESQL.mv_dedupe": "MV_DEDUPE", - "languageDocumentation.documentationESQL.mv_dedupe.markdown": "\n\n ### MV_DEDUPE\n Supprime les valeurs en doublon d'un champ multivalué.\n\n ````\n ROW a=[\"foo\", \"foo\", \"bar\", \"foo\"]\n | EVAL dedupe_a = MV_DEDUPE(a)\n ````\n Remarque : la fonction `MV_DEDUPE` est en mesure de trier les valeurs de la colonne, mais ne le fait pas systématiquement.\n ", + "languageDocumentation.documentationESQL.mv_dedupe.markdown": " ### MV_DEDUPE Supprime les valeurs en doublon d'un champ multivalué. ``` ROW a=[\"foo\", \"foo\", \"bar\", \"foo\"] | EVAL dedupe_a = MV_DEDUPE(a) ``` Remarque : La fonction `MV_DEDUPE` est en mesure de trier les valeurs de la colonne, mais ne le fait pas systématiquement.", "languageDocumentation.documentationESQL.mv_first": "MV_FIRST", - "languageDocumentation.documentationESQL.mv_first.markdown": "\n\n ### MV_FIRST\n Convertit une expression multivaluée en une colonne à valeur unique comprenant la\n première valeur. Ceci est particulièrement utile pour lire une fonction qui émet\n des colonnes multivaluées dans un ordre connu, comme `SPLIT`.\n\n L'ordre dans lequel les champs multivalués sont lus à partir\n du stockage sous-jacent n'est pas garanti. Il est *souvent* ascendant, mais ne vous y\n fiez pas. Si vous avez besoin de la valeur minimale, utilisez `MV_MIN` au lieu de\n `MV_FIRST`. `MV_MIN` comporte des optimisations pour les valeurs triées, il n'y a donc aucun\n avantage en matière de performances pour `MV_FIRST`.\n\n ````\n ROW a=\"foo;bar;baz\"\n | EVAL first_a = MV_FIRST(SPLIT(a, \";\"))\n ````\n ", + "languageDocumentation.documentationESQL.mv_first.markdown": " ### MV_FIRST Convertit une expression multivaluée en une colonne à valeur unique comprenant la valeur minimale. Ceci est particulièrement utile pour lire une fonction qui émet des colonnes multivaluées dans un ordre connu, comme `SPLIT`. ``` ROW a=\"foo;bar;baz\" | EVAL first_a = MV_FIRST(SPLIT(a, \";\")) ```", "languageDocumentation.documentationESQL.mv_last": "MV_LAST", - "languageDocumentation.documentationESQL.mv_last.markdown": "\n\n ### MV_LAST\n Convertit une expression multivaluée en une colonne à valeur unique comprenant la dernière\n valeur. Ceci est particulièrement utile pour lire une fonction qui émet des champs multivalués\n dans un ordre connu, comme `SPLIT`.\n\n L'ordre dans lequel les champs multivalués sont lus à partir\n du stockage sous-jacent n'est pas garanti. Il est *souvent* ascendant, mais ne vous y\n fiez pas. Si vous avez besoin de la valeur maximale, utilisez `MV_MAX` au lieu de\n `MV_LAST`. `MV_MAX` comporte des optimisations pour les valeurs triées, il n'y a donc aucun\n avantage en matière de performances pour `MV_LAST`.\n\n ````\n ROW a=\"foo;bar;baz\"\n | EVAL last_a = MV_LAST(SPLIT(a, \";\"))\n ````\n ", + "languageDocumentation.documentationESQL.mv_last.markdown": " ### MV_LAST Convertit une expression multivaluée en une colonne à valeur unique comprenant la dernière valeur. Ceci est particulièrement utile pour lire une fonction qui émet des colonnes multivaluées dans un ordre connu, comme `SPLIT`. ``` ROW a=\"foo;bar;baz\" | EVAL last_a = MV_LAST(SPLIT(a, \";\")) ```", "languageDocumentation.documentationESQL.mv_max": "MV_MAX", - "languageDocumentation.documentationESQL.mv_max.markdown": "\n\n ### MV_MAX\n Convertit une expression multivaluée en une colonne à valeur unique comprenant la valeur maximale.\n\n ````\n ROW a=[3, 5, 1]\n | EVAL max_a = MV_MAX(a)\n ````\n ", + "languageDocumentation.documentationESQL.mv_max.markdown": " ### MV_MAX Convertit une expression multivaluée en une colonne à valeur unique comprenant la valeur maximale. ``` ROW a=[3, 5, 1] | EVAL max_a = MV_MAX(a) ```", "languageDocumentation.documentationESQL.mv_median": "MV_MEDIAN", - "languageDocumentation.documentationESQL.mv_median.markdown": "\n\n ### MV_MEDIAN\n Convertit un champ multivalué en un champ à valeur unique comprenant la valeur médiane.\n\n ````\n ROW a=[3, 5, 1]\n | EVAL median_a = MV_MEDIAN(a)\n ````\n ", + "languageDocumentation.documentationESQL.mv_median_absolute_deviation": "MV_MEDIAN_ABSOLUTE_DEVIATION", + "languageDocumentation.documentationESQL.mv_median_absolute_deviation.markdown": " ### MV_MEDIAN_ABSOLUTE_DEVIATION Convertit un champ multivalué en un champ à valeur unique comprenant l'écart absolu médian. Il est calculé comme la médiane de chaque écart de point de données par rapport à la médiane de l'ensemble de l'échantillon. Autrement dit, pour une variable aléatoire `X`, l'écart absolu médian est `median(|median(X) - X|)`. ``` ROW values = [0, 2, 5, 6] | EVAL median_absolute_deviation = MV_MEDIAN_ABSOLUTE_DEVIATION(values), median = MV_MEDIAN(values) ``` Remarque : Si le champ a un nombre pair de valeurs, les médianes seront calculées comme la moyenne des deux valeurs du milieu. Si la valeur n'est pas un nombre à virgule flottante, les moyennes sont arrondies vers 0.", + "languageDocumentation.documentationESQL.mv_median.markdown": " ### MV_MEDIAN Convertit un champ multivalué en un champ à valeur unique comprenant la valeur médiane. ``` ROW a=[3, 5, 1] | EVAL median_a = MV_MEDIAN(a) ```", "languageDocumentation.documentationESQL.mv_min": "MV_MIN", - "languageDocumentation.documentationESQL.mv_min.markdown": "\n\n ### MV_MIN\n Convertit une expression multivaluée en une colonne à valeur unique comprenant la valeur minimale.\n\n ````\n ROW a=[2, 1]\n | EVAL min_a = MV_MIN(a)\n ````\n ", + "languageDocumentation.documentationESQL.mv_min.markdown": " ### MV_MIN Convertit une expression multivaluée en une colonne à valeur unique comprenant la valeur minimale. ``` ROW a=[2, 1] | EVAL min_a = MV_MIN(a) ```", + "languageDocumentation.documentationESQL.mv_percentile": "MV_PERCENTILE", + "languageDocumentation.documentationESQL.mv_percentile.markdown": " ### MV_PERCENTILE Convertit un champ multivalué en un champ à valeur unique comprenant la valeur à laquelle un certain pourcentage des valeurs observées se produit. ``` ROW values = [5, 5, 10, 12, 5000] | EVAL p50 = MV_PERCENTILE(values, 50), median = MV_MEDIAN(values) ```", + "languageDocumentation.documentationESQL.mv_pseries_weighted_sum": "MV_PSERIES_WEIGHTED_SUM", + "languageDocumentation.documentationESQL.mv_pseries_weighted_sum.markdown": " ### MV_PSERIES_WEIGHTED_SUM Convertit une expression multivaluée en une colonne à valeur unique en multipliant chaque élément de la liste d'entrée par le terme correspondant dans P-Series et en calculant la somme. ``` ROW a = [70.0, 45.0, 21.0, 21.0, 21.0] | EVAL sum = MV_PSERIES_WEIGHTED_SUM(a, 1.5) | KEEP sum ```", "languageDocumentation.documentationESQL.mv_slice": "MV_SLICE", - "languageDocumentation.documentationESQL.mv_slice.markdown": "\n\n ### MV_SLICE\n Renvoie un sous-ensemble du champ multivalué en utilisant les valeurs d'index de début et de fin.\n\n ````\n row a = [1, 2, 2, 3]\n | eval a1 = mv_slice(a, 1), a2 = mv_slice(a, 2, 3)\n ````\n ", + "languageDocumentation.documentationESQL.mv_slice.markdown": " ### MV_SLICE Renvoie un sous-ensemble du champ multivalué en utilisant les valeurs d'index de début et de fin. Ceci est particulièrement utile pour lire une fonction qui émet des colonnes multivaluées dans un ordre connu, comme `SPLIT` ou `MV_SORT`. ``` row a = [1, 2, 2, 3] | eval a1 = mv_slice(a, 1), a2 = mv_slice(a, 2, 3) ```", "languageDocumentation.documentationESQL.mv_sort": "MV_SORT", - "languageDocumentation.documentationESQL.mv_sort.markdown": "\n\n ### MV_SORT\n Trie une expression multivaluée par ordre lexicographique.\n\n ````\n ROW a = [4, 2, -3, 2]\n | EVAL sa = mv_sort(a), sd = mv_sort(a, \"DESC\")\n ````\n ", + "languageDocumentation.documentationESQL.mv_sort.markdown": " ### MV_SORT Trie un champ multivalué par ordre lexicographique. ``` ROW a = [4, 2, -3, 2] | EVAL sa = mv_sort(a), sd = mv_sort(a, \"DESC\") ```", "languageDocumentation.documentationESQL.mv_sum": "MV_SUM", - "languageDocumentation.documentationESQL.mv_sum.markdown": "\n\n ### MV_SUM\n Convertit un champ multivalué en un champ à valeur unique comprenant la somme de toutes les valeurs.\n\n ````\n ROW a=[3, 5, 6]\n | EVAL sum_a = MV_SUM(a)\n ````\n ", + "languageDocumentation.documentationESQL.mv_sum.markdown": " ### MV_SUM Convertit un champ multivalué en un champ à valeur unique comprenant la somme de toutes les valeurs. ``` ROW a=[3, 5, 6] | EVAL sum_a = MV_SUM(a) ```", "languageDocumentation.documentationESQL.mv_zip": "MV_ZIP", - "languageDocumentation.documentationESQL.mv_zip.markdown": "\n\n ### MV_ZIP\n Combine les valeurs de deux champs multivalués avec un délimiteur qui les relie.\n\n ````\n ROW a = [\"x\", \"y\", \"z\"], b = [\"1\", \"2\"]\n | EVAL c = mv_zip(a, b, \"-\")\n | KEEP a, b, c\n ````\n ", + "languageDocumentation.documentationESQL.mv_zip.markdown": " ### MV_ZIP Combine les valeurs de deux champs multivalués avec un délimiteur qui les relie. ``` ROW a = [\"x\", \"y\", \"z\"], b = [\"1\", \"2\"] | EVAL c = mv_zip(a, b, \"-\") | KEEP a, b, c ```", "languageDocumentation.documentationESQL.mvExpand": "MV_EXPAND", - "languageDocumentation.documentationESQL.mvExpand.markdown": "### MV_EXPAND\nLa commande de traitement `MV_EXPAND` développe les champs multivalués en indiquant une valeur par ligne et en dupliquant les autres champs : \n````\nROW a=[1,2,3], b=\"b\", j=[\"a\",\"b\"]\n| MV_EXPAND a\n````\n ", + "languageDocumentation.documentationESQL.mvExpand.markdown": "### MV_EXPAND La commande de traitement `MV_EXPAND` développe les champs multivalués en indiquant une valeur par ligne et en dupliquant les autres champs : ``` ROW a=[1,2,3], b=\"b\", j=[\"a\",\"b\"] | MV_EXPAND a ```", "languageDocumentation.documentationESQL.now": "NOW", - "languageDocumentation.documentationESQL.now.markdown": "\n\n ### NOW\n Renvoie la date et l'heure actuelles.\n\n ````\n ROW current_date = NOW()\n ````\n ", + "languageDocumentation.documentationESQL.now.markdown": " ### NOW Renvoie la date et l'heure actuelles. ``` ROW current_date = NOW() ```", "languageDocumentation.documentationESQL.operators": "Opérateurs", "languageDocumentation.documentationESQL.operatorsDocumentationESQLDescription": "ES|QL est compatible avec les opérateurs suivants :", + "languageDocumentation.documentationESQL.percentile": "PERCENTILE", + "languageDocumentation.documentationESQL.percentile.markdown": " ### PERCENTILE Renvoie la valeur à laquelle un certain pourcentage des valeurs observées se produit. Par exemple, le 95e percentile est la valeur qui est supérieure à 95 % des valeurs observées et le 50percentile est la médiane (`MEDIAN`). ``` FROM employees | STATS p0 = PERCENTILE(salary, 0) , p50 = PERCENTILE(salary, 50) , p99 = PERCENTILE(salary, 99) ```", "languageDocumentation.documentationESQL.pi": "PI", - "languageDocumentation.documentationESQL.pi.markdown": "\n\n ### PI\n Renvoie Pi, le rapport entre la circonférence et le diamètre d'un cercle.\n\n ````\n ROW PI()\n ````\n ", + "languageDocumentation.documentationESQL.pi.markdown": " ### PI Renvoie Pi, le rapport entre la circonférence et le diamètre d'un cercle. ``` ROW PI() ```", "languageDocumentation.documentationESQL.pow": "POW", - "languageDocumentation.documentationESQL.pow.markdown": "\n\n ### POW\n Renvoie la valeur d’une `base` élevée à la puissance d’un `exposant`.\n\n ````\n ROW base = 2.0, exponent = 2\n | EVAL result = POW(base, exponent)\n ````\n Remarque : Il est toujours possible de dépasser un résultat double ici ; dans ce cas, la valeur `null` sera renvoyée.\n ", + "languageDocumentation.documentationESQL.pow.markdown": " ### POW Renvoie la valeur d'une `base` élevée à la puissance d'un exposant (`exponent`). ``` ROW base = 2.0, exponent = 2 | EVAL result = POW(base, exponent) ``` Remarque : Il est toujours possible de dépasser un résultat double ici ; dans ce cas, la valeur `null` sera renvoyée.", "languageDocumentation.documentationESQL.predicates": "valeurs NULL", - "languageDocumentation.documentationESQL.predicates.markdown": "### Valeurs NULL\nPour une comparaison avec une valeur NULL, utilisez les attributs `IS NULL` et `IS NOT NULL` :\n\n````\nFROM employees\n| WHERE birth_date IS NULL\n| KEEP first_name, last_name\n| SORT first_name\n| LIMIT 3\n````\n\n````\nFROM employees\n| WHERE is_rehired IS NOT NULL\n| STATS count(emp_no)\n````\n ", + "languageDocumentation.documentationESQL.predicates.markdown": "### Valeurs NULL Pour une comparaison avec une valeur NULL, utilisez les attributs `IS NULL` et `IS NOT NULL` : ``` FROM employees | WHERE birth_date IS NULL | KEEP first_name, last_name | SORT first_name | LIMIT 3 ``` ``` FROM employees | WHERE is_rehired IS NOT NULL | STATS count(emp_no) ```", "languageDocumentation.documentationESQL.processingCommands": "Traitement des commandes", "languageDocumentation.documentationESQL.processingCommandsDescription": "Le traitement des commandes transforme un tableau des entrées par l'ajout, le retrait ou la modification des lignes et des colonnes. ES|QL est compatible avec le traitement des commandes suivant.", "languageDocumentation.documentationESQL.rename": "RENAME", - "languageDocumentation.documentationESQL.rename.markdown": "### RENAME\nUtilisez `RENAME` pour renommer une colonne en utilisant la syntaxe suivante :\n\n````\nRENAME AS \n````\n\nPar exemple :\n\n````\nFROM employees\n| KEEP first_name, last_name, still_hired\n| RENAME still_hired AS employed\n````\n\nSi une colonne portant le nouveau nom existe déjà, elle sera remplacée par la nouvelle colonne.\n\nPlusieurs colonnes peuvent être renommées à l'aide d'une seule commande `RENAME` :\n\n````\nFROM employees\n| KEEP first_name, last_name\n| RENAME first_name AS fn, last_name AS ln\n````\n ", + "languageDocumentation.documentationESQL.rename.markdown": "### RENAME Utilisez `RENAME` pour renommer une colonne en utilisant la syntaxe suivante : ``` RENAME AS ``` For example: ``` FROM employees | KEEP first_name, last_name, still_hired | RENAME still_hired AS employed ``` If a column with the new name already exists, it will be replaced by the new column. Multiple columns can be renamed with a single `RENAME` command: ``` FROM employees | KEEP first_name, last_name | RENAME first_name AS fn, last_name AS ln ```", "languageDocumentation.documentationESQL.repeat": "REPEAT", - "languageDocumentation.documentationESQL.repeat.markdown": "\n\n ### REPEAT\n Renvoie une chaîne construite par la concaténation de la `chaîne` avec elle-même, le `nombre` de fois spécifié.\n\n ````\n ROW a = \"Hello!\"\n | EVAL triple_a = REPEAT(a, 3);\n ````\n ", + "languageDocumentation.documentationESQL.repeat.markdown": " ### REPEAT Renvoie une chaîne construite par la concaténation de la chaîne (`string`) avec elle-même, le nombre (`number`) de fois spécifié. ``` ROW a = \"Hello!\" | EVAL triple_a = REPEAT(a, 3); ```", "languageDocumentation.documentationESQL.replace": "REPLACE", - "languageDocumentation.documentationESQL.replace.markdown": "\n\n ### REPLACE\n La fonction remplace dans la chaîne `str` toutes les correspondances avec l'expression régulière `regex`\n par la chaîne de remplacement `newStr`.\n\n ````\n ROW str = \"Hello World\"\n | EVAL str = REPLACE(str, \"World\", \"Universe\")\n | KEEP str\n ````\n ", + "languageDocumentation.documentationESQL.replace.markdown": " ### REPLACE La fonction remplace dans la chaîne `str` toutes les correspondances avec l'expression régulière `regex` par la chaîne de remplacement `newStr`. ``` ROW str = \"Hello World\" | EVAL str = REPLACE(str, \"World\", \"Universe\") | KEEP str ```", + "languageDocumentation.documentationESQL.reverse": "REVERSE", + "languageDocumentation.documentationESQL.reverse.markdown": " ### REVERSE Renvoie une nouvelle chaîne représentant la chaîne d'entrée dans l'ordre inverse. ``` ROW message = \"Some Text\" | EVAL message_reversed = REVERSE(message); ```", "languageDocumentation.documentationESQL.right": "RIGHT", - "languageDocumentation.documentationESQL.right.markdown": "\n\n ### RIGHT\n Renvoie la sous-chaîne qui extrait la longueur des caractères de `str` en partant de la droite.\n\n ````\n FROM employees\n | KEEP last_name\n | EVAL right = RIGHT(last_name, 3)\n | SORT last_name ASC\n | LIMIT 5\n ````\n ", + "languageDocumentation.documentationESQL.right.markdown": " ### RIGHT Renvoie la sous-chaîne qui extrait la \"longueur\" des caractères de `str` en partant de la droite. ``` FROM employees | KEEP last_name | EVAL right = RIGHT(last_name, 3) | SORT last_name ASC | LIMIT 5 ```", "languageDocumentation.documentationESQL.round": "ROUND", - "languageDocumentation.documentationESQL.round.markdown": "\n\n ### ROUND\n Arrondit un nombre au nombre spécifié de décimales.\n La valeur par défaut est 0, qui renvoie l'entier le plus proche. Si le\n nombre de décimales spécifié est négatif, la fonction arrondit au nombre de décimales à gauche\n de la virgule.\n\n ````\n FROM employees\n | KEEP first_name, last_name, height\n | EVAL height_ft = ROUND(height * 3.281, 1)\n ````\n ", + "languageDocumentation.documentationESQL.round.markdown": " ### ROUND Arrondit un nombre au nombre spécifié de décimales. La valeur par défaut est 0, qui renvoie l'entier le plus proche. Si le nombre de décimales spécifié est négatif, la fonction arrondit au nombre de décimales à gauche. ``` FROM employees | KEEP first_name, last_name, height | EVAL height_ft = ROUND(height * 3.281, 1) ```", "languageDocumentation.documentationESQL.row": "ROW", - "languageDocumentation.documentationESQL.row.markdown": "### ROW\nLa commande source `ROW` renvoie une ligne contenant une ou plusieurs colonnes avec les valeurs que vous spécifiez. Cette commande peut s'avérer utile pour les tests.\n \n````\nROW a = 1, b = \"two\", c = null\n````\n\nUtilisez des crochets pour créer des colonnes à valeurs multiples :\n\n````\nROW a = [2, 1]\n````\n\nROW permet d'utiliser des fonctions :\n\n````\nROW a = ROUND(1.23, 0)\n````\n ", + "languageDocumentation.documentationESQL.row.markdown": "### ROW La commande source `ROW` renvoie une ligne contenant une ou plusieurs colonnes avec les valeurs que vous spécifiez. Cette commande peut s'avérer utile pour les tests. ``` ROW a = 1, b = \"two\", c = null ``` Utilisez des crochets pour créer des colonnes à valeurs multiples : ``` ROW a = [2, 1] ``` ROW permet d'utiliser des fonctions : ``` ROW a = ROUND(1.23, 0) ```", "languageDocumentation.documentationESQL.rtrim": "RTRIM", - "languageDocumentation.documentationESQL.rtrim.markdown": "\n\n ### RTRIM\n Supprime les espaces à la fin des chaînes.\n\n ````\n ROW message = \" some text \", color = \" red \"\n | EVAL message = RTRIM(message)\n | EVAL color = RTRIM(color)\n | EVAL message = CONCAT(\"'\", message, \"'\")\n | EVAL color = CONCAT(\"'\", color, \"'\")\n ````\n ", + "languageDocumentation.documentationESQL.rtrim.markdown": " ### RTRIM Supprime les espaces à la fin d'une chaîne. ``` ROW message = \" some text \", color = \" red \" | EVAL message = RTRIM(message) | EVAL color = RTRIM(color) | EVAL message = CONCAT(\"'\", message, \"'\") | EVAL color = CONCAT(\"'\", color, \"'\") ```", "languageDocumentation.documentationESQL.show": "SHOW", - "languageDocumentation.documentationESQL.show.markdown": "### SHOW\nLa commande source `SHOW ` renvoie des informations sur le déploiement et ses capacités :\n\n* Utilisez `SHOW INFO` pour renvoyer la version du déploiement, la date de compilation et le hachage.\n* Utilisez `SHOW FUNCTIONS` pour renvoyer une liste de toutes les fonctions prises en charge et un résumé de chaque fonction.\n ", + "languageDocumentation.documentationESQL.show.markdown": "### SHOW La commande source `SHOW ` renvoie des informations sur le déploiement et ses capacités : * Utilisez `SHOW INFO` pour renvoyer la version du déploiement, la date de compilation et le hachage. * Utilisez `SHOW FUNCTIONS` pour renvoyer une liste de toutes les fonctions prises en charge et un résumé de chaque fonction.", "languageDocumentation.documentationESQL.signum": "SIGNUM", - "languageDocumentation.documentationESQL.signum.markdown": "\n\n ### SIGNUM\n Renvoie le signe du nombre donné.\n Il renvoie `-1` pour les nombres négatifs, `0` pour `0` et `1` pour les nombres positifs.\n\n ````\n ROW d = 100.0\n | EVAL s = SIGNUM(d)\n ````\n ", + "languageDocumentation.documentationESQL.signum.markdown": " ### SIGNUM Renvoie le signe du nombre donné. Renvoie `-1` pour les nombres négatifs, `0` pour `0` et `1` pour les nombres positifs. ``` ROW d = 100.0 | EVAL s = SIGNUM(d) ```", "languageDocumentation.documentationESQL.sin": "SIN", - "languageDocumentation.documentationESQL.sin.markdown": "\n\n ### SIN\n Renvoie la fonction trigonométrique sinusoïdale d'un angle.\n\n ````\n ROW a=1.8 \n | EVAL sin=SIN(a)\n ````\n ", + "languageDocumentation.documentationESQL.sin.markdown": " ### SIN Renvoie le sinus d'un angle. ``` ROW a=1.8 | EVAL sin=SIN(a) ```", "languageDocumentation.documentationESQL.sinh": "SINH", - "languageDocumentation.documentationESQL.sinh.markdown": "\n\n ### SINH\n Renvoie le sinus hyperbolique d'un angle.\n\n ````\n ROW a=1.8 \n | EVAL sinh=SINH(a)\n ````\n ", + "languageDocumentation.documentationESQL.sinh.markdown": " ### SINH Renvoie le sinus hyperbolique d'un nombre. ``` ROW a=1.8 | EVAL sinh=SINH(a) ```", "languageDocumentation.documentationESQL.sort": "SORT", - "languageDocumentation.documentationESQL.sort.markdown": "### SORT\nUtilisez la commande `SORT` pour trier les lignes sur un ou plusieurs champs :\n\n````\nFROM employees\n| KEEP first_name, last_name, height\n| SORT height\n````\n\nL'ordre de tri par défaut est croissant. Définissez un ordre de tri explicite en utilisant `ASC` ou `DESC` :\n\n````\nFROM employees\n| KEEP first_name, last_name, height\n| SORT height DESC\n````\n\nSi deux lignes disposent de la même clé de tri, l'ordre original sera préservé. Vous pouvez ajouter des expressions de tri pour départager les deux lignes :\n\n````\nFROM employees\n| KEEP first_name, last_name, height\n| SORT height DESC, first_name ASC\n````\n\n#### valeurs `null`\nPar défaut, les valeurs `null` sont considérées comme étant supérieures à toutes les autres valeurs. Selon un ordre de tri croissant, les valeurs `null` sont classées en dernier. Selon un ordre de tri décroissant, les valeurs `null` sont classées en premier. Pour modifier cet ordre, utilisez `NULLS FIRST` ou `NULLS LAST` :\n\n````\nFROM employees\n| KEEP first_name, last_name, height\n| SORT first_name ASC NULLS FIRST\n````\n ", + "languageDocumentation.documentationESQL.sort.markdown": "### SORT Utilisez la commande `SORT` pour trier les lignes sur un ou plusieurs champs : ``` FROM employees | KEEP first_name, last_name, height | SORT height ``` L'ordre de tri par défaut est croissant. Définissez un ordre de tri explicite en utilisant `ASC` ou `DESC` : ``` FROM employees | KEEP first_name, last_name, height | SORT height DESC ``` Si deux lignes disposent de la même clé de tri, l'ordre original sera préservé. Vous pouvez ajouter des expressions de tri pour départager les deux lignes : ``` FROM employees | KEEP first_name, last_name, height | SORT height DESC, first_name ASC ``` #### Valeurs `null` Par défaut, les valeurs `null` sont considérées comme étant supérieures à toutes les autres valeurs. Selon un ordre de tri croissant, les valeurs `null` sont classées en dernier. Selon un ordre de tri décroissant, les valeurs `null` sont classées en premier. Pour modifier cet ordre, utilisez `NULLS FIRST` ou `NULLS LAST` : ``` FROM employees | KEEP first_name, last_name, height | SORT first_name ASC NULLS FIRST ```", "languageDocumentation.documentationESQL.sourceCommands": "Commandes sources", + "languageDocumentation.documentationESQL.space": "SPACE", + "languageDocumentation.documentationESQL.space.markdown": " ### SPACE Renvoie une chaîne composée d'espaces nombre (`number`). ``` ROW message = CONCAT(\"Hello\", SPACE(1), \"World!\"); ```", "languageDocumentation.documentationESQL.split": "SPLIT", - "languageDocumentation.documentationESQL.split.markdown": "\n\n ### SPLIT\n Divise une chaîne de valeur unique en plusieurs chaînes.\n\n ````\n ROW words=\"foo;bar;baz;qux;quux;corge\"\n | EVAL word = SPLIT(words, \";\")\n ````\n ", + "languageDocumentation.documentationESQL.split.markdown": " ### SPLIT Divise une chaîne de valeur unique en plusieurs chaînes. ``` ROW words=\"foo;bar;baz;qux;quux;corge\" | EVAL word = SPLIT(words, \";\") ```", "languageDocumentation.documentationESQL.sqrt": "SQRT", - "languageDocumentation.documentationESQL.sqrt.markdown": "\n\n ### SQRT\n Renvoie la racine carrée d'un nombre. La valeur de renvoi est toujours un double, quelle que soit la valeur numérique de l'entrée.\n Les racines carrées des nombres négatifs et des infinis sont nulles.\n\n ````\n ROW d = 100.0\n | EVAL s = SQRT(d)\n ````\n ", + "languageDocumentation.documentationESQL.sqrt.markdown": " ### SQRT Renvoie la racine carrée d'un nombre. La valeur de renvoi est toujours un double, quelle que soit la valeur numérique de l'entrée. Les racines carrées des nombres négatifs et des infinis sont nulles. ``` ROW d = 100.0 | EVAL s = SQRT(d) ```", + "languageDocumentation.documentationESQL.st_centroid_agg": "ST_CENTROID_AGG", + "languageDocumentation.documentationESQL.st_centroid_agg.markdown": " ### ST_CENTROID_AGG Calcule le centroïde spatial sur un champ avec un type de géométrie de point spatial. ``` FROM airports | STATS centroid=ST_CENTROID_AGG(location) ```", "languageDocumentation.documentationESQL.st_contains": "ST_CONTAINS", - "languageDocumentation.documentationESQL.st_contains.markdown": "\n\n ### ST_CONTAINS\n Renvoie si la première géométrie contient la deuxième géométrie.\n Il s'agit de l'inverse de la fonction `ST_WITHIN`.\n\n ````\n FROM airport_city_boundaries\n | WHERE ST_CONTAINS(city_boundary, TO_GEOSHAPE(\"POLYGON((109.35 18.3, 109.45 18.3, 109.45 18.4, 109.35 18.4, 109.35 18.3))\"))\n | KEEP abbrev, airport, region, city, city_location\n ````\n ", + "languageDocumentation.documentationESQL.st_contains.markdown": " ### ST_CONTAINS Renvoie si la première géométrie contient la deuxième géométrie. Il s'agit de l'inverse de la fonction `ST_WITHIN`. ``` FROM airport_city_boundaries | WHERE ST_CONTAINS(city_boundary, TO_GEOSHAPE(\"POLYGON((109.35 18.3, 109.45 18.3, 109.45 18.4, 109.35 18.4, 109.35 18.3))\")) | KEEP abbrev, airport, region, city, city_location ```", "languageDocumentation.documentationESQL.st_disjoint": "ST_DISJOINT", - "languageDocumentation.documentationESQL.st_disjoint.markdown": "\n\n ### ST_DISJOINT\n Renvoie si les deux géométries ou colonnes géométriques sont disjointes.\n Il s'agit de l'inverse de la fonction `ST_INTERSECTS`.\n En termes mathématiques : ST_Disjoint(A, B) ⇔ A ⋂ B = ∅\n\n ````\n FROM airport_city_boundaries\n | WHERE ST_DISJOINT(city_boundary, TO_GEOSHAPE(\"POLYGON((-10 -60, 120 -60, 120 60, -10 60, -10 -60))\"))\n | KEEP abbrev, airport, region, city, city_location\n ````\n ", + "languageDocumentation.documentationESQL.st_disjoint.markdown": " ### ST_DISJOINT Renvoie si les deux géométries ou colonnes géométriques sont disjointes. Il s'agit de l'inverse de la fonction `ST_INTERSECTS`. En termes mathématiques : ST_Disjoint(A, B) ⇔ A ⋂ B = ∅ ``` FROM airport_city_boundaries | WHERE ST_DISJOINT(city_boundary, TO_GEOSHAPE(\"POLYGON((-10 -60, 120 -60, 120 60, -10 60, -10 -60))\")) | KEEP abbrev, airport, region, city, city_location ```", "languageDocumentation.documentationESQL.st_distance": "ST_DISTANCE", - "languageDocumentation.documentationESQL.st_distance.markdown": "\n\n ### ST_DISTANCE\n Calcule la distance entre deux points.\n Pour les géométries cartésiennes, c’est la distance pythagoricienne dans les mêmes unités que les coordonnées d'origine.\n Pour les géométries géographiques, c’est la distance circulaire le long du grand cercle en mètres.\n\n ````\n Aéroports FROM\n | WHERE abbrev == \"CPH\"\n | EVAL distance = ST_DISTANCE(location, city_location)\n | KEEP abbrev, name, location, city_location, distance\n ````\n ", + "languageDocumentation.documentationESQL.st_distance.markdown": " ### ST_DISTANCE Calcule la distance entre deux points. Pour les géométries cartésiennes, c’est la distance pythagoricienne dans les mêmes unités que les coordonnées d'origine. Pour les géométries géographiques, c'est la distance circulaire le long du grand cercle en mètres. ``` FROM airports | WHERE abbrev == \"CPH\" | EVAL distance = ST_DISTANCE(location, city_location) | KEEP abbrev, name, location, city_location, distance ```", "languageDocumentation.documentationESQL.st_intersects": "ST_INTERSECTS", - "languageDocumentation.documentationESQL.st_intersects.markdown": "\n\n ### ST_INTERSECTS\n Renvoie `true` (vrai) si deux géométries se croisent.\n Elles se croisent si elles ont un point commun, y compris leurs points intérieurs\n (les points situés le long des lignes ou dans des polygones).\n Il s'agit de l'inverse de la fonction `ST_DISJOINT`.\n En termes mathématiques : ST_Intersects(A, B) ⇔ A ⋂ B ≠ ∅\n\n ````\n Aéroports FROM\n | WHERE ST_INTERSECTS(location, TO_GEOSHAPE(\"POLYGON((42 14, 43 14, 43 15, 42 15, 42 14))\"))\n ````\n ", + "languageDocumentation.documentationESQL.st_intersects.markdown": " ### ST_INTERSECTS Renvoie `true` (vrai) si deux géométries se croisent. Elles se croisent si elles ont un point commun, y compris leurs points intérieurs (points le long de lignes ou à l'intérieur de polygones). Il s'agit de l'inverse de la fonction `ST_DISJOINT`. En termes mathématiques : ST_Intersects(A, B) ⇔ A ⋂ B ≠ ∅ ``` FROM airports | WHERE ST_INTERSECTS(location, TO_GEOSHAPE(\"POLYGON((42 14, 43 14, 43 15, 42 15, 42 14))\")) ```", "languageDocumentation.documentationESQL.st_within": "ST_WITHIN", - "languageDocumentation.documentationESQL.st_within.markdown": "\n\n ### ST_WITHIN\n Renvoie si la première géométrie est à l'intérieur de la deuxième géométrie.\n Il s'agit de l'inverse de la fonction `ST_CONTAINS`.\n\n ````\n FROM airport_city_boundaries\n | WHERE ST_WITHIN(city_boundary, TO_GEOSHAPE(\"POLYGON((109.1 18.15, 109.6 18.15, 109.6 18.65, 109.1 18.65, 109.1 18.15))\"))\n | KEEP abbrev, airport, region, city, city_location\n ````\n ", + "languageDocumentation.documentationESQL.st_within.markdown": " ### ST_WITHIN Renvoie si la première géométrie est comprise dans la deuxième géométrie. Il s'agit de l'inverse de la fonction `ST_CONTAINS`. ``` FROM airport_city_boundaries | WHERE ST_WITHIN(city_boundary, TO_GEOSHAPE(\"POLYGON((109.1 18.15, 109.6 18.15, 109.6 18.65, 109.1 18.65, 109.1 18.15))\")) | KEEP abbrev, airport, region, city, city_location ```", "languageDocumentation.documentationESQL.st_x": "ST_X", - "languageDocumentation.documentationESQL.st_x.markdown": "\n\n ### ST_X\n Extrait la coordonnée `x` du point fourni.\n Si les points sont de type `geo_point`, cela revient à extraire la valeur de la `longitude`.\n\n ````\n ROW point = TO_GEOPOINT(\"POINT(42.97109629958868 14.7552534006536)\")\n | EVAL x = ST_X(point), y = ST_Y(point)\n ````\n ", + "languageDocumentation.documentationESQL.st_x.markdown": " ### ST_X Extrait la coordonnée `x` du point fourni. Si les points sont de type `geo_point`, cela revient à extraire la valeur de la `longitude`. ``` ROW point = TO_GEOPOINT(\"POINT(42.97109629958868 14.7552534006536)\") | EVAL x = ST_X(point), y = ST_Y(point) ```", "languageDocumentation.documentationESQL.st_y": "ST_Y", - "languageDocumentation.documentationESQL.st_y.markdown": "\n\n ### ST_Y\n Extrait la coordonnée `y` du point fourni.\n Si les points sont de type `geo_point`, cela revient à extraire la valeur de la `latitude`.\n\n ````\n ROW point = TO_GEOPOINT(\"POINT(42.97109629958868 14.7552534006536)\")\n | EVAL x = ST_X(point), y = ST_Y(point)\n ````\n ", + "languageDocumentation.documentationESQL.st_y.markdown": " ### ST_Y Extrait la coordonnée `y` du point fourni. Si les points sont de type `geo_point`, cela revient à extraire la valeur de la `latitude`. ``` ROW point = TO_GEOPOINT(\"POINT(42.97109629958868 14.7552534006536)\") | EVAL x = ST_X(point), y = ST_Y(point) ```", "languageDocumentation.documentationESQL.starts_with": "STARTS_WITH", - "languageDocumentation.documentationESQL.starts_with.markdown": "\n\n ### STARTS_WITH\n Renvoie un booléen qui indique si une chaîne de mot-clés débute par une autre chaîne.\n\n ````\n FROM employees\n | KEEP last_name\n | EVAL ln_S = STARTS_WITH(last_name, \"B\")\n ````\n ", + "languageDocumentation.documentationESQL.starts_with.markdown": " ### STARTS_WITH Renvoie une valeur booléenne qui indique si une chaîne de mots-clés débute par une autre chaîne. ``` FROM employees | KEEP last_name | EVAL ln_S = STARTS_WITH(last_name, \"B\") ```", "languageDocumentation.documentationESQL.statsby": "STATS ... BY", - "languageDocumentation.documentationESQL.statsby.markdown": "### STATS ... BY\nUtilisez `STATS ... BY` pour regrouper les lignes en fonction d'une valeur commune et calculer une ou plusieurs valeurs agrégées sur les lignes regroupées.\n\n**Exemples** :\n\n````\nFROM employees\n| STATS count = COUNT(emp_no) BY languages\n| SORT languages\n````\n\nSi `BY` est omis, le tableau de sortie contient exactement une ligne avec les agrégations appliquées sur l'ensemble des données :\n\n````\nFROM employees\n| STATS avg_lang = AVG(languages)\n````\n\nIl est possible de calculer plusieurs valeurs :\n\n````\nFROM employees\n| STATS avg_lang = AVG(languages), max_lang = MAX(languages)\n````\n\nIl est également possible d'effectuer des regroupements en fonction de plusieurs valeurs (uniquement pour les champs longs et les champs de la famille de mots-clés) :\n\n````\nFROM employees\n| EVAL hired = DATE_FORMAT(hire_date, \"YYYY\")\n| STATS avg_salary = AVG(salary) BY hired, languages.long\n| EVAL avg_salary = ROUND(avg_salary)\n| SORT hired, languages.long\n````\n\nConsultez la rubrique **Fonctions d'agrégation** pour obtenir la liste des fonctions pouvant être utilisées avec `STATS ... BY`.\n\nLes fonctions d'agrégation et les expressions de regroupement acceptent toutes deux d'autres fonctions. Ceci est utile pour utiliser `STATS...BY` sur des colonnes à valeur multiple. Par exemple, pour calculer l'évolution moyenne du salaire, vous pouvez utiliser `MV_AVG` pour faire la moyenne des multiples valeurs par employé, et utiliser le résultat avec la fonction `AVG` :\n\n````\nFROM employees\n| STATS avg_salary_change = AVG(MV_AVG(salary_change))\n````\n\nLe regroupement par expression est par exemple le regroupement des employés en fonction de la première lettre de leur nom de famille :\n\n````\nFROM employees\n| STATS my_count = COUNT() BY LEFT(last_name, 1)\n| SORT \"LEFT(last_name, 1)\"\n````\n\nIl n'est pas obligatoire d'indiquer le nom de la colonne de sortie. S'il n'est pas spécifié, le nouveau nom de la colonne est égal à l'expression. La requête suivante renvoie une colonne appelée `AVG(salary)` :\n\n````\nFROM employees\n| STATS AVG(salary)\n````\n\nComme ce nom contient des caractères spéciaux, il doit être placé entre deux caractères (`) lorsqu'il est utilisé dans des commandes suivantes :\n\n````\nFROM employees\n| STATS AVG(salary)\n| EVAL avg_salary_rounded = ROUND(\"AVG(salary)\")\n````\n\n**Remarque** : `STATS` sans aucun groupe est beaucoup plus rapide que l'ajout d'un groupe.\n\n**Remarque** : Le regroupement sur une seule expression est actuellement beaucoup plus optimisé que le regroupement sur plusieurs expressions.\n ", + "languageDocumentation.documentationESQL.statsby.markdown": "### STATS ... BY Utilisez `STATS ... BY` pour regrouper les lignes en fonction d'une valeur commune et calculer une ou plusieurs valeurs agrégées sur les lignes regroupées. **Exemples** : ``` FROM employees | STATS count = COUNT(emp_no) BY languages | SORT languages ``` Si `BY` est omis, le tableau de sortie contient exactement une ligne avec les agrégations appliquées sur l'ensemble des données : ``` FROM employees | STATS avg_lang = AVG(languages) ``` Il est possible de calculer plusieurs valeurs : ``` FROM employees | STATS avg_lang = AVG(languages), max_lang = MAX(languages) ``` Il est également possible d'effectuer des regroupements en fonction de plusieurs valeurs (uniquement pour les champs longs et les champs de la famille de mots-clés) : ``` FROM employees | EVAL hired = DATE_FORMAT(hire_date, \"YYYY\") | STATS avg_salary = AVG(salary) BY hired, languages.long | EVAL avg_salary = ROUND(avg_salary) | SORT hired, languages.long ``` Consultez la rubrique **Fonctions d'agrégation** pour obtenir la liste des fonctions pouvant être utilisées avec `STATS ... BY`. Les fonctions d'agrégation et les expressions de regroupement acceptent toutes deux d'autres fonctions. Ceci est utile pour utiliser `STATS...BY` sur des colonnes à valeur multiple. Par exemple, pour calculer l'évolution moyenne du salaire, vous pouvez utiliser `MV_AVG` pour faire la moyenne des multiples valeurs par employé, et utiliser le résultat avec la fonction `AVG` : ``` FROM employees | STATS avg_salary_change = AVG(MV_AVG(salary_change)) ``` Le regroupement par expression est par exemple le regroupement des employés en fonction de la première lettre de leur nom de famille : ``` FROM employees | STATS my_count = COUNT() BY LEFT(last_name, 1) | SORT `LEFT(last_name, 1)` ``` Il n'est pas obligatoire d'indiquer le nom de la colonne de sortie. S'il n'est pas spécifié, le nouveau nom de la colonne est égal à l'expression. La requête suivante renvoie une colonne appelée `AVG(salary)` : ``` FROM employees | STATS AVG(salary) ``` Comme ce nom contient des caractères spéciaux, il doit être placé entre deux caractères (`) lorsqu'il est utilisé dans les commandes suivantes : ``` FROM employees | STATS AVG(salary) | EVAL avg_salary_rounded = ROUND(`AVG(salary)`) ``` **Remarque** : `STATS` sans aucun groupe est beaucoup plus rapide que l'ajout d'un groupe. **Remarque** : Le regroupement sur une seule expression est actuellement beaucoup plus optimisé que le regroupement sur plusieurs expressions.", "languageDocumentation.documentationESQL.stringOperators": "LIKE et RLIKE", - "languageDocumentation.documentationESQL.stringOperators.markdown": "### LIKE et RLIKE\nPour comparer des chaînes en utilisant des caractères génériques ou des expressions régulières, utilisez `LIKE` ou `RLIKE` :\n\nUtilisez `LIKE` pour faire correspondre des chaînes à l'aide de caractères génériques. Les caractères génériques suivants sont pris en charge :\n\n* `*` correspond à zéro caractère ou plus.\n* `?` correspond à un seul caractère.\n\n````\nFROM employees\n| WHERE first_name LIKE \"?b*\"\n| KEEP first_name, last_name\n````\n\nUtilisez `RLIKE` pour faire correspondre des chaînes à l'aide d'expressions régulières :\n\n````\nFROM employees\n| WHERE first_name RLIKE \".leja.*\"\n| KEEP first_name, last_name\n````\n ", + "languageDocumentation.documentationESQL.stringOperators.markdown": "### LIKE et RLIKE Pour comparer des chaînes en utilisant des caractères génériques ou des expressions régulières, utilisez `LIKE` ou `RLIKE` : Utilisez `LIKE` pour faire correspondre des chaînes à l'aide de caractères génériques. Les caractères génériques suivants sont pris en charge : * `*` correspond à zéro ou plusieurs caractères. * `?` correspond à un seul caractère. ``` FROM employees | WHERE first_name LIKE \"?b*\" | KEEP first_name, last_name ``` Utilisez `RLIKE` pour faire correspondre des chaînes à l'aide d'expressions régulières : ``` FROM employees | WHERE first_name RLIKE \".leja.*\" | KEEP first_name, last_name ```", "languageDocumentation.documentationESQL.substring": "SUBSTRING", - "languageDocumentation.documentationESQL.substring.markdown": "\n\n ### SUBSTRING\n Renvoie la sous-chaîne d'une chaîne, délimitée en fonction d'une position de départ et d'une longueur facultative\n\n ````\n FROM employees\n | KEEP last_name\n | EVAL ln_sub = SUBSTRING(last_name, 1, 3)\n ````\n ", + "languageDocumentation.documentationESQL.substring.markdown": " ### SUBSTRING Renvoie la sous-chaîne d'une chaîne, délimitée en fonction d'une position de départ et d'une longueur facultative. ``` FROM employees | KEEP last_name | EVAL ln_sub = SUBSTRING(last_name, 1, 3) ```", + "languageDocumentation.documentationESQL.sum": "SUM", + "languageDocumentation.documentationESQL.sum.markdown": " ### SUM La somme d'une expression numérique. ``` FROM employees | STATS SUM(languages) ```", "languageDocumentation.documentationESQL.tan": "TAN", - "languageDocumentation.documentationESQL.tan.markdown": "\n\n ### TAN\n Renvoie la fonction trigonométrique Tangente d'un angle.\n\n ````\n ROW a=1.8 \n | EVAL tan=TAN(a)\n ````\n ", + "languageDocumentation.documentationESQL.tan.markdown": " ### TAN Renvoie la tangente d'un angle. ``` ROW a=1.8 | EVAL tan=TAN(a) ```", "languageDocumentation.documentationESQL.tanh": "TANH", - "languageDocumentation.documentationESQL.tanh.markdown": "\n\n ### TANH\n Renvoie la fonction hyperbolique Tangente d'un angle.\n\n ````\n ROW a=1.8 \n | EVAL tanh=TANH(a)\n ````\n ", + "languageDocumentation.documentationESQL.tanh.markdown": " ### TANH Renvoie la tangente hyperbolique d'un nombre. ``` ROW a=1.8 | EVAL tanh=TANH(a) ```", "languageDocumentation.documentationESQL.tau": "TAU", - "languageDocumentation.documentationESQL.tau.markdown": "\n\n ### TAU\n Renvoie le rapport entre la circonférence et le rayon d'un cercle.\n\n ````\n ROW TAU()\n ````\n ", + "languageDocumentation.documentationESQL.tau.markdown": " ### TAU Renvoie le rapport entre la circonférence et le rayon d'un cercle. ``` ROW TAU() ```", "languageDocumentation.documentationESQL.to_base64": "TO_BASE64", - "languageDocumentation.documentationESQL.to_base64.markdown": "\n\n ### TO_BASE64\n Encode une chaîne en chaîne base64.\n\n ````\n row a = \"elastic\" \n | eval e = to_base64(a)\n ````\n ", + "languageDocumentation.documentationESQL.to_base64.markdown": " ### TO_BASE64 Encode une chaîne en chaîne base64. ``` row a = \"elastic\" | eval e = to_base64(a) ```", "languageDocumentation.documentationESQL.to_boolean": "TO_BOOLEAN", - "languageDocumentation.documentationESQL.to_boolean.markdown": "\n\n ### TO_BOOLEAN\n Convertit une valeur d'entrée en une valeur booléenne.\n Une chaîne de valeur *true* sera convertie, sans tenir compte de la casse, en une valeur booléenne *true*.\n Pour toute autre valeur, y compris une chaîne vide, la fonction renverra *false*.\n La valeur numérique *0* sera convertie en *false*, toute autre valeur sera convertie en *true*.\n\n ````\n ROW str = [\"true\", \"TRuE\", \"false\", \"\", \"yes\", \"1\"]\n | EVAL bool = TO_BOOLEAN(str)\n ````\n ", + "languageDocumentation.documentationESQL.to_boolean.markdown": " ### TO_BOOLEAN Convertit une valeur d'entrée en une valeur booléenne. Une chaîne de valeur *true* sera convertie, sans tenir compte de la casse, en une valeur booléenne *true*. Pour toute autre valeur, y compris une chaîne vide, la fonction renverra *false*. La valeur numérique *0* sera convertie en *false*, toute autre valeur sera convertie en *true*. ``` ROW str = [\"true\", \"TRuE\", \"false\", \"\", \"yes\", \"1\"] | EVAL bool = TO_BOOLEAN(str) ```", "languageDocumentation.documentationESQL.to_cartesianpoint": "TO_CARTESIANPOINT", - "languageDocumentation.documentationESQL.to_cartesianpoint.markdown": "\n\n ### TO_CARTESIANPOINT\n Convertit la valeur d'une entrée en une valeur `cartesian_point`.\n Une chaîne ne sera convertie que si elle respecte le format WKT Point.\n\n ````\n ROW wkt = [\"POINT(4297.11 -1475.53)\", \"POINT(7580.93 2272.77)\"]\n | MV_EXPAND wkt\n | EVAL pt = TO_CARTESIANPOINT(wkt)\n ````\n ", + "languageDocumentation.documentationESQL.to_cartesianpoint.markdown": " ### TO_CARTESIANPOINT Convertit la valeur d'une entrée en une valeur `cartesian_point`. Une chaîne ne sera convertie que si elle respecte le format WKT Point. ``` ROW wkt = [\"POINT(4297.11 -1475.53)\", \"POINT(7580.93 2272.77)\"] | MV_EXPAND wkt | EVAL pt = TO_CARTESIANPOINT(wkt) ```", "languageDocumentation.documentationESQL.to_cartesianshape": "TO_CARTESIANSHAPE", - "languageDocumentation.documentationESQL.to_cartesianshape.markdown": "\n\n ### TO_CARTESIANSHAPE\n Convertit une valeur d'entrée en une valeur `cartesian_shape`.\n Une chaîne ne sera convertie que si elle respecte le format WKT.\n\n ````\n ROW wkt = [\"POINT(4297.11 -1475.53)\", \"POLYGON ((3339584.72 1118889.97, 4452779.63 4865942.27, 2226389.81 4865942.27, 1113194.90 2273030.92, 3339584.72 1118889.97))\"]\n | MV_EXPAND wkt\n | EVAL geom = TO_CARTESIANSHAPE(wkt)\n ````\n ", + "languageDocumentation.documentationESQL.to_cartesianshape.markdown": " ### TO_CARTESIANSHAPE Convertit une valeur d'entrée en une valeur `cartesian_shape`. Une chaîne ne sera convertie que si elle respecte le format WKT. ``` ROW wkt = [\"POINT(4297.11 -1475.53)\", \"POLYGON ((3339584.72 1118889.97, 4452779.63 4865942.27, 2226389.81 4865942.27, 1113194.90 2273030.92, 3339584.72 1118889.97))\"] | MV_EXPAND wkt | EVAL geom = TO_CARTESIANSHAPE(wkt) ```", + "languageDocumentation.documentationESQL.to_date_nanos": "TO_DATE_NANOS", + "languageDocumentation.documentationESQL.to_date_nanos.markdown": " ### TO_DATE_NANOS Convertit une entrée en une valeur de date de résolution nanoseconde (ou date_nanos). Remarque : La plage de \"date nanos\" est comprise entre 1970-01-01T00:00:00.000000000Z et 2262-04-11T23:47:16.854775807Z. En outre, les nombres entiers ne peuvent pas être convertis en \"date nanos\", car la plage des nanosecondes en nombres entiers ne couvre qu'environ 2 secondes après l'heure.", + "languageDocumentation.documentationESQL.to_dateperiod": "TO_DATEPERIOD", + "languageDocumentation.documentationESQL.to_dateperiod.markdown": " ### TO_DATEPERIOD Convertit une valeur d'entrée en une valeur `date_period`. ``` row x = \"2024-01-01\"::datetime | eval y = x + \"3 DAYS\"::date_period, z = x - to_dateperiod(\"3 days\"); ```", "languageDocumentation.documentationESQL.to_datetime": "TO_DATETIME", - "languageDocumentation.documentationESQL.to_datetime.markdown": "\n\n ### TO_DATETIME\n Convertit une valeur d'entrée en une valeur de date.\n Une chaîne ne sera convertie que si elle respecte le format `yyyy-MM-dd'T'HH:mm:ss.SSS'Z'`.\n Pour convertir des dates vers d'autres formats, utilisez `DATE_PARSE`.\n\n ````\n ROW string = [\"1953-09-02T00:00:00.000Z\", \"1964-06-02T00:00:00.000Z\", \"1964-06-02 00:00:00\"]\n | EVAL datetime = TO_DATETIME(string)\n ````\n ", + "languageDocumentation.documentationESQL.to_datetime.markdown": " ### TO_DATETIME Convertit une valeur d'entrée en une valeur de date. Une chaîne ne sera convertie que si elle respecte le format `yyyy-MM-dd'T'HH:mm:ss.SSS'Z'`. Pour convertir des dates vers d'autres formats, utilisez `DATE_PARSE`. ``` ROW string = [\"1953-09-02T00:00:00.000Z\", \"1964-06-02T00:00:00.000Z\", \"1964-06-02 00:00:00\"] | EVAL datetime = TO_DATETIME(string) ``` Remarque : Notez que lors de la conversion de la résolution en nanosecondes à la résolution en millisecondes avec cette fonction, la date en nanosecondes est tronquée et non arrondie.", "languageDocumentation.documentationESQL.to_degrees": "TO_DEGREES", - "languageDocumentation.documentationESQL.to_degrees.markdown": "\n\n ### TO_DEGREES\n Convertit un nombre en radians en degrés.\n\n ````\n ROW rad = [1.57, 3.14, 4.71]\n | EVAL deg = TO_DEGREES(rad)\n ````\n ", + "languageDocumentation.documentationESQL.to_degrees.markdown": " ### TO_DEGREES Convertit un nombre en radians en degrés. ``` ROW rad = [1.57, 3.14, 4.71] | EVAL deg = TO_DEGREES(rad) ```", "languageDocumentation.documentationESQL.to_double": "TO_DOUBLE", - "languageDocumentation.documentationESQL.to_double.markdown": "\n\n ### TO_DOUBLE\n Convertit une valeur d'entrée en une valeur double. Si le paramètre d'entrée est de type date,\n sa valeur sera interprétée en millisecondes depuis l'heure Unix,\n convertie en double. Le booléen *true* sera converti en double *1.0*, et *false* en *0.0*.\n\n ````\n ROW str1 = \"5.20128E11\", str2 = \"foo\"\n | EVAL dbl = TO_DOUBLE(\"520128000000\"), dbl1 = TO_DOUBLE(str1), dbl2 = TO_DOUBLE(str2)\n ````\n ", + "languageDocumentation.documentationESQL.to_double.markdown": " ### TO_DOUBLE Convertit une valeur d'entrée en une valeur double. Si le paramètre d'entrée est de type date, sa valeur sera interprétée en millisecondes depuis l'heure Unix, convertie en double. Le booléen *true* sera converti en double *1.0*, et *false* en *0.0*. ``` ROW str1 = \"5.20128E11\", str2 = \"foo\" | EVAL dbl = TO_DOUBLE(\"520128000000\"), dbl1 = TO_DOUBLE(str1), dbl2 = TO_DOUBLE(str2) ```", "languageDocumentation.documentationESQL.to_geopoint": "TO_GEOPOINT", - "languageDocumentation.documentationESQL.to_geopoint.markdown": "\n\n ### TO_GEOPOINT\n Convertit une valeur d'entrée en une valeur `geo_point`.\n Une chaîne ne sera convertie que si elle respecte le format WKT Point.\n\n ````\n ROW wkt = \"POINT(42.97109630194 14.7552534413725)\"\n | EVAL pt = TO_GEOPOINT(wkt)\n ````\n ", + "languageDocumentation.documentationESQL.to_geopoint.markdown": " ### TO_GEOPOINT Convertit une valeur d'entrée en une valeur `geo_point`. Une chaîne ne sera convertie que si elle respecte le format WKT Point. ``` ROW wkt = \"POINT(42.97109630194 14.7552534413725)\" | EVAL pt = TO_GEOPOINT(wkt) ```", "languageDocumentation.documentationESQL.to_geoshape": "TO_GEOSHAPE", - "languageDocumentation.documentationESQL.to_geoshape.markdown": "\n\n ### TO_GEOSHAPE\n Convertit une valeur d'entrée en une valeur `geo_shape`.\n Une chaîne ne sera convertie que si elle respecte le format WKT.\n\n ````\n ROW wkt = \"POLYGON ((30 10, 40 40, 20 40, 10 20, 30 10))\"\n | EVAL geom = TO_GEOSHAPE(wkt)\n ````\n ", + "languageDocumentation.documentationESQL.to_geoshape.markdown": " ### TO_GEOSHAPE Convertit une valeur d'entrée en une valeur `geo_shape`. Une chaîne ne sera convertie que si elle respecte le format WKT. ``` ROW wkt = \"POLYGON ((30 10, 40 40, 20 40, 10 20, 30 10))\" | EVAL geom = TO_GEOSHAPE(wkt) ```", "languageDocumentation.documentationESQL.to_integer": "TO_INTEGER", - "languageDocumentation.documentationESQL.to_integer.markdown": "\n\n ### TO_INTEGER\n Convertit une valeur d'entrée en une valeur entière.\n Si le paramètre d'entrée est de type date, sa valeur sera interprétée en millisecondes\n depuis l'heure Unix, convertie en entier.\n Le booléen *true* sera converti en entier *1*, et *false* en *0*.\n\n ````\n ROW long = [5013792, 2147483647, 501379200000]\n | EVAL int = TO_INTEGER(long)\n ````\n ", + "languageDocumentation.documentationESQL.to_integer.markdown": " ### TO_INTEGER Convertit une valeur d'entrée en une valeur entière. Si le paramètre d'entrée est de type date, sa valeur sera interprétée en millisecondes depuis l'heure Unix, convertie en nombre entier. Le booléen *true* sera converti en entier *1*, et *false* en *0*. ``` ROW long = [5013792, 2147483647, 501379200000] | EVAL int = TO_INTEGER(long) ```", "languageDocumentation.documentationESQL.to_ip": "TO_IP", - "languageDocumentation.documentationESQL.to_ip.markdown": "\n\n ### TO_IP\n Convertit une chaîne d'entrée en valeur IP.\n\n ````\n ROW str1 = \"1.1.1.1\", str2 = \"foo\"\n | EVAL ip1 = TO_IP(str1), ip2 = TO_IP(str2)\n | WHERE CIDR_MATCH(ip1, \"1.0.0.0/8\")\n ````\n ", + "languageDocumentation.documentationESQL.to_ip.markdown": " ### TO_IP Convertit une chaîne d'entrée en valeur IP. ``` ROW str1 = \"1.1.1.1\", str2 = \"foo\" | EVAL ip1 = TO_IP(str1), ip2 = TO_IP(str2) | WHERE CIDR_MATCH(ip1, \"1.0.0.0/8\") ```", "languageDocumentation.documentationESQL.to_long": "TO_LONG", - "languageDocumentation.documentationESQL.to_long.markdown": "\n\n ### TO_LONG\n Convertit une valeur d'entrée en une valeur longue. Si le paramètre d'entrée est de type date,\n sa valeur sera interprétée en millisecondes depuis l'heure Unix, convertie en valeur longue.\n Le booléen *true* sera converti en valeur longue *1*, et *false* en *0*.\n\n ````\n ROW str1 = \"2147483648\", str2 = \"2147483648.2\", str3 = \"foo\"\n | EVAL long1 = TO_LONG(str1), long2 = TO_LONG(str2), long3 = TO_LONG(str3)\n ````\n ", + "languageDocumentation.documentationESQL.to_long.markdown": " ### TO_LONG Convertit une valeur d'entrée en une valeur longue. Si le paramètre d'entrée est de type date, sa valeur sera interprétée en millisecondes depuis l'heure Unix, convertie en valeur longue. Le booléen *true* sera converti en valeur longue *1*, et *false* en *0*. ``` ROW str1 = \"2147483648\", str2 = \"2147483648.2\", str3 = \"foo\" | EVAL long1 = TO_LONG(str1), long2 = TO_LONG(str2), long3 = TO_LONG(str3) ```", "languageDocumentation.documentationESQL.to_lower": "TO_LOWER", - "languageDocumentation.documentationESQL.to_lower.markdown": "\n\n ### TO_LOWER\n Renvoie une nouvelle chaîne représentant la chaîne d'entrée convertie en minuscules.\n\n ````\n ROW message = \"Some Text\"\n | EVAL message_lower = TO_LOWER(message)\n ````\n ", + "languageDocumentation.documentationESQL.to_lower.markdown": " ### TO_LOWER Renvoie une nouvelle chaîne représentant la chaîne d'entrée convertie en minuscules. ``` ROW message = \"Some Text\" | EVAL message_lower = TO_LOWER(message) ```", "languageDocumentation.documentationESQL.to_radians": "TO_RADIANS", - "languageDocumentation.documentationESQL.to_radians.markdown": "\n\n ### TO_RADIANS\n Convertit un nombre en degrés en radians.\n\n ````\n ROW deg = [90.0, 180.0, 270.0]\n | EVAL rad = TO_RADIANS(deg)\n ````\n ", + "languageDocumentation.documentationESQL.to_radians.markdown": " ### TO_RADIANS Convertit un nombre en degrés en radians. ``` ROW deg = [90.0, 180.0, 270.0] | EVAL rad = TO_RADIANS(deg) ```", "languageDocumentation.documentationESQL.to_string": "TO_STRING", - "languageDocumentation.documentationESQL.to_string.markdown": "\n\n ### TO_STRING\n Convertit une valeur d'entrée en une chaîne.\n\n ````\n ROW a=10\n | EVAL j = TO_STRING(a)\n ````\n ", + "languageDocumentation.documentationESQL.to_string.markdown": " ### TO_STRING Convertit une valeur d'entrée en une chaîne. ``` ROW a=10 | EVAL j = TO_STRING(a) ```", + "languageDocumentation.documentationESQL.to_timeduration": "TO_TIMEDURATION", + "languageDocumentation.documentationESQL.to_timeduration.markdown": " ### TO_TIMEDURATION Convertit une valeur d'entrée en valeur `time_duration`. ``` row x = \"2024-01-01\"::datetime | eval y = x + \"3 hours\"::time_duration, z = x - to_timeduration(\"3 hours\"); ```", "languageDocumentation.documentationESQL.to_unsigned_long": "TO_UNSIGNED_LONG", - "languageDocumentation.documentationESQL.to_unsigned_long.markdown": "\n\n ### TO_UNSIGNED_LONG\n Convertit une valeur d'entrée en une valeur longue non signée. Si le paramètre d'entrée est de type date,\n sa valeur sera interprétée en millisecondes depuis l'heure Unix, convertie en valeur longue non signée.\n Le booléen *true* sera converti en valeur longue non signée *1*, et *false* en *0*.\n\n ````\n ROW str1 = \"2147483648\", str2 = \"2147483648.2\", str3 = \"foo\"\n | EVAL long1 = TO_UNSIGNED_LONG(str1), long2 = TO_ULONG(str2), long3 = TO_UL(str3)\n ````\n ", + "languageDocumentation.documentationESQL.to_unsigned_long.markdown": " ### TO_UNSIGNED_LONG Convertit une valeur d'entrée en une valeur longue non signée. Si le paramètre d'entrée est de type date, sa valeur sera interprétée en millisecondes depuis l'heure Unix, convertie en valeur longue non signée. Le booléen *true* sera converti en valeur longue non signée *1*, et *false* en *0*. ``` ROW str1 = \"2147483648\", str2 = \"2147483648.2\", str3 = \"foo\" | EVAL long1 = TO_UNSIGNED_LONG(str1), long2 = TO_ULONG(str2), long3 = TO_UL(str3) ```", "languageDocumentation.documentationESQL.to_upper": "TO_UPPER", - "languageDocumentation.documentationESQL.to_upper.markdown": "\n\n ### TO_UPPER\n Renvoie une nouvelle chaîne représentant la chaîne d'entrée convertie en majuscules.\n\n ````\n ROW message = \"Some Text\"\n | EVAL message_upper = TO_UPPER(message)\n ````\n ", + "languageDocumentation.documentationESQL.to_upper.markdown": " ### TO_UPPER Renvoie une nouvelle chaîne représentant la chaîne d'entrée convertie en majuscules. ``` ROW message = \"Some Text\" | EVAL message_upper = TO_UPPER(message) ```", "languageDocumentation.documentationESQL.to_version": "TO_VERSION", - "languageDocumentation.documentationESQL.to_version.markdown": "\n\n ### TO_VERSION\n Convertit une chaîne d'entrée en une valeur de version.\n\n ````\n ROW v = TO_VERSION(\"1.2.3\")\n ````\n ", + "languageDocumentation.documentationESQL.to_version.markdown": " ### TO_VERSION Convertit une chaîne d'entrée en une valeur de version. ``` ROW v = TO_VERSION(\"1.2.3\") ```", + "languageDocumentation.documentationESQL.top": "TOP", + "languageDocumentation.documentationESQL.top.markdown": " ### TOP Collecte les valeurs les plus élevées d'un champ. Inclut les valeurs répétées. ``` FROM employees | STATS top_salaries = TOP(salary, 3, \"desc\"), top_salary = MAX(salary) ```", "languageDocumentation.documentationESQL.trim": "TRIM", - "languageDocumentation.documentationESQL.trim.markdown": "\n\n ### TRIM\n Supprime les espaces de début et de fin d'une chaîne.\n\n ````\n ROW message = \" some text \", color = \" red \"\n | EVAL message = TRIM(message)\n | EVAL color = TRIM(color)\n ````\n ", + "languageDocumentation.documentationESQL.trim.markdown": " ### TRIM Supprime les espaces de début et de fin d'une chaîne. ``` ROW message = \" some text \", color = \" red \" | EVAL message = TRIM(message) | EVAL color = TRIM(color) ```", + "languageDocumentation.documentationESQL.values": "VALEURS", + "languageDocumentation.documentationESQL.values.markdown": " ### VALUES Renvoie toutes les valeurs d'un groupe dans un champ multivalué. L'ordre des valeurs renvoyées n'est pas garanti. Si vous avez besoin que les valeurs renvoyées soient dans l'ordre, utilisez `esql-mv_sort`. ``` FROM employees | EVAL first_letter = SUBSTRING(first_name, 0, 1) | STATS first_name=MV_SORT(VALUES(first_name)) BY first_letter | SORT first_letter ```", + "languageDocumentation.documentationESQL.weighted_avg": "WEIGHTED_AVG", + "languageDocumentation.documentationESQL.weighted_avg.markdown": " ### WEIGHTED_AVG La moyenne pondérée d'une expression numérique. ``` FROM employees | STATS w_avg = WEIGHTED_AVG(salary, height) by languages | EVAL w_avg = ROUND(w_avg) | KEEP w_avg, languages | SORT languages ```", "languageDocumentation.documentationESQL.where": "WHERE", - "languageDocumentation.documentationESQL.where.markdown": "### WHERE\nUtilisez `WHERE` afin d'obtenir un tableau qui comprend toutes les lignes du tableau d'entrée pour lesquelles la condition fournie est évaluée à `true` :\n \n````\nFROM employees\n| KEEP first_name, last_name, still_hired\n| WHERE still_hired == true\n````\n\n#### Opérateurs\n\nPour obtenir un aperçu des opérateurs pris en charge, consultez la section **Opérateurs**.\n\n#### Fonctions\n`WHERE` prend en charge diverses fonctions de calcul des valeurs. Pour en savoir plus, consultez la section **Fonctions**.\n ", + "languageDocumentation.documentationESQL.where.markdown": "### WHERE Utilisez `WHERE` afin d'obtenir un tableau qui comprend toutes les lignes du tableau d'entrée pour lesquelles la condition fournie est évaluée à `true` : ``` FROM employees | KEEP first_name, last_name, still_hired | WHERE still_hired == true ``` #### Opérateurs Pour obtenir un aperçu des opérateurs pris en charge, consultez la section **Opérateurs**. #### Fonctions `WHERE` prend en charge diverses fonctions de calcul des valeurs. Pour en savoir plus, consultez la section **Fonctions**.", + "languageDocumentation.documentationFlyoutTitle": "Référence rapide ES|QL", "languageDocumentation.documentationLinkLabel": "Voir toute la documentation", + "languageDocumentation.esqlDocsLabel": "Sélectionnez ou recherchez des thèmes", + "languageDocumentation.esqlDocsLinkLabel": "Voir toute la documentation ES|QL", + "languageDocumentation.esqlSections.initialSectionLabel": "ES|QL", "languageDocumentation.header": "Référence de {language}", + "languageDocumentation.navigationAriaLabel": "Naviguer dans la documentation", + "languageDocumentation.navigationPlaceholder": "Commandes et fonctions", "languageDocumentation.searchPlaceholder": "Recherche", "languageDocumentation.tooltip": "Référence de {lang}", "lensFormulaDocs.avg": "Moyenne", "lensFormulaDocs.boolean": "booléen", "lensFormulaDocs.cardinality": "Décompte unique", - "lensFormulaDocs.cardinality.documentation.markdown": "\nCalcule le nombre de valeurs uniques d'un champ donné. Fonctionne pour les nombres, les chaînes, les dates et les valeurs booléennes.\n\nExemple : calculer le nombre de produits différents : \n`unique_count(product.name)`\n\nExemple : calculer le nombre de produits différents du groupe \"clothes\" : \n`unique_count(product.name, kql='product.group=clothes')`\n ", + "lensFormulaDocs.cardinality.documentation.markdown": "Calcule le nombre de valeurs uniques d'un champ donné. Fonctionne pour les nombres, les chaînes, les dates et les valeurs booléennes. Exemple : Calculer le nombre de produits différents : `unique_count(product.name)` Exemple : Calculer le nombre de produits différents du groupe \"vêtements\" : `unique_count(product.name, kql='product.group=clothes')`", "lensFormulaDocs.cardinality.signature": "champ : chaîne", "lensFormulaDocs.CommonFormulaDocumentation": "Les formules les plus courantes divisent deux valeurs pour produire un pourcentage. Pour obtenir un affichage correct, définissez \"Format de valeur\" sur \"pourcent\".", "lensFormulaDocs.count": "Décompte", - "lensFormulaDocs.count.documentation.markdown": "\nNombre total de documents. Lorsque vous fournissez un champ, le nombre total de valeurs de champ est compté. Lorsque vous utilisez la fonction de décompte pour les champs qui comportent plusieurs valeurs dans un même document, toutes les valeurs sont comptées.\n\n#### Exemples\n\nPour calculer le nombre total de documents, utilisez `count()`.\n\nPour calculer le nombre de produits, utilisez `count(products.id)`.\n\nPour calculer le nombre de documents qui correspondent à un filtre donné, utilisez `count(kql='price > 500')`.\n", + "lensFormulaDocs.count.documentation.markdown": "Nombre total de documents. Lorsque vous fournissez un champ, le nombre total de valeurs de champ est compté. Lorsque vous utilisez la fonction de décompte pour les champs qui comportent plusieurs valeurs dans un même document, toutes les valeurs sont comptées. #### Exemples Pour calculer le nombre total de documents, utilisez `count()`. Pour calculer le nombre de produits, utilisez `count(products.id)`. Pour calculer le nombre de documents qui correspondent à un filtre donné, utilisez `count(kql='price > 500')`.", "lensFormulaDocs.count.signature": "[champ : chaîne]", "lensFormulaDocs.counterRate": "Taux de compteur", - "lensFormulaDocs.counterRate.documentation.markdown": "\nCalcule le taux d'un compteur toujours croissant. Cette fonction renvoie uniquement des résultats utiles inhérents aux champs d'indicateurs de compteur qui contiennent une mesure quelconque à croissance régulière.\nSi la valeur diminue, elle est interprétée comme une mesure de réinitialisation de compteur. Pour obtenir des résultats plus précis, `counter_rate\" doit être calculé d’après la valeur `max` du champ.\n\nCe calcul est réalisé séparément pour des séries distinctes définies par des filtres ou des dimensions de valeurs supérieures.\nIl utilise l'intervalle en cours utilisé dans la formule.\n\nExemple : visualiser le taux d'octets reçus au fil du temps par un serveur Memcached : \n`counter_rate(max(memcached.stats.read.bytes))`\n ", + "lensFormulaDocs.counterRate.documentation.markdown": "Calcule le taux d'un compteur toujours croissant. Cette fonction renvoie uniquement des résultats utiles inhérents aux champs d'indicateurs de compteur qui contiennent une mesure quelconque à croissance régulière. Si la valeur diminue, elle est interprétée comme une mesure de réinitialisation de compteur. Pour obtenir des résultats plus précis, `counter_rate\" doit être calculé d’après la valeur `max` du champ. Ce calcul est réalisé séparément pour des séries distinctes définies par des filtres ou des dimensions de valeurs supérieures. Il utilise l'intervalle en cours utilisé dans la formule. Exemple : Visualiser le taux d'octets reçus au fil du temps par un serveur Memcached : `counter_rate(max(memcached.stats.read.bytes))`", "lensFormulaDocs.counterRate.signature": "indicateur : nombre", "lensFormulaDocs.cumulative_sum.signature": "indicateur : nombre", "lensFormulaDocs.cumulativeSum": "Somme cumulée", - "lensFormulaDocs.cumulativeSum.documentation.markdown": "\nCalcule la somme cumulée d'un indicateur au fil du temps, en ajoutant toutes les valeurs précédentes d'une série à chaque valeur. Pour utiliser cette fonction, vous devez également configurer une dimension de l'histogramme de dates.\n\nCe calcul est réalisé séparément pour des séries distinctes définies par des filtres ou des dimensions de valeurs supérieures.\n\nExemple : visualiser les octets reçus cumulés au fil du temps : \n`cumulative_sum(sum(bytes))`\n", + "lensFormulaDocs.cumulativeSum.documentation.markdown": "Calcule la somme cumulée d'un indicateur au fil du temps, en ajoutant toutes les valeurs précédentes d'une série à chaque valeur. Pour utiliser cette fonction, vous devez également configurer une dimension de l'histogramme de dates. Ce calcul est réalisé séparément pour des séries distinctes définies par des filtres ou des dimensions de valeurs supérieures. Exemple : Visualiser les octets reçus cumulés au fil du temps : `cumulative_sum(sum(bytes))`", "lensFormulaDocs.derivative": "Différences", - "lensFormulaDocs.differences.documentation.markdown": "\nCalcule la différence par rapport à la dernière valeur d'un indicateur au fil du temps. Pour utiliser cette fonction, vous devez également configurer une dimension de l'histogramme de dates.\nLes données doivent être séquentielles pour les différences. Si vos données sont vides lorsque vous utilisez des différences, essayez d'augmenter l'intervalle de l'histogramme de dates.\n\nCe calcul est réalisé séparément pour des séries distinctes définies par des filtres ou des dimensions de valeurs supérieures.\n\nExemple : visualiser la modification des octets reçus au fil du temps : \n`differences(sum(bytes))`\n", + "lensFormulaDocs.differences.documentation.markdown": "Calcule la différence par rapport à la dernière valeur d'un indicateur au fil du temps. Pour utiliser cette fonction, vous devez également configurer une dimension de l'histogramme de dates. Les données doivent être séquentielles pour les différences. Si vos données sont vides lorsque vous utilisez des différences, essayez d'augmenter l'intervalle de l'histogramme de dates. Ce calcul est réalisé séparément pour des séries distinctes définies par des filtres ou des dimensions de valeurs supérieures. Exemple : Visualiser l'évolution du nombre d'octets reçus au fil du temps : `differences(sum(bytes))`", "lensFormulaDocs.differences.signature": "indicateur : nombre", "lensFormulaDocs.documentation.columnCalculationSection": "Calculs de colonnes", "lensFormulaDocs.documentation.columnCalculationSectionDescription": "Ces fonctions sont exécutées pour chaque ligne, mais elles sont fournies avec la colonne entière comme contexte. Elles sont également appelées fonctions de fenêtre.", @@ -5693,92 +6300,92 @@ "lensFormulaDocs.documentation.elasticsearchSection": "Elasticsearch", "lensFormulaDocs.documentation.elasticsearchSectionDescription": "Ces fonctions seront exécutées sur les documents bruts pour chaque ligne du tableau résultant, en agrégeant tous les documents correspondant aux dimensions de répartition en une seule valeur.", "lensFormulaDocs.documentation.filterRatio": "Rapport de filtre", - "lensFormulaDocs.documentation.filterRatioDescription.markdown": "### Rapport de filtre :\n\nUtilisez `kql=''` pour filtrer un ensemble de documents et le comparer à d'autres documents du même regroupement.\nPar exemple, pour consulter l'évolution du taux d'erreur au fil du temps :\n\n````\ncount(kql='response.status_code > 400') / count()\n````\n ", - "lensFormulaDocs.documentation.markdown": "## Fonctionnement\n\nLes formules Lens permettent de réaliser des calculs à l'aide d'une combinaison d'agrégations Elasticsearch et\nde fonctions mathématiques. Trois types principaux de fonctions existent :\n\n* Indicateurs Elasticsearch, comme `sum(bytes)`\n* Fonctions de séries temporelles utilisant les indicateurs Elasticsearch en tant qu'entrée, comme `cumulative_sum()`\n* Fonctions mathématiques, comme `round()`\n\nVoici un exemple de formule qui les utilise tous :\n\n````\nround(100 * moving_average(\naverage(cpu.load.pct),\nwindow=10,\nkql='datacenter.name: east*'\n))\n````\n\nLes fonctions Elasticsearch utilisent un nom de champ, qui peut être entre guillemets. `sum(bytes)` est ainsi identique à\n`sum('bytes')`.\n\nCertaines fonctions utilisent des arguments nommés, comme `moving_average(count(), window=5)`.\n\nLes indicateurs Elasticsearch peuvent être filtrés à l’aide de la syntaxe KQL ou Lucene. Pour ajouter un filtre, utilisez le paramètre\nnommé `kql='field: value'` ou `lucene=''`. Utilisez toujours des guillemets simples pour écrire des requêtes KQL\nou Lucene. Si votre recherche contient un guillemet simple, utilisez une barre oblique inverse pour l’échapper, par exemple : `kql='Women\\'s\".\n\nLes fonctions mathématiques peuvent utiliser des arguments positionnels : par exemple, pow(count(), 3) est identique à count() * count() * count().\n\nUtilisez les opérateurs +, -, / et * pour réaliser des opérations de base.\n", + "lensFormulaDocs.documentation.filterRatioDescription.markdown": "### Rapport de filtre : Utilisez `kql=''` pour filtrer un ensemble de documents et le comparer à d'autres documents du même regroupement. Par exemple, pour consulter l'évolution du taux d'erreur au fil du temps : ``` count(kql='response.status_code > 400') / count() ```", + "lensFormulaDocs.documentation.markdown": "## Fonctionnement Les formules Lens permettent de réaliser des calculs à l'aide d'une combinaison d'agrégations Elasticsearch et de fonctions mathématiques. Il existe trois principaux types de fonctions : * Indicateurs Elasticsearch, comme `sum(bytes)` * Fonctions de séries temporelles utilisant les indicateurs Elasticsearch en tant qu'entrée, comme `cumulative_sum()` * Fonctions mathématiques, comme `round()` Voici un exemple de formule qui les utilise tous : ``` round(100 * moving_average( average(cpu.load.pct), window=10, kql='datacenter.name: east*' )) ``` Les fonctions Elasticsearch utilisent un nom de champ, qui peut être entre guillemets. `sum(bytes)` est ainsi identique à `sum('bytes')`. Certaines fonctions utilisent des arguments nommés, comme `moving_average(count(), window=5)`. Les indicateurs Elasticsearch peuvent être filtrés à l’aide de la syntaxe KQL ou Lucene. Pour ajouter un filtre, utilisez le paramètre `kql='field: value'` ou `lucene=''`. Utilisez toujours des guillemets simples pour écrire des requêtes KQL ou Lucene. Si votre recherche contient un guillemet simple, utilisez une barre oblique inverse pour l'échapper, par exemple : `kql='Women\\'s\". Les fonctions mathématiques peuvent utiliser des arguments positionnels : par exemple, pow(count(), 3) est identique à count() * count() * count(). Utilisez les opérateurs +, -, / et * pour réaliser des opérations de base.", "lensFormulaDocs.documentation.mathSection": "Mathématique", "lensFormulaDocs.documentation.mathSectionDescription": "Ces fonctions seront exécutées pour chaque ligne du tableau résultant en utilisant des valeurs uniques de la même ligne calculées à l'aide d'autres fonctions.", "lensFormulaDocs.documentation.percentOfTotal": "Pourcentage du total", - "lensFormulaDocs.documentation.percentOfTotalDescription.markdown": "### Pourcentage du total\n\nLes formules peuvent calculer `overall_sum` pour tous les regroupements,\nce qui permet de convertir chaque regroupement en un pourcentage du total :\n\n````\nsum(products.base_price) / overall_sum(sum(products.base_price))\n````\n ", + "lensFormulaDocs.documentation.percentOfTotalDescription.markdown": "### Pourcentage du total Les formules peuvent calculer `overall_sum` pour tous les regroupements, ce qui permet de convertir chaque regroupement en un pourcentage du total : ``` sum(products.base_price) / overall_sum(sum(products.base_price)) ```", "lensFormulaDocs.documentation.recentChange": "Modification récente", - "lensFormulaDocs.documentation.recentChangeDescription.markdown": "### Modification récente\n\nUtilisez `reducedTimeRange='30m'` pour ajouter un filtre supplémentaire sur la plage temporelle d'un indicateur aligné avec la fin d'une plage temporelle globale. Vous pouvez l'utiliser pour calculer le degré de modification récente d'une valeur.\n\n````\nmax(system.network.in.bytes, reducedTimeRange=\"30m\")\n- min(system.network.in.bytes, reducedTimeRange=\"30m\")\n````\n ", + "lensFormulaDocs.documentation.recentChangeDescription.markdown": "### Modification récente Utilisez `reducedTimeRange='30m'` pour ajouter un filtre supplémentaire sur la plage temporelle d'un indicateur aligné avec la fin d'une plage temporelle globale. Vous pouvez l'utiliser pour calculer le degré de modification récente d'une valeur. ``` max(system.network.in.bytes, reducedTimeRange=\"30m\") - min(system.network.in.bytes, reducedTimeRange=\"30m\") ```", "lensFormulaDocs.documentation.weekOverWeek": "Semaine après semaine", - "lensFormulaDocs.documentation.weekOverWeekDescription.markdown": "### Semaine après semaine :\n\nUtilisez `shift='1w'` pour obtenir la valeur de chaque regroupement\nde la semaine précédente. Le décalage ne doit pas être utilisé avec la fonction *Valeurs les plus élevées*.\n\n````\npercentile(system.network.in.bytes, percentile=99) /\npercentile(system.network.in.bytes, percentile=99, shift='1w')\n````\n ", + "lensFormulaDocs.documentation.weekOverWeekDescription.markdown": "### Semaine après semaine : Utilisez `shift='1w'` pour obtenir la valeur de chaque regroupement de la semaine précédente. Le décalage ne doit pas être utilisé avec la fonction *Valeurs les plus élevées*. ``` percentile(system.network.in.bytes, percentile=99) / percentile(system.network.in.bytes, percentile=99, shift='1w') ```", "lensFormulaDocs.frequentlyUsedHeading": "Formules courantes", "lensFormulaDocs.interval": "Intervalle de l'histogramme des dates", - "lensFormulaDocs.interval.help": "\nL’intervalle minimum spécifié pour l’histogramme de date, en millisecondes (ms).\n\nExemple : Normalisez l'indicateur de façon dynamique en fonction de la taille d'intervalle du compartiment : \n\"sum(bytes) / interval()\"\n", + "lensFormulaDocs.interval.help": "L’intervalle minimum spécifié pour l’histogramme de date, en millisecondes (ms). Exemple : Normalisez l'indicateur de façon dynamique en fonction de la taille d'intervalle du compartiment : `sum(bytes) / interval()`", "lensFormulaDocs.lastValue": "Dernière valeur", - "lensFormulaDocs.lastValue.documentation.markdown": "\nRenvoie la valeur d'un champ du dernier document, triée par le champ d'heure par défaut de la vue de données.\n\nCette fonction permet de récupérer le dernier état d'une entité.\n\nExemple : obtenir le statut actuel du serveur A : \n`last_value(server.status, kql='server.name=\"A\"')`\n", + "lensFormulaDocs.lastValue.documentation.markdown": "Renvoie la valeur d'un champ du dernier document, triée par le champ d'heure par défaut de la vue de données. Cette fonction permet de récupérer le dernier état d'une entité. Exemple : Obtenir le statut actuel du serveur A : `last_value(server.status, kql='server.name=\"A\"')`", "lensFormulaDocs.lastValue.signature": "champ : chaîne", "lensFormulaDocs.max": "Maximum", "lensFormulaDocs.median": "Médiane", - "lensFormulaDocs.metric.documentation.markdown": "\nRenvoie l'indicateur {metric} d'un champ. Cette fonction fonctionne uniquement pour les champs numériques.\n\nExemple : obtenir l'indicateur {metric} d'un prix : \n`{metric}(price)`\n\nExemple : obtenir l'indicateur {metric} d'un prix pour des commandes du Royaume-Uni : \n`{metric}(price, kql='location:UK')`\n ", + "lensFormulaDocs.metric.documentation.markdown": "Renvoie l'indicateur {metric} d'un champ. Cette fonction fonctionne uniquement pour les champs numériques. Exemple : Obtenir l'indicateur {metric} de prix : `{metric}(price)` Exemple : Obtenir l'indicateur {metric} de prix pour les commandes en provenance du Royaume-Uni : `{metric}(price, kql='location:UK')`", "lensFormulaDocs.metric.signature": "champ : chaîne", "lensFormulaDocs.min": "Minimum", "lensFormulaDocs.moving_average.signature": "indicateur : nombre, [window] : nombre", "lensFormulaDocs.movingAverage": "Moyenne mobile", - "lensFormulaDocs.movingAverage.documentation.markdown": "\nCalcule la moyenne mobile d'un indicateur au fil du temps, en prenant la moyenne des n dernières valeurs pour calculer la valeur actuelle. Pour utiliser cette fonction, vous devez également configurer une dimension de l'histogramme de dates.\nLa valeur de fenêtre par défaut est {defaultValue}.\n\nCe calcul est réalisé séparément pour des séries distinctes définies par des filtres ou des dimensions de valeurs supérieures.\n\nPrend un paramètre nommé `window` qui spécifie le nombre de dernières valeurs à inclure dans le calcul de la moyenne de la valeur actuelle.\n\nExemple : lisser une ligne de mesures : \n`moving_average(sum(bytes), window=5)`\n", + "lensFormulaDocs.movingAverage.documentation.markdown": "Calcule la moyenne mobile d'un indicateur au fil du temps, en prenant la moyenne des n dernières valeurs pour calculer la valeur actuelle. Pour utiliser cette fonction, vous devez également configurer une dimension de l'histogramme de dates. La valeur de fenêtre par défaut est {defaultValue}. Ce calcul est réalisé séparément pour des séries distinctes définies par des filtres ou des dimensions de valeurs supérieures. Prend un paramètre nommé `window` qui spécifie le nombre de dernières valeurs à inclure dans le calcul de la moyenne de la valeur actuelle. Exemple : Lisser une ligne de mesures : `moving_average(sum(bytes), window=5)`", "lensFormulaDocs.now": "Actuel", - "lensFormulaDocs.now.help": "\nLa durée actuelle passée dans Kibana exprimée en millisecondes (ms).\n\nExemple : Depuis combien de temps (en millisecondes) le serveur est-il en marche depuis son dernier redémarrage ? \n\"now() - last_value(start_time)\"\n", + "lensFormulaDocs.now.help": "La durée actuelle passée dans Kibana exprimée en millisecondes (ms). Exemple : Depuis combien de temps (en millisecondes) le serveur est-il en marche depuis son dernier redémarrage ? `now() - last_value(start_time)`", "lensFormulaDocs.number": "numéro", - "lensFormulaDocs.overall_average.documentation.markdown": "\nCalcule la moyenne d'un indicateur pour tous les points de données d'une série dans le graphique actuel. Une série est définie par une dimension à l'aide d'un histogramme de dates ou d'une fonction d'intervalle.\nD'autres dimensions permettant de répartir les données telles que les valeurs supérieures ou les filtres sont traitées en tant que séries distinctes.\n\nSi le graphique actuel n'utilise aucun histogramme de dates ou aucune fonction d'intervalle, `overall_average` calcule la moyenne pour toutes les dimensions, quelle que soit la fonction utilisée.\n\nExemple : écart par rapport à la moyenne : \n`sum(bytes) - overall_average(sum(bytes))`\n", - "lensFormulaDocs.overall_max.documentation.markdown": "\nCalcule la valeur maximale d'un indicateur pour tous les points de données d'une série dans le graphique actuel. Une série est définie par une dimension à l'aide d'un histogramme de dates ou d'une fonction d'intervalle.\nD'autres dimensions permettant de répartir les données telles que les valeurs supérieures ou les filtres sont traitées en tant que séries distinctes.\n\nSi le graphique actuel n'utilise aucun histogramme de dates ou aucune fonction d'intervalle, `overall_max` calcule la valeur maximale pour toutes les dimensions, quelle que soit la fonction utilisée.\n\nExemple : Pourcentage de plage : \n`(sum(bytes) - overall_min(sum(bytes))) / (overall_max(sum(bytes)) - overall_min(sum(bytes)))`\n", + "lensFormulaDocs.overall_average.documentation.markdown": "Calcule la moyenne d'un indicateur pour tous les points de données d'une série dans le graphique actuel. Une série est définie par une dimension à l'aide d'un histogramme de dates ou d'une fonction d'intervalle. D'autres dimensions permettant de répartir les données telles que les valeurs supérieures ou les filtres sont traitées en tant que séries distinctes. Si le graphique actuel n'utilise aucun histogramme de dates ou aucune fonction d'intervalle, `overall_average` calcule la moyenne pour toutes les dimensions, quelle que soit la fonction utilisée. Exemple : Écart par rapport à la moyenne : `sum(bytes) - overall_average(sum(bytes))`", + "lensFormulaDocs.overall_max.documentation.markdown": "Calcule la valeur maximale d'un indicateur pour tous les points de données d'une série dans le graphique actuel. Une série est définie par une dimension à l'aide d'un histogramme de dates ou d'une fonction d'intervalle. D'autres dimensions permettant de répartir les données telles que les valeurs supérieures ou les filtres sont traitées en tant que séries distinctes. Si le graphique actuel n'utilise aucun histogramme de dates ou aucune fonction d'intervalle, `overall_max` calcule la valeur maximale pour toutes les dimensions, quelle que soit la fonction utilisée. Exemple : Pourcentage de plage : `(sum(bytes) - overall_min(sum(bytes)) / (overall_max(sum(bytes)) - overall_min(sum(bytes)))`", "lensFormulaDocs.overall_metric": "indicateur : nombre", - "lensFormulaDocs.overall_min.documentation.markdown": "\nCalcule la valeur minimale d'un indicateur pour tous les points de données d'une série dans le graphique actuel. Une série est définie par une dimension à l'aide d'un histogramme de dates ou d'une fonction d'intervalle.\nD'autres dimensions permettant de répartir les données telles que les valeurs supérieures ou les filtres sont traitées en tant que séries distinctes.\n\nSi le graphique actuel n'utilise aucun histogramme de dates ou aucune fonction d'intervalle, `overall_min` calcule la valeur minimale pour toutes les dimensions, quelle que soit la fonction utilisée.\n\nExemple : Pourcentage de plage : \n`(sum(bytes) - overall_min(sum(bytes)) / (overall_max(sum(bytes)) - overall_min(sum(bytes)))`\n", - "lensFormulaDocs.overall_sum.documentation.markdown": "\nCalcule la somme d'un indicateur pour tous les points de données d'une série dans le graphique actuel. Une série est définie par une dimension à l'aide d'un histogramme de dates ou d'une fonction d'intervalle.\nD'autres dimensions permettant de répartir les données telles que les valeurs supérieures ou les filtres sont traitées en tant que séries distinctes.\n\nSi le graphique actuel n'utilise aucun histogramme de dates ou aucune fonction d'intervalle, `overall_sum` calcule la somme pour toutes les dimensions, quelle que soit la fonction utilisée.\n\nExemple : Pourcentage total : \n`sum(bytes) / overall_sum(sum(bytes))`\n", + "lensFormulaDocs.overall_min.documentation.markdown": "Calcule la valeur minimale d'un indicateur pour tous les points de données d'une série dans le graphique actuel. Une série est définie par une dimension à l'aide d'un histogramme de dates ou d'une fonction d'intervalle. D'autres dimensions permettant de répartir les données telles que les valeurs supérieures ou les filtres sont traitées en tant que séries distinctes. Si le graphique actuel n'utilise aucun histogramme de dates ou aucune fonction d'intervalle, `overall_min` calcule la valeur minimale pour toutes les dimensions, quelle que soit la fonction utilisée. Exemple : Pourcentage de plage : `(sum(bytes) - overall_min(sum(bytes)) / (overall_max(sum(bytes)) - overall_min(sum(bytes)))`", + "lensFormulaDocs.overall_sum.documentation.markdown": "Calcule la somme d'un indicateur pour tous les points de données d'une série dans le graphique actuel. Une série est définie par une dimension à l'aide d'un histogramme de dates ou d'une fonction d'intervalle. D'autres dimensions permettant de répartir les données telles que les valeurs supérieures ou les filtres sont traitées en tant que séries distinctes. Si le graphique actuel n'utilise aucun histogramme de dates ou aucune fonction d'intervalle, `overall_sum` calcule la somme pour toutes les dimensions, quelle que soit la fonction utilisée. Exemple : Pourcentage du total : `sum(bytes) / overall_sum(sum(bytes))`", "lensFormulaDocs.overallAverage": "Moyenne globale", "lensFormulaDocs.overallMax": "Max général", "lensFormulaDocs.overallMin": "Min général", "lensFormulaDocs.overallSum": "Somme générale", "lensFormulaDocs.percentile": "Centile", - "lensFormulaDocs.percentile.documentation.markdown": "\nRenvoie le centile spécifié des valeurs d'un champ. Il s'agit de la valeur de n pour cent des valeurs présentes dans les documents.\n\nExemple : obtenir le nombre d'octets supérieurs à 95 % des valeurs : \n`percentile(bytes, percentile=95)`\n", + "lensFormulaDocs.percentile.documentation.markdown": "Renvoie le centile spécifié des valeurs d'un champ. Il s'agit de la valeur de n pour cent des valeurs présentes dans les documents. Exemple : Obtenir le nombre d'octets supérieurs à 95 % des valeurs : `percentile(bytes, percentile=95)`", "lensFormulaDocs.percentile.signature": "champ : chaîne, [percentile] : nombre", "lensFormulaDocs.percentileRank": "Rang centile", - "lensFormulaDocs.percentileRanks.documentation.markdown": "\nRenvoie le pourcentage de valeurs qui sont en dessous d'une certaine valeur. Par exemple, si une valeur est supérieure à 95 % des valeurs observées, elle est placée au 95e rang centile.\n\nExemple : Obtenir le pourcentage de valeurs qui sont en dessous de 100 : \n`percentile_rank(bytes, value=100)`\n", + "lensFormulaDocs.percentileRanks.documentation.markdown": "Renvoie le pourcentage de valeurs qui sont en dessous d'une certaine valeur. Par exemple, si une valeur est supérieure à 95 % des valeurs observées, elle est placée au 95e rang centile. Exemple : Obtenir le pourcentage de valeurs qui sont en dessous de 100 : `percentile_rank(bytes, value=100)`", "lensFormulaDocs.percentileRanks.signature": "champ : chaîne, [valeur] : nombre", "lensFormulaDocs.standardDeviation": "Écart-type", - "lensFormulaDocs.standardDeviation.documentation.markdown": "\nRenvoie la taille de la variation ou de la dispersion du champ. Cette fonction ne s’applique qu’aux champs numériques.\n\n#### Exemples\n\nPour obtenir l'écart-type d'un prix, utilisez `standard_deviation(price)`.\n\nPour obtenir la variance du prix des commandes passées au Royaume-Uni, utilisez `square(standard_deviation(price, kql='location:UK'))`.\n", + "lensFormulaDocs.standardDeviation.documentation.markdown": "Renvoie la taille de la variation ou de la dispersion du champ. Cette fonction ne s'applique qu'aux champs numériques. #### Exemples Pour obtenir l'écart-type d'un prix, utilisez `standard_deviation(price)`. Pour obtenir la variance du prix des commandes passées au Royaume-Uni, utilisez `square(standard_deviation(price, kql='location:UK'))`.", "lensFormulaDocs.string": "chaîne", "lensFormulaDocs.sum": "Somme", "lensFormulaDocs.time_range": "Plage temporelle", "lensFormulaDocs.time_scale": "indicateur : nombre, unité : s|m|h|d|w|M|y", - "lensFormulaDocs.time_scale.documentation.markdown": "\nCette fonction avancée est utile pour normaliser les comptes et les sommes sur un intervalle de temps spécifique. Elle permet l'intégration avec les indicateurs qui sont stockés déjà normalisés sur un intervalle de temps spécifique.\n\nVous pouvez faire appel à cette fonction uniquement si une fonction d'histogramme des dates est utilisée dans le graphique actuel.\n\nExemple : Un rapport comparant un indicateur déjà normalisé à un autre indicateur devant être normalisé. \n`normalize_by_unit(counter_rate(max(system.diskio.write.bytes)), unit='s') / last_value(apache.status.bytes_per_second)`\n", - "lensFormulaDocs.timeRange.help": "\nL'intervalle de temps spécifié, en millisecondes (ms).\n\nExemple : Quelle est la durée de la plage temporelle actuelle en (ms) ?\n`time_range()`\n\nExemple : Une moyenne statique par minute calculée avec l'intervalle de temps actuel :\n`(sum(bytes) / time_range()) * 1000 * 60`\n", + "lensFormulaDocs.time_scale.documentation.markdown": "Cette fonction avancée est utile pour normaliser les comptes et les sommes sur un intervalle de temps spécifique. Elle permet l'intégration avec les indicateurs qui sont stockés déjà normalisés sur un intervalle de temps spécifique. Vous pouvez faire appel à cette fonction uniquement si une fonction d'histogramme des dates est utilisée dans le graphique actuel. Exemple : Un rapport comparant un indicateur déjà normalisé à un autre indicateur devant être normalisé. `normalize_by_unit(counter_rate(max(system.diskio.write.bytes)), unit='s') / last_value(apache.status.bytes_per_second)`", + "lensFormulaDocs.timeRange.help": "L'intervalle de temps spécifié, en millisecondes (ms). Exemple : Quelle est la durée de la plage temporelle actuelle en (ms) ? `time_range()` Exemple : Une moyenne statique par minute calculée avec l'intervalle de temps actuel : `(sum(bytes) / time_range()) * 1000 * 60`", "lensFormulaDocs.timeScale": "Normaliser par unité", - "lensFormulaDocs.tinymath.absFunction.markdown": "\nCalcule une valeur absolue. Une valeur négative est multipliée par -1, une valeur positive reste identique.\n\nExemple : calculer la distance moyenne par rapport au niveau de la mer `abs(average(altitude))`\n ", - "lensFormulaDocs.tinymath.addFunction.markdown": "\nAjoute jusqu'à deux nombres.\nFonctionne également avec le symbole `+`.\n\nExemple : calculer la somme de deux champs\n\n`sum(price) + sum(tax)`\n\nExemple : compenser le compte par une valeur statique\n\n`add(count(), 5)`\n ", + "lensFormulaDocs.tinymath.absFunction.markdown": "Calcule une valeur absolue. Une valeur négative est multipliée par -1, une valeur positive reste identique. Exemple : calculer la distance moyenne par rapport au niveau de la mer `abs(average(altitude))`", + "lensFormulaDocs.tinymath.addFunction.markdown": "Ajoute jusqu'à deux nombres. Fonctionne également avec le symbole `+`. Exemple : Calculer la somme de deux champs `sum(price) + sum(tax)` Exemple : Compenser le compte par une valeur statique `add(count(), 5)`", "lensFormulaDocs.tinymath.base": "base", - "lensFormulaDocs.tinymath.cbrtFunction.markdown": "\nÉtablit la racine carrée de la valeur.\n\nExemple : calculer la longueur du côté à partir du volume\n`cbrt(last_value(volume))`\n ", - "lensFormulaDocs.tinymath.ceilFunction.markdown": "\nArrondit le plafond de la valeur au chiffre supérieur.\n\nExemple : arrondir le prix au dollar supérieur\n`ceil(sum(price))`\n ", - "lensFormulaDocs.tinymath.clampFunction.markdown": "\nÉtablit une limite minimale et maximale pour la valeur.\n\nExemple : s'assurer de repérer les valeurs aberrantes\n````\nclamp(\n average(bytes),\n percentile(bytes, percentile=5),\n percentile(bytes, percentile=95)\n)\n````\n", + "lensFormulaDocs.tinymath.cbrtFunction.markdown": "Établit la racine carrée de la valeur. Exemple : Calculer la longueur du côté à partir du volume `cbrt(last_value(volume))`", + "lensFormulaDocs.tinymath.ceilFunction.markdown": "Arrondit le plafond de la valeur au chiffre supérieur. Exemple : Arrondir le prix au dollar supérieur `ceil(sum(price))`", + "lensFormulaDocs.tinymath.clampFunction.markdown": "Établit une limite minimale et maximale pour la valeur. Exemple : S'assurer de repérer les valeurs aberrantes ``` clamp( average(bytes), percentile(bytes, percentile=5), percentile(bytes, percentile=95) ) ```", "lensFormulaDocs.tinymath.condition": "condition", - "lensFormulaDocs.tinymath.cubeFunction.markdown": "\nCalcule le cube d'un nombre.\n\nExemple : calculer le volume à partir de la longueur du côté\n`cube(last_value(length))`\n ", + "lensFormulaDocs.tinymath.cubeFunction.markdown": "Calcule le cube d'un nombre. Exemple : Calculer le volume à partir de la longueur du côté `cube(last_value(length))`", "lensFormulaDocs.tinymath.decimals": "décimales", - "lensFormulaDocs.tinymath.defaultFunction.markdown": "\nRenvoie une valeur numérique par défaut lorsque la valeur est nulle.\n\nExemple : Renvoie -1 lorsqu'un champ ne contient aucune donnée.\n`defaults(average(bytes), -1)`\n", + "lensFormulaDocs.tinymath.defaultFunction.markdown": "Renvoie une valeur numérique par défaut lorsque la valeur est nulle. Exemple : Renvoie -1 si un champ n'a pas de données `defaults(average(bytes), -1)`", "lensFormulaDocs.tinymath.defaultValue": "par défaut", - "lensFormulaDocs.tinymath.divideFunction.markdown": "\nDivise le premier nombre par le deuxième.\nFonctionne également avec le symbole `/`.\n\nExemple : calculer la marge bénéficiaire\n`sum(profit) / sum(revenue)`\n\nExemple : `divide(sum(bytes), 2)`\n ", - "lensFormulaDocs.tinymath.eqFunction.markdown": "\nEffectue une comparaison d'égalité entre deux valeurs.\nÀ utiliser en tant que condition pour la fonction de comparaison `ifelse`.\nFonctionne également avec le symbole `==`.\n\nExemple : Renvoie \"true\" si la moyenne d'octets est égale à la quantité de mémoire moyenne.\n`average(bytes) == average(memory)`\n\nExemple : `eq(sum(bytes), 1000000)`\n ", - "lensFormulaDocs.tinymath.expFunction.markdown": "\nÉlève *e* à la puissance n.\n\nExemple : calculer la fonction exponentielle naturelle\n\n`exp(last_value(duration))`\n ", - "lensFormulaDocs.tinymath.fixFunction.markdown": "\nPour les valeurs positives, part du bas. Pour les valeurs négatives, part du haut.\n\nExemple : arrondir à zéro\n`fix(sum(profit))`\n ", - "lensFormulaDocs.tinymath.floorFunction.markdown": "\nArrondit à la valeur entière inférieure la plus proche.\n\nExemple : arrondir un prix au chiffre inférieur\n`floor(sum(price))`\n ", - "lensFormulaDocs.tinymath.gteFunction.markdown": "\nEffectue une comparaison de supériorité entre deux valeurs.\nÀ utiliser en tant que condition pour la fonction de comparaison `ifelse`.\nFonctionne également avec le symbole `>=`.\n\nExemple : Renvoie \"true\" si la moyenne d'octets est supérieure ou égale à la quantité moyenne de mémoire.\n`average(bytes) >= average(memory)`\n\nExemple : `gte(average(bytes), 1000)`\n ", - "lensFormulaDocs.tinymath.gtFunction.markdown": "\nEffectue une comparaison de supériorité entre deux valeurs.\nÀ utiliser en tant que condition pour la fonction de comparaison `ifelse`.\nFonctionne également avec le symbole `>`.\n\nExemple : Renvoie \"true\" si la moyenne d'octets est supérieure à la quantité moyenne de mémoire.\n`average(bytes) > average(memory)`\n\nExemple : `gt(average(bytes), 1000)`\n ", - "lensFormulaDocs.tinymath.ifElseFunction.markdown": "\nRenvoie une valeur selon si l'élément de condition est \"true\" ou \"false\".\n\nExemple : Revenus moyens par client, mais dans certains cas, l'ID du client n'est pas fourni, et le client est alors compté comme client supplémentaire.\n`sum(total)/(unique_count(customer_id) + ifelse( count() > count(kql='customer_id:*'), 1, 0))`\n ", + "lensFormulaDocs.tinymath.divideFunction.markdown": "Divise le premier nombre par le deuxième. Fonctionne également avec le symbole `/`. Exemple : Calculer la marge bénéficiaire `sum(profit) / sum(revenue)` Exemple : `divide(sum(bytes), 2)`", + "lensFormulaDocs.tinymath.eqFunction.markdown": "Effectue une comparaison d'égalité entre deux valeurs. À utiliser en tant que condition pour la fonction de comparaison `ifelse`. Fonctionne également avec le symbole `==`. Exemple : Renvoie \"true\" si la moyenne d'octets est égale à la quantité de mémoire moyenne `average(bytes) == average(memory)` Exemple : `eq(sum(bytes), 1000000)`", + "lensFormulaDocs.tinymath.expFunction.markdown": "Élève *e* à la puissance n. Exemple : Calculer la fonction exponentielle naturelle `exp(last_value(duration))`", + "lensFormulaDocs.tinymath.fixFunction.markdown": "Pour les valeurs positives, part du bas. Pour les valeurs négatives, part du haut. Exemple : Arrondir vers zéro `fix(sum(profit))`", + "lensFormulaDocs.tinymath.floorFunction.markdown": "Arrondit à la valeur entière inférieure la plus proche. Exemple : Arrondir un prix à la baisse `floor(sum(price))`", + "lensFormulaDocs.tinymath.gteFunction.markdown": "Effectue une comparaison de supériorité entre deux valeurs. À utiliser en tant que condition pour la fonction de comparaison `ifelse`. Fonctionne également avec le symbole `>=`. Exemple : Renvoie \"true\" si la moyenne d'octets est supérieure ou égale à la quantité de mémoire moyenne `average(bytes) >= average(memory)` Exemple : `gte(average(bytes), 1000)`", + "lensFormulaDocs.tinymath.gtFunction.markdown": "Effectue une comparaison de supériorité entre deux valeurs. À utiliser en tant que condition pour la fonction de comparaison `ifelse`. Fonctionne également avec le symbole `>`. Exemple : Renvoie \"true\" si la moyenne d'octets est supérieure à la quantité de mémoire moyenne `average(bytes) > average(memory)` Exemple : `gt(average(bytes), 1000)`", + "lensFormulaDocs.tinymath.ifElseFunction.markdown": "Renvoie une valeur selon si l'élément de condition est \"true\" ou \"false\". Exemple : Revenus moyens par client, mais dans certains cas, l'ID du client n'est pas fourni, et le client est alors compté comme client supplémentaire `sum(total)/(unique_count(customer_id) + ifelse( count() > count(kql='customer_id:*'), 1, 0))`", "lensFormulaDocs.tinymath.left": "gauche", - "lensFormulaDocs.tinymath.logFunction.markdown": "\nÉtablit un logarithme avec base optionnelle. La base naturelle *e* est utilisée par défaut.\n\nExemple : calculer le nombre de bits nécessaire au stockage de valeurs\n````\nlog(sum(bytes))\nlog(sum(bytes), 2)\n````\n ", - "lensFormulaDocs.tinymath.lteFunction.markdown": "\nEffectue une comparaison d'infériorité ou de supériorité entre deux valeurs.\nÀ utiliser en tant que condition pour la fonction de comparaison `ifelse`.\nFonctionne également avec le symbole `<=`.\n\nExemple : Renvoie \"true\" si la moyenne d'octets est inférieure ou égale à la quantité moyenne de mémoire.\n`average(bytes) <= average(memory)`\n\nExemple : `lte(average(bytes), 1000)`\n ", - "lensFormulaDocs.tinymath.ltFunction.markdown": "\nEffectue une comparaison d'infériorité entre deux valeurs.\nÀ utiliser en tant que condition pour la fonction de comparaison `ifelse`.\nFonctionne également avec le symbole `<`.\n\nExemple : Renvoie \"true\" si la moyenne d'octets est inférieure à la quantité moyenne de mémoire.\n`average(bytes) <= average(memory)`\n\nExemple : `lt(average(bytes), 1000)`\n ", + "lensFormulaDocs.tinymath.logFunction.markdown": "Établit un logarithme avec base optionnelle. La base naturelle *e* est utilisée par défaut. Exemple : Calculer le nombre de bits nécessaire au stockage de valeurs ``` log(sum(bytes)) log(sum(bytes), 2) ```", + "lensFormulaDocs.tinymath.lteFunction.markdown": "Effectue une comparaison d'infériorité ou de supériorité entre deux valeurs. À utiliser en tant que condition pour la fonction de comparaison `ifelse`. Fonctionne également avec le symbole `<=`. Exemple : Renvoie \"true\" si la moyenne d'octets est inférieure ou égale à la quantité de mémoire moyenne `average(bytes) <= average(memory)` Exemple : `lte(average(bytes), 1000)`", + "lensFormulaDocs.tinymath.ltFunction.markdown": "Effectue une comparaison d'infériorité entre deux valeurs. À utiliser en tant que condition pour la fonction de comparaison `ifelse`. Fonctionne également avec le symbole `<`. Exemple : Renvoie \"true\" si la moyenne d'octets est inférieure à la quantité de mémoire moyenne `average(bytes) <= average(memory)` Exemple : `lt(average(bytes), 1000)`", "lensFormulaDocs.tinymath.max": "max", - "lensFormulaDocs.tinymath.maxFunction.markdown": "\nTrouve la valeur maximale entre deux nombres.\n\nExemple : Trouver le maximum entre deux moyennes de champs\n`pick_max(average(bytes), average(memory))`\n ", + "lensFormulaDocs.tinymath.maxFunction.markdown": "Trouve la valeur maximale entre deux nombres. Exemple : Trouver la valeur maximale entre deux moyennes de champs `pick_max(average(bytes), average(memory))`", "lensFormulaDocs.tinymath.min": "min", - "lensFormulaDocs.tinymath.minFunction.markdown": "\nTrouve la valeur minimale entre deux nombres.\n\nExemple : Trouver le minimum entre deux moyennes de champs\n`pick_min(average(bytes), average(memory))`\n ", - "lensFormulaDocs.tinymath.modFunction.markdown": "\nÉtablit le reste après division de la fonction par un nombre.\n\nExemple : calculer les trois derniers chiffres d'une valeur\n`mod(sum(price), 1000)`\n ", - "lensFormulaDocs.tinymath.multiplyFunction.markdown": "\nMultiplie deux nombres.\nFonctionne également avec le symbole `*`.\n\nExemple : calculer le prix après application du taux d'imposition courant\n`sum(bytes) * last_value(tax_rate)`\n\nExemple : calculer le prix après application du taux d'imposition constant\n`multiply(sum(price), 1.2)`\n ", - "lensFormulaDocs.tinymath.powFunction.markdown": "\nÉlève la valeur à une puissance spécifique. Le deuxième argument est obligatoire.\n\nExemple : calculer le volume en fonction de la longueur du côté\n`pow(last_value(length), 3)`\n ", + "lensFormulaDocs.tinymath.minFunction.markdown": "Trouve la valeur minimale entre deux nombres. Exemple : Trouver la valeur minimale entre deux moyennes de champs `pick_min(average(bytes), average(memory))`", + "lensFormulaDocs.tinymath.modFunction.markdown": "Établit le reste après division de la fonction par un nombre. Exemple : Calculer les trois derniers chiffres d'une valeur `mod(sum(price), 1000)`", + "lensFormulaDocs.tinymath.multiplyFunction.markdown": "Multiplie deux nombres. Fonctionne également avec le symbole `*`. Exemple : Calculer le prix après application du taux d'imposition actuel `sum(bytes) * last_value(tax_rate)` Exemple : Calculer le prix après application du taux d'imposition constant `multiply(sum(price), 1.2)`", + "lensFormulaDocs.tinymath.powFunction.markdown": "Élève la valeur à une puissance spécifique. Le deuxième argument est obligatoire. Exemple : Calculer le volume en fonction de la longueur du côté `pow(last_value(length), 3)`", "lensFormulaDocs.tinymath.right": "droite", - "lensFormulaDocs.tinymath.roundFunction.markdown": "\nArrondit à un nombre donné de décimales, 0 étant la valeur par défaut.\n\nExemples : arrondir au centième\n````\nround(sum(bytes))\nround(sum(bytes), 2)\n````\n ", - "lensFormulaDocs.tinymath.sqrtFunction.markdown": "\nÉtablit la racine carrée d'une valeur positive uniquement.\n\nExemple : calculer la longueur du côté en fonction de la surface\n`sqrt(last_value(area))`\n ", - "lensFormulaDocs.tinymath.squareFunction.markdown": "\nÉlève la valeur à la puissance 2.\n\nExemple : calculer l’aire en fonction de la longueur du côté\n`square(last_value(length))`\n ", - "lensFormulaDocs.tinymath.subtractFunction.markdown": "\nSoustrait le premier nombre du deuxième.\nFonctionne également avec le symbole `-`.\n\nExemple : calculer la plage d'un champ\n`subtract(max(bytes), min(bytes))`\n ", + "lensFormulaDocs.tinymath.roundFunction.markdown": "Arrondit à un nombre donné de décimales, 0 étant la valeur par défaut. Exemple : Arrondir au centième ``` round(sum(bytes)) round(sum(bytes), 2) ```", + "lensFormulaDocs.tinymath.sqrtFunction.markdown": "Établit la racine carrée d'une valeur positive uniquement. Exemple : Calculer la longueur d'un côté en fonction de la surface `sqrt(last_value(area))`", + "lensFormulaDocs.tinymath.squareFunction.markdown": "Élève la valeur à la puissance 2. Exemple : Calculer la surface en fonction de la longueur du côté `square(last_value(length))`", + "lensFormulaDocs.tinymath.subtractFunction.markdown": "Soustrait le premier nombre du deuxième. Fonctionne également avec le symbole `-`. Exemple : Calculer la plage d'un champ `subtract(max(bytes), min(bytes))`", "lensFormulaDocs.tinymath.value": "valeur", "links.contentManagement.saveModalTitle": "Enregistrer le panneau {contentId} dans la bibliothèque", "links.dashboardLink.description": "Accéder au tableau de bord", @@ -5790,14 +6397,16 @@ "links.dashboardLink.editor.loadingDashboardLabel": "Chargement...", "links.dashboardLink.type": "Lien du tableau de bord", "links.description": "Utiliser des liens pour accéder aux tableaux de bord et aux sites web couramment utilisés.", + "links.displayName": "liens", "links.editor.addButtonLabel": "Ajouter un lien", "links.editor.cancelButtonLabel": "Fermer", "links.editor.deleteLinkTitle": "Supprimer le lien {label}", "links.editor.editLinkTitle.hasLabel": "Modifier le lien {label}", "links.editor.horizontalLayout": "Horizontal", - "links.editor.unableToSaveToastTitle": "Erreur lors de l'enregistrement du Panneau de liens", + "links.editor.unableToSaveToastTitle": "Erreur lors de l'enregistrement du panneau de liens", "links.editor.updateButtonLabel": "Mettre à jour le lien", "links.editor.verticalLayout": "Vertical", + "links.embeddable.unsupportedLinkTypeError": "Type de lien non pris en charge", "links.externalLink.description": "Accéder à l'URL", "links.externalLink.displayName": "URL", "links.externalLink.editor.disallowedUrlError": "Cette URL n'est pas autorisée par votre administrateur. Reportez-vous à la configuration \"externalUrl.policy\".", @@ -5838,7 +6447,14 @@ "managedContentBadge.text": "Géré", "management.breadcrumb": "Gestion de la Suite", "management.landing.header": "Bienvenue dans Gestion de la Suite {version}", + "management.landing.solution.header": "Gestion de la Suite {version}", + "management.landing.solution.subhead": "Gérez vos {indicesLink}, {dataViewsLink}, {ingestPipelinesLink}, {usersLink}, et plus encore.", + "management.landing.solution.viewAllPagesButton": "Afficher toutes les pages", "management.landing.subhead": "Gérez vos index, vues de données, objets enregistrés, paramètres Kibana et plus encore.", + "management.landing.subhead.dataViewsLink": "Les vues de données sont introuvables", + "management.landing.subhead.indicesLink": "index système non migrés", + "management.landing.subhead.ingestPipelinesLink": "pipelines d'ingestion", + "management.landing.subhead.usersLink": "utilisateurs", "management.landing.text": "Vous trouverez une liste complète des applications dans le menu de gauche.", "management.landing.withCardNavigation.accessTitle": "Accès", "management.landing.withCardNavigation.alertsTitle": "Alertes et informations exploitables", @@ -5847,6 +6463,7 @@ "management.landing.withCardNavigation.contentTitle": "Contenu", "management.landing.withCardNavigation.dataQualityDescription": "Recherchez et gérez les problèmes de qualité dans vos données de logs.", "management.landing.withCardNavigation.dataTitle": "Données", + "management.landing.withCardNavigation.dataUsageDescription": "Afficher l'utilisation et la conservation des données.", "management.landing.withCardNavigation.dataViewsDescription": "Créez et gérez les données Elasticsearch sélectionnées pour l'exploration.", "management.landing.withCardNavigation.fileManagementDescription": "Accédez à tous les fichiers importés.", "management.landing.withCardNavigation.indexmanagementDescription": "Configurez et assurez la maintenance de vos index Elasticsearch pour le stockage et la récupération des données.", @@ -5862,6 +6479,7 @@ "management.landing.withCardNavigation.rolesDescription": "Créez des rôles uniques pour ce projet et combinez l'ensemble exact de privilèges dont vos utilisateurs ont besoin.", "management.landing.withCardNavigation.rulesDescription": "Définissez à quel moment générer des alertes et des notifications.", "management.landing.withCardNavigation.settingsDescription": "Contrôlez les comportements des projets, tels que l'affichage des dates et le tri par défaut.", + "management.landing.withCardNavigation.spacesDescription": "Organisez vos objets enregistrés en catégories représentatives.", "management.landing.withCardNavigation.tagsDescription": "Organisez, recherchez et filtrez vos objets enregistrés en fonction de critères spécifiques.", "management.landing.withCardNavigation.transformDescription": "Organisez vos données ou copiez les derniers documents dans un index centré sur les entités.", "management.nav.label": "Gestion", @@ -5934,6 +6552,10 @@ "management.settings.spaceCalloutSubtitle": "Les modifications seront uniquement appliquées à l'espace actuel. Ces paramètres sont destinés aux utilisateurs avancés, car des configurations incorrectes peuvent avoir une incidence négative sur des aspects de Kibana.", "management.settings.spaceCalloutTitle": "Les modifications affecteront l'espace actuel.", "management.settings.spaceSettingsTabTitle": "Paramètres de l'espace", + "management.stackManagement.managementDescription": "La console centrale de gestion de la Suite Elastic.", + "management.stackManagement.managementLabel": "Gestion de la Suite", + "management.stackManagement.title": "Gestion de la Suite", + "monaco.esql.hover.acceptableTypes": "Types acceptables", "monaco.esql.hover.policyEnrichedFields": "**Champs**", "monaco.esql.hover.policyIndexes": "**Indexes**", "monaco.esql.hover.policyMatchingField": "**Champ correspondant**", @@ -5941,6 +6563,9 @@ "monaco.painlessLanguage.autocomplete.emitKeywordDescription": "Émettre une valeur sans rien renvoyer", "monaco.painlessLanguage.autocomplete.fieldValueDescription": "Récupérer la valeur du champ \"{fieldName}\"", "monaco.painlessLanguage.autocomplete.paramsKeywordDescription": "Accéder aux variables transmises dans le script", + "navigation.ui_settings.params.defaultRoute.defaultRouteTitle": "Chemin par défaut", + "navigation.uiSettings.defaultRoute.defaultRouteIsRelativeValidationMessage": "Doit être une URL relative.", + "navigation.uiSettings.defaultRoute.defaultRouteText": "Ce paramètre spécifie le chemin par défaut lors de l'ouverture de Kibana. Vous pouvez utiliser ce paramètre pour modifier la page de destination à l'ouverture de Kibana. Le chemin doit être une URL relative.", "newsfeed.emptyPrompt.noNewsText": "Si votre instance Kibana n'a pas accès à Internet, demandez à votre administrateur de désactiver cette fonctionnalité. Sinon, nous continuerons d'essayer de récupérer les actualités.", "newsfeed.emptyPrompt.noNewsTitle": "Pas d'actualités ?", "newsfeed.flyoutList.closeButtonLabel": "Fermer", @@ -6292,6 +6917,10 @@ "savedSearch.kibana_context.savedSearchId.help": "Spécifier l'ID de recherche enregistrée à utiliser pour les requêtes et les filtres", "savedSearch.kibana_context.timeRange.help": "Spécifier le filtre de plage temporelle Kibana", "savedSearch.legacyURLConflict.errorMessage": "Cette recherche a la même URL qu'un alias hérité. Désactiver l'alias pour résoudre cette erreur : {json}", + "searchApiKeysComponents.apiKeyForm.createButton": "Créer une clé d'API", + "searchApiKeysComponents.apiKeyForm.noUserPrivileges": "Vous n'avez pas accès à la gestion des clés d'API", + "searchApiKeysComponents.apiKeyForm.showApiKey": "Afficher la clé d'API", + "searchApiKeysComponents.apiKeyForm.title": "Clé d'API", "searchApiPanels.cloudIdDetails.cloudId.description": "Des bibliothèques et des connecteurs clients peuvent utiliser cet identificateur unique propre à Elastic Cloud.", "searchApiPanels.cloudIdDetails.cloudId.title": "Identifiant du cloud", "searchApiPanels.cloudIdDetails.description": "Soyez prêt à ingérer et rechercher vos données en choisissant une option de connexion :", @@ -6314,25 +6943,37 @@ "searchApiPanels.pipeline.overview.pipelineHandling.description": "Gérez les exceptions d'erreur, exécutez un autre pipeline ou redirigez les documents vers un autre index", "searchApiPanels.pipeline.overview.pipelineHandling.title": "Traitement du pipeline", "searchApiPanels.preprocessData.overview.arrayJsonHandling.learnMore": "En savoir plus", + "searchApiPanels.preprocessData.overview.arrayJsonHandling.learnMore.ariaLabel": "En savoir plus sur la gestion des tableaux/JSON", "searchApiPanels.preprocessData.overview.dataEnrichment.description": "Ajouter des informations des sources externes ou appliquer des transformations à vos documents pour une recherche plus contextuelle et pertinente.", "searchApiPanels.preprocessData.overview.dataEnrichment.learnMore": "En savoir plus", + "searchApiPanels.preprocessData.overview.dataEnrichment.learnMore.ariaLabel": "En savoir plus sur l'enrichissement de données", "searchApiPanels.preprocessData.overview.dataEnrichment.title": "Enrichissement des données", "searchApiPanels.preprocessData.overview.dataFiltering.learnMore": "En savoir plus", + "searchApiPanels.preprocessData.overview.dataFiltering.learnMore.ariaLabel": "En savoir plus sur le filtrage des données", "searchApiPanels.preprocessData.overview.dataTransformation.learnMore": "En savoir plus", + "searchApiPanels.preprocessData.overview.dataTransformation.learnMore.ariaLabel": "En savoir plus sur la transformation des données", "searchApiPanels.preprocessData.overview.pipelineHandling.learnMore": "En savoir plus", + "searchApiPanels.preprocessData.overview.pipelineHandling.learnMore.ariaLabel": "En savoir plus sur la gestion des pipelines", + "searchApiPanels.welcomeBanner.codeBox.copyAriaLabel": "Copier l'extrait de code {context}", "searchApiPanels.welcomeBanner.codeBox.copyButtonLabel": "Copier", + "searchApiPanels.welcomeBanner.codeBox.copyLabel": "Copier l'extrait de code", + "searchApiPanels.welcomeBanner.codeBox.selectAriaLabel": "{context} {languageName}", + "searchApiPanels.welcomeBanner.codeBox.selectChangeAriaLabel": "Modifier le langage en {languageName} pour chaque instance de cette page", + "searchApiPanels.welcomeBanner.codeBox.selectLabel": "Sélectionner un langage de programmation pour l'extrait de code {languageName}", "searchApiPanels.welcomeBanner.header.description": "Configurez votre client de langage de programmation, ingérez des données, et vous serez prêt à commencer vos recherches en quelques minutes.", "searchApiPanels.welcomeBanner.header.greeting.customTitle": "👋 Bonjour {name} !", "searchApiPanels.welcomeBanner.header.greeting.defaultTitle": "👋 Bonjour", "searchApiPanels.welcomeBanner.header.title": "Lancez-vous avec Elasticsearch", "searchApiPanels.welcomeBanner.ingestData.alternativeOptions": "Autres options d'ingestion", "searchApiPanels.welcomeBanner.ingestData.alternativeOptions.beatsDescription": "Des agents légers conçus pour le transfert de données pour Elasticsearch. Utilisez Beats pour envoyer des données opérationnelles depuis vos serveurs.", + "searchApiPanels.welcomeBanner.ingestData.alternativeOptions.beatsDocumentation.ariaLabel": "Documentation Beats", "searchApiPanels.welcomeBanner.ingestData.alternativeOptions.beatsDocumentationLabel": "Documentation", "searchApiPanels.welcomeBanner.ingestData.alternativeOptions.beatsTitle": "Beats", "searchApiPanels.welcomeBanner.ingestData.alternativeOptions.logstashDescription": "Pipeline de traitement des données à usage général pour Elasticsearch. Utilisez Logstash pour extraire et transformer les données d'une variétés d'entrées et de sorties.", + "searchApiPanels.welcomeBanner.ingestData.alternativeOptions.logstashDocumentation.ariaLabel": "Documentation Logstash", "searchApiPanels.welcomeBanner.ingestData.alternativeOptions.logstashDocumentationLabel": "Documentation", "searchApiPanels.welcomeBanner.ingestData.alternativeOptions.logstashTitle": "Logstash", - "searchApiPanels.welcomeBanner.ingestData.description": "Ajoutez des données à votre flux de données ou à votre index pour les rendre interrogeables à l'aide de l'API. ", + "searchApiPanels.welcomeBanner.ingestData.description": "Ajoutez des données à votre flux de données ou à votre index pour les rendre interrogeables à l'aide de l'API.", "searchApiPanels.welcomeBanner.ingestData.title": "Ingérer des données", "searchApiPanels.welcomeBanner.ingestPipelinePanel.description": "Vous pouvez utiliser des pipelines d'ingestion pour prétraiter vos données avant leur indexation dans Elasticsearch.", "searchApiPanels.welcomeBanner.ingestPipelinePanel.managedBadge": "Géré", @@ -6345,12 +6986,12 @@ "searchApiPanels.welcomeBanner.installClient.description": "Vous devez d'abord installer le client de langage de programmation de votre choix.", "searchApiPanels.welcomeBanner.installClient.title": "Installer un client", "searchApiPanels.welcomeBanner.panels.learnMore": "En savoir plus", - "searchApiPanels.welcomeBanner.selectClient.apiRequestConsoleDocLink": "Exécuter des requêtes d’API dans la console ", + "searchApiPanels.welcomeBanner.selectClient.apiRequestConsoleDocLink": "Exécuter des requêtes d’API dans la console", "searchApiPanels.welcomeBanner.selectClient.callout.description": "Avec la console, vous pouvez directement commencer à utiliser nos API REST. Aucune installation n’est requise.", "searchApiPanels.welcomeBanner.selectClient.callout.link": "Essayez la console maintenant", "searchApiPanels.welcomeBanner.selectClient.callout.title": "Lancez-vous dans la console", - "searchApiPanels.welcomeBanner.selectClient.description": "Elastic construit et assure la maintenance des clients dans plusieurs langues populaires et notre communauté a contribué à beaucoup d'autres. Sélectionnez votre client de langage favori ou explorez la {console} pour commencer.", - "searchApiPanels.welcomeBanner.selectClient.elasticsearchClientDocLink": "Clients d'Elasticsearch ", + "searchApiPanels.welcomeBanner.selectClient.description": "Elastic construit et assure la maintenance des clients dans plusieurs langues populaires et notre communauté a contribué à beaucoup d'autres. Sélectionnez votre client de langage favori ou découvrez la console pour commencer.", + "searchApiPanels.welcomeBanner.selectClient.elasticsearchClientDocLink": "Clients d'Elasticsearch", "searchApiPanels.welcomeBanner.selectClient.heading": "Choisissez-en un", "searchApiPanels.welcomeBanner.selectClient.title": "Sélectionner votre client", "searchConnectors.config.invalidInteger": "{label} doit être un nombre entier.", @@ -6362,7 +7003,7 @@ "searchConnectors.configurationConnector.config.error.title": "Erreur de connecteur", "searchConnectors.configurationConnector.config.noConfigCallout.description": "Ce connecteur ne possède aucun champ de configuration. Votre connecteur a-t-il pu se connecter avec succès à Elasticsearch et définir sa configuration ?", "searchConnectors.configurationConnector.config.noConfigCallout.title": "Aucun champ de configuration", - "searchConnectors.configurationConnector.config.submitButton.title": "Enregistrer la configuration", + "searchConnectors.configurationConnector.config.submitButton.title": "Sauvegarder et synchroniser", "searchConnectors.connector.documentLevelSecurity.enablePanel.description": "Vous permet de contrôler les documents auxquels peuvent accéder les utilisateurs, selon leurs autorisations. Cela permet de vous assurer que les résultats de recherche ne renvoient que des informations pertinentes et autorisées pour les utilisateurs, selon leurs rôles.", "searchConnectors.connector.documentLevelSecurity.enablePanel.heading": "Sécurité au niveau du document", "searchConnectors.connectors.subscriptionLabel": "Plans d'abonnement", @@ -6397,11 +7038,61 @@ "searchConnectors.content.indices.connectorScheduling.schedulePanel.contentSync.description": "Récupérez du contenu pour créer ou mettre à jour vos documents Elasticsearch.", "searchConnectors.content.indices.connectorScheduling.schedulePanel.contentSync.title": "Synchronisation de contenu", "searchConnectors.content.indices.connectorScheduling.switch.label": "Activé", + "searchConnectors.content.nativeConnectors.azureBlob.description": "Effectuez des recherches sur votre contenu sur Stockage Blob Azure.", + "searchConnectors.content.nativeConnectors.azureBlob.name": "Stockage Blob Azure", + "searchConnectors.content.nativeConnectors.box.description": "Effectuez des recherches sur votre contenu dans Box.", + "searchConnectors.content.nativeConnectors.box.name": "Box", + "searchConnectors.content.nativeConnectors.confluence_data_center.name": "Centre de données Confluence", + "searchConnectors.content.nativeConnectors.confluence.description": "Effectuez des recherches sur votre contenu dans Confluence Cloud.", + "searchConnectors.content.nativeConnectors.confluence.name": "Confluence Cloud & Server", + "searchConnectors.content.nativeConnectors.confluenceDataCenter.description": "Effectuez des recherches sur votre contenu dans le centre de données Confluence.", + "searchConnectors.content.nativeConnectors.customConnector.description": "Effectuez des recherches sur des données stockées dans des sources de données personnalisées.", + "searchConnectors.content.nativeConnectors.customConnector.name": "Connecteur personnalisé", + "searchConnectors.content.nativeConnectors.dropbox.description": "Effectuez des recherches dans vos fichiers et dossiers stockés sur Dropbox.", + "searchConnectors.content.nativeConnectors.dropbox.name": "Dropbox", + "searchConnectors.content.nativeConnectors.github.description": "Effectuez des recherches sur vos projets et référentiels sur GitHub.", + "searchConnectors.content.nativeConnectors.github.name": "Serveurs GitHub & GitHub Enterprise", + "searchConnectors.content.nativeConnectors.gmail.description": "Effectuez des recherches sur votre contenu dans Gmail.", + "searchConnectors.content.nativeConnectors.gmail.name": "Gmail", + "searchConnectors.content.nativeConnectors.googleCloud.description": "Effectuez des recherches sur votre contenu sur Google Cloud Storage.", "searchConnectors.content.nativeConnectors.googleCloud.name": "Google Cloud Storage", + "searchConnectors.content.nativeConnectors.googleDrive.description": "Effectuez des recherches sur votre contenu sur Google Drive.", + "searchConnectors.content.nativeConnectors.googleDrive.name": "Google Drive", + "searchConnectors.content.nativeConnectors.graphQL.description": "Effectuez des recherches dans votre contenu avec GraphQL.", + "searchConnectors.content.nativeConnectors.graphQL.name": "GraphQL", + "searchConnectors.content.nativeConnectors.jira_data_center.name": "Centre de données Jira", + "searchConnectors.content.nativeConnectors.jira.description": "Effectuez des recherches sur votre contenu dans Jira Cloud.", + "searchConnectors.content.nativeConnectors.jira.name": "Jira Cloud", + "searchConnectors.content.nativeConnectors.jiraDataCenter.description": "Effectuez des recherches sur votre contenu dans le centre de données Jira.", + "searchConnectors.content.nativeConnectors.jiraServer.description": "Effectuez des recherches sur votre contenu dans le serveur Jira.", + "searchConnectors.content.nativeConnectors.jiraServer.name": "Serveur Jira", + "searchConnectors.content.nativeConnectors.microsoftSQL.name": "Microsoft SQL", + "searchConnectors.content.nativeConnectors.mongoDB.description": "Effectuez des recherches sur votre contenu dans MongoDB.", + "searchConnectors.content.nativeConnectors.mongodb.name": "MongoDB", + "searchConnectors.content.nativeConnectors.msSql.description": "Effectuez des recherches sur votre contenu sur Microsoft SQL Server.", + "searchConnectors.content.nativeConnectors.mysql.description": "Effectuez des recherches sur votre contenu dans MySQL.", + "searchConnectors.content.nativeConnectors.mysql.name": "MySQL", + "searchConnectors.content.nativeConnectors.netowkrDrive.description": "Effectuez des recherches sur le contenu de votre lecteur réseau.", + "searchConnectors.content.nativeConnectors.networkDrive.name": "Lecteur réseau", + "searchConnectors.content.nativeConnectors.notion.description": "Effectuez des recherches sur votre contenu dans Notion.", + "searchConnectors.content.nativeConnectors.notion.name": "Notion", + "searchConnectors.content.nativeConnectors.oneDrive.description": "Effectuez des recherches sur votre contenu dans OneDrive.", + "searchConnectors.content.nativeConnectors.oneDrive.name": "OneDrive", + "searchConnectors.content.nativeConnectors.openTextDocumentum.description": "Recherchez votre contenu sur OpenText Documentum.", + "searchConnectors.content.nativeConnectors.openTextDocumentum.name": "OpenText Documentum", + "searchConnectors.content.nativeConnectors.oracle.description": "Effectuez des recherches sur votre contenu dans Oracle.", + "searchConnectors.content.nativeConnectors.oracle.name": "Oracle", + "searchConnectors.content.nativeConnectors.outlook.description": "Effectuez des recherches sur votre contenu dans Outlook.", + "searchConnectors.content.nativeConnectors.outlook.name": "Outlook", + "searchConnectors.content.nativeConnectors.postgreSQL.description": "Effectuez des recherches sur votre contenu dans PostgreSQL.", + "searchConnectors.content.nativeConnectors.postgresql.name": "PostgreSQL", + "searchConnectors.content.nativeConnectors.redis.description": "Effectuez des recherches sur votre contenu dans Redis.", + "searchConnectors.content.nativeConnectors.redis.name": "Redis", "searchConnectors.content.nativeConnectors.s3.accessKey.label": "ID de clé d'accès AWS", "searchConnectors.content.nativeConnectors.s3.buckets.label": "Compartiments AWS", "searchConnectors.content.nativeConnectors.s3.buckets.tooltip": "Les compartiments AWS sont ignorés lorsque des règles de synchronisation avancées sont appliquées.", "searchConnectors.content.nativeConnectors.s3.connectTimeout.label": "Délai d'attente de connexion", + "searchConnectors.content.nativeConnectors.s3.description": "Effectuez des recherches sur votre contenu dans Amazon S3.", "searchConnectors.content.nativeConnectors.s3.maxAttempts.label": "Nombre maximum de nouvelles tentatives", "searchConnectors.content.nativeConnectors.s3.maxPageSize.label": "Taille maximum de la page", "searchConnectors.content.nativeConnectors.s3.name": "S3", @@ -6411,9 +7102,24 @@ "searchConnectors.content.nativeConnectors.salesforce.clientId.tooltip": "L'ID client de votre application connectée utilisant le protocole OAuth2. Également appelé \"clé consommateur\"", "searchConnectors.content.nativeConnectors.salesforce.clientSecret.label": "Identifiant client secret", "searchConnectors.content.nativeConnectors.salesforce.clientSecret.tooltip": "L'identifiant client secret de votre application connectée utilisant le protocole OAuth2. Également appelé \"secret consommateur\"", + "searchConnectors.content.nativeConnectors.salesforce.description": "Effectuez des recherches sur votre contenu dans Salesforce.", "searchConnectors.content.nativeConnectors.salesforce.domain.label": "Domaine", "searchConnectors.content.nativeConnectors.salesforce.domain.tooltip": "Le domaine de votre instance Salesforce. Si votre URL Salesforce est \"https://foo.salesforce.com\", le domaine est \"foo\".", "searchConnectors.content.nativeConnectors.salesforce.name": "Salesforce", + "searchConnectors.content.nativeConnectors.salesforceBox.name": "Sandbox Salesforce", + "searchConnectors.content.nativeConnectors.salesforceSandbox.description": "Effectuez des recherches sur votre contenu dans Salesforce Sandbox.", + "searchConnectors.content.nativeConnectors.serviceNow.description": "Effectuez des recherches sur votre contenu dans ServiceNow.", + "searchConnectors.content.nativeConnectors.serviceNow.name": "ServiceNow", + "searchConnectors.content.nativeConnectors.sharepointOnline.description": "Effectuez des recherches sur votre contenu dans SharePoint Online.", + "searchConnectors.content.nativeConnectors.sharepointOnline.name": "SharePoint en ligne", + "searchConnectors.content.nativeConnectors.sharepointServer.description": "Effectuez des recherches sur votre contenu dans Serveur SharePoint.", + "searchConnectors.content.nativeConnectors.sharepointServer.name": "Serveur SharePoint", + "searchConnectors.content.nativeConnectors.slack.description": "Effectuez des recherches sur votre contenu dans Slack.", + "searchConnectors.content.nativeConnectors.slack.name": "Slack", + "searchConnectors.content.nativeConnectors.teams.description": "Effectuez des recherches sur votre contenu dans Teams.", + "searchConnectors.content.nativeConnectors.teams.name": "Équipes", + "searchConnectors.content.nativeConnectors.zoom.description": "Effectuez des recherches sur votre contenu dans Zoom.", + "searchConnectors.content.nativeConnectors.zoom.name": "Effectuer un zoom", "searchConnectors.cronEditor.cronDaily.fieldHour.textAtLabel": "À", "searchConnectors.cronEditor.cronDaily.fieldTimeLabel": "Heure", "searchConnectors.cronEditor.cronDaily.hourSelectLabel": "Heure", @@ -6514,7 +7220,7 @@ "searchConnectors.nativeConnectors.box.includeInheritedUsersTooltip": "Incluez les groupes et les utilisateurs hérités lors de l'indexation des autorisations. L'activation de ce champ configurable entraînera une dégradation significative des performances.", "searchConnectors.nativeConnectors.box.name": "Box", "searchConnectors.nativeConnectors.box.pathLabel": "Chemin permettant de récupérer les fichiers/dossiers", - "searchConnectors.nativeConnectors.box.pathTooltip": "Le chemin est ignoré lorsque des règles de synchronisation avancées sont appliquées. ", + "searchConnectors.nativeConnectors.box.pathTooltip": "Le chemin est ignoré lorsque des règles de synchronisation avancées sont appliquées.", "searchConnectors.nativeConnectors.box.refreshTokenLabel": "Token d'actualisation", "searchConnectors.nativeConnectors.boxTooltip.name": "Box", "searchConnectors.nativeConnectors.confluence.indexLabelsLabel": "Activer les étiquettes d'indexation", @@ -6716,6 +7422,7 @@ "searchConnectors.nativeConnectors.sharepoint_online.configuration.useDocumentLevelSecurityLabel": "Activer la sécurité au niveau du document", "searchConnectors.nativeConnectors.sharepoint_online.configuration.useDocumentLevelSecurityTooltip": "La sécurité au niveau du document préserve dans Elasticsearch les identités et permissions paramétrées dans Sharepoint Online. Ces métadonnées sont ajoutées à votre document Elasticsearch afin que vous puissiez contrôler l'accès en lecture des utilisateurs et des groupes. La synchronisation de contrôle d'accès garantit que ces métadonnées sont correctement actualisées.", "searchConnectors.nativeConnectors.sharepoint_online.name": "SharePoint en ligne", + "searchConnectors.nativeConnectors.sharepoint_server.configuration.authentication": "Authentification", "searchConnectors.nativeConnectors.sharepoint_server.configuration.fetchUniqueListItemPermissionsLabel": "Récupérer les autorisations d'un élément de liste unique", "searchConnectors.nativeConnectors.sharepoint_server.configuration.fetchUniqueListItemPermissionsTooltip": "Activer cette option pour récupérer les autorisations d'un élément de liste unique. Ce paramètre est susceptible d'augmenter le délai de synchronisation. Si ce paramètre est désactivé, un élément de liste hérite des permissions de son site parent.", "searchConnectors.nativeConnectors.sharepoint_server.configuration.fetchUniqueListPermissionsLabel": "Récupérer les autorisations de liste unique", @@ -6725,6 +7432,8 @@ "searchConnectors.nativeConnectors.sharepoint_server.configuration.site_collections": "Liste de collections de sites SharePoint séparées par des virgules à indexer", "searchConnectors.nativeConnectors.sharepoint_server.configuration.username": "Nom d'utilisateur du serveur SharePoint", "searchConnectors.nativeConnectors.sharepoint_server.name": "Serveur SharePoint", + "searchConnectors.nativeConnectors.sharepoint_server.options.basicLabel": "De base", + "searchConnectors.nativeConnectors.sharepoint_server.options.ntlmLabel": "NTLM", "searchConnectors.nativeConnectors.slack.autoJoinChannels.label": "Rejoindre automatiquement les canaux", "searchConnectors.nativeConnectors.slack.autoJoinChannels.tooltip": "Le bot de l'application Slack pourra seulement lire l'historique des conversations des canaux qu'il a rejoints. L’option par défaut nécessite qu'il soit invité manuellement aux canaux. L'activation de cette option lui permet de s'inviter automatiquement sur tous les canaux publics.", "searchConnectors.nativeConnectors.slack.fetchLastNDays.label": "Nombre de jours d'historique de messages à récupérer", @@ -6787,74 +7496,6 @@ "searchConnectors.syncStatus.inProgress": "Synchronisation en cours", "searchConnectors.syncStatus.pending": "Synchronisation en attente", "searchConnectors.syncStatus.suspended": "Synchronisation suspendue", - "searchConnectorsPlugin.content.nativeConnectors.azureBlob.description": "Effectuez des recherches sur votre contenu sur Stockage Blob Azure.", - "searchConnectorsPlugin.content.nativeConnectors.azureBlob.name": "Stockage Blob Azure", - "searchConnectorsPlugin.content.nativeConnectors.box.description": "Effectuez des recherches sur votre contenu dans Box.", - "searchConnectorsPlugin.content.nativeConnectors.box.name": "Box", - "searchConnectorsPlugin.content.nativeConnectors.confluence_data_center.name": "Centre de données Confluence", - "searchConnectorsPlugin.content.nativeConnectors.confluence.description": "Effectuez des recherches sur votre contenu dans Confluence Cloud.", - "searchConnectorsPlugin.content.nativeConnectors.confluence.name": "Confluence Cloud & Server", - "searchConnectorsPlugin.content.nativeConnectors.confluenceDataCenter.description": "Effectuez des recherches sur votre contenu dans le centre de données Confluence.", - "searchConnectorsPlugin.content.nativeConnectors.customConnector.description": "Effectuez des recherches sur des données stockées dans des sources de données personnalisées.", - "searchConnectorsPlugin.content.nativeConnectors.customConnector.name": "Connecteur personnalisé", - "searchConnectorsPlugin.content.nativeConnectors.dropbox.description": "Effectuez des recherches dans vos fichiers et dossiers stockés sur Dropbox.", - "searchConnectorsPlugin.content.nativeConnectors.dropbox.name": "Dropbox", - "searchConnectorsPlugin.content.nativeConnectors.github.description": "Effectuez des recherches sur vos projets et référentiels sur GitHub.", - "searchConnectorsPlugin.content.nativeConnectors.github.name": "Serveurs GitHub & GitHub Enterprise", - "searchConnectorsPlugin.content.nativeConnectors.gmail.description": "Effectuez des recherches sur votre contenu dans Gmail.", - "searchConnectorsPlugin.content.nativeConnectors.gmail.name": "Gmail", - "searchConnectorsPlugin.content.nativeConnectors.googleCloud.description": "Effectuez des recherches sur votre contenu sur Google Cloud Storage.", - "searchConnectorsPlugin.content.nativeConnectors.googleCloud.name": "Google Cloud Storage", - "searchConnectorsPlugin.content.nativeConnectors.googleDrive.description": "Effectuez des recherches sur votre contenu sur Google Drive.", - "searchConnectorsPlugin.content.nativeConnectors.googleDrive.name": "Google Drive", - "searchConnectorsPlugin.content.nativeConnectors.graphQL.description": "Effectuez des recherches dans votre contenu avec GraphQL.", - "searchConnectorsPlugin.content.nativeConnectors.graphQL.name": "GraphQL", - "searchConnectorsPlugin.content.nativeConnectors.jira_data_center.name": "Centre de données Jira", - "searchConnectorsPlugin.content.nativeConnectors.jira.description": "Effectuez des recherches sur votre contenu dans Jira Cloud.", - "searchConnectorsPlugin.content.nativeConnectors.jira.name": "Jira Cloud", - "searchConnectorsPlugin.content.nativeConnectors.jiraDataCenter.description": "Effectuez des recherches sur votre contenu dans le centre de données Jira.", - "searchConnectorsPlugin.content.nativeConnectors.jiraServer.description": "Effectuez des recherches sur votre contenu dans le serveur Jira.", - "searchConnectorsPlugin.content.nativeConnectors.jiraServer.name": "Serveur Jira", - "searchConnectorsPlugin.content.nativeConnectors.microsoftSQL.name": "Microsoft SQL", - "searchConnectorsPlugin.content.nativeConnectors.mongoDB.description": "Effectuez des recherches sur votre contenu dans MongoDB.", - "searchConnectorsPlugin.content.nativeConnectors.mongodb.name": "MongoDB", - "searchConnectorsPlugin.content.nativeConnectors.msSql.description": "Effectuez des recherches sur votre contenu sur Microsoft SQL Server.", - "searchConnectorsPlugin.content.nativeConnectors.mysql.description": "Effectuez des recherches sur votre contenu dans MySQL.", - "searchConnectorsPlugin.content.nativeConnectors.mysql.name": "MySQL", - "searchConnectorsPlugin.content.nativeConnectors.netowkrDrive.description": "Effectuez des recherches sur le contenu de votre lecteur réseau.", - "searchConnectorsPlugin.content.nativeConnectors.networkDrive.name": "Lecteur réseau", - "searchConnectorsPlugin.content.nativeConnectors.notion.description": "Effectuez des recherches sur votre contenu dans Notion.", - "searchConnectorsPlugin.content.nativeConnectors.notion.name": "Notion", - "searchConnectorsPlugin.content.nativeConnectors.oneDrive.description": "Effectuez des recherches sur votre contenu dans OneDrive.", - "searchConnectorsPlugin.content.nativeConnectors.oneDrive.name": "OneDrive", - "searchConnectorsPlugin.content.nativeConnectors.openTextDocumentum.description": "Recherchez votre contenu sur OpenText Documentum.", - "searchConnectorsPlugin.content.nativeConnectors.openTextDocumentum.name": "OpenText Documentum", - "searchConnectorsPlugin.content.nativeConnectors.oracle.description": "Effectuez des recherches sur votre contenu dans Oracle.", - "searchConnectorsPlugin.content.nativeConnectors.oracle.name": "Oracle", - "searchConnectorsPlugin.content.nativeConnectors.outlook.description": "Effectuez des recherches sur votre contenu dans Outlook.", - "searchConnectorsPlugin.content.nativeConnectors.outlook.name": "Outlook", - "searchConnectorsPlugin.content.nativeConnectors.postgreSQL.description": "Effectuez des recherches sur votre contenu dans PostgreSQL.", - "searchConnectorsPlugin.content.nativeConnectors.postgresql.name": "PostgreSQL", - "searchConnectorsPlugin.content.nativeConnectors.redis.description": "Effectuez des recherches sur votre contenu dans Redis.", - "searchConnectorsPlugin.content.nativeConnectors.redis.name": "Redis", - "searchConnectorsPlugin.content.nativeConnectors.s3.description": "Effectuez des recherches sur votre contenu dans Amazon S3.", - "searchConnectorsPlugin.content.nativeConnectors.s3.name": "S3", - "searchConnectorsPlugin.content.nativeConnectors.salesforce.description": "Effectuez des recherches sur votre contenu dans Salesforce.", - "searchConnectorsPlugin.content.nativeConnectors.salesforce.name": "Salesforce", - "searchConnectorsPlugin.content.nativeConnectors.salesforceBox.name": "Sandbox Salesforce", - "searchConnectorsPlugin.content.nativeConnectors.salesforceSandbox.description": "Effectuez des recherches sur votre contenu dans Salesforce Sandbox.", - "searchConnectorsPlugin.content.nativeConnectors.serviceNow.description": "Effectuez des recherches sur votre contenu dans ServiceNow.", - "searchConnectorsPlugin.content.nativeConnectors.serviceNow.name": "ServiceNow", - "searchConnectorsPlugin.content.nativeConnectors.sharepointOnline.description": "Effectuez des recherches sur votre contenu dans SharePoint Online.", - "searchConnectorsPlugin.content.nativeConnectors.sharepointOnline.name": "SharePoint en ligne", - "searchConnectorsPlugin.content.nativeConnectors.sharepointServer.description": "Effectuez des recherches sur votre contenu dans Serveur SharePoint.", - "searchConnectorsPlugin.content.nativeConnectors.sharepointServer.name": "Serveur SharePoint", - "searchConnectorsPlugin.content.nativeConnectors.slack.description": "Effectuez des recherches sur votre contenu dans Slack.", - "searchConnectorsPlugin.content.nativeConnectors.slack.name": "Slack", - "searchConnectorsPlugin.content.nativeConnectors.teams.description": "Effectuez des recherches sur votre contenu dans Teams.", - "searchConnectorsPlugin.content.nativeConnectors.teams.name": "Équipes", - "searchConnectorsPlugin.content.nativeConnectors.zoom.description": "Effectuez des recherches sur votre contenu dans Zoom.", - "searchConnectorsPlugin.content.nativeConnectors.zoom.name": "Effectuer un zoom", "searchErrors.errors.fetchError": "Vérifiez votre connexion réseau et réessayez.", "searchErrors.esError.unknownRootCause": "inconnue", "searchErrors.esError.viewDetailsButtonLabel": "Afficher les détails", @@ -6877,8 +7518,11 @@ "searchIndexDocuments.result.expandTooltip.showMore": "Afficher {amount} champs en plus", "searchIndexDocuments.result.header.metadata.deleteDocument": "Supprimer le document", "searchIndexDocuments.result.header.metadata.icon.ariaLabel": "Métadonnées pour le document : {id}", + "searchIndexDocuments.result.header.metadata.score": "Score", "searchIndexDocuments.result.header.metadata.title": "Métadonnées du document", "searchIndexDocuments.result.title.id": "ID de document : {id}", + "searchIndexDocuments.result.value.denseVector.copy": "Copier le vecteur", + "searchIndexDocuments.result.value.denseVector.dimLabel": "{value} dimensions", "searchResponseWarnings.badgeButtonLabel": "{warningCount} {warningCount, plural, one {avertissement} other {avertissements}}", "searchResponseWarnings.description.multipleClusters": "Ces clusters ont rencontré des problèmes lors du renvoi des données et les résultats pourraient être incomplets.", "searchResponseWarnings.description.singleCluster": "Ce cluster a rencontré des problèmes lors du renvoi des données et les résultats pourraient être incomplets.", @@ -6886,6 +7530,7 @@ "searchResponseWarnings.title.clustersClause": "Un problème est survenu avec {nonSuccessfulClustersCount} {nonSuccessfulClustersCount, plural, one {cluster} other {clusters}}", "searchResponseWarnings.title.clustersClauseAndRequestsClause": "{clustersClause} pour {requestsCount} requêtes", "searchResponseWarnings.viewDetailsButtonLabel": "Afficher les détails", + "securitySolutionPackages.alertAssignments.upsell": "Passer à {requiredLicense} pour utiliser les affectations d'alertes", "securitySolutionPackages.alertSuppressionRuleDetails.upsell": "La suppression d'alertes est configurée mais elle ne sera pas appliquée en raison d'une licence insuffisante", "securitySolutionPackages.alertSuppressionRuleForm.upsell": "La suppression d'alertes est activée avec la licence {requiredLicense} ou supérieure", "securitySolutionPackages.beta.label": "Bêta", @@ -6897,53 +7542,83 @@ "securitySolutionPackages.dataTable.eventsTab.unit": "{totalCount, plural, =1 {alerte} other {alertes}}", "securitySolutionPackages.dataTable.loadingEventsDataLabel": "Chargement des événements", "securitySolutionPackages.dataTable.unit": "{totalCount, plural, =1 {alerte} other {alertes}}", + "securitySolutionPackages.ecsDataQualityDashboard.actions.askAssistant": "Demander à l'assistant", "securitySolutionPackages.ecsDataQualityDashboard.addToCaseSuccessToast": "Résultats de qualité des données ajoutés au cas", "securitySolutionPackages.ecsDataQualityDashboard.addToNewCaseButton": "Ajouter au nouveau cas", + "securitySolutionPackages.ecsDataQualityDashboard.all": "Tous", + "securitySolutionPackages.ecsDataQualityDashboard.allFields": "Tous les champs", "securitySolutionPackages.ecsDataQualityDashboard.allTab.allFieldsTableTitle": "Tous les champs - {indexName}", "securitySolutionPackages.ecsDataQualityDashboard.cancelButton": "Annuler", + "securitySolutionPackages.ecsDataQualityDashboard.changeYourSearchCriteriaOrRun": "Modifiez vos critères de recherche ou lancez une nouvelle vérification", "securitySolutionPackages.ecsDataQualityDashboard.checkAllButton": "Tout vérifier", "securitySolutionPackages.ecsDataQualityDashboard.checkAllErrorCheckingIndexMessage": "Une erreur s'est produite lors de la vérification de l'index {indexName}", "securitySolutionPackages.ecsDataQualityDashboard.checkingLabel": "Vérification de {index}", + "securitySolutionPackages.ecsDataQualityDashboard.checkNow": "Vérifier maintenant", + "securitySolutionPackages.ecsDataQualityDashboard.close": "Fermer", "securitySolutionPackages.ecsDataQualityDashboard.coldDescription": "L'index n'est plus mis à jour et il est interrogé peu fréquemment. Les informations doivent toujours être interrogeables, mais il est acceptable que ces requêtes soient plus lentes.", "securitySolutionPackages.ecsDataQualityDashboard.coldPatternTooltip": "{indices} {indices, plural, =1 {L'index correspondant} other {Les index correspondants}} au modèle {pattern} {indices, plural, =1 {est} other {sont}} \"cold\". Les index \"cold\" ne sont plus mis à jour et ne sont pas interrogés fréquemment. Les informations doivent toujours être interrogeables, mais il est acceptable que ces requêtes soient plus lentes.", "securitySolutionPackages.ecsDataQualityDashboard.compareFieldsTable.searchFieldsPlaceholder": "Rechercher dans les champs", "securitySolutionPackages.ecsDataQualityDashboard.copyToClipboardButton": "Copier dans le presse-papiers", "securitySolutionPackages.ecsDataQualityDashboard.createADataQualityCaseForIndexHeaderText": "Créer un cas de qualité des données pour l'index {indexName}", "securitySolutionPackages.ecsDataQualityDashboard.createADataQualityCaseHeaderText": "Créer un cas de qualité des données", + "securitySolutionPackages.ecsDataQualityDashboard.customFields": "Champs personnalisés", "securitySolutionPackages.ecsDataQualityDashboard.customTab.customFieldsTableTitle": "Champs personnalisés - {indexName}", "securitySolutionPackages.ecsDataQualityDashboard.customTab.ecsComplaintFieldsTableTitle": "Champs de plainte ECS - {indexName}", + "securitySolutionPackages.ecsDataQualityDashboard.dataQuality": "Qualité des données", + "securitySolutionPackages.ecsDataQualityDashboard.dataQualityDashboardConversationId": "Tableau de bord de Qualité des données", "securitySolutionPackages.ecsDataQualityDashboard.dataQualityPromptContextPill": "Qualité des données ({indexName})", "securitySolutionPackages.ecsDataQualityDashboard.dataQualityPromptContextPillTooltip": "Ajoutez ce rapport de Qualité des données comme contexte", "securitySolutionPackages.ecsDataQualityDashboard.dataQualitySuggestedUserPrompt": "Expliquez les résultats ci-dessus et donnez des options pour résoudre les incompatibilités.", "securitySolutionPackages.ecsDataQualityDashboard.defaultPanelTitle": "Vérifier les mappings d'index", + "securitySolutionPackages.ecsDataQualityDashboard.detectionEngineRulesWontWorkMessage": "❌ Les règles de moteur de détection référençant ces champs ne leur correspondront peut-être pas correctement", + "securitySolutionPackages.ecsDataQualityDashboard.docs": "Documents", + "securitySolutionPackages.ecsDataQualityDashboard.documentValuesActual": "Valeurs du document (réelles)", + "securitySolutionPackages.ecsDataQualityDashboard.ecsCompliantFields": "Champs conformes à ECS", + "securitySolutionPackages.ecsDataQualityDashboard.ecsDescription": "Description ECS", + "securitySolutionPackages.ecsDataQualityDashboard.ecsMappingType": "Type de mapping ECS", + "securitySolutionPackages.ecsDataQualityDashboard.ecsMappingTypeExpected": "Type de mapping ECS (attendu)", + "securitySolutionPackages.ecsDataQualityDashboard.ecsValues": "Valeurs ECS", + "securitySolutionPackages.ecsDataQualityDashboard.ecsValuesExpected": "Valeurs ECS (attendues)", "securitySolutionPackages.ecsDataQualityDashboard.ecsVersionStat": "Version ECS", + "securitySolutionPackages.ecsDataQualityDashboard.emptyErrorPrompt.errorGenericCheckTitle": "Une erreur s'est produite durant la vérification", "securitySolutionPackages.ecsDataQualityDashboard.emptyErrorPrompt.errorLoadingMappingsBody": "Un problème est survenu lors du chargement des mappings : {error}", "securitySolutionPackages.ecsDataQualityDashboard.emptyErrorPrompt.errorLoadingMappingsTitle": "Impossible de charger les mappings d'index", "securitySolutionPackages.ecsDataQualityDashboard.emptyErrorPrompt.errorLoadingMetadataTitle": "Les index correspondant au modèle {pattern} ne seront pas vérifiés", "securitySolutionPackages.ecsDataQualityDashboard.emptyErrorPrompt.errorLoadingUnallowedValuesBody": "Un problème est survenu lors du chargement des valeurs non autorisées : {error}", "securitySolutionPackages.ecsDataQualityDashboard.emptyErrorPrompt.errorLoadingUnallowedValuesTitle": "Impossible de charger les valeurs non autorisées", + "securitySolutionPackages.ecsDataQualityDashboard.emptyLoadingPrompt.checkingIndexPrompt": "Vérification de l'index", "securitySolutionPackages.ecsDataQualityDashboard.emptyLoadingPrompt.loadingEcsMetadataPrompt": "Chargement des métadonnées ECS", "securitySolutionPackages.ecsDataQualityDashboard.emptyLoadingPrompt.loadingMappingsPrompt": "Chargement des mappings", "securitySolutionPackages.ecsDataQualityDashboard.emptyLoadingPrompt.loadingStatsPrompt": "Chargement des statistiques", "securitySolutionPackages.ecsDataQualityDashboard.emptyLoadingPrompt.loadingUnallowedValuesPrompt": "Chargement des valeurs non autorisées", + "securitySolutionPackages.ecsDataQualityDashboard.errorLoadingHistoricalResults": "Impossible de charger l'historique", "securitySolutionPackages.ecsDataQualityDashboard.errorLoadingIlmExplainLabel": "Erreur lors du chargement d'ILM Explain : {details}", "securitySolutionPackages.ecsDataQualityDashboard.errorLoadingMappingsLabel": "Erreur lors du chargement des mappings pour {patternOrIndexName} : {details}", "securitySolutionPackages.ecsDataQualityDashboard.errorLoadingStatsLabel": "Erreur lors du chargement des statistiques : {details}", "securitySolutionPackages.ecsDataQualityDashboard.errorLoadingUnallowedValuesLabel": "Erreur lors du chargement des valeurs non autorisées pour l'index {indexName} : {details}", + "securitySolutionPackages.ecsDataQualityDashboard.errors.error": "Erreur", "securitySolutionPackages.ecsDataQualityDashboard.errors.errorMayOccurLabel": "Des erreurs peuvent survenir lorsque le modèle ou les métadonnées de l'index sont temporairement indisponibles, ou si vous ne disposez pas des privilèges requis pour l'accès", + "securitySolutionPackages.ecsDataQualityDashboard.errors.errors": "Erreurs", + "securitySolutionPackages.ecsDataQualityDashboard.errors.errorsCalloutSummary": "La qualité des données n'a pas été vérifiée pour certains index", "securitySolutionPackages.ecsDataQualityDashboard.errors.manage": "gérer", "securitySolutionPackages.ecsDataQualityDashboard.errors.monitor": "moniteur", "securitySolutionPackages.ecsDataQualityDashboard.errors.or": "ou", + "securitySolutionPackages.ecsDataQualityDashboard.errors.pattern": "Modèle", "securitySolutionPackages.ecsDataQualityDashboard.errors.read": "lire", "securitySolutionPackages.ecsDataQualityDashboard.errors.theFollowingPrivilegesLabel": "Les privilèges suivants sont requis pour vérifier un index :", "securitySolutionPackages.ecsDataQualityDashboard.errors.viewIndexMetadata": "view_index_metadata", "securitySolutionPackages.ecsDataQualityDashboard.errorsPopover.viewErrorsButton": "Afficher les erreurs", + "securitySolutionPackages.ecsDataQualityDashboard.fail": "Échec", + "securitySolutionPackages.ecsDataQualityDashboard.failedTooltip": "Échoué", + "securitySolutionPackages.ecsDataQualityDashboard.field": "Champ", "securitySolutionPackages.ecsDataQualityDashboard.fieldsLabel": "Champs", + "securitySolutionPackages.ecsDataQualityDashboard.filterResultsByOutcome": "Filtrer les résultats par issue", "securitySolutionPackages.ecsDataQualityDashboard.frozenDescription": "L'index n'est plus mis à jour et il est rarement interrogé. Les informations doivent toujours être interrogeables, mais il est acceptable que ces requêtes soient extrêmement lentes.", "securitySolutionPackages.ecsDataQualityDashboard.frozenPatternTooltip": "{indices} {indices, plural, =1 {L'index correspondant} other {Les index correspondants}} au modèle {pattern} {indices, plural, =1 {est} other {sont}} \"frozen\". Les index gelés ne sont plus mis à jour et sont rarement interrogés. Les informations doivent toujours être interrogeables, mais il est acceptable que ces requêtes soient extrêmement lentes.", "securitySolutionPackages.ecsDataQualityDashboard.getResultErrorTitle": "Erreur lors de la lecture des résultats d'examen qualité des données sauvegardées", "securitySolutionPackages.ecsDataQualityDashboard.hotDescription": "L'index est mis à jour et interrogé de façon active", "securitySolutionPackages.ecsDataQualityDashboard.hotPatternTooltip": "{indices} {indices, plural, =1 {L'index correspondant} other {Les index correspondants}} au modèle {pattern} {indices, plural, =1 {est} other {sont}} \"hot\". Les index \"hot\" sont mis à jour et interrogés de façon active.", + "securitySolutionPackages.ecsDataQualityDashboard.ilmPhaseCapitalized": "Phase ILM", "securitySolutionPackages.ecsDataQualityDashboard.ilmPhaseCold": "froid", "securitySolutionPackages.ecsDataQualityDashboard.ilmPhaseFrozen": "frozen", "securitySolutionPackages.ecsDataQualityDashboard.ilmPhaseHot": "hot", @@ -6955,7 +7630,20 @@ "securitySolutionPackages.ecsDataQualityDashboard.ilmPhasesEmptyPromptTitle": "Sélectionner une ou plusieurs phases ILM", "securitySolutionPackages.ecsDataQualityDashboard.ilmPhaseUnmanaged": "non géré", "securitySolutionPackages.ecsDataQualityDashboard.ilmPhaseWarm": "warm", + "securitySolutionPackages.ecsDataQualityDashboard.incompatibleCallout": "Les champs sont incompatibles avec ECS lorsque les mappings d'index, ou les valeurs des champs de l'index, ne sont pas conformes à la version {version} d'Elastic Common Schema (ECS).", + "securitySolutionPackages.ecsDataQualityDashboard.incompatibleCalloutTitle": "{fieldCount} {fieldCount, plural, =1 {Champ incompatible} other {Champs incompatibles}}", + "securitySolutionPackages.ecsDataQualityDashboard.incompatibleEmptyContent": "Tous les mappings de champs et toutes les valeurs de documents de cet index sont conformes à Elastic Common Schema (ECS).", + "securitySolutionPackages.ecsDataQualityDashboard.incompatibleEmptyTitle": "Toutes les valeurs et tous les mappings de champs sont conformes à ECS", + "securitySolutionPackages.ecsDataQualityDashboard.incompatibleFieldMappings": "Mappings de champ incompatibles – {indexName}", + "securitySolutionPackages.ecsDataQualityDashboard.incompatibleFields": "Champs incompatibles", + "securitySolutionPackages.ecsDataQualityDashboard.incompatibleFieldsWithCount": "{count, plural, one {Champ incompatible} other {Champs incompatibles}}", + "securitySolutionPackages.ecsDataQualityDashboard.incompatibleFieldValues": "Valeurs de champ incompatibles – {indexName}", + "securitySolutionPackages.ecsDataQualityDashboard.index": "Index", + "securitySolutionPackages.ecsDataQualityDashboard.indexCheckFlyout.historyTab": "Historique", + "securitySolutionPackages.ecsDataQualityDashboard.indexCheckFlyout.latestCheckTab": "Dernière vérification", "securitySolutionPackages.ecsDataQualityDashboard.indexLifecycleManagementPhasesTooltip": "La qualité des données sera vérifiée pour les index comprenant ces phases de gestion du cycle de vie des index (ILM, Index Lifecycle Management)", + "securitySolutionPackages.ecsDataQualityDashboard.indexMappingType": "Type de mapping d'index", + "securitySolutionPackages.ecsDataQualityDashboard.indexMappingTypeActual": "Type de mapping d'index (réel)", "securitySolutionPackages.ecsDataQualityDashboard.indexNameLabel": "Nom de l'index", "securitySolutionPackages.ecsDataQualityDashboard.indexProperties.addToNewCaseButton": "Ajouter au nouveau cas", "securitySolutionPackages.ecsDataQualityDashboard.indexProperties.allCallout": "Tous les mappings relatifs aux champs de cet index, y compris ceux qui sont conformes à la version {version} d'Elastic Common Schema (ECS) et ceux qui ne le sont pas", @@ -6991,47 +7679,81 @@ "securitySolutionPackages.ecsDataQualityDashboard.indexProperties.summaryMarkdownDescription": "L'index `{indexName}` a des [mappings]({mappingUrl}) ou des valeurs de champ différentes de l'[Elastic Common Schema]({ecsReferenceUrl}) (ECS), [définitions]({ecsFieldReferenceUrl}).de version `{version}`.", "securitySolutionPackages.ecsDataQualityDashboard.indexProperties.summaryMarkdownTitle": "Qualité des données", "securitySolutionPackages.ecsDataQualityDashboard.indexProperties.unknownCategoryLabel": "Inconnu", - "securitySolutionPackages.ecsDataQualityDashboard.indexSizeTooltip": "La taille de l'index principal (n'inclut pas de répliques)", + "securitySolutionPackages.ecsDataQualityDashboard.indexSizeTooltip": "Taille de l'index (sans les répliques)", + "securitySolutionPackages.ecsDataQualityDashboard.indices": "Index", + "securitySolutionPackages.ecsDataQualityDashboard.indicesChecked": "Index vérifiés", + "securitySolutionPackages.ecsDataQualityDashboard.introducingDataQualityHistory": "Présentation de l'historique de la qualité des données", "securitySolutionPackages.ecsDataQualityDashboard.lastCheckedLabel": "Dernière vérification", + "securitySolutionPackages.ecsDataQualityDashboard.loadingHistoricalResults": "Chargement des résultats antérieurs", + "securitySolutionPackages.ecsDataQualityDashboard.mappingThatConflictWithEcsMessage": "❌ Les mappings ou valeurs de champs qui ne sont pas conformes à ECS ne sont pas pris en charge", + "securitySolutionPackages.ecsDataQualityDashboard.noResultsMatchYourSearchCriteria": "Aucun résultat ne correspond à vos critères de recherche.", + "securitySolutionPackages.ecsDataQualityDashboard.notIncludedInHistoricalResults": "Non inclus dans les résultats antérieurs. Pour afficher les données complètes sur les champs de la même famille, exécutez une nouvelle vérification.", + "securitySolutionPackages.ecsDataQualityDashboard.pagesMayNotDisplayEventsMessage": "❌ Les pages peuvent ne pas afficher certains événements ou champs en raison de mappings ou valeurs de champs inattendus", + "securitySolutionPackages.ecsDataQualityDashboard.pass": "Réussite", + "securitySolutionPackages.ecsDataQualityDashboard.passedTooltip": "Approuvé", "securitySolutionPackages.ecsDataQualityDashboard.patternLabel.allPassedTooltip": "Tous les index correspondant à ce modèle ont réussi les vérifications de qualité des données", + "securitySolutionPackages.ecsDataQualityDashboard.patternLabel.someFailedTooltip": "Au moins un index correspondant à ce modèle a échoué à un contrôle de qualité des données", + "securitySolutionPackages.ecsDataQualityDashboard.patternLabel.someUncheckedTooltip": "Au moins un index correspondant à ce modèle n'a pas été vérifié pour la qualité des données", "securitySolutionPackages.ecsDataQualityDashboard.patternSummary.docsLabel": "Documents", "securitySolutionPackages.ecsDataQualityDashboard.patternSummary.indicesLabel": "Index", - "securitySolutionPackages.ecsDataQualityDashboard.patternSummary.patternOrIndexTooltip": "Modèle, ou index spécifique", + "securitySolutionPackages.ecsDataQualityDashboard.patternSummary.patternOrIndexTooltip": "Nom d'index ou modèle", "securitySolutionPackages.ecsDataQualityDashboard.postResultErrorTitle": "Erreur lors de l'écriture des résultats d'examen qualité des données sauvegardées", "securitySolutionPackages.ecsDataQualityDashboard.remoteClustersCallout.title": "Les clusters distants ne seront pas vérifiés", "securitySolutionPackages.ecsDataQualityDashboard.remoteClustersCallout.toCheckIndicesOnRemoteClustersLabel": "Pour vérifier les index sur des clusters distants prenant en charge la recherche dans différents clusters, connectez-vous à l'instance Kibana du cluster distant", + "securitySolutionPackages.ecsDataQualityDashboard.result": "Résultat", + "securitySolutionPackages.ecsDataQualityDashboard.sameFamily": "Même famille", "securitySolutionPackages.ecsDataQualityDashboard.sameFamilyBadgeLabel": "même famille", "securitySolutionPackages.ecsDataQualityDashboard.sameFamilyTab.sameFamilyFieldMappingsTableTitle": "Mêmes familles de mappings de champ – {indexName}", "securitySolutionPackages.ecsDataQualityDashboard.securitySolutionPackages.ecsDataQualityDashboardSubtitle": "Vérifiez la compatibilité des mappings et des valeurs d'index avec", "securitySolutionPackages.ecsDataQualityDashboard.selectAnIndexPrompt": "Sélectionner un index pour le comparer à la version ECS", "securitySolutionPackages.ecsDataQualityDashboard.selectOneOrMorPhasesPlaceholder": "Sélectionner une ou plusieurs phases ILM", + "securitySolutionPackages.ecsDataQualityDashboard.size": "Taille", "securitySolutionPackages.ecsDataQualityDashboard.statLabels.checkedLabel": "vérifié", "securitySolutionPackages.ecsDataQualityDashboard.statLabels.docsLabel": "Documents", + "securitySolutionPackages.ecsDataQualityDashboard.statLabels.incompatibleFieldsLabel": "Champs incompatibles", + "securitySolutionPackages.ecsDataQualityDashboard.statLabels.incompatibleIndexToolTip": "Mappings et valeurs incompatibles avec ECS", + "securitySolutionPackages.ecsDataQualityDashboard.statLabels.indicesCheckedLabel": "Index vérifiés", "securitySolutionPackages.ecsDataQualityDashboard.statLabels.indicesLabel": "Index", "securitySolutionPackages.ecsDataQualityDashboard.statLabels.sameFamilyLabel": "Même famille", "securitySolutionPackages.ecsDataQualityDashboard.statLabels.sizeLabel": "Taille", - "securitySolutionPackages.ecsDataQualityDashboard.statLabels.totalDocsToolTip": "Nombre total de documents, dans tous les index", - "securitySolutionPackages.ecsDataQualityDashboard.statLabels.totalIncompatibleToolTip": "Nombre total de champs incompatibles avec ECS, dans tous les index qui ont été vérifiés", - "securitySolutionPackages.ecsDataQualityDashboard.statLabels.totalIndicesToolTip": "Nombre total de tous les index", - "securitySolutionPackages.ecsDataQualityDashboard.statLabels.totalSizeToolTip": "La taille totale de tous les index principaux (n'inclut pas de répliques)", + "securitySolutionPackages.ecsDataQualityDashboard.statLabels.totalCheckedIndicesPatternToolTip": "Nombre total d'index vérifiés correspondant à ce modèle d'index", + "securitySolutionPackages.ecsDataQualityDashboard.statLabels.totalCheckedIndicesToolTip": "Nombre total d'index vérifiés", + "securitySolutionPackages.ecsDataQualityDashboard.statLabels.totalDocsPatternToolTip": "Nombre total de documents dans les index correspondant à ce modèle d'indexation", + "securitySolutionPackages.ecsDataQualityDashboard.statLabels.totalDocsToolTip": "Nombre total de documents dans l'ensemble des index", + "securitySolutionPackages.ecsDataQualityDashboard.statLabels.totalIncompatiblePatternToolTip": "Nombre total de champs vérifiés incompatibles avec ECS dans les index correspondant à ce modèle d'indexation", + "securitySolutionPackages.ecsDataQualityDashboard.statLabels.totalIncompatibleToolTip": "Nombre total de champs vérifiés incompatibles avec ECS", + "securitySolutionPackages.ecsDataQualityDashboard.statLabels.totalIndicesPatternToolTip": "Nombre total d'index correspondant à ce modèle d'indexation", + "securitySolutionPackages.ecsDataQualityDashboard.statLabels.totalIndicesToolTip": "Nombre total d'index", + "securitySolutionPackages.ecsDataQualityDashboard.statLabels.totalSizePatternToolTip": "Taille totale des indices (hors répliques) correspondant à ce modèle d'indexation", + "securitySolutionPackages.ecsDataQualityDashboard.statLabels.totalSizeToolTip": "Taille totale des indices (hors répliques)", "securitySolutionPackages.ecsDataQualityDashboard.storage.docs.unit": "{totalCount, plural, =1 {Document} other {Documents}}", "securitySolutionPackages.ecsDataQualityDashboard.storageTreemap.noDataLabel": "Aucune donnée à afficher", "securitySolutionPackages.ecsDataQualityDashboard.storageTreemap.noDataReasonLabel": "Le champ {stackByField1} n'était présent dans aucun groupe", + "securitySolutionPackages.ecsDataQualityDashboard.summaryTable.actionsColumn": "Actions", "securitySolutionPackages.ecsDataQualityDashboard.summaryTable.collapseLabel": "Réduire", "securitySolutionPackages.ecsDataQualityDashboard.summaryTable.expandRowsColumn": "Développer les lignes", "securitySolutionPackages.ecsDataQualityDashboard.summaryTable.indexesNameLabel": "Nom de l'index", "securitySolutionPackages.ecsDataQualityDashboard.summaryTable.indexToolTip": "Cet index correspond au nom d'index ou de modèle : {pattern}", "securitySolutionPackages.ecsDataQualityDashboard.summaryTable.lastCheckColumn": "Dernière vérification", + "securitySolutionPackages.ecsDataQualityDashboard.thisIndexHasNotBeenCheckedTooltip": "Cet index n'a pas été vérifié", "securitySolutionPackages.ecsDataQualityDashboard.timestampDescriptionLabel": "Date/heure d'origine de l'événement. Il s'agit des date et heure extraites de l'événement, représentant généralement le moment auquel l'événement a été généré par la source. Si la source de l'événement ne comporte pas d'horodatage original, cette valeur est habituellement remplie la première fois que l'événement a été reçu par le pipeline. Champs requis pour tous les événements.", "securitySolutionPackages.ecsDataQualityDashboard.toasts.copiedErrorsToastTitle": "Erreurs copiées dans le presse-papiers", "securitySolutionPackages.ecsDataQualityDashboard.toasts.copiedResultsToastTitle": "Résultats copiés dans le presse-papiers", + "securitySolutionPackages.ecsDataQualityDashboard.toggleHistoricalResultCheckedAt": "Bascule de résultat historique vérifié à {checkedAt}", + "securitySolutionPackages.ecsDataQualityDashboard.totalChecks": "{formattedCount} {count, plural, one {vérification} other {vérifications}}", + "securitySolutionPackages.ecsDataQualityDashboard.tryIt": "Essayer", "securitySolutionPackages.ecsDataQualityDashboard.unmanagedDescription": "L'index n'est pas géré par la Gestion du cycle de vie des index (ILM)", "securitySolutionPackages.ecsDataQualityDashboard.unmanagedPatternTooltip": "{indices} {indices, plural, =1 {L'index correspondant} other {Les index correspondants}} au modèle {pattern} {indices, plural, =1 {n'est pas géré} other {ne sont pas gérés}} par la gestion du cycle de vie des index (ILM)", + "securitySolutionPackages.ecsDataQualityDashboard.viewHistory": "Afficher l'historique", + "securitySolutionPackages.ecsDataQualityDashboard.viewPastResults": "Voir les résultats antérieurs", "securitySolutionPackages.ecsDataQualityDashboard.warmDescription": "L'index n'est plus mis à jour mais il est toujours interrogé", "securitySolutionPackages.ecsDataQualityDashboard.warmPatternTooltip": "{indices} {indices, plural, =1 {L'index correspondant} other {Les index correspondants}} au modèle {pattern} {indices, plural, =1 {est} other {sont}} \"warm\". Les index \"warm\" ne sont plus mis à jour, mais ils sont toujours interrogés.", "securitySolutionPackages.entityAnalytics.navigation": "Analyse des entités", "securitySolutionPackages.entityAnalytics.pageDesc": "Détecter les menaces des utilisateurs et des hôtes de votre réseau avec l'Analyse des entités", "securitySolutionPackages.entityAnalytics.paywall.upgradeButton": "Passer à {requiredLicenseOrProduct}", + "securitySolutionPackages.features.featureRegistry.assistant.manageGlobalKnowledgeBaseSubFeatureDescription": "Apportez des modifications à n'importe quelle entrée de la base de connaissances personnalisée au niveau de l'espace (global). Cela permettra également aux utilisateurs de modifier les entrées globales créées par d'autres utilisateurs.", + "securitySolutionPackages.features.featureRegistry.assistant.manageGlobalKnowledgeBaseSubFeatureDetails": "Autoriser les modifications des entrées globales", + "securitySolutionPackages.features.featureRegistry.assistant.manageGlobalKnowledgeBaseSubFeatureName": "Base de connaissances", "securitySolutionPackages.features.featureRegistry.assistant.updateAnonymizationSubFeatureDetails": "Autoriser les modifications", "securitySolutionPackages.features.featureRegistry.assistant.updateAnonymizationSubFeatureName": "Sélection et Anonymisation de champ", "securitySolutionPackages.features.featureRegistry.casesSettingsSubFeatureDetails": "Modifier les paramètres du cas", @@ -7039,8 +7761,10 @@ "securitySolutionPackages.features.featureRegistry.deleteSubFeatureDetails": "Supprimer les cas et les commentaires", "securitySolutionPackages.features.featureRegistry.deleteSubFeatureName": "Supprimer", "securitySolutionPackages.features.featureRegistry.linkSecuritySolutionAssistantTitle": "Assistant d’intelligence artificielle d’Elastic", + "securitySolutionPackages.features.featureRegistry.linkSecuritySolutionAttackDiscoveryTitle": "Attack discovery", "securitySolutionPackages.features.featureRegistry.linkSecuritySolutionCaseTitle": "Cas", "securitySolutionPackages.features.featureRegistry.linkSecuritySolutionTitle": "Sécurité", + "securitySolutionPackages.features.featureRegistry.securityGroupDescription": "Chaque privilège de sous-fonctionnalité de ce groupe doit être attribué individuellement. L'affectation globale n'est prise en charge que lorsque votre offre tarifaire n'autorise pas les privilèges de fonctionnalités individuelles.", "securitySolutionPackages.features.featureRegistry.subFeatures.assistant.description": "Modifiez les champs par défaut autorisés à être utilisés par l'assistant IA et Attack discovery. Anonymisez n'importe quel contenu pour les champs sélectionnés.", "securitySolutionPackages.features.featureRegistry.subFeatures.blockList": "Liste noire", "securitySolutionPackages.features.featureRegistry.subFeatures.blockList.description": "Étendez la protection d'Elastic Defend contre les processus malveillants et protégez-vous des applications potentiellement nuisibles.", @@ -7081,19 +7805,15 @@ "securitySolutionPackages.features.featureRegistry.subFeatures.trustedApplications": "Applications de confiance", "securitySolutionPackages.features.featureRegistry.subFeatures.trustedApplications.description": "Aide à atténuer les conflits avec d'autres logiciels, généralement d'autres applications d'antivirus ou de sécurité des points de terminaison.", "securitySolutionPackages.features.featureRegistry.subFeatures.trustedApplications.privilegesTooltip": "\"Tous les espaces\" est requis pour l'accès aux applications de confiance.", - "securitySolutionPackages.flyout.right.header.collapseDetailButtonAriaLabel": "Réduire les détails", - "securitySolutionPackages.flyout.right.header.collapseDetailButtonLabel": "Réduire les détails", - "securitySolutionPackages.flyout.right.header.expandDetailButtonAriaLabel": "Développer les détails", - "securitySolutionPackages.flyout.right.header.expandDetailButtonLabel": "Développer les détails", - "securitySolutionPackages.flyout.shared.errorDescription": "Une erreur est survenue lors de l'affichage de {message}.", - "securitySolutionPackages.flyout.shared.errorTitle": "Impossible d'afficher {title}.", - "securitySolutionPackages.flyout.shared.ExpandablePanelButtonIconAriaLabel": "Activer/Désactiver le panneau extensible", - "securitySolutionPackages.flyout.shared.expandablePanelLoadingAriaLabel": "panneau extensible", "securitySolutionPackages.markdown.insight.upsell": "Passez au niveau {requiredLicense} pour pouvoir utiliser les informations des guides d'investigation", "securitySolutionPackages.markdown.investigationGuideInteractions.upsell": "Passez au niveau {requiredLicense} pour pouvoir utiliser les interactions des guides d'investigation", "securitySolutionPackages.navigation.landingLinks": "Vues de sécurité", "securitySolutionPackages.sideNav.betaBadge.label": "Bêta", "securitySolutionPackages.sideNav.togglePanel": "Activer/Désactiver le panneau de navigation", + "securitySolutionPackages.upselling.pages.attackDiscovery.pageTitle.betaBadge": "Version d'évaluation technique", + "securitySolutionPackages.upselling.pages.attackDiscovery.pageTitle.betaTooltip": "Cette fonctionnalité est en version d’évaluation technique, elle est susceptible d’être modifiée. Veuillez utiliser Attack Discovery avec prudence dans les environnements de production.", + "securitySolutionPackages.upselling.pages.attackDiscovery.pageTitle.pageTitle": "Attack discovery", + "securitySolutionPackages.upselling.sections.attackDiscovery.findPotentialAttacksWithAiTitle": "Trouvez les attaques potentielles grâce à l'IA", "share.advancedSettings.csv.quoteValuesText": "Les valeurs doivent-elles être mises entre guillemets dans les exportations CSV ?", "share.advancedSettings.csv.quoteValuesTitle": "Mettre les valeurs CSV entre guillemets", "share.advancedSettings.csv.separatorText": "Séparer les valeurs exportées avec cette chaîne", @@ -7109,8 +7829,6 @@ "share.dashboard.link.description": "Partagez un lien direct avec cette recherche.", "share.embed.dashboard.helpText": "Intégrez ce tableau de bord dans une autre page web. Sélectionnez les éléments à inclure dans la vue intégrable.", "share.embed.helpText": "Intégrez ce {objectType} dans une autre page web.", - "share.export.generateButtonLabel": "Exporter un fichier", - "share.export.helpText": "Sélectionnez le type de fichier que vous souhaitez exporter pour cette visualisation.", "share.fileType": "Type de fichier", "share.link.copied": "Texte copié", "share.link.copyEmbedCodeButton": "Copier le code intégré", @@ -7121,7 +7839,7 @@ "share.modalContent.copyUrlButtonLabel": "Copier l'URL Post", "share.postURLWatcherMessage": "Copiez cette URL POST pour appeler la génération depuis l'extérieur de Kibana ou à partir de Watcher.", "share.postURLWatcherMessage.unsavedChanges": "L'URL peut changer si vous mettez Kibana à niveau.", - "share.screenCapturePanelContent.optimizeForPrintingHelpText": "Utilise plusieurs pages, affichant au maximum 2 visualisations par page ", + "share.screenCapturePanelContent.optimizeForPrintingHelpText": "Utilise plusieurs pages, affichant au maximum 2 visualisations par page", "share.screenCapturePanelContent.optimizeForPrintingLabel": "Pour l'impression", "share.urlPanel.canNotShareAsSavedObjectHelpText": "Pour le partager comme objet enregistré, enregistrez le {objectType}.", "share.urlPanel.copyIframeCodeButtonLabel": "Copier le code iFrame", @@ -7154,6 +7872,8 @@ "sharedUXPackages.card.noData.noPermission.title": "Contactez votre administrateur", "sharedUXPackages.card.noData.title": "Ajouter Elastic Agent", "sharedUXPackages.chrome.sideNavigation.betaBadge.label": "Bêta", + "sharedUXPackages.chrome.sideNavigation.feedbackCallout.btn": "Faites-nous en part", + "sharedUXPackages.chrome.sideNavigation.feedbackCallout.title": "Comment fonctionne la navigation de votre côté ? Il manque quelque chose ?", "sharedUXPackages.chrome.sideNavigation.recentlyAccessed.title": "Récent", "sharedUXPackages.chrome.sideNavigation.togglePanel": "Afficher/Masquer le panneau de navigation \"{title}\"", "sharedUXPackages.codeEditor.ariaLabel": "Éditeur de code", @@ -7221,11 +7941,17 @@ "sharedUXPackages.noDataPage.introNoDocLink": "Ajoutez vos données pour commencer.", "sharedUXPackages.noDataPage.welcomeTitle": "Bienvenue dans Elastic {solution}.", "sharedUXPackages.noDataViewsPrompt.addDataViewText": "Créer une vue de données", + "sharedUXPackages.noDataViewsPrompt.addDataViewTextNoPrivilege": "Créer une vue de données", + "sharedUXPackages.noDataViewsPrompt.addDataViewTooltipNoPrivilege": "Demandez à votre administrateur les autorisations nécessaires pour créer une vue de données.", + "sharedUXPackages.noDataViewsPrompt.createDataView": "Créer une vue de données", "sharedUXPackages.noDataViewsPrompt.dataViewExplanation": "Les vues de données identifient les données Elasticsearch que vous souhaitez explorer. Vous pouvez faire pointer des vues de données vers un ou plusieurs flux de données, index et alias d'index, tels que vos données de log d'hier, ou vers tous les index contenant vos données de log.", + "sharedUXPackages.noDataViewsPrompt.esqlExplanation": "ES|QL est un langage de requête canalisé de nouvelle génération et un moteur de calcul développé par Elastic pour filtrer, transformer et analyser les données. ES|QL vous aide à rationaliser vos workflows afin d'assurer un traitement des données rapide et efficace.", + "sharedUXPackages.noDataViewsPrompt.esqlPanel.title": "Interrogez vos données avec ES|QL", "sharedUXPackages.noDataViewsPrompt.learnMore": "Envie d'en savoir plus ?", "sharedUXPackages.noDataViewsPrompt.noPermission.dataViewExplanation": "Les vues de données identifient les données Elasticsearch que vous souhaitez explorer. Pour créer des vues de données, demandez les autorisations requises à votre administrateur.", "sharedUXPackages.noDataViewsPrompt.readDocumentation": "Lisez les documents", - "sharedUXPackages.noDataViewsPrompt.youHaveData": "Vous avez des données dans Elasticsearch.", + "sharedUXPackages.noDataViewsPrompt.tryEsqlText": "Essayer ES|QL", + "sharedUXPackages.noDataViewsPrompt.youHaveData": "Comment souhaitez-vous explorer vos données Elasticsearch ?", "sharedUXPackages.prompt.errors.notFound.body": "Désolé, la page que vous recherchez est introuvable. Elle a peut-être été retirée ou renommée, ou peut-être qu'elle n'a jamais existé.", "sharedUXPackages.prompt.errors.notFound.goBacklabel": "Retour", "sharedUXPackages.prompt.errors.notFound.title": "Page introuvable", @@ -7233,6 +7959,7 @@ "sharedUXPackages.solutionNav.menuText": "menu", "sharedUXPackages.solutionNav.mobileTitleText": "{solutionName} {menuText}", "sharedUXPackages.solutionNav.openLabel": "Ouvrir la navigation latérale", + "sse.internalError": "Une erreur interne s'est produite", "telemetry.callout.appliesSettingTitle": "Les modifications apportées à ce paramètre s'appliquent dans {allOfKibanaText} et sont enregistrées automatiquement.", "telemetry.callout.appliesSettingTitle.allOfKibanaText": "tout Kibana", "telemetry.callout.clusterStatisticsDescription": "Voici un exemple des statistiques de cluster de base que nous collecterons. Cela comprend le nombre d'index, de partitions et de nœuds. Cela comprend également des statistiques d'utilisation de niveau élevé, comme l'état d'activation du monitoring.", @@ -7310,12 +8037,12 @@ "timelion.help.functions.fitHelpText": "Remplit les valeurs nulles à l'aide d'une fonction fit définie.", "timelion.help.functions.hide.args.hideHelpText": "Masquer ou afficher les séries", "timelion.help.functions.hideHelpText": "Masquer les séries par défaut", - "timelion.help.functions.holt.args.alphaHelpText": "\n Pondération de lissage de 0 à 1.\n Augmentez l’alpha pour que la nouvelle série suive de plus près l'originale.\n Diminuez-le pour rendre la série plus lisse.", - "timelion.help.functions.holt.args.betaHelpText": "\n Pondération de tendance de 0 à 1.\n Augmentez le bêta pour que les lignes montantes/descendantes continuent à monter/descendre plus longtemps.\n Diminuez-le pour que la fonction apprenne plus rapidement la nouvelle tendance.", - "timelion.help.functions.holt.args.gammaHelpText": "\n Pondération saisonnière de 0 à 1. Vos données ressemblent-elles à une vague ?\n Augmentez cette valeur pour donner plus d'importance aux saisons récentes et ainsi modifier plus rapidement la forme de la vague.\n Diminuez-la pour réduire l'importance des nouvelles saisons et ainsi rendre l'historique plus important.\n ", - "timelion.help.functions.holt.args.sampleHelpText": "\n Le nombre de saisons à échantillonner avant de commencer à \"prédire\" dans une série saisonnière.\n (Utile uniquement avec gamma, par défaut : all)", + "timelion.help.functions.holt.args.alphaHelpText": "Pondération de lissage de 0 à 1. Augmentez l’alpha pour que la nouvelle série suive de plus près l'originale. Diminuez-le pour rendre la série plus lisse.", + "timelion.help.functions.holt.args.betaHelpText": "Pondération de tendance de 0 à 1. Augmentez le bêta pour que les lignes montantes/descendantes continuent à monter/descendre plus longtemps. Diminuez-le pour que la fonction apprenne plus rapidement la nouvelle tendance.", + "timelion.help.functions.holt.args.gammaHelpText": "Pondération saisonnière de 0 à 1. Vos données ressemblent-elles à une vague ? Augmentez cette valeur pour donner plus d'importance aux saisons récentes et ainsi modifier plus rapidement la forme de la vague. Diminuez-la pour réduire l'importance des nouvelles saisons et ainsi rendre l'historique plus important.", + "timelion.help.functions.holt.args.sampleHelpText": "Le nombre de saisons à échantillonner avant de commencer à \"prédire\" dans une série saisonnière. (Utile uniquement avec gamma, par défaut : all)", "timelion.help.functions.holt.args.seasonHelpText": "La longueur de la saison, par ex. 1w, si votre modèle se répète chaque semaine. (Utile uniquement avec gamma)", - "timelion.help.functions.holtHelpText": "\n Échantillonner le début d'une série et l'utiliser pour prévoir ce qui devrait se produire\n via plusieurs paramètres facultatifs. En règle générale, cela ne prédit pas\n l'avenir, mais ce qui devrait se produire maintenant en fonction des données passées,\n ce qui peut être utile pour la détection des anomalies. Notez que les valeurs null seront remplacées par des valeurs prévues.", + "timelion.help.functions.holtHelpText": "Échantillonner le début d'une série et l'utiliser pour prévoir ce qui devrait se produire via plusieurs paramètres facultatifs. En règle générale, cela ne prédit pas l'avenir, mais ce qui devrait se produire maintenant en fonction des données passées, ce qui peut être utile pour la détection des anomalies. Notez que les valeurs null seront remplacées par des valeurs prévues.", "timelion.help.functions.label.args.labelHelpText": "Valeur de légende pour les séries. Vous pouvez utiliser $1, $2, etc. dans la chaîne pour correspondre aux groupes de captures d'expressions régulières.", "timelion.help.functions.label.args.regexHelpText": "Une expression régulière compatible avec les groupes de captures", "timelion.help.functions.labelHelpText": "Modifiez l'étiquette des séries. Utiliser %s pour référencer l'étiquette existante", @@ -7382,10 +8109,10 @@ "timelion.help.functions.trim.args.startHelpText": "Compartiments à retirer du début de la série. Par défaut : 1", "timelion.help.functions.trimHelpText": "Définir N compartiments au début ou à la fin de la série sur null pour ajuster le \"problème de compartiment partiel\"", "timelion.help.functions.worldbank.args.codeHelpText": "Chemin de l'API Worldbank (Banque mondiale). Il s'agit généralement de tout ce qui suit le domaine, avant la chaîne de requête. Par exemple : {apiPathExample}.", - "timelion.help.functions.worldbankHelpText": "\n [expérimental]\n Extrayez des données de {worldbankUrl} à l'aide du chemin d’accès aux séries.\n La Banque mondiale fournit surtout des données annuelles et n'a souvent aucune donnée pour l'année en cours.\n Essayez {offsetQuery} si vous n’obtenez pas de données pour les plages temporelles récentes.", + "timelion.help.functions.worldbankHelpText": "[expérimental] Extrayez des données de {worldbankUrl} à l'aide du chemin d’accès aux séries. La Banque mondiale fournit surtout des données annuelles et n'a souvent aucune donnée pour l'année en cours. Essayez {offsetQuery} si vous n’obtenez pas de données pour les plages temporelles récentes.", "timelion.help.functions.worldbankIndicators.args.countryHelpText": "Identifiant de pays de la Banque mondiale. Généralement le code à 2 caractères du pays.", "timelion.help.functions.worldbankIndicators.args.indicatorHelpText": "Le code d'indicateur à utiliser. Vous devrez le rechercher sur {worldbankUrl}. Souvent très complexe. Par exemple, {indicatorExample} correspond à la population.", - "timelion.help.functions.worldbankIndicatorsHelpText": "\n [expérimental]\n Extrayez des données de {worldbankUrl} à l'aide du nom et de l'indicateur du pays. La Banque mondiale fournit\n surtout des données annuelles et n'a souvent aucune donnée pour l'année en cours. Essayez {offsetQuery} si vous n’obtenez pas de données pour\n les plages temporelles récentes.", + "timelion.help.functions.worldbankIndicatorsHelpText": "[expérimental] Extrayez des données de {worldbankUrl} à l'aide du nom et de l'indicateur du pays. La Banque mondiale fournit surtout des données annuelles et n'a souvent aucune donnée pour l'année en cours. Essayez {offsetQuery} si vous n’obtenez pas de données pour les plages temporelles récentes.", "timelion.help.functions.yaxis.args.colorHelpText": "Couleur de l'étiquette de l'axe", "timelion.help.functions.yaxis.args.labelHelpText": "Étiquette de l'axe", "timelion.help.functions.yaxis.args.maxHelpText": "Valeur max.", @@ -7440,6 +8167,9 @@ "timelion.vis.invalidIntervalErrorMessage": "Format d'intervalle non valide.", "timelion.vis.selectIntervalHelpText": "Choisissez une option ou créez une valeur personnalisée. Exemples : 30s, 20m, 24h, 2d, 1w, 1M", "timelion.vis.selectIntervalPlaceholder": "Choisir un intervalle", + "tryInConsole.button.text": "Exécuter dans la console", + "tryInConsole.embeddedConsoleButton.ariaLabel": "Exécuter dans la console - s'ouvre dans la console intégrée", + "tryInConsole.inNewTab.button.ariaLabel": "Exécuter dans la console - s'ouvre dans un nouvel onglet", "uiActions.actionPanel.more": "Plus", "uiActions.actionPanel.title": "Options", "uiActions.errors.incompatibleAction": "Action non compatible", @@ -7514,19 +8244,15 @@ "uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.encodeDescription": "Si elle est activée, l'URL sera précédée de l’encodage-pourcent comme caractère d'échappement", "uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.encodeUrl": "Encoder l'URL", "uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.openInNewTabLabel": "Ouvrir l'URL dans un nouvel onglet", - "uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewHelpText": "Veuillez noter que dans l'aperçu, les variables '{{event.*}}' sont remplacées par des valeurs factices.", - "uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewLabel": "Aperçu de l'URL :", - "uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewLinkText": "Aperçu", "uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateLabel": "Entrer l'URL", - "uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplatePlaceholderText": "Exemple : {exampleUrl}", "uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateSyntaxHelpLinkText": "Aide pour la syntaxe", "uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateVariablesFilterPlaceholderText": "Variables de filtre", "uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateVariablesHelpLinkText": "Aide", - "uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlFormatErrorMessage": "Format non valide : {message}", - "uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlFormatGeneralErrorMessage": "Format non valide. Exemple : {exampleUrl}", + "unifiedDataTable.additionalActionsColumnHeader": "Colonne d'actions supplémentaires", "unifiedDataTable.advancedDiffModesTooltip": "Les modes avancés offrent des capacités de diffraction améliorées, mais ils fonctionnent sur des documents bruts et ne prennent donc pas en charge le formatage des champs.", "unifiedDataTable.clearSelection": "Effacer la sélection", - "unifiedDataTable.compareSelectedRowsButtonLabel": "Comparer", + "unifiedDataTable.compareSelectedRowsButtonDisabledTooltip": "La comparaison est limitée à {limit} lignes", + "unifiedDataTable.compareSelectedRowsButtonLabel": "Comparer les éléments sélectionnés", "unifiedDataTable.comparingDocuments": "Comparaison de {documentCount} documents", "unifiedDataTable.comparingResults": "Comparaison de {documentCount} résultats", "unifiedDataTable.comparisonColumnPinnedTooltip": "Document épinglé : {documentId}", @@ -7539,10 +8265,15 @@ "unifiedDataTable.controlColumnHeader": "Colonne de commande", "unifiedDataTable.copyColumnNameToClipboard.toastTitle": "Copié dans le presse-papiers", "unifiedDataTable.copyColumnValuesToClipboard.toastTitle": "Valeurs de la colonne \"{column}\" copiées dans le presse-papiers", + "unifiedDataTable.copyDocsToClipboardJSON": "Copier des documents au format JSON", "unifiedDataTable.copyEscapedValueWithFormulasToClipboardWarningText": "Les valeurs peuvent contenir des formules avec échappement.", "unifiedDataTable.copyFailedErrorText": "Impossible de copier dans le presse-papiers avec ce navigateur", - "unifiedDataTable.copyResultsToClipboardJSON": "Copier les résultats dans le presse-papiers (JSON)", + "unifiedDataTable.copyResultsToClipboardJSON": "Copier les résultats au format JSON", + "unifiedDataTable.copyRowsAsJsonToClipboard.toastTitle": "Copié dans le presse-papiers", + "unifiedDataTable.copyRowsAsTextToClipboard.toastTitle": "Copié dans le presse-papiers", + "unifiedDataTable.copySelectionToClipboard": "Copier la sélection en tant que texte", "unifiedDataTable.copyValueToClipboard.toastTitle": "Copié dans le presse-papiers", + "unifiedDataTable.deselectAllRowsOnPageColumnHeader": "Désélectionner toutes les lignes visibles", "unifiedDataTable.diffModeChars": "Par caractère", "unifiedDataTable.diffModeFullValue": "Valeur totale", "unifiedDataTable.diffModeLines": "Par ligne", @@ -7553,17 +8284,20 @@ "unifiedDataTable.enableShowDiff": "Vous devez activer l'option Afficher les différences", "unifiedDataTable.exitDocumentComparison": "Quitter le mode comparaison", "unifiedDataTable.fieldColumnTitle": "Champ", + "unifiedDataTable.grid.additionalRowActions": "Actions supplémentaires", "unifiedDataTable.grid.closePopover": "Fermer la fenêtre contextuelle", "unifiedDataTable.grid.copyCellValueButton": "Copier la valeur", "unifiedDataTable.grid.copyClipboardButtonTitle": "Copier la valeur de {column}", "unifiedDataTable.grid.copyColumnNameToClipBoardButton": "Copier le nom", "unifiedDataTable.grid.copyColumnValuesToClipBoardButton": "Copier la colonne", - "unifiedDataTable.grid.documentHeader": "Document", + "unifiedDataTable.grid.documentHeader": "Résumé", "unifiedDataTable.grid.editFieldButton": "Modifier le champ de la vue de données", + "unifiedDataTable.grid.esqlMultivalueFilteringDisabled": "Le filtrage multivalué n'est pas pris en charge dans ES|QL", "unifiedDataTable.grid.filterFor": "Filtrer sur", "unifiedDataTable.grid.filterForAria": "Filtrer sur cette {value}", "unifiedDataTable.grid.filterOut": "Exclure", "unifiedDataTable.grid.filterOutAria": "Exclure cette {value}", + "unifiedDataTable.grid.resetColumnWidthButton": "Réinitialiser la largeur", "unifiedDataTable.grid.selectDoc": "Sélectionner le document \"{rowNumber}\"", "unifiedDataTable.grid.viewDoc": "Afficher/Masquer les détails de la boîte de dialogue", "unifiedDataTable.gridSampleSize.fetchMoreLinkDisabledTooltip": "Pour charger plus, l'intervalle d'actualisation doit d'abord être désactivé", @@ -7585,6 +8319,8 @@ "unifiedDataTable.sampleSizeSettings.sampleSizeLabel": "Taille de l'échantillon", "unifiedDataTable.searchGenerationWithDescription": "Tableau généré par la recherche {searchTitle}", "unifiedDataTable.searchGenerationWithDescriptionGrid": "Tableau généré par la recherche {searchTitle} ({searchDescription})", + "unifiedDataTable.selectAllDocs": "Tout sélectionner ({rowsCount})", + "unifiedDataTable.selectAllRowsOnPageColumnHeader": "Sélectionner toutes les lignes visibles", "unifiedDataTable.selectColumnHeader": "Sélectionner la colonne", "unifiedDataTable.selectedResultsButtonLabel": "Sélectionné", "unifiedDataTable.selectedRowsButtonLabel": "Sélectionné", @@ -7601,9 +8337,15 @@ "unifiedDataTable.showSelectedResultsOnly": "Afficher uniquement les résultats sélectionnés", "unifiedDataTable.tableHeader.timeFieldIconTooltip": "Ce champ représente l'heure à laquelle les événements se sont produits.", "unifiedDataTable.tableHeader.timeFieldIconTooltipAriaLabel": "{timeFieldName} : ce champ représente l'heure à laquelle les événements se sont produits.", + "unifiedDocViewer.docView.logsOverview.accordion.qualityIssues.table.datasetQualityLinkTitle": "Détails de l’ensemble de données", + "unifiedDocViewer.docView.logsOverview.accordion.qualityIssues.table.field": "Problème", + "unifiedDocViewer.docView.logsOverview.accordion.qualityIssues.table.textIgnored": "champ ignoré", + "unifiedDocViewer.docView.logsOverview.accordion.qualityIssues.table.values": "Valeurs", "unifiedDocViewer.docView.logsOverview.accordion.title.cloud": "Cloud", "unifiedDocViewer.docView.logsOverview.accordion.title.other": "Autre", + "unifiedDocViewer.docView.logsOverview.accordion.title.qualityIssues": "Problèmes de qualité", "unifiedDocViewer.docView.logsOverview.accordion.title.serviceInfra": "Service et Infrastructure", + "unifiedDocViewer.docView.logsOverview.accordion.title.techPreview": "PRÉVERSION TECHNIQUE", "unifiedDocViewer.docView.logsOverview.label.cloudAvailabilityZone": "Zone de disponibilité du cloud", "unifiedDocViewer.docView.logsOverview.label.cloudInstanceId": "ID d'instance du cloud", "unifiedDocViewer.docView.logsOverview.label.cloudProjectId": "ID de projet du cloud", @@ -7626,8 +8368,9 @@ "unifiedDocViewer.docView.table.ignored.singleAboveTooltip": "La valeur dans ce champ est trop longue et ne peut pas être recherchée ni filtrée.", "unifiedDocViewer.docView.table.ignored.singleMalformedTooltip": "La valeur dans ce champ est mal formée et ne peut pas être recherchée ni filtrée.", "unifiedDocViewer.docView.table.ignored.singleUnknownTooltip": "La valeur dans ce champ a été ignorée par Elasticsearch et ne peut pas être recherchée ni filtrée.", - "unifiedDocViewer.docView.table.searchPlaceHolder": "Rechercher les noms de champs", + "unifiedDocViewer.docView.table.searchPlaceHolder": "Rechercher des noms ou valeurs de champs", "unifiedDocViewer.docViews.json.jsonTitle": "JSON", + "unifiedDocViewer.docViews.table.esqlMultivalueFilteringDisabled": "Le filtrage multivalué n'est pas pris en charge dans ES|QL", "unifiedDocViewer.docViews.table.filterForFieldPresentButtonAriaLabel": "Filtrer sur le champ", "unifiedDocViewer.docViews.table.filterForFieldPresentButtonTooltip": "Filtrer sur le champ", "unifiedDocViewer.docViews.table.filterForValueButtonAriaLabel": "Filtrer sur la valeur", @@ -7648,6 +8391,8 @@ "unifiedDocViewer.docViews.table.unindexedFieldsCanNotBeSearchedTooltip": "Les champs non indexés ou les valeurs ignorées ne peuvent pas être recherchés", "unifiedDocViewer.docViews.table.unindexedFieldsCanNotBeSearchedWarningMessage": "Il est impossible d’effectuer une recherche sur des champs non indexés", "unifiedDocViewer.docViews.table.unpinFieldLabel": "Désépingler le champ", + "unifiedDocViewer.docViews.table.viewLessButton": "Afficher moins", + "unifiedDocViewer.docViews.table.viewMoreButton": "Voir plus", "unifiedDocViewer.fieldActions.copyToClipboard": "Copier dans le presse-papiers", "unifiedDocViewer.fieldActions.filterForFieldPresent": "Filtrer sur le champ", "unifiedDocViewer.fieldActions.filterForValue": "Filtrer sur la valeur", @@ -7659,15 +8404,19 @@ "unifiedDocViewer.fieldChooser.discoverField.name": "Champ", "unifiedDocViewer.fieldChooser.discoverField.value": "Valeur", "unifiedDocViewer.fieldsTable.ariaLabel": "Valeurs des champs", + "unifiedDocViewer.fieldsTable.pinControlColumnHeader": "Épingler la colonne Champ", "unifiedDocViewer.flyout.closeButtonLabel": "Fermer", "unifiedDocViewer.flyout.documentNavigation": "Navigation dans le document", "unifiedDocViewer.flyout.docViewerDetailHeading": "Document", "unifiedDocViewer.flyout.docViewerEsqlDetailHeading": "Résultat", + "unifiedDocViewer.flyout.screenReaderDescription": "Vous êtes dans une boîte de dialogue non modale. Pour fermer la boîte de dialogue, appuyez sur Échap.", "unifiedDocViewer.flyout.toastColumnAdded": "La colonne \"{columnName}\" a été ajoutée", "unifiedDocViewer.flyout.toastColumnRemoved": "La colonne \"{columnName}\" a été supprimée", + "unifiedDocViewer.hideNullValues.switchLabel": "Masquer les champs nuls", "unifiedDocViewer.json.codeEditorAriaLabel": "Affichage JSON en lecture seule d’un document Elasticsearch", "unifiedDocViewer.json.copyToClipboardLabel": "Copier dans le presse-papiers", "unifiedDocViewer.loadingJSON": "Chargement de JSON", + "unifiedDocViewer.showOnlySelectedFields.switchLabel": "Éléments sélectionnés uniquement", "unifiedDocViewer.sourceViewer.errorMessage": "Impossible de récupérer les données pour le moment. Actualisez l'onglet et réessayez.", "unifiedDocViewer.sourceViewer.errorMessageTitle": "Une erreur s'est produite.", "unifiedDocViewer.sourceViewer.refresh": "Actualiser", @@ -7711,7 +8460,6 @@ "unifiedFieldList.fieldsAccordion.existenceTimeoutLabel": "Les informations de champ ont pris trop de temps", "unifiedFieldList.fieldStats.bucketPercentageTooltip": "{formattedPercentage} ({count, plural, one {# enregistrement} other {# enregistrements}})", "unifiedFieldList.fieldStats.calculatedFromSampleRecordsLabel": "Calculé à partir de {sampledDocumentsFormatted} {sampledDocuments, plural, one {exemple d'enregistrement} other {exemples d'enregistrement}}.", - "unifiedFieldList.fieldStats.calculatedFromSampleValuesLabel": "Calculé à partir {sampledValuesFormatted} {sampledValues, plural, one {d'un exemple de valeur} other {d’exemples de valeur}}.", "unifiedFieldList.fieldStats.calculatedFromTotalRecordsLabel": "Calculé à partir de {totalDocumentsFormatted} {totalDocuments, plural, one {enregistrement} other {enregistrements}}.", "unifiedFieldList.fieldStats.countLabel": "Décompte", "unifiedFieldList.fieldStats.displayToggleLegend": "Basculer soit", @@ -7741,7 +8489,7 @@ "unifiedFieldList.useGroupedFields.emptyFieldsLabel": "Champs vides", "unifiedFieldList.useGroupedFields.emptyFieldsLabelHelp": "Champs ne possédant aucune des valeurs spécifiées dans vos filtres.", "unifiedFieldList.useGroupedFields.metaFieldsLabel": "Champs méta", - "unifiedFieldList.useGroupedFields.noAvailableDataLabel": "Aucun champ disponible ne contient de données.", + "unifiedFieldList.useGroupedFields.noAvailableDataLabel": "Aucun champ disponible contenant des données.", "unifiedFieldList.useGroupedFields.noEmptyDataLabel": "Aucun champ vide.", "unifiedFieldList.useGroupedFields.noMetaDataLabel": "Aucun champ méta.", "unifiedFieldList.useGroupedFields.popularFieldsLabel": "Champs populaires", @@ -7802,7 +8550,7 @@ "unifiedSearch.filter.filterBar.invalidDateFormatProvidedErrorMessage": "Format de date non valide fourni", "unifiedSearch.filter.filterBar.labelWarningInfo": "Le champ {fieldName} n'existe pas dans la vue en cours.", "unifiedSearch.filter.filterBar.labelWarningText": "Avertissement", - "unifiedSearch.filter.filterBar.negatedFilterPrefix": "NON ", + "unifiedSearch.filter.filterBar.negatedFilterPrefix": "NON", "unifiedSearch.filter.filterBar.pinFilterButtonLabel": "Épingler dans toutes les applications", "unifiedSearch.filter.filterBar.pinnedFilterPrefix": "Épinglé", "unifiedSearch.filter.filterBar.preview": "Aperçu {icon}", @@ -7895,9 +8643,15 @@ "unifiedSearch.optionsList.popover.sortOrder.desc": "Décroissant", "unifiedSearch.query.queryBar.clearInputLabel": "Effacer l'entrée", "unifiedSearch.query.queryBar.comboboxAriaLabel": "Rechercher et filtrer la page {pageType}", + "unifiedSearch.query.queryBar.esqlMenu.documentation": "Documentation", + "unifiedSearch.query.queryBar.esqlMenu.exampleQueries": "Requêtes recommandées", + "unifiedSearch.query.queryBar.esqlMenu.feedback": "Soumettre un commentaire", + "unifiedSearch.query.queryBar.esqlMenu.label": "Aide sur ES|QL", + "unifiedSearch.query.queryBar.esqlMenu.quickReference": "Référence rapide", + "unifiedSearch.query.queryBar.esqlMenu.switcherLabelTitle": "Vue de données", "unifiedSearch.query.queryBar.indexPattern.addFieldButton": "Ajouter un champ à cette vue de données", "unifiedSearch.query.queryBar.indexPattern.addNewDataView": "Créer une vue de données", - "unifiedSearch.query.queryBar.indexPattern.createForMatchingIndices": "Explorer {indicesLength, plural,\n one {# index correspondant}\n other {# index correspondants}}", + "unifiedSearch.query.queryBar.indexPattern.createForMatchingIndices": "Explorer {indicesLength, plural, one {# index correspondant} other {# index correspondants}}", "unifiedSearch.query.queryBar.indexPattern.dataViewsLabel": "Vues de données", "unifiedSearch.query.queryBar.indexPattern.findDataView": "Rechercher une vue de données", "unifiedSearch.query.queryBar.indexPattern.findFilterSet": "Trouver une requête", @@ -8222,6 +8976,8 @@ "visTypeMarkdown.function.help": "Visualisation Markdown", "visTypeMarkdown.function.markdown.help": "Markdown à rendre", "visTypeMarkdown.function.openLinksInNewTab.help": "Ouvre les liens dans un nouvel onglet", + "visTypeMarkdown.markdownDescription": "Ajoutez du texte et des images à votre tableau de bord.", + "visTypeMarkdown.markdownTitleInWizard": "Texte", "visTypeMarkdown.params.fontSizeLabel": "Taille de police de base en points", "visTypeMarkdown.params.helpLinkLabel": "Aide", "visTypeMarkdown.params.openLinksLabel": "Ouvrir les liens dans un nouvel onglet", @@ -8283,7 +9039,7 @@ "visTypeTable.function.args.splitColumnHelpText": "Diviser par la configuration des dimensions de colonne", "visTypeTable.function.args.splitRowHelpText": "Diviser par la configuration des dimensions de ligne", "visTypeTable.function.args.titleHelpText": "Titre de la visualisation. Le titre est utilisé comme nom de fichier par défaut pour l'exportation CSV.", - "visTypeTable.function.args.totalFuncHelpText": "Spécifie la fonction de calcul du nombre total de lignes. Les options possibles sont : ", + "visTypeTable.function.args.totalFuncHelpText": "Spécifie la fonction de calcul du nombre total de lignes. Les options possibles sont :", "visTypeTable.function.dimension.metrics": "Indicateurs", "visTypeTable.function.dimension.splitColumn": "Diviser par colonne", "visTypeTable.function.dimension.splitRow": "Diviser par ligne", @@ -8339,8 +9095,6 @@ "visTypeTimeseries.addDeleteButtons.deleteButtonDefaultTooltip": "Supprimer", "visTypeTimeseries.addDeleteButtons.reEnableTooltip": "Réactiver", "visTypeTimeseries.addDeleteButtons.temporarilyDisableTooltip": "Désactiver temporairement", - "visTypeTimeseries.advancedSettings.allowCheckingForFailedShardsText": "Afficher un message d'avertissement pour les données partielles dans les graphiques TSVB si la requête réussit pour certaines partitions, mais échoue pour d'autres.", - "visTypeTimeseries.advancedSettings.allowCheckingForFailedShardsTitle": "Afficher les échecs de partition de requête TSVB", "visTypeTimeseries.advancedSettings.allowStringIndicesText": "Vous permet d'interroger les index Elasticsearch dans les visualisations TSVB.", "visTypeTimeseries.advancedSettings.allowStringIndicesTitle": "Autoriser les index de chaîne dans TSVB", "visTypeTimeseries.advancedSettings.maxBucketsText": "A un impact sur la densité de l'histogramme TSVB. Doit être défini sur une valeur supérieure à \"histogram:maxBars\".", @@ -8550,6 +9304,7 @@ "visTypeTimeseries.indexPatternSelect.switchModePopover.title": "Mode de vue de données", "visTypeTimeseries.indexPatternSelect.switchModePopover.useKibanaIndices": "Utiliser des vues de données Kibana", "visTypeTimeseries.indexPatternSelect.updateIndex": "Mettre à jour la visualisation avec la vue de données saisie", + "visTypeTimeseries.kbnVisTypes.metricsDescription": "Réalisez des analyses avancées de vos données temporelles.", "visTypeTimeseries.kbnVisTypes.metricsTitle": "TSVB", "visTypeTimeseries.lastValueModeIndicator.lastBucketDate": "Compartiment : {lastBucketDate}", "visTypeTimeseries.lastValueModeIndicator.lastValue": "Dernière valeur", @@ -8941,7 +9696,7 @@ "visTypeVega.vegaParser.dataExceedsSomeParamsUseTimesLimitErrorMessage": "Les données ne doivent pas avoir plus d'un paramètre {urlParam}, {valuesParam} et {sourceParam}", "visTypeVega.vegaParser.hostConfigIsDeprecatedWarningMessage": "{deprecatedConfigName} a été déclassé. Utilisez {newConfigName} à la place.", "visTypeVega.vegaParser.hostConfigValueTypeErrorMessage": "S'il est présent, le paramètre {configName} doit être un objet", - "visTypeVega.vegaParser.inputSpecDoesNotSpecifySchemaErrorMessage": "Vos spécifications requièrent un champ {schemaParam} avec une URL valide pour\nVega (voir {vegaSchemaUrl}) ou\nVega-Lite (voir {vegaLiteSchemaUrl}).\nL'URL est uniquement un identificateur. Kibana et votre navigateur n'accéderont jamais à cette URL.", + "visTypeVega.vegaParser.inputSpecDoesNotSpecifySchemaErrorMessage": "Vos spécifications requièrent un champ {schemaParam} avec une URL valide pour Vega (voir {vegaSchemaUrl}) ou Vega-Lite (voir {vegaLiteSchemaUrl}). L'URL est uniquement un identificateur. Kibana et votre navigateur n'accéderont jamais à cette URL.", "visTypeVega.vegaParser.invalidVegaSpecErrorMessage": "Spécification Vega non valide", "visTypeVega.vegaParser.kibanaConfigValueTypeErrorMessage": "S'il est présent, le paramètre {configName} doit être un objet", "visTypeVega.vegaParser.maxBoundsValueTypeWarningMessage": "{maxBoundsConfigName} doit être un tableau avec quatre nombres", @@ -9106,7 +9861,7 @@ "visualizations.confirmModal.saveDuplicateConfirmationMessage": "L'enregistrement de \"{name}\" crée un doublon de titre. Voulez-vous tout de même enregistrer ?", "visualizations.confirmModal.saveDuplicateConfirmationTitle": "Cette visualisation existe déjà", "visualizations.confirmModal.title": "Modifications non enregistrées", - "visualizations.controls.notificationMessage": "Les contrôles d'entrée sont déclassés et seront supprimés dans une prochaine version. Utilisez les nouveaux contrôles pour filtrer les données de votre tableau de bord et interagir avec elles. ", + "visualizations.controls.notificationMessage": "Les contrôles d'entrée sont déclassés et seront supprimés dans une prochaine version. Utilisez les nouveaux contrôles pour filtrer les données de votre tableau de bord et interagir avec elles.", "visualizations.createVisualization.failedToLoadErrorMessage": "Impossible de charger la visualisation", "visualizations.createVisualization.noIndexPatternOrSavedSearchIdErrorMessage": "Vous devez fournir un indexPattern ou un savedSearchId", "visualizations.createVisualization.noVisTypeErrorMessage": "Vous devez fournir un type de visualisation valide", @@ -9115,6 +9870,7 @@ "visualizations.editor.createBreadcrumb": "Créer", "visualizations.editor.defaultEditBreadcrumbText": "Modifier la visualisation", "visualizations.editVisualization.readOnlyErrorMessage": "Les visualisations {visTypeTitle} sont en lecture seule et ne peuvent pas être ouvertes dans l'éditeur", + "visualizations.embeddable.errorTitle": "Impossible de charger la visualisation", "visualizations.embeddable.inspectorTitle": "Inspecteur", "visualizations.embeddable.legacyURLConflict.errorMessage": "Cette visualisation a la même URL qu'un alias hérité. Désactiver l'alias pour résoudre cette erreur : {json}", "visualizations.embeddable.placeholderTitle": "Titre de l'espace réservé", @@ -9162,6 +9918,8 @@ "visualizations.newChart.libraryMode.new": "nouveau", "visualizations.newChart.libraryMode.old": "âge", "visualizations.newGaugeChart.notificationMessage": "La nouvelle bibliothèque de graphiques de jauge ne prend pas encore en charge l'agrégation de graphiques fractionnés. {conditionalMessage}", + "visualizations.newVisWizard.aggBasedGroupDescription": "Utilisez notre bibliothèque Visualize classique pour créer des graphiques basés sur des agrégations.", + "visualizations.newVisWizard.aggBasedGroupTitle": "Basé sur une agrégation", "visualizations.newVisWizard.chooseSourceTitle": "Choisir une source", "visualizations.newVisWizard.filterVisTypeAriaLabel": "Filtrer un type de visualisation", "visualizations.newVisWizard.goBackLink": "Sélectionner une visualisation différente", @@ -9172,6 +9930,7 @@ "visualizations.newVisWizard.searchSelection.notFoundLabel": "Aucun recherche enregistrée ni aucun index correspondants n'ont été trouvés.", "visualizations.newVisWizard.searchSelection.savedObjectType.dataView": "Vue de données", "visualizations.newVisWizard.searchSelection.savedObjectType.search": "Recherche enregistrée", + "visualizations.newVisWizard.title": "Nouvelle visualisation", "visualizations.noDataView.label": "vue de données", "visualizations.noMatchRoute.bannerText": "L'application Visualize ne reconnaît pas cet itinéraire : {route}.", "visualizations.noMatchRoute.bannerTitleText": "Page introuvable", @@ -9219,6 +9978,7 @@ "visualizations.visualizeListingDashboardAppName": "Application Tableau de bord", "visualizations.visualizeListingDeleteErrorTitle": "Erreur lors de la suppression de la visualisation", "visualizations.visualizeListingDeleteErrorTitle.duplicateWarning": "L'enregistrement de \"{value}\" crée un doublon de titre.", + "visualizations.visualizeSavedObjectName": "Visualisation", "visualizationUiComponents.colorPicker.seriesColor.label": "Couleur de la série", "visualizationUiComponents.colorPicker.tooltip.auto": "Lens choisit automatiquement des couleurs à votre place sauf si vous spécifiez une couleur personnalisée.", "visualizationUiComponents.colorPicker.tooltip.custom": "Effacez la couleur personnalisée pour revenir au mode \"Auto\".", @@ -9277,8 +10037,9 @@ "xpack.actions.availableConnectorFeatures.compatibility.alertingRules": "Règles d'alerting", "xpack.actions.availableConnectorFeatures.compatibility.cases": "Cas", "xpack.actions.availableConnectorFeatures.compatibility.generativeAIForObservability": "IA générative pour l'observabilité", - "xpack.actions.availableConnectorFeatures.compatibility.generativeAIForSearchPlayground": "L'IA générative pour Search Playground", + "xpack.actions.availableConnectorFeatures.compatibility.generativeAIForSearchPlayground": "IA générative pour Search", "xpack.actions.availableConnectorFeatures.compatibility.generativeAIForSecurity": "IA générative pour la sécurité", + "xpack.actions.availableConnectorFeatures.compatibility.securitySolution": "Solution de sécurité", "xpack.actions.availableConnectorFeatures.securitySolution": "Solution de sécurité", "xpack.actions.availableConnectorFeatures.uptime": "Uptime", "xpack.actions.builtin.cases.jiraTitle": "Jira", @@ -9298,9 +10059,10 @@ "xpack.actions.serverSideErrors.unavailableLicenseInformationErrorMessage": "Les actions sont indisponibles - les informations de licence ne sont pas disponibles actuellement.", "xpack.actions.subActionsFramework.urlValidationError": "Erreur lors de la validation de l'URL : {message}", "xpack.actions.urlAllowedHostsConfigurationError": "Le {field} cible \"{value}\" n'est pas ajouté à la configuration Kibana xpack.actions.allowedHosts", + "xpack.aiAssistant.aiAssistantLabel": "Assistant d'intelligence artificielle", "xpack.aiAssistant.askAssistantButton.buttonLabel": "Demander à l'assistant", "xpack.aiAssistant.askAssistantButton.popoverContent": "Obtenez des informations relatives à vos données grâce à l'assistant d'Elastic", - "xpack.aiAssistant.assistantSetup.title": "Bienvenue sur l'assistant d'intelligence artificielle d'Elastic", + "xpack.aiAssistant.assistantSetup.title": "Bienvenue sur Elastic AI Assistant", "xpack.aiAssistant.chatActionsMenu.euiButtonIcon.menuLabel": "Menu", "xpack.aiAssistant.chatActionsMenu.euiToolTip.moreActionsLabel": "Plus d'actions", "xpack.aiAssistant.chatCollapsedItems.hideEvents": "Masquer {count} événements", @@ -9308,6 +10070,7 @@ "xpack.aiAssistant.chatCollapsedItems.toggleButtonLabel": "Afficher/masquer les éléments", "xpack.aiAssistant.chatFlyout.euiButtonIcon.expandConversationListLabel": "Développer la liste des conversations", "xpack.aiAssistant.chatFlyout.euiButtonIcon.newChatLabel": "Nouveau chat", + "xpack.aiAssistant.chatFlyout.euiFlyoutResizable.aiAssistantLabel": "Menu volant Chat de l'assistant d'IA", "xpack.aiAssistant.chatFlyout.euiToolTip.collapseConversationListLabel": "Réduire la liste des conversations", "xpack.aiAssistant.chatFlyout.euiToolTip.expandConversationListLabel": "Développer la liste des conversations", "xpack.aiAssistant.chatFlyout.euiToolTip.newChatLabel": "Nouveau chat", @@ -9332,6 +10095,10 @@ "xpack.aiAssistant.chatTimeline.messages.system.label": "Système", "xpack.aiAssistant.chatTimeline.messages.user.label": "Vous", "xpack.aiAssistant.checkingKbAvailability": "Vérification de la disponibilité de la base de connaissances", + "xpack.aiAssistant.conversationList.deleteConversationIconLabel": "Supprimer", + "xpack.aiAssistant.conversationList.errorMessage": "Échec de chargement", + "xpack.aiAssistant.conversationList.noConversations": "Aucune conversation", + "xpack.aiAssistant.conversationList.title": "Précédemment", "xpack.aiAssistant.conversationStartTitle": "a démarré une conversation", "xpack.aiAssistant.couldNotFindConversationContent": "Impossible de trouver une conversation avec l'ID {conversationId}. Assurez-vous que la conversation existe et que vous y avez accès.", "xpack.aiAssistant.couldNotFindConversationTitle": "Conversation introuvable", @@ -9371,7 +10138,7 @@ "xpack.aiAssistant.technicalPreviewBadgeDescription": "GTP4 est nécessaire pour bénéficier d'une meilleure expérience avec les appels de fonctions (par exemple lors de la réalisation d'analyse de la cause d'un problème, de la visualisation de données et autres). GPT3.5 peut fonctionner pour certains des workflows les plus simples comme les explications d'erreurs ou pour bénéficier d'une expérience comparable à ChatGPT au sein de Kibana à partir du moment où les appels de fonctions ne sont pas fréquents.", "xpack.aiAssistant.userExecutedFunctionEvent": "a exécuté la fonction {functionName}", "xpack.aiAssistant.userSuggestedFunctionEvent": "a demandé la fonction {functionName}", - "xpack.aiAssistant.welcomeMessage.div.checkTrainedModelsToLabel": " {retryInstallingLink} ou vérifiez {trainedModelsLink} pour vous assurer que {modelName} est déployé et en cours d'exécution.", + "xpack.aiAssistant.welcomeMessage.div.checkTrainedModelsToLabel": "{retryInstallingLink} ou vérifiez {trainedModelsLink} pour vous assurer que {modelName} est déployé et en cours d'exécution.", "xpack.aiAssistant.welcomeMessage.div.settingUpKnowledgeBaseLabel": "Configuration de la base de connaissances", "xpack.aiAssistant.welcomeMessage.inspectErrorsButtonEmptyLabel": "Inspecter les problèmes", "xpack.aiAssistant.welcomeMessage.issuesDescriptionListTitleLabel": "Problèmes", @@ -9387,9 +10154,19 @@ "xpack.aiAssistant.welcomeMessageKnowledgeBase.yourKnowledgeBaseIsNotSetUpCorrectlyLabel": "Votre base de connaissances n'a pas été configurée.", "xpack.aiAssistant.welcomeMessageKnowledgeBaseSetupErrorPanel.retryInstallingLinkLabel": "Réessayer l'installation", "xpack.aiops.actions.openChangePointInMlAppName": "Ouvrir dans AIOps Labs", + "xpack.aiops.analysis.analysisTypeDipFallbackInfoTitle": "Meilleurs éléments pour la plage temporelle de référence de base", + "xpack.aiops.analysis.analysisTypeDipInfoContent": "Le taux de log médian pour la plage temporelle d'écart-type sélectionnée est inférieur à la référence de base. Le tableau des résultats de l'analyse présente donc des éléments statistiquement significatifs inclus dans la plage temporelle de base qui sont moins nombreux ou manquant dans la plage temporelle d'écart-type. La colonne \"doc count\" (décompte de documents) renvoie à la quantité de documents dans la plage temporelle de base.", + "xpack.aiops.analysis.analysisTypeDipInfoContentFallback": "La plage temporelle de déviation ne contient aucun document. Les résultats montrent donc les catégories de message des meilleurs logs et les valeurs des champs pour la période de référence.", + "xpack.aiops.analysis.analysisTypeDipInfoTitle": "Baisse du taux de log", + "xpack.aiops.analysis.analysisTypeInfoTitlePrefix": "Type d'analyse :", + "xpack.aiops.analysis.analysisTypeSpikeFallbackInfoTitle": "Meilleurs éléments pour la plage temporelle de déviation", + "xpack.aiops.analysis.analysisTypeSpikeInfoContent": "Le taux de log médian pour la plage temporelle d'écart-type sélectionnée est inférieur à la référence de base. Le tableau des résultats de l'analyse présente donc des éléments statistiquement significatifs inclus dans la plage temporelle d'écart-type, qui contribuent au pic. La colonne \"doc count\" (décompte de documents) renvoie à la quantité de documents dans la plage temporelle d'écart-type.", + "xpack.aiops.analysis.analysisTypeSpikeInfoContentFallback": "La plage temporelle de référence de base ne contient aucun document. Les résultats montrent donc les catégories de message des meilleurs logs et les valeurs des champs pour la plage temporelle de déviation.", + "xpack.aiops.analysis.analysisTypeSpikeInfoTitle": "Pic du taux de log", "xpack.aiops.analysis.columnSelectorAriaLabel": "Filtrer les colonnes", "xpack.aiops.analysis.columnSelectorNotEnoughColumnsSelected": "Au moins une colonne doit être sélectionnée.", "xpack.aiops.analysis.errorCallOutTitle": "Génération {errorCount, plural, one {de l'erreur suivante} other {des erreurs suivantes}} au cours de l'analyse.", + "xpack.aiops.analysis.fieldsButtonLabel": "Champs", "xpack.aiops.analysis.fieldSelectorNotEnoughFieldsSelected": "Le regroupement nécessite la sélection d'au moins 2 champs.", "xpack.aiops.analysis.fieldSelectorPlaceholder": "Recherche", "xpack.aiops.analysisCompleteLabel": "Analyse terminée", @@ -9403,7 +10180,7 @@ "xpack.aiops.changePointDetection.actions.filterOutValueAction": "Exclure la valeur", "xpack.aiops.changePointDetection.actionsColumn": "Actions", "xpack.aiops.changePointDetection.addButtonLabel": "Ajouter", - "xpack.aiops.changePointDetection.aggregationIntervalTitle": "Intervalle d'agrégation : ", + "xpack.aiops.changePointDetection.aggregationIntervalTitle": "Intervalle d'agrégation :", "xpack.aiops.changePointDetection.applyTimeRangeLabel": "Appliquer la plage temporelle", "xpack.aiops.changePointDetection.attachChartsLabel": "Attacher les graphiques", "xpack.aiops.changePointDetection.attachmentTitle": "Point de modification : {function}({metric}){splitBy}", @@ -9449,7 +10226,7 @@ "xpack.aiops.changePointDetection.selectMetricFieldLabel": "Champ d'indicateur", "xpack.aiops.changePointDetection.selectSpitFieldLabel": "Diviser le champ", "xpack.aiops.changePointDetection.spikeDescription": "Un pic significatif existe au niveau de ce point.", - "xpack.aiops.changePointDetection.splitByTitle": " diviser par \"{splitField}\"", + "xpack.aiops.changePointDetection.splitByTitle": "diviser par \"{splitField}\"", "xpack.aiops.changePointDetection.stepChangeDescription": "La modification indique une hausse ou une baisse statistiquement significative dans la distribution des valeurs.", "xpack.aiops.changePointDetection.submitDashboardAttachButtonLabel": "Attacher", "xpack.aiops.changePointDetection.timeColumn": "Heure", @@ -9480,6 +10257,15 @@ "xpack.aiops.embeddableChangePointChart.viewTypeSelector.chartsLabel": "Graphiques", "xpack.aiops.embeddableChangePointChart.viewTypeSelector.tableLabel": "Tableau", "xpack.aiops.embeddableChangePointChartDisplayName": "Modifier la détection du point", + "xpack.aiops.embeddablePatternAnalysis.attachmentTitle": "Analyse du modèle : {fieldName}", + "xpack.aiops.embeddablePatternAnalysis.config.applyAndCloseLabel": "Appliquer et fermer", + "xpack.aiops.embeddablePatternAnalysis.config.applyFlyoutAriaLabel": "Appliquer les modifications", + "xpack.aiops.embeddablePatternAnalysis.config.cancelButtonLabel": "Annuler", + "xpack.aiops.embeddablePatternAnalysis.config.dataViewLabel": "Vue de données", + "xpack.aiops.embeddablePatternAnalysis.config.dataViewSelectorPlaceholder": "Sélectionner la vue de données", + "xpack.aiops.embeddablePatternAnalysis.config.title.edit": "Modifier l'analyse des modèles", + "xpack.aiops.embeddablePatternAnalysis.config.title.new": "Créer une analyse de modèle", + "xpack.aiops.embeddablePatternAnalysisDisplayName": "Analyse du modèle", "xpack.aiops.fieldContextPopover.descriptionTooltipContent": "Afficher les principales valeurs de champ", "xpack.aiops.fieldContextPopover.descriptionTooltipLogPattern": "La valeur du champ pour ce champ montre un exemple du modèle de champ de texte important identifié.", "xpack.aiops.fieldContextPopover.notTopTenValueMessage": "Le terme sélectionné n'est pas dans le top 10", @@ -9502,11 +10288,15 @@ "xpack.aiops.logCategorization.counts": "{count} {count, plural, one {Modèle trouvé} other {Modèles trouvés}}", "xpack.aiops.logCategorization.embeddableMenu.aria": "Options d'analyse de modèles", "xpack.aiops.logCategorization.embeddableMenu.minimumTimeRange.tooltip": "Ajoute une plage temporelle plus large à l’analyse afin d’améliorer la précision du modèle.", + "xpack.aiops.logCategorization.embeddableMenu.minimumTimeRangeOptionsRowAriaLabel": "Sélectionnez une plage temporelle minimale", "xpack.aiops.logCategorization.embeddableMenu.minimumTimeRangeOptionsRowLabel": "Plage temporelle minimale", - "xpack.aiops.logCategorization.embeddableMenu.patternAnalysisSettingsTitle": " Paramètres d’analyse du modèle", + "xpack.aiops.logCategorization.embeddableMenu.patternAnalysisSettingsTitle": "Paramètres d’analyse du modèle", "xpack.aiops.logCategorization.embeddableMenu.selectedFieldRowLabel": "Champ sélectionné", + "xpack.aiops.logCategorization.embeddableMenu.textFieldWarning.title": "La vue de données sélectionnée ne contient aucun champ de texte.", + "xpack.aiops.logCategorization.embeddableMenu.textFieldWarning.title.description": "L'analyse de modèle ne peut être exécutée que sur des vues de données comportant un champ de texte.", "xpack.aiops.logCategorization.embeddableMenu.tooltip": "Options", "xpack.aiops.logCategorization.embeddableMenu.totalPatternsMessage": "Modèles totaux dans {minimumTimeRangeOption} : {categoryCount}", + "xpack.aiops.logCategorization.embeddableMenu.totalPatternsMessage2": "Aucun temps supplémentaire ne sera ajouté à la plage que vous avez spécifiée avec le sélecteur de temps.", "xpack.aiops.logCategorization.emptyPromptBody": "L'analyse de modèle de log regroupe les messages dans des modèles courants.", "xpack.aiops.logCategorization.emptyPromptTitle": "Sélectionner un champ de texte et cliquer sur exécuter l'analyse du modèle pour lancer l'analyse", "xpack.aiops.logCategorization.errorLoadingCategories": "Erreur lors du chargement des catégories", @@ -9519,6 +10309,11 @@ "xpack.aiops.logCategorization.filterOut": "Exclure {values, plural, one {modèle} other {modèles}} dans Discover", "xpack.aiops.logCategorization.flyout.filterIn": "Filtrer sur {values, plural, one {modèle} other {modèles}}", "xpack.aiops.logCategorization.flyout.filterOut": "Exclure {values, plural, one {modèle} other {modèles}}", + "xpack.aiops.logCategorization.minimumTimeRange.1month": "1 mois", + "xpack.aiops.logCategorization.minimumTimeRange.1week": "1 semaine", + "xpack.aiops.logCategorization.minimumTimeRange.3months": "3 mois", + "xpack.aiops.logCategorization.minimumTimeRange.6months": "6 mois", + "xpack.aiops.logCategorization.minimumTimeRange.noMin": "Utiliser la plage spécifiée dans le sélecteur de temps", "xpack.aiops.logCategorization.noCategoriesBody": "Assurez-vous que le champ sélectionné est rempli dans la plage temporelle sélectionnée.", "xpack.aiops.logCategorization.noCategoriesTitle": "Aucun modèle n'a été trouvé", "xpack.aiops.logCategorization.noDocsBody": "Assurez-vous que la plage temporelle sélectionnée contient des documents.", @@ -9540,13 +10335,16 @@ "xpack.aiops.logCategorization.randomSamplerSettingsPopUp.randomSamplerPercentageRowLabel": "Pourcentage d'échantillonnage", "xpack.aiops.logCategorization.randomSamplerSettingsPopUp.randomSamplerRowLabel": "Échantillonnage aléatoire", "xpack.aiops.logCategorization.runButton": "Exécuter l'analyse du modèle", - "xpack.aiops.logCategorization.selectedCounts": " | {count} sélectionné(s)", + "xpack.aiops.logCategorization.selectedCounts": "| {count} sélectionné(s)", "xpack.aiops.logCategorization.selectedResultsButtonLabel": "Sélectionné", "xpack.aiops.logCategorization.tabs.bucket": "Compartiment", "xpack.aiops.logCategorization.tabs.bucket.tooltip": "Modèles apparaissant dans le compartiment anormal.", "xpack.aiops.logCategorization.tabs.fullTimeRange": "Plage temporelle entière", "xpack.aiops.logCategorization.tabs.fullTimeRange.tooltip": "Modèles apparaissant dans la plage temporelle choisie pour la page.", "xpack.aiops.logCategorizationTimeSeriesWarning.description": "L'analyse du modèle de log ne fonctionne que sur des index temporels.", + "xpack.aiops.logRateAnalysis.fieldCandidates.ecsIdentifiedMessage": "Les documents sources ont été identifiés comme étant conformes à ECS.", + "xpack.aiops.logRateAnalysis.fieldCandidates.fieldsDropdownHintMessage": "Utilisez le menu déroulant \"Champs\" pour modifier la sélection.", + "xpack.aiops.logRateAnalysis.fieldCandidates.fieldsSelectedMessage": "{selectedItemsCount} champs sur {allItemsCount} ont été présélectionnés pour l'analyse.", "xpack.aiops.logRateAnalysis.loadingState.doneMessage": "Terminé.", "xpack.aiops.logRateAnalysis.loadingState.groupingResults": "Transformation de paires champ/valeur significatives en groupes.", "xpack.aiops.logRateAnalysis.loadingState.identifiedFieldCandidates": "{fieldCandidatesCount, plural, one {# candidat de champ identifié} other {# candidats de champs identifiés}}.", @@ -9564,7 +10362,7 @@ "xpack.aiops.logRateAnalysis.page.emptyPromptBody": "La fonction d'analyse des pics de taux de log identifie les combinaisons champ/valeur statistiquement significatives qui contribuent à un pic ou une baisse de taux de log.", "xpack.aiops.logRateAnalysis.page.emptyPromptTitle": "Commencez par cliquer sur un pic ou une baisse dans l'histogramme.", "xpack.aiops.logRateAnalysis.page.fieldFilterApplyButtonLabel": "Appliquer", - "xpack.aiops.logRateAnalysis.page.fieldFilterHelpText": "Désélectionnez les champs non pertinents pour les supprimer des groupes et cliquez sur le bouton Appliquer pour réexécuter le regroupement. Utilisez la barre de recherche pour filtrer la liste, puis sélectionnez/désélectionnez plusieurs champs avec les actions ci-dessous.", + "xpack.aiops.logRateAnalysis.page.fieldFilterHelpText": "Désélectionnez les champs non pertinents pour les supprimer de l'analyse et cliquez sur le bouton Appliquer pour réexécuter l'analyse. Utilisez la barre de recherche pour filtrer la liste, puis sélectionnez/désélectionnez plusieurs champs avec les actions ci-dessous.", "xpack.aiops.logRateAnalysis.page.fieldSelector.deselectAllItems": "Tout désélectionner", "xpack.aiops.logRateAnalysis.page.fieldSelector.deselectAllSearchedItems": "Désélectionner les éléments filtrés", "xpack.aiops.logRateAnalysis.page.fieldSelector.selectAllItems": "Tout sélectionner", @@ -9625,12 +10423,15 @@ "xpack.aiops.logRateAnalysis.resultsTableGroups.impactLabelColumnTooltip": "Niveau d'impact du groupe sur la différence de taux de messages", "xpack.aiops.logRateAnalysis.resultsTableGroups.logRateChangeLabelColumnTooltip": "Le facteur par lequel le taux de journalisation a changé. Cette valeur est normalisée afin de tenir compte des différentes longueurs des plages temporelles de référence et d’écart.", "xpack.aiops.logRateAnalysis.resultsTableGroups.logRateColumnTooltip": "Représentation visuelle de l'impact du groupe sur la différence de taux de messages.", - "xpack.aiops.logRateAnalysis.resultsTableGroups.logRateDocDecreaseLabel": "les documents descendent jusqu'à 0 de {baselineBucketRate} au niveau de référence", - "xpack.aiops.logRateAnalysis.resultsTableGroups.logRateDocIncreaseLabel": "{deviationBucketRate} {deviationBucketRate, plural, one {doc} other {docs}} remontent de 0 au niveau de référence", + "xpack.aiops.logRateAnalysis.resultsTableGroups.logRateDocDecreaseLabel": "jusqu'à 0 depuis {baselineBucketRate} au niveau de référence", + "xpack.aiops.logRateAnalysis.resultsTableGroups.logRateDocIncreaseLabel": "jusqu'à {deviationBucketRate} depuis 0 au niveau de référence", + "xpack.aiops.logRateAnalysis.resultsTableGroups.logRateFactorDecreaseLabel": "{roundedFactor} fois inférieur", + "xpack.aiops.logRateAnalysis.resultsTableGroups.logRateFactorIncreaseLabel": "{roundedFactor} fois supérieur", "xpack.aiops.logRateAnalysisTimeSeriesWarning.description": "L'analyse des taux de log ne fonctionne que sur des index temporels.", "xpack.aiops.miniHistogram.noDataLabel": "S. O.", "xpack.aiops.navMenu.mlAppNameText": "Machine Learning et Analytique", "xpack.aiops.observabilityAIAssistantContextualInsight.logRateAnalysisTitle": "Causes possibles et résolutions", + "xpack.aiops.patternAnalysis.typeDisplayName": "analyse du modèle", "xpack.aiops.progressAriaLabel": "Progression", "xpack.aiops.progressTitle": "Progression : {progress} % — {progressMessage}", "xpack.aiops.rerunAnalysisButtonTitle": "Lancer l'analyse", @@ -9809,7 +10610,7 @@ "xpack.alerting.rulesClient.validateActions.actionsWithInvalidThrottles": "La fréquence de l'action ne peut pas être inférieure à l'intervalle de planification de {scheduleIntervalText} : {groups}", "xpack.alerting.rulesClient.validateActions.actionsWithInvalidTimeRange": "La plage temporelle du filtre d'alertes de l'action a une valeur non valide : {hours}", "xpack.alerting.rulesClient.validateActions.actionWithInvalidTimeframe": "La durée du filtre d'alertes de l'action a des champs manquants : jours, heures ou fuseau horaire : {uuids}", - "xpack.alerting.rulesClient.validateActions.errorSummary": "Impossible de valider les actions en raison {errorNum, plural, one {de l'erreur suivante :} other {des # erreurs suivantes :\n-}} {errorList}", + "xpack.alerting.rulesClient.validateActions.errorSummary": "Impossible de valider les actions en raison {errorNum, plural, one {de l'erreur suivante :} other {des # erreurs suivantes : -}} {errorList}", "xpack.alerting.rulesClient.validateActions.hasDuplicatedUuid": "Les actions ont des UUID en double", "xpack.alerting.rulesClient.validateActions.invalidGroups": "Groupes d'actions non valides : {groups}", "xpack.alerting.rulesClient.validateActions.misconfiguredConnector": "Connecteurs non valides : {groups}", @@ -9836,27 +10637,29 @@ "xpack.alerting.taskRunner.warning.maxQueuedActions": "Le nombre maximal d'actions en file d'attente a été atteint ; les actions excédentaires n'ont pas été déclenchées.", "xpack.apm..breadcrumb.apmLabel": "APM", "xpack.apm.a.thresholdMet": "Seuil atteint", + "xpack.apm.add.apm.agent.button.": "Ajouter un APM", "xpack.apm.addDataButtonLabel": "Ajouter des données", + "xpack.apm.addDataContextMenu.link": "Ajouter des données", "xpack.apm.agent_explorer.error.missing_configuration": "Pour utiliser la toute dernière version de l’agent, vous devez définir xpack.apm.latestAgentVersionsUrl.", "xpack.apm.agentConfig.allOptionLabel": "Tous", - "xpack.apm.agentConfig.apiRequestSize.description": "Taille totale compressée maximale du corps de la requête envoyé à l'API d'ingestion du serveur APM depuis un encodage fragmenté (diffusion HTTP).\nVeuillez noter qu'un léger dépassement est possible.\n\nLes unités d'octets autorisées sont `b`, `kb` et `mb`. `1kb` correspond à `1024b`.", + "xpack.apm.agentConfig.apiRequestSize.description": "Taille totale compressée maximale du corps de la requête envoyé à l'API d'ingestion du serveur APM depuis un encodage fragmenté (diffusion HTTP). Veuillez noter qu'un léger dépassement est possible. Les unités d'octets autorisées sont `b`, `kb` et `mb`. `1kb` correspond à `1024b`.", "xpack.apm.agentConfig.apiRequestSize.label": "Taille de la requête API", - "xpack.apm.agentConfig.apiRequestTime.description": "Durée maximale de l'ouverture d'une requête HTTP sur le serveur APM.\n\nREMARQUE : cette valeur doit être inférieure à celle du paramètre `read_timeout` du serveur APM.", + "xpack.apm.agentConfig.apiRequestTime.description": "Durée maximale de l'ouverture d'une requête HTTP sur le serveur APM. REMARQUE : cette valeur doit être inférieure à celle du paramètre `read_timeout` du serveur APM.", "xpack.apm.agentConfig.apiRequestTime.label": "Heure de la requête API", - "xpack.apm.agentConfig.applicationPackages.description": "Permet de déterminer si un cadre de trace de pile est un cadre dans l'application ou un cadre de bibliothèque. Cela permet à l'application APM de réduire les cadres de pile du code de la bibliothèque et de mettre en surbrillance les cadres de pile qui proviennent de votre application. Plusieurs packages racine peuvent être définis sous forme de liste séparée par des virgules ; il n'est pas nécessaire de configurer des sous-packages. Étant donné que ce paramètre aide à déterminer les classes à analyser au démarrage, la définition de cette option peut également améliorer le temps de démarrage.\n\nVous devez définir cette option afin d'utiliser les annotations d'API `@CaptureTransaction` et `@CaptureSpan`.", + "xpack.apm.agentConfig.applicationPackages.description": "Permet de déterminer si un cadre de trace de pile est un cadre dans l'application ou un cadre de bibliothèque. Cela permet à l'application APM de réduire les cadres de pile du code de la bibliothèque et de mettre en surbrillance les cadres de pile qui proviennent de votre application. Plusieurs packages racine peuvent être définis sous forme de liste séparée par des virgules ; il n'est pas nécessaire de configurer des sous-packages. Étant donné que ce paramètre aide à déterminer les classes à analyser au démarrage, la définition de cette option peut également améliorer le temps de démarrage. Vous devez définir cette option afin d'utiliser les annotations d'API `@CaptureTransaction` et `@CaptureSpan`.", "xpack.apm.agentConfig.applicationPackages.label": "Packages de l'application", - "xpack.apm.agentConfig.captureBody.description": "Pour les transactions qui sont des requêtes HTTP, l'agent peut éventuellement capturer le corps de la requête (par ex., variables POST).\nPour les transactions qui sont initiées par la réception d'un message depuis un agent de message, l'agent peut capturer le corps du message texte.", + "xpack.apm.agentConfig.captureBody.description": "Pour les transactions qui sont des requêtes HTTP, l'agent peut éventuellement capturer le corps de la requête (par ex., variables POST). Pour les transactions qui sont initiées par la réception d'un message depuis un agent de message, l'agent peut capturer le corps du message texte.", "xpack.apm.agentConfig.captureBody.label": "Capturer le corps", - "xpack.apm.agentConfig.captureBodyContentTypes.description": "Configure les types de contenu qui doivent être enregistrés.\n\nLes valeurs par défaut se terminent par un caractère générique afin que les types de contenu tels que `text/plain; charset=utf-8` soient également capturés.", + "xpack.apm.agentConfig.captureBodyContentTypes.description": "Configure les types de contenu qui doivent être enregistrés. Les valeurs par défaut se terminent par un caractère générique afin que les types de contenu tels que `text/plain; charset=utf-8` soient également capturés.", "xpack.apm.agentConfig.captureBodyContentTypes.label": "Capturer les types de contenu du corps", - "xpack.apm.agentConfig.captureHeaders.description": "Si cette option est définie sur `true`, l'agent capturera les en-têtes de la requête HTTP et de la réponse (y compris les cookies), ainsi que les en-têtes/les propriétés du message lors de l'utilisation de frameworks de messagerie (tels que Kafka).\n\nREMARQUE : Si `false` est défini, cela permet de réduire la bande passante du réseau, l'espace disque et les allocations d'objets.", + "xpack.apm.agentConfig.captureHeaders.description": "Si cette option est définie sur `true`, l'agent capturera les en-têtes de la requête HTTP et de la réponse (y compris les cookies), ainsi que les en-têtes/les propriétés du message lors de l'utilisation de frameworks de messagerie (tels que Kafka). REMARQUE : Si `false` est défini, cela permet de réduire la bande passante du réseau, l'espace disque et les allocations d'objets.", "xpack.apm.agentConfig.captureHeaders.label": "Capturer les en-têtes", - "xpack.apm.agentConfig.captureJmxMetrics.description": "Enregistrer les indicateurs de JMX sur le serveur APM\n\nPeut contenir plusieurs définitions d'indicateurs JMX séparées par des virgules :\n\n`object_name[] attribute[:metric_name=]`\n\nPour en savoir plus, consultez la documentation de l'agent Java.", + "xpack.apm.agentConfig.captureJmxMetrics.description": "Les indicateurs du rapport de JMX vers le serveur APM peuvent contenir plusieurs définitions d’indicateurs JMX séparés par des virgules : `object_name[] attribute[:metric_name=]` Consultez la documentation de l'agent Java pour plus de détails.", "xpack.apm.agentConfig.captureJmxMetrics.label": "Capturer les indicateurs JMX", "xpack.apm.agentConfig.chooseService.editButton": "Modifier", "xpack.apm.agentConfig.chooseService.service.environment.label": "Environnement", "xpack.apm.agentConfig.chooseService.service.name.label": "Nom de service", - "xpack.apm.agentConfig.circuitBreakerEnabled.description": "Nombre booléen spécifiant si le disjoncteur doit être activé ou non. Lorsqu'il est activé, l'agent interroge régulièrement les monitorings de tension pour détecter l'état de tension du système/du processus/de la JVM. Si L'UN des monitorings détecte un signe de tension, l'agent s'interrompt, comme si l'option de configuration `recording` était définie sur `false`, réduisant ainsi la consommation des ressources au minimum. Pendant l'interruption, l'agent continue à interroger les mêmes monitorings pour vérifier si l'état de tension a été allégé. Si TOUS les monitorings indiquent que le système, le processus et la JVM ne sont plus en état de tension, l'agent reprend son activité et redevient entièrement fonctionnel.", + "xpack.apm.agentConfig.circuitBreakerEnabled.description": "Nombre booléen spécifiant si le disjoncteur doit être activé ou non. Lorsqu'il est activé, l'agent interroge régulièrement les monitorings de tension pour détecter l'état de tension du système/du processus/de la JVM. Si L'UN des monitorings détecte un signe de tension, l'agent s'interrompt, comme si l'option de configuration `recording` était définie sur `false`, réduisant ainsi la consommation des ressources au minimum. Pendant l'interruption, l'agent continue à interroger les mêmes monitorings pour vérifier si l'état de tension a été allégé. Si TOUS les monitorings indiquent que le système, le processus et la JVM ne sont plus en état de tension, l'agent reprend son activité et redevient entièrement fonctionnel.", "xpack.apm.agentConfig.circuitBreakerEnabled.label": "Disjoncteur activé", "xpack.apm.agentConfig.configTable.appliedTooltipMessage": "Appliqué par au moins un agent", "xpack.apm.agentConfig.configTable.configTable.failurePromptText": "La liste des configurations d'agent n'a pas pu être récupérée. Votre utilisateur ne dispose peut-être pas d'autorisations suffisantes.", @@ -9872,7 +10675,7 @@ "xpack.apm.agentConfig.context_propagation_only.label": "Propagation du contexte seulement", "xpack.apm.agentConfig.createConfigButtonLabel": "Créer une configuration", "xpack.apm.agentConfig.createConfigTitle": "Créer une configuration", - "xpack.apm.agentConfig.dedotCustomMetrics.description": "Remplace les points par des traits de soulignement dans les noms des indicateurs personnalisés.\n\nAVERTISSEMENT : L'attribution de la valeur `false` peut entraîner des conflits de mapping car les points indiquent une imbrication dans Elasticsearch.\nUn tel conflit peut se produire par exemple entre deux indicateurs si l'un se nomme `foo` et l'autre `foo.bar`.\nLe premier mappe `foo` sur un nombre, et le second indicateur mappe `foo` en tant qu'objet.", + "xpack.apm.agentConfig.dedotCustomMetrics.description": "Remplace les points par des traits de soulignement dans les noms des indicateurs personnalisés. AVERTISSEMENT : L'attribution de la valeur `false` peut entraîner des conflits de mapping car les points indiquent une imbrication dans Elasticsearch. Un tel conflit peut se produire par exemple entre deux indicateurs si l'un se nomme `foo` et l'autre `foo.bar`. Le premier mappe `foo` sur un nombre, et le second indicateur mappe `foo` en tant qu'objet.", "xpack.apm.agentConfig.dedotCustomMetrics.label": "Retirer les points des indicateurs personnalisés", "xpack.apm.agentConfig.deleteModal.cancel": "Annuler", "xpack.apm.agentConfig.deleteModal.confirm": "Supprimer", @@ -9882,30 +10685,30 @@ "xpack.apm.agentConfig.deleteSection.deleteConfigFailedTitle": "La configuration n'a pas pu être supprimée", "xpack.apm.agentConfig.deleteSection.deleteConfigSucceededText": "Vous avez supprimé une configuration de \"{serviceName}\". La propagation jusqu'aux agents pourra prendre un certain temps.", "xpack.apm.agentConfig.deleteSection.deleteConfigSucceededTitle": "La configuration a été supprimée", - "xpack.apm.agentConfig.disableInstrumentations.description": "Liste séparée par des virgules de modules pour lesquels désactiver l'instrumentation.\nLorsque l'instrumentation est désactivée pour un module, aucun intervalle n'est collecté pour ce module.\n\nLa liste à jour des modules pour lesquels l'instrumentation peut être désactivée est spécifique du langage et peut être trouvée en cliquant sur les liens suivants : [Java](https://www.elastic.co/guide/en/apm/agent/java/current/config-core.html#config-disable-instrumentations)", + "xpack.apm.agentConfig.disableInstrumentations.description": "Liste séparée par des virgules de modules pour lesquels désactiver l'instrumentation. Lorsque l'instrumentation est désactivée pour un module, aucun intervalle n'est collecté pour ce module. La liste à jour des modules pour lesquels l'instrumentation peut être désactivée est spécifique du langage et peut être trouvée en cliquant sur les liens suivants : [Java](https://www.elastic.co/guide/en/apm/agent/java/current/config-core.html#config-disable-instrumentations)", "xpack.apm.agentConfig.disableInstrumentations.label": "Désactiver les instrumentations", - "xpack.apm.agentConfig.disableOutgoingTracecontextHeaders.description": "Utilisez cette option pour désactiver l'injection d'en-têtes `tracecontext` dans une communication sortante.\n\nAVERTISSEMENT : La désactivation de l'injection d'en-têtes `tracecontext` signifie que le traçage distribué ne fonctionnera pas sur les services en aval.", + "xpack.apm.agentConfig.disableOutgoingTracecontextHeaders.description": "Utilisez cette option pour désactiver l'injection d'en-têtes `tracecontext` dans une communication sortante. AVERTISSEMENT : La désactivation de l'injection d'en-têtes `tracecontext` signifie que le traçage distribué ne fonctionnera pas sur les services en aval.", "xpack.apm.agentConfig.disableOutgoingTracecontextHeaders.label": "Désactiver les en-têtes tracecontext sortants", "xpack.apm.agentConfig.editConfigTitle": "Modifier la configuration", - "xpack.apm.agentConfig.enableExperimentalInstrumentations.description": "Indique s'il faut appliquer des instrumentations expérimentales.\n\nREMARQUE : Le fait de modifier cette valeur au moment de l'exécution peut ralentir temporairement l'application. Définir cette valeur sur true active les instrumentations dans le groupe expérimental.", + "xpack.apm.agentConfig.enableExperimentalInstrumentations.description": "Indique s'il faut appliquer des instrumentations expérimentales. REMARQUE : Le fait de modifier cette valeur au moment de l'exécution peut ralentir temporairement l'application. Définir cette valeur sur true active les instrumentations dans le groupe expérimental.", "xpack.apm.agentConfig.enableExperimentalInstrumentations.label": "Activer les instrumentations expérimentales", - "xpack.apm.agentConfig.enableInstrumentations.description": "Une liste des instrumentations qui doivent être activées de façon sélective. Les options valides sont indiquées dans la [documentation de l’agent Java APM](https://www.elastic.co/guide/en/apm/agent/java/current/config-core.html#config-disable-instrumentations).\n\nLorsqu'une valeur non vide est définie, seules les instrumentations répertoriées sont activées si elles ne sont pas désactivées via `disable_instrumentations` ou `enable_experimental_instrumentations`.\nLorsque cette option n'est pas définie ou est vide (par défaut), toutes les instrumentations activées par défaut sont activées, sauf si elles sont désactivées via `disable_instrumentations` ou `enable_experimental_instrumentations`.", + "xpack.apm.agentConfig.enableInstrumentations.description": "Une liste des instrumentations qui doivent être activées de façon sélective. Les options valides sont indiquées dans la [documentation de l’agent Java APM](https://www.elastic.co/guide/en/apm/agent/java/current/config-core.html#config-disable-instrumentations). Lorsqu'une valeur non vide est définie, seules les instrumentations répertoriées sont activées si elles ne sont pas désactivées via `disable_instrumentations` ou `enable_experimental_instrumentations`. Lorsque cette option n'est pas définie ou est vide (par défaut), toutes les instrumentations activées par défaut sont activées, sauf si elles sont désactivées via `disable_instrumentations` ou `enable_experimental_instrumentations`.", "xpack.apm.agentConfig.enableInstrumentations.label": "Désactiver les instrumentations", "xpack.apm.agentConfig.enableLogCorrelation.description": "Nombre booléen spécifiant si l'agent doit être intégré au MDC de SLF4J pour activer la corrélation de logs de suivi. Si cette option est configurée sur `true`, l'agent définira `trace.id` et `transaction.id` pour les intervalles et transactions actifs sur le MDC. Depuis la version 1.16.0 de l'agent Java, l'agent ajoute également le `error.id` de l'erreur capturée au MDC juste avant le logging du message d'erreur. REMARQUE : bien qu'il soit autorisé d'activer ce paramètre au moment de l'exécution, vous ne pouvez pas le désactiver sans redémarrage.", "xpack.apm.agentConfig.enableLogCorrelation.label": "Activer la corrélation de logs", - "xpack.apm.agentConfig.exitSpanMinDuration.description": "Les intervalles de sortie sont des intervalles qui représentent un appel à un service externe, tel qu'une base de données. Si de tels appels sont très courts, ils ne sont généralement pas pertinents et ils peuvent être ignorés.\n\nREMARQUE : Si un intervalle propage des ID de traçage distribué, il ne sera pas ignoré, même s'il est plus court que le seuil configuré. Cela permet de s'assurer qu'aucune trace interrompue n'est enregistrée.", + "xpack.apm.agentConfig.exitSpanMinDuration.description": "Les intervalles de sortie sont des intervalles qui représentent un appel à un service externe, tel qu'une base de données. Si de tels appels sont très courts, ils ne sont généralement pas pertinents et ils peuvent être ignorés. REMARQUE : Si un intervalle propage des ID de traçage distribué, il ne sera pas ignoré, même s'il est plus court que le seuil configuré. Cela permet de s'assurer qu'aucune trace interrompue n'est enregistrée.", "xpack.apm.agentConfig.exitSpanMinDuration.label": "Durée min. d'intervalle de sortie", - "xpack.apm.agentConfig.ignoreExceptions.description": "Liste d'exceptions qui doivent être ignorées et non signalées comme des erreurs.\nCela permet d'ignorer les exceptions qui ont été lancées dans le flux de contrôle normal mais qui ne sont pas de réelles erreurs.", + "xpack.apm.agentConfig.ignoreExceptions.description": "Liste d'exceptions qui doivent être ignorées et non signalées comme des erreurs. Cela permet d'ignorer les exceptions qui ont été lancées dans le flux de contrôle normal mais qui ne sont pas de réelles erreurs.", "xpack.apm.agentConfig.ignoreExceptions.label": "Ignorer les exceptions", - "xpack.apm.agentConfig.ignoreMessageQueues.description": "Utilisé pour exclure les files d'attente/sujets de messagerie spécifiques du traçage. \n\nCette propriété doit être définie sur un tableau contenant une ou plusieurs chaînes.\nUne fois définie, les envois vers et les réceptions depuis les files d'attente/sujets spécifiés seront ignorés.", + "xpack.apm.agentConfig.ignoreMessageQueues.description": "Utilisé pour exclure les files d'attente/sujets de messagerie spécifiques du traçage. Cette propriété doit être définie sur un tableau contenant une ou plusieurs chaînes. Une fois définie, les envois vers et les réceptions depuis les files d'attente/sujets spécifiés seront ignorés.", "xpack.apm.agentConfig.ignoreMessageQueues.label": "Ignorer les files d'attente des messages", "xpack.apm.agentConfig.logEcsReformatting.description": "Spécifier si et comment l'agent doit reformater automatiquement les logs d'application en [JSON compatible avec ECS](https://www.elastic.co/guide/en/ecs-logging/overview/master/intro.html), compatible avec l'ingestion dans Elasticsearch à des fins d'analyse de log plus poussée.", "xpack.apm.agentConfig.logEcsReformatting.label": "Reformatage ECS des logs", "xpack.apm.agentConfig.logLevel.description": "Définit le niveau de logging pour l'agent", "xpack.apm.agentConfig.logLevel.label": "Niveau du log", - "xpack.apm.agentConfig.logSending.description": "Expérimental, requiert la version la plus récente de l'agent Java.\n\nSi `true` est défini,\nL'agent envoie les logs directement au serveur APM.", + "xpack.apm.agentConfig.logSending.description": "Expérimental, requiert la version la plus récente de l'agent Java. Si défini sur `true`, l'agent enverra les logs directement au serveur APM.", "xpack.apm.agentConfig.logSending.label": "Envoi de logs (expérimental)", - "xpack.apm.agentConfig.mongodbCaptureStatementCommands.description": "Les noms de commande MongoDB pour lesquels le document de commande est capturé, limité aux opérations en lecture seule courantes par défaut. Définissez cette option sur `\"\"` (vide) pour désactiver la capture, et sur `*` pour tout capturer (ce qui est déconseillé, car cela peut entraîner la capture d'informations sensibles).\n\nCette option prend en charge le caractère générique `*` qui correspond à zéro caractère ou plus. Exemples : `/foo/*/bar/*/baz*`, `*foo*`. La correspondance n'est pas sensible à la casse par défaut. L'ajout de `(?-i)` au début d'un élément rend la correspondance sensible à la casse.", + "xpack.apm.agentConfig.mongodbCaptureStatementCommands.description": "Les noms de commande MongoDB pour lesquels le document de commande est capturé, limité aux opérations en lecture seule courantes par défaut. Définissez cette option sur `\"\"` (vide) pour désactiver la capture, et sur `*` pour tout capturer (ce qui est déconseillé, car cela peut entraîner la capture d'informations sensibles). Cette option prend en charge le caractère générique `*` qui correspond à zéro caractère ou plus. Exemples : `/foo/*/bar/*/baz*`, `*foo*`. La correspondance n'est pas sensible à la casse par défaut. L'ajout de `(?-i)` au début d'un élément rend la correspondance sensible à la casse.", "xpack.apm.agentConfig.mongodbCaptureStatementCommands.label": "Commandes d'instruction pour la capture MongoDB", "xpack.apm.agentConfig.newConfig.description": "Affinez votre configuration d'agent depuis l'application APM. Les modifications sont automatiquement propagées à vos agents APM, ce qui vous évite d'effectuer un redéploiement.", "xpack.apm.agentConfig.profilingInferredSpansEnabled.description": "Définissez cette option sur `true` afin que l'agent crée des intervalles pour des exécutions de méthodes basées sur async-profiler, un profiler d'échantillonnage (ou profiler statistique). En raison de la nature du fonctionnement des profilers d'échantillonnage, la durée des intervalles générés n'est pas exacte, il ne s'agit que d'estimations. `profiling_inferred_spans_sampling_interval` vous permet d'ajuster avec exactitude le compromis entre précision et surcharge. Les intervalles générés sont créés à la fin d'une session de profilage. Cela signifie qu'il existe un délai entre les intervalles réguliers et les intervalles générés visibles dans l'interface utilisateur. REMARQUE : cette fonctionnalité n'est pas disponible sous Windows.", @@ -9918,7 +10721,7 @@ "xpack.apm.agentConfig.profilingInferredSpansMinDuration.label": "Durée minimale des intervalles générés par le profilage", "xpack.apm.agentConfig.profilingInferredSpansSamplingInterval.description": "Fréquence à laquelle les traces de pile sont rassemblées au cours d'une session de profilage. Plus vous définissez un chiffre bas, plus les durées seront précises. Cela induit une surcharge plus élevée et un plus grand nombre d'intervalles, pour des opérations potentiellement non pertinentes. La durée minimale d'un intervalle généré par le profilage est identique à la valeur de ce paramètre.", "xpack.apm.agentConfig.profilingInferredSpansSamplingInterval.label": "Intervalle d'échantillonnage des intervalles générés par le profilage", - "xpack.apm.agentConfig.range.errorText": "{rangeType, select,\n between {doit être compris entre {min} et {max}}\n gt {doit être supérieur à {min}}\n lt {doit être inférieur à {max}}\n other {doit être un entier}\n }", + "xpack.apm.agentConfig.range.errorText": "{rangeType, select, between {Doit être compris entre {min} et {max}} gt {Doit être supérieur à {min}} lt {Doit être inférieur à {max}} other {Doit être un entier} }", "xpack.apm.agentConfig.recording.description": "Lorsque l'enregistrement est activé, l'agent instrumente les requêtes HTTP entrantes, effectue le suivi des erreurs, et collecte et envoie les indicateurs. Lorsque l'enregistrement n'est pas activé, l'agent agit comme un noop, sans collecter de données ni communiquer avec le serveur AMP, sauf pour rechercher la configuration mise à jour. Puisqu'il s'agit d'un commutateur réversible, les threads d'agents ne sont pas détruits lorsque le mode sans enregistrement est défini. Ils restent principalement inactifs, de sorte que la surcharge est négligeable. Vous pouvez utiliser ce paramètre pour contrôler dynamiquement si Elastic APM doit être activé ou désactivé.", "xpack.apm.agentConfig.recording.label": "Enregistrement", "xpack.apm.agentConfig.sanitizeFiledNames.description": "Il est parfois nécessaire d'effectuer un nettoyage, c'est-à-dire de supprimer les données sensibles envoyées à Elastic APM. Cette configuration accepte une liste de modèles de caractères génériques de champs de noms qui doivent être nettoyés. Ils s'appliquent aux en-têtes HTTP (y compris les cookies) et aux données `application/x-www-form-urlencoded` (champs de formulaire POST). La chaîne de la requête et le corps de la requête capturé (comme des données `application/json`) ne seront pas nettoyés.", @@ -9928,7 +10731,7 @@ "xpack.apm.agentConfig.saveConfig.succeeded.text": "La configuration de \"{serviceName}\" a été enregistrée. La propagation jusqu'aux agents pourra prendre un certain temps.", "xpack.apm.agentConfig.saveConfig.succeeded.title": "Configuration enregistrée", "xpack.apm.agentConfig.saveConfigurationButtonLabel": "Étape suivante", - "xpack.apm.agentConfig.serverTimeout.description": "Si une requête au serveur APM prend plus de temps que le délai d'expiration configuré,\nla requête est annulée et l'événement (exception ou transaction) est abandonné.\nDéfinissez sur 0 pour désactiver les délais d'expiration.\n\nAVERTISSEMENT : si les délais d'expiration sont désactivés ou définis sur une valeur élevée, il est possible que votre application rencontre des problèmes de mémoire en cas d'expiration du serveur APM.", + "xpack.apm.agentConfig.serverTimeout.description": "Si une requête adressée au serveur APM prend plus de temps que le délai d'expiration configuré, la requête est annulée et l'événement (exception ou transaction) est ignoré. Définissez sur 0 pour désactiver les délais d'expiration. AVERTISSEMENT : si les délais d'expiration sont désactivés ou définis sur une valeur élevée, il est possible que votre application rencontre des problèmes de mémoire en cas d'expiration du serveur APM.", "xpack.apm.agentConfig.serverTimeout.label": "Délai d'expiration du serveur", "xpack.apm.agentConfig.servicePage.alreadyConfiguredOption": "déjà configuré", "xpack.apm.agentConfig.servicePage.cancelButton": "Annuler", @@ -9945,17 +10748,17 @@ "xpack.apm.agentConfig.settingsPage.notFound.message": "La configuration demandée n'existe pas", "xpack.apm.agentConfig.settingsPage.notFound.title": "Désolé, une erreur est survenue", "xpack.apm.agentConfig.settingsPage.saveButton": "Enregistrer la configuration", - "xpack.apm.agentConfig.spanCompressionEnabled.description": "L'attribution de la valeur \"true\" à cette option activera la fonctionnalité de compression de l'intervalle.\nLa compression d'intervalle réduit la surcharge de collecte, de traitement et de stockage, et supprime l'encombrement dans l'interface utilisateur. Le compromis est que certaines informations, telles que les instructions de base de données de tous les intervalles compressés, ne seront pas collectées.", + "xpack.apm.agentConfig.spanCompressionEnabled.description": "L'attribution de la valeur \"true\" à cette option activera la fonctionnalité de compression de l'intervalle. La compression d'intervalle réduit la surcharge de collecte, de traitement et de stockage, et supprime l'encombrement dans l'interface utilisateur. Le compromis est que certaines informations, telles que les instructions de base de données de tous les intervalles compressés, ne seront pas collectées.", "xpack.apm.agentConfig.spanCompressionEnabled.label": "Compression d'intervalle activée", "xpack.apm.agentConfig.spanCompressionExactMatchMaxDuration.description": "Les intervalles consécutifs qui sont des correspondances parfaites et qui se trouvent sous ce seuil seront compressés en un seul intervalle composite. Cette option ne s'applique pas aux intervalles composites. Cela réduit la surcharge de collecte, de traitement et de stockage, et supprime l'encombrement dans l'interface utilisateur. Le compromis est que les instructions de base de données de tous les intervalles compressés ne seront pas collectées.", "xpack.apm.agentConfig.spanCompressionExactMatchMaxDuration.label": "Durée maximale de compression d'intervalles en correspondance parfaite", "xpack.apm.agentConfig.spanCompressionSameKindMaxDuration.description": "Les intervalles consécutifs qui ont la même destination et qui se trouvent sous ce seuil seront compressés en un seul intervalle composite. Cette option ne s'applique pas aux intervalles composites. Cela réduit la surcharge de collecte, de traitement et de stockage, et supprime l'encombrement dans l'interface utilisateur. Le compromis est que les instructions de base de données de tous les intervalles compressés ne seront pas collectées.", "xpack.apm.agentConfig.spanCompressionSameKindMaxDuration.label": "Durée maximale de compression d'intervalles de même genre", - "xpack.apm.agentConfig.spanFramesMinDuration.description": "(déclassé, utilisez `span_stack_trace_min_duration` à la place) Dans ses paramètres par défaut, l'agent APM collectera une trace de la pile avec chaque intervalle enregistré.\nBien qu'il soit très pratique de trouver l'endroit exact dans votre code qui provoque l'intervalle, la collecte de cette trace de la pile provoque une certaine surcharge. \nLorsque cette option est définie sur une valeur négative, telle que `-1ms`, les traces de pile sont collectées pour tous les intervalles. En choisissant une valeur positive, par ex. `5ms`, la collecte des traces de pile se limitera aux intervalles dont la durée est égale ou supérieure à la valeur donnée, c’est-à-dire 5 millisecondes.\n\nPour désactiver complètement la collecte des traces de pile des intervalles, réglez la valeur sur `0ms`.", + "xpack.apm.agentConfig.spanFramesMinDuration.description": "(déclassé, utilisez `span_stack_trace_min_duration` à la place) Dans ses paramètres par défaut, l'agent APM collectera une trace de la pile avec chaque intervalle enregistré. Bien qu'il soit très pratique de trouver l'endroit exact dans votre code qui provoque l'intervalle, la collecte de cette trace de la pile provoque une certaine surcharge. Lorsque cette option est définie sur une valeur négative, telle que `-1ms`, les traces de pile sont collectées pour tous les intervalles. En choisissant une valeur positive, par ex. `5ms`, la collecte des traces de pile se limitera aux intervalles dont la durée est égale ou supérieure à la valeur donnée, c’est-à-dire 5 millisecondes. Pour désactiver complètement la collecte des traces de pile des intervalles, réglez la valeur sur `0ms`.", "xpack.apm.agentConfig.spanFramesMinDuration.label": "Durée minimale des cadres des intervalles", - "xpack.apm.agentConfig.spanMinDuration.description": "Définit la durée minimale des intervalles. Une tentative visant à ignorer les intervalles qui s'exécutent plus rapidement que ce seuil peut avoir lieu.\n\nLa tentative échoue si elle mène à un intervalle qui ne peut pas être ignoré. Les intervalles qui propagent le contexte de trace aux services en aval, tels que les requêtes HTTP sortantes, ne peuvent pas être ignorés. De plus, les intervalles qui conduisent à une erreur ou qui peuvent être le parent d'une opération asynchrone ne peuvent pas être ignorés.\n\nCependant, les appels externes qui ne propagent pas le contexte, tels que les appels à une base de données, peuvent être ignorés à l'aide de ce seuil.", + "xpack.apm.agentConfig.spanMinDuration.description": "Définit la durée minimale des intervalles. Une tentative visant à ignorer les intervalles qui s'exécutent plus rapidement que ce seuil peut avoir lieu. La tentative échoue si elle mène à un intervalle qui ne peut pas être ignoré. Les intervalles qui propagent le contexte de trace aux services en aval, tels que les requêtes HTTP sortantes, ne peuvent pas être ignorés. De plus, les intervalles qui conduisent à une erreur ou qui peuvent être le parent d'une opération asynchrone ne peuvent pas être ignorés. Cependant, les appels externes qui ne propagent pas le contexte, tels que les appels à une base de données, peuvent être ignorés à l'aide de ce seuil.", "xpack.apm.agentConfig.spanMinDuration.label": "Durée minimale de l'intervalle", - "xpack.apm.agentConfig.spanStackTraceMinDuration.description": "Bien qu'il soit très pratique de trouver l'endroit exact dans votre code qui provoque l'intervalle, la collecte de cette trace de la pile provoque une certaine surcharge. Lorsque cette option est définie sur la valeur `0ms`, les traces de pile sont collectées pour tous les intervalles. En choisissant une valeur positive, par ex. `5ms`, la collecte des traces de pile se limitera aux intervalles dont la durée est égale ou supérieure à la valeur donnée, c’est-à-dire 5 millisecondes.\n\nPour désactiver complètement la collecte des traces de pile des intervalles, réglez la valeur sur `-1ms`.", + "xpack.apm.agentConfig.spanStackTraceMinDuration.description": "Bien qu'il soit très pratique de trouver l'endroit exact dans votre code qui provoque l'intervalle, la collecte de cette trace de la pile provoque une certaine surcharge. Lorsque cette option est définie sur la valeur `0ms`, les traces de pile sont collectées pour tous les intervalles. En choisissant une valeur positive, par ex. `5ms`, la collecte des traces de pile se limitera aux intervalles dont la durée est égale ou supérieure à la valeur donnée, c’est-à-dire 5 millisecondes. Pour désactiver complètement la collecte des traces de pile des intervalles, réglez la valeur sur `-1ms`.", "xpack.apm.agentConfig.spanStackTraceMinDuration.label": "Durée minimale de la trace de pile de l'intervalle", "xpack.apm.agentConfig.stackTraceLimit.description": "En définissant cette option sur 0, la collecte des traces de pile sera désactivée. Toute valeur entière positive sera utilisée comme nombre maximal de cadres à collecter. La valeur -1 signifie que tous les cadres seront collectés.", "xpack.apm.agentConfig.stackTraceLimit.label": "Limite de trace de pile", @@ -9969,24 +10772,24 @@ "xpack.apm.agentConfig.stressMonitorSystemCpuReliefThreshold.label": "Seuil d'allègement de la tension du monitoring du CPU système", "xpack.apm.agentConfig.stressMonitorSystemCpuStressThreshold.description": "Seuil utilisé par le monitoring du CPU du système pour détecter la tension du processeur du système. Si le CPU système dépasse ce seuil pour une durée d'au moins `stress_monitor_cpu_duration_threshold`, le monitoring considère qu'il est en état de tension.", "xpack.apm.agentConfig.stressMonitorSystemCpuStressThreshold.label": "Seuil de tension du monitoring du CPU système", - "xpack.apm.agentConfig.traceContinuationStrategy.description": "Cette option permet un certain contrôle sur la façon dont l'agent APM gère les en-tête de contexte de trace W3C dans les requêtes entrantes. Par défaut, les en-têtes `traceparent` et `tracestate` sont utilisés conformément aux spécifications W3C pour le traçage distribué. Cependant, dans certains cas, il peut être utile de ne pas utiliser l'en-tête `traceparent` entrant. Quelques exemples de cas d'utilisation :\n\n* Un service monitoré par Elastic reçoit des requêtes avec les en-têtes `traceparent` de services non monitorés.\n* Un service monitoré par Elastic est publiquement exposé, et ne souhaite pas que les données de traçage (ID de traçage, décisions d'échantillonnage) puissent être usurpées par des requêtes d'utilisateur.\n\nLes valeurs valides sont :\n* \"continue\" : comportement par défaut. Une valeur `traceparent` entrante est utilisée pour continuer le traçage et déterminer la décision d'échantillonnage.\n* \"restart\" : ignore toujours l'en-tête `traceparent` des requêtes entrantes. Un nouveau trace-id sera généré et la décision d'échantillonnage sera prise en fonction de transaction_sample_rate. Une liaison d'intervalle sera effectuée vers le `traceparent` entrant.\n* \"restart_external\" : Si une requête entrante inclut le drapeau de fournisseur `es` dans `tracestate`, tout `traceparent` sera considéré comme interne et sera géré comme décrit pour `continue` ci-dessus. Autrement, tout `traceparent` est considéré comme externe et sera géré comme décrit pour `restart` ci-dessus.\n\nDepuis Elastic Observability 8.2, les liens d'intervalle sont visibles dans les vues de trace.\n\nCette option ne respecte pas la casse.", + "xpack.apm.agentConfig.traceContinuationStrategy.description": "Cette option permet un certain contrôle sur la façon dont l'agent APM gère les en-tête de contexte de trace W3C dans les requêtes entrantes. Par défaut, les en-têtes `traceparent` et `tracestate` sont utilisés conformément aux spécifications W3C pour le traçage distribué. Cependant, dans certains cas, il peut être utile de ne pas utiliser l'en-tête `traceparent` entrant. Quelques exemples de cas d'utilisation : * Un service monitoré par Elastic reçoit des requêtes avec les en-têtes `traceparent` de services non monitorés. * Un service monitoré par Elastic est publiquement exposé, et ne souhaite pas que les données de traçage (ID de traçage, décisions d'échantillonnage) puissent être usurpées par des requêtes d'utilisateur. Les valeurs valides sont : * 'continue' : comportement par défaut. Une valeur `traceparent` entrante est utilisée pour continuer le traçage et déterminer la décision d'échantillonnage. * 'restart' : ignore toujours l'en-tête `traceparent` des requêtes entrantes. Un nouveau trace-id sera généré et la décision d'échantillonnage sera prise en fonction de transaction_sample_rate. Une liaison d'intervalle sera effectuée vers le `traceparent` entrant. * 'restart_external' : Si une requête entrante inclut le drapeau de fournisseur `es` dans `tracestate`, tout `traceparent` sera considéré comme interne et sera géré comme décrit pour `continue` ci-dessus. Autrement, tout `traceparent` est considéré comme externe et sera géré comme décrit pour `restart` ci-dessus. Depuis Elastic Observability 8.2, les liens d'intervalle sont visibles dans les vues de trace. Cette option ne respecte pas la casse.", "xpack.apm.agentConfig.traceContinuationStrategy.label": "Stratégie de poursuite de traçage", - "xpack.apm.agentConfig.traceMethods.description": "Liste de méthodes pour lesquelles une transaction ou un intervalle doivent être créés.\n\nSi vous souhaitez monitorer un nombre important de méthodes,\nutilisez `profiling_inferred_spans_enabled`.\n\nCela fonctionne en instrumentant chaque méthode correspondante pour inclure le code qui crée un intervalle pour la méthode.\nSi la création d'un intervalle est très économique en termes de performances,\nl'instrumentation de toute une base de codes ou d'une méthode qui est exécutée dans une boucle serrée entraîne une surcharge significative.\n\nREMARQUE : utilisez les caractères génériques uniquement si cela est nécessaire.\nPlus vous faites correspondre de méthodes, plus l'agent créera une surcharge.\nNotez également qu'il existe une quantité maximale d'intervalles par transaction, `transaction_max_spans`.\n\nPour en savoir plus, consultez la documentation de l'agent Java.", + "xpack.apm.agentConfig.traceMethods.description": "Liste de méthodes pour lesquelles une transaction ou un intervalle doivent être créés. Si vous souhaitez monitorer un nombre important de méthodes, utilisez `profiling_inferred_spans_enabled`. Cela fonctionne en instrumentant chaque méthode correspondante pour inclure le code qui crée un intervalle pour la méthode. Alors que la création d'un intervalle est très économique en termes de performances, l'instrumentation de toute une base de codes ou d'une méthode qui est exécutée dans une boucle serrée entraîne une surcharge significative. REMARQUE : utilisez les caractères génériques uniquement si cela est nécessaire. Plus vous faites correspondre de méthodes, plus l'agent créera une surcharge. Notez également qu'il existe une quantité maximale d'intervalles par transaction, `transaction_max_spans`. Pour en savoir plus, consultez la documentation de l'agent Java.", "xpack.apm.agentConfig.traceMethods.label": "Méthodes de traçage", "xpack.apm.agentConfig.transactionIgnoreUrl.description": "Utilisé pour limiter l'instrumentation des requêtes vers certaines URL. Cette configuration accepte une liste séparée par des virgules de modèles de caractères génériques de chemins d'URL qui doivent être ignorés. Lorsqu'une requête HTTP entrante sera détectée, son chemin de requête sera confronté à chaque élément figurant dans cette liste. Par exemple, l'ajout de `/home/index` à cette liste permettrait de faire correspondre et de supprimer l'instrumentation de `http://localhost/home/index` ainsi que de `http://whatever.com/home/index?value1=123`", "xpack.apm.agentConfig.transactionIgnoreUrl.label": "Ignorer les transactions basées sur les URL", - "xpack.apm.agentConfig.transactionIgnoreUserAgents.description": "Utilisé pour limiter l'instrumentation des requêtes de certains agents utilisateurs.\n\nLorsqu'une requête HTTP entrante est détectée,\nl'agent utilisateur des en-têtes de la requête sera testé avec chaque élément de cette liste.\nExemple : `curl/*`, `*pingdom*`", + "xpack.apm.agentConfig.transactionIgnoreUserAgents.description": "Utilisé pour limiter l'instrumentation des requêtes de certains agents utilisateurs. Lorsqu'une requête HTTP entrante est détectée, l'agent utilisateur des en-têtes de requête sera vérifié par rapport à chaque élément de cette liste. Exemple : `curl/*`, `*pingdom*`", "xpack.apm.agentConfig.transactionIgnoreUserAgents.label": "La transaction ignore les agents utilisateurs", "xpack.apm.agentConfig.transactionMaxSpans.description": "Limite la quantité d'intervalles enregistrés par transaction.", "xpack.apm.agentConfig.transactionMaxSpans.label": "Nb maxi d'intervalles de transaction", - "xpack.apm.agentConfig.transactionNameGroups.description": "Avec cette option,\nvous pouvez regrouper les noms de transaction contenant des parties dynamiques avec une expression de caractère générique.\nPar exemple,\nle modèle \"GET /user/*/cart\" consolide les transactions,\ntelles que \"GET /users/42/cart\" et \"GET /users/73/cart\", en un même nom de transaction, \"GET /users/*/cart\",\nréduisant ainsi la cardinalité du nom de transaction.", + "xpack.apm.agentConfig.transactionNameGroups.description": "Avec cette option, vous pouvez regrouper les noms de transaction contenant des parties dynamiques avec une expression de caractère générique. Par exemple, le modèle `GET /user/*/cart` consoliderait les transactions telles que `GET /users/42/cart` et `GET /users/73/cart` en un seul nom de transaction `GET /users/*/cart`, réduisant ainsi la cardinalité du nom de transaction.", "xpack.apm.agentConfig.transactionNameGroups.label": "Groupes de noms de transaction", "xpack.apm.agentConfig.transactionSampleRate.description": "Par défaut, l'agent échantillonnera chaque transaction (par ex. requête à votre service). Pour réduire la surcharge et les exigences de stockage, vous pouvez définir le taux d'échantillonnage sur une valeur comprise entre 0,0 et 1,0. La durée globale et le résultat des transactions non échantillonnées seront toujours enregistrés, mais pas les informations de contexte, les étiquettes ni les intervalles.", "xpack.apm.agentConfig.transactionSampleRate.label": "Taux d'échantillonnage des transactions", - "xpack.apm.agentConfig.unnestExceptions.description": "Lors du reporting d'exceptions,\ndésimbrique les exceptions correspondant au modèle de caractère générique.\nCela peut s'avérer pratique pour Spring avec \"org.springframework.web.util.NestedServletException\",\npar exemple.", + "xpack.apm.agentConfig.unnestExceptions.description": "Lors du reporting d'exceptions, désimbrique les exceptions correspondant au modèle de caractère générique. Par exemple, cela peut s'avérer pratique pour Spring avec `org.springframework.web.util.NestedServletException`.", "xpack.apm.agentConfig.unnestExceptions.label": "Désimbriquer les exceptions", "xpack.apm.agentConfig.unsavedSetting.tooltip": "Non enregistré", - "xpack.apm.agentConfig.usePathAsTransactionName.description": "Si `true` est défini,\nles noms de transaction de frameworks non pris en charge ou partiellement pris en charge seront au format `$method $path` au lieu de simplement `$method unknown route`.\n\nAVERTISSEMENT : si vos URL contiennent des paramètres de chemin tels que `/user/$userId`,\nsoyez très prudent en activant cet indicateur,\ncar cela peut entraîner une explosion de groupes de transactions.\nConsultez l'option `transaction_name_groups` pour savoir comment atténuer ce problème en regroupant les URL ensemble.", + "xpack.apm.agentConfig.usePathAsTransactionName.description": "Lorsque défini sur `true`, les noms de transaction de frameworks non pris en charge ou partiellement pris en charge seront au format `$method $path` au lieu de simplement `$method unknown route`. AVERTISSEMENT : Si vos URL contiennent des paramètres de chemin tels que `/user/$userId`, vous devez être très prudent lorsque vous activez cet indicateur, car cela peut entraîner une explosion des groupes de transactions. Consultez l'option `transaction_name_groups` pour savoir comment atténuer ce problème en regroupant les URL ensemble.", "xpack.apm.agentConfig.usePathAsTransactionName.label": "Utiliser le chemin comme nom de transaction", "xpack.apm.agentExplorer.agentLanguageSelect.label": "Langage de l'agent", "xpack.apm.agentExplorer.agentLanguageSelect.placeholder": "Tous", @@ -10054,13 +10857,14 @@ "xpack.apm.aiAssistant.starterPrompts.explainNoData.title": "Expliquer", "xpack.apm.alertDetails.error.toastDescription": "Impossible de charger les graphiques de la page de détails d’alerte. Veuillez essayer d’actualiser la page si l’alerte vient d’être créée", "xpack.apm.alertDetails.error.toastTitle": "Une erreur s’est produite lors de l’identification de la plage temporelle de l’alerte.", + "xpack.apm.alertDetails.thresholdTitle": "Seuil dépassé", "xpack.apm.alertDetails.viewInApm": "Afficher dans APM", "xpack.apm.alerting.fields.environment": "Environnement", "xpack.apm.alerting.fields.error.group.id": "Clé du groupe d'erreurs", "xpack.apm.alerting.fields.service": "Service", "xpack.apm.alerting.fields.transaction.name": "Nom", "xpack.apm.alerting.fields.type": "Type", - "xpack.apm.alerting.transaction.name.custom.text": "Ajouter \\{searchValue\\} en tant que nouveau nom de transaction", + "xpack.apm.alerting.transaction.name.custom.text": "Ajouter '{searchValue}' en tant que nouveau nom de transaction", "xpack.apm.alertingEmbeddables.serviceName.error.toastDescription": "Impossible de charger les visualisations d’APM.", "xpack.apm.alertingEmbeddables.serviceName.error.toastTitle": "Une erreur s'est produite lors de l'identification du nom du service APM ou du type de transaction.", "xpack.apm.alertingEmbeddables.timeRange.error.toastTitle": "Une erreur s’est produite lors de l’identification de la plage temporelle de l’alerte.", @@ -10094,20 +10898,20 @@ "xpack.apm.alerts.timeLabels.minutes": "minutes", "xpack.apm.alerts.timeLabels.seconds": "secondes", "xpack.apm.alertTypes.anomaly.description": "Alerte lorsque la latence, le rendement ou le taux de transactions ayant échoué d'un service est anormal.", - "xpack.apm.alertTypes.errorCount.defaultActionMessage": "'{{context.reason}}'\n\n'{{rule.name}}' est active selon les conditions suivantes :\n\n- Nom de service : '{{context.serviceName}}'\n- Environnement : '{{context.environment}}'\n- Nombre d’erreurs : '{{context.triggerValue}}' erreurs sur la dernière période de '{{context.interval}}'\n- Seuil : '{{context.threshold}}'\n\n[Afficher les détails de l’alerte]('{{context.alertDetailsUrl}}')\n", - "xpack.apm.alertTypes.errorCount.defaultRecoveryMessage": "'{{context.reason}}'\n\n'{{rule.name}}' s’est rétablie.\n\n- Nom de service : '{{context.serviceName}}'\n- Environnement : '{{context.environment}}'\n- Nombre d’erreurs : '{{context.triggerValue}}' erreurs sur la dernière période de '{{context.interval}}'\n- Seuil : '{{context.threshold}}'\n\n[Afficher les détails de l’alerte]('{{context.alertDetailsUrl}}')\n", + "xpack.apm.alertTypes.errorCount.defaultActionMessage": "'{{context.reason}}' '{{rule.name}}' est actif avec les conditions suivantes : - Nom du service : '{{context.serviceName}}' - Environnement : '{{context.environment}}' - Nombre d'erreurs : '{{context.triggerValue}}' erreurs au cours des dernières '{{context.interval}}' - Seuil : '{{context.threshold}}' [Afficher les détails de l'alerte]('{{context.alertDetailsUrl}}')", + "xpack.apm.alertTypes.errorCount.defaultRecoveryMessage": "'{{context.reason}}' '{{rule.name}}' s'est rétabli : - Nom du service : '{{context.serviceName}}' - Environnement : '{{context.environment}}' - Nombre d'erreurs : '{{context.triggerValue}}' erreurs au cours des dernières '{{context.interval}}' - Seuil : '{{context.threshold}}' [Afficher les détails de l'alerte]('{{context.alertDetailsUrl}}')", "xpack.apm.alertTypes.errorCount.description": "Alerte lorsque le nombre d'erreurs d'un service dépasse un seuil défini.", "xpack.apm.alertTypes.errorCount.reason": "Le nombre d'erreurs est {measured} au cours des derniers/dernières {interval} pour {group}. Alerte lorsque > {threshold}.", "xpack.apm.alertTypes.minimumWindowSize.description": "La valeur minimale requise est {sizeValue} {sizeUnit}. Elle permet de s'assurer que l'alerte comporte suffisamment de données à évaluer. Si vous choisissez une valeur trop basse, l'alerte ne se déclenchera peut-être pas comme prévu.", - "xpack.apm.alertTypes.transactionDuration.defaultActionMessage": "'{{context.reason}}'\n\n'{{rule.name}}' est active selon les conditions suivantes :\n\n- Nom de service : '{{context.serviceName}}'\n- Type de transaction : '{{context.transactionType}}'\n- Nom de transaction : '{{context.transactionName}}'\n- Environnement : '{{context.environment}}'\n- Latence : '{{context.triggerValue}}' sur la dernière période de '{{context.interval}}'\n- Seuil : '{{context.threshold}}' ms\n\n[Afficher les détails de l’alerte]('{{context.alertDetailsUrl}}')\n", - "xpack.apm.alertTypes.transactionDuration.defaultRecoveryMessage": "'{{context.reason}}'\n\n'{{rule.name}}' s’est rétablie.\n\n- Nom de service : '{{context.serviceName}}'\n- Type de transaction : '{{context.transactionType}}'\n- Nom de transaction : '{{context.transactionName}}'\n- Environnement : '{{context.environment}}'\n- Latence : '{{context.triggerValue}}' sur la dernière période de '{{context.interval}}'\n- Seuil : '{{context.threshold}}' ms\n\n[Afficher les détails de l’alerte]('{{context.alertDetailsUrl}}')\n", + "xpack.apm.alertTypes.transactionDuration.defaultActionMessage": "'{{context.reason}}' '{{rule.name}}' est actif avec les conditions suivantes : - Nom du service : '{{context.serviceName}}' - Type de transaction : '{{context.transactionType}}' - Nom de transaction : '{{context.transactionName}}' - Environnement : '{{context.environment}}' - Latence : '{{context.triggerValue}}' sur les dernières '{{context.interval}}' - Seuil : '{{context.threshold}}' ms [Afficher les détails de l'alerte]('{{context.alertDetailsUrl}}')", + "xpack.apm.alertTypes.transactionDuration.defaultRecoveryMessage": "'{{context.reason}}' '{{rule.name}}' s'est rétabli : - Nom du service : '{{context.serviceName}}' - Type de transaction : '{{context.transactionType}}' - Nom de transaction : '{{context.transactionName}}' - Environnement : '{{context.environment}}' - Latence : '{{context.triggerValue}}' sur les dernières '{{context.interval}}' - Seuil : '{{context.threshold}}' ms [Afficher les détails de l'alerte]('{{context.alertDetailsUrl}}')", "xpack.apm.alertTypes.transactionDuration.description": "Alerte lorsque la latence d'un type de transaction spécifique dans un service dépasse le seuil défini.", "xpack.apm.alertTypes.transactionDuration.reason": "La latence de {aggregationType} est {measured} au cours des derniers/dernières {interval} pour {group}. Alerte lorsque > {threshold}.", - "xpack.apm.alertTypes.transactionDurationAnomaly.defaultActionMessage": "'{{context.reason}}'\n\n'{{rule.name}}' est active selon les conditions suivantes :\n\n- Nom de service : '{{context.serviceName}}'\n- Type de transaction : '{{context.transactionType}}'\n- Environnement : '{{context.environment}}'\n- Sévérité : '{{context.triggerValue}}'\n- Seuil : '{{context.threshold}}'\n\n[Afficher les détails de l’alerte]('{{context.alertDetailsUrl}}')\n", - "xpack.apm.alertTypes.transactionDurationAnomaly.defaultRecoveryMessage": "'{{context.reason}}'\n\n'{{rule.name}}' s’est rétablie.\n\n- Nom de service : '{{context.serviceName}}'\n- Type de transaction : '{{context.transactionType}}'\n- Environnement : '{{context.environment}}'\n- Sévérité : '{{context.triggerValue}}'\n- Seuil : '{{context.threshold}}'\n\n[Afficher les détails de l’alerte]('{{context.alertDetailsUrl}}')\n", + "xpack.apm.alertTypes.transactionDurationAnomaly.defaultActionMessage": "'{{context.reason}}' '{{rule.name}}' est actif avec les conditions suivantes : - Nom du service : '{{context.serviceName}}' - Type de transaction : '{{context.transactionType}}' - Environnement : '{{context.environment}}' - Gravité : '{{context.triggerValue}}' - Seuil : '{{context.threshold}}' [Afficher les détails de l'alerte]('{{context.alertDetailsUrl}}')", + "xpack.apm.alertTypes.transactionDurationAnomaly.defaultRecoveryMessage": "'{{context.reason}}' '{{rule.name}}' s'est rétabli : - Nom du service : '{{context.serviceName}}' - Type de transaction : '{{context.transactionType}}' - Environnement : '{{context.environment}}' - Gravité : '{{context.triggerValue}}' - Seuil : '{{context.threshold}}' [Afficher les détails de l'alerte]('{{context.alertDetailsUrl}}')", "xpack.apm.alertTypes.transactionDurationAnomaly.reason": "Une anomalie {severityLevel} {detectorTypeLabel} avec un score de {anomalyScore} a été détectée dans le dernier {interval} pour {serviceName}.", - "xpack.apm.alertTypes.transactionErrorRate.defaultActionMessage": "'{{context.reason}}'\n\n'{{rule.name}}' est active selon les conditions suivantes :\n\n- Nom de service : '{{context.serviceName}}'\n- Type de transaction : '{{context.transactionType}}'\n- Environnement : '{{context.environment}}'\n- Taux de transactions ayant échoué : '{{context.triggerValue}}' % des erreurs sur la dernière période de '{{context.interval}}'\n- Seuil : '{{context.threshold}}'%\n\n[Afficher les détails de l’alerte]('{{context.alertDetailsUrl}}')\n", - "xpack.apm.alertTypes.transactionErrorRate.defaultRecoveryMessage": "'{{context.reason}}'\n\n'{{rule.name}}' s’est rétablie.\n\n- Nom de service : '{{context.serviceName}}'\n- Type de transaction : '{{context.transactionType}}'\n- Environnement : '{{context.environment}}'\n- Taux de transactions ayant échoué : '{{context.triggerValue}}' % des erreurs sur la dernière période de '{{context.interval}}'\n- Seuil : '{{context.threshold}}'%\n\n[Afficher les détails de l’alerte]('{{context.alertDetailsUrl}}')\n", + "xpack.apm.alertTypes.transactionErrorRate.defaultActionMessage": "'{{context.reason}}' '{{rule.name}}' est actif avec les conditions suivantes : - Nom du service : '{{context.serviceName}}' - Type de transaction : '{{context.transactionType}}' - Environnement : '{{context.environment}}' - Taux de transactions échouées : '{{context.triggerValue}}'% d'erreurs au cours des dernières '{{context.interval}}' - Seuil : '{{context.threshold}}'% [Afficher les détails de l'alerte]('{{context.alertDetailsUrl}}')", + "xpack.apm.alertTypes.transactionErrorRate.defaultRecoveryMessage": "'{{context.reason}}' '{{rule.name}}' s'est rétabli : - Nom du service : '{{context.serviceName}}' - Type de transaction : '{{context.transactionType}}' - Environnement : '{{context.environment}}' - Taux de transactions échouées : '{{context.triggerValue}}'% d'erreurs au cours des dernières '{{context.interval}}' - Seuil : '{{context.threshold}}'% [Afficher les détails de l'alerte]('{{context.alertDetailsUrl}}')", "xpack.apm.alertTypes.transactionErrorRate.description": "Alerte lorsque le taux d'erreurs de transaction d'un service dépasse un seuil défini.", "xpack.apm.alertTypes.transactionErrorRate.reason": "L'échec des transactions est {measured} au cours des derniers/dernières {interval} pour {group}. Alerte lorsque > {threshold}.", "xpack.apm.analyzeDataButton.label": "Explorer les données", @@ -10141,8 +10945,10 @@ "xpack.apm.apmSettings.save.error": "Une erreur s'est produite lors de l'enregistrement des paramètres", "xpack.apm.apmSettings.saveButton": "Enregistrer les modifications", "xpack.apm.appName": "APM", + "xpack.apm.associate.service.logs.button": "Associer les logs de service existants", "xpack.apm.betaBadgeDescription": "Cette fonctionnalité est actuellement en version bêta. Si vous rencontrez des bugs ou si vous souhaitez apporter des commentaires, ouvrez un ticket de problème ou visitez notre forum de discussion.", "xpack.apm.betaBadgeLabel": "Bêta", + "xpack.apm.button.exploreLogs": "Explorer les logs", "xpack.apm.chart.annotation.version": "Version", "xpack.apm.chart.comparison.defaultPreviousPeriodLabel": "Période précédente", "xpack.apm.chart.cpuSeries.processAverageLabel": "Moyenne de processus", @@ -10155,6 +10961,7 @@ "xpack.apm.clickArialLabel.": "Cliquez pour afficher plus de détails", "xpack.apm.coldstartRate": "Taux de démarrage à froid", "xpack.apm.coldstartRate.chart.coldstartRate": "Taux de démarrage à froid (moy.)", + "xpack.apm.collect.service.logs.button": "Collecter de nouveaux logs de service", "xpack.apm.comparison.expectedBoundsTitle": "Limites attendues", "xpack.apm.comparison.mlExpectedBoundsDisabledText": "Limites attendues (la détection d'anomalie doit être activée pour l’environnement)", "xpack.apm.comparison.mlExpectedBoundsText": "Limites attendues", @@ -10303,14 +11110,21 @@ "xpack.apm.durationDistributionChartWithScrubber.emptySelectionText": "Glisser et déposer pour sélectionner une plage", "xpack.apm.durationDistributionChartWithScrubber.panelTitle": "Distribution de la latence", "xpack.apm.durationDistributionChartWithScrubber.selectionText": "Selection : {formattedSelection}", + "xpack.apm.eemFeedback.button.openSurvey": "Dites-nous ce que vous pensez !", + "xpack.apm.eemFeedback.title": "Faites-nous part de vos réflexions !", "xpack.apm.emptyMessage.noDataFoundDescription": "Essayez avec une autre plage temporelle ou réinitialisez le filtre de recherche.", "xpack.apm.emptyMessage.noDataFoundLabel": "Aucune donnée trouvée.", - "xpack.apm.environmentsSelectCustomOptionText": "Ajouter \\{searchValue\\} en tant que nouvel environnement", + "xpack.apm.entitiesInventoryCallout.linklabel": "Essayez notre nouvel inventaire !", + "xpack.apm.entitiesInventoryCallout.linkTooltip": "Cacher ceci", + "xpack.apm.entityLink.eemGuide.description": "Désolé, nous ne pouvons pas vous donner plus de détails sur ce service pour le moment dû au motif suivant : {limitationsLink}.", + "xpack.apm.entityLink.eemGuide.description.link": "limitations du modèle d'entité d'Elastic", + "xpack.apm.entityLink.eemGuide.goBackButtonLabel": "Retour", + "xpack.apm.entityLink.eemGuide.title": "Service non pris en charge", "xpack.apm.environmentsSelectPlaceholder": "Sélectionner l'environnement", "xpack.apm.error.prompt.body": "Veuillez consulter la console de développeur de votre navigateur pour plus de détails.", "xpack.apm.error.prompt.title": "Désolé, une erreur s'est produite :(", "xpack.apm.errorCountAlert.name": "Seuil de nombre d'erreurs", - "xpack.apm.errorCountRuleType.errors": " erreurs", + "xpack.apm.errorCountRuleType.errors": "erreurs", "xpack.apm.errorGroup.chart.ocurrences": "Occurrences d'erreurs", "xpack.apm.errorGroup.tabs.exceptionStacktraceLabel": "Trace de pile d'exception", "xpack.apm.errorGroup.tabs.logStacktraceLabel": "Trace de pile des logs", @@ -10331,7 +11145,6 @@ "xpack.apm.errorGroupTopTransactions.loading": "Chargement...", "xpack.apm.errorGroupTopTransactions.noResults": "Aucune erreur trouvée associée à des transactions", "xpack.apm.errorGroupTopTransactions.title": "5 principales transactions affectées", - "xpack.apm.errorKeySelectCustomOptionText": "Ajouter \\{searchValue\\} comme nouvelle clé de groupe d'erreurs", "xpack.apm.errorOverview.treemap.dropdown.devices.subtitle": "Cet affichage sous forme de compartimentage permet de visualiser plus facilement et rapidement les appareils les plus affectés", "xpack.apm.errorOverview.treemap.dropdown.versions.subtitle": "Cet affichage sous forme de compartimentage permet de visualiser plus facilement et rapidement les versions les plus affectées.", "xpack.apm.errorOverview.treemap.subtitle": "Compartimentage {currentTreemap} affichant le nombre total et les plus affectés", @@ -10369,6 +11182,7 @@ "xpack.apm.failure_badge.tooltip": "event.outcome = échec", "xpack.apm.featureRegistry.apmFeatureName": "APM et expérience utilisateur", "xpack.apm.feedbackMenu.appName": "APM", + "xpack.apm.feedbackModal.body.thanks": "Merci d'avoir essayé notre nouvelle interface. Nous continuerons à l'améliorer, alors revenez souvent.", "xpack.apm.fetcher.error.status": "Erreur", "xpack.apm.fetcher.error.title": "Erreur lors de la récupération des ressources", "xpack.apm.fetcher.error.url": "URL", @@ -10531,7 +11345,7 @@ "xpack.apm.home.alertsMenu.alerts": "Alertes et règles", "xpack.apm.home.alertsMenu.createAnomalyAlert": "Créer une règle d'anomalie", "xpack.apm.home.alertsMenu.createThresholdAlert": "Créer une règle de seuil", - "xpack.apm.home.alertsMenu.errorCount": " Créer une règle de comptage des erreurs", + "xpack.apm.home.alertsMenu.errorCount": "Créer une règle de comptage des erreurs", "xpack.apm.home.alertsMenu.transactionDuration": "Latence", "xpack.apm.home.alertsMenu.transactionErrorRate": "Taux de transactions ayant échoué", "xpack.apm.home.alertsMenu.viewActiveAlerts": "Gérer les règles", @@ -10575,7 +11389,7 @@ "xpack.apm.jvmsTable.nonHeapMemoryColumnLabel": "Moy. segment de mémoire sans tas", "xpack.apm.jvmsTable.threadCountColumnLabel": "Nombre de threads max", "xpack.apm.keyValueFilterList.actionFilterLabel": "Filtrer par valeur", - "xpack.apm.kueryBar.placeholder": "Rechercher {event, select,\n transaction {des transactions}\n metric {des indicateurs}\n error {des erreurs}\n other {des transactions, des erreurs et des indicateurs}\n } (par ex. {queryExample})", + "xpack.apm.kueryBar.placeholder": "Rechercher {event, select, transaction {des transactions} metric {des indicateurs} error {des erreurs} other {des transactions, des erreurs et des indicateurs} } (p. ex. {queryExample})", "xpack.apm.labs": "Ateliers", "xpack.apm.labs.cancel": "Annuler", "xpack.apm.labs.description": "Essayez les fonctionnalités APM qui sont en version d'évaluation technique et en cours de progression.", @@ -10584,6 +11398,11 @@ "xpack.apm.latencyCorrelations.licenseCheckText": "Pour utiliser les corrélations de latence, vous devez disposer d'une licence Elastic Platinum. Elle vous permettra de découvrir quels champs sont corrélés à de faibles performances.", "xpack.apm.license.button": "Commencer l'essai", "xpack.apm.license.title": "Commencer un essai gratuit de 30 jours", + "xpack.apm.logErrorRate": "Taux d'erreur des logs", + "xpack.apm.logErrorRate.tooltip.description": "Taux de logs d'erreurs par minute observé pour un {serviceName} donné.", + "xpack.apm.logRate": "Taux du log", + "xpack.apm.logs.chart.logRate": "Taux de log", + "xpack.apm.logs.chart.logsErrorRate": "Taux d'erreur des logs", "xpack.apm.managedTable.errorMessage": "Impossible de récupérer", "xpack.apm.managedTable.loadingDescription": "Chargement…", "xpack.apm.metadata.help": "Comment ajouter des étiquettes et d'autres données", @@ -10644,19 +11463,24 @@ "xpack.apm.mobileServiceDetails.serviceMapTabLabel": "Carte des services", "xpack.apm.mobileServiceDetails.transactionsTabLabel": "Transactions", "xpack.apm.mobileServices.breadcrumb.title": "Services", + "xpack.apm.multiSignal.servicesTable.logErrorRate.tooltip.serviceNameLabel": "service.name", + "xpack.apm.multiSignal.servicesTable.logRate.tooltip.description": "Taux de logs par minute observé pour un {serviceName} donné.", + "xpack.apm.multiSignal.servicesTable.logRate.tooltip.serviceNameLabel": "service.name", + "xpack.apm.multiSignal.table.tooltip.formula": "Calcul de la formule :", "xpack.apm.navigation.apmSettingsTitle": "Paramètres", "xpack.apm.navigation.apmStorageExplorerTitle": "Explorateur de stockage", "xpack.apm.navigation.apmTutorialTitle": "Tutoriel", "xpack.apm.navigation.dependenciesTitle": "Dépendances", + "xpack.apm.navigation.rootTitle": "Applications", "xpack.apm.navigation.serviceGroupsTitle": "Groupes de services", "xpack.apm.navigation.serviceMapTitle": "Carte des services", - "xpack.apm.navigation.servicesTitle": "Services", + "xpack.apm.navigation.servicesTitle": "Inventaire de service", "xpack.apm.navigation.tracesTitle": "Traces", "xpack.apm.noDataConfig.addApmIntegrationButtonLabel": "Ajouter l'intégration APM", "xpack.apm.noDataConfig.addDataButtonLabel": "Ajouter des données", "xpack.apm.noDataConfig.solutionName": "Observabilité", "xpack.apm.notAvailableLabel": "S. O.", - "xpack.apm.observabilityAiAssistant.functions.registerGetApmDownstreamDependencies.descriptionForUser": "Obtenez les dépendances en aval (services ou back-ends non instrumentés) pour un \n service. Vous pouvez ainsi mapper le nom d'une dépendance en aval avec un service par le \n renvoi de span.destination.service.resource et de service.name. Utilisez cette fonction pour \n explorer plus avant si nécessaire.", + "xpack.apm.observabilityAiAssistant.functions.registerGetApmDownstreamDependencies.descriptionForUser": "Obtenez les dépendances en aval (services ou back-ends non instrumentés) pour un service. Vous pouvez ainsi mapper le nom d'une dépendance en aval avec un service par le renvoi de span.destination.service.resource et de service.name. Utilisez ceci pour explorer davantage, si nécessaire.", "xpack.apm.observabilityAiAssistant.functions.registerGetApmServicesList.descriptionForUser": "Obtenez la liste des services surveillés, leur statut d'intégrité et les alertes.", "xpack.apm.onboarding.agent.column.configSettings": "Paramètre de configuration", "xpack.apm.onboarding.agent.column.configValue": "Valeur de configuration", @@ -10689,12 +11513,12 @@ "xpack.apm.onboarding.django.install.title": "Installer l'agent APM", "xpack.apm.onboarding.djangoClient.configure.commands.addAgentComment": "Ajouter l'agent aux applications installées", "xpack.apm.onboarding.djangoClient.configure.commands.addTracingMiddlewareComment": "Ajoutez notre intergiciel de traçage pour envoyer des indicateurs de performance", - "xpack.apm.onboarding.dotNet.configureAgent.textPost": "Si vous ne transférez pas une instance `IConfiguration` à l'agent (par ex., pour les applications non ASP.NET Core) vous pouvez également configurer l'agent par le biais de variables d'environnement. \n Pour une utilisation avancée, consultez [the documentation]({documentationLink}), qui comprend notamment le guide de démarrage rapide pour [Profiler Auto instrumentation]({profilerLink}).", + "xpack.apm.onboarding.dotNet.configureAgent.textPost": "Si vous ne transférez pas une instance `IConfiguration` à l'agent (par ex., pour les applications non ASP.NET Core) vous pouvez également configurer l'agent par le biais de variables d'environnement. Pour une utilisation avancée, consultez [the documentation]({documentationLink}), qui comprend notamment le guide de démarrage rapide pour [Profiler Auto instrumentation]({profilerLink}).", "xpack.apm.onboarding.dotNet.configureAgent.title": "Exemple de fichier appsettings.json :", - "xpack.apm.onboarding.dotNet.configureApplication.textPost": "La transmission d'une instance `IConfiguration` est facultative mais si cette opération est effectuée, l'agent lira les paramètres de configuration depuis cette instance `IConfiguration` (par ex. à partir du fichier `appsettings.json`).", - "xpack.apm.onboarding.dotNet.configureApplication.textPre": "Si vous utilisez ASP.NET Core avec le package `Elastic.Apm.NetCoreAll`, appelez la méthode `UseAllElasticApm` dans la méthode \"Configure\" dans le fichier `Startup.cs`.", + "xpack.apm.onboarding.dotNet.configureApplication.textPost": "La transmission d'une instance `IConfiguration` est facultative mais si cette opération est effectuée, l'agent lira les paramètres de configuration depuis cette instance `IConfiguration` (par ex. à partir du fichier `appsettings.json`).", + "xpack.apm.onboarding.dotNet.configureApplication.textPre": "Si vous utilisez ASP.NET Core avec le package `Elastic.Apm.NetCoreAll`, appelez la méthode `UseAllElasticApm` dans la méthode `Configure` dans le fichier `Startup.cs`.", "xpack.apm.onboarding.dotNet.configureApplication.title": "Ajouter l'agent à l'application", - "xpack.apm.onboarding.dotNet.download.textPre": "Ajoutez le ou les packages d'agent depuis [NuGet]({allNuGetPackagesLink}) à votre application .NET. Plusieurs packages NuGet sont disponibles pour différents cas d'utilisation. \n\nPour une application ASP.NET Core avec Entity Framework Core, téléchargez le package [Elastic.Apm.NetCoreAll]({netCoreAllApmPackageLink}). Ce package ajoutera automatiquement chaque composant d'agent à votre application. \n\n Si vous souhaitez minimiser les dépendances, vous pouvez utiliser le package [Elastic.Apm.AspNetCore]({aspNetCorePackageLink}) uniquement pour le monitoring d'ASP.NET Core ou le package [Elastic.Apm.EfCore]({efCorePackageLink}) uniquement pour le monitoring d'Entity Framework Core. \n\n Si vous souhaitez seulement utiliser l'API d'agent publique pour l'instrumentation manuelle, utilisez le package [Elastic.Apm]({elasticApmPackageLink}).", + "xpack.apm.onboarding.dotNet.download.textPre": "Ajoutez le ou les packages d'agent depuis [NuGet]({allNuGetPackagesLink}) à votre application .NET. Plusieurs packages NuGet sont disponibles pour différents cas d'utilisation. Pour une application ASP.NET Core avec Entity Framework Core, téléchargez le package [Elastic.Apm.NetCoreAll]({netCoreAllApmPackageLink}). Ce package ajoutera automatiquement chaque composant d'agent à votre application. Si vous souhaitez minimiser les dépendances, vous pouvez utiliser le package [Elastic.Apm.AspNetCore]({aspNetCorePackageLink}) uniquement pour le monitoring d'ASP.NET Core ou le package [Elastic.Apm.EfCore]({efCorePackageLink}) uniquement pour le monitoring d'Entity Framework Core. Si vous souhaitez seulement utiliser l'API d'agent publique pour l'instrumentation manuelle, utilisez le package [Elastic.Apm]({elasticApmPackageLink}).", "xpack.apm.onboarding.dotNet.download.title": "Télécharger l'agent APM", "xpack.apm.onboarding.dotnetClient.createConfig.commands.defaultServiceName": "La valeur par défaut est l'assemblage d'entrée de l'application.", "xpack.apm.onboarding.flask.configure.textPost": "Consultez la [documentation]({documentationLink}) pour une utilisation avancée.", @@ -10717,26 +11541,26 @@ "xpack.apm.onboarding.goClient.configure.commands.initializeUsingEnvironmentVariablesComment": "Initialisez à l'aide des variables d'environnement :", "xpack.apm.onboarding.goClient.configure.commands.usedExecutableNameComment": "En l'absence de spécification, le nom de l'exécutable est utilisé.", "xpack.apm.onboarding.introduction.imageAltDescription": "Capture d'écran du tableau de bord principal.", - "xpack.apm.onboarding.java.download.textPre": "Téléchargez le fichier jar de l'agent depuis [Maven Central]({mavenCentralLink}). N'ajoutez **pas** l'agent comme dépendance de votre application.", + "xpack.apm.onboarding.java.download.textPre": "Téléchargez le fichier jar de l'agent depuis [Maven Central]({mavenCentralLink}). N'ajoutez **pas** l'agent comme dépendance de votre application.", "xpack.apm.onboarding.java.download.title": "Télécharger l'agent APM", "xpack.apm.onboarding.java.startApplication.textPost": "Consultez la [documentation]({documentationLink}) pour découvrir les options de configuration et l'utilisation avancée.", - "xpack.apm.onboarding.java.startApplication.textPre": "Ajoutez l'indicateur `-javaagent` et configurez l'agent avec les propriétés du système.\n\n * Définir le nom de service requis (caractères autorisés : a-z, A-Z, 0-9, -, _ et espace)\n * Définir l'URL personnalisée du serveur APM (par défaut : {customApmServerUrl})\n * Définir le token secret du serveur APM\n * Définir l'environnement de service\n * Définir le package de base de votre application", + "xpack.apm.onboarding.java.startApplication.textPre": "Ajoutez l'indicateur `-javaagent` et configurez l'agent avec les propriétés du système. * Définir le nom de service requis (caractères autorisés : a-z, A-Z, 0-9, -, _ et espace) * * Définir l'URL personnalisée du serveur APM (par défaut : {customApmServerUrl}) * Définir le jeton secret du serveur APM * Définir l'environnement de service * Définir le package de base de votre application", "xpack.apm.onboarding.java.startApplication.title": "Lancer votre application avec l'indicateur javaagent", "xpack.apm.onboarding.node.configure.textPost": "Consultez [the documentation]({documentationLink}) pour une utilisation avancée, notamment pour connaître l'utilisation avec [Babel/ES Modules]({babelEsModulesLink}).", - "xpack.apm.onboarding.node.configure.textPre": "Les agents sont des bibliothèques exécutées dans les processus de votre application. Les services APM sont créés par programmation à partir du `serviceName`. Cet agent prend en charge de nombreux frameworks, mais peut également être utilisé avec votre pile personnalisée.", + "xpack.apm.onboarding.node.configure.textPre": "Les agents sont des bibliothèques exécutées dans les processus de votre application. Les services APM sont créés par programmation à partir du `serviceName`. Cet agent prend en charge de nombreux frameworks, mais peut également être utilisé avec votre pile personnalisée.", "xpack.apm.onboarding.node.configure.title": "Configurer l'agent", "xpack.apm.onboarding.node.install.textPre": "Installez l'agent APM pour Node.js comme dépendance de votre application.", "xpack.apm.onboarding.node.install.title": "Installer l'agent APM", "xpack.apm.onboarding.nodeClient.configure.commands.addThisToTheFileTopComment": "Ajoutez ceci tout en haut du premier fichier chargé dans votre application", "xpack.apm.onboarding.nodeClient.createConfig.commands.serviceName": "Remplace le nom de service dans package.json.", "xpack.apm.onboarding.otel.column.value.copyIconText": "Copier dans le presse-papiers", - "xpack.apm.onboarding.otel.configureAgent.textPost": "Consultez la [documentation]({documentationLink}) pour découvrir les options de configuration et l'utilisation avancée.\n\n", + "xpack.apm.onboarding.otel.configureAgent.textPost": "Consultez la [documentation]({documentationLink}) pour découvrir les options de configuration et l'utilisation avancée.", "xpack.apm.onboarding.otel.configureAgent.textPre": "Spécifiez les paramètres OpenTelemetry suivants dans le cadre du démarrage de votre application. Notez que les SDK OpenTelemetry exigent du code de démarrage en plus de ces paramètres de configuration. Pour plus de détails, consultez la [documentation OpenTelemetry Elastic]({openTelemetryDocumentationLink}) et les [guides d'instrumentation OpenTelemetry]({openTelemetryInstrumentationLink}).", "xpack.apm.onboarding.otel.configureAgent.title": "Configurer OpenTelemetry dans votre application", "xpack.apm.onboarding.otel.download.textPre": "Consultez les [guides d'instrumentation OpenTelemetry]({openTelemetryInstrumentationLink}) pour télécharger l'agent ou le SDK OpenTelemetry pour votre langage.", "xpack.apm.onboarding.otel.download.title": "Télécharger l’agent APM OpenTelemetry ou le SDK", "xpack.apm.onboarding.php.Configure the agent.textPre": "APM se lance automatiquement au démarrage de l'application. Configurez l'agent soit depuis le fichier `php.ini` :", - "xpack.apm.onboarding.php.configureAgent.textPost": "Consultez la [documentation]({documentationLink}) pour découvrir les options de configuration et l'utilisation avancée.\n\n", + "xpack.apm.onboarding.php.configureAgent.textPost": "Consultez la [documentation]({documentationLink}) pour découvrir les options de configuration et l'utilisation avancée.", "xpack.apm.onboarding.php.configureAgent.title": "Configurer l'agent", "xpack.apm.onboarding.php.download.textPre": "Téléchargez le package correspondant à votre plateforme depuis [GitHub releases]({githubReleasesLink}).", "xpack.apm.onboarding.php.download.title": "Télécharger l'agent APM", @@ -10745,7 +11569,7 @@ "xpack.apm.onboarding.php.installPackage.title": "Installer le package téléchargé", "xpack.apm.onboarding.rack.configure.commands.optionalComment": "facultatif, la valeur par défaut est config/elastic_apm.yml", "xpack.apm.onboarding.rack.configure.commands.requiredComment": "requis", - "xpack.apm.onboarding.rack.configure.textPost": "Consultez la [documentation]({documentationLink}) pour découvrir les options de configuration et l'utilisation avancée.\n\n", + "xpack.apm.onboarding.rack.configure.textPost": "Consultez la [documentation]({documentationLink}) pour découvrir les options de configuration et l'utilisation avancée.", "xpack.apm.onboarding.rack.configure.textPre": "Pour Rack, ou un framework compatible (par ex., Sinatra), intégrez l'intergiciel à votre application et démarrez l'agent.", "xpack.apm.onboarding.rack.configure.title": "Configurer l'agent", "xpack.apm.onboarding.rack.createConfig.textPre": "Créez un fichier config {configFile} :", @@ -10753,7 +11577,7 @@ "xpack.apm.onboarding.rack.install.textPre": "Ajoutez l'agent à votre Gemfile.", "xpack.apm.onboarding.rack.install.title": "Installer l'agent APM", "xpack.apm.onboarding.rackClient.createConfig.commands.defaultsToTheNameOfRackAppClassComment": "La valeur par défaut est le nom de la classe de votre application Rack.", - "xpack.apm.onboarding.rails.configure.textPost": "Consultez la [documentation]({documentationLink}) pour découvrir les options de configuration et l'utilisation avancée.\n\n", + "xpack.apm.onboarding.rails.configure.textPost": "Consultez la [documentation]({documentationLink}) pour découvrir les options de configuration et l'utilisation avancée.", "xpack.apm.onboarding.rails.configure.textPre": "APM se lance automatiquement au démarrage de l'application. Configurer l'agent, en créant le fichier config {configFile}", "xpack.apm.onboarding.rails.configure.title": "Configurer l'agent", "xpack.apm.onboarding.rails.install.textPre": "Ajoutez l'agent à votre Gemfile.", @@ -10764,6 +11588,8 @@ "xpack.apm.onboarding.shared_clients.configure.commands.serverUrlHint": "Définir l'URL personnalisée du serveur APM (par défaut : {defaultApmServerUrl}). L'URL doit être complète et inclure le protocole (http ou https) et le port.", "xpack.apm.onboarding.shared_clients.configure.commands.serviceEnvironmentHint": "Le nom de l'environnement dans lequel ce service est déployé, par exemple \"production\" ou \"test\". Les environnements vous permettent de facilement filtrer les données à un niveau global dans l'interface utilisateur APM. Il est important de garantir la cohérence des noms d'environnements entre les différents agents.", "xpack.apm.onboarding.shared_clients.configure.commands.serviceNameHint": "Le nom de service est le filtre principal dans l'interface utilisateur APM et est utilisé pour regrouper les erreurs et suivre les données ensemble. Caractères autorisés : a-z, A-Z, 0-9, -, _ et espace.", + "xpack.apm.onboarding.specProvider.learnMoreAriaLabel": "En savoir plus sur APM", + "xpack.apm.onboarding.specProvider.learnMoreLabel": "En savoir plus", "xpack.apm.onboarding.specProvider.longDescription": "Le monitoring des performances applicatives (APM) collecte les indicateurs et les erreurs de performance approfondies depuis votre application. Cela vous permet de monitorer les performances de milliers d'applications en temps réel. {learnMoreLink}.", "xpack.apm.percentOfParent": "({value} de {parentType, select, transaction { transaction } trace {trace} other {parentType inconnu} })", "xpack.apm.profiling.callout.description": "Universal Profiling fournit une visibilité sans précédent du code au milieu du comportement en cours d'exécution de toutes les applications. La fonctionnalité profile chaque ligne de code chez le ou les hôtes qui exécutent vos services, y compris votre code applicatif, le kernel et même les bibliothèque tierces.", @@ -10898,6 +11724,7 @@ "xpack.apm.serviceGroups.groupDetailsForm.description.optional": "Facultatif", "xpack.apm.serviceGroups.groupDetailsForm.edit.title": "Modifier un groupe", "xpack.apm.serviceGroups.groupDetailsForm.invalidColorError": "Veuillez fournir une valeur HEX de couleur valide", + "xpack.apm.serviceGroups.groupDetailsForm.invalidNameError": "Veuillez fournir une valeur de nom valide", "xpack.apm.serviceGroups.groupDetailsForm.name": "Nom", "xpack.apm.serviceGroups.groupDetailsForm.selectServices": "Sélectionner des services", "xpack.apm.serviceGroups.groupsCount": "{servicesCount} {servicesCount, plural, =0 {groupe} one {groupe} other {groupes}}", @@ -10937,13 +11764,13 @@ "xpack.apm.serviceIcons.serverless": "Sans serveur", "xpack.apm.serviceIcons.service": "Service", "xpack.apm.serviceIcons.serviceDetails.cloud.architecture": "Architecture", - "xpack.apm.serviceIcons.serviceDetails.cloud.availabilityZoneLabel": "{zones, plural, =0 {Zone de disponibilité} one {Zone de disponibilité} other {Zones de disponibilité}} ", - "xpack.apm.serviceIcons.serviceDetails.cloud.faasTriggerTypeLabel": "{triggerTypes, plural, =0 {Type de déclencheur} one {Type de déclencheur} other {Types de déclencheurs}} ", - "xpack.apm.serviceIcons.serviceDetails.cloud.functionNameLabel": "{functionNames, plural, =0 {Nom de fonction} one {Nom de fonction} other {Noms de fonction}} ", - "xpack.apm.serviceIcons.serviceDetails.cloud.machineTypesLabel": "{machineTypes, plural, =0{Type de machine} one {Type de machine} other {Types de machines}} ", + "xpack.apm.serviceIcons.serviceDetails.cloud.availabilityZoneLabel": "{zones, plural, =0 {Zone de disponibilité} one {Zone de disponibilité} other {Zones de disponibilité}}", + "xpack.apm.serviceIcons.serviceDetails.cloud.faasTriggerTypeLabel": "{triggerTypes, plural, =0 {Type de déclencheur} one {Type de déclencheur} other {Types de déclencheurs}}", + "xpack.apm.serviceIcons.serviceDetails.cloud.functionNameLabel": "{functionNames, plural, =0 {Nom de fonction} one {Nom de fonction} other {Noms de fonction}}", + "xpack.apm.serviceIcons.serviceDetails.cloud.machineTypesLabel": "{machineTypes, plural, =0{Type de machine} one {Type de machine} other {Types de machines}}", "xpack.apm.serviceIcons.serviceDetails.cloud.projectIdLabel": "ID de projet", "xpack.apm.serviceIcons.serviceDetails.cloud.providerLabel": "Fournisseur cloud", - "xpack.apm.serviceIcons.serviceDetails.cloud.regionLabel": "{regions, plural, =0 {Region} one {Région} other {Régions}} ", + "xpack.apm.serviceIcons.serviceDetails.cloud.regionLabel": "{regions, plural, =0 {Region} one {Région} other {Régions}}", "xpack.apm.serviceIcons.serviceDetails.cloud.serviceNameLabel": "Service Cloud", "xpack.apm.serviceIcons.serviceDetails.container.image.name": "Images de conteneurs", "xpack.apm.serviceIcons.serviceDetails.container.os.label": "Système d'exploitation", @@ -10994,7 +11821,6 @@ "xpack.apm.serviceMap.zoomIn": "Zoom avant", "xpack.apm.serviceMap.zoomOut": "Zoom arrière", "xpack.apm.serviceMetrics.loading": "Chargement des indicateurs", - "xpack.apm.serviceNamesSelectCustomOptionText": "Ajouter \\{searchValue\\} en tant que nouveau nom de service", "xpack.apm.serviceNamesSelectPlaceholder": "Sélectionner le nom du service", "xpack.apm.serviceNodeMetrics.containerId": "ID de conteneur", "xpack.apm.serviceNodeMetrics.host": "Hôte", @@ -11087,10 +11913,24 @@ "xpack.apm.servicesTable.tooltip.metricsExplanation": "Les indicateurs du services sont agrégés sur le type de transaction, qui peut être une requête ou un chargement de page. Si ni l'un ni l'autre n'existent, les indicateurs sont agrégés sur le type de transaction disponible en premier.", "xpack.apm.servicesTable.transactionColumnLabel": "Type de transaction", "xpack.apm.servicesTable.transactionErrorRate": "Taux de transactions ayant échoué", + "xpack.apm.serviceTabEmptyState.dependenciesContent": "Visualisez les dépendances de votre service sur les services internes et tiers en instrumentant avec APM.", + "xpack.apm.serviceTabEmptyState.dependenciesTitle": "Comprendre les dépendances de votre service", + "xpack.apm.serviceTabEmptyState.errorGroupOverviewContent": "Analysez les erreurs jusqu'à la transaction spécifique pour identifier les erreurs spécifiques au sein de votre service.", + "xpack.apm.serviceTabEmptyState.errorGroupOverviewTitle": "Identifier les erreurs de transaction liées à vos applications", + "xpack.apm.serviceTabEmptyState.infrastructureContent": "Résolvez les problèmes de service en visualisant l'infrastructure sur laquelle votre service s'exécute.", + "xpack.apm.serviceTabEmptyState.infrastructureTitle": "Comprendre sur quoi s'exécute votre service", + "xpack.apm.serviceTabEmptyState.metricsContent": "Collectez des indicateurs tels que l'utilisation du processeur et de la mémoire pour identifier les goulots d'étranglement des performances qui pourraient affecter vos utilisateurs.", + "xpack.apm.serviceTabEmptyState.metricsTitle": "Afficher les indicateurs clés de votre application", + "xpack.apm.serviceTabEmptyState.overviewContent": "Comprendre les performances, les relations et les dépendances de vos applications en instrumentant avec APM.", + "xpack.apm.serviceTabEmptyState.overviewTitle": "Détectez et résolvez les problèmes plus rapidement grâce à une meilleure visibilité sur votre application", + "xpack.apm.serviceTabEmptyState.serviceMapContent": "Consultez en un coup d'œil les dépendances de vos services pour vous aider à identifier celles susceptibles d'affecter votre service.", + "xpack.apm.serviceTabEmptyState.serviceMapTitle": "Visualiser les dépendances reliant vos services", + "xpack.apm.serviceTabEmptyState.transactionsContent": "Résolvez les problèmes liés aux performances de votre service en analysant la latence, le débit et les erreurs jusqu'à la transaction spécifique.", + "xpack.apm.serviceTabEmptyState.transactionsTitle": "Résoudre les problèmes liés à la latence, au débit et aux erreurs", "xpack.apm.settings.agentConfig": "Configuration de l'agent", "xpack.apm.settings.agentConfig.createConfigButton.tooltip": "Vous ne disposez pas d'autorisations pour créer des configurations d'agent", "xpack.apm.settings.agentConfig.descriptionText": "Affinez votre configuration d'agent depuis l'application APM. Les modifications sont automatiquement propagées à vos agents APM, ce qui vous évite d'effectuer un redéploiement.", - "xpack.apm.settings.agentConfiguration.all.option.calloutTitle": "Ce changement de configuration aura un impact sur tous les services, à l'exception de ceux qui utilisent un agent OpenTelemetry. ", + "xpack.apm.settings.agentConfiguration.all.option.calloutTitle": "Ce changement de configuration aura un impact sur tous les services, à l'exception de ceux qui utilisent un agent OpenTelemetry.", "xpack.apm.settings.agentConfiguration.service.otel.error": "Les services sélectionnés utilisent un agent OpenTelemetry, qui n'est pas pris en charge", "xpack.apm.settings.agentExplorer": "Explorateur d'agent", "xpack.apm.settings.agentExplorer.descriptionText": "L'explorateur d'agent fournit un inventaire des agents déployés et des détails les concernant.", @@ -11125,6 +11965,7 @@ "xpack.apm.settings.agentKeys.emptyPromptTitle": "Créer votre première clé", "xpack.apm.settings.agentKeys.invalidate.failed": "Erreur lors de la suppression de la clé de l'agent APM \"{name}\"", "xpack.apm.settings.agentKeys.invalidate.succeeded": "Suppression de la clé de l'agent APM \"{name}\"", + "xpack.apm.settings.agentKeys.noPermissionCreateAgentKeyTooltipLabel": "Votre rôle d'utilisateur ne dispose pas d'autorisations pour créer des clés d'agent.", "xpack.apm.settings.agentKeys.noPermissionToManagelApiKeysDescription": "Contactez votre administrateur système", "xpack.apm.settings.agentKeys.noPermissionToManagelApiKeysTitle": "Vous devez disposer d'une autorisation pour gérer les clés d'API", "xpack.apm.settings.agentKeys.table.creationColumnName": "Créé", @@ -11157,6 +11998,7 @@ "xpack.apm.settings.anomalyDetection.jobList.mlDescriptionText": "Pour ajouter la détection des anomalies à un nouvel environnement, créez une tâche de Machine Learning. Vous pouvez gérer les tâches de Machine Learning existantes dans {mlJobsLink}.", "xpack.apm.settings.anomalyDetection.jobList.mlDescriptionText.mlJobsLinkText": "Machine Learning", "xpack.apm.settings.anomalyDetection.jobList.mlJobLinkText": "Gérer la tâche", + "xpack.apm.settings.anomalyDetection.jobList.noPermissionAddEnvironmentsTooltipLabel": "Votre rôle d'utilisateur ne dispose pas d'autorisations pour créer des tâches", "xpack.apm.settings.anomalyDetection.jobList.okStatusLabel": "OK", "xpack.apm.settings.anomalyDetection.jobList.openAnomalyExplorerrLinkText": "Ouvrir dans Anomaly Explorer", "xpack.apm.settings.anomalyDetection.jobList.showLegacyJobsCheckboxText": "Afficher les tâches héritées", @@ -11364,6 +12206,8 @@ "xpack.apm.storageExplorer.table.samplingColumnName": "Taux d’échantillonnage", "xpack.apm.storageExplorer.table.serviceColumnName": "Service", "xpack.apm.storageExplorerLinkLabel": "Explorateur de stockage", + "xpack.apm.subFeatureRegistry.modifySettings": "Possibilité de modifier les paramètres", + "xpack.apm.subFeatureRegistry.settings": "Paramètres", "xpack.apm.technicalPreviewBadgeDescription": "Cette fonctionnalité est en version d'évaluation technique et pourra être modifiée ou retirée complètement dans une future version. Elastic s'efforcera de corriger tout problème, mais les fonctionnalités des versions d'évaluation technique ne sont pas soumises aux SLA de support des fonctionnalités officielles en disponibilité générale.", "xpack.apm.technicalPreviewBadgeLabel": "Version d'évaluation technique", "xpack.apm.timeComparison.label": "Comparaison", @@ -11377,6 +12221,7 @@ "xpack.apm.traceExplorer.appName": "APM", "xpack.apm.traceExplorer.criticalPathTab": "Chemin critique agrégé", "xpack.apm.traceExplorer.waterfallTab": "Cascade", + "xpack.apm.traceLink.fetchingTraceLabel": "Récupération des traces...", "xpack.apm.traceOverview.topTracesTab": "Premières traces", "xpack.apm.traceOverview.traceExplorerTab": "Explorer", "xpack.apm.traceSearchBox.refreshButton": "Recherche", @@ -11508,7 +12353,6 @@ "xpack.apm.transactionsTable.tableSearch.placeholder": "Rechercher des transactions par nom", "xpack.apm.transactionsTable.title": "Transactions", "xpack.apm.transactionsTableColumnName.alertsColumnLabel": "Alertes actives", - "xpack.apm.transactionTypesSelectCustomOptionText": "Ajouter \\{searchValue\\} en tant que nouveau type de transaction", "xpack.apm.transactionTypesSelectPlaceholder": "Sélectionner le type de transaction", "xpack.apm.tryItButton.euiButtonIcon.admin": "Veuillez référer à votre administrateur pour rendre cette fonctionnalité {featureEnabled}.", "xpack.apm.tryItButton.euiButtonIcon.admin.off": "désactivé", @@ -11530,7 +12374,7 @@ "xpack.apm.tutorial.apmAgents.statusCheck.text": "Vérifiez que votre application est en cours d'exécution et que les agents envoient les données.", "xpack.apm.tutorial.apmAgents.statusCheck.title": "Statut de l'agent", "xpack.apm.tutorial.apmAgents.title": "Agents APM", - "xpack.apm.tutorial.apmServer.callOut.message": "Assurez-vous de mettre à jour votre serveur APM vers la version 7.0 ou supérieure. Vous pouvez également migrer vos données 6.x à l'aide de l'assistant de migration disponible dans la section de gestion de Kibana.", + "xpack.apm.tutorial.apmServer.callOut.message": "Assurez-vous de mettre à jour votre serveur APM vers la version 7.0 ou supérieure. Vous pouvez également migrer vos données 6.x à l'aide de l'assistant de migration disponible dans la section de gestion de Kibana.", "xpack.apm.tutorial.apmServer.callOut.title": "Important : mise à niveau vers la version 7.0 ou supérieure", "xpack.apm.tutorial.apmServer.fleet.apmIntegration.button": "Intégration APM", "xpack.apm.tutorial.apmServer.fleet.apmIntegration.description": "Fleet vous permet de gérer de manière centralisée les agents Elastic qui exécutent l'intégration APM. L'option par défaut consiste à installer un serveur Fleet sur un hôte dédié. Pour les configurations sans hôte dédié, nous vous recommandons de suivre les instructions pour installer le serveur APM autonome pour votre système d'exploitation en sélectionnant l'onglet correspondant ci-dessus.", @@ -11558,13 +12402,13 @@ "xpack.apm.tutorial.djangoClient.configure.title": "Configurer l'agent", "xpack.apm.tutorial.djangoClient.install.textPre": "Installez l'agent APM pour Python en tant que dépendance.", "xpack.apm.tutorial.djangoClient.install.title": "Installer l'agent APM", - "xpack.apm.tutorial.dotNetClient.configureAgent.textPost": "Si vous ne transférez pas une instance `IConfiguration` à l'agent (par ex., pour les applications non ASP.NET Core) vous pouvez également configurer l'agent par le biais de variables d'environnement. \n Pour une utilisation avancée, consultez [the documentation]({documentationLink}), qui comprend notamment le guide de démarrage rapide pour [Profiler Auto instrumentation]({profilerLink}).", + "xpack.apm.tutorial.dotNetClient.configureAgent.textPost": "Si vous ne transférez pas une instance `IConfiguration` à l'agent (par ex., pour les applications non ASP.NET Core) vous pouvez également configurer l'agent par le biais de variables d'environnement. Pour une utilisation avancée, consultez [the documentation]({documentationLink}), qui comprend notamment le guide de démarrage rapide pour [Profiler Auto instrumentation]({profilerLink}).", "xpack.apm.tutorial.dotNetClient.configureAgent.title": "Exemple de fichier appsettings.json :", - "xpack.apm.tutorial.dotNetClient.configureApplication.textPost": "La transmission d'une instance `IConfiguration` est facultative mais si cette opération est effectuée, l'agent lira les paramètres de configuration depuis cette instance `IConfiguration` (par ex. à partir du fichier `appsettings.json`).", - "xpack.apm.tutorial.dotNetClient.configureApplication.textPre": "Si vous utilisez ASP.NET Core avec le package `Elastic.Apm.NetCoreAll`, appelez la méthode `UseAllElasticApm` dans la méthode \"Configure\" dans le fichier `Startup.cs`.", + "xpack.apm.tutorial.dotNetClient.configureApplication.textPost": "La transmission d'une instance `IConfiguration` est facultative mais si cette opération est effectuée, l'agent lira les paramètres de configuration depuis cette instance `IConfiguration` (par ex. à partir du fichier `appsettings.json`).", + "xpack.apm.tutorial.dotNetClient.configureApplication.textPre": "Si vous utilisez ASP.NET Core avec le package `Elastic.Apm.NetCoreAll`, appelez la méthode `UseAllElasticApm` dans la méthode `Configure` dans le fichier `Startup.cs`.", "xpack.apm.tutorial.dotNetClient.configureApplication.title": "Ajouter l'agent à l'application", "xpack.apm.tutorial.dotnetClient.createConfig.commands.defaultServiceName": "La valeur par défaut est l'assemblage d'entrée de l'application.", - "xpack.apm.tutorial.dotNetClient.download.textPre": "Ajoutez le ou les packages d'agent depuis [NuGet]({allNuGetPackagesLink}) à votre application .NET. Plusieurs packages NuGet sont disponibles pour différents cas d'utilisation. \n\nPour une application ASP.NET Core avec Entity Framework Core, téléchargez le package [Elastic.Apm.NetCoreAll]({netCoreAllApmPackageLink}). Ce package ajoutera automatiquement chaque composant d'agent à votre application. \n\n Si vous souhaitez minimiser les dépendances, vous pouvez utiliser le package [Elastic.Apm.AspNetCore]({aspNetCorePackageLink}) uniquement pour le monitoring d'ASP.NET Core ou le package [Elastic.Apm.EfCore]({efCorePackageLink}) uniquement pour le monitoring d'Entity Framework Core. \n\n Si vous souhaitez seulement utiliser l'API d'agent publique pour l'instrumentation manuelle, utilisez le package [Elastic.Apm]({elasticApmPackageLink}).", + "xpack.apm.tutorial.dotNetClient.download.textPre": "Ajoutez le ou les packages d'agent depuis [NuGet]({allNuGetPackagesLink}) à votre application .NET. Plusieurs packages NuGet sont disponibles pour différents cas d'utilisation. Pour une application ASP.NET Core avec Entity Framework Core, téléchargez le package [Elastic.Apm.NetCoreAll]({netCoreAllApmPackageLink}). Ce package ajoutera automatiquement chaque composant d'agent à votre application. Si vous souhaitez minimiser les dépendances, vous pouvez utiliser le package [Elastic.Apm.AspNetCore]({aspNetCorePackageLink}) uniquement pour le monitoring d'ASP.NET Core ou le package [Elastic.Apm.EfCore]({efCorePackageLink}) uniquement pour le monitoring d'Entity Framework Core. Si vous souhaitez seulement utiliser l'API d'agent publique pour l'instrumentation manuelle, utilisez le package [Elastic.Apm]({elasticApmPackageLink}).", "xpack.apm.tutorial.dotNetClient.download.title": "Télécharger l'agent APM", "xpack.apm.tutorial.downloadServer.title": "Télécharger et décompresser le serveur APM", "xpack.apm.tutorial.downloadServerRpm": "Vous cherchez les packages aarch64 ? Consultez la [Download page]({downloadPageLink}).", @@ -11595,13 +12439,13 @@ "xpack.apm.tutorial.javaClient.download.textPre": "Téléchargez le fichier jar de l'agent depuis [Maven Central]({mavenCentralLink}). N'ajoutez **pas** l'agent comme dépendance de votre application.", "xpack.apm.tutorial.javaClient.download.title": "Télécharger l'agent APM", "xpack.apm.tutorial.javaClient.startApplication.textPost": "Consultez la [documentation]({documentationLink}) pour découvrir les options de configuration et l'utilisation avancée.", - "xpack.apm.tutorial.javaClient.startApplication.textPre": "Ajoutez l'indicateur `-javaagent` et configurez l'agent avec les propriétés du système.\n\n * Définir le nom de service requis (caractères autorisés : a-z, A-Z, 0-9, -, _ et espace)\n * Définir l'URL personnalisée du serveur APM (par défaut : {customApmServerUrl})\n * Définir le token secret du serveur APM\n * Définir l'environnement de service\n * Définir le package de base de votre application", + "xpack.apm.tutorial.javaClient.startApplication.textPre": "Ajoutez l'indicateur `-javaagent` et configurez l'agent avec les propriétés du système. * Définir le nom de service requis (caractères autorisés : a-z, A-Z, 0-9, -, _ et espace) * * Définir l'URL personnalisée du serveur APM (par défaut : {customApmServerUrl}) * Définir le jeton secret du serveur APM * Définir l'environnement de service * Définir le package de base de votre application", "xpack.apm.tutorial.javaClient.startApplication.title": "Lancer votre application avec l'indicateur javaagent", "xpack.apm.tutorial.jsClient.enableRealUserMonitoring.textPre": "Le serveur APM désactive la prise en charge du RUM par défaut. Consultez la [documentation]({documentationLink}) pour obtenir des détails sur l'activation de la prise en charge du RUM. Lorsque vous utilisez l'intégration APM avec Fleet, le support RUM est automatiquement activé.", "xpack.apm.tutorial.jsClient.enableRealUserMonitoring.title": "Activer la prise en charge du Real User Monitoring (monitoring des utilisateurs réels) dans le serveur APM", "xpack.apm.tutorial.jsClient.installDependency.commands.setServiceVersionComment": "Définir la version de service (requis pour la fonctionnalité source map)", "xpack.apm.tutorial.jsClient.installDependency.textPost": "Les intégrations de framework, tel que React ou Angular, ont des dépendances personnalisées. Consultez la [integration documentation]({docLink}) pour plus d'informations.", - "xpack.apm.tutorial.jsClient.installDependency.textPre": "Vous pouvez installer l'Agent comme dépendance de votre application avec `npm install @elastic/apm-rum --save`.\n\nVous pouvez ensuite initialiser l'agent et le configurer dans votre application de cette façon :", + "xpack.apm.tutorial.jsClient.installDependency.textPre": "Vous pouvez installer l'Agent comme dépendance de votre application avec `npm install @elastic/apm-rum --save`. Vous pouvez ensuite initialiser l'agent et le configurer dans votre application de cette façon :", "xpack.apm.tutorial.jsClient.installDependency.title": "Configurer l'agent comme dépendance", "xpack.apm.tutorial.jsClient.scriptTags.textPre": "Vous pouvez également utiliser les balises Script pour configurer l'agent. Ajoutez un indicateur `