diff --git a/.buildkite/ftr_oblt_serverless_configs.yml b/.buildkite/ftr_oblt_serverless_configs.yml index fbf0406f37be4..75909e7c21c46 100644 --- a/.buildkite/ftr_oblt_serverless_configs.yml +++ b/.buildkite/ftr_oblt_serverless_configs.yml @@ -6,6 +6,10 @@ disabled: - x-pack/test_serverless/functional/test_suites/observability/cypress/config_headless.ts - x-pack/test_serverless/functional/test_suites/observability/cypress/config_runner.ts + # serverless config files that run deployment-agnostic tests + # Failing https://github.com/elastic/kibana/issues/195811 + - x-pack/test/api_integration/deployment_agnostic/configs/serverless/oblt.serverless.config.ts + defaultQueue: 'n2-4-spot' enabled: - x-pack/test_serverless/api_integration/test_suites/observability/config.ts @@ -25,5 +29,3 @@ enabled: - x-pack/test_serverless/functional/test_suites/observability/common_configs/config.group5.ts - x-pack/test_serverless/functional/test_suites/observability/common_configs/config.group6.ts - x-pack/test_serverless/functional/test_suites/observability/config.screenshots.ts - # serverless config files that run deployment-agnostic tests - - x-pack/test/api_integration/deployment_agnostic/configs/serverless/oblt.serverless.config.ts diff --git a/.buildkite/ftr_search_serverless_configs.yml b/.buildkite/ftr_search_serverless_configs.yml index e6efee5860806..413558bffa0fe 100644 --- a/.buildkite/ftr_search_serverless_configs.yml +++ b/.buildkite/ftr_search_serverless_configs.yml @@ -1,6 +1,10 @@ disabled: # Base config files, only necessary to inform config finding script + # serverless config files that run deployment-agnostic tests + # Failing https://github.com/elastic/kibana/issues/195811 + - x-pack/test/api_integration/deployment_agnostic/configs/serverless/search.serverless.config.ts + defaultQueue: 'n2-4-spot' enabled: - x-pack/test_serverless/api_integration/test_suites/search/config.ts @@ -18,5 +22,3 @@ enabled: - x-pack/test_serverless/functional/test_suites/search/common_configs/config.group4.ts - x-pack/test_serverless/functional/test_suites/search/common_configs/config.group5.ts - x-pack/test_serverless/functional/test_suites/search/common_configs/config.group6.ts - # serverless config files that run deployment-agnostic tests - - x-pack/test/api_integration/deployment_agnostic/configs/serverless/search.serverless.config.ts diff --git a/.buildkite/ftr_security_serverless_configs.yml b/.buildkite/ftr_security_serverless_configs.yml index 6d42c030b2d4f..caf9fcc5ac92a 100644 --- a/.buildkite/ftr_security_serverless_configs.yml +++ b/.buildkite/ftr_security_serverless_configs.yml @@ -20,6 +20,10 @@ disabled: - x-pack/test_serverless/functional/config.base.ts - x-pack/test_serverless/shared/config.base.ts + # serverless config files that run deployment-agnostic tests + # Failing https://github.com/elastic/kibana/issues/195811 + - x-pack/test/api_integration/deployment_agnostic/configs/serverless/security.serverless.config.ts + defaultQueue: 'n2-4-spot' enabled: - x-pack/test_serverless/api_integration/test_suites/security/config.ts @@ -100,5 +104,3 @@ enabled: - x-pack/test/security_solution_api_integration/test_suites/edr_workflows/response_actions/trial_license_complete_tier/configs/serverless.config.ts - x-pack/test/security_solution_endpoint/configs/serverless.endpoint.config.ts - x-pack/test/security_solution_endpoint/configs/serverless.integrations.config.ts - # serverless config files that run deployment-agnostic tests - - x-pack/test/api_integration/deployment_agnostic/configs/serverless/security.serverless.config.ts diff --git a/.buildkite/pipeline-resource-definitions/kibana-artifacts-container-image.yml b/.buildkite/pipeline-resource-definitions/kibana-artifacts-container-image.yml index 37bc5ee59ff0b..eb86f8d7aab2a 100644 --- a/.buildkite/pipeline-resource-definitions/kibana-artifacts-container-image.yml +++ b/.buildkite/pipeline-resource-definitions/kibana-artifacts-container-image.yml @@ -44,3 +44,4 @@ spec: access_level: MANAGE_BUILD_AND_READ tags: - kibana + - kibana-serverless-release diff --git a/.buildkite/pipeline-resource-definitions/kibana-es-serverless-snapshots.yml b/.buildkite/pipeline-resource-definitions/kibana-es-serverless-snapshots.yml index 684e2e07fb187..6ba182ccd393e 100644 --- a/.buildkite/pipeline-resource-definitions/kibana-es-serverless-snapshots.yml +++ b/.buildkite/pipeline-resource-definitions/kibana-es-serverless-snapshots.yml @@ -55,3 +55,4 @@ spec: branch: main tags: - kibana + - kibana-serverless-release diff --git a/.buildkite/pipeline-resource-definitions/kibana-es-snapshots.yml b/.buildkite/pipeline-resource-definitions/kibana-es-snapshots.yml index 851862a613111..d386542fbdf0c 100644 --- a/.buildkite/pipeline-resource-definitions/kibana-es-snapshots.yml +++ b/.buildkite/pipeline-resource-definitions/kibana-es-snapshots.yml @@ -46,19 +46,19 @@ spec: access_level: MANAGE_BUILD_AND_READ schedules: Daily build (main): - cronline: 0 9 * * * America/New_York + cronline: 0 22 * * * America/New_York message: Daily build branch: main Daily build (8.x): - cronline: 0 9 * * * America/New_York + cronline: 0 22 * * * America/New_York message: Daily build branch: '8.x' Daily build (8.15): - cronline: 0 9 * * * America/New_York + cronline: 0 22 * * * America/New_York message: Daily build branch: '8.15' Daily build (7.17): - cronline: 0 9 * * * America/New_York + cronline: 0 22 * * * America/New_York message: Daily build branch: '7.17' tags: diff --git a/.buildkite/pipeline-resource-definitions/kibana-on-merge.yml b/.buildkite/pipeline-resource-definitions/kibana-on-merge.yml index 5e6622e6da513..e524adc786c0e 100644 --- a/.buildkite/pipeline-resource-definitions/kibana-on-merge.yml +++ b/.buildkite/pipeline-resource-definitions/kibana-on-merge.yml @@ -49,3 +49,4 @@ spec: access_level: MANAGE_BUILD_AND_READ tags: - kibana + - kibana-serverless-release diff --git a/.buildkite/pipeline-resource-definitions/kibana-serverless-emergency-release.yml b/.buildkite/pipeline-resource-definitions/kibana-serverless-emergency-release.yml index c51e44432596d..62b05bc49dae6 100644 --- a/.buildkite/pipeline-resource-definitions/kibana-serverless-emergency-release.yml +++ b/.buildkite/pipeline-resource-definitions/kibana-serverless-emergency-release.yml @@ -30,3 +30,4 @@ spec: access_level: READ_ONLY tags: - kibana + - kibana-serverless-release diff --git a/.buildkite/pipeline-resource-definitions/kibana-serverless-quality-gates-emergency.yml b/.buildkite/pipeline-resource-definitions/kibana-serverless-quality-gates-emergency.yml index 267db48ba6d90..ef04fd324b31a 100644 --- a/.buildkite/pipeline-resource-definitions/kibana-serverless-quality-gates-emergency.yml +++ b/.buildkite/pipeline-resource-definitions/kibana-serverless-quality-gates-emergency.yml @@ -33,3 +33,4 @@ spec: access_level: READ_ONLY tags: - kibana + - kibana-serverless-release diff --git a/.buildkite/pipeline-resource-definitions/kibana-serverless-quality-gates.yml b/.buildkite/pipeline-resource-definitions/kibana-serverless-quality-gates.yml index 8d4e7f35cd6fe..e9ea3d02b8968 100644 --- a/.buildkite/pipeline-resource-definitions/kibana-serverless-quality-gates.yml +++ b/.buildkite/pipeline-resource-definitions/kibana-serverless-quality-gates.yml @@ -33,3 +33,4 @@ spec: access_level: READ_ONLY tags: - kibana + - kibana-serverless-release diff --git a/.buildkite/pipeline-resource-definitions/kibana-serverless-release-testing.yml b/.buildkite/pipeline-resource-definitions/kibana-serverless-release-testing.yml index 0a033e72d53b8..5276871fa1c9f 100644 --- a/.buildkite/pipeline-resource-definitions/kibana-serverless-release-testing.yml +++ b/.buildkite/pipeline-resource-definitions/kibana-serverless-release-testing.yml @@ -46,3 +46,4 @@ spec: access_level: MANAGE_BUILD_AND_READ tags: - kibana + - kibana-serverless-release diff --git a/.buildkite/pipeline-resource-definitions/kibana-serverless-release.yml b/.buildkite/pipeline-resource-definitions/kibana-serverless-release.yml index 7a35ea3ad1ec8..e1457f10420f7 100644 --- a/.buildkite/pipeline-resource-definitions/kibana-serverless-release.yml +++ b/.buildkite/pipeline-resource-definitions/kibana-serverless-release.yml @@ -48,3 +48,4 @@ spec: branch: main tags: - kibana + - kibana-serverless-release diff --git a/.buildkite/pull_requests.json b/.buildkite/pull_requests.json index 1f45c01042888..614d45969cdd7 100644 --- a/.buildkite/pull_requests.json +++ b/.buildkite/pull_requests.json @@ -30,7 +30,8 @@ "^\\.backportrc\\.json$", "^nav-kibana-dev\\.docnav\\.json$", "^src/dev/prs/kibana_qa_pr_list\\.json$", - "^\\.buildkite/pull_requests\\.json$" + "^\\.buildkite/pull_requests\\.json$", + "^\\.devcontainer/" ], "always_require_ci_on_changed": [ "^docs/developer/plugin-list.asciidoc$", diff --git a/.buildkite/scripts/steps/cloud/build_and_deploy.sh b/.buildkite/scripts/steps/cloud/build_and_deploy.sh index 25e7d8fc631c9..220ab497aaf7b 100755 --- a/.buildkite/scripts/steps/cloud/build_and_deploy.sh +++ b/.buildkite/scripts/steps/cloud/build_and_deploy.sh @@ -51,7 +51,7 @@ fi if is_pr_with_label "ci:cloud-redeploy"; then echo "--- Shutdown Previous Deployment" CLOUD_DEPLOYMENT_ID=$(ecctl deployment list --output json | jq -r '.deployments[] | select(.name == "'$CLOUD_DEPLOYMENT_NAME'") | .id') - if [ -z "${CLOUD_DEPLOYMENT_ID}" ]; then + if [ -z "${CLOUD_DEPLOYMENT_ID}" ] || [ "${CLOUD_DEPLOYMENT_ID}" == "null" ]; then echo "No deployment to remove" else echo "Shutting down previous deployment..." diff --git a/.buildkite/scripts/steps/es_snapshots/promote.sh b/.buildkite/scripts/steps/es_snapshots/promote.sh index cf52f5e9ff650..5654d7bd3b8d3 100755 --- a/.buildkite/scripts/steps/es_snapshots/promote.sh +++ b/.buildkite/scripts/steps/es_snapshots/promote.sh @@ -16,4 +16,12 @@ ts-node "$(dirname "${0}")/promote_manifest.ts" "$ES_SNAPSHOT_MANIFEST" if [[ "$BUILDKITE_BRANCH" == "main" ]]; then echo "--- Trigger agent packer cache pipeline" ts-node .buildkite/scripts/steps/trigger_pipeline.ts kibana-agent-packer-cache main + cat << EOF | buildkite-agent pipeline upload +steps: + - label: "Builds Kibana VM images for cache update" + trigger: ci-vm-images + build: + env: + IMAGES_CONFIG="kibana/images.yml" +EOF fi diff --git a/.buildkite/scripts/steps/serverless/deploy.sh b/.buildkite/scripts/steps/serverless/deploy.sh index d30723393dacd..cbbc6c6c664dd 100644 --- a/.buildkite/scripts/steps/serverless/deploy.sh +++ b/.buildkite/scripts/steps/serverless/deploy.sh @@ -56,7 +56,7 @@ deploy() { PROJECT_ID=$(jq -r '[.items[] | select(.name == "'$PROJECT_NAME'")] | .[0].id' $PROJECT_EXISTS_LOGS) if is_pr_with_label "ci:project-redeploy"; then - if [ -z "${PROJECT_ID}" ]; then + if [ -z "${PROJECT_ID}" ] || [ "${PROJECT_ID}" == "null" ]; then echo "No project to remove" else echo "Shutting down previous project..." diff --git a/.eslintrc.js b/.eslintrc.js index 797b84522df3f..c604844089ef4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -978,6 +978,7 @@ module.exports = { files: [ 'x-pack/plugins/observability_solution/**/!(*.stories.tsx|*.test.tsx|*.storybook_decorator.tsx|*.mock.tsx)', 'src/plugins/ai_assistant_management/**/!(*.stories.tsx|*.test.tsx|*.storybook_decorator.tsx|*.mock.tsx)', + 'x-pack/packages/observability/logs_overview/**/!(*.stories.tsx|*.test.tsx|*.storybook_decorator.tsx|*.mock.tsx)', ], rules: { '@kbn/i18n/strings_should_be_translated_with_i18n': 'warn', diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9b3c46d065fe1..10496d5351ef6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -6,11 +6,11 @@ #### x-pack/test/alerting_api_integration/common/plugins/aad @elastic/response-ops -packages/kbn-ace @elastic/kibana-management 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 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 @@ -652,6 +652,7 @@ 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 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d07f60cf09253..737eedabadfa0 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -36,4 +36,6 @@ When forming the risk matrix, consider some of the following examples and how th ### For maintainers -- [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) +- [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#_add_your_labels) +- [ ] This will appear in the **Release Notes** and follow the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) + diff --git a/.github/updatecli/values.d/ironbank.yml b/.github/updatecli/values.d/ironbank.yml new file mode 100644 index 0000000000000..fd1134eda376a --- /dev/null +++ b/.github/updatecli/values.d/ironbank.yml @@ -0,0 +1,2 @@ +config: + - path: src/dev/build/tasks/os_packages/docker_generator/templates/ironbank \ No newline at end of file diff --git a/.github/updatecli/values.d/scm.yml b/.github/updatecli/values.d/scm.yml new file mode 100644 index 0000000000000..34d902fb389d5 --- /dev/null +++ b/.github/updatecli/values.d/scm.yml @@ -0,0 +1,11 @@ +scm: + enabled: true + owner: elastic + repository: kibana + branch: main + commitusingapi: true + # begin updatecli-compose policy values + user: kibanamachine + email: 42973632+kibanamachine@users.noreply.github.com + # end updatecli-compose policy values + diff --git a/.github/updatecli/values.d/updatecli-compose.yml b/.github/updatecli/values.d/updatecli-compose.yml new file mode 100644 index 0000000000000..02df609f2a30c --- /dev/null +++ b/.github/updatecli/values.d/updatecli-compose.yml @@ -0,0 +1,3 @@ +spec: + files: + - "updatecli-compose.yaml" \ No newline at end of file diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e16dbcb261807..e80b3b2c73463 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -73,7 +73,9 @@ jobs: env: GITHUB_TOKEN: ${{secrets.KIBANAMACHINE_TOKEN}} SLACK_TOKEN: ${{secrets.CODE_SCANNING_SLACK_TOKEN}} - CODEQL_BRANCHES: 7.17,8.x,main + CODE_SCANNING_ES_HOST: ${{secrets.CODE_SCANNING_ES_HOST}} + CODE_SCANNING_ES_API_KEY: ${{secrets.CODE_SCANNING_ES_API_KEY}} + CODE_SCANNING_BRANCHES: 7.17,8.x,main run: | npm ci --omit=dev node codeql-alert diff --git a/.github/workflows/oblt-github-commands.yml b/.github/workflows/oblt-github-commands.yml index 443c0fa5f9071..48df40f3343d9 100644 --- a/.github/workflows/oblt-github-commands.yml +++ b/.github/workflows/oblt-github-commands.yml @@ -14,6 +14,7 @@ on: permissions: contents: read + issues: write pull-requests: write jobs: diff --git a/.github/workflows/updatecli-compose.yml b/.github/workflows/updatecli-compose.yml new file mode 100644 index 0000000000000..cbab42d3a63b1 --- /dev/null +++ b/.github/workflows/updatecli-compose.yml @@ -0,0 +1,38 @@ +--- +name: updatecli-compose + +on: + workflow_dispatch: + schedule: + - cron: '0 6 * * *' + +permissions: + contents: read + +jobs: + compose: + runs-on: ubuntu-latest + permissions: + contents: write + packages: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + + - uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446 # v3.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: elastic/oblt-actions/updatecli/run@v1 + with: + command: --experimental compose diff + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - uses: elastic/oblt-actions/updatecli/run@v1 + with: + command: --experimental compose apply + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/NOTICE.txt b/NOTICE.txt index 3cee52c089cb4..bdd6a95e57b04 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -68,68 +68,6 @@ Author Tobias Koppers @sokra --- This product has relied on ASTExplorer that is licensed under MIT. ---- -This product includes code that is based on Ace editor, which was available -under a "BSD" license. - -Distributed under the BSD license: - -Copyright (c) 2010, Ajax.org B.V. -All rights reserved. - - Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - * Neither the name of Ajax.org B.V. nor the - names of its contributors may be used to endorse or promote products - derived from this software without specific prior written permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY -DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - ---- -This product includes code that is based on Ace editor, which was available -under a "BSD" license. - -Distributed under the BSD license: - -Copyright (c) 2010, Ajax.org B.V. -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - * Neither the name of Ajax.org B.V. nor the - names of its contributors may be used to endorse or promote products - derived from this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY -DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - --- This product includes code that is based on flot-charts, which was available under a "MIT" license. diff --git a/api_docs/kbn_ace.devdocs.json b/api_docs/kbn_ace.devdocs.json deleted file mode 100644 index 31b9c39264e4d..0000000000000 --- a/api_docs/kbn_ace.devdocs.json +++ /dev/null @@ -1,210 +0,0 @@ -{ - "id": "@kbn/ace", - "client": { - "classes": [], - "functions": [], - "interfaces": [], - "enums": [], - "misc": [], - "objects": [] - }, - "server": { - "classes": [], - "functions": [], - "interfaces": [], - "enums": [], - "misc": [], - "objects": [] - }, - "common": { - "classes": [], - "functions": [ - { - "parentPluginId": "@kbn/ace", - "id": "def-common.addToRules", - "type": "Function", - "tags": [], - "label": "addToRules", - "description": [], - "signature": [ - "(otherRules: any, embedUnder: any) => void" - ], - "path": "packages/kbn-ace/src/ace/modes/lexer_rules/x_json_highlight_rules.ts", - "deprecated": false, - "trackAdoption": false, - "children": [ - { - "parentPluginId": "@kbn/ace", - "id": "def-common.addToRules.$1", - "type": "Any", - "tags": [], - "label": "otherRules", - "description": [], - "signature": [ - "any" - ], - "path": "packages/kbn-ace/src/ace/modes/lexer_rules/x_json_highlight_rules.ts", - "deprecated": false, - "trackAdoption": false, - "isRequired": true - }, - { - "parentPluginId": "@kbn/ace", - "id": "def-common.addToRules.$2", - "type": "Any", - "tags": [], - "label": "embedUnder", - "description": [], - "signature": [ - "any" - ], - "path": "packages/kbn-ace/src/ace/modes/lexer_rules/x_json_highlight_rules.ts", - "deprecated": false, - "trackAdoption": false, - "isRequired": true - } - ], - "returnComment": [], - "initialIsOpen": false - }, - { - "parentPluginId": "@kbn/ace", - "id": "def-common.ElasticsearchSqlHighlightRules", - "type": "Function", - "tags": [], - "label": "ElasticsearchSqlHighlightRules", - "description": [], - "signature": [ - "(this: any) => void" - ], - "path": "packages/kbn-ace/src/ace/modes/lexer_rules/elasticsearch_sql_highlight_rules.ts", - "deprecated": false, - "trackAdoption": false, - "returnComment": [], - "children": [], - "initialIsOpen": false - }, - { - "parentPluginId": "@kbn/ace", - "id": "def-common.installXJsonMode", - "type": "Function", - "tags": [], - "label": "installXJsonMode", - "description": [], - "signature": [ - "(editor: ", - "Editor", - ") => void" - ], - "path": "packages/kbn-ace/src/ace/modes/x_json/x_json.ts", - "deprecated": false, - "trackAdoption": false, - "children": [ - { - "parentPluginId": "@kbn/ace", - "id": "def-common.installXJsonMode.$1", - "type": "Object", - "tags": [], - "label": "editor", - "description": [], - "signature": [ - "Editor" - ], - "path": "packages/kbn-ace/src/ace/modes/x_json/x_json.ts", - "deprecated": false, - "trackAdoption": false, - "isRequired": true - } - ], - "returnComment": [], - "initialIsOpen": false - }, - { - "parentPluginId": "@kbn/ace", - "id": "def-common.ScriptHighlightRules", - "type": "Function", - "tags": [], - "label": "ScriptHighlightRules", - "description": [], - "signature": [ - "(this: any) => void" - ], - "path": "packages/kbn-ace/src/ace/modes/lexer_rules/script_highlight_rules.ts", - "deprecated": false, - "trackAdoption": false, - "children": [ - { - "parentPluginId": "@kbn/ace", - "id": "def-common.ScriptHighlightRules.$1", - "type": "Any", - "tags": [], - "label": "this", - "description": [], - "signature": [ - "any" - ], - "path": "packages/kbn-ace/src/ace/modes/lexer_rules/script_highlight_rules.ts", - "deprecated": false, - "trackAdoption": false, - "isRequired": true - } - ], - "returnComment": [], - "initialIsOpen": false - }, - { - "parentPluginId": "@kbn/ace", - "id": "def-common.XJsonHighlightRules", - "type": "Function", - "tags": [], - "label": "XJsonHighlightRules", - "description": [], - "signature": [ - "(this: any) => void" - ], - "path": "packages/kbn-ace/src/ace/modes/lexer_rules/x_json_highlight_rules.ts", - "deprecated": false, - "trackAdoption": false, - "children": [ - { - "parentPluginId": "@kbn/ace", - "id": "def-common.XJsonHighlightRules.$1", - "type": "Any", - "tags": [], - "label": "this", - "description": [], - "signature": [ - "any" - ], - "path": "packages/kbn-ace/src/ace/modes/lexer_rules/x_json_highlight_rules.ts", - "deprecated": false, - "trackAdoption": false, - "isRequired": true - } - ], - "returnComment": [], - "initialIsOpen": false - } - ], - "interfaces": [], - "enums": [], - "misc": [ - { - "parentPluginId": "@kbn/ace", - "id": "def-common.XJsonMode", - "type": "Any", - "tags": [], - "label": "XJsonMode", - "description": [], - "signature": [ - "any" - ], - "path": "packages/kbn-ace/src/ace/modes/x_json/x_json.ts", - "deprecated": false, - "trackAdoption": false, - "initialIsOpen": false - } - ], - "objects": [] - } -} \ No newline at end of file diff --git a/api_docs/kbn_ace.mdx b/api_docs/kbn_ace.mdx deleted file mode 100644 index 64aba3c6788e8..0000000000000 --- a/api_docs/kbn_ace.mdx +++ /dev/null @@ -1,33 +0,0 @@ ---- -#### -#### This document is auto-generated and is meant to be viewed inside our experimental, new docs system. -#### Reach out in #docs-engineering for more info. -#### -id: kibKbnAcePluginApi -slug: /kibana-dev-docs/api/kbn-ace -title: "@kbn/ace" -image: https://source.unsplash.com/400x175/?github -description: API docs for the @kbn/ace plugin -date: 2024-10-09 -tags: ['contributor', 'dev', 'apidocs', 'kibana', '@kbn/ace'] ---- -import kbnAceObj from './kbn_ace.devdocs.json'; - - - -Contact [@elastic/kibana-management](https://github.com/orgs/elastic/teams/kibana-management) for questions regarding this plugin. - -**Code health stats** - -| Public API count | Any count | Items lacking comments | Missing exports | -|-------------------|-----------|------------------------|-----------------| -| 11 | 5 | 11 | 0 | - -## Common - -### Functions - - -### Consts, variables and types - - diff --git a/api_docs/plugin_directory.mdx b/api_docs/plugin_directory.mdx index a5a2307c4d6db..959b02632bf07 100644 --- a/api_docs/plugin_directory.mdx +++ b/api_docs/plugin_directory.mdx @@ -242,7 +242,6 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] | Package name           | Maintaining team | Description | API Cnt | Any Cnt | Missing
comments | Missing
exports | |--------------|----------------|-----------|--------------|----------|---------------|--------| -| | [@elastic/kibana-management](https://github.com/orgs/elastic/teams/kibana-management) | - | 11 | 5 | 11 | 0 | | | [@elastic/response-ops](https://github.com/orgs/elastic/teams/response-ops) | - | 14 | 0 | 14 | 0 | | | [@elastic/ml-ui](https://github.com/orgs/elastic/teams/ml-ui) | - | 36 | 0 | 0 | 0 | | | [@elastic/ml-ui](https://github.com/orgs/elastic/teams/ml-ui) | - | 2 | 0 | 0 | 0 | @@ -797,4 +796,3 @@ tags: ['contributor', 'dev', 'apidocs', 'kibana'] | | [@elastic/kibana-operations](https://github.com/orgs/elastic/teams/kibana-operations) | - | 9 | 0 | 4 | 0 | | | [@elastic/kibana-core](https://github.com/orgs/elastic/teams/kibana-core) | - | 1254 | 0 | 4 | 0 | | | [@elastic/security-detection-rule-management](https://github.com/orgs/elastic/teams/security-detection-rule-management) | - | 20 | 0 | 10 | 0 | - diff --git a/dev_docs/nav-kibana-dev.docnav.json b/dev_docs/nav-kibana-dev.docnav.json index 8b8cd64a44664..a7d696fc10574 100644 --- a/dev_docs/nav-kibana-dev.docnav.json +++ b/dev_docs/nav-kibana-dev.docnav.json @@ -278,6 +278,10 @@ { "id": "kibDevReactKibanaContext", "label": "Kibana React Contexts" + }, + { + "id": "kibDevDocsChromeRecentlyAccessed", + "label": "Recently Viewed" } ] }, diff --git a/dev_docs/shared_ux/chrome_recently_accessed/chrome_recently_accessed.mdx b/dev_docs/shared_ux/chrome_recently_accessed/chrome_recently_accessed.mdx new file mode 100644 index 0000000000000..cca466bcf1ac3 --- /dev/null +++ b/dev_docs/shared_ux/chrome_recently_accessed/chrome_recently_accessed.mdx @@ -0,0 +1,66 @@ +--- +id: kibDevDocsChromeRecentlyAccessed +slug: /kibana-dev-docs/chrome/recently-accessed +title: Chrome Recently Viewed +description: How to use chrome's recently accessed service to add your links to the recently viewed list in the side navigation. +date: 2024-10-04 +tags: ['kibana', 'dev', 'contributor', 'chrome', 'navigation', 'shared-ux'] +--- + +## Introduction + +The service allows applications to register recently visited objects. These items are displayed in the "Recently Viewed" section of a side navigation menu, providing users with quick access to their previously visited resources. This service includes methods for adding, retrieving, and subscribing to the recently accessed history. + +![Recently viewed section in the sidenav](./chrome_recently_accessed.png) + +## Guidelines + +The service should be used thoughtfully to provide users with easy access to key resources they've interacted with. Unlike browser history, this feature is for important items that users may want to revisit. + +### DOs + +- Register important resources that users may want to revisit. Like a dashboard, a saved search, or another specific object. +- Update the link when the state of the current resource changes. For example, if a user changes the time range while on a dashboard, update the recently viewed link to reflect the latest viewed state where possible. See below for instructions on how to update the link when state changes. + +### DON'Ts + +- Don't register every page view. +- Don't register temporary or transient states as individual items. +- Prevent overloading. Keep the list focused on high-value resources. +- Don't add a recently viewed object without first speaking to relevant Product Managers. + +## Usage + +To register an item with the `ChromeRecentlyAccessed` service, provide a unique `id`, a `label`, and a `link`. The `id` is used to identify and deduplicate the item, the `label` is displayed in the "Recently Viewed" list and the `link` is used to navigate to the item when selected. + +```ts +const link = '/app/map/1234'; +const label = 'Map 1234'; +const id = 'map-1234'; + +coreStart.chrome.recentlyAccessed.add(link, label, id); +``` + +To update the link when state changes, add another item with the same `id`. This will replace the existing item in the "Recently Viewed" list. + +```ts +const link = '/app/map/1234'; +const label = 'Map 1234'; + +coreStart.chrome.recentlyAccessed.add(`/app/map/1234`, label, id); + +// User changes the time range and we want to update the link in the "Recently Viewed" list +coreStart.chrome.recentlyAccessed.add( + `/app/map/1234?timeRangeFrom=now-30m&timeRangeTo=now`, + label, + id +); +``` + +## Implementation details + +The services is based on package. This package provides a `RecentlyAccessedService` that uses browser local storage to manage records of recently accessed objects. Internally it implements the queue with a maximum length of 20 items. When the queue is full, the oldest item is removed. +Applications can create their own instance of `RecentlyAccessedService` to manage their own list of recently accessed items scoped to their application. + +- is a service available via `coreStart.chrome.recentlyAccessed` and should be used to add items to chrome's sidenav. +- is package that `ChromeRecentlyAccessed` is using internally and the package can be used to create your own instance and manage your own list of recently accessed items that is independent for chrome's sidenav. \ No newline at end of file diff --git a/dev_docs/shared_ux/chrome_recently_accessed/chrome_recently_accessed.png b/dev_docs/shared_ux/chrome_recently_accessed/chrome_recently_accessed.png new file mode 100644 index 0000000000000..41d3913b048a2 Binary files /dev/null and b/dev_docs/shared_ux/chrome_recently_accessed/chrome_recently_accessed.png differ diff --git a/dev_docs/shared_ux/shared_ux_landing.mdx b/dev_docs/shared_ux/shared_ux_landing.mdx index 4be8ad134be15..d96798eefa61f 100644 --- a/dev_docs/shared_ux/shared_ux_landing.mdx +++ b/dev_docs/shared_ux/shared_ux_landing.mdx @@ -66,5 +66,10 @@ layout: landing title: 'Kibana React Contexts', description: 'Learn how to use common React contexts in Kibana', }, + { + pageId: 'kibDevDocsChromeRecentlyAccessed', + title: 'Chrome Recently Viewed', + description: 'Learn how to add recently viewed items to the side navigation', + }, ]} /> diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index 50095f8b7018f..0b97a425001ec 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -41,7 +41,6 @@ yarn kbn watch [discrete] === List of Already Migrated Packages to Bazel -- @kbn/ace - @kbn/analytics - @kbn/apm-config-loader - @kbn/apm-utils @@ -93,4 +92,4 @@ yarn kbn watch - @kbn/ui-shared-deps-npm - @kbn/ui-shared-deps-src - @kbn/utility-types -- @kbn/utils +- @kbn/utils \ No newline at end of file diff --git a/docs/maps/connect-to-ems.asciidoc b/docs/maps/connect-to-ems.asciidoc index e41d544d64e4d..1ccdedb1da2a9 100644 --- a/docs/maps/connect-to-ems.asciidoc +++ b/docs/maps/connect-to-ems.asciidoc @@ -1,6 +1,6 @@ :ems: Elastic Maps Service :ems-docker-repo: docker.elastic.co/elastic-maps-service/elastic-maps-server -:ems-docker-image: {ems-docker-repo}:{version}-amd64 +:ems-docker-image: {ems-docker-repo}:{version} :ems-headers-url: https://deployment-host [[maps-connect-to-ems]] @@ -81,34 +81,53 @@ If you cannot connect to {ems} from the {kib} server or browser clients, and you {hosted-ems} is a self-managed version of {ems} offered as a Docker image that provides both the EMS basemaps and EMS boundaries. The image is bundled with basemaps up to zoom level 8. After connecting it to your {es} cluster for license validation, you have the option to download and configure a more detailed basemaps database. -You can use +docker pull+ to download the {hosted-ems} image from the Elastic Docker registry. - +. Pull the {hosted-ems} Docker image. ++ ifeval::["{release-state}"=="unreleased"] -Version {version} of {hosted-ems} has not yet been released, so no Docker image is currently available for this version. +WARNING: Version {version} of {hosted-ems} has not yet been released. +No Docker image is currently available for this version. endif::[] - -ifeval::["{release-state}"!="unreleased"] - ++ ["source","bash",subs="attributes"] ---------------------------------- docker pull {ems-docker-image} ---------------------------------- -Start {hosted-ems} and expose the default port `8080`: +. Optional: Install +https://docs.sigstore.dev/system_config/installation/[Cosign] for your +environment. Then use Cosign to verify the {es} image's signature. ++ +[source,sh,subs="attributes"] +---- +wget https://artifacts.elastic.co/cosign.pub +cosign verify --key cosign.pub {ems-docker-image} +---- ++ +The `cosign` command prints the check results and the signature payload in JSON format: ++ +[source,sh,subs="attributes"] +-------------------------------------------- +Verification for {ems-docker-image} -- +The following checks were performed on each of these signatures: + - The cosign claims were validated + - Existence of the claims in the transparency log was verified offline + - The signatures were verified against the specified public key +-------------------------------------------- + +. Start {hosted-ems} and expose the default port `8080`: ++ ["source","bash",subs="attributes"] ---------------------------------- docker run --rm --init --publish 8080:8080 \ {ems-docker-image} ---------------------------------- - ++ Once {hosted-ems} is running, follow instructions from the webpage at `localhost:8080` to define a configuration file and optionally download a more detailed basemaps database. - ++ [role="screenshot"] image::images/elastic-maps-server-instructions.png[Set-up instructions] -endif::[] - [float] [[elastic-maps-server-configuration]] ==== Configuration @@ -193,7 +212,6 @@ One way to configure {hosted-ems} is to provide `elastic-maps-server.yml` via bi ["source","yaml",subs="attributes"] -------------------------------------------- -version: '2' services: ems-server: image: {ems-docker-image} @@ -212,7 +230,6 @@ These variables can be set with +docker-compose+ like this: ["source","yaml",subs="attributes"] ---------------------------------------------------------- -version: '2' services: ems-server: image: {ems-docker-image} diff --git a/docs/maps/images/elastic-maps-server-instructions.png b/docs/maps/images/elastic-maps-server-instructions.png index 5c0b47ce8f49f..524ae2192b5e5 100644 Binary files a/docs/maps/images/elastic-maps-server-instructions.png and b/docs/maps/images/elastic-maps-server-instructions.png differ diff --git a/docs/search/index.asciidoc b/docs/search/index.asciidoc index f046330ac13e9..ab4b007800da4 100644 --- a/docs/search/index.asciidoc +++ b/docs/search/index.asciidoc @@ -9,8 +9,8 @@ The *Search* space in {kib} comprises the following features: * <> * https://www.elastic.co/guide/en/elasticsearch/reference/current/search-application-overview.html[Search Applications] * https://www.elastic.co/guide/en/elasticsearch/reference/current/behavioral-analytics-overview.html[Behavioral Analytics] -* Inference Endpoints UI -* AI Assistant for Search +* <> +* <> * Persistent Dev Tools <> [float] @@ -19,53 +19,53 @@ The *Search* space in {kib} comprises the following features: The Search solution and use case is made up of many tools and features across the {stack}. As a result, the release notes for your features of interest might live in different Elastic docs. -// Use the following table to find links to the appropriate documentation, API references (if applicable), and release notes. +Use the following table to find links to the appropriate documentation, API references (if applicable), and release notes. -// [options="header"] -// |=== -// | Name | API reference | Documentation | Release notes +[options="header"] +|=== +| Name | API reference | Documentation | Release notes -// | Connectors -// | link:https://example.com/connectors/api[API reference] -// | link:https://example.com/connectors/docs[Documentation] -// | link:https://example.com/connectors/notes[Release notes] +| Connectors +| {ref}/connector-apis.html[API reference] +| {ref}/es-connectors.html[Elastic Connectors] +| {ref}/es-connectors-release-notes.html[Elasticsearch guide] -// | Web crawler -// | link:https://example.com/web_crawlers/api[API reference] -// | link:https://example.com/web_crawlers/docs[Documentation] -// | link:https://example.com/web_crawlers/notes[Release notes] +| Web crawler +| N/A +| {enterprise-search-ref}/crawler.html[Documentation] +| {enterprise-search-ref}/changelog.html[Enterprise Search Guide] -// | Playground -// | link:https://example.com/playground/api[API reference] -// | link:https://example.com/playground/docs[Documentation] -// | link:https://example.com/playground/notes[Release notes] +| Playground +| N/A +| {kibana-ref}/playground.html[Documentation] +| {kibana-ref}/release-notes.html[Kibana guide] -// | Search Applications -// | link:https://example.com/search_apps/api[API reference] -// | link:https://example.com/search_apps/docs[Documentation] -// | link:https://example.com/search_apps/notes[Release notes] +| Search Applications +| {ref}/search-application-apis.html[API reference] +| {enterprise-search-ref}/app-search-workplace-search.html[Documentation] +| {ref}/es-release-notes.html[Elasticsearch guide] -// | Behavioral Analytics -// | link:https://example.com/behavioral_analytics/api[API reference] -// | link:https://example.com/behavioral_analytics/docs[Documentation] -// | link:https://example.com/behavioral_analytics/notes[Release notes] +| Behavioral Analytics +| {ref}/behavioral-analytics-apis.html[API reference] +| {ref}/behavioral-analytics-start.html[Documentation] +| {ref}/es-release-notes.html[Elasticsearch guide] -// | Inference Endpoints -// | link:https://example.com/inference_endpoints/api[API reference] -// | link:https://example.com/inference_endpoints/docs[Documentation] -// | link:https://example.com/inference_endpoints/notes[Release notes] +| Inference Endpoints +| {ref}/inference-apis.html[API reference] +| {kibana-ref}/inference-endpoints.html[Documentation] +| {ref}/es-release-notes.html[Elasticsearch guide] -// | Console -// | link:https://example.com/console/api[API reference] -// | link:https://example.com/console/docs[Documentation] -// | link:https://example.com/console/notes[Release notes] +| Console +| N/A +| {kibana-ref}/console-kibana.html[Documentation] +| {kibana-ref}/release-notes.html[Kibana guide] -// | Search UI -// | link:https://www.elastic.co/docs/current/search-ui/api/architecture[API reference] -// | link:https://www.elastic.co/docs/current/search-ui/overview[Documentation] -// | link:https://example.com/search_ui/notes[Release notes] +| Search UI +| https://www.elastic.co/docs/current/search-ui/api/architecture[API reference] +| https://www.elastic.co/docs/current/search-ui[Documentation] +| https://www.elastic.co/docs/current/search-ui[Search UI] -// |=== +|=== include::search-connection-details.asciidoc[] include::playground/index.asciidoc[] diff --git a/oas_docs/bundle.json b/oas_docs/bundle.json index 8a2eb526a09c0..8fdffea9bac41 100644 --- a/oas_docs/bundle.json +++ b/oas_docs/bundle.json @@ -19328,6 +19328,27 @@ "description": { "type": "string" }, + "discovery": { + "additionalProperties": true, + "properties": { + "fields": { + "items": { + "additionalProperties": true, + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, "download": { "type": "string" }, @@ -19716,7 +19737,8 @@ "type": { "enum": [ "integration", - "input" + "input", + "content" ], "type": "string" }, @@ -19793,6 +19815,27 @@ "description": { "type": "string" }, + "discovery": { + "additionalProperties": true, + "properties": { + "fields": { + "items": { + "additionalProperties": true, + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, "download": { "type": "string" }, @@ -20181,7 +20224,8 @@ "type": { "enum": [ "integration", - "input" + "input", + "content" ], "type": "string" }, @@ -21769,6 +21813,27 @@ "description": { "type": "string" }, + "discovery": { + "additionalProperties": true, + "properties": { + "fields": { + "items": { + "additionalProperties": true, + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, "download": { "type": "string" }, @@ -22197,7 +22262,8 @@ "type": { "enum": [ "integration", - "input" + "input", + "content" ], "type": "string" }, @@ -22329,6 +22395,27 @@ "description": { "type": "string" }, + "discovery": { + "additionalProperties": true, + "properties": { + "fields": { + "items": { + "additionalProperties": true, + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, "download": { "type": "string" }, @@ -22757,7 +22844,8 @@ "type": { "enum": [ "integration", - "input" + "input", + "content" ], "type": "string" }, @@ -23279,6 +23367,27 @@ "description": { "type": "string" }, + "discovery": { + "additionalProperties": true, + "properties": { + "fields": { + "items": { + "additionalProperties": true, + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, "download": { "type": "string" }, @@ -23707,7 +23816,8 @@ "type": { "enum": [ "integration", - "input" + "input", + "content" ], "type": "string" }, @@ -23827,6 +23937,27 @@ "description": { "type": "string" }, + "discovery": { + "additionalProperties": true, + "properties": { + "fields": { + "items": { + "additionalProperties": true, + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, "download": { "type": "string" }, @@ -24255,7 +24386,8 @@ "type": { "enum": [ "integration", - "input" + "input", + "content" ], "type": "string" }, diff --git a/oas_docs/bundle.serverless.json b/oas_docs/bundle.serverless.json index a1202965b5341..4719fcb479bb5 100644 --- a/oas_docs/bundle.serverless.json +++ b/oas_docs/bundle.serverless.json @@ -19328,6 +19328,27 @@ "description": { "type": "string" }, + "discovery": { + "additionalProperties": true, + "properties": { + "fields": { + "items": { + "additionalProperties": true, + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, "download": { "type": "string" }, @@ -19716,7 +19737,8 @@ "type": { "enum": [ "integration", - "input" + "input", + "content" ], "type": "string" }, @@ -19793,6 +19815,27 @@ "description": { "type": "string" }, + "discovery": { + "additionalProperties": true, + "properties": { + "fields": { + "items": { + "additionalProperties": true, + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, "download": { "type": "string" }, @@ -20181,7 +20224,8 @@ "type": { "enum": [ "integration", - "input" + "input", + "content" ], "type": "string" }, @@ -21769,6 +21813,27 @@ "description": { "type": "string" }, + "discovery": { + "additionalProperties": true, + "properties": { + "fields": { + "items": { + "additionalProperties": true, + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, "download": { "type": "string" }, @@ -22197,7 +22262,8 @@ "type": { "enum": [ "integration", - "input" + "input", + "content" ], "type": "string" }, @@ -22329,6 +22395,27 @@ "description": { "type": "string" }, + "discovery": { + "additionalProperties": true, + "properties": { + "fields": { + "items": { + "additionalProperties": true, + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, "download": { "type": "string" }, @@ -22757,7 +22844,8 @@ "type": { "enum": [ "integration", - "input" + "input", + "content" ], "type": "string" }, @@ -23279,6 +23367,27 @@ "description": { "type": "string" }, + "discovery": { + "additionalProperties": true, + "properties": { + "fields": { + "items": { + "additionalProperties": true, + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, "download": { "type": "string" }, @@ -23707,7 +23816,8 @@ "type": { "enum": [ "integration", - "input" + "input", + "content" ], "type": "string" }, @@ -23827,6 +23937,27 @@ "description": { "type": "string" }, + "discovery": { + "additionalProperties": true, + "properties": { + "fields": { + "items": { + "additionalProperties": true, + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, "download": { "type": "string" }, @@ -24255,7 +24386,8 @@ "type": { "enum": [ "integration", - "input" + "input", + "content" ], "type": "string" }, diff --git a/oas_docs/output/kibana.serverless.staging.yaml b/oas_docs/output/kibana.serverless.staging.yaml index a4b3f37c5c2e8..d199e0b92e5a0 100644 --- a/oas_docs/output/kibana.serverless.staging.yaml +++ b/oas_docs/output/kibana.serverless.staging.yaml @@ -18990,6 +18990,20 @@ paths: type: array description: type: string + discovery: + additionalProperties: true + type: object + properties: + fields: + items: + additionalProperties: true + type: object + properties: + name: + type: string + required: + - name + type: array download: type: string format_version: @@ -19270,6 +19284,7 @@ paths: enum: - integration - input + - content type: string vars: items: @@ -19322,6 +19337,20 @@ paths: type: array description: type: string + discovery: + additionalProperties: true + type: object + properties: + fields: + items: + additionalProperties: true + type: object + properties: + name: + type: string + required: + - name + type: array download: type: string format_version: @@ -19602,6 +19631,7 @@ paths: enum: - integration - input + - content type: string vars: items: @@ -20574,6 +20604,20 @@ paths: type: array description: type: string + discovery: + additionalProperties: true + type: object + properties: + fields: + items: + additionalProperties: true + type: object + properties: + name: + type: string + required: + - name + type: array download: type: string elasticsearch: @@ -20881,6 +20925,7 @@ paths: enum: - integration - input + - content type: string vars: items: @@ -20970,6 +21015,20 @@ paths: type: array description: type: string + discovery: + additionalProperties: true + type: object + properties: + fields: + items: + additionalProperties: true + type: object + properties: + name: + type: string + required: + - name + type: array download: type: string elasticsearch: @@ -21277,6 +21336,7 @@ paths: enum: - integration - input + - content type: string vars: items: @@ -21629,6 +21689,20 @@ paths: type: array description: type: string + discovery: + additionalProperties: true + type: object + properties: + fields: + items: + additionalProperties: true + type: object + properties: + name: + type: string + required: + - name + type: array download: type: string elasticsearch: @@ -21936,6 +22010,7 @@ paths: enum: - integration - input + - content type: string vars: items: @@ -22017,6 +22092,20 @@ paths: type: array description: type: string + discovery: + additionalProperties: true + type: object + properties: + fields: + items: + additionalProperties: true + type: object + properties: + name: + type: string + required: + - name + type: array download: type: string elasticsearch: @@ -22324,6 +22413,7 @@ paths: enum: - integration - input + - content type: string vars: items: @@ -40832,6 +40922,7 @@ components: enum: - OpenAI - Azure OpenAI + - Other type: string Security_AI_Assistant_API_Reader: additionalProperties: true diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml index a4b3f37c5c2e8..d199e0b92e5a0 100644 --- a/oas_docs/output/kibana.serverless.yaml +++ b/oas_docs/output/kibana.serverless.yaml @@ -18990,6 +18990,20 @@ paths: type: array description: type: string + discovery: + additionalProperties: true + type: object + properties: + fields: + items: + additionalProperties: true + type: object + properties: + name: + type: string + required: + - name + type: array download: type: string format_version: @@ -19270,6 +19284,7 @@ paths: enum: - integration - input + - content type: string vars: items: @@ -19322,6 +19337,20 @@ paths: type: array description: type: string + discovery: + additionalProperties: true + type: object + properties: + fields: + items: + additionalProperties: true + type: object + properties: + name: + type: string + required: + - name + type: array download: type: string format_version: @@ -19602,6 +19631,7 @@ paths: enum: - integration - input + - content type: string vars: items: @@ -20574,6 +20604,20 @@ paths: type: array description: type: string + discovery: + additionalProperties: true + type: object + properties: + fields: + items: + additionalProperties: true + type: object + properties: + name: + type: string + required: + - name + type: array download: type: string elasticsearch: @@ -20881,6 +20925,7 @@ paths: enum: - integration - input + - content type: string vars: items: @@ -20970,6 +21015,20 @@ paths: type: array description: type: string + discovery: + additionalProperties: true + type: object + properties: + fields: + items: + additionalProperties: true + type: object + properties: + name: + type: string + required: + - name + type: array download: type: string elasticsearch: @@ -21277,6 +21336,7 @@ paths: enum: - integration - input + - content type: string vars: items: @@ -21629,6 +21689,20 @@ paths: type: array description: type: string + discovery: + additionalProperties: true + type: object + properties: + fields: + items: + additionalProperties: true + type: object + properties: + name: + type: string + required: + - name + type: array download: type: string elasticsearch: @@ -21936,6 +22010,7 @@ paths: enum: - integration - input + - content type: string vars: items: @@ -22017,6 +22092,20 @@ paths: type: array description: type: string + discovery: + additionalProperties: true + type: object + properties: + fields: + items: + additionalProperties: true + type: object + properties: + name: + type: string + required: + - name + type: array download: type: string elasticsearch: @@ -22324,6 +22413,7 @@ paths: enum: - integration - input + - content type: string vars: items: @@ -40832,6 +40922,7 @@ components: enum: - OpenAI - Azure OpenAI + - Other type: string Security_AI_Assistant_API_Reader: additionalProperties: true diff --git a/oas_docs/output/kibana.staging.yaml b/oas_docs/output/kibana.staging.yaml index 809703d3887fa..a6c3729da1630 100644 --- a/oas_docs/output/kibana.staging.yaml +++ b/oas_docs/output/kibana.staging.yaml @@ -22419,6 +22419,20 @@ paths: type: array description: type: string + discovery: + additionalProperties: true + type: object + properties: + fields: + items: + additionalProperties: true + type: object + properties: + name: + type: string + required: + - name + type: array download: type: string format_version: @@ -22699,6 +22713,7 @@ paths: enum: - integration - input + - content type: string vars: items: @@ -22751,6 +22766,20 @@ paths: type: array description: type: string + discovery: + additionalProperties: true + type: object + properties: + fields: + items: + additionalProperties: true + type: object + properties: + name: + type: string + required: + - name + type: array download: type: string format_version: @@ -23031,6 +23060,7 @@ paths: enum: - integration - input + - content type: string vars: items: @@ -24003,6 +24033,20 @@ paths: type: array description: type: string + discovery: + additionalProperties: true + type: object + properties: + fields: + items: + additionalProperties: true + type: object + properties: + name: + type: string + required: + - name + type: array download: type: string elasticsearch: @@ -24310,6 +24354,7 @@ paths: enum: - integration - input + - content type: string vars: items: @@ -24399,6 +24444,20 @@ paths: type: array description: type: string + discovery: + additionalProperties: true + type: object + properties: + fields: + items: + additionalProperties: true + type: object + properties: + name: + type: string + required: + - name + type: array download: type: string elasticsearch: @@ -24706,6 +24765,7 @@ paths: enum: - integration - input + - content type: string vars: items: @@ -25058,6 +25118,20 @@ paths: type: array description: type: string + discovery: + additionalProperties: true + type: object + properties: + fields: + items: + additionalProperties: true + type: object + properties: + name: + type: string + required: + - name + type: array download: type: string elasticsearch: @@ -25365,6 +25439,7 @@ paths: enum: - integration - input + - content type: string vars: items: @@ -25446,6 +25521,20 @@ paths: type: array description: type: string + discovery: + additionalProperties: true + type: object + properties: + fields: + items: + additionalProperties: true + type: object + properties: + name: + type: string + required: + - name + type: array download: type: string elasticsearch: @@ -25753,6 +25842,7 @@ paths: enum: - integration - input + - content type: string vars: items: @@ -49393,6 +49483,7 @@ components: enum: - OpenAI - Azure OpenAI + - Other type: string Security_AI_Assistant_API_Reader: additionalProperties: true diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index 809703d3887fa..a6c3729da1630 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -22419,6 +22419,20 @@ paths: type: array description: type: string + discovery: + additionalProperties: true + type: object + properties: + fields: + items: + additionalProperties: true + type: object + properties: + name: + type: string + required: + - name + type: array download: type: string format_version: @@ -22699,6 +22713,7 @@ paths: enum: - integration - input + - content type: string vars: items: @@ -22751,6 +22766,20 @@ paths: type: array description: type: string + discovery: + additionalProperties: true + type: object + properties: + fields: + items: + additionalProperties: true + type: object + properties: + name: + type: string + required: + - name + type: array download: type: string format_version: @@ -23031,6 +23060,7 @@ paths: enum: - integration - input + - content type: string vars: items: @@ -24003,6 +24033,20 @@ paths: type: array description: type: string + discovery: + additionalProperties: true + type: object + properties: + fields: + items: + additionalProperties: true + type: object + properties: + name: + type: string + required: + - name + type: array download: type: string elasticsearch: @@ -24310,6 +24354,7 @@ paths: enum: - integration - input + - content type: string vars: items: @@ -24399,6 +24444,20 @@ paths: type: array description: type: string + discovery: + additionalProperties: true + type: object + properties: + fields: + items: + additionalProperties: true + type: object + properties: + name: + type: string + required: + - name + type: array download: type: string elasticsearch: @@ -24706,6 +24765,7 @@ paths: enum: - integration - input + - content type: string vars: items: @@ -25058,6 +25118,20 @@ paths: type: array description: type: string + discovery: + additionalProperties: true + type: object + properties: + fields: + items: + additionalProperties: true + type: object + properties: + name: + type: string + required: + - name + type: array download: type: string elasticsearch: @@ -25365,6 +25439,7 @@ paths: enum: - integration - input + - content type: string vars: items: @@ -25446,6 +25521,20 @@ paths: type: array description: type: string + discovery: + additionalProperties: true + type: object + properties: + fields: + items: + additionalProperties: true + type: object + properties: + name: + type: string + required: + - name + type: array download: type: string elasticsearch: @@ -25753,6 +25842,7 @@ paths: enum: - integration - input + - content type: string vars: items: @@ -49393,6 +49483,7 @@ components: enum: - OpenAI - Azure OpenAI + - Other type: string Security_AI_Assistant_API_Reader: additionalProperties: true diff --git a/package.json b/package.json index 57b84f1c46dcb..734ce9cce5128 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.cd77847.0", "@types/react": "~18.2.0", "@types/react-dom": "~18.2.0", + "@xstate5/react/**/xstate": "^5.18.1", "globby/fast-glob": "^3.2.11" }, "dependencies": { @@ -153,11 +154,11 @@ "@hapi/wreck": "^18.1.0", "@hello-pangea/dnd": "16.6.0", "@kbn/aad-fixtures-plugin": "link:x-pack/test/alerting_api_integration/common/plugins/aad", - "@kbn/ace": "link:packages/kbn-ace", "@kbn/actions-plugin": "link:x-pack/plugins/actions", "@kbn/actions-simulators-plugin": "link:x-pack/test/alerting_api_integration/common/plugins/actions_simulators", "@kbn/actions-types": "link:packages/kbn-actions-types", "@kbn/advanced-settings-plugin": "link:src/plugins/advanced_settings", + "@kbn/ai-assistant": "link:x-pack/packages/kbn-ai-assistant", "@kbn/ai-assistant-management-plugin": "link:src/plugins/ai_assistant_management/selection", "@kbn/aiops-change-point-detection": "link:x-pack/packages/ml/aiops_change_point_detection", "@kbn/aiops-common": "link:x-pack/packages/ml/aiops_common", @@ -687,6 +688,7 @@ "@kbn/observability-fixtures-plugin": "link:x-pack/test/cases_api_integration/common/plugins/observability", "@kbn/observability-get-padded-alert-time-range-util": "link:x-pack/packages/observability/get_padded_alert_time_range_util", "@kbn/observability-logs-explorer-plugin": "link:x-pack/plugins/observability_solution/observability_logs_explorer", + "@kbn/observability-logs-overview": "link:x-pack/packages/observability/logs_overview", "@kbn/observability-onboarding-plugin": "link:x-pack/plugins/observability_solution/observability_onboarding", "@kbn/observability-plugin": "link:x-pack/plugins/observability_solution/observability", "@kbn/observability-shared-plugin": "link:x-pack/plugins/observability_solution/observability_shared", @@ -1050,6 +1052,7 @@ "@turf/helpers": "6.0.1", "@turf/length": "^6.0.2", "@xstate/react": "^3.2.2", + "@xstate5/react": "npm:@xstate/react@^4.1.2", "adm-zip": "^0.5.9", "ai": "^2.2.33", "ajv": "^8.12.0", @@ -1063,7 +1066,6 @@ "bitmap-sdf": "^1.0.3", "blurhash": "^2.0.1", "borc": "3.0.0", - "brace": "0.11.1", "brok": "^6.0.0", "byte-size": "^8.1.0", "cacheable-lookup": "6", @@ -1201,7 +1203,6 @@ "re-resizable": "^6.9.9", "re2js": "0.4.2", "react": "^17.0.2", - "react-ace": "^7.0.5", "react-diff-view": "^3.2.1", "react-dom": "^17.0.2", "react-dropzone": "^4.2.9", @@ -1283,6 +1284,7 @@ "whatwg-fetch": "^3.0.0", "xml2js": "^0.5.0", "xstate": "^4.38.2", + "xstate5": "npm:xstate@^5.18.1", "xterm": "^5.1.0", "yauzl": "^2.10.0", "yazl": "^2.5.1", @@ -1304,6 +1306,7 @@ "@babel/plugin-proposal-optional-chaining": "^7.21.0", "@babel/plugin-proposal-private-methods": "^7.18.6", "@babel/plugin-transform-class-properties": "^7.24.7", + "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", "@babel/plugin-transform-numeric-separator": "^7.24.7", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/preset-env": "^7.24.7", diff --git a/packages/core/application/core-application-browser/src/app_mount.ts b/packages/core/application/core-application-browser/src/app_mount.ts index a34550bc98fcd..4fb38b10a3704 100644 --- a/packages/core/application/core-application-browser/src/app_mount.ts +++ b/packages/core/application/core-application-browser/src/app_mount.ts @@ -89,7 +89,7 @@ export interface AppMountParameters { * This string should not include the base path from HTTP. * * @deprecated Use {@link AppMountParameters.history} instead. - * @removeBy 8.8.0 + * remove after https://github.com/elastic/kibana/issues/132600 is done * * @example * diff --git a/packages/core/elasticsearch/core-elasticsearch-server/src/contracts.ts b/packages/core/elasticsearch/core-elasticsearch-server/src/contracts.ts index bc712a61a535e..4e0bd253eb8b4 100644 --- a/packages/core/elasticsearch/core-elasticsearch-server/src/contracts.ts +++ b/packages/core/elasticsearch/core-elasticsearch-server/src/contracts.ts @@ -81,7 +81,7 @@ export interface ElasticsearchServiceSetup { setUnauthorizedErrorHandler: (handler: UnauthorizedErrorHandler) => void; /** - * @deprecated + * @deprecated Can be removed when https://github.com/elastic/kibana/issues/119862 is done. */ legacy: { /** diff --git a/packages/core/http/core-http-resources-server-internal/src/http_resources_service.test.ts b/packages/core/http/core-http-resources-server-internal/src/http_resources_service.test.ts index efce905e6564f..1a7757d4e1eaa 100644 --- a/packages/core/http/core-http-resources-server-internal/src/http_resources_service.test.ts +++ b/packages/core/http/core-http-resources-server-internal/src/http_resources_service.test.ts @@ -69,6 +69,23 @@ describe('HttpResources service', () => { expect(registeredRouteConfig.options?.access).toBe('internal'); }); + it('registration defaults to excluded from OAS', () => { + register({ ...routeConfig, options: { access: 'internal' } }, async (ctx, req, res) => + res.ok() + ); + const [[registeredRouteConfig]] = router.get.mock.calls; + expect(registeredRouteConfig.options?.excludeFromOAS).toBe(true); + }); + + it('registration allows being included in OAS', () => { + register( + { ...routeConfig, options: { access: 'internal', excludeFromOAS: false } }, + async (ctx, req, res) => res.ok() + ); + const [[registeredRouteConfig]] = router.get.mock.calls; + expect(registeredRouteConfig.options?.excludeFromOAS).toBe(false); + }); + describe('renderCoreApp', () => { it('formats successful response', async () => { register(routeConfig, async (ctx, req, res) => { diff --git a/packages/core/http/core-http-resources-server-internal/src/http_resources_service.ts b/packages/core/http/core-http-resources-server-internal/src/http_resources_service.ts index d9e75d49e72cf..29114c0dffc07 100644 --- a/packages/core/http/core-http-resources-server-internal/src/http_resources_service.ts +++ b/packages/core/http/core-http-resources-server-internal/src/http_resources_service.ts @@ -89,6 +89,7 @@ export class HttpResourcesService implements CoreService mockResponse), header: jest.fn().mockImplementation(() => mockResponse), -}; -const mockResponseToolkit: any = { +} as unknown as jest.Mocked; + +const mockResponseToolkit = { response: jest.fn().mockReturnValue(mockResponse), -}; +} as unknown as jest.Mocked; const logger = loggingSystemMock.create().get(); const enhanceWithContext = (fn: (...args: any[]) => any) => fn.bind(null, {}); @@ -132,6 +134,42 @@ describe('Router', () => { } ); + it('adds versioned header v2023-10-31 to public, unversioned routes', async () => { + const router = new Router('', logger, enhanceWithContext, routerOptions); + router.post( + { + path: '/public', + options: { + access: 'public', + }, + validate: false, + }, + (context, req, res) => res.ok({ headers: { AAAA: 'test' } }) // with some fake headers + ); + router.post( + { + path: '/internal', + options: { + access: 'internal', + }, + validate: false, + }, + (context, req, res) => res.ok() + ); + const [{ handler: publicHandler }, { handler: internalHandler }] = router.getRoutes(); + + await publicHandler(createRequestMock(), mockResponseToolkit); + expect(mockResponse.header).toHaveBeenCalledTimes(2); + const [first, second] = mockResponse.header.mock.calls + .concat() + .sort(([k1], [k2]) => k1.localeCompare(k2)); + expect(first).toEqual(['AAAA', 'test']); + expect(second).toEqual(['elastic-api-version', '2023-10-31']); + + await internalHandler(createRequestMock(), mockResponseToolkit); + expect(mockResponse.header).toHaveBeenCalledTimes(2); // no additional calls + }); + it('constructs lazily provided validations once (idempotency)', async () => { const router = new Router('', logger, enhanceWithContext, routerOptions); const { fooValidation } = testValidation; diff --git a/packages/core/http/core-http-router-server-internal/src/router.ts b/packages/core/http/core-http-router-server-internal/src/router.ts index 52363e7ea95be..bb99de64581be 100644 --- a/packages/core/http/core-http-router-server-internal/src/router.ts +++ b/packages/core/http/core-http-router-server-internal/src/router.ts @@ -33,13 +33,13 @@ import { validBodyOutput, getRequestValidation } from '@kbn/core-http-server'; import type { RouteSecurityGetter } from '@kbn/core-http-server'; import type { DeepPartial } from '@kbn/utility-types'; import { RouteValidator } from './validator'; -import { CoreVersionedRouter } from './versioned_router'; +import { ALLOWED_PUBLIC_VERSION, CoreVersionedRouter } from './versioned_router'; import { CoreKibanaRequest } from './request'; import { kibanaResponseFactory } from './response'; import { HapiResponseAdapter } from './response_adapter'; import { wrapErrors } from './error_wrapper'; import { Method } from './versioned_router/types'; -import { prepareRouteConfigValidation } from './util'; +import { getVersionHeader, injectVersionHeader, prepareRouteConfigValidation } from './util'; import { stripIllegalHttp2Headers } from './strip_illegal_http2_headers'; import { validRouteSecurity } from './security_route_config_validator'; import { InternalRouteConfig } from './route'; @@ -171,6 +171,7 @@ export interface InternalRouterRoute extends RouterRoute { /** @internal */ interface InternalGetRoutesOptions { + /** @default false */ excludeVersionedRoutes?: boolean; } @@ -200,10 +201,11 @@ export class Router( route: InternalRouteConfig, handler: RequestHandler, - internalOptions: { isVersioned: boolean } = { isVersioned: false } + { isVersioned }: { isVersioned: boolean } = { isVersioned: false } ) => { route = prepareRouteConfigValidation(route); const routeSchemas = routeSchemasFromRouteConfig(route, method); + const isPublicUnversionedRoute = route.options?.access === 'public' && !isVersioned; this.routes.push({ handler: async (req, responseToolkit) => @@ -211,18 +213,19 @@ export class Router, route.options), /** Below is added for introspection */ validationSchemas: route.validate, - isVersioned: internalOptions.isVersioned, + isVersioned, }); }; @@ -266,10 +269,12 @@ export class Router { it('wraps only expected values in "once"', () => { @@ -49,3 +50,17 @@ describe('prepareResponseValidation', () => { expect(validation.response![500].body).toBeUndefined(); }); }); + +describe('injectResponseHeaders', () => { + it('injects an empty value as expected', () => { + const result = injectResponseHeaders({}, kibanaResponseFactory.ok()); + expect(result.options.headers).toEqual({}); + }); + it('merges values as expected', () => { + const result = injectResponseHeaders( + { foo: 'false', baz: 'true' }, + kibanaResponseFactory.ok({ headers: { foo: 'true', bar: 'false' } }) + ); + expect(result.options.headers).toEqual({ foo: 'false', bar: 'false', baz: 'true' }); + }); +}); diff --git a/packages/core/http/core-http-router-server-internal/src/util.ts b/packages/core/http/core-http-router-server-internal/src/util.ts index 0d1c8abb0e103..176d33b589880 100644 --- a/packages/core/http/core-http-router-server-internal/src/util.ts +++ b/packages/core/http/core-http-router-server-internal/src/util.ts @@ -14,6 +14,9 @@ import { type RouteMethod, type RouteValidator, } from '@kbn/core-http-server'; +import type { Mutable } from 'utility-types'; +import type { IKibanaResponse, ResponseHeaders } from '@kbn/core-http-server'; +import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; import type { InternalRouteConfig } from './route'; function isStatusCode(key: string) { @@ -63,3 +66,29 @@ export function prepareRouteConfigValidation( } return config; } + +/** + * @note mutates the response object + * @internal + */ +export function injectResponseHeaders( + headers: ResponseHeaders, + response: IKibanaResponse +): IKibanaResponse { + const mutableResponse = response as Mutable; + mutableResponse.options.headers = { + ...mutableResponse.options.headers, + ...headers, + }; + return mutableResponse; +} + +export function getVersionHeader(version: string): ResponseHeaders { + return { + [ELASTIC_HTTP_VERSION_HEADER]: version, + }; +} + +export function injectVersionHeader(version: string, response: IKibanaResponse): IKibanaResponse { + return injectResponseHeaders(getVersionHeader(version), response); +} diff --git a/packages/core/http/core-http-router-server-internal/src/versioned_router/core_versioned_route.ts b/packages/core/http/core-http-router-server-internal/src/versioned_router/core_versioned_route.ts index 71ab30bbe8b80..e9a9e60de8193 100644 --- a/packages/core/http/core-http-router-server-internal/src/versioned_router/core_versioned_route.ts +++ b/packages/core/http/core-http-router-server-internal/src/versioned_router/core_versioned_route.ts @@ -38,7 +38,7 @@ import { readVersion, removeQueryVersion, } from './route_version_utils'; -import { injectResponseHeaders } from './inject_response_headers'; +import { getVersionHeader, injectVersionHeader } from '../util'; import { validRouteSecurity } from '../security_route_config_validator'; import { resolvers } from './handler_resolvers'; @@ -221,9 +221,7 @@ export class CoreVersionedRoute implements VersionedRoute { req.params = params; req.query = query; } catch (e) { - return res.badRequest({ - body: e.message, - }); + return res.badRequest({ body: e.message, headers: getVersionHeader(version) }); } } else { // Preserve behavior of not passing through unvalidated data @@ -252,12 +250,7 @@ export class CoreVersionedRoute implements VersionedRoute { } } - return injectResponseHeaders( - { - [ELASTIC_HTTP_VERSION_HEADER]: version, - }, - response - ); + return injectVersionHeader(version, response); }; private validateVersion(version: string) { diff --git a/packages/core/http/core-http-router-server-internal/src/versioned_router/inject_response_headers.ts b/packages/core/http/core-http-router-server-internal/src/versioned_router/inject_response_headers.ts deleted file mode 100644 index c27c92023f56e..0000000000000 --- a/packages/core/http/core-http-router-server-internal/src/versioned_router/inject_response_headers.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", 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 { Mutable } from 'utility-types'; -import type { IKibanaResponse } from '@kbn/core-http-server'; - -/** - * @note mutates the response object - * @internal - */ -export function injectResponseHeaders(headers: object, response: IKibanaResponse): IKibanaResponse { - const mutableResponse = response as Mutable; - mutableResponse.options = { - ...mutableResponse.options, - headers: { - ...mutableResponse.options.headers, - ...headers, - }, - }; - return mutableResponse; -} diff --git a/packages/core/http/core-http-server-internal/src/http_service.ts b/packages/core/http/core-http-server-internal/src/http_service.ts index e5a82f0abefb0..3f803b06f15fd 100644 --- a/packages/core/http/core-http-server-internal/src/http_service.ts +++ b/packages/core/http/core-http-server-internal/src/http_service.ts @@ -9,9 +9,13 @@ import { Observable, Subscription, combineLatest, firstValueFrom, of, mergeMap } from 'rxjs'; import { map } from 'rxjs'; +import { schema, TypeOf } from '@kbn/config-schema'; import { pick, Semaphore } from '@kbn/std'; -import { generateOpenApiDocument } from '@kbn/router-to-openapispec'; +import { + generateOpenApiDocument, + type GenerateOpenApiDocumentOptionsFilters, +} from '@kbn/router-to-openapispec'; import { Logger } from '@kbn/logging'; import { Env } from '@kbn/config'; import type { CoreContext, CoreService } from '@kbn/core-base-server-internal'; @@ -254,49 +258,55 @@ export class HttpService const baseUrl = basePath.publicBaseUrl ?? `http://localhost:${config.port}${basePath.serverBasePath}`; + const stringOrStringArraySchema = schema.oneOf([ + schema.string(), + schema.arrayOf(schema.string()), + ]); + const querySchema = schema.object({ + access: schema.maybe(schema.oneOf([schema.literal('public'), schema.literal('internal')])), + excludePathsMatching: schema.maybe(stringOrStringArraySchema), + pathStartsWith: schema.maybe(stringOrStringArraySchema), + pluginId: schema.maybe(schema.string()), + version: schema.maybe(schema.string()), + }); + server.route({ path: '/api/oas', method: 'GET', handler: async (req, h) => { - const version = req.query?.version; - - let pathStartsWith: undefined | string[]; - if (typeof req.query?.pathStartsWith === 'string') { - pathStartsWith = [req.query.pathStartsWith]; - } else { - pathStartsWith = req.query?.pathStartsWith; - } - - let excludePathsMatching: undefined | string[]; - if (typeof req.query?.excludePathsMatching === 'string') { - excludePathsMatching = [req.query.excludePathsMatching]; - } else { - excludePathsMatching = req.query?.excludePathsMatching; + let filters: GenerateOpenApiDocumentOptionsFilters; + let query: TypeOf; + try { + query = querySchema.validate(req.query); + filters = { + ...query, + excludePathsMatching: + typeof query.excludePathsMatching === 'string' + ? [query.excludePathsMatching] + : query.excludePathsMatching, + pathStartsWith: + typeof query.pathStartsWith === 'string' + ? [query.pathStartsWith] + : query.pathStartsWith, + }; + } catch (e) { + return h.response({ message: e.message }).code(400); } - - const pluginId = req.query?.pluginId; - - const access = req.query?.access as 'public' | 'internal' | undefined; - if (access && !['public', 'internal'].some((a) => a === access)) { - return h - .response({ - message: 'Invalid access query parameter. Must be one of "public" or "internal".', - }) - .code(400); - } - return await firstValueFrom( of(1).pipe( HttpService.generateOasSemaphore.acquire(), mergeMap(async () => { try { // Potentially quite expensive - const result = generateOpenApiDocument(this.httpServer.getRouters({ pluginId }), { - baseUrl, - title: 'Kibana HTTP APIs', - version: '0.0.0', // TODO get a better version here - filters: { pathStartsWith, excludePathsMatching, access, version }, - }); + const result = generateOpenApiDocument( + this.httpServer.getRouters({ pluginId: query.pluginId }), + { + baseUrl, + title: 'Kibana HTTP APIs', + version: '0.0.0', // TODO get a better version here + filters, + } + ); return h.response(result); } catch (e) { this.log.error(e); 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 bdf4f9f03c784..194191e6f423f 100644 --- a/packages/core/http/core-http-server/src/router/route.ts +++ b/packages/core/http/core-http-server/src/router/route.ts @@ -215,7 +215,7 @@ export interface RouteConfigOptions { /** * Defines intended request origin of the route: * - public. The route is public, declared stable and intended for external access. - * In the future, may require an incomming request to contain a specified header. + * In the future, may require an incoming request to contain a specified header. * - internal. The route is internal and intended for internal access only. * * Defaults to 'internal' If not declared, @@ -284,6 +284,14 @@ export interface RouteConfigOptions { */ deprecated?: boolean; + /** + * Whether this route should be treated as "invisible" and excluded from router + * OAS introspection. + * + * @default false + */ + excludeFromOAS?: boolean; + /** * Release version or date that this route will be removed * Use with `deprecated: true` @@ -292,6 +300,7 @@ export interface RouteConfigOptions { * @example 9.0.0 */ discontinued?: string; + /** * Defines the security requirements for a route, including authorization and authentication. * diff --git a/packages/core/plugins/core-plugins-server-internal/src/plugin.ts b/packages/core/plugins/core-plugins-server-internal/src/plugin.ts index 8837cb24083d6..cd330a647da66 100644 --- a/packages/core/plugins/core-plugins-server-internal/src/plugin.ts +++ b/packages/core/plugins/core-plugins-server-internal/src/plugin.ts @@ -15,7 +15,6 @@ import { isConfigSchema } from '@kbn/config-schema'; import type { Logger } from '@kbn/logging'; import { type PluginOpaqueId, PluginType } from '@kbn/core-base-common'; import type { - AsyncPlugin, Plugin, PluginConfigDescriptor, PluginInitializer, @@ -58,8 +57,7 @@ export class PluginWrapper< private instance?: | Plugin - | PrebootPlugin - | AsyncPlugin; + | PrebootPlugin; private readonly startDependencies$ = new Subject<[CoreStart, TPluginsStart, TStart]>(); public readonly startDependencies = firstValueFrom(this.startDependencies$); diff --git a/packages/core/plugins/core-plugins-server/index.ts b/packages/core/plugins/core-plugins-server/index.ts index b2c6057c4a1ac..a5fd0fd2e2ec3 100644 --- a/packages/core/plugins/core-plugins-server/index.ts +++ b/packages/core/plugins/core-plugins-server/index.ts @@ -10,7 +10,6 @@ export type { PrebootPlugin, Plugin, - AsyncPlugin, PluginConfigDescriptor, PluginConfigSchema, PluginInitializer, diff --git a/packages/core/plugins/core-plugins-server/src/index.ts b/packages/core/plugins/core-plugins-server/src/index.ts index 35b1b7c11d422..e48d077389ece 100644 --- a/packages/core/plugins/core-plugins-server/src/index.ts +++ b/packages/core/plugins/core-plugins-server/src/index.ts @@ -10,7 +10,6 @@ export type { PrebootPlugin, Plugin, - AsyncPlugin, PluginConfigDescriptor, PluginConfigSchema, PluginInitializer, diff --git a/packages/core/plugins/core-plugins-server/src/types.ts b/packages/core/plugins/core-plugins-server/src/types.ts index 6da8b2727733e..7be2647ba48d2 100644 --- a/packages/core/plugins/core-plugins-server/src/types.ts +++ b/packages/core/plugins/core-plugins-server/src/types.ts @@ -301,26 +301,6 @@ export interface Plugin< stop?(): MaybePromise; } -/** - * A plugin with asynchronous lifecycle methods. - * - * @deprecated Asynchronous lifecycles are deprecated, and should be migrated to sync {@link Plugin | plugin} - * @removeBy 8.8.0 - * @public - */ -export interface AsyncPlugin< - TSetup = void, - TStart = void, - TPluginsSetup extends object = object, - TPluginsStart extends object = object -> { - setup(core: CoreSetup, plugins: TPluginsSetup): TSetup | Promise; - - start(core: CoreStart, plugins: TPluginsStart): TStart | Promise; - - stop?(): MaybePromise; -} - /** * @public */ @@ -478,7 +458,5 @@ export type PluginInitializer< > = ( core: PluginInitializerContext ) => Promise< - | Plugin - | PrebootPlugin - | AsyncPlugin + Plugin | PrebootPlugin >; diff --git a/packages/kbn-ace/README.md b/packages/kbn-ace/README.md deleted file mode 100644 index c11d5cc2f24b8..0000000000000 --- a/packages/kbn-ace/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# @kbn/ace - -This package contains the XJSON mode for brace. This is an extension of the `brace/mode/json` mode. - -This package also contains an import of the entire brace editor which is used for creating the custom XJSON worker. - -## Note to plugins -_This code should not be eagerly loaded_. - -Make sure imports of this package are behind a lazy-load `import()` statement. - -Your plugin should already be loading application code this way in the `mount` function. - -## Deprecated - -This package is considered deprecated and will be removed in future. - -New and existing editor functionality should use Monaco. - -_Do not add new functionality to this package_. Build new functionality for Monaco and use it instead. diff --git a/packages/kbn-ace/index.ts b/packages/kbn-ace/index.ts deleted file mode 100644 index c9cc0b7a73e86..0000000000000 --- a/packages/kbn-ace/index.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", 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 { - ElasticsearchSqlHighlightRules, - ScriptHighlightRules, - XJsonHighlightRules, - addXJsonToRules, - XJsonMode, - installXJsonMode, -} from './src/ace/modes'; diff --git a/packages/kbn-ace/kibana.jsonc b/packages/kbn-ace/kibana.jsonc deleted file mode 100644 index 0a01d96a6b1c6..0000000000000 --- a/packages/kbn-ace/kibana.jsonc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "type": "shared-common", - "id": "@kbn/ace", - "owner": "@elastic/kibana-management" -} diff --git a/packages/kbn-ace/package.json b/packages/kbn-ace/package.json deleted file mode 100644 index 3d3ed36941978..0000000000000 --- a/packages/kbn-ace/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "@kbn/ace", - "version": "1.0.0", - "private": true, - "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0" -} \ No newline at end of file diff --git a/packages/kbn-ace/src/ace/modes/index.ts b/packages/kbn-ace/src/ace/modes/index.ts deleted file mode 100644 index ffbb385663e48..0000000000000 --- a/packages/kbn-ace/src/ace/modes/index.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", 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 { - ElasticsearchSqlHighlightRules, - ScriptHighlightRules, - XJsonHighlightRules, - addXJsonToRules, -} from './lexer_rules'; - -export { installXJsonMode, XJsonMode } from './x_json'; diff --git a/packages/kbn-ace/src/ace/modes/lexer_rules/elasticsearch_sql_highlight_rules.ts b/packages/kbn-ace/src/ace/modes/lexer_rules/elasticsearch_sql_highlight_rules.ts deleted file mode 100644 index a4cb60529281d..0000000000000 --- a/packages/kbn-ace/src/ace/modes/lexer_rules/elasticsearch_sql_highlight_rules.ts +++ /dev/null @@ -1,104 +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 ace from 'brace'; - -const { TextHighlightRules } = ace.acequire('ace/mode/text_highlight_rules'); -const oop = ace.acequire('ace/lib/oop'); - -export const ElasticsearchSqlHighlightRules = function (this: any) { - // See https://www.elastic.co/guide/en/elasticsearch/reference/current/sql-commands.html - const keywords = - 'describe|between|in|like|not|and|or|desc|select|from|where|having|group|by|order' + - 'asc|desc|pivot|for|in|as|show|columns|include|frozen|tables|escape|limit|rlike|all|distinct|is'; - - const builtinConstants = 'true|false'; - - // See https://www.elastic.co/guide/en/elasticsearch/reference/current/sql-syntax-show-functions.html - const builtinFunctions = - 'avg|count|first|first_value|last|last_value|max|min|sum|kurtosis|mad|percentile|percentile_rank|skewness' + - '|stddev_pop|sum_of_squares|var_pop|histogram|case|coalesce|greatest|ifnull|iif|isnull|least|nullif|nvl' + - '|curdate|current_date|current_time|current_timestamp|curtime|dateadd|datediff|datepart|datetrunc|date_add' + - '|date_diff|date_part|date_trunc|day|dayname|dayofmonth|dayofweek|dayofyear|day_name|day_of_month|day_of_week' + - '|day_of_year|dom|dow|doy|hour|hour_of_day|idow|isodayofweek|isodow|isoweek|isoweekofyear|iso_day_of_week|iso_week_of_year' + - '|iw|iwoy|minute|minute_of_day|minute_of_hour|month|monthname|month_name|month_of_year|now|quarter|second|second_of_minute' + - '|timestampadd|timestampdiff|timestamp_add|timestamp_diff|today|week|week_of_year|year|abs|acos|asin|atan|atan2|cbrt' + - '|ceil|ceiling|cos|cosh|cot|degrees|e|exp|expm1|floor|log|log10|mod|pi|power|radians|rand|random|round|sign|signum|sin' + - '|sinh|sqrt|tan|truncate|ascii|bit_length|char|character_length|char_length|concat|insert|lcase|left|length|locate' + - '|ltrim|octet_length|position|repeat|replace|right|rtrim|space|substring|ucase|cast|convert|database|user|st_astext|st_aswkt' + - '|st_distance|st_geometrytype|st_geomfromtext|st_wkttosql|st_x|st_y|st_z|score'; - - // See https://www.elastic.co/guide/en/elasticsearch/reference/current/sql-data-types.html - const dataTypes = - 'null|boolean|byte|short|integer|long|double|float|half_float|scaled_float|keyword|text|binary|date|ip|object|nested|time' + - '|interval_year|interval_month|interval_day|interval_hour|interval_minute|interval_second|interval_year_to_month' + - 'inteval_day_to_hour|interval_day_to_minute|interval_day_to_second|interval_hour_to_minute|interval_hour_to_second' + - 'interval_minute_to_second|geo_point|geo_shape|shape'; - - const keywordMapper = this.createKeywordMapper( - { - keyword: [keywords, builtinFunctions, builtinConstants, dataTypes].join('|'), - }, - 'identifier', - true - ); - - this.$rules = { - start: [ - { - token: 'comment', - regex: '--.*$', - }, - { - token: 'comment', - start: '/\\*', - end: '\\*/', - }, - { - token: 'string', // " string - regex: '".*?"', - }, - { - token: 'constant', // ' string - regex: "'.*?'", - }, - { - token: 'string', // ` string (apache drill) - regex: '`.*?`', - }, - { - token: 'entity.name.function', // float - regex: '[+-]?\\d+(?:(?:\\.\\d*)?(?:[eE][+-]?\\d+)?)?\\b', - }, - { - token: keywordMapper, - regex: '[a-zA-Z_$][a-zA-Z0-9_$]*\\b', - }, - { - token: 'keyword.operator', - regex: '⇐|<⇒|\\*|\\.|\\:\\:|\\+|\\-|\\/|\\/\\/|%|&|\\^|~|<|>|<=|=>|==|!=|<>|=', - }, - { - token: 'paren.lparen', - regex: '[\\(]', - }, - { - token: 'paren.rparen', - regex: '[\\)]', - }, - { - token: 'text', - regex: '\\s+', - }, - ], - }; - this.normalizeRules(); -}; - -oop.inherits(ElasticsearchSqlHighlightRules, TextHighlightRules); diff --git a/packages/kbn-ace/src/ace/modes/lexer_rules/index.ts b/packages/kbn-ace/src/ace/modes/lexer_rules/index.ts deleted file mode 100644 index aa8c6af19c10f..0000000000000 --- a/packages/kbn-ace/src/ace/modes/lexer_rules/index.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", 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 { ElasticsearchSqlHighlightRules } from './elasticsearch_sql_highlight_rules'; -export { ScriptHighlightRules } from './script_highlight_rules'; -export { XJsonHighlightRules, addToRules as addXJsonToRules } from './x_json_highlight_rules'; diff --git a/packages/kbn-ace/src/ace/modes/lexer_rules/script_highlight_rules.ts b/packages/kbn-ace/src/ace/modes/lexer_rules/script_highlight_rules.ts deleted file mode 100644 index 64e8a1a6594bd..0000000000000 --- a/packages/kbn-ace/src/ace/modes/lexer_rules/script_highlight_rules.ts +++ /dev/null @@ -1,73 +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 ace from 'brace'; -const oop = ace.acequire('ace/lib/oop'); -const { TextHighlightRules } = ace.acequire('ace/mode/text_highlight_rules'); -const painlessKeywords = - 'def|int|long|byte|String|float|double|char|null|if|else|while|do|for|continue|break|new|try|catch|throw|this|instanceof|return|ctx'; - -export function ScriptHighlightRules(this: any) { - this.name = 'ScriptHighlightRules'; - this.$rules = { - start: [ - { - token: 'script.comment', - regex: '\\/\\/.*$', - }, - { - token: 'script.string.regexp', - regex: '[/](?:(?:\\[(?:\\\\]|[^\\]])+\\])|(?:\\\\/|[^\\]/]))*[/]\\w*\\s*(?=[).,;]|$)', - }, - { - token: 'script.string', // single line - regex: "['](?:(?:\\\\.)|(?:[^'\\\\]))*?[']", - }, - { - token: 'script.constant.numeric', // hex - regex: '0[xX][0-9a-fA-F]+\\b', - }, - { - token: 'script.constant.numeric', // float - regex: '[+-]?\\d+(?:(?:\\.\\d*)?(?:[eE][+-]?\\d+)?)?\\b', - }, - { - token: 'script.constant.language.boolean', - regex: '(?:true|false)\\b', - }, - { - token: 'script.keyword', - regex: painlessKeywords, - }, - { - token: 'script.text', - regex: '[a-zA-Z_$][a-zA-Z0-9_$]*\\b', - }, - { - token: 'script.keyword.operator', - regex: - '\\?\\.|\\*\\.|=~|==~|!|%|&|\\*|\\-\\-|\\-|\\+\\+|\\+|~|===|==|=|!=|!==|<=|>=|<<=|>>=|>>>=|<>|<|>|->|!|&&|\\|\\||\\?\\:|\\*=|%=|\\+=|\\-=|&=|\\^=|\\b(?:in|instanceof|new|typeof|void)', - }, - { - token: 'script.lparen', - regex: '[[({]', - }, - { - token: 'script.rparen', - regex: '[\\])}]', - }, - { - token: 'script.text', - regex: '\\s+', - }, - ], - }; -} - -oop.inherits(ScriptHighlightRules, TextHighlightRules); diff --git a/packages/kbn-ace/src/ace/modes/lexer_rules/x_json_highlight_rules.ts b/packages/kbn-ace/src/ace/modes/lexer_rules/x_json_highlight_rules.ts deleted file mode 100644 index f69e2fbbf5d8a..0000000000000 --- a/packages/kbn-ace/src/ace/modes/lexer_rules/x_json_highlight_rules.ts +++ /dev/null @@ -1,184 +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 { defaultsDeep } from 'lodash'; -import ace from 'brace'; -import 'brace/mode/json'; - -import { ElasticsearchSqlHighlightRules } from './elasticsearch_sql_highlight_rules'; -import { ScriptHighlightRules } from './script_highlight_rules'; - -const { JsonHighlightRules } = ace.acequire('ace/mode/json_highlight_rules'); -const oop = ace.acequire('ace/lib/oop'); - -const jsonRules = function (root: any) { - root = root ? root : 'json'; - const rules: any = {}; - const xJsonRules = [ - { - token: [ - 'variable', - 'whitespace', - 'ace.punctuation.colon', - 'whitespace', - 'punctuation.start_triple_quote', - ], - regex: '("(?:[^"]*_)?script"|"inline"|"source")(\\s*?)(:)(\\s*?)(""")', - next: 'script-start', - merge: false, - push: true, - }, - { - token: 'variable', // single line - regex: '["](?:(?:\\\\.)|(?:[^"\\\\]))*?["]\\s*(?=:)', - }, - { - token: 'punctuation.start_triple_quote', - regex: '"""', - next: 'string_literal', - merge: false, - push: true, - }, - { - token: 'string', // single line - regex: '["](?:(?:\\\\.)|(?:[^"\\\\]))*?["]', - }, - { - token: 'constant.numeric', // hex - regex: '0[xX][0-9a-fA-F]+\\b', - }, - { - token: 'constant.numeric', // float - regex: '[+-]?\\d+(?:(?:\\.\\d*)?(?:[eE][+-]?\\d+)?)?\\b', - }, - { - token: 'constant.language.boolean', - regex: '(?:true|false)\\b', - }, - { - token: 'invalid.illegal', // single quoted strings are not allowed - regex: "['](?:(?:\\\\.)|(?:[^'\\\\]))*?[']", - }, - { - token: 'invalid.illegal', // comments are not allowed - regex: '\\/\\/.*$', - }, - { - token: 'paren.lparen', - merge: false, - regex: '{', - next: root, - push: true, - }, - { - token: 'paren.lparen', - merge: false, - regex: '[[(]', - }, - { - token: 'paren.rparen', - merge: false, - regex: '[\\])]', - }, - { - token: 'paren.rparen', - regex: '}', - merge: false, - next: 'pop', - }, - { - token: 'punctuation.comma', - regex: ',', - }, - { - token: 'punctuation.colon', - regex: ':', - }, - { - token: 'whitespace', - regex: '\\s+', - }, - { - token: 'text', - regex: '.+?', - }, - ]; - - rules[root] = xJsonRules; - rules[root + '-sql'] = [ - { - token: [ - 'variable', - 'whitespace', - 'ace.punctuation.colon', - 'whitespace', - 'punctuation.start_triple_quote', - ], - regex: '("query")(\\s*?)(:)(\\s*?)(""")', - next: 'sql-start', - merge: false, - push: true, - }, - ].concat(xJsonRules as any); - - rules.string_literal = [ - { - token: 'punctuation.end_triple_quote', - regex: '"""', - next: 'pop', - }, - { - token: 'multi_string', - regex: '.', - }, - ]; - return rules; -}; - -export function XJsonHighlightRules(this: any) { - this.$rules = { - ...jsonRules('start'), - }; - - this.embedRules(ScriptHighlightRules, 'script-', [ - { - token: 'punctuation.end_triple_quote', - regex: '"""', - next: 'pop', - }, - ]); - - this.embedRules(ElasticsearchSqlHighlightRules, 'sql-', [ - { - token: 'punctuation.end_triple_quote', - regex: '"""', - next: 'pop', - }, - ]); -} - -oop.inherits(XJsonHighlightRules, JsonHighlightRules); - -export function addToRules(otherRules: any, embedUnder: any) { - otherRules.$rules = defaultsDeep(otherRules.$rules, jsonRules(embedUnder)); - otherRules.embedRules(ScriptHighlightRules, 'script-', [ - { - token: 'punctuation.end_triple_quote', - regex: '"""', - next: 'pop', - }, - ]); - otherRules.embedRules(ElasticsearchSqlHighlightRules, 'sql-', [ - { - token: 'punctuation.end_triple_quote', - regex: '"""', - next: 'pop', - }, - ]); -} diff --git a/packages/kbn-ace/src/ace/modes/x_json/index.ts b/packages/kbn-ace/src/ace/modes/x_json/index.ts deleted file mode 100644 index a1651c9e06979..0000000000000 --- a/packages/kbn-ace/src/ace/modes/x_json/index.ts +++ /dev/null @@ -1,10 +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 { installXJsonMode, XJsonMode } from './x_json'; diff --git a/packages/kbn-ace/src/ace/modes/x_json/worker/worker.d.ts b/packages/kbn-ace/src/ace/modes/x_json/worker/worker.d.ts deleted file mode 100644 index 34598ea61003b..0000000000000 --- a/packages/kbn-ace/src/ace/modes/x_json/worker/worker.d.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", 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". - */ - -// Satisfy TS's requirements that the module be declared per './index.ts'. -declare module '!!raw-loader!./worker.js' { - const content: string; - // eslint-disable-next-line import/no-default-export - export default content; -} diff --git a/packages/kbn-ace/src/ace/modes/x_json/worker/x_json.ace.worker.js b/packages/kbn-ace/src/ace/modes/x_json/worker/x_json.ace.worker.js deleted file mode 100644 index 63ca258e524d4..0000000000000 --- a/packages/kbn-ace/src/ace/modes/x_json/worker/x_json.ace.worker.js +++ /dev/null @@ -1,1265 +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". - */ - -/* @notice - * - * This product includes code that is based on Ace editor, which was available - * under a "BSD" license. - * - * Distributed under the BSD license: - * - * Copyright (c) 2010, Ajax.org B.V. - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * * Redistributions of source code must retain the above copyright - * notice, this list of conditions and the following disclaimer. - * * Redistributions in binary form must reproduce the above copyright - * notice, this list of conditions and the following disclaimer in the - * documentation and/or other materials provided with the distribution. - * * Neither the name of Ajax.org B.V. nor the - * names of its contributors may be used to endorse or promote products - * derived from this software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND - * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -/* eslint-disable prettier/prettier,no-var,eqeqeq,no-use-before-define,block-scoped-var,no-undef, - guard-for-in,one-var,strict,no-redeclare,no-sequences,no-proto,new-cap,no-nested-ternary,no-unused-vars, - prefer-const,no-empty,no-extend-native,camelcase */ -/* - This file is loaded up as a blob by Brace to hand to Ace to load as Jsonp - (hence the redefining of everything). It is based on the json - mode from the brace distro. - - It is very likely that this file will be removed in future but for now it enables - extended JSON parsing, like e.g. """{}""" (triple quotes) -*/ -// @internal -// @ts-nocheck -"no use strict"; -! function(window) { - function resolveModuleId(id, paths) { - for (var testPath = id, tail = ""; testPath;) { - var alias = paths[testPath]; - if ("string" == typeof alias) return alias + tail; - if (alias) return alias.location.replace(/\/*$/, "/") + (tail || alias.main || alias.name); - if (alias === !1) return ""; - var i = testPath.lastIndexOf("/"); - if (-1 === i) break; - tail = testPath.substr(i) + tail, testPath = testPath.slice(0, i) - } - return id - } - if (!(void 0 !== window.window && window.document || window.acequire && window.define)) { - window.console || (window.console = function() { - var msgs = Array.prototype.slice.call(arguments, 0); - postMessage({ - type: "log", - data: msgs - }) - }, window.console.error = window.console.warn = window.console.log = window.console.trace = window.console), window.window = window, window.ace = window, window.onerror = function(message, file, line, col, err) { - postMessage({ - type: "error", - data: { - message: message, - data: err.data, - file: file, - line: line, - col: col, - stack: err.stack - } - }) - }, window.normalizeModule = function(parentId, moduleName) { - if (-1 !== moduleName.indexOf("!")) { - var chunks = moduleName.split("!"); - return window.normalizeModule(parentId, chunks[0]) + "!" + window.normalizeModule(parentId, chunks[1]) - } - if ("." == moduleName.charAt(0)) { - var base = parentId.split("/").slice(0, -1).join("/"); - for (moduleName = (base ? base + "/" : "") + moduleName; - 1 !== moduleName.indexOf(".") && previous != moduleName;) { - var previous = moduleName; - moduleName = moduleName.replace(/^\.\//, "").replace(/\/\.\//, "/").replace(/[^\/]+\/\.\.\//, "") - } - } - return moduleName - }, window.acequire = function acequire(parentId, id) { - if (id || (id = parentId, parentId = null), !id.charAt) throw Error("worker.js acequire() accepts only (parentId, id) as arguments"); - id = window.normalizeModule(parentId, id); - var module = window.acequire.modules[id]; - if (module) return module.initialized || (module.initialized = !0, module.exports = module.factory().exports), module.exports; - if (!window.acequire.tlns) return console.log("unable to load " + id); - var path = resolveModuleId(id, window.acequire.tlns); - return ".js" != path.slice(-3) && (path += ".js"), window.acequire.id = id, window.acequire.modules[id] = {}, importScripts(path), window.acequire(parentId, id) - }, window.acequire.modules = {}, window.acequire.tlns = {}, window.define = function(id, deps, factory) { - if (2 == arguments.length ? (factory = deps, "string" != typeof id && (deps = id, id = window.acequire.id)) : 1 == arguments.length && (factory = id, deps = [], id = window.acequire.id), "function" != typeof factory) return window.acequire.modules[id] = { - exports: factory, - initialized: !0 - }, void 0; - deps.length || (deps = ["require", "exports", "module"]); - var req = function(childId) { - return window.acequire(id, childId) - }; - window.acequire.modules[id] = { - exports: {}, - factory: function() { - var module = this, - returnExports = factory.apply(this, deps.map(function(dep) { - switch (dep) { - case "require": - return req; - case "exports": - return module.exports; - case "module": - return module; - default: - return req(dep) - } - })); - return returnExports && (module.exports = returnExports), module - } - } - }, window.define.amd = {}, acequire.tlns = {}, window.initBaseUrls = function(topLevelNamespaces) { - for (var i in topLevelNamespaces) acequire.tlns[i] = topLevelNamespaces[i] - }, window.initSender = function() { - var EventEmitter = window.acequire("ace/lib/event_emitter").EventEmitter, - oop = window.acequire("ace/lib/oop"), - Sender = function() {}; - return function() { - oop.implement(this, EventEmitter), this.callback = function(data, callbackId) { - postMessage({ - type: "call", - id: callbackId, - data: data - }) - }, this.emit = function(name, data) { - postMessage({ - type: "event", - name: name, - data: data - }) - } - }.call(Sender.prototype), new Sender - }; - var main = window.main = null, - sender = window.sender = null; - window.onmessage = function(e) { - var msg = e.data; - if (msg.event && sender) sender._signal(msg.event, msg.data); - else if (msg.command) - if (main[msg.command]) main[msg.command].apply(main, msg.args); - else { - if (!window[msg.command]) throw Error("Unknown command:" + msg.command); - window[msg.command].apply(window, msg.args) - } - else if (msg.init) { - window.initBaseUrls(msg.tlns), acequire("ace/lib/es5-shim"), sender = window.sender = window.initSender(); - var clazz = acequire(msg.module)[msg.classname]; - main = window.main = new clazz(sender) - } - } - } -}(this), ace.define("ace/lib/oop", ["require", "exports", "module"], function(acequire, exports) { - "use strict"; - exports.inherits = function(ctor, superCtor) { - ctor.super_ = superCtor, ctor.prototype = Object.create(superCtor.prototype, { - constructor: { - value: ctor, - enumerable: !1, - writable: !0, - configurable: !0 - } - }) - }, exports.mixin = function(obj, mixin) { - for (var key in mixin) obj[key] = mixin[key]; - return obj - }, exports.implement = function(proto, mixin) { - exports.mixin(proto, mixin) - } -}), ace.define("ace/range", ["require", "exports", "module"], function(acequire, exports) { - "use strict"; - var comparePoints = function(p1, p2) { - return p1.row - p2.row || p1.column - p2.column - }, - Range = function(startRow, startColumn, endRow, endColumn) { - this.start = { - row: startRow, - column: startColumn - }, this.end = { - row: endRow, - column: endColumn - } - }; - (function() { - this.isEqual = function(range) { - return this.start.row === range.start.row && this.end.row === range.end.row && this.start.column === range.start.column && this.end.column === range.end.column - }, this.toString = function() { - return "Range: [" + this.start.row + "/" + this.start.column + "] -> [" + this.end.row + "/" + this.end.column + "]" - }, this.contains = function(row, column) { - return 0 == this.compare(row, column) - }, this.compareRange = function(range) { - var cmp, end = range.end, - start = range.start; - return cmp = this.compare(end.row, end.column), 1 == cmp ? (cmp = this.compare(start.row, start.column), 1 == cmp ? 2 : 0 == cmp ? 1 : 0) : -1 == cmp ? -2 : (cmp = this.compare(start.row, start.column), -1 == cmp ? -1 : 1 == cmp ? 42 : 0) - }, this.comparePoint = function(p) { - return this.compare(p.row, p.column) - }, this.containsRange = function(range) { - return 0 == this.comparePoint(range.start) && 0 == this.comparePoint(range.end) - }, this.intersects = function(range) { - var cmp = this.compareRange(range); - return -1 == cmp || 0 == cmp || 1 == cmp - }, this.isEnd = function(row, column) { - return this.end.row == row && this.end.column == column - }, this.isStart = function(row, column) { - return this.start.row == row && this.start.column == column - }, this.setStart = function(row, column) { - "object" == typeof row ? (this.start.column = row.column, this.start.row = row.row) : (this.start.row = row, this.start.column = column) - }, this.setEnd = function(row, column) { - "object" == typeof row ? (this.end.column = row.column, this.end.row = row.row) : (this.end.row = row, this.end.column = column) - }, this.inside = function(row, column) { - return 0 == this.compare(row, column) ? this.isEnd(row, column) || this.isStart(row, column) ? !1 : !0 : !1 - }, this.insideStart = function(row, column) { - return 0 == this.compare(row, column) ? this.isEnd(row, column) ? !1 : !0 : !1 - }, this.insideEnd = function(row, column) { - return 0 == this.compare(row, column) ? this.isStart(row, column) ? !1 : !0 : !1 - }, this.compare = function(row, column) { - return this.isMultiLine() || row !== this.start.row ? this.start.row > row ? -1 : row > this.end.row ? 1 : this.start.row === row ? column >= this.start.column ? 0 : -1 : this.end.row === row ? this.end.column >= column ? 0 : 1 : 0 : this.start.column > column ? -1 : column > this.end.column ? 1 : 0 - }, this.compareStart = function(row, column) { - return this.start.row == row && this.start.column == column ? -1 : this.compare(row, column) - }, this.compareEnd = function(row, column) { - return this.end.row == row && this.end.column == column ? 1 : this.compare(row, column) - }, this.compareInside = function(row, column) { - return this.end.row == row && this.end.column == column ? 1 : this.start.row == row && this.start.column == column ? -1 : this.compare(row, column) - }, this.clipRows = function(firstRow, lastRow) { - if (this.end.row > lastRow) var end = { - row: lastRow + 1, - column: 0 - }; - else if (firstRow > this.end.row) var end = { - row: firstRow, - column: 0 - }; - if (this.start.row > lastRow) var start = { - row: lastRow + 1, - column: 0 - }; - else if (firstRow > this.start.row) var start = { - row: firstRow, - column: 0 - }; - return Range.fromPoints(start || this.start, end || this.end) - }, this.extend = function(row, column) { - var cmp = this.compare(row, column); - if (0 == cmp) return this; - if (-1 == cmp) var start = { - row: row, - column: column - }; - else var end = { - row: row, - column: column - }; - return Range.fromPoints(start || this.start, end || this.end) - }, this.isEmpty = function() { - return this.start.row === this.end.row && this.start.column === this.end.column - }, this.isMultiLine = function() { - return this.start.row !== this.end.row - }, this.clone = function() { - return Range.fromPoints(this.start, this.end) - }, this.collapseRows = function() { - return 0 == this.end.column ? new Range(this.start.row, 0, Math.max(this.start.row, this.end.row - 1), 0) : new Range(this.start.row, 0, this.end.row, 0) - }, this.toScreenRange = function(session) { - var screenPosStart = session.documentToScreenPosition(this.start), - screenPosEnd = session.documentToScreenPosition(this.end); - return new Range(screenPosStart.row, screenPosStart.column, screenPosEnd.row, screenPosEnd.column) - }, this.moveBy = function(row, column) { - this.start.row += row, this.start.column += column, this.end.row += row, this.end.column += column - } - }).call(Range.prototype), Range.fromPoints = function(start, end) { - return new Range(start.row, start.column, end.row, end.column) - }, Range.comparePoints = comparePoints, Range.comparePoints = function(p1, p2) { - return p1.row - p2.row || p1.column - p2.column - }, exports.Range = Range -}), ace.define("ace/apply_delta", ["require", "exports", "module"], function(acequire, exports) { - "use strict"; - exports.applyDelta = function(docLines, delta) { - var row = delta.start.row, - startColumn = delta.start.column, - line = docLines[row] || ""; - switch (delta.action) { - case "insert": - var lines = delta.lines; - if (1 === lines.length) docLines[row] = line.substring(0, startColumn) + delta.lines[0] + line.substring(startColumn); - else { - var args = [row, 1].concat(delta.lines); - docLines.splice.apply(docLines, args), docLines[row] = line.substring(0, startColumn) + docLines[row], docLines[row + delta.lines.length - 1] += line.substring(startColumn) - } - break; - case "remove": - var endColumn = delta.end.column, - endRow = delta.end.row; - row === endRow ? docLines[row] = line.substring(0, startColumn) + line.substring(endColumn) : docLines.splice(row, endRow - row + 1, line.substring(0, startColumn) + docLines[endRow].substring(endColumn)) - } - } -}), ace.define("ace/lib/event_emitter", ["require", "exports", "module"], function(acequire, exports) { - "use strict"; - var EventEmitter = {}, - stopPropagation = function() { - this.propagationStopped = !0 - }, - preventDefault = function() { - this.defaultPrevented = !0 - }; - EventEmitter._emit = EventEmitter._dispatchEvent = function(eventName, e) { - this._eventRegistry || (this._eventRegistry = {}), this._defaultHandlers || (this._defaultHandlers = {}); - var listeners = this._eventRegistry[eventName] || [], - defaultHandler = this._defaultHandlers[eventName]; - if (listeners.length || defaultHandler) { - "object" == typeof e && e || (e = {}), e.type || (e.type = eventName), e.stopPropagation || (e.stopPropagation = stopPropagation), e.preventDefault || (e.preventDefault = preventDefault), listeners = listeners.slice(); - for (var i = 0; listeners.length > i && (listeners[i](e, this), !e.propagationStopped); i++); - return defaultHandler && !e.defaultPrevented ? defaultHandler(e, this) : void 0 - } - }, EventEmitter._signal = function(eventName, e) { - var listeners = (this._eventRegistry || {})[eventName]; - if (listeners) { - listeners = listeners.slice(); - for (var i = 0; listeners.length > i; i++) listeners[i](e, this) - } - }, EventEmitter.once = function(eventName, callback) { - var _self = this; - callback && this.addEventListener(eventName, function newCallback() { - _self.removeEventListener(eventName, newCallback), callback.apply(null, arguments) - }) - }, EventEmitter.setDefaultHandler = function(eventName, callback) { - var handlers = this._defaultHandlers; - if (handlers || (handlers = this._defaultHandlers = { - _disabled_: {} - }), handlers[eventName]) { - var old = handlers[eventName], - disabled = handlers._disabled_[eventName]; - disabled || (handlers._disabled_[eventName] = disabled = []), disabled.push(old); - var i = disabled.indexOf(callback); - 1 != i && disabled.splice(i, 1) - } - handlers[eventName] = callback - }, EventEmitter.removeDefaultHandler = function(eventName, callback) { - var handlers = this._defaultHandlers; - if (handlers) { - var disabled = handlers._disabled_[eventName]; - if (handlers[eventName] == callback) handlers[eventName], disabled && this.setDefaultHandler(eventName, disabled.pop()); - else if (disabled) { - var i = disabled.indexOf(callback); - 1 != i && disabled.splice(i, 1) - } - } - }, EventEmitter.on = EventEmitter.addEventListener = function(eventName, callback, capturing) { - this._eventRegistry = this._eventRegistry || {}; - var listeners = this._eventRegistry[eventName]; - return listeners || (listeners = this._eventRegistry[eventName] = []), -1 == listeners.indexOf(callback) && listeners[capturing ? "unshift" : "push"](callback), callback - }, EventEmitter.off = EventEmitter.removeListener = EventEmitter.removeEventListener = function(eventName, callback) { - this._eventRegistry = this._eventRegistry || {}; - var listeners = this._eventRegistry[eventName]; - if (listeners) { - var index = listeners.indexOf(callback); - 1 !== index && listeners.splice(index, 1) - } - }, EventEmitter.removeAllListeners = function(eventName) { - this._eventRegistry && (this._eventRegistry[eventName] = []) - }, exports.EventEmitter = EventEmitter -}), ace.define("ace/anchor", ["require", "exports", "module", "ace/lib/oop", "ace/lib/event_emitter"], function(acequire, exports) { - "use strict"; - var oop = acequire("./lib/oop"), - EventEmitter = acequire("./lib/event_emitter").EventEmitter, - Anchor = exports.Anchor = function(doc, row, column) { - this.$onChange = this.onChange.bind(this), this.attach(doc), column === void 0 ? this.setPosition(row.row, row.column) : this.setPosition(row, column) - }; - (function() { - function $pointsInOrder(point1, point2, equalPointsInOrder) { - var bColIsAfter = equalPointsInOrder ? point1.column <= point2.column : point1.column < point2.column; - return point1.row < point2.row || point1.row == point2.row && bColIsAfter - } - - function $getTransformedPoint(delta, point, moveIfEqual) { - var deltaIsInsert = "insert" == delta.action, - deltaRowShift = (deltaIsInsert ? 1 : -1) * (delta.end.row - delta.start.row), - deltaColShift = (deltaIsInsert ? 1 : -1) * (delta.end.column - delta.start.column), - deltaStart = delta.start, - deltaEnd = deltaIsInsert ? deltaStart : delta.end; - return $pointsInOrder(point, deltaStart, moveIfEqual) ? { - row: point.row, - column: point.column - } : $pointsInOrder(deltaEnd, point, !moveIfEqual) ? { - row: point.row + deltaRowShift, - column: point.column + (point.row == deltaEnd.row ? deltaColShift : 0) - } : { - row: deltaStart.row, - column: deltaStart.column - } - } - oop.implement(this, EventEmitter), this.getPosition = function() { - return this.$clipPositionToDocument(this.row, this.column) - }, this.getDocument = function() { - return this.document - }, this.$insertRight = !1, this.onChange = function(delta) { - if (!(delta.start.row == delta.end.row && delta.start.row != this.row || delta.start.row > this.row)) { - var point = $getTransformedPoint(delta, { - row: this.row, - column: this.column - }, this.$insertRight); - this.setPosition(point.row, point.column, !0) - } - }, this.setPosition = function(row, column, noClip) { - var pos; - if (pos = noClip ? { - row: row, - column: column - } : this.$clipPositionToDocument(row, column), this.row != pos.row || this.column != pos.column) { - var old = { - row: this.row, - column: this.column - }; - this.row = pos.row, this.column = pos.column, this._signal("change", { - old: old, - value: pos - }) - } - }, this.detach = function() { - this.document.removeEventListener("change", this.$onChange) - }, this.attach = function(doc) { - this.document = doc || this.document, this.document.on("change", this.$onChange) - }, this.$clipPositionToDocument = function(row, column) { - var pos = {}; - return row >= this.document.getLength() ? (pos.row = Math.max(0, this.document.getLength() - 1), pos.column = this.document.getLine(pos.row).length) : 0 > row ? (pos.row = 0, pos.column = 0) : (pos.row = row, pos.column = Math.min(this.document.getLine(pos.row).length, Math.max(0, column))), 0 > column && (pos.column = 0), pos - } - }).call(Anchor.prototype) -}), ace.define("ace/document", ["require", "exports", "module", "ace/lib/oop", "ace/apply_delta", "ace/lib/event_emitter", "ace/range", "ace/anchor"], function(acequire, exports) { - "use strict"; - var oop = acequire("./lib/oop"), - applyDelta = acequire("./apply_delta").applyDelta, - EventEmitter = acequire("./lib/event_emitter").EventEmitter, - Range = acequire("./range").Range, - Anchor = acequire("./anchor").Anchor, - Document = function(textOrLines) { - this.$lines = [""], 0 === textOrLines.length ? this.$lines = [""] : Array.isArray(textOrLines) ? this.insertMergedLines({ - row: 0, - column: 0 - }, textOrLines) : this.insert({ - row: 0, - column: 0 - }, textOrLines) - }; - (function() { - oop.implement(this, EventEmitter), this.setValue = function(text) { - var len = this.getLength() - 1; - this.remove(new Range(0, 0, len, this.getLine(len).length)), this.insert({ - row: 0, - column: 0 - }, text) - }, this.getValue = function() { - return this.getAllLines().join(this.getNewLineCharacter()) - }, this.createAnchor = function(row, column) { - return new Anchor(this, row, column) - }, this.$split = 0 === "aaa".split(/a/).length ? function(text) { - return text.replace(/\r\n|\r/g, "\n").split("\n"); - } : function(text) { - return text.split(/\r\n|\r|\n/); - }, this.$detectNewLine = function(text) { - var match = text.match(/^.*?(\r\n|\r|\n)/m); - this.$autoNewLine = match ? match[1] : "\n", this._signal("changeNewLineMode") - }, this.getNewLineCharacter = function() { - switch (this.$newLineMode) { - case "windows": - return "\r\n"; - case "unix": - return "\n"; - default: - return this.$autoNewLine || "\n" - } - }, this.$autoNewLine = "", this.$newLineMode = "auto", this.setNewLineMode = function(newLineMode) { - this.$newLineMode !== newLineMode && (this.$newLineMode = newLineMode, this._signal("changeNewLineMode")) - }, this.getNewLineMode = function() { - return this.$newLineMode - }, this.isNewLine = function(text) { - return "\r\n" == text || "\r" == text || "\n" == text - }, this.getLine = function(row) { - return this.$lines[row] || "" - }, this.getLines = function(firstRow, lastRow) { - return this.$lines.slice(firstRow, lastRow + 1) - }, this.getAllLines = function() { - return this.getLines(0, this.getLength()) - }, this.getLength = function() { - return this.$lines.length - }, this.getTextRange = function(range) { - return this.getLinesForRange(range).join(this.getNewLineCharacter()) - }, this.getLinesForRange = function(range) { - var lines; - if (range.start.row === range.end.row) lines = [this.getLine(range.start.row).substring(range.start.column, range.end.column)]; - else { - lines = this.getLines(range.start.row, range.end.row), lines[0] = (lines[0] || "").substring(range.start.column); - var l = lines.length - 1; - range.end.row - range.start.row == l && (lines[l] = lines[l].substring(0, range.end.column)) - } - return lines - }, this.insertLines = function(row, lines) { - return console.warn("Use of document.insertLines is deprecated. Use the insertFullLines method instead."), this.insertFullLines(row, lines) - }, this.removeLines = function(firstRow, lastRow) { - return console.warn("Use of document.removeLines is deprecated. Use the removeFullLines method instead."), this.removeFullLines(firstRow, lastRow) - }, this.insertNewLine = function(position) { - return console.warn("Use of document.insertNewLine is deprecated. Use insertMergedLines(position, ['', '']) instead."), this.insertMergedLines(position, ["", ""]) - }, this.insert = function(position, text) { - return 1 >= this.getLength() && this.$detectNewLine(text), this.insertMergedLines(position, this.$split(text)) - }, this.insertInLine = function(position, text) { - var start = this.clippedPos(position.row, position.column), - end = this.pos(position.row, position.column + text.length); - return this.applyDelta({ - start: start, - end: end, - action: "insert", - lines: [text] - }, !0), this.clonePos(end) - }, this.clippedPos = function(row, column) { - var length = this.getLength(); - void 0 === row ? row = length : 0 > row ? row = 0 : row >= length && (row = length - 1, column = void 0); - var line = this.getLine(row); - return void 0 == column && (column = line.length), column = Math.min(Math.max(column, 0), line.length), { - row: row, - column: column - } - }, this.clonePos = function(pos) { - return { - row: pos.row, - column: pos.column - } - }, this.pos = function(row, column) { - return { - row: row, - column: column - } - }, this.$clipPosition = function(position) { - var length = this.getLength(); - return position.row >= length ? (position.row = Math.max(0, length - 1), position.column = this.getLine(length - 1).length) : (position.row = Math.max(0, position.row), position.column = Math.min(Math.max(position.column, 0), this.getLine(position.row).length)), position - }, this.insertFullLines = function(row, lines) { - row = Math.min(Math.max(row, 0), this.getLength()); - var column = 0; - this.getLength() > row ? (lines = lines.concat([""]), column = 0) : (lines = [""].concat(lines), row--, column = this.$lines[row].length), this.insertMergedLines({ - row: row, - column: column - }, lines) - }, this.insertMergedLines = function(position, lines) { - var start = this.clippedPos(position.row, position.column), - end = { - row: start.row + lines.length - 1, - column: (1 == lines.length ? start.column : 0) + lines[lines.length - 1].length - }; - return this.applyDelta({ - start: start, - end: end, - action: "insert", - lines: lines - }), this.clonePos(end) - }, this.remove = function(range) { - var start = this.clippedPos(range.start.row, range.start.column), - end = this.clippedPos(range.end.row, range.end.column); - return this.applyDelta({ - start: start, - end: end, - action: "remove", - lines: this.getLinesForRange({ - start: start, - end: end - }) - }), this.clonePos(start) - }, this.removeInLine = function(row, startColumn, endColumn) { - var start = this.clippedPos(row, startColumn), - end = this.clippedPos(row, endColumn); - return this.applyDelta({ - start: start, - end: end, - action: "remove", - lines: this.getLinesForRange({ - start: start, - end: end - }) - }, !0), this.clonePos(start) - }, this.removeFullLines = function(firstRow, lastRow) { - firstRow = Math.min(Math.max(0, firstRow), this.getLength() - 1), lastRow = Math.min(Math.max(0, lastRow), this.getLength() - 1); - var deleteFirstNewLine = lastRow == this.getLength() - 1 && firstRow > 0, - deleteLastNewLine = this.getLength() - 1 > lastRow, - startRow = deleteFirstNewLine ? firstRow - 1 : firstRow, - startCol = deleteFirstNewLine ? this.getLine(startRow).length : 0, - endRow = deleteLastNewLine ? lastRow + 1 : lastRow, - endCol = deleteLastNewLine ? 0 : this.getLine(endRow).length, - range = new Range(startRow, startCol, endRow, endCol), - deletedLines = this.$lines.slice(firstRow, lastRow + 1); - return this.applyDelta({ - start: range.start, - end: range.end, - action: "remove", - lines: this.getLinesForRange(range) - }), deletedLines - }, this.removeNewLine = function(row) { - this.getLength() - 1 > row && row >= 0 && this.applyDelta({ - start: this.pos(row, this.getLine(row).length), - end: this.pos(row + 1, 0), - action: "remove", - lines: ["", ""] - }) - }, this.replace = function(range, text) { - if (range instanceof Range || (range = Range.fromPoints(range.start, range.end)), 0 === text.length && range.isEmpty()) return range.start; - if (text == this.getTextRange(range)) return range.end; - this.remove(range); - var end; - return end = text ? this.insert(range.start, text) : range.start - }, this.applyDeltas = function(deltas) { - for (var i = 0; deltas.length > i; i++) this.applyDelta(deltas[i]) - }, this.revertDeltas = function(deltas) { - for (var i = deltas.length - 1; i >= 0; i--) this.revertDelta(deltas[i]) - }, this.applyDelta = function(delta, doNotValidate) { - var isInsert = "insert" == delta.action; - (isInsert ? 1 >= delta.lines.length && !delta.lines[0] : !Range.comparePoints(delta.start, delta.end)) || (isInsert && delta.lines.length > 2e4 && this.$splitAndapplyLargeDelta(delta, 2e4), applyDelta(this.$lines, delta, doNotValidate), this._signal("change", delta)) - }, this.$splitAndapplyLargeDelta = function(delta, MAX) { - for (var lines = delta.lines, l = lines.length, row = delta.start.row, column = delta.start.column, from = 0, to = 0;;) { - from = to, to += MAX - 1; - var chunk = lines.slice(from, to); - if (to > l) { - delta.lines = chunk, delta.start.row = row + from, delta.start.column = column; - break - } - chunk.push(""), this.applyDelta({ - start: this.pos(row + from, column), - end: this.pos(row + to, column = 0), - action: delta.action, - lines: chunk - }, !0) - } - }, this.revertDelta = function(delta) { - this.applyDelta({ - start: this.clonePos(delta.start), - end: this.clonePos(delta.end), - action: "insert" == delta.action ? "remove" : "insert", - lines: delta.lines.slice() - }) - }, this.indexToPosition = function(index, startRow) { - for (var lines = this.$lines || this.getAllLines(), newlineLength = this.getNewLineCharacter().length, i = startRow || 0, l = lines.length; l > i; i++) - if (index -= lines[i].length + newlineLength, 0 > index) return { - row: i, - column: index + lines[i].length + newlineLength - }; - return { - row: l - 1, - column: lines[l - 1].length - } - }, this.positionToIndex = function(pos, startRow) { - for (var lines = this.$lines || this.getAllLines(), newlineLength = this.getNewLineCharacter().length, index = 0, row = Math.min(pos.row, lines.length), i = startRow || 0; row > i; ++i) index += lines[i].length + newlineLength; - return index + pos.column - } - }).call(Document.prototype), exports.Document = Document -}), ace.define("ace/lib/lang", ["require", "exports", "module"], function(acequire, exports) { - "use strict"; - exports.last = function(a) { - return a[a.length - 1] - }, exports.stringReverse = function(string) { - return string.split("").reverse().join("") - }, exports.stringRepeat = function(string, count) { - for (var result = ""; count > 0;) 1 & count && (result += string), (count >>= 1) && (string += string); - return result - }; - var trimBeginRegexp = /^\s\s*/, - trimEndRegexp = /\s\s*$/; - exports.stringTrimLeft = function(string) { - return string.replace(trimBeginRegexp, "") - }, exports.stringTrimRight = function(string) { - return string.replace(trimEndRegexp, "") - }, exports.copyObject = function(obj) { - var copy = {}; - for (var key in obj) copy[key] = obj[key]; - return copy - }, exports.copyArray = function(array) { - for (var copy = [], i = 0, l = array.length; l > i; i++) copy[i] = array[i] && "object" == typeof array[i] ? this.copyObject(array[i]) : array[i]; - return copy - }, exports.deepCopy = function deepCopy(obj) { - if ("object" != typeof obj || !obj) return obj; - var copy; - if (Array.isArray(obj)) { - copy = []; - for (var key = 0; obj.length > key; key++) copy[key] = deepCopy(obj[key]); - return copy - } - if ("[object Object]" !== Object.prototype.toString.call(obj)) return obj; - copy = {}; - for (var key in obj) copy[key] = deepCopy(obj[key]); - return copy - }, exports.arrayToMap = function(arr) { - for (var map = {}, i = 0; arr.length > i; i++) map[arr[i]] = 1; - return map - }, exports.createMap = function(props) { - var map = Object.create(null); - for (var i in props) map[i] = props[i]; - return map - }, exports.arrayRemove = function(array, value) { - for (var i = 0; array.length >= i; i++) value === array[i] && array.splice(i, 1) - }, exports.escapeRegExp = function(str) { - return str.replace(/([.*+?^${}()|[\]\/\\])/g, "\\$1"); - }, exports.escapeHTML = function(str) { - return str.replace(/&/g, "&").replace(/"/g, """).replace(/'/g, "'").replace(/ i; i += 2) { - if (Array.isArray(data[i + 1])) var d = { - action: "insert", - start: data[i], - lines: data[i + 1] - }; - else var d = { - action: "remove", - start: data[i], - end: data[i + 1] - }; - doc.applyDelta(d, !0) - } - return _self.$timeout ? deferredUpdate.schedule(_self.$timeout) : (_self.onUpdate(), void 0) - }) - }; - (function() { - this.$timeout = 500, this.setTimeout = function(timeout) { - this.$timeout = timeout - }, this.setValue = function(value) { - this.doc.setValue(value), this.deferredUpdate.schedule(this.$timeout) - }, this.getValue = function(callbackId) { - this.sender.callback(this.doc.getValue(), callbackId) - }, this.onUpdate = function() {}, this.isPending = function() { - return this.deferredUpdate.isPending() - } - }).call(Mirror.prototype) -}), ace.define("ace/mode/json/json_parse", ["require", "exports", "module"], function() { - "use strict"; - var at, ch, text, value, escapee = { - '"': '"', - "\\": "\\", - "/": "/", - b: "\b", - f: "\f", - n: "\n", - r: "\r", - t: " " - }, - error = function(m) { - throw { - name: "SyntaxError", - message: m, - at: at, - text: text - } - }, - reset = function (newAt) { - ch = text.charAt(newAt); - at = newAt + 1; - }, - next = function(c) { - return c && c !== ch && error("Expected '" + c + "' instead of '" + ch + "'"), ch = text.charAt(at), at += 1, ch - }, - nextUpTo = function (upTo, errorMessage) { - let currentAt = at, - i = text.indexOf(upTo, currentAt); - if (i < 0) { - error(errorMessage || 'Expected \'' + upTo + '\''); - } - reset(i + upTo.length); - return text.substring(currentAt, i); - }, - peek = function (c) { - return text.substr(at, c.length) === c; // nocommit - double check - }, - number = function() { - var number, string = ""; - for ("-" === ch && (string = "-", next("-")); ch >= "0" && "9" >= ch;) string += ch, next(); - if ("." === ch) - for (string += "."; next() && ch >= "0" && "9" >= ch;) string += ch; - if ("e" === ch || "E" === ch) - for (string += ch, next(), ("-" === ch || "+" === ch) && (string += ch, next()); ch >= "0" && "9" >= ch;) string += ch, next(); - return number = +string, isNaN(number) ? (error("Bad number"), void 0) : number - }, - string = function() { - var hex, i, uffff, string = ""; - if ('"' === ch) { - if (peek('""')) { - // literal - next('"'); - next('"'); - return nextUpTo('"""', 'failed to find closing \'"""\''); - } else { - for (; next();) { - if ('"' === ch) return next(), string; - if ("\\" === ch) - if (next(), "u" === ch) { - for (uffff = 0, i = 0; 4 > i && (hex = parseInt(next(), 16), isFinite(hex)); i += 1) uffff = 16 * uffff + hex; - string += String.fromCharCode(uffff) - } else { - if ("string" != typeof escapee[ch]) break; - string += escapee[ch] - } - else string += ch - } - } - } - error("Bad string") - }, - white = function() { - for (; ch && " " >= ch;) next() - }, - word = function() { - switch (ch) { - case "t": - return next("t"), next("r"), next("u"), next("e"), !0; - case "f": - return next("f"), next("a"), next("l"), next("s"), next("e"), !1; - case "n": - return next("n"), next("u"), next("l"), next("l"), null - } - error("Unexpected '" + ch + "'") - }, - array = function() { - var array = []; - if ("[" === ch) { - if (next("["), white(), "]" === ch) return next("]"), array; - for (; ch;) { - if (array.push(value()), white(), "]" === ch) return next("]"), array; - next(","), white() - } - } - error("Bad array") - }, - object = function() { - var key, object = {}; - if ("{" === ch) { - if (next("{"), white(), "}" === ch) return next("}"), object; - for (; ch;) { - if (key = string(), white(), next(":"), Object.hasOwnProperty.call(object, key) && error('Duplicate key "' + key + '"'), object[key] = value(), white(), "}" === ch) return next("}"), object; - next(","), white() - } - } - error("Bad object") - }; - return value = function() { - switch (white(), ch) { - case "{": - return object(); - case "[": - return array(); - case '"': - return string(); - case "-": - return number(); - default: - return ch >= "0" && "9" >= ch ? number() : word() - } - }, - function(source, reviver) { - var result; - return text = source, at = 0, ch = " ", result = value(), white(), ch && error("Syntax error"), "function" == typeof reviver ? function walk(holder, key) { - var k, v, value = holder[key]; - if (value && "object" == typeof value) - for (k in value) Object.hasOwnProperty.call(value, k) && (v = walk(value, k), void 0 !== v ? value[k] = v : delete value[k]); - return reviver.call(holder, key, value) - }({ - "": result - }, "") : result - } -}), ace.define("ace/mode/json_worker", ["require", "exports", "module", "ace/lib/oop", "ace/worker/mirror", "ace/mode/json/json_parse"], function(acequire, exports) { - "use strict"; - var oop = acequire("../lib/oop"), - Mirror = acequire("../worker/mirror").Mirror, - parse = acequire("./json/json_parse"), - JsonWorker = exports.JsonWorker = function(sender) { - Mirror.call(this, sender), this.setTimeout(200) - }; - oop.inherits(JsonWorker, Mirror), - function() { - this.onUpdate = function() { - var value = this.doc.getValue(), - errors = []; - try { - value && parse(value) - } catch (e) { - var pos = this.doc.indexToPosition(e.at - 1); - errors.push({ - row: pos.row, - column: pos.column, - text: e.message, - type: "error" - }) - } - this.sender.emit("annotate", errors) - } - }.call(JsonWorker.prototype) -}), ace.define("ace/lib/es5-shim", ["require", "exports", "module"], function() { - function Empty() {} - - function doesDefinePropertyWork(object) { - try { - return Object.defineProperty(object, "sentinel", {}), "sentinel" in object - } catch (exception) {} - } - - function toInteger(n) { - return n = +n, n !== n ? n = 0 : 0 !== n && n !== 1 / 0 && n !== -(1 / 0) && (n = (n > 0 || -1) * Math.floor(Math.abs(n))), n - } - Function.prototype.bind || (Function.prototype.bind = function(that) { - var target = this; - if ("function" != typeof target) throw new TypeError("Function.prototype.bind called on incompatible " + target); - var args = slice.call(arguments, 1), - bound = function() { - if (this instanceof bound) { - var result = target.apply(this, args.concat(slice.call(arguments))); - return Object(result) === result ? result : this - } - return target.apply(that, args.concat(slice.call(arguments))) - }; - return target.prototype && (Empty.prototype = target.prototype, bound.prototype = new Empty, Empty.prototype = null), bound - }); - var defineGetter, defineSetter, lookupGetter, lookupSetter, supportsAccessors, call = Function.prototype.call, - prototypeOfArray = Array.prototype, - prototypeOfObject = Object.prototype, - slice = prototypeOfArray.slice, - _toString = call.bind(prototypeOfObject.toString), - owns = call.bind(prototypeOfObject.hasOwnProperty); - if ((supportsAccessors = owns(prototypeOfObject, "__defineGetter__")) && (defineGetter = call.bind(prototypeOfObject.__defineGetter__), defineSetter = call.bind(prototypeOfObject.__defineSetter__), lookupGetter = call.bind(prototypeOfObject.__lookupGetter__), lookupSetter = call.bind(prototypeOfObject.__lookupSetter__)), 2 != [1, 2].splice(0).length) - if (function() { - function makeArray(l) { - var a = Array(l + 2); - return a[0] = a[1] = 0, a - } - var lengthBefore, array = []; - return array.splice.apply(array, makeArray(20)), array.splice.apply(array, makeArray(26)), lengthBefore = array.length, array.splice(5, 0, "XXX"), lengthBefore + 1 == array.length, lengthBefore + 1 == array.length ? !0 : void 0 - }()) { - var array_splice = Array.prototype.splice; - Array.prototype.splice = function(start, deleteCount) { - return arguments.length ? array_splice.apply(this, [void 0 === start ? 0 : start, void 0 === deleteCount ? this.length - start : deleteCount].concat(slice.call(arguments, 2))) : [] - } - } else Array.prototype.splice = function(pos, removeCount) { - var length = this.length; - pos > 0 ? pos > length && (pos = length) : void 0 == pos ? pos = 0 : 0 > pos && (pos = Math.max(length + pos, 0)), length > pos + removeCount || (removeCount = length - pos); - var removed = this.slice(pos, pos + removeCount), - insert = slice.call(arguments, 2), - add = insert.length; - if (pos === length) add && this.push.apply(this, insert); - else { - var remove = Math.min(removeCount, length - pos), - tailOldPos = pos + remove, - tailNewPos = tailOldPos + add - remove, - tailCount = length - tailOldPos, - lengthAfterRemove = length - remove; - if (tailOldPos > tailNewPos) - for (var i = 0; tailCount > i; ++i) this[tailNewPos + i] = this[tailOldPos + i]; - else if (tailNewPos > tailOldPos) - for (i = tailCount; i--;) this[tailNewPos + i] = this[tailOldPos + i]; - if (add && pos === lengthAfterRemove) this.length = lengthAfterRemove, this.push.apply(this, insert); - else - for (this.length = lengthAfterRemove + add, i = 0; add > i; ++i) this[pos + i] = insert[i] - } - return removed - }; - Array.isArray || (Array.isArray = function(obj) { - return "[object Array]" == _toString(obj) - }); - var boxedString = Object("a"), - splitString = "a" != boxedString[0] || !(0 in boxedString); - if (Array.prototype.forEach || (Array.prototype.forEach = function(fun) { - var object = toObject(this), - self = splitString && "[object String]" == _toString(this) ? this.split("") : object, - thisp = arguments[1], - i = -1, - length = self.length >>> 0; - if ("[object Function]" != _toString(fun)) throw new TypeError; - for (; length > ++i;) i in self && fun.call(thisp, self[i], i, object) - }), Array.prototype.map || (Array.prototype.map = function(fun) { - var object = toObject(this), - self = splitString && "[object String]" == _toString(this) ? this.split("") : object, - length = self.length >>> 0, - result = Array(length), - thisp = arguments[1]; - if ("[object Function]" != _toString(fun)) throw new TypeError(fun + " is not a function"); - for (var i = 0; length > i; i++) i in self && (result[i] = fun.call(thisp, self[i], i, object)); - return result - }), Array.prototype.filter || (Array.prototype.filter = function(fun) { - var value, object = toObject(this), - self = splitString && "[object String]" == _toString(this) ? this.split("") : object, - length = self.length >>> 0, - result = [], - thisp = arguments[1]; - if ("[object Function]" != _toString(fun)) throw new TypeError(fun + " is not a function"); - for (var i = 0; length > i; i++) i in self && (value = self[i], fun.call(thisp, value, i, object) && result.push(value)); - return result - }), Array.prototype.every || (Array.prototype.every = function(fun) { - var object = toObject(this), - self = splitString && "[object String]" == _toString(this) ? this.split("") : object, - length = self.length >>> 0, - thisp = arguments[1]; - if ("[object Function]" != _toString(fun)) throw new TypeError(fun + " is not a function"); - for (var i = 0; length > i; i++) - if (i in self && !fun.call(thisp, self[i], i, object)) return !1; - return !0 - }), Array.prototype.some || (Array.prototype.some = function(fun) { - var object = toObject(this), - self = splitString && "[object String]" == _toString(this) ? this.split("") : object, - length = self.length >>> 0, - thisp = arguments[1]; - if ("[object Function]" != _toString(fun)) throw new TypeError(fun + " is not a function"); - for (var i = 0; length > i; i++) - if (i in self && fun.call(thisp, self[i], i, object)) return !0; - return !1 - }), Array.prototype.reduce || (Array.prototype.reduce = function(fun) { - var object = toObject(this), - self = splitString && "[object String]" == _toString(this) ? this.split("") : object, - length = self.length >>> 0; - if ("[object Function]" != _toString(fun)) throw new TypeError(fun + " is not a function"); - if (!length && 1 == arguments.length) throw new TypeError("reduce of empty array with no initial value"); - var result, i = 0; - if (arguments.length >= 2) result = arguments[1]; - else - for (;;) { - if (i in self) { - result = self[i++]; - break - } - if (++i >= length) throw new TypeError("reduce of empty array with no initial value") - } - for (; length > i; i++) i in self && (result = fun.call(void 0, result, self[i], i, object)); - return result - }), Array.prototype.reduceRight || (Array.prototype.reduceRight = function(fun) { - var object = toObject(this), - self = splitString && "[object String]" == _toString(this) ? this.split("") : object, - length = self.length >>> 0; - if ("[object Function]" != _toString(fun)) throw new TypeError(fun + " is not a function"); - if (!length && 1 == arguments.length) throw new TypeError("reduceRight of empty array with no initial value"); - var result, i = length - 1; - if (arguments.length >= 2) result = arguments[1]; - else - for (;;) { - if (i in self) { - result = self[i--]; - break - } - if (0 > --i) throw new TypeError("reduceRight of empty array with no initial value") - } - do i in this && (result = fun.call(void 0, result, self[i], i, object)); while (i--); - return result - }), Array.prototype.indexOf && -1 == [0, 1].indexOf(1, 2) || (Array.prototype.indexOf = function(sought) { - var self = splitString && "[object String]" == _toString(this) ? this.split("") : toObject(this), - length = self.length >>> 0; - if (!length) return -1; - var i = 0; - for (arguments.length > 1 && (i = toInteger(arguments[1])), i = i >= 0 ? i : Math.max(0, length + i); length > i; i++) - if (i in self && self[i] === sought) return i; - return -1 - }), Array.prototype.lastIndexOf && -1 == [0, 1].lastIndexOf(0, -3) || (Array.prototype.lastIndexOf = function(sought) { - var self = splitString && "[object String]" == _toString(this) ? this.split("") : toObject(this), - length = self.length >>> 0; - if (!length) return -1; - var i = length - 1; - for (arguments.length > 1 && (i = Math.min(i, toInteger(arguments[1]))), i = i >= 0 ? i : length - Math.abs(i); i >= 0; i--) - if (i in self && sought === self[i]) return i; - return -1 - }), Object.getPrototypeOf || (Object.getPrototypeOf = function(object) { - return object.__proto__ || (object.constructor ? object.constructor.prototype : prototypeOfObject) - }), !Object.getOwnPropertyDescriptor) { - var ERR_NON_OBJECT = "Object.getOwnPropertyDescriptor called on a non-object: "; - Object.getOwnPropertyDescriptor = function(object, property) { - if ("object" != typeof object && "function" != typeof object || null === object) throw new TypeError(ERR_NON_OBJECT + object); - if (owns(object, property)) { - var descriptor, getter, setter; - if (descriptor = { - enumerable: !0, - configurable: !0 - }, supportsAccessors) { - var prototype = object.__proto__; - object.__proto__ = prototypeOfObject; - var getter = lookupGetter(object, property), - setter = lookupSetter(object, property); - if (object.__proto__ = prototype, getter || setter) return getter && (descriptor.get = getter), setter && (descriptor.set = setter), descriptor - } - return descriptor.value = object[property], descriptor - } - } - } - if (Object.getOwnPropertyNames || (Object.getOwnPropertyNames = function(object) { - return Object.keys(object) - }), !Object.create) { - var createEmpty; - createEmpty = null === Object.prototype.__proto__ ? function() { - return { - __proto__: null - } - } : function() { - var empty = {}; - for (var i in empty) empty[i] = null; - return empty.constructor = empty.hasOwnProperty = empty.propertyIsEnumerable = empty.isPrototypeOf = empty.toLocaleString = empty.toString = empty.valueOf = empty.__proto__ = null, empty - }, Object.create = function(prototype, properties) { - var object; - if (null === prototype) object = createEmpty(); - else { - if ("object" != typeof prototype) throw new TypeError("typeof prototype[" + typeof prototype + "] != 'object'"); - var Type = function() {}; - Type.prototype = prototype, object = new Type, object.__proto__ = prototype - } - return void 0 !== properties && Object.defineProperties(object, properties), object - } - } - if (Object.defineProperty) { - var definePropertyWorksOnObject = doesDefinePropertyWork({}), - definePropertyWorksOnDom = "undefined" == typeof document || doesDefinePropertyWork(document.createElement("div")); - if (!definePropertyWorksOnObject || !definePropertyWorksOnDom) var definePropertyFallback = Object.defineProperty - } - if (!Object.defineProperty || definePropertyFallback) { - var ERR_NON_OBJECT_DESCRIPTOR = "Property description must be an object: ", - ERR_NON_OBJECT_TARGET = "Object.defineProperty called on non-object: ", - ERR_ACCESSORS_NOT_SUPPORTED = "getters & setters can not be defined on this javascript engine"; - Object.defineProperty = function(object, property, descriptor) { - if ("object" != typeof object && "function" != typeof object || null === object) throw new TypeError(ERR_NON_OBJECT_TARGET + object); - if ("object" != typeof descriptor && "function" != typeof descriptor || null === descriptor) throw new TypeError(ERR_NON_OBJECT_DESCRIPTOR + descriptor); - if (definePropertyFallback) try { - return definePropertyFallback.call(Object, object, property, descriptor) - } catch (exception) {} - if (owns(descriptor, "value")) - if (supportsAccessors && (lookupGetter(object, property) || lookupSetter(object, property))) { - var prototype = object.__proto__; - object.__proto__ = prototypeOfObject, delete object[property], object[property] = descriptor.value, object.__proto__ = prototype - } else object[property] = descriptor.value; - else { - if (!supportsAccessors) throw new TypeError(ERR_ACCESSORS_NOT_SUPPORTED); - owns(descriptor, "get") && defineGetter(object, property, descriptor.get), owns(descriptor, "set") && defineSetter(object, property, descriptor.set) - } - return object - } - } - Object.defineProperties || (Object.defineProperties = function(object, properties) { - for (var property in properties) owns(properties, property) && Object.defineProperty(object, property, properties[property]); - return object - }), Object.seal || (Object.seal = function(object) { - return object - }), Object.freeze || (Object.freeze = function(object) { - return object - }); - try { - Object.freeze(function() {}) - } catch (exception) { - Object.freeze = function(freezeObject) { - return function(object) { - return "function" == typeof object ? object : freezeObject(object) - } - }(Object.freeze) - } - if (Object.preventExtensions || (Object.preventExtensions = function(object) { - return object - }), Object.isSealed || (Object.isSealed = function() { - return !1 - }), Object.isFrozen || (Object.isFrozen = function() { - return !1 - }), Object.isExtensible || (Object.isExtensible = function(object) { - if (Object(object) === object) throw new TypeError; - for (var name = ""; owns(object, name);) name += "?"; - object[name] = !0; - var returnValue = owns(object, name); - return delete object[name], returnValue - }), !Object.keys) { - var hasDontEnumBug = !0, - dontEnums = ["toString", "toLocaleString", "valueOf", "hasOwnProperty", "isPrototypeOf", "propertyIsEnumerable", "constructor"], - dontEnumsLength = dontEnums.length; - for (var key in { - toString: null - }) hasDontEnumBug = !1; - Object.keys = function(object) { - if ("object" != typeof object && "function" != typeof object || null === object) throw new TypeError("Object.keys called on a non-object"); - var keys = []; - for (var name in object) owns(object, name) && keys.push(name); - if (hasDontEnumBug) - for (var i = 0, ii = dontEnumsLength; ii > i; i++) { - var dontEnum = dontEnums[i]; - owns(object, dontEnum) && keys.push(dontEnum) - } - return keys - } - } - Date.now || (Date.now = function() { - return (new Date).getTime() - }); - var ws = " \n \f\r   ᠎              \u2028\u2029"; - if (!String.prototype.trim || ws.trim()) { - ws = "[" + ws + "]"; - var trimBeginRegexp = RegExp("^" + ws + ws + "*"), - trimEndRegexp = RegExp(ws + ws + "*$"); - String.prototype.trim = function() { - return (this + "").replace(trimBeginRegexp, "").replace(trimEndRegexp, "") - } - } - var toObject = function(o) { - if (null == o) throw new TypeError("can't convert " + o + " to object"); - return Object(o) - } -}); diff --git a/packages/kbn-ace/src/ace/modes/x_json/x_json.ts b/packages/kbn-ace/src/ace/modes/x_json/x_json.ts deleted file mode 100644 index 5a535e237a327..0000000000000 --- a/packages/kbn-ace/src/ace/modes/x_json/x_json.ts +++ /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", 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 ace from 'brace'; -import { XJsonHighlightRules } from '..'; -import { workerModule } from './worker'; - -const { WorkerClient } = ace.acequire('ace/worker/worker_client'); - -const oop = ace.acequire('ace/lib/oop'); - -const { Mode: JSONMode } = ace.acequire('ace/mode/json'); -const { Tokenizer: AceTokenizer } = ace.acequire('ace/tokenizer'); -const { MatchingBraceOutdent } = ace.acequire('ace/mode/matching_brace_outdent'); -const { CstyleBehaviour } = ace.acequire('ace/mode/behaviour/cstyle'); -const { FoldMode: CStyleFoldMode } = ace.acequire('ace/mode/folding/cstyle'); - -const XJsonMode: any = function XJsonMode(this: any) { - const ruleset: any = new (XJsonHighlightRules as any)(); - ruleset.normalizeRules(); - this.$tokenizer = new AceTokenizer(ruleset.getRules()); - this.$outdent = new MatchingBraceOutdent(); - this.$behaviour = new CstyleBehaviour(); - this.foldingRules = new CStyleFoldMode(); -}; - -oop.inherits(XJsonMode, JSONMode); - -// Then clobber `createWorker` method to install our worker source. Per ace's wiki: https://github.com/ajaxorg/ace/wiki/Syntax-validation -(XJsonMode.prototype as any).createWorker = function (session: ace.IEditSession) { - const xJsonWorker = new WorkerClient(['ace'], workerModule, 'JsonWorker'); - - xJsonWorker.attachToDocument(session.getDocument()); - - xJsonWorker.on('annotate', function (e: { data: any }) { - session.setAnnotations(e.data); - }); - - xJsonWorker.on('terminate', function () { - session.clearAnnotations(); - }); - - return xJsonWorker; -}; - -export { XJsonMode }; - -export function installXJsonMode(editor: ace.Editor) { - const session = editor.getSession(); - session.setMode(new XJsonMode()); -} diff --git a/packages/kbn-ace/tsconfig.json b/packages/kbn-ace/tsconfig.json deleted file mode 100644 index a545abd7d65a6..0000000000000 --- a/packages/kbn-ace/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "allowJs": false, - "outDir": "target/types", - "stripInternal": true, - "types": ["node"] - }, - "include": [ - "**/*.ts", - "src/ace/modes/x_json/worker/x_json.ace.worker.js" - ], - "exclude": [ - "target/**/*", - ] -} diff --git a/packages/kbn-alerting-types/index.ts b/packages/kbn-alerting-types/index.ts index 0a930e6a9319c..b2288900a1248 100644 --- a/packages/kbn-alerting-types/index.ts +++ b/packages/kbn-alerting-types/index.ts @@ -18,4 +18,5 @@ export * from './r_rule_types'; export * from './rule_notify_when_type'; export * from './rule_type_types'; export * from './rule_types'; +export * from './rule_settings'; export * from './search_strategy_types'; diff --git a/packages/kbn-alerting-types/rule_settings.ts b/packages/kbn-alerting-types/rule_settings.ts new file mode 100644 index 0000000000000..b25ad201c2dc0 --- /dev/null +++ b/packages/kbn-alerting-types/rule_settings.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". + */ + +export interface RulesSettingsModificationMetadata { + createdBy: string | null; + updatedBy: string | null; + createdAt: string; + updatedAt: string; +} + +export interface RulesSettingsFlappingProperties { + enabled: boolean; + lookBackWindow: number; + statusChangeThreshold: number; +} + +export interface RuleSpecificFlappingProperties { + lookBackWindow: number; + statusChangeThreshold: number; +} + +export type RulesSettingsFlapping = RulesSettingsFlappingProperties & + RulesSettingsModificationMetadata; + +export interface RulesSettingsQueryDelayProperties { + delay: number; +} + +export type RulesSettingsQueryDelay = RulesSettingsQueryDelayProperties & + RulesSettingsModificationMetadata; + +export interface RulesSettingsProperties { + flapping?: RulesSettingsFlappingProperties; + queryDelay?: RulesSettingsQueryDelayProperties; +} + +export interface RulesSettings { + flapping?: RulesSettingsFlapping; + queryDelay?: RulesSettingsQueryDelay; +} diff --git a/packages/kbn-alerts-ui-shared/src/common/apis/fetch_flapping_settings/fetch_flapping_settings.test.ts b/packages/kbn-alerts-ui-shared/src/common/apis/fetch_flapping_settings/fetch_flapping_settings.test.ts new file mode 100644 index 0000000000000..d5feaa731335a --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/common/apis/fetch_flapping_settings/fetch_flapping_settings.test.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 { httpServiceMock } from '@kbn/core/public/mocks'; +import { fetchFlappingSettings } from './fetch_flapping_settings'; + +const http = httpServiceMock.createStartContract(); + +describe('fetchFlappingSettings', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('should call fetch rule flapping API', async () => { + const now = new Date().toISOString(); + http.get.mockResolvedValue({ + created_by: 'test', + updated_by: 'test', + created_at: now, + updated_at: now, + enabled: true, + look_back_window: 20, + status_change_threshold: 20, + }); + + const result = await fetchFlappingSettings({ http }); + + expect(result).toEqual({ + createdBy: 'test', + updatedBy: 'test', + createdAt: now, + updatedAt: now, + enabled: true, + lookBackWindow: 20, + statusChangeThreshold: 20, + }); + }); +}); diff --git a/packages/kbn-alerts-ui-shared/src/common/apis/fetch_flapping_settings/fetch_flapping_settings.ts b/packages/kbn-alerts-ui-shared/src/common/apis/fetch_flapping_settings/fetch_flapping_settings.ts new file mode 100644 index 0000000000000..6ad702ebc945e --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/common/apis/fetch_flapping_settings/fetch_flapping_settings.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", 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 { HttpSetup } from '@kbn/core/public'; +import { AsApiContract } from '@kbn/actions-types'; +import { RulesSettingsFlapping } from '@kbn/alerting-types'; +import { INTERNAL_BASE_ALERTING_API_PATH } from '../../constants'; +import { transformFlappingSettingsResponse } from './transform_flapping_settings_response'; + +export const fetchFlappingSettings = async ({ http }: { http: HttpSetup }) => { + const res = await http.get>( + `${INTERNAL_BASE_ALERTING_API_PATH}/rules/settings/_flapping` + ); + return transformFlappingSettingsResponse(res); +}; diff --git a/src/plugins/console/public/lib/ace_token_provider/index.ts b/packages/kbn-alerts-ui-shared/src/common/apis/fetch_flapping_settings/index.ts similarity index 91% rename from src/plugins/console/public/lib/ace_token_provider/index.ts rename to packages/kbn-alerts-ui-shared/src/common/apis/fetch_flapping_settings/index.ts index 8819ac19a1262..68ff193255403 100644 --- a/src/plugins/console/public/lib/ace_token_provider/index.ts +++ b/packages/kbn-alerts-ui-shared/src/common/apis/fetch_flapping_settings/index.ts @@ -7,4 +7,4 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export * from './token_provider'; +export * from './fetch_flapping_settings'; diff --git a/packages/kbn-alerts-ui-shared/src/common/apis/fetch_flapping_settings/transform_flapping_settings_response.test.ts b/packages/kbn-alerts-ui-shared/src/common/apis/fetch_flapping_settings/transform_flapping_settings_response.test.ts new file mode 100644 index 0000000000000..e53d133f6838b --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/common/apis/fetch_flapping_settings/transform_flapping_settings_response.test.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 { transformFlappingSettingsResponse } from './transform_flapping_settings_response'; + +describe('transformFlappingSettingsResponse', () => { + test('should transform flapping settings response', () => { + const now = new Date().toISOString(); + + const result = transformFlappingSettingsResponse({ + created_by: 'test', + updated_by: 'test', + created_at: now, + updated_at: now, + enabled: true, + look_back_window: 20, + status_change_threshold: 20, + }); + + expect(result).toEqual({ + createdBy: 'test', + updatedBy: 'test', + createdAt: now, + updatedAt: now, + enabled: true, + lookBackWindow: 20, + statusChangeThreshold: 20, + }); + }); +}); diff --git a/packages/kbn-alerts-ui-shared/src/common/apis/fetch_flapping_settings/transform_flapping_settings_response.ts b/packages/kbn-alerts-ui-shared/src/common/apis/fetch_flapping_settings/transform_flapping_settings_response.ts new file mode 100644 index 0000000000000..a628829927a3b --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/common/apis/fetch_flapping_settings/transform_flapping_settings_response.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 { AsApiContract } from '@kbn/actions-types'; +import { RulesSettingsFlapping } from '@kbn/alerting-types'; + +export const transformFlappingSettingsResponse = ({ + look_back_window: lookBackWindow, + status_change_threshold: statusChangeThreshold, + created_at: createdAt, + created_by: createdBy, + updated_at: updatedAt, + updated_by: updatedBy, + ...rest +}: AsApiContract): RulesSettingsFlapping => ({ + ...rest, + lookBackWindow, + statusChangeThreshold, + createdAt, + createdBy, + updatedAt, + updatedBy, +}); diff --git a/src/plugins/console/public/application/models/index.ts b/packages/kbn-alerts-ui-shared/src/common/constants/rule_flapping.ts similarity index 79% rename from src/plugins/console/public/application/models/index.ts rename to packages/kbn-alerts-ui-shared/src/common/constants/rule_flapping.ts index 0d4a8f474daee..49ea5a63b3fca 100644 --- a/src/plugins/console/public/application/models/index.ts +++ b/packages/kbn-alerts-ui-shared/src/common/constants/rule_flapping.ts @@ -7,5 +7,5 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export * from './legacy_core_editor/legacy_core_editor'; -export * from './sense_editor'; +// Feature flag for frontend rule specific flapping in rule flyout +export const IS_RULE_SPECIFIC_FLAPPING_ENABLED = false; diff --git a/packages/kbn-alerts-ui-shared/src/common/hooks/use_fetch_flapping_settings.test.tsx b/packages/kbn-alerts-ui-shared/src/common/hooks/use_fetch_flapping_settings.test.tsx new file mode 100644 index 0000000000000..10e1869b9e64c --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/common/hooks/use_fetch_flapping_settings.test.tsx @@ -0,0 +1,106 @@ +/* + * Copyright 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, { FunctionComponent } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook } from '@testing-library/react-hooks'; +import { testQueryClientConfig } from '../test_utils/test_query_client_config'; +import { useFetchFlappingSettings } from './use_fetch_flapping_settings'; +import { httpServiceMock } from '@kbn/core-http-browser-mocks'; + +const queryClient = new QueryClient(testQueryClientConfig); + +const wrapper: FunctionComponent> = ({ children }) => ( + {children} +); + +const http = httpServiceMock.createStartContract(); + +const now = new Date().toISOString(); + +describe('useFetchFlappingSettings', () => { + beforeEach(() => { + http.get.mockResolvedValue({ + created_by: 'test', + updated_by: 'test', + created_at: now, + updated_at: now, + enabled: true, + look_back_window: 20, + status_change_threshold: 20, + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + queryClient.clear(); + }); + + test('should call fetchFlappingSettings with the correct parameters', async () => { + const { result, waitFor } = renderHook( + () => useFetchFlappingSettings({ http, enabled: true }), + { + wrapper, + } + ); + + await waitFor(() => { + return expect(result.current.isInitialLoading).toEqual(false); + }); + + expect(result.current.data).toEqual({ + createdAt: now, + createdBy: 'test', + updatedAt: now, + updatedBy: 'test', + enabled: true, + lookBackWindow: 20, + statusChangeThreshold: 20, + }); + }); + + test('should not call fetchFlappingSettings if enabled is false', async () => { + const { result, waitFor } = renderHook( + () => useFetchFlappingSettings({ http, enabled: false }), + { + wrapper, + } + ); + + await waitFor(() => { + return expect(result.current.isInitialLoading).toEqual(false); + }); + + expect(http.get).not.toHaveBeenCalled(); + }); + + test('should call onSuccess when the fetching was successful', async () => { + const onSuccessMock = jest.fn(); + const { result, waitFor } = renderHook( + () => useFetchFlappingSettings({ http, enabled: true, onSuccess: onSuccessMock }), + { + wrapper, + } + ); + + await waitFor(() => { + return expect(result.current.isInitialLoading).toEqual(false); + }); + + expect(onSuccessMock).toHaveBeenCalledWith({ + createdAt: now, + createdBy: 'test', + updatedAt: now, + updatedBy: 'test', + enabled: true, + lookBackWindow: 20, + statusChangeThreshold: 20, + }); + }); +}); diff --git a/packages/kbn-alerts-ui-shared/src/common/hooks/use_fetch_flapping_settings.ts b/packages/kbn-alerts-ui-shared/src/common/hooks/use_fetch_flapping_settings.ts new file mode 100644 index 0000000000000..6b72c2fea734b --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/common/hooks/use_fetch_flapping_settings.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 { useQuery } from '@tanstack/react-query'; +import { HttpStart } from '@kbn/core-http-browser'; +import { RulesSettingsFlapping } from '@kbn/alerting-types/rule_settings'; +import { fetchFlappingSettings } from '../apis/fetch_flapping_settings'; + +interface UseFetchFlappingSettingsProps { + http: HttpStart; + enabled: boolean; + onSuccess?: (settings: RulesSettingsFlapping) => void; +} + +export const useFetchFlappingSettings = (props: UseFetchFlappingSettingsProps) => { + const { http, enabled, onSuccess } = props; + + const queryFn = () => { + return fetchFlappingSettings({ http }); + }; + + const { data, isFetching, isError, isLoadingError, isLoading, isInitialLoading } = useQuery({ + queryKey: ['fetchFlappingSettings'], + queryFn, + onSuccess, + enabled, + refetchOnWindowFocus: false, + retry: false, + }); + + return { + isInitialLoading, + isLoading: isLoading || isFetching, + isError: isError || isLoadingError, + data, + }; +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/create_rule_form.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/create_rule_form.tsx index 71aeb2bcaab77..fc96ae214a7a8 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/create_rule_form.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/create_rule_form.tsx @@ -92,6 +92,7 @@ export const CreateRuleForm = (props: CreateRuleFormProps) => { connectors, connectorTypes, aadTemplateFields, + flappingSettings, } = useLoadDependencies({ http, toasts: notifications.toasts, @@ -117,6 +118,7 @@ export const CreateRuleForm = (props: CreateRuleFormProps) => { actions: newFormData.actions, notifyWhen: newFormData.notifyWhen, alertDelay: newFormData.alertDelay, + flapping: newFormData.flapping, }, }); }, @@ -173,6 +175,7 @@ export const CreateRuleForm = (props: CreateRuleFormProps) => { selectedRuleTypeModel: ruleTypeModel, selectedRuleType: ruleType, validConsumers, + flappingSettings, canShowConsumerSelection, showMustacheAutocompleteSwitch, multiConsumerSelection: getInitialMultiConsumer({ diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/edit_rule_form.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/edit_rule_form.tsx index 5091444276873..6e92b94cc2e0d 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/edit_rule_form.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/edit_rule_form.tsx @@ -69,6 +69,7 @@ export const EditRuleForm = (props: EditRuleFormProps) => { connectors, connectorTypes, aadTemplateFields, + flappingSettings, } = useLoadDependencies({ http, toasts: notifications.toasts, @@ -89,6 +90,7 @@ export const EditRuleForm = (props: EditRuleFormProps) => { actions: newFormData.actions, notifyWhen: newFormData.notifyWhen, alertDelay: newFormData.alertDelay, + flapping: newFormData.flapping, }, }); }, @@ -160,6 +162,7 @@ export const EditRuleForm = (props: EditRuleFormProps) => { minimumScheduleInterval: uiConfig?.minimumScheduleInterval, selectedRuleType: ruleType, selectedRuleTypeModel: ruleTypeModel, + flappingSettings, showMustacheAutocompleteSwitch, }} > diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_load_dependencies.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_load_dependencies.test.tsx index 263c9e2118056..9d2ce3b6f1211 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_load_dependencies.test.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_load_dependencies.test.tsx @@ -50,6 +50,10 @@ jest.mock('../utils/get_authorized_rule_types', () => ({ getAvailableRuleTypes: jest.fn(), })); +jest.mock('../../common/hooks/use_fetch_flapping_settings', () => ({ + useFetchFlappingSettings: jest.fn(), +})); + const { useLoadUiConfig } = jest.requireMock('../../common/hooks/use_load_ui_config'); const { useHealthCheck } = jest.requireMock('../../common/hooks/use_health_check'); const { useResolveRule } = jest.requireMock('../../common/hooks/use_resolve_rule'); @@ -60,6 +64,9 @@ const { useLoadRuleTypeAadTemplateField } = jest.requireMock( ); const { useLoadRuleTypesQuery } = jest.requireMock('../../common/hooks/use_load_rule_types_query'); const { getAvailableRuleTypes } = jest.requireMock('../utils/get_authorized_rule_types'); +const { useFetchFlappingSettings } = jest.requireMock( + '../../common/hooks/use_fetch_flapping_settings' +); const uiConfigMock = { isUsingSecurity: true, @@ -103,6 +110,15 @@ useResolveRule.mockReturnValue({ data: ruleMock, }); +useFetchFlappingSettings.mockReturnValue({ + isLoading: false, + isInitialLoading: false, + data: { + lookBackWindow: 20, + statusChangeThreshold: 20, + }, +}); + const indexThresholdRuleType = { enabledInLicense: true, recoveryActionGroup: { @@ -260,6 +276,10 @@ describe('useLoadDependencies', () => { uiConfig: uiConfigMock, healthCheckError: null, fetchedFormData: ruleMock, + flappingSettings: { + lookBackWindow: 20, + statusChangeThreshold: 20, + }, connectors: [mockConnector], connectorTypes: [mockConnectorType], aadTemplateFields: [mockAadTemplateField], diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_load_dependencies.ts b/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_load_dependencies.ts index da59e85a933a1..5e0c52b1089ba 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_load_dependencies.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_load_dependencies.ts @@ -22,6 +22,8 @@ import { } from '../../common/hooks'; import { getAvailableRuleTypes } from '../utils'; import { RuleTypeRegistryContract } from '../../common'; +import { useFetchFlappingSettings } from '../../common/hooks/use_fetch_flapping_settings'; +import { IS_RULE_SPECIFIC_FLAPPING_ENABLED } from '../../common/constants/rule_flapping'; import { useLoadRuleTypeAadTemplateField } from '../../common/hooks/use_load_rule_type_aad_template_fields'; export interface UseLoadDependencies { @@ -81,6 +83,15 @@ export const useLoadDependencies = (props: UseLoadDependencies) => { filteredRuleTypes, }); + const { + data: flappingSettings, + isLoading: isLoadingFlappingSettings, + isInitialLoading: isInitialLoadingFlappingSettings, + } = useFetchFlappingSettings({ + http, + enabled: IS_RULE_SPECIFIC_FLAPPING_ENABLED, + }); + const { data: connectors = [], isLoading: isLoadingConnectors, @@ -144,6 +155,7 @@ export const useLoadDependencies = (props: UseLoadDependencies) => { isLoadingUiConfig || isLoadingHealthCheck || isLoadingRuleTypes || + isLoadingFlappingSettings || isLoadingConnectors || isLoadingConnectorTypes || isLoadingAadtemplateFields @@ -156,6 +168,7 @@ export const useLoadDependencies = (props: UseLoadDependencies) => { isLoadingHealthCheck || isLoadingRule || isLoadingRuleTypes || + isLoadingFlappingSettings || isLoadingConnectors || isLoadingConnectorTypes || isLoadingAadtemplateFields @@ -166,6 +179,7 @@ export const useLoadDependencies = (props: UseLoadDependencies) => { isLoadingHealthCheck, isLoadingRule, isLoadingRuleTypes, + isLoadingFlappingSettings, isLoadingConnectors, isLoadingConnectorTypes, isLoadingAadtemplateFields, @@ -178,6 +192,7 @@ export const useLoadDependencies = (props: UseLoadDependencies) => { isInitialLoadingUiConfig || isInitialLoadingHealthCheck || isInitialLoadingRuleTypes || + isInitialLoadingFlappingSettings || isInitialLoadingConnectors || isInitialLoadingConnectorTypes || isInitialLoadingAadTemplateField @@ -190,6 +205,7 @@ export const useLoadDependencies = (props: UseLoadDependencies) => { isInitialLoadingHealthCheck || isInitialLoadingRule || isInitialLoadingRuleTypes || + isInitialLoadingFlappingSettings || isInitialLoadingConnectors || isInitialLoadingConnectorTypes || isInitialLoadingAadTemplateField @@ -200,6 +216,7 @@ export const useLoadDependencies = (props: UseLoadDependencies) => { isInitialLoadingHealthCheck, isInitialLoadingRule, isInitialLoadingRuleTypes, + isInitialLoadingFlappingSettings, isInitialLoadingConnectors, isInitialLoadingConnectorTypes, isInitialLoadingAadTemplateField, @@ -213,6 +230,7 @@ export const useLoadDependencies = (props: UseLoadDependencies) => { uiConfig, healthCheckError, fetchedFormData, + flappingSettings, connectors, connectorTypes, aadTemplateFields, diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_definition.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_definition.test.tsx index 01f9f39e9d086..b91148c220844 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_definition.test.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_definition.test.tsx @@ -19,12 +19,37 @@ import type { DocLinksStart } from '@kbn/core-doc-links-browser'; import { RuleDefinition } from './rule_definition'; import { RuleType } from '@kbn/alerting-types'; import { RuleTypeModel } from '../../common/types'; +import { RuleSettingsFlappingFormProps } from '../../rule_settings/rule_settings_flapping_form'; +import { ALERT_FLAPPING_DETECTION_TITLE } from '../translations'; +import userEvent from '@testing-library/user-event'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; jest.mock('../hooks', () => ({ useRuleFormState: jest.fn(), useRuleFormDispatch: jest.fn(), })); +jest.mock('../../common/constants/rule_flapping', () => ({ + IS_RULE_SPECIFIC_FLAPPING_ENABLED: true, +})); + +jest.mock('../../rule_settings/rule_settings_flapping_form', () => ({ + RuleSettingsFlappingForm: (props: RuleSettingsFlappingFormProps) => ( +
+ +
+ ), +})); + const ruleType = { id: '.es-query', name: 'Test', @@ -73,6 +98,13 @@ const plugins = { dataViews: {} as DataViewsPublicPluginStart, unifiedSearch: {} as UnifiedSearchPublicPluginStart, docLinks: {} as DocLinksStart, + application: { + capabilities: { + rulesSettings: { + writeFlappingSettingsUI: true, + }, + }, + }, }; const { useRuleFormState, useRuleFormDispatch } = jest.requireMock('../hooks'); @@ -279,4 +311,105 @@ describe('Rule Definition', () => { }, }); }); + + test('should render rule flapping settings correctly', () => { + useRuleFormState.mockReturnValue({ + plugins, + formData: { + id: 'test-id', + params: {}, + schedule: { + interval: '1m', + }, + alertDelay: { + active: 5, + }, + notifyWhen: null, + consumer: 'stackAlerts', + }, + selectedRuleType: ruleType, + selectedRuleTypeModel: ruleModel, + canShowConsumerSelection: true, + validConsumers: ['logs', 'stackAlerts'], + }); + + render(); + + expect(screen.getByText(ALERT_FLAPPING_DETECTION_TITLE)).toBeInTheDocument(); + expect(screen.getByTestId('ruleSettingsFlappingForm')).toBeInTheDocument(); + }); + + test('should allow flapping to be changed', async () => { + useRuleFormState.mockReturnValue({ + plugins, + formData: { + id: 'test-id', + params: {}, + schedule: { + interval: '1m', + }, + alertDelay: { + active: 5, + }, + notifyWhen: null, + consumer: 'stackAlerts', + }, + selectedRuleType: ruleType, + selectedRuleTypeModel: ruleModel, + canShowConsumerSelection: true, + validConsumers: ['logs', 'stackAlerts'], + }); + + render(); + + await userEvent.click(screen.getByText('onFlappingChange')); + expect(mockOnChange).toHaveBeenCalledWith({ + payload: { + property: 'flapping', + value: { + lookBackWindow: 15, + statusChangeThreshold: 15, + }, + }, + type: 'setRuleProperty', + }); + }); + + test('should open and close flapping popover when button icon is clicked', async () => { + useRuleFormState.mockReturnValue({ + plugins, + formData: { + id: 'test-id', + params: {}, + schedule: { + interval: '1m', + }, + alertDelay: { + active: 5, + }, + notifyWhen: null, + consumer: 'stackAlerts', + }, + selectedRuleType: ruleType, + selectedRuleTypeModel: ruleModel, + canShowConsumerSelection: true, + validConsumers: ['logs', 'stackAlerts'], + }); + + render( + + + + ); + + expect(screen.queryByTestId('ruleSettingsFlappingTooltipTitle')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByTestId('ruleSettingsFlappingTitleTooltipButton')); + + expect(screen.queryByTestId('ruleSettingsFlappingTooltipTitle')).toBeInTheDocument(); + + await userEvent.click(screen.getByTestId('ruleSettingsFlappingTitleTooltipButton')); + + expect(screen.queryByTestId('ruleSettingsFlappingTooltipTitle')).not.toBeVisible(); + }); }); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_definition.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_definition.tsx index fe4812436144a..3b404edc5d029 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_definition.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_definition.tsx @@ -25,6 +25,7 @@ import { useEuiTheme, COLOR_MODES_STANDARD, } from '@elastic/eui'; +import { RuleSpecificFlappingProperties } from '@kbn/alerting-types'; import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; import { AlertConsumers } from '@kbn/rule-data-utils'; import { @@ -39,6 +40,8 @@ import { ADVANCED_OPTIONS_TITLE, ALERT_DELAY_DESCRIPTION_TEXT, ALERT_DELAY_HELP_TEXT, + ALERT_FLAPPING_DETECTION_TITLE, + ALERT_FLAPPING_DETECTION_DESCRIPTION, } from '../translations'; import { RuleAlertDelay } from './rule_alert_delay'; import { RuleConsumerSelection } from './rule_consumer_selection'; @@ -46,6 +49,9 @@ import { RuleSchedule } from './rule_schedule'; import { useRuleFormState, useRuleFormDispatch } from '../hooks'; import { MULTI_CONSUMER_RULE_TYPE_IDS } from '../constants'; import { getAuthorizedConsumers } from '../utils'; +import { RuleSettingsFlappingTitleTooltip } from '../../rule_settings/rule_settings_flapping_title_tooltip'; +import { RuleSettingsFlappingForm } from '../../rule_settings/rule_settings_flapping_form'; +import { IS_RULE_SPECIFIC_FLAPPING_ENABLED } from '../../common/constants/rule_flapping'; export const RuleDefinition = () => { const { @@ -58,17 +64,26 @@ export const RuleDefinition = () => { selectedRuleTypeModel, validConsumers, canShowConsumerSelection = false, + flappingSettings, } = useRuleFormState(); const { colorMode } = useEuiTheme(); const dispatch = useRuleFormDispatch(); - const { charts, data, dataViews, unifiedSearch, docLinks } = plugins; + const { charts, data, dataViews, unifiedSearch, docLinks, application } = plugins; - const { params, schedule, notifyWhen } = formData; + const { + capabilities: { rulesSettings }, + } = application; + + const { writeFlappingSettingsUI } = rulesSettings || {}; + + const { params, schedule, notifyWhen, flapping } = formData; const [isAdvancedOptionsVisible, setIsAdvancedOptionsVisible] = useState(false); + const [isFlappingPopoverOpen, setIsFlappingPopoverOpen] = useState(false); + const authorizedConsumers = useMemo(() => { if (!validConsumers?.length) { return []; @@ -143,6 +158,19 @@ export const RuleDefinition = () => { [dispatch] ); + const onSetFlapping = useCallback( + (value: RuleSpecificFlappingProperties | null) => { + dispatch({ + type: 'setRuleProperty', + payload: { + property: 'flapping', + value, + }, + }); + }, + [dispatch] + ); + return ( @@ -243,7 +271,10 @@ export const RuleDefinition = () => { { + setIsAdvancedOptionsVisible(isOpen); + setIsFlappingPopoverOpen(false); + }} initialIsOpen={isAdvancedOptionsVisible} buttonProps={{ 'data-test-subj': 'advancedOptionsAccordionButton', @@ -274,6 +305,31 @@ export const RuleDefinition = () => { > + {IS_RULE_SPECIFIC_FLAPPING_ENABLED && ( + {ALERT_FLAPPING_DETECTION_TITLE}} + description={ + +

+ {ALERT_FLAPPING_DETECTION_DESCRIPTION} + +

+
+ } + > + +
+ )}
diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/translations.ts b/packages/kbn-alerts-ui-shared/src/rule_form/translations.ts index e7b060dce9831..20e87c66f10f4 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/translations.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/translations.ts @@ -85,6 +85,21 @@ export const ALERT_DELAY_TITLE_PREFIX = i18n.translate( } ); +export const ALERT_FLAPPING_DETECTION_TITLE = i18n.translate( + 'alertsUIShared.ruleForm.ruleDefinition.alertFlappingDetectionTitle', + { + defaultMessage: 'Alert flapping detection', + } +); + +export const ALERT_FLAPPING_DETECTION_DESCRIPTION = i18n.translate( + 'alertsUIShared.ruleForm.ruleDefinition.alertFlappingDetectionDescription', + { + defaultMessage: + 'Detect alerts that switch quickly between active and recovered states and reduce unwanted noise for these flapping alerts', + } +); + export const SCHEDULE_TITLE_PREFIX = i18n.translate( 'alertsUIShared.ruleForm.ruleSchedule.scheduleTitlePrefix', { diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/types.ts b/packages/kbn-alerts-ui-shared/src/rule_form/types.ts index ac81f45de19e6..d33c74da528db 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/types.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/types.ts @@ -20,7 +20,7 @@ import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/ import type { SettingsStart } from '@kbn/core-ui-settings-browser'; import { RuleCreationValidConsumer } from '@kbn/rule-data-utils'; import { ActionType } from '@kbn/actions-types'; -import { ActionVariable } from '@kbn/alerting-types'; +import { ActionVariable, RulesSettingsFlapping } from '@kbn/alerting-types'; import { ActionConnector, ActionTypeRegistryContract, @@ -46,6 +46,7 @@ export interface RuleFormData { alertDelay?: Rule['alertDelay']; notifyWhen?: Rule['notifyWhen']; ruleTypeId?: Rule['ruleTypeId']; + flapping?: Rule['flapping']; } export interface RuleFormPlugins { @@ -83,6 +84,7 @@ export interface RuleFormState { minimumScheduleInterval?: MinimumScheduleInterval; canShowConsumerSelection?: boolean; validConsumers?: RuleCreationValidConsumer[]; + flappingSettings?: RulesSettingsFlapping; } export type InitialRule = Partial & diff --git a/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_form.tsx b/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_form.tsx new file mode 100644 index 0000000000000..99f64f0a3977f --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_form.tsx @@ -0,0 +1,318 @@ +/* + * Copyright 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, useRef, useState } from 'react'; +import { + EuiBadge, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiLink, + EuiPopover, + EuiSpacer, + EuiSplitPanel, + EuiSwitch, + EuiText, + EuiOutsideClickDetector, + useEuiTheme, + useIsWithinMinBreakpoint, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { RuleSpecificFlappingProperties, RulesSettingsFlapping } from '@kbn/alerting-types'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { RuleSettingsFlappingMessage } from './rule_settings_flapping_message'; +import { RuleSettingsFlappingInputs } from './rule_settings_flapping_inputs'; + +const flappingLabel = i18n.translate('alertsUIShared.ruleSettingsFlappingForm.flappingLabel', { + defaultMessage: 'Flapping Detection', +}); + +const flappingOnLabel = i18n.translate('alertsUIShared.ruleSettingsFlappingForm.onLabel', { + defaultMessage: 'ON', +}); + +const flappingOffLabel = i18n.translate('alertsUIShared.ruleSettingsFlappingForm.offLabel', { + defaultMessage: 'OFF', +}); + +const flappingOverrideLabel = i18n.translate( + 'alertsUIShared.ruleSettingsFlappingForm.overrideLabel', + { + defaultMessage: 'Custom', + } +); + +const flappingOffContentRules = i18n.translate( + 'alertsUIShared.ruleSettingsFlappingForm.flappingOffContentRules', + { + defaultMessage: 'Rules', + } +); + +const flappingOffContentSettings = i18n.translate( + 'alertsUIShared.ruleSettingsFlappingForm.flappingOffContentSettings', + { + defaultMessage: 'Settings', + } +); + +const flappingExternalLinkLabel = i18n.translate( + 'alertsUIShared.ruleSettingsFlappingForm.flappingExternalLinkLabel', + { + defaultMessage: "What's this?", + } +); + +const flappingOverrideConfiguration = i18n.translate( + 'alertsUIShared.ruleSettingsFlappingForm.flappingOverrideConfiguration', + { + defaultMessage: 'Customize Configuration', + } +); + +const clampFlappingValues = (flapping: RuleSpecificFlappingProperties) => { + return { + ...flapping, + statusChangeThreshold: Math.min(flapping.lookBackWindow, flapping.statusChangeThreshold), + }; +}; + +export interface RuleSettingsFlappingFormProps { + flappingSettings?: RuleSpecificFlappingProperties | null; + spaceFlappingSettings?: RulesSettingsFlapping; + canWriteFlappingSettingsUI: boolean; + onFlappingChange: (value: RuleSpecificFlappingProperties | null) => void; +} + +export const RuleSettingsFlappingForm = (props: RuleSettingsFlappingFormProps) => { + const { flappingSettings, spaceFlappingSettings, canWriteFlappingSettingsUI, onFlappingChange } = + props; + + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const cachedFlappingSettings = useRef(); + + const isDesktop = useIsWithinMinBreakpoint('xl'); + + const { euiTheme } = useEuiTheme(); + + const onFlappingToggle = useCallback(() => { + if (!spaceFlappingSettings) { + return; + } + if (flappingSettings) { + cachedFlappingSettings.current = flappingSettings; + return onFlappingChange(null); + } + const initialFlappingSettings = cachedFlappingSettings.current || spaceFlappingSettings; + onFlappingChange({ + lookBackWindow: initialFlappingSettings.lookBackWindow, + statusChangeThreshold: initialFlappingSettings.statusChangeThreshold, + }); + }, [spaceFlappingSettings, flappingSettings, onFlappingChange]); + + const internalOnFlappingChange = useCallback( + (flapping: RuleSpecificFlappingProperties) => { + const clampedValue = clampFlappingValues(flapping); + onFlappingChange(clampedValue); + cachedFlappingSettings.current = clampedValue; + }, + [onFlappingChange] + ); + + const onLookBackWindowChange = useCallback( + (value: number) => { + if (!flappingSettings) { + return; + } + internalOnFlappingChange({ + ...flappingSettings, + lookBackWindow: value, + }); + }, + [flappingSettings, internalOnFlappingChange] + ); + + const onStatusChangeThresholdChange = useCallback( + (value: number) => { + if (!flappingSettings) { + return; + } + internalOnFlappingChange({ + ...flappingSettings, + statusChangeThreshold: value, + }); + }, + [flappingSettings, internalOnFlappingChange] + ); + + const flappingOffTooltip = useMemo(() => { + if (!spaceFlappingSettings) { + return null; + } + const { enabled } = spaceFlappingSettings; + if (enabled) { + return null; + } + + if (canWriteFlappingSettingsUI) { + return ( + setIsPopoverOpen(false)}> + setIsPopoverOpen(!isPopoverOpen)} + /> + } + > + + {flappingOffContentRules}, + settings: {flappingOffContentSettings}, + }} + /> + + + + ); + } + // TODO: Add the external doc link here! + return ( + + {flappingExternalLinkLabel} + + ); + }, [canWriteFlappingSettingsUI, isPopoverOpen, spaceFlappingSettings]); + + const flappingFormHeader = useMemo(() => { + if (!spaceFlappingSettings) { + return null; + } + const { enabled } = spaceFlappingSettings; + + return ( + + + + + {flappingLabel} + + + {enabled ? flappingOnLabel : flappingOffLabel} + + {flappingSettings && enabled && ( + {flappingOverrideLabel} + )} + + + {enabled && ( + + )} + {flappingOffTooltip} + + + {flappingSettings && enabled && ( + <> + + + + )} + + ); + }, [ + isDesktop, + euiTheme, + spaceFlappingSettings, + flappingSettings, + flappingOffTooltip, + onFlappingToggle, + ]); + + const flappingFormBody = useMemo(() => { + if (!flappingSettings) { + return null; + } + if (!spaceFlappingSettings?.enabled) { + return null; + } + return ( + + + + ); + }, [ + flappingSettings, + spaceFlappingSettings, + onLookBackWindowChange, + onStatusChangeThresholdChange, + ]); + + const flappingFormMessage = useMemo(() => { + if (!spaceFlappingSettings || !spaceFlappingSettings.enabled) { + return null; + } + const settingsToUse = flappingSettings || spaceFlappingSettings; + return ( + + + + ); + }, [spaceFlappingSettings, flappingSettings, euiTheme]); + + return ( + + + + {flappingFormHeader} + {flappingFormBody} + + + {flappingFormMessage} + + ); +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_message.tsx b/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_message.tsx index b7c8681ef221b..d6d488e08f0c1 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_message.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_message.tsx @@ -37,21 +37,34 @@ export const flappingOffMessage = i18n.translate( export interface RuleSettingsFlappingMessageProps { lookBackWindow: number; statusChangeThreshold: number; + isUsingRuleSpecificFlapping: boolean; } export const RuleSettingsFlappingMessage = (props: RuleSettingsFlappingMessageProps) => { - const { lookBackWindow, statusChangeThreshold } = props; + const { lookBackWindow, statusChangeThreshold, isUsingRuleSpecificFlapping } = props; return ( - {getLookBackWindowLabelRuleRuns(lookBackWindow)}, - statusChangeThreshold: {getStatusChangeThresholdRuleRuns(statusChangeThreshold)}, - }} - /> + {!isUsingRuleSpecificFlapping && ( + {getLookBackWindowLabelRuleRuns(lookBackWindow)}, + statusChangeThreshold: {getStatusChangeThresholdRuleRuns(statusChangeThreshold)}, + }} + /> + )} + {isUsingRuleSpecificFlapping && ( + {getLookBackWindowLabelRuleRuns(lookBackWindow)}, + statusChangeThreshold: {getStatusChangeThresholdRuleRuns(statusChangeThreshold)}, + }} + /> + )} ); }; diff --git a/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_title_tooltip.tsx b/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_title_tooltip.tsx new file mode 100644 index 0000000000000..2a5cc4186013d --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_title_tooltip.tsx @@ -0,0 +1,140 @@ +/* + * Copyright 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 { + EuiButtonIcon, + EuiPopover, + EuiPopoverProps, + EuiPopoverTitle, + EuiSpacer, + EuiText, + EuiOutsideClickDetector, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; + +const tooltipTitle = i18n.translate( + 'alertsUIShared.ruleSettingsFlappingTitleTooltip.tooltipTitle', + { + defaultMessage: 'Alert flapping detection', + } +); + +const flappingTitlePopoverFlappingDetection = i18n.translate( + 'alertsUIShared.ruleSettingsFlappingTitleTooltip.flappingTitlePopoverFlappingDetection', + { + defaultMessage: 'flapping detection', + } +); + +const flappingTitlePopoverAlertStatus = i18n.translate( + 'alertsUIShared.ruleSettingsFlappingTitleTooltip.flappingTitlePopoverAlertStatus', + { + defaultMessage: 'alert status change threshold', + } +); + +const flappingTitlePopoverLookBack = i18n.translate( + 'alertsUIShared.ruleSettingsFlappingTitleTooltip.flappingTitlePopoverLookBack', + { + defaultMessage: 'rule run look back window', + } +); + +const flappingOffContentRules = i18n.translate( + 'alertsUIShared.ruleSettingsFlappingTitleTooltip.flappingOffContentRules', + { + defaultMessage: 'Rules', + } +); + +const flappingOffContentSettings = i18n.translate( + 'alertsUIShared.ruleSettingsFlappingTitleTooltip.flappingOffContentSettings', + { + defaultMessage: 'Settings', + } +); + +interface RuleSettingsFlappingTitleTooltipProps { + isOpen: boolean; + setIsPopoverOpen: (isOpen: boolean) => void; + anchorPosition?: EuiPopoverProps['anchorPosition']; +} + +export const RuleSettingsFlappingTitleTooltip = (props: RuleSettingsFlappingTitleTooltipProps) => { + const { isOpen, setIsPopoverOpen, anchorPosition = 'leftCenter' } = props; + + return ( + setIsPopoverOpen(false)}> + setIsPopoverOpen(!isOpen)} + /> + } + > + + {tooltipTitle} + + + {flappingTitlePopoverFlappingDetection}, + }} + /> + + + + {flappingTitlePopoverAlertStatus}, + }} + /> + + + + {flappingTitlePopoverLookBack}, + }} + /> + + + + {flappingOffContentRules}, + settings: {flappingOffContentSettings}, + }} + /> + + + + ); +}; diff --git a/packages/kbn-apm-synthtrace-client/src/lib/entity.ts b/packages/kbn-apm-synthtrace-client/src/lib/entity.ts index 4d522ef07ff0e..b26dbfc7ffb46 100644 --- a/packages/kbn-apm-synthtrace-client/src/lib/entity.ts +++ b/packages/kbn-apm-synthtrace-client/src/lib/entity.ts @@ -7,6 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +export type ObjectEntry = [keyof T, T[keyof T]]; + export type Fields | undefined = undefined> = { '@timestamp'?: number; } & (TMeta extends undefined ? {} : Partial<{ meta: TMeta }>); @@ -27,4 +29,14 @@ export class Entity { return this; } + + overrides(overrides: Partial) { + const overrideEntries = Object.entries(overrides) as Array>; + + overrideEntries.forEach(([fieldName, value]) => { + this.fields[fieldName] = value; + }); + + return this; + } } diff --git a/packages/kbn-apm-synthtrace-client/src/lib/gaussian_events.ts b/packages/kbn-apm-synthtrace-client/src/lib/gaussian_events.ts new file mode 100644 index 0000000000000..4f1db28017d29 --- /dev/null +++ b/packages/kbn-apm-synthtrace-client/src/lib/gaussian_events.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 { castArray } from 'lodash'; +import { SynthtraceGenerator } from '../types'; +import { Fields } from './entity'; +import { Serializable } from './serializable'; + +export class GaussianEvents { + constructor( + private readonly from: Date, + private readonly to: Date, + private readonly mean: Date, + private readonly width: number, + private readonly totalPoints: number + ) {} + + *generator( + map: ( + timestamp: number, + index: number + ) => Serializable | Array> + ): SynthtraceGenerator { + if (this.totalPoints <= 0) { + return; + } + + const startTime = this.from.getTime(); + const endTime = this.to.getTime(); + const meanTime = this.mean.getTime(); + const densityInterval = 1 / (this.totalPoints - 1); + + for (let eventIndex = 0; eventIndex < this.totalPoints; eventIndex++) { + const quantile = eventIndex * densityInterval; + + const standardScore = Math.sqrt(2) * inverseError(2 * quantile - 1); + const timestamp = Math.round(meanTime + standardScore * this.width); + + if (timestamp >= startTime && timestamp <= endTime) { + yield* this.generateEvents(timestamp, eventIndex, map); + } + } + } + + private *generateEvents( + timestamp: number, + eventIndex: number, + map: ( + timestamp: number, + index: number + ) => Serializable | Array> + ): Generator> { + const events = castArray(map(timestamp, eventIndex)); + for (const event of events) { + yield event; + } + } +} + +function inverseError(x: number): number { + const a = 0.147; + const sign = x < 0 ? -1 : 1; + + const part1 = 2 / (Math.PI * a) + Math.log(1 - x * x) / 2; + const part2 = Math.log(1 - x * x) / a; + + return sign * Math.sqrt(Math.sqrt(part1 * part1 - part2) - part1); +} diff --git a/packages/kbn-apm-synthtrace-client/src/lib/infra/host.ts b/packages/kbn-apm-synthtrace-client/src/lib/infra/host.ts index 198949b482be3..30550d64c4df8 100644 --- a/packages/kbn-apm-synthtrace-client/src/lib/infra/host.ts +++ b/packages/kbn-apm-synthtrace-client/src/lib/infra/host.ts @@ -27,7 +27,7 @@ interface HostDocument extends Fields { 'cloud.provider'?: string; } -class Host extends Entity { +export class Host extends Entity { cpu({ cpuTotalValue }: { cpuTotalValue?: number } = {}) { return new HostMetrics({ ...this.fields, @@ -175,3 +175,11 @@ export function host(name: string): Host { 'cloud.provider': 'gcp', }); } + +export function minimalHost(name: string): Host { + return new Host({ + 'agent.id': 'synthtrace', + 'host.hostname': name, + 'host.name': name, + }); +} diff --git a/packages/kbn-apm-synthtrace-client/src/lib/infra/index.ts b/packages/kbn-apm-synthtrace-client/src/lib/infra/index.ts index 853a9549ce02c..2957605cffcd3 100644 --- a/packages/kbn-apm-synthtrace-client/src/lib/infra/index.ts +++ b/packages/kbn-apm-synthtrace-client/src/lib/infra/index.ts @@ -8,7 +8,7 @@ */ import { dockerContainer, DockerContainerMetricsDocument } from './docker_container'; -import { host, HostMetricsDocument } from './host'; +import { host, HostMetricsDocument, minimalHost } from './host'; import { k8sContainer, K8sContainerMetricsDocument } from './k8s_container'; import { pod, PodMetricsDocument } from './pod'; import { awsRds, AWSRdsMetricsDocument } from './aws/rds'; @@ -24,6 +24,7 @@ export type InfraDocument = export const infra = { host, + minimalHost, pod, dockerContainer, k8sContainer, diff --git a/packages/kbn-apm-synthtrace-client/src/lib/interval.ts b/packages/kbn-apm-synthtrace-client/src/lib/interval.ts index 1d56c42e1fe12..5a5ed3ab5fdbe 100644 --- a/packages/kbn-apm-synthtrace-client/src/lib/interval.ts +++ b/packages/kbn-apm-synthtrace-client/src/lib/interval.ts @@ -34,6 +34,10 @@ interface IntervalOptions { rate?: number; } +interface StepDetails { + stepMilliseconds: number; +} + export class Interval { private readonly intervalAmount: number; private readonly intervalUnit: unitOfTime.DurationConstructor; @@ -46,12 +50,16 @@ export class Interval { this._rate = options.rate || 1; } + private getIntervalMilliseconds(): number { + return moment.duration(this.intervalAmount, this.intervalUnit).asMilliseconds(); + } + private getTimestamps() { const from = this.options.from.getTime(); const to = this.options.to.getTime(); let time: number = from; - const diff = moment.duration(this.intervalAmount, this.intervalUnit).asMilliseconds(); + const diff = this.getIntervalMilliseconds(); const timestamps: number[] = []; @@ -68,15 +76,19 @@ export class Interval { *generator( map: ( timestamp: number, - index: number + index: number, + stepDetails: StepDetails ) => Serializable | Array> ): SynthtraceGenerator { const timestamps = this.getTimestamps(); + const stepDetails: StepDetails = { + stepMilliseconds: this.getIntervalMilliseconds(), + }; let index = 0; for (const timestamp of timestamps) { - const events = castArray(map(timestamp, index)); + const events = castArray(map(timestamp, index, stepDetails)); index++; for (const event of events) { yield event; diff --git a/packages/kbn-apm-synthtrace-client/src/lib/logs/index.ts b/packages/kbn-apm-synthtrace-client/src/lib/logs/index.ts index e19f0f6fd6565..2bbc59eb37e70 100644 --- a/packages/kbn-apm-synthtrace-client/src/lib/logs/index.ts +++ b/packages/kbn-apm-synthtrace-client/src/lib/logs/index.ts @@ -68,6 +68,7 @@ export type LogDocument = Fields & 'event.duration': number; 'event.start': Date; 'event.end': Date; + labels?: Record; test_field: string | string[]; date: Date; severity: string; @@ -156,6 +157,26 @@ function create(logsOptions: LogsOptions = defaultLogsOptions): Log { ).dataset('synth'); } +function createMinimal({ + dataset = 'synth', + namespace = 'default', +}: { + dataset?: string; + namespace?: string; +} = {}): Log { + return new Log( + { + 'input.type': 'logs', + 'data_stream.namespace': namespace, + 'data_stream.type': 'logs', + 'data_stream.dataset': dataset, + 'event.dataset': dataset, + }, + { isLogsDb: false } + ); +} + export const log = { create, + createMinimal, }; diff --git a/packages/kbn-apm-synthtrace-client/src/lib/poisson_events.test.ts b/packages/kbn-apm-synthtrace-client/src/lib/poisson_events.test.ts new file mode 100644 index 0000000000000..0741884550f32 --- /dev/null +++ b/packages/kbn-apm-synthtrace-client/src/lib/poisson_events.test.ts @@ -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 { PoissonEvents } from './poisson_events'; +import { Serializable } from './serializable'; + +describe('poisson events', () => { + it('generates events within the given time range', () => { + const poissonEvents = new PoissonEvents(new Date(1000), new Date(2000), 10); + + const events = Array.from( + poissonEvents.generator((timestamp) => new Serializable({ '@timestamp': timestamp })) + ); + + expect(events.length).toBeGreaterThanOrEqual(1); + + for (const event of events) { + expect(event.fields['@timestamp']).toBeGreaterThanOrEqual(1000); + expect(event.fields['@timestamp']).toBeLessThanOrEqual(2000); + } + }); + + it('generates at least one event if the rate is greater than 0', () => { + const poissonEvents = new PoissonEvents(new Date(1000), new Date(2000), 1); + + const events = Array.from( + poissonEvents.generator((timestamp) => new Serializable({ '@timestamp': timestamp })) + ); + + expect(events.length).toBeGreaterThanOrEqual(1); + + for (const event of events) { + expect(event.fields['@timestamp']).toBeGreaterThanOrEqual(1000); + expect(event.fields['@timestamp']).toBeLessThanOrEqual(2000); + } + }); + + it('generates no event if the rate is 0', () => { + const poissonEvents = new PoissonEvents(new Date(1000), new Date(2000), 0); + + const events = Array.from( + poissonEvents.generator((timestamp) => new Serializable({ '@timestamp': timestamp })) + ); + + expect(events.length).toBe(0); + }); +}); diff --git a/packages/kbn-apm-synthtrace-client/src/lib/poisson_events.ts b/packages/kbn-apm-synthtrace-client/src/lib/poisson_events.ts new file mode 100644 index 0000000000000..e7fd24b8323e7 --- /dev/null +++ b/packages/kbn-apm-synthtrace-client/src/lib/poisson_events.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", 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 { castArray } from 'lodash'; +import { SynthtraceGenerator } from '../types'; +import { Fields } from './entity'; +import { Serializable } from './serializable'; + +export class PoissonEvents { + constructor( + private readonly from: Date, + private readonly to: Date, + private readonly rate: number + ) {} + + private getTotalTimePeriod(): number { + return this.to.getTime() - this.from.getTime(); + } + + private getInterarrivalTime(): number { + const distribution = -Math.log(1 - Math.random()) / this.rate; + const totalTimePeriod = this.getTotalTimePeriod(); + return Math.floor(distribution * totalTimePeriod); + } + + *generator( + map: ( + timestamp: number, + index: number + ) => Serializable | Array> + ): SynthtraceGenerator { + if (this.rate <= 0) { + return; + } + + let currentTime = this.from.getTime(); + const endTime = this.to.getTime(); + let eventIndex = 0; + + while (currentTime < endTime) { + const interarrivalTime = this.getInterarrivalTime(); + currentTime += interarrivalTime; + + if (currentTime < endTime) { + yield* this.generateEvents(currentTime, eventIndex, map); + eventIndex++; + } + } + + // ensure at least one event has been emitted + if (this.rate > 0 && eventIndex === 0) { + const forcedEventTime = + this.from.getTime() + Math.floor(Math.random() * this.getTotalTimePeriod()); + yield* this.generateEvents(forcedEventTime, eventIndex, map); + } + } + + private *generateEvents( + timestamp: number, + eventIndex: number, + map: ( + timestamp: number, + index: number + ) => Serializable | Array> + ): Generator> { + const events = castArray(map(timestamp, eventIndex)); + for (const event of events) { + yield event; + } + } +} diff --git a/packages/kbn-apm-synthtrace-client/src/lib/timerange.ts b/packages/kbn-apm-synthtrace-client/src/lib/timerange.ts index ccdea4ee75197..1c6f12414a148 100644 --- a/packages/kbn-apm-synthtrace-client/src/lib/timerange.ts +++ b/packages/kbn-apm-synthtrace-client/src/lib/timerange.ts @@ -9,10 +9,12 @@ import datemath from '@kbn/datemath'; import type { Moment } from 'moment'; +import { GaussianEvents } from './gaussian_events'; import { Interval } from './interval'; +import { PoissonEvents } from './poisson_events'; export class Timerange { - constructor(private from: Date, private to: Date) {} + constructor(public readonly from: Date, public readonly to: Date) {} interval(interval: string) { return new Interval({ from: this.from, to: this.to, interval }); @@ -21,6 +23,29 @@ export class Timerange { ratePerMinute(rate: number) { return this.interval(`1m`).rate(rate); } + + poissonEvents(rate: number) { + return new PoissonEvents(this.from, this.to, rate); + } + + gaussianEvents(mean: Date, width: number, totalPoints: number) { + return new GaussianEvents(this.from, this.to, mean, width, totalPoints); + } + + splitInto(segmentCount: number): Timerange[] { + const duration = this.to.getTime() - this.from.getTime(); + const segmentDuration = duration / segmentCount; + + return Array.from({ length: segmentCount }, (_, i) => { + const from = new Date(this.from.getTime() + i * segmentDuration); + const to = new Date(from.getTime() + segmentDuration); + return new Timerange(from, to); + }); + } + + toString() { + return `Timerange(from=${this.from.toISOString()}, to=${this.to.toISOString()})`; + } } type DateLike = Date | number | Moment | string; diff --git a/packages/kbn-apm-synthtrace/src/lib/logs/custom_logsdb_index_templates.ts b/packages/kbn-apm-synthtrace/src/lib/logs/custom_logsdb_index_templates.ts index a0b155444919e..3eadd3f3941de 100644 --- a/packages/kbn-apm-synthtrace/src/lib/logs/custom_logsdb_index_templates.ts +++ b/packages/kbn-apm-synthtrace/src/lib/logs/custom_logsdb_index_templates.ts @@ -25,6 +25,7 @@ export const indexTemplates: { template: { settings: { mode: 'logsdb', + default_pipeline: 'logs@default-pipeline', }, }, priority: 500, diff --git a/packages/kbn-apm-synthtrace/src/lib/logs/logs_synthtrace_es_client.ts b/packages/kbn-apm-synthtrace/src/lib/logs/logs_synthtrace_es_client.ts index a6a64429f9b86..9673d1678132b 100644 --- a/packages/kbn-apm-synthtrace/src/lib/logs/logs_synthtrace_es_client.ts +++ b/packages/kbn-apm-synthtrace/src/lib/logs/logs_synthtrace_es_client.ts @@ -7,16 +7,20 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { Client } from '@elastic/elasticsearch'; +import { Client, estypes } from '@elastic/elasticsearch'; import { pipeline, Readable } from 'stream'; import { LogDocument } from '@kbn/apm-synthtrace-client/src/lib/logs'; -import { MappingTypeMapping } from '@elastic/elasticsearch/lib/api/types'; +import { IngestProcessorContainer, MappingTypeMapping } from '@elastic/elasticsearch/lib/api/types'; +import { ValuesType } from 'utility-types'; import { SynthtraceEsClient, SynthtraceEsClientOptions } from '../shared/base_client'; import { getSerializeTransform } from '../shared/get_serialize_transform'; import { Logger } from '../utils/create_logger'; import { indexTemplates, IndexTemplateName } from './custom_logsdb_index_templates'; import { getRoutingTransform } from '../shared/data_stream_get_routing_transform'; +export const LogsIndex = 'logs'; +export const LogsCustom = 'logs@custom'; + export type LogsSynthtraceEsClientOptions = Omit; export class LogsSynthtraceEsClient extends SynthtraceEsClient { @@ -60,6 +64,47 @@ export class LogsSynthtraceEsClient extends SynthtraceEsClient { this.logger.error(`Index creation failed: ${index} - ${err.message}`); } } + + async updateIndexTemplate( + indexName: string, + modify: ( + template: ValuesType< + estypes.IndicesGetIndexTemplateResponse['index_templates'] + >['index_template'] + ) => estypes.IndicesPutIndexTemplateRequest + ) { + try { + const response = await this.client.indices.getIndexTemplate({ + name: indexName, + }); + + await Promise.all( + response.index_templates.map((template) => { + return this.client.indices.putIndexTemplate({ + ...modify(template.index_template), + name: template.name, + }); + }) + ); + + this.logger.info(`Updated ${indexName} index template`); + } catch (err) { + this.logger.error(`Update index template failed: ${indexName} - ${err.message}`); + } + } + + async createCustomPipeline(processors: IngestProcessorContainer[]) { + try { + this.client.ingest.putPipeline({ + id: LogsCustom, + processors, + version: 1, + }); + this.logger.info(`Custom pipeline created: ${LogsCustom}`); + } catch (err) { + this.logger.error(`Custom pipeline creation failed: ${LogsCustom} - ${err.message}`); + } + } } function logsPipeline() { diff --git a/packages/kbn-apm-synthtrace/src/scenarios/degraded_logs.ts b/packages/kbn-apm-synthtrace/src/scenarios/degraded_logs.ts index 47dd4ffd2652f..b3e41bbdd4e28 100644 --- a/packages/kbn-apm-synthtrace/src/scenarios/degraded_logs.ts +++ b/packages/kbn-apm-synthtrace/src/scenarios/degraded_logs.ts @@ -16,12 +16,10 @@ import { getCluster, getCloudRegion, getCloudProvider, + MORE_THAN_1024_CHARS, } from './helpers/logs_mock_data'; import { parseLogsScenarioOpts } from './helpers/logs_scenario_opts_parser'; -const MORE_THAN_1024_CHARS = - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?'; - // Logs Data logic const MESSAGE_LOG_LEVELS = [ { message: 'A simple log', level: 'info' }, diff --git a/packages/kbn-apm-synthtrace/src/scenarios/degraded_synthetics_monitors.ts b/packages/kbn-apm-synthtrace/src/scenarios/degraded_synthetics_monitors.ts index c61fecd8b7109..6e00bfd0abf15 100644 --- a/packages/kbn-apm-synthtrace/src/scenarios/degraded_synthetics_monitors.ts +++ b/packages/kbn-apm-synthtrace/src/scenarios/degraded_synthetics_monitors.ts @@ -14,12 +14,9 @@ import { } from '@kbn/apm-synthtrace-client'; import { Scenario } from '../cli/scenario'; import { withClient } from '../lib/utils/with_client'; -import { getIpAddress } from './helpers/logs_mock_data'; +import { MORE_THAN_1024_CHARS, getIpAddress } from './helpers/logs_mock_data'; import { getAtIndexOrRandom } from './helpers/get_at_index_or_random'; -const MORE_THAN_1024_CHARS = - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?'; - const MONITOR_NAMES = Array(4) .fill(null) .map((_, idx) => `synth-monitor-${idx}`); diff --git a/packages/kbn-apm-synthtrace/src/scenarios/distributed_unstructured_logs.ts b/packages/kbn-apm-synthtrace/src/scenarios/distributed_unstructured_logs.ts new file mode 100644 index 0000000000000..83860635ae64a --- /dev/null +++ b/packages/kbn-apm-synthtrace/src/scenarios/distributed_unstructured_logs.ts @@ -0,0 +1,197 @@ +/* + * Copyright 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 { infra, LogDocument, log } from '@kbn/apm-synthtrace-client'; +import { fakerEN as faker } from '@faker-js/faker'; +import { z } from '@kbn/zod'; +import { Scenario } from '../cli/scenario'; +import { withClient } from '../lib/utils/with_client'; +import { + LogMessageGenerator, + generateUnstructuredLogMessage, + unstructuredLogMessageGenerators, +} from './helpers/unstructured_logs'; + +const scenarioOptsSchema = z.intersection( + z.object({ + randomSeed: z.number().default(0), + messageGroup: z + .enum([ + 'httpAccess', + 'userAuthentication', + 'networkEvent', + 'dbOperations', + 'taskOperations', + 'degradedOperations', + 'errorOperations', + ]) + .default('dbOperations'), + }), + z + .discriminatedUnion('distribution', [ + z.object({ + distribution: z.literal('uniform'), + rate: z.number().default(1), + }), + z.object({ + distribution: z.literal('poisson'), + rate: z.number().default(1), + }), + z.object({ + distribution: z.literal('gaussian'), + mean: z.coerce.date().describe('Time of the peak of the gaussian distribution'), + width: z.number().default(5000).describe('Width of the gaussian distribution in ms'), + totalPoints: z + .number() + .default(100) + .describe('Total number of points in the gaussian distribution'), + }), + ]) + .default({ distribution: 'uniform', rate: 1 }) +); + +type ScenarioOpts = z.output; + +const scenario: Scenario = async (runOptions) => { + return { + generate: ({ range, clients: { logsEsClient } }) => { + const { logger } = runOptions; + const scenarioOpts = scenarioOptsSchema.parse(runOptions.scenarioOpts ?? {}); + + faker.seed(scenarioOpts.randomSeed); + faker.setDefaultRefDate(range.from.toISOString()); + + logger.debug(`Generating ${scenarioOpts.distribution} logs...`); + + // Logs Data logic + const LOG_LEVELS = ['info', 'debug', 'error', 'warn', 'trace', 'fatal']; + + const clusterDefinions = [ + { + 'orchestrator.cluster.id': faker.string.nanoid(), + 'orchestrator.cluster.name': 'synth-cluster-1', + 'orchestrator.namespace': 'default', + 'cloud.provider': 'gcp', + 'cloud.region': 'eu-central-1', + 'cloud.availability_zone': 'eu-central-1a', + 'cloud.project.id': faker.string.nanoid(), + }, + { + 'orchestrator.cluster.id': faker.string.nanoid(), + 'orchestrator.cluster.name': 'synth-cluster-2', + 'orchestrator.namespace': 'production', + 'cloud.provider': 'aws', + 'cloud.region': 'us-east-1', + 'cloud.availability_zone': 'us-east-1a', + 'cloud.project.id': faker.string.nanoid(), + }, + { + 'orchestrator.cluster.id': faker.string.nanoid(), + 'orchestrator.cluster.name': 'synth-cluster-3', + 'orchestrator.namespace': 'kube', + 'cloud.provider': 'azure', + 'cloud.region': 'area-51', + 'cloud.availability_zone': 'area-51a', + 'cloud.project.id': faker.string.nanoid(), + }, + ]; + + const hostEntities = [ + { + 'host.name': 'host-1', + 'agent.id': 'synth-agent-1', + 'agent.name': 'nodejs', + 'cloud.instance.id': faker.string.nanoid(), + 'orchestrator.resource.id': faker.string.nanoid(), + ...clusterDefinions[0], + }, + { + 'host.name': 'host-2', + 'agent.id': 'synth-agent-2', + 'agent.name': 'custom', + 'cloud.instance.id': faker.string.nanoid(), + 'orchestrator.resource.id': faker.string.nanoid(), + ...clusterDefinions[1], + }, + { + 'host.name': 'host-3', + 'agent.id': 'synth-agent-3', + 'agent.name': 'python', + 'cloud.instance.id': faker.string.nanoid(), + 'orchestrator.resource.id': faker.string.nanoid(), + ...clusterDefinions[2], + }, + ].map((hostDefinition) => + infra.minimalHost(hostDefinition['host.name']).overrides(hostDefinition) + ); + + const serviceNames = Array(3) + .fill(null) + .map((_, idx) => `synth-service-${idx}`); + + const generatorFactory = + scenarioOpts.distribution === 'uniform' + ? range.interval('1s').rate(scenarioOpts.rate) + : scenarioOpts.distribution === 'poisson' + ? range.poissonEvents(scenarioOpts.rate) + : range.gaussianEvents(scenarioOpts.mean, scenarioOpts.width, scenarioOpts.totalPoints); + + const logs = generatorFactory.generator((timestamp) => { + const entity = faker.helpers.arrayElement(hostEntities); + const serviceName = faker.helpers.arrayElement(serviceNames); + const level = faker.helpers.arrayElement(LOG_LEVELS); + const messages = logMessageGenerators[scenarioOpts.messageGroup](faker); + + return messages.map((message) => + log + .createMinimal() + .message(message) + .logLevel(level) + .service(serviceName) + .overrides({ + ...entity.fields, + labels: { + scenario: 'rare', + population: scenarioOpts.distribution, + }, + }) + .timestamp(timestamp) + ); + }); + + return [ + withClient( + logsEsClient, + logger.perf('generating_logs', () => [logs]) + ), + ]; + }, + }; +}; + +export default scenario; + +const logMessageGenerators = { + httpAccess: generateUnstructuredLogMessage([unstructuredLogMessageGenerators.httpAccess]), + userAuthentication: generateUnstructuredLogMessage([ + unstructuredLogMessageGenerators.userAuthentication, + ]), + networkEvent: generateUnstructuredLogMessage([unstructuredLogMessageGenerators.networkEvent]), + dbOperations: generateUnstructuredLogMessage([unstructuredLogMessageGenerators.dbOperation]), + taskOperations: generateUnstructuredLogMessage([ + unstructuredLogMessageGenerators.taskStatusSuccess, + ]), + degradedOperations: generateUnstructuredLogMessage([ + unstructuredLogMessageGenerators.taskStatusFailure, + ]), + errorOperations: generateUnstructuredLogMessage([ + unstructuredLogMessageGenerators.error, + unstructuredLogMessageGenerators.restart, + ]), +} satisfies Record; diff --git a/packages/kbn-apm-synthtrace/src/scenarios/failed_logs.ts b/packages/kbn-apm-synthtrace/src/scenarios/failed_logs.ts new file mode 100644 index 0000000000000..91ddedac270b5 --- /dev/null +++ b/packages/kbn-apm-synthtrace/src/scenarios/failed_logs.ts @@ -0,0 +1,195 @@ +/* + * Copyright 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 { LogDocument, log, generateShortId, generateLongId } from '@kbn/apm-synthtrace-client'; +import { merge } from 'lodash'; +import { Scenario } from '../cli/scenario'; +import { IndexTemplateName } from '../lib/logs/custom_logsdb_index_templates'; +import { withClient } from '../lib/utils/with_client'; +import { + getServiceName, + getCluster, + getCloudRegion, + getCloudProvider, + MORE_THAN_1024_CHARS, +} from './helpers/logs_mock_data'; +import { parseLogsScenarioOpts } from './helpers/logs_scenario_opts_parser'; +import { LogsIndex } from '../lib/logs/logs_synthtrace_es_client'; + +const processors = [ + { + script: { + tag: 'normalize log level', + lang: 'painless', + source: ` + String level = ctx['log.level']; + if ('0'.equals(level)) { + ctx['log.level'] = 'info'; + } else if ('1'.equals(level)) { + ctx['log.level'] = 'debug'; + } else if ('2'.equals(level)) { + ctx['log.level'] = 'warning'; + } else if ('3'.equals(level)) { + ctx['log.level'] = 'error'; + } else { + throw new Exception("Not a valid log level"); + } + `, + }, + }, +]; + +// Logs Data logic +const MESSAGE_LOG_LEVELS = [ + { message: 'A simple log', level: '0' }, + { + message: 'Another log message', + level: '1', + }, + { + message: 'A log message generated from a warning', + level: '2', + }, + { message: 'Error with certificate: "ca_trusted_fingerprint"', level: '3' }, +]; + +const scenario: Scenario = async (runOptions) => { + const { isLogsDb } = parseLogsScenarioOpts(runOptions.scenarioOpts); + return { + bootstrap: async ({ logsEsClient }) => { + await logsEsClient.createCustomPipeline(processors); + if (isLogsDb) await logsEsClient.createIndexTemplate(IndexTemplateName.LogsDb); + + await logsEsClient.updateIndexTemplate( + isLogsDb ? IndexTemplateName.LogsDb : LogsIndex, + (template) => { + const next = { + name: LogsIndex, + data_stream: { + failure_store: true, + }, + }; + + return merge({}, template, next); + } + ); + }, + generate: ({ range, clients: { logsEsClient } }) => { + const { logger } = runOptions; + + const constructLogsCommonData = () => { + const index = Math.floor(Math.random() * 3); + const serviceName = getServiceName(index); + const logMessage = MESSAGE_LOG_LEVELS[index]; + const { clusterId, clusterName } = getCluster(index); + const cloudRegion = getCloudRegion(index); + + const commonLongEntryFields: LogDocument = { + 'trace.id': generateShortId(), + 'agent.name': 'synth-agent', + 'orchestrator.cluster.name': clusterName, + 'orchestrator.cluster.id': clusterId, + 'orchestrator.resource.id': generateShortId(), + 'cloud.provider': getCloudProvider(), + 'cloud.region': cloudRegion, + 'cloud.availability_zone': `${cloudRegion}a`, + 'cloud.project.id': generateShortId(), + 'cloud.instance.id': generateShortId(), + 'log.file.path': `/logs/${generateLongId()}/error.txt`, + }; + + return { + index, + serviceName, + logMessage, + cloudRegion, + commonLongEntryFields, + }; + }; + + const datasetSynth1Logs = (timestamp: number) => { + const { + serviceName, + logMessage: { level, message }, + commonLongEntryFields, + } = constructLogsCommonData(); + + return log + .create({ isLogsDb }) + .dataset('synth.1') + .message(message) + .logLevel(level) + .service(serviceName) + .defaults(commonLongEntryFields) + .timestamp(timestamp); + }; + + const datasetSynth2Logs = (i: number, timestamp: number) => { + const { + serviceName, + logMessage: { level, message }, + commonLongEntryFields, + } = constructLogsCommonData(); + const isFailed = i % 60 === 0; + return log + .create({ isLogsDb }) + .dataset('synth.2') + .message(message) + .logLevel(isFailed ? '4' : level) // "script_exception": Not a valid log level + .service(serviceName) + .defaults(commonLongEntryFields) + .timestamp(timestamp); + }; + + const datasetSynth3Logs = (i: number, timestamp: number) => { + const { + serviceName, + logMessage: { level, message }, + cloudRegion, + commonLongEntryFields, + } = constructLogsCommonData(); + const isMalformed = i % 10 === 0; + const isFailed = i % 80 === 0; + return log + .create({ isLogsDb }) + .dataset('synth.3') + .message(message) + .logLevel(isFailed ? '5' : level) // "script_exception": Not a valid log level + .service(serviceName) + .defaults({ + ...commonLongEntryFields, + 'cloud.availability_zone': isMalformed + ? MORE_THAN_1024_CHARS // "ignore_above": 1024 in mapping + : `${cloudRegion}a`, + }) + .timestamp(timestamp); + }; + + const logs = range + .interval('1m') + .rate(1) + .generator((timestamp) => { + return Array(200) + .fill(0) + .flatMap((_, index) => [ + datasetSynth1Logs(timestamp), + datasetSynth2Logs(index, timestamp), + datasetSynth3Logs(index, timestamp), + ]); + }); + + return withClient( + logsEsClient, + logger.perf('generating_logs', () => logs) + ); + }, + }; +}; + +export default scenario; diff --git a/packages/kbn-apm-synthtrace/src/scenarios/helpers/logs_mock_data.ts b/packages/kbn-apm-synthtrace/src/scenarios/helpers/logs_mock_data.ts index e974528f16a80..5f3cbd5f054dd 100644 --- a/packages/kbn-apm-synthtrace/src/scenarios/helpers/logs_mock_data.ts +++ b/packages/kbn-apm-synthtrace/src/scenarios/helpers/logs_mock_data.ts @@ -59,6 +59,9 @@ const SERVICE_NAMES = Array(3) .fill(null) .map((_, idx) => `synth-service-${idx}`); +export const MORE_THAN_1024_CHARS = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?'; + // Functions to get random elements export const getCluster = (index?: number) => getAtIndexOrRandom(CLUSTER, index); export const getIpAddress = (index?: number) => getAtIndexOrRandom(IP_ADDRESSES, index); diff --git a/packages/kbn-apm-synthtrace/src/scenarios/helpers/unstructured_logs.ts b/packages/kbn-apm-synthtrace/src/scenarios/helpers/unstructured_logs.ts new file mode 100644 index 0000000000000..490bd449e2b60 --- /dev/null +++ b/packages/kbn-apm-synthtrace/src/scenarios/helpers/unstructured_logs.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", 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 { Faker, faker } from '@faker-js/faker'; + +export type LogMessageGenerator = (f: Faker) => string[]; + +export const unstructuredLogMessageGenerators = { + httpAccess: (f: Faker) => [ + `${f.internet.ip()} - - [${f.date + .past() + .toISOString() + .replace('T', ' ') + .replace( + /\..+/, + '' + )}] "${f.internet.httpMethod()} ${f.internet.url()} HTTP/1.1" ${f.helpers.arrayElement([ + 200, 301, 404, 500, + ])} ${f.number.int({ min: 100, max: 5000 })}`, + ], + dbOperation: (f: Faker) => [ + `${f.database.engine()}: ${f.database.column()} ${f.helpers.arrayElement([ + 'created', + 'updated', + 'deleted', + 'inserted', + ])} successfully ${f.number.int({ max: 100000 })} times`, + ], + taskStatusSuccess: (f: Faker) => [ + `${f.hacker.noun()}: ${f.word.words()} ${f.helpers.arrayElement([ + 'triggered', + 'executed', + 'processed', + 'handled', + ])} successfully at ${f.date.recent().toISOString()}`, + ], + taskStatusFailure: (f: Faker) => [ + `${f.hacker.noun()}: ${f.helpers.arrayElement([ + 'triggering', + 'execution', + 'processing', + 'handling', + ])} of ${f.word.words()} failed at ${f.date.recent().toISOString()}`, + ], + error: (f: Faker) => [ + `${f.helpers.arrayElement([ + 'Error', + 'Exception', + 'Failure', + 'Crash', + 'Bug', + 'Issue', + ])}: ${f.hacker.phrase()}`, + `Stopping ${f.number.int(42)} background tasks...`, + 'Shutting down process...', + ], + restart: (f: Faker) => { + const service = f.database.engine(); + return [ + `Restarting ${service}...`, + `Waiting for queue to drain...`, + `Service ${service} restarted ${f.helpers.arrayElement([ + 'successfully', + 'with errors', + 'with warnings', + ])}`, + ]; + }, + userAuthentication: (f: Faker) => [ + `User ${f.internet.userName()} ${f.helpers.arrayElement([ + 'logged in', + 'logged out', + 'failed to login', + ])}`, + ], + networkEvent: (f: Faker) => [ + `Network ${f.helpers.arrayElement([ + 'connection', + 'disconnection', + 'data transfer', + ])} ${f.helpers.arrayElement(['from', 'to'])} ${f.internet.ip()}`, + ], +} satisfies Record; + +export const generateUnstructuredLogMessage = + (generators: LogMessageGenerator[] = Object.values(unstructuredLogMessageGenerators)) => + (f: Faker = faker) => + f.helpers.arrayElement(generators)(f); diff --git a/packages/kbn-apm-synthtrace/src/scenarios/logs_traces_hosts.ts b/packages/kbn-apm-synthtrace/src/scenarios/logs_traces_hosts.ts index 8a6bdf409a573..6dac3fc9f3226 100644 --- a/packages/kbn-apm-synthtrace/src/scenarios/logs_traces_hosts.ts +++ b/packages/kbn-apm-synthtrace/src/scenarios/logs_traces_hosts.ts @@ -8,21 +8,22 @@ */ import { - log, - LogDocument, + ApmFields, InfraDocument, - apm, Instance, - infra, - ApmFields, + LogDocument, + apm, generateShortId, + infra, + log, } from '@kbn/apm-synthtrace-client'; import { Scenario } from '../cli/scenario'; +import { IndexTemplateName } from '../lib/logs/custom_logsdb_index_templates'; import { Logger } from '../lib/utils/create_logger'; -import { withClient } from '../lib/utils/with_client'; import { getSynthtraceEnvironment } from '../lib/utils/get_synthtrace_environment'; +import { withClient } from '../lib/utils/with_client'; +import { MORE_THAN_1024_CHARS } from './helpers/logs_mock_data'; import { parseLogsScenarioOpts, parseStringToBoolean } from './helpers/logs_scenario_opts_parser'; -import { IndexTemplateName } from '../lib/logs/custom_logsdb_index_templates'; const ENVIRONMENT = getSynthtraceEnvironment(__filename); @@ -475,6 +476,3 @@ const DATASETS = [ ]; const LOG_LEVELS = ['info', 'error', 'warn', 'debug']; - -const MORE_THAN_1024_CHARS = - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?'; diff --git a/packages/kbn-apm-synthtrace/src/scenarios/simple_logs.ts b/packages/kbn-apm-synthtrace/src/scenarios/simple_logs.ts index 3c1fdc5131395..08d914c1017dd 100644 --- a/packages/kbn-apm-synthtrace/src/scenarios/simple_logs.ts +++ b/packages/kbn-apm-synthtrace/src/scenarios/simple_logs.ts @@ -7,19 +7,20 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { LogDocument, log, generateShortId, generateLongId } from '@kbn/apm-synthtrace-client'; +import { LogDocument, generateLongId, generateShortId, log } from '@kbn/apm-synthtrace-client'; import moment from 'moment'; import { Scenario } from '../cli/scenario'; import { IndexTemplateName } from '../lib/logs/custom_logsdb_index_templates'; import { withClient } from '../lib/utils/with_client'; import { - getServiceName, - getGeoCoordinate, - getIpAddress, - getCluster, + MORE_THAN_1024_CHARS, + getAgentName, getCloudProvider, getCloudRegion, - getAgentName, + getCluster, + getGeoCoordinate, + getIpAddress, + getServiceName, } from './helpers/logs_mock_data'; import { parseLogsScenarioOpts } from './helpers/logs_scenario_opts_parser'; @@ -30,9 +31,6 @@ const MESSAGE_LOG_LEVELS = [ { message: 'Error with certificate: "ca_trusted_fingerprint"', level: 'error' }, ]; -const MORE_THAN_1024_CHARS = - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?'; - const scenario: Scenario = async (runOptions) => { const { isLogsDb } = parseLogsScenarioOpts(runOptions.scenarioOpts); diff --git a/packages/kbn-apm-synthtrace/tsconfig.json b/packages/kbn-apm-synthtrace/tsconfig.json index d0f5c5801597a..db93e36421b83 100644 --- a/packages/kbn-apm-synthtrace/tsconfig.json +++ b/packages/kbn-apm-synthtrace/tsconfig.json @@ -10,6 +10,7 @@ "@kbn/apm-synthtrace-client", "@kbn/dev-utils", "@kbn/elastic-agent-utils", + "@kbn/zod", ], "exclude": [ "target/**/*", diff --git a/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.test.ts b/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.test.ts index 21330d0fea3b1..2dfe239ce5b88 100644 --- a/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.test.ts +++ b/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.test.ts @@ -593,6 +593,85 @@ ROW (asdf + asdf)::string, 1.2::string, "1234"::integer, (12321342134 + 23412341 - "aaaaaaaaaaa")::boolean`); }); }); + + describe('list literals', () => { + describe('numeric', () => { + test('wraps long list literals one line', () => { + const query = + 'ROW [1234567890, 1234567890, 1234567890, 1234567890, 1234567890, 1234567890, 1234567890, 1234567890, 1234567890]'; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +ROW + [1234567890, 1234567890, 1234567890, 1234567890, 1234567890, 1234567890, + 1234567890, 1234567890, 1234567890]`); + }); + + test('wraps long list literals to multiple lines one line', () => { + const query = `ROW [1234567890, 1234567890, 1234567890, 1234567890, 1234567890, 1234567890, + 1234567890, 1234567890, 1234567890, 1234567890, 1234567890, 1234567890, + 1234567890, 1234567890, 1234567890, 1234567890, 1234567890, 1234567890, + 1234567890, 1234567890, 1234567890]`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +ROW + [1234567890, 1234567890, 1234567890, 1234567890, 1234567890, 1234567890, + 1234567890, 1234567890, 1234567890, 1234567890, 1234567890, 1234567890, + 1234567890, 1234567890, 1234567890, 1234567890, 1234567890, 1234567890, + 1234567890, 1234567890, 1234567890]`); + }); + + test('breaks very long values one-per-line', () => { + const query = `ROW fn1(fn2(fn3(fn4(fn5(fn6(fn7(fn8([1234567890, 1234567890, 1234567890, 1234567890, 1234567890]))))))))`; + const text = reprint(query, { wrap: 40 }).text; + + expect('\n' + text).toBe(` +ROW + FN1( + FN2( + FN3( + FN4( + FN5( + FN6( + FN7( + FN8( + [ + 1234567890, + 1234567890, + 1234567890, + 1234567890, + 1234567890]))))))))`); + }); + }); + + describe('string', () => { + test('wraps long list literals one line', () => { + const query = + 'ROW ["some text", "another text", "one more text literal", "and another one", "and one more", "and one more", "and one more", "and one more", "and one more"]'; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +ROW + ["some text", "another text", "one more text literal", "and another one", + "and one more", "and one more", "and one more", "and one more", + "and one more"]`); + }); + + test('can break very long strings per line', () => { + const query = + 'ROW ["..............................................", "..............................................", ".............................................."]'; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +ROW + [ + "..............................................", + "..............................................", + ".............................................."]`); + }); + }); + }); }); test.todo('Idempotence on multiple times pretty printing'); diff --git a/packages/kbn-esql-ast/src/pretty_print/wrapping_pretty_printer.ts b/packages/kbn-esql-ast/src/pretty_print/wrapping_pretty_printer.ts index fde7f60a1dba5..91f65a389f0c3 100644 --- a/packages/kbn-esql-ast/src/pretty_print/wrapping_pretty_printer.ts +++ b/packages/kbn-esql-ast/src/pretty_print/wrapping_pretty_printer.ts @@ -15,9 +15,10 @@ import { CommandVisitorContext, ExpressionVisitorContext, FunctionCallExpressionVisitorContext, + ListLiteralExpressionVisitorContext, Visitor, } from '../visitor'; -import { singleItems } from '../visitor/utils'; +import { children, singleItems } from '../visitor/utils'; import { BasicPrettyPrinter, BasicPrettyPrinterOptions } from './basic_pretty_printer'; import { getPrettyPrintStats } from './helpers'; import { LeafPrinter } from './leaf_printer'; @@ -235,7 +236,11 @@ export class WrappingPrettyPrinter { } private printArguments( - ctx: CommandVisitorContext | CommandOptionVisitorContext | FunctionCallExpressionVisitorContext, + ctx: + | CommandVisitorContext + | CommandOptionVisitorContext + | FunctionCallExpressionVisitorContext + | ListLiteralExpressionVisitorContext, inp: Input ) { let txt = ''; @@ -247,7 +252,7 @@ export class WrappingPrettyPrinter { let remainingCurrentLine = inp.remaining; let oneArgumentPerLine = false; - for (const child of singleItems(ctx.node.args)) { + for (const child of children(ctx.node)) { if (getPrettyPrintStats(child).hasLineBreakingDecorations) { oneArgumentPerLine = true; break; @@ -489,13 +494,11 @@ export class WrappingPrettyPrinter { }) .on('visitListLiteralExpression', (ctx, inp: Input): Output => { - let elements = ''; - - for (const out of ctx.visitElements(inp)) { - elements += (elements ? ', ' : '') + out.txt; - } - - const formatted = `[${elements}]${inp.suffix ?? ''}`; + const args = this.printArguments(ctx, { + indent: inp.indent, + remaining: inp.remaining - 1, + }); + const formatted = `[${args.txt}]${inp.suffix ?? ''}`; const { txt, indented } = this.decorateWithComments(inp.indent, ctx.node, formatted); return { txt, indented }; diff --git a/packages/kbn-esql-ast/src/types.ts b/packages/kbn-esql-ast/src/types.ts index 0ca48b2326f7d..1bac6e0cff5b3 100644 --- a/packages/kbn-esql-ast/src/types.ts +++ b/packages/kbn-esql-ast/src/types.ts @@ -40,6 +40,7 @@ export type ESQLAstField = ESQLFunction | ESQLColumn; export type ESQLAstItem = ESQLSingleAstItem | ESQLAstItem[]; export type ESQLAstNodeWithArgs = ESQLCommand | ESQLCommandOption | ESQLFunction; +export type ESQLAstNodeWithChildren = ESQLAstNodeWithArgs | ESQLList; /** * *Proper* are nodes which are objects with `type` property, once we get rid diff --git a/packages/kbn-esql-ast/src/visitor/contexts.ts b/packages/kbn-esql-ast/src/visitor/contexts.ts index 0f637962b7ddd..4b4f04fdca4bb 100644 --- a/packages/kbn-esql-ast/src/visitor/contexts.ts +++ b/packages/kbn-esql-ast/src/visitor/contexts.ts @@ -12,11 +12,12 @@ // and makes it harder to understand the code structure. import { type GlobalVisitorContext, SharedData } from './global_visitor_context'; -import { firstItem, singleItems } from './utils'; +import { children, firstItem, singleItems } from './utils'; import type { ESQLAstCommand, ESQLAstItem, ESQLAstNodeWithArgs, + ESQLAstNodeWithChildren, ESQLAstRenameExpression, ESQLColumn, ESQLCommandOption, @@ -47,6 +48,11 @@ import { Builder } from '../builder'; const isNodeWithArgs = (x: unknown): x is ESQLAstNodeWithArgs => !!x && typeof x === 'object' && Array.isArray((x as any).args); +const isNodeWithChildren = (x: unknown): x is ESQLAstNodeWithChildren => + !!x && + typeof x === 'object' && + (Array.isArray((x as any).args) || Array.isArray((x as any).values)); + export class VisitorContext< Methods extends VisitorMethods = VisitorMethods, Data extends SharedData = SharedData, @@ -99,13 +105,13 @@ export class VisitorContext< public arguments(): ESQLAstExpressionNode[] { const node = this.node; - if (!isNodeWithArgs(node)) { + if (!isNodeWithChildren(node)) { return []; } const args: ESQLAstExpressionNode[] = []; - for (const arg of singleItems(node.args)) { + for (const arg of children(node)) { args.push(arg); } diff --git a/packages/kbn-esql-ast/src/visitor/utils.ts b/packages/kbn-esql-ast/src/visitor/utils.ts index 2e54a89c2bf52..0dc95b73cf9d7 100644 --- a/packages/kbn-esql-ast/src/visitor/utils.ts +++ b/packages/kbn-esql-ast/src/visitor/utils.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { ESQLAstItem, ESQLSingleAstItem } from '../types'; +import { ESQLAstItem, ESQLProperNode, ESQLSingleAstItem } from '../types'; /** * Normalizes AST "item" list to only contain *single* items. @@ -48,3 +48,32 @@ export const lastItem = (items: ESQLAstItem[]): ESQLSingleAstItem | undefined => if (Array.isArray(last)) return lastItem(last as ESQLAstItem[]); return last as ESQLSingleAstItem; }; + +export function* children(node: ESQLProperNode): Iterable { + switch (node.type) { + case 'function': + case 'command': + case 'option': { + for (const arg of singleItems(node.args)) { + yield arg; + } + break; + } + case 'list': { + for (const item of singleItems(node.values)) { + yield item; + } + break; + } + case 'inlineCast': { + if (Array.isArray(node.value)) { + for (const item of singleItems(node.value)) { + yield item; + } + } else { + yield node.value; + } + break; + } + } +} diff --git a/packages/kbn-esql-utils/index.ts b/packages/kbn-esql-utils/index.ts index 223181f2bd154..333557964d873 100644 --- a/packages/kbn-esql-utils/index.ts +++ b/packages/kbn-esql-utils/index.ts @@ -29,6 +29,7 @@ export { isQueryWrappedByPipes, retrieveMetadataColumns, getQueryColumnsFromESQLQuery, + isESQLColumnSortable, TextBasedLanguages, } from './src'; diff --git a/packages/kbn-esql-utils/src/index.ts b/packages/kbn-esql-utils/src/index.ts index e36283c7a9238..3b3228e7a2a4a 100644 --- a/packages/kbn-esql-utils/src/index.ts +++ b/packages/kbn-esql-utils/src/index.ts @@ -31,3 +31,4 @@ export { getStartEndParams, hasStartEndParams, } from './utils/run_query'; +export { isESQLColumnSortable } from './utils/esql_fields_utils'; diff --git a/packages/kbn-esql-utils/src/utils/esql_fields_utils.test.ts b/packages/kbn-esql-utils/src/utils/esql_fields_utils.test.ts new file mode 100644 index 0000000000000..ef8a24e686bd6 --- /dev/null +++ b/packages/kbn-esql-utils/src/utils/esql_fields_utils.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", 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 { DatatableColumn } from '@kbn/expressions-plugin/common'; +import { isESQLColumnSortable } from './esql_fields_utils'; + +describe('esql fields helpers', () => { + describe('isESQLColumnSortable', () => { + it('returns false for geo fields', () => { + const geoField = { + id: 'geo.coordinates', + name: 'geo.coordinates', + meta: { + type: 'geo_point', + esType: 'geo_point', + }, + isNull: false, + } as DatatableColumn; + expect(isESQLColumnSortable(geoField)).toBeFalsy(); + }); + + it('returns false for source fields', () => { + const sourceField = { + id: '_source', + name: '_source', + meta: { + type: '_source', + esType: '_source', + }, + isNull: false, + } as DatatableColumn; + expect(isESQLColumnSortable(sourceField)).toBeFalsy(); + }); + + it('returns false for counter fields', () => { + const tsdbField = { + id: 'tsbd_counter', + name: 'tsbd_counter', + meta: { + type: 'number', + esType: 'counter_long', + }, + isNull: false, + } as DatatableColumn; + expect(isESQLColumnSortable(tsdbField)).toBeFalsy(); + }); + + it('returns true for everything else', () => { + const keywordField = { + id: 'sortable', + name: 'sortable', + meta: { + type: 'string', + esType: 'keyword', + }, + isNull: false, + } as DatatableColumn; + expect(isESQLColumnSortable(keywordField)).toBeTruthy(); + }); + }); +}); diff --git a/packages/kbn-esql-utils/src/utils/esql_fields_utils.ts b/packages/kbn-esql-utils/src/utils/esql_fields_utils.ts new file mode 100644 index 0000000000000..f5a0fe7b81340 --- /dev/null +++ b/packages/kbn-esql-utils/src/utils/esql_fields_utils.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", 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 { DatatableColumn } from '@kbn/expressions-plugin/common'; + +const SPATIAL_FIELDS = ['geo_point', 'geo_shape', 'point', 'shape']; +const SOURCE_FIELD = '_source'; +const TSDB_COUNTER_FIELDS_PREFIX = 'counter_'; + +/** + * Check if a column is sortable. + * + * @param column The DatatableColumn of the field. + * @returns True if the column is sortable, false otherwise. + */ + +export const isESQLColumnSortable = (column: DatatableColumn): boolean => { + // We don't allow sorting on spatial fields + if (SPATIAL_FIELDS.includes(column.meta?.type)) { + return false; + } + + // we don't allow sorting on the _source field + if (column.meta?.type === SOURCE_FIELD) { + return false; + } + + // we don't allow sorting on tsdb counter fields + if (column.meta?.esType && column.meta?.esType?.indexOf(TSDB_COUNTER_FIELDS_PREFIX) !== -1) { + return false; + } + + return true; +}; 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 84779f1dd36b5..a0a4a359c5ff6 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts @@ -387,6 +387,23 @@ describe('autocomplete', () => { '```````````````````````````````round(doubleField) + 1```````````````` + 1```````` + 1```` + 1`` + 1`', ] ); + + 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); + + 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( + 'doubleField' + ); + expect( + await getSuggestions('from a_index | EVAL foo = 1 | KEEP doubleField, /') + ).not.toContain('doubleField'); + }); }); } @@ -1111,11 +1128,14 @@ describe('autocomplete', () => { ]); }); - describe('KEEP ', () => { + describe.each(['KEEP', 'DROP'])('%s ', (commandName) => { // KEEP field - testSuggestions('FROM a | KEEP /', getFieldNamesByType('any').map(attachTriggerCommand)); testSuggestions( - 'FROM a | KEEP d/', + `FROM a | ${commandName} /`, + getFieldNamesByType('any').map(attachTriggerCommand) + ); + testSuggestions( + `FROM a | ${commandName} d/`, getFieldNamesByType('any') .map((text) => ({ text, @@ -1124,11 +1144,11 @@ describe('autocomplete', () => { .map(attachTriggerCommand) ); testSuggestions( - 'FROM a | KEEP doubleFiel/', + `FROM a | ${commandName} doubleFiel/`, getFieldNamesByType('any').map(attachTriggerCommand) ); testSuggestions( - 'FROM a | KEEP doubleField/', + `FROM a | ${commandName} doubleField/`, ['doubleField, ', 'doubleField | '] .map((text) => ({ text, @@ -1141,7 +1161,7 @@ describe('autocomplete', () => { // Let's get funky with the field names testSuggestions( - 'FROM a | KEEP @timestamp/', + `FROM a | ${commandName} @timestamp/`, ['@timestamp, ', '@timestamp | '] .map((text) => ({ text, @@ -1150,10 +1170,15 @@ describe('autocomplete', () => { })) .map(attachTriggerCommand), undefined, - [[{ name: '@timestamp', type: 'date' }]] + [ + [ + { name: '@timestamp', type: 'date' }, + { name: 'utc_stamp', type: 'date' }, + ], + ] ); testSuggestions( - 'FROM a | KEEP foo.bar/', + `FROM a | ${commandName} foo.bar/`, ['foo.bar, ', 'foo.bar | '] .map((text) => ({ text, @@ -1162,26 +1187,34 @@ describe('autocomplete', () => { })) .map(attachTriggerCommand), undefined, - [[{ name: 'foo.bar', type: 'double' }]] + [ + [ + { name: 'foo.bar', type: 'double' }, + { name: 'baz', type: 'date' }, + ], + ] ); describe('escaped field names', () => { // This isn't actually the behavior we want, but this test is here // to make sure no weird suggestions start cropping up in this case. - testSuggestions('FROM a | KEEP `foo.bar`/', ['foo.bar'], undefined, [ + testSuggestions(`FROM a | ${commandName} \`foo.bar\`/`, ['foo.bar'], undefined, [ [{ name: 'foo.bar', type: 'double' }], ]); // @todo re-enable these tests when we can use AST to support this case - testSuggestions.skip('FROM a | KEEP `foo.bar`/', ['foo.bar, ', 'foo.bar | '], undefined, [ - [{ name: 'foo.bar', type: 'double' }], - ]); testSuggestions.skip( - 'FROM a | KEEP `foo`.`bar`/', + `FROM a | ${commandName} \`foo.bar\`/`, ['foo.bar, ', 'foo.bar | '], undefined, [[{ name: 'foo.bar', type: 'double' }]] ); - testSuggestions.skip('FROM a | KEEP `any#Char$Field`/', [ + testSuggestions.skip( + `FROM a | ${commandName} \`foo\`.\`bar\`/`, + ['foo.bar, ', 'foo.bar | '], + undefined, + [[{ name: 'foo.bar', type: 'double' }]] + ); + testSuggestions.skip(`FROM a | ${commandName} \`any#Char$Field\`/`, [ '`any#Char$Field`, ', '`any#Char$Field` | ', ]); @@ -1189,12 +1222,28 @@ describe('autocomplete', () => { // Subsequent fields testSuggestions( - 'FROM a | KEEP doubleField, dateFiel/', + `FROM a | ${commandName} doubleField, dateFiel/`, getFieldNamesByType('any') .filter((s) => s !== 'doubleField') .map(attachTriggerCommand) ); - testSuggestions('FROM a | KEEP doubleField, dateField/', ['dateField, ', 'dateField | ']); + testSuggestions(`FROM a | ${commandName} doubleField, dateField/`, [ + 'dateField, ', + 'dateField | ', + ]); + + // out of fields + testSuggestions( + `FROM a | ${commandName} doubleField, dateField/`, + ['dateField | '], + undefined, + [ + [ + { name: 'doubleField', type: 'double' }, + { name: 'dateField', type: 'date' }, + ], + ] + ); }); }); }); diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts index 2433f5d496521..6f9fb66a8c715 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts @@ -627,7 +627,7 @@ async function getExpressionSuggestionsByType( literals: argDef.constantOnly, }, { - ignoreFields: isNewExpression + ignoreColumns: isNewExpression ? command.args.filter(isColumnItem).map(({ name }) => name) : [], } @@ -656,10 +656,15 @@ async function getExpressionSuggestionsByType( })); } - return [ - { ...pipeCompleteItem, text: ' | ' }, - { ...commaCompleteItem, text: ', ' }, - ].map((s) => ({ + 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, @@ -1176,15 +1181,15 @@ async function getFieldsOrFunctionsSuggestions( }, { ignoreFn = [], - ignoreFields = [], + ignoreColumns = [], }: { ignoreFn?: string[]; - ignoreFields?: string[]; + ignoreColumns?: string[]; } = {} ): Promise { const filteredFieldsByType = pushItUpInTheList( (await (fields - ? getFieldsByType(types, ignoreFields, { + ? getFieldsByType(types, ignoreColumns, { advanceCursor: commandName === 'sort', openSuggestions: commandName === 'sort', }) @@ -1195,7 +1200,10 @@ async function getFieldsOrFunctionsSuggestions( const filteredVariablesByType: string[] = []; if (variables) { for (const variable of variables.values()) { - if (types.includes('any') || types.includes(variable[0].type)) { + if ( + (types.includes('any') || types.includes(variable[0].type)) && + !ignoreColumns.includes(variable[0].name) + ) { filteredVariablesByType.push(variable[0].name); } } @@ -1515,7 +1523,7 @@ async function getListArgsSuggestions( fields: true, variables: anyVariables, }, - { ignoreFields: [firstArg.name, ...otherArgs.map(({ name }) => name)] } + { ignoreColumns: [firstArg.name, ...otherArgs.map(({ name }) => name)] } )) ); } @@ -1875,18 +1883,16 @@ async function getOptionArgsSuggestions( * for a given fragment of text in a generic way. A good example is * a field name. * - * When typing a field name, there are three scenarios + * When typing a field name, there are 2 scenarios * - * 1. user hasn't begun typing + * 1. field name is incomplete (includes the empty string) * KEEP / - * - * 2. user is typing a partial field name * KEEP fie/ * - * 3. user has typed a complete field name + * 2. field name is complete * KEEP field/ * - * This function provides a framework for handling all three scenarios in a clean way. + * 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 diff --git a/packages/kbn-import-resolver/src/import_resolver.ts b/packages/kbn-import-resolver/src/import_resolver.ts index 1b41418a5cb24..9ca16981b2afc 100644 --- a/packages/kbn-import-resolver/src/import_resolver.ts +++ b/packages/kbn-import-resolver/src/import_resolver.ts @@ -122,11 +122,6 @@ export class ImportResolver { return true; } - // ignore amd require done by ace syntax plugin - if (req === 'ace/lib/dom') { - return true; - } - // typescript validates these imports fine and they're purely virtual thanks to ambient type definitions in @elastic/eui so /shrug if ( req.startsWith('@elastic/eui/src/components/') || diff --git a/packages/kbn-import-resolver/src/integration_tests/import_resolver.test.ts b/packages/kbn-import-resolver/src/integration_tests/import_resolver.test.ts index f484de7904f06..1089f811b6e98 100644 --- a/packages/kbn-import-resolver/src/integration_tests/import_resolver.test.ts +++ b/packages/kbn-import-resolver/src/integration_tests/import_resolver.test.ts @@ -99,12 +99,6 @@ describe('#resolve()', () => { } `); - expect(resolver.resolve('ace/lib/dom', FIXTURES_DIR)).toMatchInlineSnapshot(` - Object { - "type": "ignore", - } - `); - expect(resolver.resolve('@elastic/eui/src/components/foo', FIXTURES_DIR)) .toMatchInlineSnapshot(` Object { diff --git a/packages/kbn-management/settings/setting_ids/index.ts b/packages/kbn-management/settings/setting_ids/index.ts index a7051804289bd..e926007f77f25 100644 --- a/packages/kbn-management/settings/setting_ids/index.ts +++ b/packages/kbn-management/settings/setting_ids/index.ts @@ -142,6 +142,7 @@ export const OBSERVABILITY_APM_ENABLE_SERVICE_INVENTORY_TABLE_SEARCH_BAR = 'observability:apmEnableServiceInventoryTableSearchBar'; export const OBSERVABILITY_LOGS_EXPLORER_ALLOWED_DATA_VIEWS_ID = 'observability:logsExplorer:allowedDataViews'; +export const OBSERVABILITY_LOGS_SHARED_NEW_LOGS_OVERVIEW_ID = 'observability:newLogsOverview'; export const OBSERVABILITY_ENTITY_CENTRIC_EXPERIENCE = 'observability:entityCentricExperience'; export const OBSERVABILITY_LOGS_DATA_ACCESS_LOG_SOURCES_ID = 'observability:logSources'; export const OBSERVABILITY_ENABLE_LOGS_STREAM = 'observability:enableLogsStream'; @@ -178,13 +179,9 @@ export const SECURITY_SOLUTION_RULES_TABLE_REFRESH_ID = 'securitySolution:rulesT export const SECURITY_SOLUTION_ENABLE_NEWS_FEED_ID = 'securitySolution:enableNewsFeed'; export const SECURITY_SOLUTION_NEWS_FEED_URL_ID = 'securitySolution:newsFeedUrl'; export const SECURITY_SOLUTION_IP_REPUTATION_LINKS_ID = 'securitySolution:ipReputationLinks'; -export const SECURITY_SOLUTION_ENABLE_CCS_WARNING_ID = 'securitySolution:enableCcsWarning'; export const SECURITY_SOLUTION_SHOW_RELATED_INTEGRATIONS_ID = 'securitySolution:showRelatedIntegrations'; export const SECURITY_SOLUTION_DEFAULT_ALERT_TAGS_KEY = 'securitySolution:alertTags' as const; -/** This Kibana Advanced Setting allows users to enable/disable querying cold and frozen data tiers in analyzer */ -export const SECURITY_SOLUTION_EXCLUDE_COLD_AND_FROZEN_TIERS_IN_ANALYZER = - 'securitySolution:excludeColdAndFrozenTiersInAnalyzer' as const; /** This Kibana Advanced Setting allows users to enable/disable the Asset Criticality feature */ export const SECURITY_SOLUTION_ENABLE_ASSET_CRITICALITY_SETTING = 'securitySolution:enableAssetCriticality' as const; diff --git a/packages/kbn-optimizer/src/worker/webpack.config.ts b/packages/kbn-optimizer/src/worker/webpack.config.ts index 539d3098030e0..52a837724480d 100644 --- a/packages/kbn-optimizer/src/worker/webpack.config.ts +++ b/packages/kbn-optimizer/src/worker/webpack.config.ts @@ -247,6 +247,18 @@ export function getWebpackConfig( }, }, }, + { + test: /node_modules\/@?xstate5\/.*\.js$/, + use: { + loader: 'babel-loader', + options: { + babelrc: false, + envName: worker.dist ? 'production' : 'development', + presets: [BABEL_PRESET], + plugins: ['@babel/plugin-transform-logical-assignment-operators'], + }, + }, + }, { test: /\.(html|md|txt|tmpl)$/, use: { diff --git a/packages/kbn-router-to-openapispec/index.ts b/packages/kbn-router-to-openapispec/index.ts index 17f8253348ab3..1869167db0323 100644 --- a/packages/kbn-router-to-openapispec/index.ts +++ b/packages/kbn-router-to-openapispec/index.ts @@ -7,4 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { generateOpenApiDocument } from './src/generate_oas'; +export { + generateOpenApiDocument, + type GenerateOpenApiDocumentOptionsFilters, +} from './src/generate_oas'; diff --git a/packages/kbn-router-to-openapispec/src/util.test.ts b/packages/kbn-router-to-openapispec/src/util.test.ts index 79b4ddf8eba84..abbb605df79e5 100644 --- a/packages/kbn-router-to-openapispec/src/util.test.ts +++ b/packages/kbn-router-to-openapispec/src/util.test.ts @@ -163,6 +163,15 @@ describe('prepareRoutes', () => { output: [{ path: '/api/foo', options: { access: pub } }], filters: { excludePathsMatching: ['/api/b'], access: pub }, }, + { + input: [ + { path: '/api/foo', options: { access: pub, excludeFromOAS: true } }, + { path: '/api/bar', options: { access: internal } }, + { path: '/api/baz', options: { access: pub } }, + ], + output: [{ path: '/api/baz', options: { access: pub } }], + filters: { excludePathsMatching: ['/api/bar'], access: pub }, + }, ])('returns the expected routes #%#', ({ input, output, filters }) => { expect(prepareRoutes(input, filters)).toEqual(output); }); diff --git a/packages/kbn-router-to-openapispec/src/util.ts b/packages/kbn-router-to-openapispec/src/util.ts index 1aa2a080ccc18..55f7348dc199a 100644 --- a/packages/kbn-router-to-openapispec/src/util.ts +++ b/packages/kbn-router-to-openapispec/src/util.ts @@ -105,13 +105,14 @@ export const getVersionedHeaderParam = ( }); export const prepareRoutes = < - R extends { path: string; options: { access?: 'public' | 'internal' } } + R extends { path: string; options: { access?: 'public' | 'internal'; excludeFromOAS?: boolean } } >( routes: R[], filters: GenerateOpenApiDocumentOptionsFilters = {} ): R[] => { if (Object.getOwnPropertyNames(filters).length === 0) return routes; return routes.filter((route) => { + if (route.options.excludeFromOAS) return false; if ( filters.excludePathsMatching && filters.excludePathsMatching.some((ex) => route.path.startsWith(ex)) diff --git a/packages/kbn-search-connectors/components/configuration/connector_configuration_form.tsx b/packages/kbn-search-connectors/components/configuration/connector_configuration_form.tsx index e70754d5e09e8..f7e619f407f12 100644 --- a/packages/kbn-search-connectors/components/configuration/connector_configuration_form.tsx +++ b/packages/kbn-search-connectors/components/configuration/connector_configuration_form.tsx @@ -109,6 +109,15 @@ export const ConnectorConfigurationForm: React.FC = items={category.configEntries} hasDocumentLevelSecurityEnabled={hasDocumentLevelSecurity} setConfigEntry={(key, value) => { + const entry = localConfig[key]; + if (entry && !isCategoryEntry(entry)) { + const newConfiguration: ConnectorConfiguration = { + ...localConfig, + [key]: { ...entry, value }, + }; + setLocalConfig(newConfiguration); + } + const categories = configView.categories; categories[index] = { ...categories[index], [key]: value }; setConfigView({ @@ -136,6 +145,15 @@ export const ConnectorConfigurationForm: React.FC = items={configView.advancedConfigurations} hasDocumentLevelSecurityEnabled={hasDocumentLevelSecurity} setConfigEntry={(key, value) => { + const entry = localConfig[key]; + if (entry && !isCategoryEntry(entry)) { + const newConfiguration: ConnectorConfiguration = { + ...localConfig, + [key]: { ...entry, value }, + }; + setLocalConfig(newConfiguration); + } + setConfigView({ ...configView, advancedConfigurations: configView.advancedConfigurations.map((config) => diff --git a/packages/kbn-storybook/src/webpack.config.ts b/packages/kbn-storybook/src/webpack.config.ts index fb901692e7f66..b03d78dbbc190 100644 --- a/packages/kbn-storybook/src/webpack.config.ts +++ b/packages/kbn-storybook/src/webpack.config.ts @@ -125,6 +125,17 @@ export default ({ config: storybookConfig }: { config: Configuration }) => { }, ], }, + { + test: /node_modules\/@?xstate5\/.*\.js$/, + use: { + loader: 'babel-loader', + options: { + babelrc: false, + presets: [require.resolve('@kbn/babel-preset/webpack_preset')], + plugins: ['@babel/plugin-transform-logical-assignment-operators'], + }, + }, + }, ], }, plugins: [new IgnoreNotFoundExportPlugin()], diff --git a/packages/kbn-test/src/jest/resolver.js b/packages/kbn-test/src/jest/resolver.js index 27e0b14876587..8f985e9463962 100644 --- a/packages/kbn-test/src/jest/resolver.js +++ b/packages/kbn-test/src/jest/resolver.js @@ -70,7 +70,7 @@ module.exports = (request, options) => { return FILE_MOCK; } - if (reqExt === '.worker' && (reqBasename.endsWith('.ace') || reqBasename.endsWith('.editor'))) { + if (reqExt === '.worker' && reqBasename.endsWith('.editor')) { return WORKER_MOCK; } } diff --git a/packages/kbn-ui-shared-deps-npm/BUILD.bazel b/packages/kbn-ui-shared-deps-npm/BUILD.bazel index 48f234b0bfe10..ad3f3474f1b4e 100644 --- a/packages/kbn-ui-shared-deps-npm/BUILD.bazel +++ b/packages/kbn-ui-shared-deps-npm/BUILD.bazel @@ -53,7 +53,6 @@ RUNTIME_DEPS = [ "@npm//jquery", "@npm//lodash", "@npm//moment-timezone", - "@npm//react-ace", "@npm//react-dom", "@npm//react-router-dom", "@npm//react-router-dom-v5-compat", diff --git a/packages/kbn-ui-shared-deps-npm/webpack.config.js b/packages/kbn-ui-shared-deps-npm/webpack.config.js index 3b16430aeb724..926a041a72c3d 100644 --- a/packages/kbn-ui-shared-deps-npm/webpack.config.js +++ b/packages/kbn-ui-shared-deps-npm/webpack.config.js @@ -88,7 +88,6 @@ module.exports = (_, argv) => { 'moment-timezone/moment-timezone', 'moment-timezone/data/packed/latest.json', 'moment', - 'react-ace', 'react-dom', 'react-dom/server', 'react-router-dom', diff --git a/packages/kbn-user-profile-components/src/hooks/use_update_user_profile.tsx b/packages/kbn-user-profile-components/src/hooks/use_update_user_profile.tsx index 8c276dc533f6c..edf11d43b2c84 100644 --- a/packages/kbn-user-profile-components/src/hooks/use_update_user_profile.tsx +++ b/packages/kbn-user-profile-components/src/hooks/use_update_user_profile.tsx @@ -74,23 +74,25 @@ export const useUpdateUserProfile = ({ { title: notificationTitle, text: ( - - -

{pageReloadText}

- window.location.reload()} - data-test-subj="windowReloadButton" - > - {i18n.translate( - 'userProfileComponents.updateUserProfile.notification.requiresPageReloadButtonLabel', - { - defaultMessage: 'Reload page', - } - )} - -
-
+ <> +

{pageReloadText}

+ + + window.location.reload()} + data-test-subj="windowReloadButton" + > + {i18n.translate( + 'userProfileComponents.updateUserProfile.notification.requiresPageReloadButtonLabel', + { + defaultMessage: 'Reload page', + } + )} + + + + ), }, { diff --git a/packages/kbn-xstate-utils/kibana.jsonc b/packages/kbn-xstate-utils/kibana.jsonc index cd1151a3f2103..1fb3507854b98 100644 --- a/packages/kbn-xstate-utils/kibana.jsonc +++ b/packages/kbn-xstate-utils/kibana.jsonc @@ -1,5 +1,5 @@ { - "type": "shared-common", + "type": "shared-browser", "id": "@kbn/xstate-utils", "owner": "@elastic/obs-ux-logs-team" } diff --git a/packages/kbn-xstate-utils/src/console_inspector.ts b/packages/kbn-xstate-utils/src/console_inspector.ts new file mode 100644 index 0000000000000..8792ab44f3c28 --- /dev/null +++ b/packages/kbn-xstate-utils/src/console_inspector.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { + ActorRefLike, + AnyActorRef, + InspectedActorEvent, + InspectedEventEvent, + InspectedSnapshotEvent, + InspectionEvent, +} from 'xstate5'; +import { isDevMode } from './dev_tools'; + +export const createConsoleInspector = () => { + if (!isDevMode()) { + return () => {}; + } + + // eslint-disable-next-line no-console + const log = console.info.bind(console); + + const logActorEvent = (actorEvent: InspectedActorEvent) => { + if (isActorRef(actorEvent.actorRef)) { + log( + '✨ %c%s%c is a new actor of type %c%s%c:', + ...styleAsActor(actorEvent.actorRef.id), + ...styleAsKeyword(actorEvent.type), + actorEvent.actorRef + ); + } else { + log('✨ New %c%s%c actor without id:', ...styleAsKeyword(actorEvent.type), actorEvent); + } + }; + + const logEventEvent = (eventEvent: InspectedEventEvent) => { + if (isActorRef(eventEvent.actorRef)) { + log( + '🔔 %c%s%c received event %c%s%c from %c%s%c:', + ...styleAsActor(eventEvent.actorRef.id), + ...styleAsKeyword(eventEvent.event.type), + ...styleAsKeyword(eventEvent.sourceRef?.id), + eventEvent + ); + } else { + log('🔔 Event', ...styleAsKeyword(eventEvent.event.type), ':', eventEvent); + } + }; + + const logSnapshotEvent = (snapshotEvent: InspectedSnapshotEvent) => { + if (isActorRef(snapshotEvent.actorRef)) { + log( + '📸 %c%s%c updated due to %c%s%c:', + ...styleAsActor(snapshotEvent.actorRef.id), + ...styleAsKeyword(snapshotEvent.event.type), + snapshotEvent.snapshot + ); + } else { + log('📸 Snapshot due to %c%s%c:', ...styleAsKeyword(snapshotEvent.event.type), snapshotEvent); + } + }; + + return (inspectionEvent: InspectionEvent) => { + if (inspectionEvent.type === '@xstate.actor') { + logActorEvent(inspectionEvent); + } else if (inspectionEvent.type === '@xstate.event') { + logEventEvent(inspectionEvent); + } else if (inspectionEvent.type === '@xstate.snapshot') { + logSnapshotEvent(inspectionEvent); + } else { + log(`❓ Received inspection event:`, inspectionEvent); + } + }; +}; + +const isActorRef = (actorRefLike: ActorRefLike): actorRefLike is AnyActorRef => + 'id' in actorRefLike; + +const keywordStyle = 'font-weight: bold'; +const styleAsKeyword = (value: any) => [keywordStyle, value, ''] as const; + +const actorStyle = 'font-weight: bold; text-decoration: underline'; +const styleAsActor = (value: any) => [actorStyle, value, ''] as const; diff --git a/packages/kbn-xstate-utils/src/index.ts b/packages/kbn-xstate-utils/src/index.ts index 107585ba2096f..3edf83e8a32c2 100644 --- a/packages/kbn-xstate-utils/src/index.ts +++ b/packages/kbn-xstate-utils/src/index.ts @@ -9,5 +9,6 @@ export * from './actions'; export * from './dev_tools'; +export * from './console_inspector'; export * from './notification_channel'; export * from './types'; diff --git a/packages/serverless/settings/security_project/index.ts b/packages/serverless/settings/security_project/index.ts index 3932f924ea94d..dbbf6e506eda8 100644 --- a/packages/serverless/settings/security_project/index.ts +++ b/packages/serverless/settings/security_project/index.ts @@ -19,11 +19,9 @@ export const SECURITY_PROJECT_SETTINGS = [ settings.SECURITY_SOLUTION_DEFAULT_ANOMALY_SCORE_ID, settings.SECURITY_SOLUTION_RULES_TABLE_REFRESH_ID, settings.SECURITY_SOLUTION_IP_REPUTATION_LINKS_ID, - settings.SECURITY_SOLUTION_ENABLE_CCS_WARNING_ID, settings.SECURITY_SOLUTION_SHOW_RELATED_INTEGRATIONS_ID, settings.SECURITY_SOLUTION_NEWS_FEED_URL_ID, settings.SECURITY_SOLUTION_ENABLE_NEWS_FEED_ID, settings.SECURITY_SOLUTION_DEFAULT_ALERT_TAGS_KEY, settings.SECURITY_SOLUTION_ENABLE_ASSET_CRITICALITY_SETTING, - settings.SECURITY_SOLUTION_EXCLUDE_COLD_AND_FROZEN_TIERS_IN_ANALYZER, ]; diff --git a/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.component.tsx b/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.component.tsx index 41c525c5ca0b0..16d1bebd46548 100644 --- a/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.component.tsx +++ b/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.component.tsx @@ -22,12 +22,14 @@ import { getHasApiKeys$ } from '../lib/get_has_api_keys'; export interface Props { /** Handler for successfully creating a new data view. */ onDataViewCreated: (dataView: unknown) => void; - /** Handler for when try ES|QL is clicked and user has been navigated to try ES|QL in discover. */ - onESQLNavigationComplete?: () => void; /** if set to true allows creation of an ad-hoc dataview from data view editor */ allowAdHocDataView?: boolean; /** if the kibana instance is customly branded */ showPlainSpinner: boolean; + /** If the cluster has data, this handler allows the user to try ES|QL */ + onTryESQL?: () => void; + /** Handler for when try ES|QL is clicked and user has been navigated to try ES|QL in discover. */ + onESQLNavigationComplete?: () => void; } type AnalyticsNoDataPageProps = Props & @@ -119,9 +121,10 @@ const flavors: { */ export const AnalyticsNoDataPage: React.FC = ({ onDataViewCreated, - onESQLNavigationComplete, allowAdHocDataView, showPlainSpinner, + onTryESQL, + onESQLNavigationComplete, ...services }) => { const { prependBasePath, kibanaGuideDocLink, getHttp: get, pageFlavor } = services; @@ -138,8 +141,9 @@ export const AnalyticsNoDataPage: React.FC = ({ {...{ noDataConfig, onDataViewCreated, - onESQLNavigationComplete, allowAdHocDataView, + onTryESQL, + onESQLNavigationComplete, showPlainSpinner, }} /> diff --git a/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.stories.tsx b/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.stories.tsx index 3c75cefb38cb2..fa251cb03bdbe 100644 --- a/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.stories.tsx +++ b/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.stories.tsx @@ -29,8 +29,8 @@ export default { export const Analytics = (params: AnalyticsNoDataPageStorybookParams) => { return ( - - + + ); }; diff --git a/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.test.tsx b/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.test.tsx index 543c1c4817c5b..6b2d3441ed0d1 100644 --- a/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.test.tsx +++ b/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.test.tsx @@ -14,6 +14,7 @@ import { getAnalyticsNoDataPageServicesMock, getAnalyticsNoDataPageServicesMockWithCustomBranding, } from '@kbn/shared-ux-page-analytics-no-data-mocks'; +import { NoDataViewsPrompt } from '@kbn/shared-ux-prompt-no-data-views'; import { AnalyticsNoDataPageProvider } from './services'; import { AnalyticsNoDataPage as Component } from './analytics_no_data_page.component'; @@ -29,28 +30,86 @@ describe('AnalyticsNoDataPage', () => { jest.resetAllMocks(); }); - it('renders correctly', async () => { - const component = mountWithIntl( - - - - ); + describe('loading state', () => { + it('renders correctly', async () => { + const component = mountWithIntl( + + + + ); - await act(() => new Promise(setImmediate)); + await act(() => new Promise(setImmediate)); - expect(component.find(Component).length).toBe(1); - expect(component.find(Component).props().onDataViewCreated).toBe(onDataViewCreated); - expect(component.find(Component).props().allowAdHocDataView).toBe(true); + expect(component.find(Component).length).toBe(1); + expect(component.find(Component).props().onDataViewCreated).toBe(onDataViewCreated); + expect(component.find(Component).props().allowAdHocDataView).toBe(true); + }); + + it('passes correct boolean value to showPlainSpinner', async () => { + const component = mountWithIntl( + + + + ); + + await act(async () => { + component.update(); + }); + + expect(component.find(Component).length).toBe(1); + expect(component.find(Component).props().showPlainSpinner).toBe(true); + }); }); - it('passes correct boolean value to showPlainSpinner', () => { - const component = mountWithIntl( - - - - ); + describe('with ES data', () => { + jest.spyOn(services, 'hasESData').mockResolvedValue(true); + jest.spyOn(services, 'hasUserDataView').mockResolvedValue(false); + + it('renders the prompt to create a data view', async () => { + const onTryESQL = jest.fn(); + + await act(async () => { + const component = mountWithIntl( + + + + ); + + await new Promise(setImmediate); + component.update(); + + expect(component.find(Component).length).toBe(1); + expect(component.find(NoDataViewsPrompt).length).toBe(1); + }); + }); + + it('renders the prompt to create a data view with a custom onTryESQL action', async () => { + const onTryESQL = jest.fn(); + + await act(async () => { + const component = mountWithIntl( + + + + ); + + await new Promise(setImmediate); + component.update(); + + const tryESQLLink = component.find('button[data-test-subj="tryESQLLink"]'); + expect(tryESQLLink.length).toBe(1); + tryESQLLink.simulate('click'); - expect(component.find(Component).length).toBe(1); - expect(component.find(Component).props().showPlainSpinner).toBe(true); + expect(onTryESQL).toHaveBeenCalled(); + }); + }); }); }); diff --git a/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.tsx b/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.tsx index b64a296bbf74a..f7c80705daa58 100644 --- a/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.tsx +++ b/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.tsx @@ -20,8 +20,9 @@ import { AnalyticsNoDataPage as Component } from './analytics_no_data_page.compo */ export const AnalyticsNoDataPage = ({ onDataViewCreated, - onESQLNavigationComplete, allowAdHocDataView, + onTryESQL, + onESQLNavigationComplete, }: AnalyticsNoDataPageProps) => { const { customBranding, ...services } = useServices(); const showPlainSpinner = useObservable(customBranding.hasCustomBranding$) ?? false; @@ -33,6 +34,7 @@ export const AnalyticsNoDataPage = ({ allowAdHocDataView={allowAdHocDataView} onDataViewCreated={onDataViewCreated} onESQLNavigationComplete={onESQLNavigationComplete} + onTryESQL={onTryESQL} /> ); }; diff --git a/packages/shared-ux/page/analytics_no_data/impl/tsconfig.json b/packages/shared-ux/page/analytics_no_data/impl/tsconfig.json index 659aacfd3874d..ba872e1ecd761 100644 --- a/packages/shared-ux/page/analytics_no_data/impl/tsconfig.json +++ b/packages/shared-ux/page/analytics_no_data/impl/tsconfig.json @@ -23,6 +23,7 @@ "@kbn/i18n-react", "@kbn/core-http-browser", "@kbn/core-http-browser-mocks", + "@kbn/shared-ux-prompt-no-data-views", ], "exclude": [ "target/**/*", diff --git a/packages/shared-ux/page/analytics_no_data/mocks/src/storybook.ts b/packages/shared-ux/page/analytics_no_data/mocks/src/storybook.ts index c664bb192518c..f8cca693a072c 100644 --- a/packages/shared-ux/page/analytics_no_data/mocks/src/storybook.ts +++ b/packages/shared-ux/page/analytics_no_data/mocks/src/storybook.ts @@ -18,9 +18,14 @@ import type { } from '@kbn/shared-ux-page-analytics-no-data-types'; import { of } from 'rxjs'; +interface PropArguments { + useCustomOnTryESQL: boolean; +} + type ServiceArguments = Pick; -export type Params = ArgumentParams<{}, ServiceArguments> & KibanaNoDataPageStorybookParams; +export type Params = ArgumentParams & + KibanaNoDataPageStorybookParams; const kibanaNoDataMock = new KibanaNoDataPageStorybookMock(); @@ -30,7 +35,13 @@ export class StorybookMock extends AbstractStorybookMock< {}, ServiceArguments > { - propArguments = {}; + propArguments = { + // requires hasESData to be toggled to true + useCustomOnTryESQL: { + control: 'boolean', + defaultValue: false, + }, + }; serviceArguments = { kibanaGuideDocLink: { control: 'text', @@ -59,9 +70,10 @@ export class StorybookMock extends AbstractStorybookMock< }; } - getProps() { + getProps(params: Params) { return { onDataViewCreated: action('onDataViewCreated'), + onTryESQL: params.useCustomOnTryESQL ? action('onTryESQL-from-props') : undefined, }; } } diff --git a/packages/shared-ux/page/analytics_no_data/types/index.d.ts b/packages/shared-ux/page/analytics_no_data/types/index.d.ts index 9fd6653a48b6a..94bf85500da6b 100644 --- a/packages/shared-ux/page/analytics_no_data/types/index.d.ts +++ b/packages/shared-ux/page/analytics_no_data/types/index.d.ts @@ -70,6 +70,8 @@ export interface AnalyticsNoDataPageProps { onDataViewCreated: (dataView: unknown) => void; /** if set to true allows creation of an ad-hoc data view from data view editor */ allowAdHocDataView?: boolean; + /** If the cluster has data, this handler allows the user to try ES|QL */ + onTryESQL?: () => void; /** Handler for when try ES|QL is clicked and user has been navigated to try ES|QL in discover. */ onESQLNavigationComplete?: () => void; } diff --git a/packages/shared-ux/page/kibana_no_data/impl/src/kibana_no_data_page.tsx b/packages/shared-ux/page/kibana_no_data/impl/src/kibana_no_data_page.tsx index 2042d7fa1420d..d74c3aabd5662 100644 --- a/packages/shared-ux/page/kibana_no_data/impl/src/kibana_no_data_page.tsx +++ b/packages/shared-ux/page/kibana_no_data/impl/src/kibana_no_data_page.tsx @@ -20,9 +20,10 @@ import { useServices } from './services'; */ export const KibanaNoDataPage = ({ onDataViewCreated, - onESQLNavigationComplete, noDataConfig, allowAdHocDataView, + onTryESQL, + onESQLNavigationComplete, showPlainSpinner, }: KibanaNoDataPageProps) => { // These hooks are temporary, until this component is moved to a package. @@ -58,8 +59,9 @@ export const KibanaNoDataPage = ({ return ( ); } diff --git a/packages/shared-ux/page/kibana_no_data/types/index.d.ts b/packages/shared-ux/page/kibana_no_data/types/index.d.ts index 56067e9d555f9..c391149f7efaa 100644 --- a/packages/shared-ux/page/kibana_no_data/types/index.d.ts +++ b/packages/shared-ux/page/kibana_no_data/types/index.d.ts @@ -60,6 +60,8 @@ export interface KibanaNoDataPageProps { allowAdHocDataView?: boolean; /** Set to true if the kibana is customly branded */ showPlainSpinner: boolean; + /** If the cluster has data, this handler allows the user to try ES|QL */ + onTryESQL?: () => void; /** Handler for when try ES|QL is clicked and user has been navigated to try ES|QL in discover. */ onESQLNavigationComplete?: () => void; } diff --git a/packages/shared-ux/prompt/no_data_views/impl/src/actions.tsx b/packages/shared-ux/prompt/no_data_views/impl/src/actions.tsx deleted file mode 100644 index 6f2af97df6e04..0000000000000 --- a/packages/shared-ux/prompt/no_data_views/impl/src/actions.tsx +++ /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", 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 { EuiButton, EuiLink, EuiSpacer, EuiText } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import React from 'react'; - -interface NoDataButtonProps { - onClickCreate: (() => void) | undefined; - canCreateNewDataView: boolean; - onTryESQL?: () => void; - esqlDocLink?: string; -} - -const createDataViewText = i18n.translate('sharedUXPackages.noDataViewsPrompt.addDataViewText', { - defaultMessage: 'Create data view', -}); - -export const NoDataButtonLink = ({ - onClickCreate, - canCreateNewDataView, - onTryESQL, - esqlDocLink, -}: NoDataButtonProps) => { - if (!onTryESQL && !canCreateNewDataView) { - return null; - } - - return ( - <> - {canCreateNewDataView && ( - - {createDataViewText} - - )} - {canCreateNewDataView && onTryESQL && } - {onTryESQL && ( - - - - - ), - }} - /> - - - - - - )} - - ); -}; diff --git a/packages/shared-ux/prompt/no_data_views/impl/src/data_view_illustration.tsx b/packages/shared-ux/prompt/no_data_views/impl/src/data_view_illustration.tsx index cb817225254a9..099cdc87a21eb 100644 --- a/packages/shared-ux/prompt/no_data_views/impl/src/data_view_illustration.tsx +++ b/packages/shared-ux/prompt/no_data_views/impl/src/data_view_illustration.tsx @@ -26,5 +26,14 @@ export const DataViewIllustration = () => { } `; - return Data view illustration; + return ( + Data view illustration + ); }; diff --git a/packages/shared-ux/prompt/no_data_views/impl/src/documentation_link.tsx b/packages/shared-ux/prompt/no_data_views/impl/src/documentation_link.tsx index 8e74bead6922e..d190764af947d 100644 --- a/packages/shared-ux/prompt/no_data_views/impl/src/documentation_link.tsx +++ b/packages/shared-ux/prompt/no_data_views/impl/src/documentation_link.tsx @@ -13,9 +13,10 @@ import { FormattedMessage } from '@kbn/i18n-react'; interface Props { href: string; + ['data-test-subj']?: string; } -export function DocumentationLink({ href }: Props) { +export function DocumentationLink({ href, ['data-test-subj']: dataTestSubj }: Props) { return (
@@ -28,7 +29,7 @@ export function DocumentationLink({ href }: Props) {
- + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/kbn-ace/src/ace/modes/x_json/worker/index.ts b/packages/shared-ux/prompt/no_data_views/impl/src/esql_illustration.tsx similarity index 64% rename from packages/kbn-ace/src/ace/modes/x_json/worker/index.ts rename to packages/shared-ux/prompt/no_data_views/impl/src/esql_illustration.tsx index b09099ed9ad01..a2da4c416ed55 100644 --- a/packages/kbn-ace/src/ace/modes/x_json/worker/index.ts +++ b/packages/shared-ux/prompt/no_data_views/impl/src/esql_illustration.tsx @@ -7,10 +7,18 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -// @ts-ignore -import src from '!!raw-loader!./x_json.ace.worker'; +import React from 'react'; -export const workerModule = { - id: 'ace/mode/json_worker', - src, +import png from './esql_illustration.svg'; + +export const EsqlIllustration = () => { + return ( + ES|QL illustration + ); }; diff --git a/packages/shared-ux/prompt/no_data_views/impl/src/no_data_views.component.test.tsx b/packages/shared-ux/prompt/no_data_views/impl/src/no_data_views.component.test.tsx index ad2e176a511f0..75363c80b67b5 100644 --- a/packages/shared-ux/prompt/no_data_views/impl/src/no_data_views.component.test.tsx +++ b/packages/shared-ux/prompt/no_data_views/impl/src/no_data_views.component.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test-jest-helpers'; -import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; +import { EuiButton, EuiCard } from '@elastic/eui'; import { NoDataViewsPrompt } from './no_data_views.component'; import { DocumentationLink } from './documentation_link'; @@ -19,36 +19,64 @@ describe('', () => { ); - expect(component.find(EuiEmptyPrompt).length).toBe(1); - expect(component.find(EuiButton).length).toBe(1); - expect(component.find(DocumentationLink).length).toBe(1); + expect(component.find(EuiCard).length).toBe(2); + expect(component.find(EuiButton).length).toBe(2); + expect(component.find(DocumentationLink).length).toBe(2); + + expect(component.find('EuiButton[data-test-subj="createDataViewButton"]').length).toBe(1); + expect(component.find('DocumentationLink[data-test-subj="docLinkDataViews"]').length).toBe(1); + + expect(component.find('EuiButton[data-test-subj="tryESQLLink"]').length).toBe(1); + expect(component.find('DocumentationLink[data-test-subj="docLinkEsql"]').length).toBe(1); }); - test('does not render button if canCreateNewDataViews is false', () => { + test('does not render "Create data view" button if canCreateNewDataViews is false', () => { const component = mountWithIntl(); - expect(component.find(EuiButton).length).toBe(0); + expect(component.find('EuiButton[data-test-subj="createDataViewButton"]').length).toBe(0); }); - test('does not documentation link if linkToDocumentation is not provided', () => { + test('does not render documentation links if links to documentation are not provided', () => { const component = mountWithIntl( ); - expect(component.find(DocumentationLink).length).toBe(0); + expect(component.find('DocumentationLink[data-test-subj="docLinkDataViews"]').length).toBe(0); + expect(component.find('DocumentationLink[data-test-subj="docLinkEsql"]').length).toBe(0); }); test('onClickCreate', () => { const onClickCreate = jest.fn(); const component = mountWithIntl( - + ); - component.find('button').simulate('click'); + component.find('button[data-test-subj="createDataViewButton"]').simulate('click'); expect(onClickCreate).toHaveBeenCalledTimes(1); }); + + test('onClickTryEsql', () => { + const onClickTryEsql = jest.fn(); + const component = mountWithIntl( + + ); + + component.find('button[data-test-subj="tryESQLLink"]').simulate('click'); + + expect(onClickTryEsql).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/shared-ux/prompt/no_data_views/impl/src/no_data_views.component.tsx b/packages/shared-ux/prompt/no_data_views/impl/src/no_data_views.component.tsx index d5807891e734d..3bfed37aa0b1a 100644 --- a/packages/shared-ux/prompt/no_data_views/impl/src/no_data_views.component.tsx +++ b/packages/shared-ux/prompt/no_data_views/impl/src/no_data_views.component.tsx @@ -7,95 +7,222 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React from 'react'; import { css } from '@emotion/react'; +import React from 'react'; -import { EuiEmptyPrompt, EuiPanel } from '@elastic/eui'; +import { + EuiButton, + EuiCard, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiSpacer, + EuiText, + EuiTextAlign, + EuiToolTip, + useEuiPaddingCSS, +} from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { withSuspense } from '@kbn/shared-ux-utility'; import { NoDataViewsPromptComponentProps } from '@kbn/shared-ux-prompt-no-data-views-types'; import { DocumentationLink } from './documentation_link'; -import { NoDataButtonLink } from './actions'; +import { DataViewIllustration } from './data_view_illustration'; +import { EsqlIllustration } from './esql_illustration'; -// Using raw value because it is content dependent -const MAX_WIDTH = 830; +// max width value to use in pixels +const MAX_WIDTH = 770; -/** - * A presentational component that is shown in cases when there are no data views created yet. - */ -export const NoDataViewsPrompt = ({ +const PromptAddDataViews = ({ onClickCreate, canCreateNewDataView, dataViewsDocLink, + emptyPromptColor, +}: Pick< + NoDataViewsPromptComponentProps, + 'onClickCreate' | 'canCreateNewDataView' | 'dataViewsDocLink' | 'emptyPromptColor' +>) => { + const icon = ; + + const title = ( + + ); + + const description = ( + <> + {canCreateNewDataView ? ( + + ) : ( + + )} + + ); + + const footer = dataViewsDocLink ? ( + <> + {canCreateNewDataView ? ( + + + + ) : ( + + } + > + + + + + )} + + + + ) : undefined; + + return ( + + ); +}; + +const PromptTryEsql = ({ onTryESQL, esqlDocLink, - emptyPromptColor = 'plain', -}: NoDataViewsPromptComponentProps) => { - const title = canCreateNewDataView ? ( -

- -
- -

- ) : ( -

- -

- ); + emptyPromptColor, +}: Pick< + NoDataViewsPromptComponentProps, + 'onClickCreate' | 'onTryESQL' | 'esqlDocLink' | 'emptyPromptColor' +>) => { + if (!onTryESQL) { + // we need to handle the case where the Try ES|QL click handler is not set because + // onTryESQL is set via a useEffect that has asynchronous dependencies + return null; + } + + const icon = ; - const body = canCreateNewDataView ? ( -

- -

- ) : ( -

- -

+ const title = ( + ); - const footer = dataViewsDocLink ? : undefined; + const description = ( + + ); - // Load this illustration lazily - const Illustration = withSuspense( - React.lazy(() => - import('./data_view_illustration').then(({ DataViewIllustration }) => { - return { default: DataViewIllustration }; - }) - ), - + const footer = ( + <> + + + + + {esqlDocLink && } + ); - const icon = ; - const actions = ( - + return ( + ); +}; + +/** + * A presentational component that is shown in cases when there are no data views created yet. + */ +export const NoDataViewsPrompt = ({ + onClickCreate, + canCreateNewDataView, + dataViewsDocLink, + onTryESQL, + esqlDocLink, + emptyPromptColor = 'plain', +}: NoDataViewsPromptComponentProps) => { + const cssStyles = [ + css` + max-width: ${MAX_WIDTH}px; + `, + useEuiPaddingCSS('top').m, + useEuiPaddingCSS('right').m, + useEuiPaddingCSS('left').m, + ]; return ( - + > + + + +

+ +

+
+
+ + + + + + + + + + + +
+ ); }; diff --git a/packages/shared-ux/prompt/no_data_views/impl/src/no_data_views.tsx b/packages/shared-ux/prompt/no_data_views/impl/src/no_data_views.tsx index 43ae5f267ea90..340147505cb25 100644 --- a/packages/shared-ux/prompt/no_data_views/impl/src/no_data_views.tsx +++ b/packages/shared-ux/prompt/no_data_views/impl/src/no_data_views.tsx @@ -27,12 +27,15 @@ type CloseDataViewEditorFn = ReturnType { - const { canCreateNewDataView, openDataViewEditor, dataViewsDocLink, onTryESQL, esqlDocLink } = + const { canCreateNewDataView, openDataViewEditor, dataViewsDocLink, esqlDocLink, ...services } = useServices(); + const onTryESQL = onTryESQLProp ?? services.onTryESQL; + const closeDataViewEditor = useRef(); useEffect(() => { diff --git a/packages/shared-ux/prompt/no_data_views/impl/tsconfig.json b/packages/shared-ux/prompt/no_data_views/impl/tsconfig.json index 673823e620474..2af357080c07c 100644 --- a/packages/shared-ux/prompt/no_data_views/impl/tsconfig.json +++ b/packages/shared-ux/prompt/no_data_views/impl/tsconfig.json @@ -16,8 +16,6 @@ ], "kbn_references": [ "@kbn/i18n-react", - "@kbn/i18n", - "@kbn/shared-ux-utility", "@kbn/test-jest-helpers", "@kbn/shared-ux-prompt-no-data-views-types", "@kbn/shared-ux-prompt-no-data-views-mocks", diff --git a/packages/shared-ux/prompt/no_data_views/mocks/src/storybook.ts b/packages/shared-ux/prompt/no_data_views/mocks/src/storybook.ts index 63f46d2008077..973152201587d 100644 --- a/packages/shared-ux/prompt/no_data_views/mocks/src/storybook.ts +++ b/packages/shared-ux/prompt/no_data_views/mocks/src/storybook.ts @@ -34,17 +34,19 @@ export class StorybookMock extends AbstractStorybookMock< defaultValue: true, }, dataViewsDocLink: { - options: ['some/link', undefined], - control: { type: 'radio' }, - }, - esqlDocLink: { - options: ['some/link', undefined], + options: ['dataviews/link', undefined], control: { type: 'radio' }, + defaultValue: 'dataviews/link', }, canTryEsql: { control: 'boolean', defaultValue: true, }, + esqlDocLink: { + options: ['esql/link', undefined], + control: { type: 'radio' }, + defaultValue: 'esql/link', + }, }; dependencies = []; @@ -59,7 +61,7 @@ export class StorybookMock extends AbstractStorybookMock< let onTryESQL; if (canTryEsql !== false) { - onTryESQL = action('onTryESQL'); + onTryESQL = action('onTryESQL-from-services'); } return { diff --git a/packages/shared-ux/prompt/no_data_views/types/index.d.ts b/packages/shared-ux/prompt/no_data_views/types/index.d.ts index 15f9f53c59fe6..7bca285bee717 100644 --- a/packages/shared-ux/prompt/no_data_views/types/index.d.ts +++ b/packages/shared-ux/prompt/no_data_views/types/index.d.ts @@ -42,7 +42,7 @@ export interface NoDataViewsPromptServices { openDataViewEditor: (options: DataViewEditorOptions) => () => void; /** A link to information about Data Views in Kibana */ dataViewsDocLink: string; - /** Get a handler for trying ES|QL */ + /** If the cluster has data, this handler allows the user to try ES|QL */ onTryESQL: (() => void) | undefined; /** A link to the documentation for ES|QL */ esqlDocLink: string; @@ -92,7 +92,7 @@ export interface NoDataViewsPromptComponentProps { emptyPromptColor?: EuiEmptyPromptProps['color']; /** Click handler for create button. **/ onClickCreate?: () => void; - /** Handler for someone wanting to try ES|QL. */ + /** If the cluster has data, this handler allows the user to try ES|QL */ onTryESQL?: () => void; /** Link to documentation on ES|QL. */ esqlDocLink?: string; @@ -104,6 +104,10 @@ export interface NoDataViewsPromptProps { allowAdHocDataView?: boolean; /** Handler for successfully creating a new data view. */ onDataViewCreated: (dataView: unknown) => void; + /** If the cluster has data, this handler allows the user to try ES|QL */ + onTryESQL?: () => void; /** Handler for when try ES|QL is clicked and user has been navigated to try ES|QL in discover. */ onESQLNavigationComplete?: () => void; + /** Empty prompt color **/ + emptyPromptColor?: PanelColor; } diff --git a/renovate.json b/renovate.json index dccc37ef702a4..ff7ee4b0aaafa 100644 --- a/renovate.json +++ b/renovate.json @@ -58,7 +58,7 @@ "matchDepNames": ["@elastic/charts"], "reviewers": ["team:visualizations", "markov00", "nickofthyme"], "matchBaseBranches": ["main"], - "labels": ["release_note:skip", "backport:skip", "Team:Visualizations"], + "labels": ["release_note:skip", "backport:prev-minor", "Team:Visualizations"], "enabled": true }, { diff --git a/src/core/public/styles/_ace_overrides.scss b/src/core/public/styles/_ace_overrides.scss deleted file mode 100644 index ca5230b46acd3..0000000000000 --- a/src/core/public/styles/_ace_overrides.scss +++ /dev/null @@ -1,202 +0,0 @@ -// SASSTODO: Replace with an EUI editor -// Intentionally not using the EuiCodeBlock colors here because they actually change -// hue from light to dark theme. So some colors would change while others wouldn't. -// Seemed weird, so just hexing all the colors but using the `makeHighContrastColor()` -// function to ensure accessible contrast. - -// In order to override the TM (Textmate) theme of Ace/Brace, everywhere, -// it is being scoped by a known outer selector -.kbnBody { - .ace-tm { - $aceBackground: tintOrShade($euiColorLightShade, 50%, 0); - - background-color: $euiColorLightestShade; - color: $euiTextColor; - - .ace_scrollbar { - @include euiScrollBar; - } - - .ace_gutter-active-line, - .ace_marker-layer .ace_active-line { - background-color: transparentize($euiColorLightShade, .3); - } - - .ace_snippet-marker { - width: 100%; - background-color: $aceBackground; - border: none; - } - - .ace_indent-guide { - background: linear-gradient(to left, $euiColorMediumShade 0%, $euiColorMediumShade 1px, transparent 1px, transparent 100%); - } - - .ace_search { - z-index: $euiZLevel1 + 1; - } - - .ace_layer.ace_marker-layer { - overflow: visible; - } - - .ace_warning { - color: $euiColorDanger; - } - - .ace_method { - color: makeHighContrastColor(#DD0A73, $aceBackground); - } - - .ace_url, - .ace_start_triple_quote, - .ace_end_triple_quote { - color: makeHighContrastColor(#00A69B, $aceBackground); - } - - .ace_multi_string { - color: makeHighContrastColor(#009926, $aceBackground); - font-style: italic; - } - - .ace_gutter { - background-color: $euiColorEmptyShade; - color: $euiColorDarkShade; - border-left: 1px solid $aceBackground; - } - - .ace_print-margin { - width: 1px; - background: $euiColorLightShade; - } - - .ace_fold { - background-color: #6B72E6; - } - - .ace_cursor { - color: $euiColorFullShade; - } - - .ace_invisible { - color: $euiColorLightShade; - } - - .ace_storage, - .ace_keyword { - color: makeHighContrastColor(#0079A5, $aceBackground); - } - - .ace_constant { - color: makeHighContrastColor(#900, $aceBackground); - } - - .ace_constant.ace_buildin { - color: makeHighContrastColor(rgb(88, 72, 246), $aceBackground); - } - - .ace_constant.ace_language { - color: makeHighContrastColor(rgb(88, 92, 246), $aceBackground); - } - - .ace_constant.ace_library { - color: makeHighContrastColor(#009926, $aceBackground); - } - - .ace_invalid { - background-color: euiCallOutColor('danger', 'background'); - color: euiCallOutColor('danger', 'foreground'); - } - - .ace_support.ace_function { - color: makeHighContrastColor(rgb(60, 76, 114), $aceBackground); - } - - .ace_support.ace_constant { - color: makeHighContrastColor(#009926, $aceBackground); - } - - .ace_support.ace_type, - .ace_support.ace_class { - color: makeHighContrastColor(rgb(109, 121, 222), $aceBackground); - } - - .ace_keyword.ace_operator { - color: makeHighContrastColor($euiColorDarkShade, $aceBackground); - } - - .ace_string { - color: makeHighContrastColor(#009926, $aceBackground); - } - - .ace_comment { - color: makeHighContrastColor(rgb(76, 136, 107), $aceBackground); - } - - .ace_comment.ace_doc { - color: makeHighContrastColor(#0079A5, $aceBackground); - } - - .ace_comment.ace_doc.ace_tag { - color: makeHighContrastColor($euiColorMediumShade, $aceBackground); - } - - .ace_constant.ace_numeric { - color: makeHighContrastColor(#0079A5, $aceBackground); - } - - .ace_variable { - color: makeHighContrastColor(#0079A5, $aceBackground); - } - - .ace_xml-pe { - color: makeHighContrastColor($euiColorDarkShade, $aceBackground); - } - - .ace_entity.ace_name.ace_function { - color: makeHighContrastColor(#0000A2, $aceBackground); - } - - .ace_heading { - color: makeHighContrastColor(rgb(12, 7, 255), $aceBackground); - } - - .ace_list { - color: makeHighContrastColor(rgb(185, 6, 144), $aceBackground); - } - - .ace_meta.ace_tag { - color: makeHighContrastColor(rgb(0, 22, 142), $aceBackground); - } - - .ace_string.ace_regex { - color: makeHighContrastColor(rgb(255, 0, 0), $aceBackground); - } - - .ace_marker-layer .ace_selection { - background: tintOrShade($euiColorPrimary, 70%, 70%); - } - - &.ace_multiselect .ace_selection.ace_start { - box-shadow: 0 0 3px 0 $euiColorEmptyShade; - } - - .ace_marker-layer .ace_step { - background: tintOrShade($euiColorWarning, 80%, 80%); - } - - .ace_marker-layer .ace_stack { - background: tintOrShade($euiColorSuccess, 80%, 80%); - } - - .ace_marker-layer .ace_bracket { - margin: -1px 0 0 -1px; - border: $euiBorderThin; - } - - .ace_marker-layer .ace_selected-word { - background: $euiColorLightestShade; - border: $euiBorderThin; - } - } -} diff --git a/src/core/public/styles/_index.scss b/src/core/public/styles/_index.scss index 42981c7e07398..cfdb1c7192dcd 100644 --- a/src/core/public/styles/_index.scss +++ b/src/core/public/styles/_index.scss @@ -1,4 +1,3 @@ @import './base'; -@import './ace_overrides'; @import './chrome/index'; @import './rendering/index'; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index f4852bdc97fe3..1ac38b1d44157 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -267,7 +267,6 @@ export { PluginType } from '@kbn/core-base-common'; export type { PrebootPlugin, Plugin, - AsyncPlugin, PluginConfigDescriptor, PluginConfigSchema, PluginInitializer, 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 d3a0e80e93d29..fa40ee8e162c3 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 @@ -91,7 +91,7 @@ describe('checking migration metadata changes on all registered SO types', () => "endpoint:unified-user-artifact-manifest": "71c7fcb52c658b21ea2800a6b6a76972ae1c776e", "endpoint:user-artifact-manifest": "1c3533161811a58772e30cdc77bac4631da3ef2b", "enterprise_search_telemetry": "9ac912e1417fc8681e0cd383775382117c9e3d3d", - "entity-definition": "61be3e95966045122b55e181bb39658b1dc9bbe9", + "entity-definition": "e3811fd5fbb878d170067c0d6897a2e63010af36", "entity-discovery-api-key": "c267a65c69171d1804362155c1378365f5acef88", "entity-engine-status": "0738aa1a06d3361911740f8f166071ea43a00927", "epm-packages": "8042d4a1522f6c4e6f5486e791b3ffe3a22f88fd", diff --git a/src/core/server/integration_tests/http/oas.test.ts b/src/core/server/integration_tests/http/oas.test.ts index c6a1d4e308356..413b8b01754b5 100644 --- a/src/core/server/integration_tests/http/oas.test.ts +++ b/src/core/server/integration_tests/http/oas.test.ts @@ -193,7 +193,9 @@ it('only accepts "public" or "internal" for "access" query param', async () => { const server = await startService({ config: { server: { oas: { enabled: true } } } }); const result = await supertest(server.listener).get('/api/oas').query({ access: 'invalid' }); expect(result.body.message).toBe( - 'Invalid access query parameter. Must be one of "public" or "internal".' + `[access]: types that failed validation: +- [access.0]: expected value to equal [public] +- [access.1]: expected value to equal [internal]` ); expect(result.status).toBe(400); }); diff --git a/src/core/server/integration_tests/http/router.test.ts b/src/core/server/integration_tests/http/router.test.ts index 0b7bbb8ce55c3..c0a690e479e67 100644 --- a/src/core/server/integration_tests/http/router.test.ts +++ b/src/core/server/integration_tests/http/router.test.ts @@ -836,6 +836,82 @@ describe('Handler', () => { expect(body).toEqual(12); }); + + it('adds versioned header v2023-10-31 to public, unversioned routes', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.post( + { + path: '/public', + validate: { body: schema.object({ ok: schema.boolean() }) }, + options: { + access: 'public', + }, + }, + (context, req, res) => { + if (req.body.ok) { + return res.ok({ body: 'ok', headers: { test: 'this' } }); + } + return res.customError({ statusCode: 499, body: 'custom error' }); + } + ); + router.post( + { + path: '/internal', + validate: { body: schema.object({ ok: schema.boolean() }) }, + }, + (context, req, res) => { + return res.ok({ body: 'ok', headers: { test: 'this' } }); + } + ); + await server.start(); + + // Includes header if validation fails + { + const { headers } = await supertest(innerServer.listener) + .post('/public') + .send({ ok: null }) + .expect(400); + expect(headers).toMatchObject({ 'elastic-api-version': '2023-10-31' }); + } + + // Includes header if custom error + { + const { headers } = await supertest(innerServer.listener) + .post('/public') + .send({ ok: false }) + .expect(499); + expect(headers).toMatchObject({ 'elastic-api-version': '2023-10-31' }); + } + + // Includes header if OK + { + const { headers } = await supertest(innerServer.listener) + .post('/public') + .send({ ok: true }) + .expect(200); + expect(headers).toMatchObject({ 'elastic-api-version': '2023-10-31' }); + } + + // Internal unversioned routes do not include the header for OK + { + const { headers } = await supertest(innerServer.listener) + .post('/internal') + .send({ ok: true }) + .expect(200); + expect(headers).not.toMatchObject({ 'elastic-api-version': '2023-10-31' }); + } + + // Internal unversioned routes do not include the header for validation failures + { + const { headers } = await supertest(innerServer.listener) + .post('/internal') + .send({ ok: null }) + .expect(400); + expect(headers).not.toMatchObject({ 'elastic-api-version': '2023-10-31' }); + } + }); }); describe('handleLegacyErrors', () => { diff --git a/src/core/server/integration_tests/http/versioned_router.test.ts b/src/core/server/integration_tests/http/versioned_router.test.ts index 9f2b2625a6a7e..254337f82abcf 100644 --- a/src/core/server/integration_tests/http/versioned_router.test.ts +++ b/src/core/server/integration_tests/http/versioned_router.test.ts @@ -112,14 +112,12 @@ describe('Routing versioned requests', () => { await server.start(); - await expect(supertest.get('/my-path').expect(200)).resolves.toEqual( - expect.objectContaining({ - body: { v: '1' }, - header: expect.objectContaining({ - 'elastic-api-version': '2020-02-02', - }), - }) - ); + await expect(supertest.get('/my-path').expect(200)).resolves.toMatchObject({ + body: { v: '1' }, + header: expect.objectContaining({ + 'elastic-api-version': '2020-02-02', + }), + }); }); it('returns the expected output for badly formatted versions', async () => { @@ -137,11 +135,9 @@ describe('Routing versioned requests', () => { .set('Elastic-Api-Version', 'abc') .expect(400) .then(({ body }) => body) - ).resolves.toEqual( - expect.objectContaining({ - message: expect.stringMatching(/Invalid version/), - }) - ); + ).resolves.toMatchObject({ + message: expect.stringMatching(/Invalid version/), + }); }); it('returns the expected responses for failed validation', async () => { @@ -163,18 +159,14 @@ describe('Routing versioned requests', () => { await server.start(); await expect( - supertest - .post('/my-path') - .send({}) - .set('Elastic-Api-Version', '1') - .expect(400) - .then(({ body }) => body) - ).resolves.toEqual( - expect.objectContaining({ + supertest.post('/my-path').send({}).set('Elastic-Api-Version', '1').expect(400) + ).resolves.toMatchObject({ + body: { error: 'Bad Request', message: expect.stringMatching(/expected value of type/), - }) - ); + }, + headers: { 'elastic-api-version': '1' }, // includes version if validation failed + }); expect(captureErrorMock).not.toHaveBeenCalled(); }); @@ -193,7 +185,7 @@ describe('Routing versioned requests', () => { .set('Elastic-Api-Version', '2023-10-31') .expect(200) .then(({ header }) => header) - ).resolves.toEqual(expect.objectContaining({ 'elastic-api-version': '2023-10-31' })); + ).resolves.toMatchObject({ 'elastic-api-version': '2023-10-31' }); }); it('runs response validation when in dev', async () => { @@ -236,11 +228,9 @@ describe('Routing versioned requests', () => { .set('Elastic-Api-Version', '1') .expect(500) .then(({ body }) => body) - ).resolves.toEqual( - expect.objectContaining({ - message: expect.stringMatching(/Failed output validation/), - }) - ); + ).resolves.toMatchObject({ + message: expect.stringMatching(/Failed output validation/), + }); await expect( supertest @@ -248,11 +238,9 @@ describe('Routing versioned requests', () => { .set('Elastic-Api-Version', '2') .expect(500) .then(({ body }) => body) - ).resolves.toEqual( - expect.objectContaining({ - message: expect.stringMatching(/Failed output validation/), - }) - ); + ).resolves.toMatchObject({ + message: expect.stringMatching(/Failed output validation/), + }); // This should pass response validation await expect( @@ -261,11 +249,9 @@ describe('Routing versioned requests', () => { .set('Elastic-Api-Version', '3') .expect(200) .then(({ body }) => body) - ).resolves.toEqual( - expect.objectContaining({ - v: '3', - }) - ); + ).resolves.toMatchObject({ + v: '3', + }); expect(captureErrorMock).not.toHaveBeenCalled(); }); @@ -367,9 +353,7 @@ describe('Routing versioned requests', () => { .set('Elastic-Api-Version', '2020-02-02') .expect(500) .then(({ body }) => body) - ).resolves.toEqual( - expect.objectContaining({ message: expect.stringMatching(/No handlers registered/) }) - ); + ).resolves.toMatchObject({ message: expect.stringMatching(/No handlers registered/) }); expect(captureErrorMock).not.toHaveBeenCalled(); }); diff --git a/src/dev/precommit_hook/casing_check_config.js b/src/dev/precommit_hook/casing_check_config.js index 2eaeb64f8be5f..3572781c4b262 100644 --- a/src/dev/precommit_hook/casing_check_config.js +++ b/src/dev/precommit_hook/casing_check_config.js @@ -87,6 +87,9 @@ export const IGNORE_FILE_GLOBS = [ // Support for including http-client.env.json configurations '**/http-client.env.json', + + // updatecli configuration for driving the UBI/Ironbank image updates + 'updatecli-compose.yaml', ]; /** diff --git a/src/plugins/bfetch/server/ui_settings.ts b/src/plugins/bfetch/server/ui_settings.ts index aee4903d226c0..132dd19ef8b9c 100644 --- a/src/plugins/bfetch/server/ui_settings.ts +++ b/src/plugins/bfetch/server/ui_settings.ts @@ -18,7 +18,7 @@ export function getUiSettings(): Record> { name: i18n.translate('bfetch.disableBfetch', { defaultMessage: 'Disable request batching', }), - value: false, + value: true, description: i18n.translate('bfetch.disableBfetchDesc', { defaultMessage: 'Disables requests batching. This increases number of HTTP requests from Kibana, but allows to debug requests individually.', diff --git a/src/plugins/console/README.md b/src/plugins/console/README.md index 02da27229286a..35921de334380 100644 --- a/src/plugins/console/README.md +++ b/src/plugins/console/README.md @@ -44,7 +44,7 @@ POST /_some_endpoint ``` ## Architecture -Console uses Ace editor that is wrapped with [`CoreEditor`](https://github.com/elastic/kibana/blob/main/src/plugins/console/public/types/core_editor.ts), so that if needed it can easily be replaced with another editor, for example Monaco. +Console uses Monaco editor that is wrapped with [`kbn-monaco`](https://github.com/elastic/kibana/blob/main/packages/kbn-monaco/index.ts), so that if needed it can easily be replaced with another editor. The autocomplete logic is located in [`autocomplete`](https://github.com/elastic/kibana/blob/main/src/plugins/console/public/lib/autocomplete) folder. Autocomplete rules are computed by classes in `components` sub-folder. ## Autocomplete definitions @@ -317,8 +317,4 @@ Another change is replacing jQuery with the core http client to communicate with ### Outstanding issues #### Autocomplete suggestions for Kibana API endpoints Console currently supports autocomplete suggestions for Elasticsearch API endpoints. The autocomplete suggestions for Kibana API endpoints are not supported yet. -Related issue: [#130661](https://github.com/elastic/kibana/issues/130661) - -#### Migration to Monaco Editor -Console plugin is currently using Ace Editor and it is planned to migrate to Monaco Editor in the future. -Related issue: [#57435](https://github.com/elastic/kibana/issues/57435) \ No newline at end of file +Related issue: [#130661](https://github.com/elastic/kibana/issues/130661) \ No newline at end of file diff --git a/src/plugins/console/public/application/contexts/editor_context/editor_registry.ts b/src/plugins/console/public/application/contexts/editor_context/editor_registry.ts index 8197ff0460e86..dc7b58ecbd267 100644 --- a/src/plugins/console/public/application/contexts/editor_context/editor_registry.ts +++ b/src/plugins/console/public/application/contexts/editor_context/editor_registry.ts @@ -8,12 +8,11 @@ */ import { MonacoEditorActionsProvider } from '../../containers/editor/monaco_editor_actions_provider'; -import { SenseEditor } from '../../models/sense_editor'; export class EditorRegistry { - private inputEditor: SenseEditor | MonacoEditorActionsProvider | undefined; + private inputEditor: MonacoEditorActionsProvider | undefined; - setInputEditor(inputEditor: SenseEditor | MonacoEditorActionsProvider) { + setInputEditor(inputEditor: MonacoEditorActionsProvider) { this.inputEditor = inputEditor; } diff --git a/src/plugins/console/public/application/hooks/index.ts b/src/plugins/console/public/application/hooks/index.ts index b6b7211a940e4..29c554771dad0 100644 --- a/src/plugins/console/public/application/hooks/index.ts +++ b/src/plugins/console/public/application/hooks/index.ts @@ -8,7 +8,6 @@ */ export { useSetInputEditor } from './use_set_input_editor'; -export { useRestoreRequestFromHistory } from './use_restore_request_from_history'; -export { useSendCurrentRequest, sendRequest } from './use_send_current_request'; +export { sendRequest } from './use_send_current_request'; export { useSaveCurrentTextObject } from './use_save_current_text_object'; export { useDataInit } from './use_data_init'; diff --git a/src/plugins/console/public/application/hooks/use_restore_request_from_history/index.ts b/src/plugins/console/public/application/hooks/use_restore_request_from_history/index.ts deleted file mode 100644 index 47f12868d9bc6..0000000000000 --- a/src/plugins/console/public/application/hooks/use_restore_request_from_history/index.ts +++ /dev/null @@ -1,10 +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 { useRestoreRequestFromHistory } from './use_restore_request_from_history'; diff --git a/src/plugins/console/public/application/hooks/use_restore_request_from_history/restore_request_from_history.ts b/src/plugins/console/public/application/hooks/use_restore_request_from_history/restore_request_from_history.ts deleted file mode 100644 index 897e499dc481e..0000000000000 --- a/src/plugins/console/public/application/hooks/use_restore_request_from_history/restore_request_from_history.ts +++ /dev/null @@ -1,48 +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 RowParser from '../../../lib/row_parser'; -import { ESRequest } from '../../../types'; -import { SenseEditor } from '../../models/sense_editor'; -import { formatRequestBodyDoc } from '../../../lib/utils'; - -export function restoreRequestFromHistory(editor: SenseEditor, req: ESRequest) { - const coreEditor = editor.getCoreEditor(); - let pos = coreEditor.getCurrentPosition(); - let prefix = ''; - let suffix = '\n'; - const parser = new RowParser(coreEditor); - if (parser.isStartRequestRow(pos.lineNumber)) { - pos.column = 1; - suffix += '\n'; - } else if (parser.isEndRequestRow(pos.lineNumber)) { - const line = coreEditor.getLineValue(pos.lineNumber); - pos.column = line.length + 1; - prefix = '\n\n'; - } else if (parser.isInBetweenRequestsRow(pos.lineNumber)) { - pos.column = 1; - } else { - pos = editor.nextRequestEnd(pos); - prefix = '\n\n'; - } - - let s = prefix + req.method + ' ' + req.endpoint; - if (req.data) { - const indent = true; - const formattedData = formatRequestBodyDoc([req.data], indent); - s += '\n' + formattedData.data; - } - - s += suffix; - - coreEditor.insert(pos, s); - coreEditor.moveCursorToPosition({ lineNumber: pos.lineNumber + prefix.length, column: 1 }); - coreEditor.clearSelection(); - coreEditor.getContainer().focus(); -} diff --git a/src/plugins/console/public/application/hooks/use_restore_request_from_history/restore_request_from_history_to_monaco.ts b/src/plugins/console/public/application/hooks/use_restore_request_from_history/restore_request_from_history_to_monaco.ts deleted file mode 100644 index 08c2bc6af86a3..0000000000000 --- a/src/plugins/console/public/application/hooks/use_restore_request_from_history/restore_request_from_history_to_monaco.ts +++ /dev/null @@ -1,25 +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 { formatRequestBodyDoc } from '../../../lib/utils'; -import { MonacoEditorActionsProvider } from '../../containers/editor/monaco_editor_actions_provider'; -import { ESRequest } from '../../../types'; - -export async function restoreRequestFromHistoryToMonaco( - provider: MonacoEditorActionsProvider, - req: ESRequest -) { - let s = req.method + ' ' + req.endpoint; - if (req.data) { - const indent = true; - const formattedData = formatRequestBodyDoc([req.data], indent); - s += '\n' + formattedData.data; - } - await provider.restoreRequestFromHistory(s); -} diff --git a/src/plugins/console/public/application/hooks/use_restore_request_from_history/use_restore_request_from_history.ts b/src/plugins/console/public/application/hooks/use_restore_request_from_history/use_restore_request_from_history.ts deleted file mode 100644 index 5ee0d185923c2..0000000000000 --- a/src/plugins/console/public/application/hooks/use_restore_request_from_history/use_restore_request_from_history.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", 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 { useCallback } from 'react'; -import { instance as registry } from '../../contexts/editor_context/editor_registry'; -import { ESRequest } from '../../../types'; -import { restoreRequestFromHistoryToMonaco } from './restore_request_from_history_to_monaco'; -import { MonacoEditorActionsProvider } from '../../containers/editor/monaco_editor_actions_provider'; - -export const useRestoreRequestFromHistory = () => { - return useCallback(async (req: ESRequest) => { - const editor = registry.getInputEditor(); - await restoreRequestFromHistoryToMonaco(editor as MonacoEditorActionsProvider, req); - }, []); -}; diff --git a/src/plugins/console/public/application/hooks/use_send_current_request/index.ts b/src/plugins/console/public/application/hooks/use_send_current_request/index.ts index 753184f67e998..656c0b939cf5b 100644 --- a/src/plugins/console/public/application/hooks/use_send_current_request/index.ts +++ b/src/plugins/console/public/application/hooks/use_send_current_request/index.ts @@ -7,5 +7,4 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { useSendCurrentRequest } from './use_send_current_request'; export { sendRequest } from './send_request'; diff --git a/src/plugins/console/public/application/hooks/use_send_current_request/track.ts b/src/plugins/console/public/application/hooks/use_send_current_request/track.ts deleted file mode 100644 index e663c0b8354c1..0000000000000 --- a/src/plugins/console/public/application/hooks/use_send_current_request/track.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", 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 { SenseEditor } from '../../models/sense_editor'; -import { getEndpointFromPosition } from '../../../lib/autocomplete/get_endpoint_from_position'; -import { MetricsTracker } from '../../../types'; - -export const track = ( - requests: Array<{ method: string }>, - editor: SenseEditor, - trackUiMetric: MetricsTracker -) => { - const coreEditor = editor.getCoreEditor(); - // `getEndpointFromPosition` gets values from the server-side generated JSON files which - // are a combination of JS, automatically generated JSON and manual overrides. That means - // the metrics reported from here will be tied to the definitions in those files. - // See src/legacy/core_plugins/console/server/api_server/spec - const endpointDescription = getEndpointFromPosition( - coreEditor, - coreEditor.getCurrentPosition(), - editor.parser - ); - - if (requests[0] && endpointDescription) { - const eventName = `${requests[0].method}_${endpointDescription.id ?? 'unknown'}`; - trackUiMetric.count(eventName); - } -}; diff --git a/src/plugins/console/public/application/hooks/use_send_current_request/use_send_current_request.test.tsx b/src/plugins/console/public/application/hooks/use_send_current_request/use_send_current_request.test.tsx deleted file mode 100644 index 7f3082d5ef3dc..0000000000000 --- a/src/plugins/console/public/application/hooks/use_send_current_request/use_send_current_request.test.tsx +++ /dev/null @@ -1,130 +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". - */ - -jest.mock('./send_request', () => ({ sendRequest: jest.fn() })); -jest.mock('../../contexts/editor_context/editor_registry', () => ({ - instance: { getInputEditor: jest.fn() }, -})); -jest.mock('./track', () => ({ track: jest.fn() })); -jest.mock('../../contexts/request_context', () => ({ useRequestActionContext: jest.fn() })); -jest.mock('../../../lib/utils', () => ({ replaceVariables: jest.fn() })); - -import React from 'react'; -import { renderHook, act } from '@testing-library/react-hooks'; - -import { ContextValue, ServicesContextProvider } from '../../contexts'; -import { serviceContextMock } from '../../contexts/services_context.mock'; -import { useRequestActionContext } from '../../contexts/request_context'; -import { instance as editorRegistry } from '../../contexts/editor_context/editor_registry'; -import * as utils from '../../../lib/utils'; - -import { sendRequest } from './send_request'; -import { useSendCurrentRequest } from './use_send_current_request'; - -describe('useSendCurrentRequest', () => { - let mockContextValue: ContextValue; - let dispatch: (...args: unknown[]) => void; - const contexts = ({ children }: { children: JSX.Element }) => ( - {children} - ); - - beforeEach(() => { - mockContextValue = serviceContextMock.create(); - dispatch = jest.fn(); - (useRequestActionContext as jest.Mock).mockReturnValue(dispatch); - (utils.replaceVariables as jest.Mock).mockReturnValue(['test']); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it('calls send request', async () => { - // Set up mocks - (mockContextValue.services.settings.toJSON as jest.Mock).mockReturnValue({}); - // This request should succeed - (sendRequest as jest.Mock).mockResolvedValue([]); - (editorRegistry.getInputEditor as jest.Mock).mockImplementation(() => ({ - getRequestsInRange: () => ['test'], - })); - - const { result } = renderHook(() => useSendCurrentRequest(), { wrapper: contexts }); - await act(() => result.current()); - expect(sendRequest).toHaveBeenCalledWith({ - http: mockContextValue.services.http, - requests: ['test'], - }); - - // Second call should be the request success - const [, [requestSucceededCall]] = (dispatch as jest.Mock).mock.calls; - expect(requestSucceededCall).toEqual({ type: 'requestSuccess', payload: { data: [] } }); - }); - - it('handles known errors', async () => { - // Set up mocks - (sendRequest as jest.Mock).mockRejectedValue({ response: 'nada' }); - (editorRegistry.getInputEditor as jest.Mock).mockImplementation(() => ({ - getRequestsInRange: () => ['test'], - })); - - const { result } = renderHook(() => useSendCurrentRequest(), { wrapper: contexts }); - await act(() => result.current()); - // Second call should be the request failure - const [, [requestFailedCall]] = (dispatch as jest.Mock).mock.calls; - - // The request must have concluded - expect(requestFailedCall).toEqual({ type: 'requestFail', payload: { response: 'nada' } }); - }); - - it('handles unknown errors', async () => { - // Set up mocks - (sendRequest as jest.Mock).mockRejectedValue(NaN /* unexpected error value */); - (editorRegistry.getInputEditor as jest.Mock).mockImplementation(() => ({ - getRequestsInRange: () => ['test'], - })); - - const { result } = renderHook(() => useSendCurrentRequest(), { wrapper: contexts }); - await act(() => result.current()); - // Second call should be the request failure - const [, [requestFailedCall]] = (dispatch as jest.Mock).mock.calls; - - // The request must have concluded - expect(requestFailedCall).toEqual({ type: 'requestFail', payload: undefined }); - // It also notified the user - expect(mockContextValue.services.notifications.toasts.addError).toHaveBeenCalledWith(NaN, { - title: 'Unknown Request Error', - }); - }); - - it('notifies the user about save to history errors once only', async () => { - // Set up mocks - (sendRequest as jest.Mock).mockReturnValue( - [{ request: {} }, { request: {} }] /* two responses to save history */ - ); - (mockContextValue.services.settings.toJSON as jest.Mock).mockReturnValue({ - isHistoryEnabled: true, - }); - (mockContextValue.services.history.addToHistory as jest.Mock).mockImplementation(() => { - // Mock throwing - throw new Error('cannot save!'); - }); - (editorRegistry.getInputEditor as jest.Mock).mockImplementation(() => ({ - getRequestsInRange: () => ['test', 'test'], - })); - - const { result } = renderHook(() => useSendCurrentRequest(), { wrapper: contexts }); - await act(() => result.current()); - - expect(dispatch).toHaveBeenCalledTimes(2); - - expect(mockContextValue.services.history.addToHistory).toHaveBeenCalledTimes(2); - // It only called notification once - expect(mockContextValue.services.notifications.toasts.addError).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/plugins/console/public/application/hooks/use_send_current_request/use_send_current_request.ts b/src/plugins/console/public/application/hooks/use_send_current_request/use_send_current_request.ts deleted file mode 100644 index afdd5358432e9..0000000000000 --- a/src/plugins/console/public/application/hooks/use_send_current_request/use_send_current_request.ts +++ /dev/null @@ -1,148 +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 { i18n } from '@kbn/i18n'; -import { useCallback } from 'react'; - -import { toMountPoint } from '../../../shared_imports'; -import { isQuotaExceededError } from '../../../services/history'; -import { instance as registry } from '../../contexts/editor_context/editor_registry'; -import { useRequestActionContext, useServicesContext } from '../../contexts'; -import { StorageQuotaError } from '../../components/storage_quota_error'; -import { sendRequest } from './send_request'; -import { track } from './track'; -import { replaceVariables } from '../../../lib/utils'; -import { StorageKeys } from '../../../services'; -import { DEFAULT_VARIABLES } from '../../../../common/constants'; -import { SenseEditor } from '../../models'; - -export const useSendCurrentRequest = () => { - const { - services: { history, settings, notifications, trackUiMetric, http, autocompleteInfo, storage }, - ...startServices - } = useServicesContext(); - - const dispatch = useRequestActionContext(); - - return useCallback(async () => { - try { - const editor = registry.getInputEditor() as SenseEditor; - const variables = storage.get(StorageKeys.VARIABLES, DEFAULT_VARIABLES); - let requests = await editor.getRequestsInRange(); - requests = replaceVariables(requests, variables); - if (!requests.length) { - notifications.toasts.add( - i18n.translate('console.notification.error.noRequestSelectedTitle', { - defaultMessage: - 'No request selected. Select a request by placing the cursor inside it.', - }) - ); - return; - } - - dispatch({ type: 'sendRequest', payload: undefined }); - - // Fire and forget - setTimeout(() => track(requests, editor as SenseEditor, trackUiMetric), 0); - - const results = await sendRequest({ http, requests }); - - let saveToHistoryError: undefined | Error; - const { isHistoryEnabled } = settings.toJSON(); - - if (isHistoryEnabled) { - results.forEach(({ request: { path, method, data } }) => { - try { - history.addToHistory(path, method, data); - } catch (e) { - // Grab only the first error - if (!saveToHistoryError) { - saveToHistoryError = e; - } - } - }); - } - - if (saveToHistoryError) { - const errorTitle = i18n.translate('console.notification.error.couldNotSaveRequestTitle', { - defaultMessage: 'Could not save request to Console history.', - }); - if (isQuotaExceededError(saveToHistoryError)) { - const toast = notifications.toasts.addWarning({ - title: i18n.translate('console.notification.error.historyQuotaReachedMessage', { - defaultMessage: - 'Request history is full. Clear the console history or disable saving new requests.', - }), - text: toMountPoint( - StorageQuotaError({ - onClearHistory: () => { - history.clearHistory(); - notifications.toasts.remove(toast); - }, - onDisableSavingToHistory: () => { - settings.setIsHistoryEnabled(false); - notifications.toasts.remove(toast); - }, - }), - startServices - ), - }); - } else { - // Best effort, but still notify the user. - notifications.toasts.addError(saveToHistoryError, { - title: errorTitle, - }); - } - } - - const { polling } = settings.toJSON(); - if (polling) { - // If the user has submitted a request against ES, something in the fields, indices, aliases, - // or templates may have changed, so we'll need to update this data. Assume that if - // the user disables polling they're trying to optimize performance or otherwise - // preserve resources, so they won't want this request sent either. - autocompleteInfo.retrieve(settings, settings.getAutocomplete()); - } - - dispatch({ - type: 'requestSuccess', - payload: { - data: results, - }, - }); - } catch (e) { - if (e?.response) { - dispatch({ - type: 'requestFail', - payload: e, - }); - } else { - dispatch({ - type: 'requestFail', - payload: undefined, - }); - notifications.toasts.addError(e, { - title: i18n.translate('console.notification.error.unknownErrorTitle', { - defaultMessage: 'Unknown Request Error', - }), - }); - } - } - }, [ - storage, - dispatch, - http, - settings, - notifications.toasts, - trackUiMetric, - history, - autocompleteInfo, - startServices, - ]); -}; diff --git a/src/plugins/console/public/application/hooks/use_set_input_editor.ts b/src/plugins/console/public/application/hooks/use_set_input_editor.ts index d6029420a1772..148ede97520ea 100644 --- a/src/plugins/console/public/application/hooks/use_set_input_editor.ts +++ b/src/plugins/console/public/application/hooks/use_set_input_editor.ts @@ -10,14 +10,13 @@ import { useCallback } from 'react'; import { useEditorActionContext } from '../contexts/editor_context'; import { instance as registry } from '../contexts/editor_context/editor_registry'; -import { SenseEditor } from '../models'; import { MonacoEditorActionsProvider } from '../containers/editor/monaco_editor_actions_provider'; export const useSetInputEditor = () => { const dispatch = useEditorActionContext(); return useCallback( - (editor: SenseEditor | MonacoEditorActionsProvider) => { + (editor: MonacoEditorActionsProvider) => { dispatch({ type: 'setInputEditor', payload: editor }); registry.setInputEditor(editor); }, diff --git a/src/plugins/console/public/application/models/legacy_core_editor/create.ts b/src/plugins/console/public/application/models/legacy_core_editor/create.ts deleted file mode 100644 index b2631e8d6712b..0000000000000 --- a/src/plugins/console/public/application/models/legacy_core_editor/create.ts +++ /dev/null @@ -1,20 +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 ace from 'brace'; -import { LegacyCoreEditor } from './legacy_core_editor'; - -export const create = (el: HTMLElement) => { - const actions = document.querySelector('#ConAppEditorActions'); - if (!actions) { - throw new Error('Could not find ConAppEditorActions element!'); - } - const aceEditor = ace.edit(el); - return new LegacyCoreEditor(aceEditor, actions); -}; diff --git a/src/plugins/console/public/application/models/legacy_core_editor/create_readonly.ts b/src/plugins/console/public/application/models/legacy_core_editor/create_readonly.ts deleted file mode 100644 index dc0a95c224395..0000000000000 --- a/src/plugins/console/public/application/models/legacy_core_editor/create_readonly.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", 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 _ from 'lodash'; -import ace from 'brace'; -import { Mode } from './mode/output'; -import smartResize from './smart_resize'; - -export interface CustomAceEditor extends ace.Editor { - update: (text: string, mode?: string | Mode, cb?: () => void) => void; - append: (text: string, foldPrevious?: boolean, cb?: () => void) => void; -} - -/** - * Note: using read-only ace editor leaks the Ace editor API - use this as sparingly as possible or - * create an interface for it so that we don't rely directly on vendor APIs. - */ -export function createReadOnlyAceEditor(element: HTMLElement): CustomAceEditor { - const output: CustomAceEditor = ace.acequire('ace/ace').edit(element); - - const outputMode = new Mode(); - - output.$blockScrolling = Infinity; - output.resize = smartResize(output); - output.update = (val, mode, cb) => { - if (typeof mode === 'function') { - cb = mode as () => void; - mode = void 0; - } - - const session = output.getSession(); - const currentMode = val ? mode || outputMode : 'ace/mode/text'; - - // @ts-ignore - // ignore ts error here due to type definition mistake in brace for setMode(mode: string): void; - // this method accepts string or SyntaxMode which is an object. See https://github.com/ajaxorg/ace/blob/13dc911dbc0ea31ca343d5744b3f472767458fc3/ace.d.ts#L467 - session.setMode(currentMode); - session.setValue(val); - if (typeof cb === 'function') { - setTimeout(cb); - } - }; - - output.append = (val: string, foldPrevious?: boolean, cb?: () => void) => { - if (typeof foldPrevious === 'function') { - cb = foldPrevious; - foldPrevious = true; - } - if (_.isUndefined(foldPrevious)) { - foldPrevious = true; - } - const session = output.getSession(); - const lastLine = session.getLength(); - if (foldPrevious) { - output.moveCursorTo(Math.max(0, lastLine - 1), 0); - } - session.insert({ row: lastLine, column: 0 }, '\n' + val); - output.moveCursorTo(lastLine + 1, 0); - if (typeof cb === 'function') { - setTimeout(cb); - } - }; - - (function setupSession(session) { - session.setMode('ace/mode/text'); - (session as unknown as { setFoldStyle: (v: string) => void }).setFoldStyle('markbeginend'); - session.setTabSize(2); - session.setUseWrapMode(true); - })(output.getSession()); - - output.setShowPrintMargin(false); - output.setReadOnly(true); - - return output; -} diff --git a/src/plugins/console/public/application/models/legacy_core_editor/index.ts b/src/plugins/console/public/application/models/legacy_core_editor/index.ts deleted file mode 100644 index e885257520245..0000000000000 --- a/src/plugins/console/public/application/models/legacy_core_editor/index.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", 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 'brace'; -import 'brace/ext/language_tools'; -import 'brace/ext/searchbox'; -import 'brace/mode/json'; -import 'brace/mode/text'; - -export * from './legacy_core_editor'; -export * from './create_readonly'; -export * from './create'; diff --git a/src/plugins/console/public/application/models/legacy_core_editor/input.test.js b/src/plugins/console/public/application/models/legacy_core_editor/input.test.js deleted file mode 100644 index e472edc1af125..0000000000000 --- a/src/plugins/console/public/application/models/legacy_core_editor/input.test.js +++ /dev/null @@ -1,559 +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 './legacy_core_editor.test.mocks'; -import RowParser from '../../../lib/row_parser'; -import { createTokenIterator } from '../../factories'; -import $ from 'jquery'; -import { create } from './create'; - -describe('Input', () => { - let coreEditor; - beforeEach(() => { - // Set up our document body - document.body.innerHTML = `
-
-
-
-
`; - - coreEditor = create(document.querySelector('#ConAppEditor')); - - $(coreEditor.getContainer()).show(); - }); - afterEach(() => { - $(coreEditor.getContainer()).hide(); - }); - - describe('.getLineCount', () => { - it('returns the correct line length', async () => { - await coreEditor.setValue('1\n2\n3\n4', true); - expect(coreEditor.getLineCount()).toBe(4); - }); - }); - - describe('Tokenization', () => { - function tokensAsList() { - const iter = createTokenIterator({ - editor: coreEditor, - position: { lineNumber: 1, column: 1 }, - }); - const ret = []; - let t = iter.getCurrentToken(); - const parser = new RowParser(coreEditor); - if (parser.isEmptyToken(t)) { - t = parser.nextNonEmptyToken(iter); - } - while (t) { - ret.push({ value: t.value, type: t.type }); - t = parser.nextNonEmptyToken(iter); - } - - return ret; - } - - let testCount = 0; - - function tokenTest(tokenList, prefix, data) { - if (data && typeof data !== 'string') { - data = JSON.stringify(data, null, 3); - } - if (data) { - if (prefix) { - data = prefix + '\n' + data; - } - } else { - data = prefix; - } - - test('Token test ' + testCount++ + ' prefix: ' + prefix, async function () { - await coreEditor.setValue(data, true); - const tokens = tokensAsList(); - const normTokenList = []; - for (let i = 0; i < tokenList.length; i++) { - normTokenList.push({ type: tokenList[i++], value: tokenList[i] }); - } - - expect(tokens).toEqual(normTokenList); - }); - } - - tokenTest(['method', 'GET', 'url.part', '_search'], 'GET _search'); - - tokenTest(['method', 'GET', 'url.slash', '/', 'url.part', '_search'], 'GET /_search'); - - tokenTest( - [ - 'method', - 'GET', - 'url.protocol_host', - 'http://somehost', - 'url.slash', - '/', - 'url.part', - '_search', - ], - 'GET http://somehost/_search' - ); - - tokenTest(['method', 'GET', 'url.protocol_host', 'http://somehost'], 'GET http://somehost'); - - tokenTest( - ['method', 'GET', 'url.protocol_host', 'http://somehost', 'url.slash', '/'], - 'GET http://somehost/' - ); - - tokenTest( - ['method', 'GET', 'url.protocol_host', 'http://test:user@somehost', 'url.slash', '/'], - 'GET http://test:user@somehost/' - ); - - tokenTest( - ['method', 'GET', 'url.part', '_cluster', 'url.slash', '/', 'url.part', 'nodes'], - 'GET _cluster/nodes' - ); - - tokenTest( - [ - 'method', - 'GET', - 'url.slash', - '/', - 'url.part', - '_cluster', - 'url.slash', - '/', - 'url.part', - 'nodes', - ], - 'GET /_cluster/nodes' - ); - - tokenTest( - ['method', 'GET', 'url.part', 'index', 'url.slash', '/', 'url.part', '_search'], - 'GET index/_search' - ); - - tokenTest(['method', 'GET', 'url.part', 'index'], 'GET index'); - - tokenTest( - ['method', 'GET', 'url.part', 'index', 'url.slash', '/', 'url.part', 'type'], - 'GET index/type' - ); - - tokenTest( - [ - 'method', - 'GET', - 'url.slash', - '/', - 'url.part', - 'index', - 'url.slash', - '/', - 'url.part', - 'type', - 'url.slash', - '/', - ], - 'GET /index/type/' - ); - - tokenTest( - [ - 'method', - 'GET', - 'url.part', - 'index', - 'url.slash', - '/', - 'url.part', - 'type', - 'url.slash', - '/', - 'url.part', - '_search', - ], - 'GET index/type/_search' - ); - - tokenTest( - [ - 'method', - 'GET', - 'url.part', - 'index', - 'url.slash', - '/', - 'url.part', - 'type', - 'url.slash', - '/', - 'url.part', - '_search', - 'url.questionmark', - '?', - 'url.param', - 'value', - 'url.equal', - '=', - 'url.value', - '1', - ], - 'GET index/type/_search?value=1' - ); - - tokenTest( - [ - 'method', - 'GET', - 'url.part', - 'index', - 'url.slash', - '/', - 'url.part', - 'type', - 'url.slash', - '/', - 'url.part', - '1', - ], - 'GET index/type/1' - ); - - tokenTest( - [ - 'method', - 'GET', - 'url.slash', - '/', - 'url.part', - 'index1', - 'url.comma', - ',', - 'url.part', - 'index2', - 'url.slash', - '/', - ], - 'GET /index1,index2/' - ); - - tokenTest( - [ - 'method', - 'GET', - 'url.slash', - '/', - 'url.part', - 'index1', - 'url.comma', - ',', - 'url.part', - 'index2', - 'url.slash', - '/', - 'url.part', - '_search', - ], - 'GET /index1,index2/_search' - ); - - tokenTest( - [ - 'method', - 'GET', - 'url.part', - 'index1', - 'url.comma', - ',', - 'url.part', - 'index2', - 'url.slash', - '/', - 'url.part', - '_search', - ], - 'GET index1,index2/_search' - ); - - tokenTest( - [ - 'method', - 'GET', - 'url.slash', - '/', - 'url.part', - 'index1', - 'url.comma', - ',', - 'url.part', - 'index2', - ], - 'GET /index1,index2' - ); - - tokenTest( - ['method', 'GET', 'url.part', 'index1', 'url.comma', ',', 'url.part', 'index2'], - 'GET index1,index2' - ); - - tokenTest( - ['method', 'GET', 'url.slash', '/', 'url.part', 'index1', 'url.comma', ','], - 'GET /index1,' - ); - - tokenTest( - ['method', 'PUT', 'url.slash', '/', 'url.part', 'index', 'url.slash', '/'], - 'PUT /index/' - ); - - tokenTest( - ['method', 'GET', 'url.part', 'index', 'url.slash', '/', 'url.part', '_search'], - 'GET index/_search ' - ); - - tokenTest(['method', 'PUT', 'url.slash', '/', 'url.part', 'index'], 'PUT /index'); - - tokenTest( - [ - 'method', - 'PUT', - 'url.slash', - '/', - 'url.part', - 'index1', - 'url.comma', - ',', - 'url.part', - 'index2', - 'url.slash', - '/', - 'url.part', - 'type1', - 'url.comma', - ',', - 'url.part', - 'type2', - ], - 'PUT /index1,index2/type1,type2' - ); - - tokenTest( - [ - 'method', - 'PUT', - 'url.slash', - '/', - 'url.part', - 'index1', - 'url.slash', - '/', - 'url.part', - 'type1', - 'url.comma', - ',', - 'url.part', - 'type2', - 'url.comma', - ',', - ], - 'PUT /index1/type1,type2,' - ); - - tokenTest( - [ - 'method', - 'PUT', - 'url.part', - 'index1', - 'url.comma', - ',', - 'url.part', - 'index2', - 'url.slash', - '/', - 'url.part', - 'type1', - 'url.comma', - ',', - 'url.part', - 'type2', - 'url.slash', - '/', - 'url.part', - '1234', - ], - 'PUT index1,index2/type1,type2/1234' - ); - - tokenTest( - [ - 'method', - 'POST', - 'url.part', - '_search', - 'paren.lparen', - '{', - 'variable', - '"q"', - 'punctuation.colon', - ':', - 'paren.lparen', - '{', - 'paren.rparen', - '}', - 'paren.rparen', - '}', - ], - 'POST _search\n' + '{\n' + ' "q": {}\n' + ' \n' + '}' - ); - - tokenTest( - [ - 'method', - 'POST', - 'url.part', - '_search', - 'paren.lparen', - '{', - 'variable', - '"q"', - 'punctuation.colon', - ':', - 'paren.lparen', - '{', - 'variable', - '"s"', - 'punctuation.colon', - ':', - 'paren.lparen', - '{', - 'paren.rparen', - '}', - 'paren.rparen', - '}', - 'paren.rparen', - '}', - ], - 'POST _search\n' + '{\n' + ' "q": { "s": {}}\n' + ' \n' + '}' - ); - - function statesAsList() { - const ret = []; - const maxLine = coreEditor.getLineCount(); - for (let line = 1; line <= maxLine; line++) ret.push(coreEditor.getLineState(line)); - return ret; - } - - function statesTest(statesList, prefix, data) { - if (data && typeof data !== 'string') { - data = JSON.stringify(data, null, 3); - } - if (data) { - if (prefix) { - data = prefix + '\n' + data; - } - } else { - data = prefix; - } - - test('States test ' + testCount++ + ' prefix: ' + prefix, async function () { - await coreEditor.setValue(data, true); - const modes = statesAsList(); - expect(modes).toEqual(statesList); - }); - } - - statesTest( - ['start', 'json', 'json', 'start'], - 'POST _search\n' + '{\n' + ' "query": { "match_all": {} }\n' + '}' - ); - - statesTest( - ['start', 'json', ['json', 'json'], ['json', 'json'], 'json', 'start'], - 'POST _search\n' + '{\n' + ' "query": { \n' + ' "match_all": {} \n' + ' }\n' + '}' - ); - - statesTest( - ['start', 'json', 'json', 'start'], - 'POST _search\n' + '{\n' + ' "script": { "source": "" }\n' + '}' - ); - - statesTest( - ['start', 'json', 'json', 'start'], - 'POST _search\n' + '{\n' + ' "script": ""\n' + '}' - ); - - statesTest( - ['start', 'json', ['json', 'json'], 'json', 'start'], - 'POST _search\n' + '{\n' + ' "script": {\n' + ' }\n' + '}' - ); - - statesTest( - [ - 'start', - 'json', - ['script-start', 'json', 'json', 'json'], - ['script-start', 'json', 'json', 'json'], - ['json', 'json'], - 'json', - 'start', - ], - 'POST _search\n' + - '{\n' + - ' "test": { "script": """\n' + - ' test script\n' + - ' """\n' + - ' }\n' + - '}' - ); - - statesTest( - ['start', 'json', ['script-start', 'json'], ['script-start', 'json'], 'json', 'start'], - 'POST _search\n' + '{\n' + ' "script": """\n' + ' test script\n' + ' """,\n' + '}' - ); - - statesTest( - ['start', 'json', 'json', 'start'], - 'POST _search\n' + '{\n' + ' "script": """test script""",\n' + '}' - ); - - statesTest( - ['start', 'json', ['string_literal', 'json'], ['string_literal', 'json'], 'json', 'start'], - 'POST _search\n' + '{\n' + ' "something": """\n' + ' test script\n' + ' """,\n' + '}' - ); - - statesTest( - [ - 'start', - 'json', - ['string_literal', 'json', 'json', 'json'], - ['string_literal', 'json', 'json', 'json'], - ['json', 'json'], - ['json', 'json'], - 'json', - 'start', - ], - 'POST _search\n' + - '{\n' + - ' "something": { "f" : """\n' + - ' test script\n' + - ' """,\n' + - ' "g": 1\n' + - ' }\n' + - '}' - ); - - statesTest( - ['start', 'json', 'json', 'start'], - 'POST _search\n' + '{\n' + ' "something": """test script""",\n' + '}' - ); - }); -}); diff --git a/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.test.mocks.ts b/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.test.mocks.ts deleted file mode 100644 index 2ef5551e893d1..0000000000000 --- a/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.test.mocks.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", 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". - */ - -jest.mock('./mode/worker', () => { - return { workerModule: { id: 'sense_editor/mode/worker', src: '' } }; -}); - -import '@kbn/web-worker-stub'; - -// @ts-ignore -window.URL = { - createObjectURL: () => { - return ''; - }, -}; - -import 'brace'; -import 'brace/ext/language_tools'; -import 'brace/ext/searchbox'; -import 'brace/mode/json'; -import 'brace/mode/text'; - -document.queryCommandSupported = () => true; diff --git a/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts b/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts deleted file mode 100644 index edeb64104be7f..0000000000000 --- a/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts +++ /dev/null @@ -1,511 +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 ace, { type Annotation } from 'brace'; -import { Editor as IAceEditor, IEditSession as IAceEditSession } from 'brace'; -import $ from 'jquery'; -import { - CoreEditor, - Position, - Range, - Token, - TokensProvider, - EditorEvent, - AutoCompleterFunction, -} from '../../../types'; -import { AceTokensProvider } from '../../../lib/ace_token_provider'; -import * as curl from '../sense_editor/curl'; -import smartResize from './smart_resize'; -import * as InputMode from './mode/input'; - -const _AceRange = ace.acequire('ace/range').Range; - -const rangeToAceRange = ({ start, end }: Range) => - new _AceRange(start.lineNumber - 1, start.column - 1, end.lineNumber - 1, end.column - 1); - -export class LegacyCoreEditor implements CoreEditor { - private _aceOnPaste: Function; - $actions: JQuery; - resize: () => void; - - constructor(private readonly editor: IAceEditor, actions: HTMLElement) { - this.$actions = $(actions); - this.editor.setShowPrintMargin(false); - - const session = this.editor.getSession(); - // @ts-expect-error - // ignore ts error here due to type definition mistake in brace for setMode(mode: string): void; - // this method accepts string or SyntaxMode which is an object. See https://github.com/ajaxorg/ace/blob/13dc911dbc0ea31ca343d5744b3f472767458fc3/ace.d.ts#L467 - session.setMode(new InputMode.Mode()); - (session as unknown as { setFoldStyle: (style: string) => void }).setFoldStyle('markbeginend'); - session.setTabSize(2); - session.setUseWrapMode(true); - - this.resize = smartResize(this.editor); - - // Intercept ace on paste handler. - this._aceOnPaste = this.editor.onPaste; - this.editor.onPaste = this.DO_NOT_USE_onPaste.bind(this); - - this.editor.setOptions({ - enableBasicAutocompletion: true, - }); - - this.editor.$blockScrolling = Infinity; - this.hideActionsBar(); - this.editor.focus(); - } - - // dirty check for tokenizer state, uses a lot less cycles - // than listening for tokenizerUpdate - waitForLatestTokens(): Promise { - return new Promise((resolve) => { - const session = this.editor.getSession(); - const checkInterval = 25; - - const check = () => { - // If the bgTokenizer doesn't exist, we can assume that the underlying editor has been - // torn down, e.g. by closing the History tab, and we don't need to do anything further. - if (session.bgTokenizer) { - // Wait until the bgTokenizer is done running before executing the callback. - if ((session.bgTokenizer as unknown as { running: boolean }).running) { - setTimeout(check, checkInterval); - } else { - resolve(); - } - } - }; - - setTimeout(check, 0); - }); - } - - getLineState(lineNumber: number) { - const session = this.editor.getSession(); - return session.getState(lineNumber - 1); - } - - getValueInRange(range: Range): string { - return this.editor.getSession().getTextRange(rangeToAceRange(range)); - } - - getTokenProvider(): TokensProvider { - return new AceTokensProvider(this.editor.getSession()); - } - - getValue(): string { - return this.editor.getValue(); - } - - async setValue(text: string, forceRetokenize: boolean): Promise { - const session = this.editor.getSession(); - session.setValue(text); - if (forceRetokenize) { - await this.forceRetokenize(); - } - } - - getLineValue(lineNumber: number): string { - const session = this.editor.getSession(); - return session.getLine(lineNumber - 1); - } - - getCurrentPosition(): Position { - const cursorPosition = this.editor.getCursorPosition(); - return { - lineNumber: cursorPosition.row + 1, - column: cursorPosition.column + 1, - }; - } - - clearSelection(): void { - this.editor.clearSelection(); - } - - getTokenAt(pos: Position): Token | null { - const provider = this.getTokenProvider(); - return provider.getTokenAt(pos); - } - - insert(valueOrPos: string | Position, value?: string): void { - if (typeof valueOrPos === 'string') { - this.editor.insert(valueOrPos); - return; - } - const document = this.editor.getSession().getDocument(); - document.insert( - { - column: valueOrPos.column - 1, - row: valueOrPos.lineNumber - 1, - }, - value || '' - ); - } - - moveCursorToPosition(pos: Position): void { - this.editor.moveCursorToPosition({ row: pos.lineNumber - 1, column: pos.column - 1 }); - } - - replace(range: Range, value: string): void { - const session = this.editor.getSession(); - session.replace(rangeToAceRange(range), value); - } - - getLines(startLine: number, endLine: number): string[] { - const session = this.editor.getSession(); - return session.getLines(startLine - 1, endLine - 1); - } - - replaceRange(range: Range, value: string) { - const pos = this.editor.getCursorPosition(); - this.editor.getSession().replace(rangeToAceRange(range), value); - - const maxRow = Math.max(range.start.lineNumber - 1 + value.split('\n').length - 1, 1); - pos.row = Math.min(pos.row, maxRow); - this.editor.moveCursorToPosition(pos); - // ACE UPGRADE - check if needed - at the moment the above may trigger a selection. - this.editor.clearSelection(); - } - - getSelectionRange() { - const result = this.editor.getSelectionRange(); - return { - start: { - lineNumber: result.start.row + 1, - column: result.start.column + 1, - }, - end: { - lineNumber: result.end.row + 1, - column: result.end.column + 1, - }, - }; - } - - getLineCount() { - // Only use this function to return line count as it uses - // a cache. - return this.editor.getSession().getLength(); - } - - addMarker(range: Range) { - return this.editor - .getSession() - .addMarker(rangeToAceRange(range), 'ace_snippet-marker', 'fullLine', false); - } - - removeMarker(ref: number) { - this.editor.getSession().removeMarker(ref); - } - - getWrapLimit(): number { - return this.editor.getSession().getWrapLimit(); - } - - on(event: EditorEvent, listener: () => void) { - if (event === 'changeCursor') { - this.editor.getSession().selection.on(event, listener); - } else if (event === 'changeSelection') { - this.editor.on(event, listener); - } else { - this.editor.getSession().on(event, listener); - } - } - - off(event: EditorEvent, listener: () => void) { - if (event === 'changeSelection') { - this.editor.off(event, listener); - } - } - - isCompleterActive() { - return Boolean( - (this.editor as unknown as { completer: { activated: unknown } }).completer && - (this.editor as unknown as { completer: { activated: unknown } }).completer.activated - ); - } - - detachCompleter() { - // In some situations we need to detach the autocomplete suggestions element manually, - // such as when navigating away from Console when the suggestions list is open. - const completer = (this.editor as unknown as { completer: { detach(): void } }).completer; - return completer?.detach(); - } - - private forceRetokenize() { - const session = this.editor.getSession(); - return new Promise((resolve) => { - // force update of tokens, but not on this thread to allow for ace rendering. - setTimeout(function () { - let i; - for (i = 0; i < session.getLength(); i++) { - session.getTokens(i); - } - resolve(); - }); - }); - } - - // eslint-disable-next-line @typescript-eslint/naming-convention - private DO_NOT_USE_onPaste(text: string) { - if (text && curl.detectCURL(text)) { - const curlInput = curl.parseCURL(text); - this.editor.insert(curlInput); - return; - } - this._aceOnPaste.call(this.editor, text); - } - - private setActionsBar = (value: number | null, topOrBottom: 'top' | 'bottom' = 'top') => { - if (value === null) { - this.$actions.css('visibility', 'hidden'); - } else { - if (topOrBottom === 'top') { - this.$actions.css({ - bottom: 'auto', - top: value, - visibility: 'visible', - }); - } else { - this.$actions.css({ - top: 'auto', - bottom: value, - visibility: 'visible', - }); - } - } - }; - - private hideActionsBar = () => { - this.setActionsBar(null); - }; - - execCommand(cmd: string) { - this.editor.execCommand(cmd); - } - - getContainer(): HTMLDivElement { - return this.editor.container as HTMLDivElement; - } - - setStyles(styles: { wrapLines: boolean; fontSize: string }) { - this.editor.getSession().setUseWrapMode(styles.wrapLines); - this.editor.container.style.fontSize = styles.fontSize; - } - - registerKeyboardShortcut(opts: { keys: string; fn: () => void; name: string }): void { - this.editor.commands.addCommand({ - exec: opts.fn, - name: opts.name, - bindKey: opts.keys, - }); - } - - unregisterKeyboardShortcut(command: string) { - // @ts-ignore - this.editor.commands.removeCommand(command); - } - - legacyUpdateUI(range: Range) { - if (!this.$actions) { - return; - } - if (range) { - // elements are positioned relative to the editor's container - // pageY is relative to page, so subtract the offset - // from pageY to get the new top value - const offsetFromPage = $(this.editor.container).offset()!.top; - const startLine = range.start.lineNumber; - const startColumn = range.start.column; - const firstLine = this.getLineValue(startLine); - const maxLineLength = this.getWrapLimit() - 5; - const isWrapping = firstLine.length > maxLineLength; - const totalOffset = offsetFromPage - (window.pageYOffset || 0); - const getScreenCoords = (line: number) => - this.editor.renderer.textToScreenCoordinates(line - 1, startColumn).pageY - totalOffset; - const topOfReq = getScreenCoords(startLine); - - if (topOfReq >= 0) { - const { bottom: maxBottom } = this.editor.container.getBoundingClientRect(); - if (topOfReq > maxBottom - totalOffset) { - this.setActionsBar(0, 'bottom'); - return; - } - let offset = 0; - if (isWrapping) { - // Try get the line height of the text area in pixels. - const textArea = $(this.editor.container.querySelector('textArea')!); - const hasRoomOnNextLine = this.getLineValue(startLine).length < maxLineLength; - if (textArea && hasRoomOnNextLine) { - // Line height + the number of wraps we have on a line. - offset += this.getLineValue(startLine).length * textArea.height()!; - } else { - if (startLine > 1) { - this.setActionsBar(getScreenCoords(startLine - 1)); - return; - } - this.setActionsBar(getScreenCoords(startLine + 1)); - return; - } - } - this.setActionsBar(topOfReq + offset); - return; - } - - const bottomOfReq = - this.editor.renderer.textToScreenCoordinates(range.end.lineNumber, range.end.column).pageY - - offsetFromPage; - - if (bottomOfReq >= 0) { - this.setActionsBar(0); - return; - } - } - } - - registerAutocompleter(autocompleter: AutoCompleterFunction): void { - // Hook into Ace - - // disable standard context based autocompletion. - // @ts-ignore - ace.define( - 'ace/autocomplete/text_completer', - ['require', 'exports', 'module'], - function ( - require: unknown, - exports: { - getCompletions: ( - innerEditor: unknown, - session: unknown, - pos: unknown, - prefix: unknown, - callback: (e: null | Error, values: string[]) => void - ) => void; - } - ) { - exports.getCompletions = function (innerEditor, session, pos, prefix, callback) { - callback(null, []); - }; - } - ); - - const langTools = ace.acequire('ace/ext/language_tools'); - - langTools.setCompleters([ - { - identifierRegexps: [ - /[a-zA-Z_0-9\.\$\-\u00A2-\uFFFF]/, // adds support for dot character - ], - getCompletions: ( - // eslint-disable-next-line @typescript-eslint/naming-convention - DO_NOT_USE_1: IAceEditor, - aceEditSession: IAceEditSession, - pos: { row: number; column: number }, - prefix: string, - callback: (...args: unknown[]) => void - ) => { - const position: Position = { - lineNumber: pos.row + 1, - column: pos.column + 1, - }; - - const getAnnotationControls = () => { - let customAnnotation: Annotation; - return { - setAnnotation(text: string) { - const annotations = aceEditSession.getAnnotations(); - customAnnotation = { - text, - row: pos.row, - column: pos.column, - type: 'warning', - }; - - aceEditSession.setAnnotations([...annotations, customAnnotation]); - }, - removeAnnotation() { - aceEditSession.setAnnotations( - aceEditSession.getAnnotations().filter((a: Annotation) => a !== customAnnotation) - ); - }, - }; - }; - - autocompleter(position, prefix, callback, getAnnotationControls()); - }, - }, - ]); - } - - destroy() { - this.editor.destroy(); - } - - /** - * Formats body of the request in the editor by removing the extra whitespaces at the beginning of lines, - * And adds the correct indentation for each line - * @param reqRange request range to indent - */ - autoIndent(reqRange: Range) { - const session = this.editor.getSession(); - const mode = session.getMode(); - const startRow = reqRange.start.lineNumber; - const endRow = reqRange.end.lineNumber; - const tab = session.getTabString(); - - for (let row = startRow; row <= endRow; row++) { - let prevLineState = ''; - let prevLineIndent = ''; - if (row > 0) { - prevLineState = session.getState(row - 1); - const prevLine = session.getLine(row - 1); - prevLineIndent = mode.getNextLineIndent(prevLineState, prevLine, tab); - } - - const line = session.getLine(row); - // @ts-ignore - // Brace does not expose type definition for mode.$getIndent, though we have access to this method provided by the underlying Ace editor. - // See https://github.com/ajaxorg/ace/blob/87ce087ed1cf20eeabe56fb0894e048d9bc9c481/lib/ace/mode/text.js#L259 - const currLineIndent = mode.$getIndent(line); - if (prevLineIndent !== currLineIndent) { - if (currLineIndent.length > 0) { - // If current line has indentation, remove it. - // Next we will add the correct indentation by looking at the previous line - const range = new _AceRange(row, 0, row, currLineIndent.length); - session.remove(range); - } - if (prevLineIndent.length > 0) { - // If previous line has indentation, add indentation at the current line - session.insert({ row, column: 0 }, prevLineIndent); - } - } - - // Lastly outdent any closing braces - mode.autoOutdent(prevLineState, session, row); - } - } - - getAllFoldRanges(): Range[] { - const session = this.editor.getSession(); - // @ts-ignore - // Brace does not expose type definition for session.getAllFolds, though we have access to this method provided by the underlying Ace editor. - // See https://github.com/ajaxorg/ace/blob/13dc911dbc0ea31ca343d5744b3f472767458fc3/ace.d.ts#L82 - return session.getAllFolds().map((fold) => fold.range); - } - - addFoldsAtRanges(foldRanges: Range[]) { - const session = this.editor.getSession(); - foldRanges.forEach((range) => { - try { - session.addFold('...', _AceRange.fromPoints(range.start, range.end)); - } catch (e) { - // ignore the error if a fold fails - } - }); - } -} diff --git a/src/plugins/console/public/application/models/legacy_core_editor/mode/input.ts b/src/plugins/console/public/application/models/legacy_core_editor/mode/input.ts deleted file mode 100644 index 450feec6e9c3d..0000000000000 --- a/src/plugins/console/public/application/models/legacy_core_editor/mode/input.ts +++ /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", 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 ace from 'brace'; -import { workerModule } from './worker'; -import { ScriptMode } from './script'; - -const TextMode = ace.acequire('ace/mode/text').Mode; - -const MatchingBraceOutdent = ace.acequire('ace/mode/matching_brace_outdent').MatchingBraceOutdent; -const CstyleBehaviour = ace.acequire('ace/mode/behaviour/cstyle').CstyleBehaviour; -const CStyleFoldMode = ace.acequire('ace/mode/folding/cstyle').FoldMode; -const WorkerClient = ace.acequire('ace/worker/worker_client').WorkerClient; -const AceTokenizer = ace.acequire('ace/tokenizer').Tokenizer; - -import { InputHighlightRules } from './input_highlight_rules'; - -export class Mode extends TextMode { - constructor() { - super(); - this.$tokenizer = new AceTokenizer(new InputHighlightRules().getRules()); - this.$outdent = new MatchingBraceOutdent(); - this.$behaviour = new CstyleBehaviour(); - this.foldingRules = new CStyleFoldMode(); - this.createModeDelegates({ - 'script-': ScriptMode, - }); - } -} - -(function (this: Mode) { - this.getCompletions = function () { - // autocomplete is done by the autocomplete module. - return []; - }; - - this.getNextLineIndent = function (state: string, line: string, tab: string) { - let indent = this.$getIndent(line); - - if (state !== 'string_literal') { - const match = line.match(/^.*[\{\(\[]\s*$/); - if (match) { - indent += tab; - } - } - - return indent; - }; - - this.checkOutdent = function (state: unknown, line: string, input: string) { - return this.$outdent.checkOutdent(line, input); - }; - - this.autoOutdent = function (state: unknown, doc: string, row: string) { - this.$outdent.autoOutdent(doc, row); - }; - this.createWorker = function (session: { - getDocument: () => string; - setAnnotations: (arg0: unknown) => void; - }) { - const worker = new WorkerClient(['ace', 'sense_editor'], workerModule, 'SenseWorker'); - worker.attachToDocument(session.getDocument()); - worker.on('error', function (e: { data: unknown }) { - session.setAnnotations([e.data]); - }); - - worker.on('ok', function (anno: { data: unknown }) { - session.setAnnotations(anno.data); - }); - - return worker; - }; -}).call(Mode.prototype); diff --git a/src/plugins/console/public/application/models/legacy_core_editor/mode/input_highlight_rules.ts b/src/plugins/console/public/application/models/legacy_core_editor/mode/input_highlight_rules.ts deleted file mode 100644 index 8a2f64b3c71f4..0000000000000 --- a/src/plugins/console/public/application/models/legacy_core_editor/mode/input_highlight_rules.ts +++ /dev/null @@ -1,180 +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 ace from 'brace'; -import { addXJsonToRules } from '@kbn/ace'; - -type Token = - | string - | { token?: string; regex?: string; next?: string; push?: boolean; include?: string }; - -export function addEOL( - tokens: Token[], - reg: string | RegExp, - nextIfEOL: string, - normalNext?: string -) { - if (typeof reg === 'object') { - reg = reg.source; - } - return [ - { token: tokens.concat(['whitespace']), regex: reg + '(\\s*)$', next: nextIfEOL }, - { token: tokens, regex: reg, next: normalNext }, - ]; -} - -export const mergeTokens = (...args: any[]) => [].concat.apply([], args); - -const TextHighlightRules = ace.acequire('ace/mode/text_highlight_rules').TextHighlightRules; -// translating this to monaco -export class InputHighlightRules extends TextHighlightRules { - constructor() { - super(); - this.$rules = { - // TODO - 'start-sql': [ - { token: 'whitespace', regex: '\\s+' }, - { token: 'paren.lparen', regex: '{', next: 'json-sql', push: true }, - { regex: '', next: 'start' }, - ], - start: mergeTokens( - [ - // done - { token: 'warning', regex: '#!.*$' }, - // done - { include: 'comments' }, - // done - { token: 'paren.lparen', regex: '{', next: 'json', push: true }, - ], - // done - addEOL(['method'], /([a-zA-Z]+)/, 'start', 'method_sep'), - [ - // done - { - token: 'whitespace', - regex: '\\s+', - }, - // done - { - token: 'text', - regex: '.+?', - }, - ] - ), - method_sep: mergeTokens( - // done - addEOL( - ['whitespace', 'url.protocol_host', 'url.slash'], - /(\s+)(https?:\/\/[^?\/,]+)(\/)/, - 'start', - 'url' - ), - // done - addEOL(['whitespace', 'variable.template'], /(\s+)(\${\w+})/, 'start', 'url'), - // done - addEOL(['whitespace', 'url.protocol_host'], /(\s+)(https?:\/\/[^?\/,]+)/, 'start', 'url'), - // done - addEOL(['whitespace', 'url.slash'], /(\s+)(\/)/, 'start', 'url'), - // done - addEOL(['whitespace'], /(\s+)/, 'start', 'url') - ), - url: mergeTokens( - // done - addEOL(['variable.template'], /(\${\w+})/, 'start'), - // TODO - addEOL(['url.part'], /(_sql)/, 'start-sql', 'url-sql'), - // done - addEOL(['url.part'], /([^?\/,\s]+)/, 'start'), - // done - addEOL(['url.comma'], /(,)/, 'start'), - // done - addEOL(['url.slash'], /(\/)/, 'start'), - // done - addEOL(['url.questionmark'], /(\?)/, 'start', 'urlParams'), - // done - addEOL(['whitespace', 'comment.punctuation', 'comment.line'], /(\s+)(\/\/)(.*$)/, 'start') - ), - urlParams: mergeTokens( - // done - addEOL(['url.param', 'url.equal', 'variable.template'], /([^&=]+)(=)(\${\w+})/, 'start'), - // done - addEOL(['url.param', 'url.equal', 'url.value'], /([^&=]+)(=)([^&]*)/, 'start'), - // done - addEOL(['url.param'], /([^&=]+)/, 'start'), - // done - addEOL(['url.amp'], /(&)/, 'start'), - // done - addEOL(['whitespace', 'comment.punctuation', 'comment.line'], /(\s+)(\/\/)(.*$)/, 'start') - ), - // TODO - 'url-sql': mergeTokens( - addEOL(['url.part'], /([^?\/,\s]+)/, 'start-sql'), - addEOL(['url.comma'], /(,)/, 'start-sql'), - addEOL(['url.slash'], /(\/)/, 'start-sql'), - addEOL(['url.questionmark'], /(\?)/, 'start-sql', 'urlParams-sql') - ), - // TODO - 'urlParams-sql': mergeTokens( - addEOL(['url.param', 'url.equal', 'url.value'], /([^&=]+)(=)([^&]*)/, 'start-sql'), - addEOL(['url.param'], /([^&=]+)/, 'start-sql'), - addEOL(['url.amp'], /(&)/, 'start-sql') - ), - /** - * Each key in this.$rules considered to be a state in state machine. Regular expressions define the tokens for the current state, as well as the transitions into another state. - * See for more details https://cloud9-sdk.readme.io/docs/highlighting-rules#section-defining-states - * * - * Define a state for comments, these comment rules then can be included in other states. E.g. in 'start' and 'json' states by including { include: 'comments' } - * This will avoid duplicating the same rules in other states - */ - comments: [ - { - // Capture a line comment, indicated by # - // done - token: ['comment.punctuation', 'comment.line'], - regex: /(#)(.*$)/, - }, - { - // Begin capturing a block comment, indicated by /* - // done - token: 'comment.punctuation', - regex: /\/\*/, - push: [ - { - // Finish capturing a block comment, indicated by */ - // done - token: 'comment.punctuation', - regex: /\*\//, - next: 'pop', - }, - { - // done - defaultToken: 'comment.block', - }, - ], - }, - { - // Capture a line comment, indicated by // - // done - token: ['comment.punctuation', 'comment.line'], - regex: /(\/\/)(.*$)/, - }, - ], - }; - - addXJsonToRules(this, 'json'); - // Add comment rules to json rule set - this.$rules.json.unshift({ include: 'comments' }); - - this.$rules.json.unshift({ token: 'variable.template', regex: /("\${\w+}")/ }); - - if (this instanceof InputHighlightRules) { - this.normalizeRules(); - } - } -} diff --git a/src/plugins/console/public/application/models/legacy_core_editor/mode/output.ts b/src/plugins/console/public/application/models/legacy_core_editor/mode/output.ts deleted file mode 100644 index df7f3c37d55ec..0000000000000 --- a/src/plugins/console/public/application/models/legacy_core_editor/mode/output.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", 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 ace from 'brace'; - -import { OutputJsonHighlightRules } from './output_highlight_rules'; - -const JSONMode = ace.acequire('ace/mode/json').Mode; -const MatchingBraceOutdent = ace.acequire('ace/mode/matching_brace_outdent').MatchingBraceOutdent; -const CstyleBehaviour = ace.acequire('ace/mode/behaviour/cstyle').CstyleBehaviour; -const CStyleFoldMode = ace.acequire('ace/mode/folding/cstyle').FoldMode; -ace.acequire('ace/worker/worker_client'); -const AceTokenizer = ace.acequire('ace/tokenizer').Tokenizer; - -export class Mode extends JSONMode { - constructor() { - super(); - this.$tokenizer = new AceTokenizer(new OutputJsonHighlightRules().getRules()); - this.$outdent = new MatchingBraceOutdent(); - this.$behaviour = new CstyleBehaviour(); - this.foldingRules = new CStyleFoldMode(); - } -} - -(function (this: Mode) { - this.createWorker = function () { - return null; - }; - - this.$id = 'sense/mode/input'; -}).call(Mode.prototype); diff --git a/src/plugins/console/public/application/models/legacy_core_editor/mode/output_highlight_rules.test.ts b/src/plugins/console/public/application/models/legacy_core_editor/mode/output_highlight_rules.test.ts deleted file mode 100644 index a18841aa4dc17..0000000000000 --- a/src/plugins/console/public/application/models/legacy_core_editor/mode/output_highlight_rules.test.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", 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 { mapStatusCodeToBadge } from './output_highlight_rules'; - -describe('mapStatusCodeToBadge', () => { - const testCases = [ - { - description: 'treats 100 as as default', - value: '# PUT test-index 100 Continue', - badge: 'badge.badge--default', - }, - { - description: 'treats 200 as success', - value: '# PUT test-index 200 OK', - badge: 'badge.badge--success', - }, - { - description: 'treats 301 as primary', - value: '# PUT test-index 301 Moved Permanently', - badge: 'badge.badge--primary', - }, - { - description: 'treats 400 as warning', - value: '# PUT test-index 404 Not Found', - badge: 'badge.badge--warning', - }, - { - description: 'treats 502 as danger', - value: '# PUT test-index 502 Bad Gateway', - badge: 'badge.badge--danger', - }, - { - description: 'treats unexpected numbers as danger', - value: '# PUT test-index 666 Demonic Invasion', - badge: 'badge.badge--danger', - }, - { - description: 'treats no numbers as undefined', - value: '# PUT test-index', - badge: undefined, - }, - ]; - - testCases.forEach(({ description, value, badge }) => { - test(description, () => { - expect(mapStatusCodeToBadge(value)).toBe(badge); - }); - }); -}); diff --git a/src/plugins/console/public/application/models/legacy_core_editor/mode/output_highlight_rules.ts b/src/plugins/console/public/application/models/legacy_core_editor/mode/output_highlight_rules.ts deleted file mode 100644 index 765ba3e263f22..0000000000000 --- a/src/plugins/console/public/application/models/legacy_core_editor/mode/output_highlight_rules.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", 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 ace from 'brace'; -import 'brace/mode/json'; -import { addXJsonToRules } from '@kbn/ace'; - -const JsonHighlightRules = ace.acequire('ace/mode/json_highlight_rules').JsonHighlightRules; - -export const mapStatusCodeToBadge = (value?: string) => { - const regExpMatchArray = value?.match(/\d+/); - if (regExpMatchArray) { - const status = parseInt(regExpMatchArray[0], 10); - if (status <= 199) { - return 'badge.badge--default'; - } - if (status <= 299) { - return 'badge.badge--success'; - } - if (status <= 399) { - return 'badge.badge--primary'; - } - if (status <= 499) { - return 'badge.badge--warning'; - } - return 'badge.badge--danger'; - } -}; - -export class OutputJsonHighlightRules extends JsonHighlightRules { - constructor() { - super(); - this.$rules = {}; - addXJsonToRules(this, 'start'); - this.$rules.start.unshift( - { - token: 'warning', - regex: '#!.*$', - }, - { - token: 'comment', - // match a comment starting with a hash at the start of the line - // ignore status codes and status texts at the end of the line (e.g. # GET _search/foo 200, # GET _search/foo 200 OK) - regex: /#(.*?)(?=[1-5][0-9][0-9]\s(?:[\sA-Za-z]+)|(?:[1-5][0-9][0-9])|$)/, - }, - { - token: mapStatusCodeToBadge, - // match status codes and status texts at the end of the line (e.g. # GET _search/foo 200, # GET _search/foo 200 OK) - // this rule allows us to highlight them with the corresponding badge color (e.g. 200 OK -> badge.badge--success) - regex: /([1-5][0-9][0-9]\s?[\sA-Za-z]+$)/, - } - ); - - if (this instanceof OutputJsonHighlightRules) { - this.normalizeRules(); - } - } -} diff --git a/src/plugins/console/public/application/models/legacy_core_editor/mode/script.ts b/src/plugins/console/public/application/models/legacy_core_editor/mode/script.ts deleted file mode 100644 index f50b6d3abe8ab..0000000000000 --- a/src/plugins/console/public/application/models/legacy_core_editor/mode/script.ts +++ /dev/null @@ -1,48 +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 ace from 'brace'; -import { ScriptHighlightRules } from '@kbn/ace'; - -const TextMode = ace.acequire('ace/mode/text').Mode; -const MatchingBraceOutdent = ace.acequire('ace/mode/matching_brace_outdent').MatchingBraceOutdent; -const CstyleBehaviour = ace.acequire('ace/mode/behaviour/cstyle').CstyleBehaviour; -const CStyleFoldMode = ace.acequire('ace/mode/folding/cstyle').FoldMode; -ace.acequire('ace/tokenizer'); - -export class ScriptMode extends TextMode { - constructor() { - super(); - this.$outdent = new MatchingBraceOutdent(); - this.$behaviour = new CstyleBehaviour(); - this.foldingRules = new CStyleFoldMode(); - } -} - -(function (this: ScriptMode) { - this.HighlightRules = ScriptHighlightRules; - - this.getNextLineIndent = function (state: unknown, line: string, tab: string) { - let indent = this.$getIndent(line); - const match = line.match(/^.*[\{\[]\s*$/); - if (match) { - indent += tab; - } - - return indent; - }; - - this.checkOutdent = function (state: unknown, line: string, input: string) { - return this.$outdent.checkOutdent(line, input); - }; - - this.autoOutdent = function (state: unknown, doc: string, row: string) { - this.$outdent.autoOutdent(doc, row); - }; -}).call(ScriptMode.prototype); diff --git a/src/plugins/console/public/application/models/legacy_core_editor/mode/worker/index.d.ts b/src/plugins/console/public/application/models/legacy_core_editor/mode/worker/index.d.ts deleted file mode 100644 index 8067bec3556ae..0000000000000 --- a/src/plugins/console/public/application/models/legacy_core_editor/mode/worker/index.d.ts +++ /dev/null @@ -1,10 +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 declare const workerModule: { id: string; src: string }; diff --git a/src/plugins/console/public/application/models/legacy_core_editor/mode/worker/index.js b/src/plugins/console/public/application/models/legacy_core_editor/mode/worker/index.js deleted file mode 100644 index 23f636b79e1a6..0000000000000 --- a/src/plugins/console/public/application/models/legacy_core_editor/mode/worker/index.js +++ /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", 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 src from '!!raw-loader!./worker'; - -export const workerModule = { - id: 'sense_editor/mode/worker', - src, -}; diff --git a/src/plugins/console/public/application/models/legacy_core_editor/mode/worker/worker.js b/src/plugins/console/public/application/models/legacy_core_editor/mode/worker/worker.js deleted file mode 100644 index 65567f377cc52..0000000000000 --- a/src/plugins/console/public/application/models/legacy_core_editor/mode/worker/worker.js +++ /dev/null @@ -1,2392 +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". - */ - -/* @notice - * - * This product includes code that is based on Ace editor, which was available - * under a "BSD" license. - * - * Distributed under the BSD license: - * - * Copyright (c) 2010, Ajax.org B.V. - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * * Redistributions of source code must retain the above copyright - * notice, this list of conditions and the following disclaimer. - * * Redistributions in binary form must reproduce the above copyright - * notice, this list of conditions and the following disclaimer in the - * documentation and/or other materials provided with the distribution. - * * Neither the name of Ajax.org B.V. nor the - * names of its contributors may be used to endorse or promote products - * derived from this software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND - * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -/* eslint-disable prettier/prettier,prefer-const,eqeqeq,import/no-commonjs,no-undef,no-sequences, - block-scoped-var,no-use-before-define,no-var,one-var,guard-for-in,new-cap,no-nested-ternary,no-redeclare, - no-unused-vars,no-extend-native,no-empty,camelcase,no-proto,@kbn/imports/no_unresolvable_imports */ -/* - This file is loaded up as a blob by Brace to hand to Ace to load as Jsonp - (hence the redefining of everything). It is based on the javascript - mode from the brace distro. -*/ -function init(window) { - function resolveModuleId(id, paths) { - for (let testPath = id, tail = ''; testPath;) { - let alias = paths[testPath]; - if ('string' === typeof alias) return alias + tail; - if (alias) - {return (alias.location.replace(/\/*$/, '/') + (tail || alias.main || alias.name));} - if (alias === !1) return ''; - let i = testPath.lastIndexOf('/'); - if (-1 === i) break; - (tail = testPath.substr(i) + tail), (testPath = testPath.slice(0, i)); - } - return id; - } - if ( - !( - (void 0 !== window.window && window.document) || - (window.acequire && window.define) - ) - ) { - window.console || - ((window.console = function () { - let msgs = Array.prototype.slice.call(arguments, 0); - postMessage({ type: 'log', data: msgs }); - }), - (window.console.error = window.console.warn = window.console.log = window.console.trace = - window.console)), - (window.window = window), - (window.ace = window), - (window.onerror = function (message, file, line, col, err) { - postMessage({ - type: 'error', - data: { - message: message, - data: err.data, - file: file, - line: line, - col: col, - stack: err.stack, - }, - }); - }), - (window.normalizeModule = function (parentId, moduleName) { - if (-1 !== moduleName.indexOf('!')) { - let chunks = moduleName.split('!'); - return ( - window.normalizeModule(parentId, chunks[0]) + - '!' + - window.normalizeModule(parentId, chunks[1]) - ); - } - if ('.' == moduleName.charAt(0)) { - let base = parentId - .split('/') - .slice(0, -1) - .join('/'); - for ( - moduleName = (base ? base + '/' : '') + moduleName; - -1 !== moduleName.indexOf('.') && previous != moduleName; - - ) { - var previous = moduleName; - moduleName = moduleName - .replace(/^\.\//, '') - .replace(/\/\.\//, '/') - .replace(/[^\/]+\/\.\.\//, ''); - } - } - return moduleName; - }), - (window.acequire = function acequire(parentId, id) { - if ((id || ((id = parentId), (parentId = null)), !id.charAt)) - {throw Error( - 'worker.js acequire() accepts only (parentId, id) as arguments' - );} - id = window.normalizeModule(parentId, id); - let module = window.acequire.modules[id]; - if (module) - {return ( - module.initialized || - ((module.initialized = !0), - (module.exports = module.factory().exports)), - module.exports - );} - if (!window.acequire.tlns) return console.log('unable to load ' + id); - let path = resolveModuleId(id, window.acequire.tlns); - return ( - '.js' != path.slice(-3) && (path += '.js'), - (window.acequire.id = id), - (window.acequire.modules[id] = {}), - importScripts(path), - window.acequire(parentId, id) - ); - }), - (window.acequire.modules = {}), - (window.acequire.tlns = {}), - (window.define = function (id, deps, factory) { - if ( - (2 == arguments.length - ? ((factory = deps), - 'string' !== typeof id && ((deps = id), (id = window.acequire.id))) - : 1 == arguments.length && - ((factory = id), (deps = []), (id = window.acequire.id)), - 'function' !== typeof factory) - ) - {return ( - (window.acequire.modules[id] = { - exports: factory, - initialized: !0, - }), - void 0 - );} - deps.length || (deps = ['require', 'exports', 'module']); - let req = function (childId) { - return window.acequire(id, childId); - }; - window.acequire.modules[id] = { - exports: {}, - factory: function () { - let module = this, - returnExports = factory.apply( - this, - deps.map(function (dep) { - switch (dep) { - case 'require': - return req; - case 'exports': - return module.exports; - case 'module': - return module; - default: - return req(dep); - } - }) - ); - return returnExports && (module.exports = returnExports), module; - }, - }; - }), - (window.define.amd = {}), - (acequire.tlns = {}), - (window.initBaseUrls = function (topLevelNamespaces) { - for (let i in topLevelNamespaces) - {acequire.tlns[i] = topLevelNamespaces[i];} - }), - (window.initSender = function () { - let EventEmitter = window.acequire('ace/lib/event_emitter') - .EventEmitter, - oop = window.acequire('ace/lib/oop'), - Sender = function () {}; - return ( - function () { - oop.implement(this, EventEmitter), - (this.callback = function (data, callbackId) { - postMessage({ type: 'call', id: callbackId, data: data }); - }), - (this.emit = function (name, data) { - postMessage({ type: 'event', name: name, data: data }); - }); - }.call(Sender.prototype), - new Sender() - ); - }); - let main = (window.main = null), - sender = (window.sender = null); - window.onmessage = function (e) { - let msg = e.data; - if (msg.event && sender) sender._signal(msg.event, msg.data); - else if (msg.command) - {if (main[msg.command]) main[msg.command].apply(main, msg.args); - else { - if (!window[msg.command]) - throw Error('Unknown command:' + msg.command); - window[msg.command].apply(window, msg.args); - }} - else if (msg.init) { - window.initBaseUrls(msg.tlns), - acequire('ace/lib/es5-shim'), - (sender = window.sender = window.initSender()); - let clazz = acequire(msg.module)[msg.classname]; - main = window.main = new clazz(sender); - } - }; - } -} -init(this); -ace.define('ace/lib/oop', ['require', 'exports', 'module'], function ( - acequire, - exports -) { - (exports.inherits = function (ctor, superCtor) { - (ctor.super_ = superCtor), - (ctor.prototype = Object.create(superCtor.prototype, { - constructor: { - value: ctor, - enumerable: !1, - writable: !0, - configurable: !0, - }, - })); - }), - (exports.mixin = function (obj, mixin) { - for (let key in mixin) obj[key] = mixin[key]; - return obj; - }), - (exports.implement = function (proto, mixin) { - exports.mixin(proto, mixin); - }); -}), -ace.define('ace/range', ['require', 'exports', 'module'], function ( - acequire, - exports -) { - let comparePoints = function (p1, p2) { - return p1.row - p2.row || p1.column - p2.column; - }, - Range = function (startRow, startColumn, endRow, endColumn) { - (this.start = { row: startRow, column: startColumn }), - (this.end = { row: endRow, column: endColumn }); - }; - (function () { - (this.isEqual = function (range) { - return ( - this.start.row === range.start.row && - this.end.row === range.end.row && - this.start.column === range.start.column && - this.end.column === range.end.column - ); - }), - (this.toString = function () { - return ( - 'Range: [' + - this.start.row + - '/' + - this.start.column + - '] -> [' + - this.end.row + - '/' + - this.end.column + - ']' - ); - }), - (this.contains = function (row, column) { - return 0 == this.compare(row, column); - }), - (this.compareRange = function (range) { - let cmp, - end = range.end, - start = range.start; - return ( - (cmp = this.compare(end.row, end.column)), - 1 == cmp - ? ((cmp = this.compare(start.row, start.column)), - 1 == cmp ? 2 : 0 == cmp ? 1 : 0) - : -1 == cmp - ? -2 - : ((cmp = this.compare(start.row, start.column)), - -1 == cmp ? -1 : 1 == cmp ? 42 : 0) - ); - }), - (this.comparePoint = function (p) { - return this.compare(p.row, p.column); - }), - (this.containsRange = function (range) { - return ( - 0 == this.comparePoint(range.start) && - 0 == this.comparePoint(range.end) - ); - }), - (this.intersects = function (range) { - let cmp = this.compareRange(range); - return -1 == cmp || 0 == cmp || 1 == cmp; - }), - (this.isEnd = function (row, column) { - return this.end.row == row && this.end.column == column; - }), - (this.isStart = function (row, column) { - return this.start.row == row && this.start.column == column; - }), - (this.setStart = function (row, column) { - 'object' === typeof row - ? ((this.start.column = row.column), (this.start.row = row.row)) - : ((this.start.row = row), (this.start.column = column)); - }), - (this.setEnd = function (row, column) { - 'object' === typeof row - ? ((this.end.column = row.column), (this.end.row = row.row)) - : ((this.end.row = row), (this.end.column = column)); - }), - (this.inside = function (row, column) { - return 0 == this.compare(row, column) - ? this.isEnd(row, column) || this.isStart(row, column) - ? !1 - : !0 - : !1; - }), - (this.insideStart = function (row, column) { - return 0 == this.compare(row, column) - ? this.isEnd(row, column) - ? !1 - : !0 - : !1; - }), - (this.insideEnd = function (row, column) { - return 0 == this.compare(row, column) - ? this.isStart(row, column) - ? !1 - : !0 - : !1; - }), - (this.compare = function (row, column) { - return this.isMultiLine() || row !== this.start.row - ? this.start.row > row - ? -1 - : row > this.end.row - ? 1 - : this.start.row === row - ? column >= this.start.column - ? 0 - : -1 - : this.end.row === row - ? this.end.column >= column - ? 0 - : 1 - : 0 - : this.start.column > column - ? -1 - : column > this.end.column - ? 1 - : 0; - }), - (this.compareStart = function (row, column) { - return this.start.row == row && this.start.column == column - ? -1 - : this.compare(row, column); - }), - (this.compareEnd = function (row, column) { - return this.end.row == row && this.end.column == column - ? 1 - : this.compare(row, column); - }), - (this.compareInside = function (row, column) { - return this.end.row == row && this.end.column == column - ? 1 - : this.start.row == row && this.start.column == column - ? -1 - : this.compare(row, column); - }), - (this.clipRows = function (firstRow, lastRow) { - if (this.end.row > lastRow) var end = { row: lastRow + 1, column: 0 }; - else if (firstRow > this.end.row) - {var end = { row: firstRow, column: 0 };} - if (this.start.row > lastRow) - {var start = { row: lastRow + 1, column: 0 };} - else if (firstRow > this.start.row) - {var start = { row: firstRow, column: 0 };} - return Range.fromPoints(start || this.start, end || this.end); - }), - (this.extend = function (row, column) { - let cmp = this.compare(row, column); - if (0 == cmp) return this; - if (-1 == cmp) var start = { row: row, column: column }; - else var end = { row: row, column: column }; - return Range.fromPoints(start || this.start, end || this.end); - }), - (this.isEmpty = function () { - return ( - this.start.row === this.end.row && - this.start.column === this.end.column - ); - }), - (this.isMultiLine = function () { - return this.start.row !== this.end.row; - }), - (this.clone = function () { - return Range.fromPoints(this.start, this.end); - }), - (this.collapseRows = function () { - return 0 == this.end.column - ? new Range( - this.start.row, - 0, - Math.max(this.start.row, this.end.row - 1), - 0 - ) - : new Range(this.start.row, 0, this.end.row, 0); - }), - (this.toScreenRange = function (session) { - let screenPosStart = session.documentToScreenPosition(this.start), - screenPosEnd = session.documentToScreenPosition(this.end); - return new Range( - screenPosStart.row, - screenPosStart.column, - screenPosEnd.row, - screenPosEnd.column - ); - }), - (this.moveBy = function (row, column) { - (this.start.row += row), - (this.start.column += column), - (this.end.row += row), - (this.end.column += column); - }); - }.call(Range.prototype), - (Range.fromPoints = function (start, end) { - return new Range(start.row, start.column, end.row, end.column); - }), - (Range.comparePoints = comparePoints), - (Range.comparePoints = function (p1, p2) { - return p1.row - p2.row || p1.column - p2.column; - }), - (exports.Range = Range)); -}), -ace.define('ace/apply_delta', ['require', 'exports', 'module'], function ( - acequire, - exports -) { - exports.applyDelta = function (docLines, delta) { - let row = delta.start.row, - startColumn = delta.start.column, - line = docLines[row] || ''; - switch (delta.action) { - case 'insert': - var lines = delta.lines; - if (1 === lines.length) - {docLines[row] = - line.substring(0, startColumn) + - delta.lines[0] + - line.substring(startColumn);} - else { - let args = [row, 1].concat(delta.lines); - docLines.splice.apply(docLines, args), - (docLines[row] = line.substring(0, startColumn) + docLines[row]), - (docLines[row + delta.lines.length - 1] += line.substring( - startColumn - )); - } - break; - case 'remove': - var endColumn = delta.end.column, - endRow = delta.end.row; - row === endRow - ? (docLines[row] = - line.substring(0, startColumn) + line.substring(endColumn)) - : docLines.splice( - row, - endRow - row + 1, - line.substring(0, startColumn) + - docLines[endRow].substring(endColumn) - ); - } - }; -}), -ace.define( - 'ace/lib/event_emitter', - ['require', 'exports', 'module'], - function (acequire, exports) { - let EventEmitter = {}, - stopPropagation = function () { - this.propagationStopped = !0; - }, - preventDefault = function () { - this.defaultPrevented = !0; - }; - (EventEmitter._emit = EventEmitter._dispatchEvent = function ( - eventName, - e - ) { - this._eventRegistry || (this._eventRegistry = {}), - this._defaultHandlers || (this._defaultHandlers = {}); - let listeners = this._eventRegistry[eventName] || [], - defaultHandler = this._defaultHandlers[eventName]; - if (listeners.length || defaultHandler) { - ('object' === typeof e && e) || (e = {}), - e.type || (e.type = eventName), - e.stopPropagation || (e.stopPropagation = stopPropagation), - e.preventDefault || (e.preventDefault = preventDefault), - (listeners = listeners.slice()); - for ( - let i = 0; - listeners.length > i && - (listeners[i](e, this), !e.propagationStopped); - i++ - ); - return defaultHandler && !e.defaultPrevented - ? defaultHandler(e, this) - : void 0; - } - }), - (EventEmitter._signal = function (eventName, e) { - let listeners = (this._eventRegistry || {})[eventName]; - if (listeners) { - listeners = listeners.slice(); - for (let i = 0; listeners.length > i; i++) listeners[i](e, this); - } - }), - (EventEmitter.once = function (eventName, callback) { - let _self = this; - callback && - this.addEventListener(eventName, function newCallback() { - _self.removeEventListener(eventName, newCallback), - callback.apply(null, arguments); - }); - }), - (EventEmitter.setDefaultHandler = function (eventName, callback) { - let handlers = this._defaultHandlers; - if ( - (handlers || - (handlers = this._defaultHandlers = { _disabled_: {} }), - handlers[eventName]) - ) { - let old = handlers[eventName], - disabled = handlers._disabled_[eventName]; - disabled || (handlers._disabled_[eventName] = disabled = []), - disabled.push(old); - let i = disabled.indexOf(callback); - -1 != i && disabled.splice(i, 1); - } - handlers[eventName] = callback; - }), - (EventEmitter.removeDefaultHandler = function (eventName, callback) { - let handlers = this._defaultHandlers; - if (handlers) { - let disabled = handlers._disabled_[eventName]; - if (handlers[eventName] == callback) - {handlers[eventName], - disabled && this.setDefaultHandler(eventName, disabled.pop());} - else if (disabled) { - let i = disabled.indexOf(callback); - -1 != i && disabled.splice(i, 1); - } - } - }), - (EventEmitter.on = EventEmitter.addEventListener = function ( - eventName, - callback, - capturing - ) { - this._eventRegistry = this._eventRegistry || {}; - let listeners = this._eventRegistry[eventName]; - return ( - listeners || (listeners = this._eventRegistry[eventName] = []), - -1 == listeners.indexOf(callback) && - listeners[capturing ? 'unshift' : 'push'](callback), - callback - ); - }), - (EventEmitter.off = EventEmitter.removeListener = EventEmitter.removeEventListener = function ( - eventName, - callback - ) { - this._eventRegistry = this._eventRegistry || {}; - let listeners = this._eventRegistry[eventName]; - if (listeners) { - let index = listeners.indexOf(callback); - -1 !== index && listeners.splice(index, 1); - } - }), - (EventEmitter.removeAllListeners = function (eventName) { - this._eventRegistry && (this._eventRegistry[eventName] = []); - }), - (exports.EventEmitter = EventEmitter); - } -), -ace.define( - 'ace/anchor', - ['require', 'exports', 'module', 'ace/lib/oop', 'ace/lib/event_emitter'], - function (acequire, exports) { - let oop = acequire('./lib/oop'), - EventEmitter = acequire('./lib/event_emitter').EventEmitter, - Anchor = (exports.Anchor = function (doc, row, column) { - (this.$onChange = this.onChange.bind(this)), - this.attach(doc), - column === void 0 - ? this.setPosition(row.row, row.column) - : this.setPosition(row, column); - }); - (function () { - function $pointsInOrder(point1, point2, equalPointsInOrder) { - let bColIsAfter = equalPointsInOrder - ? point1.column <= point2.column - : point1.column < point2.column; - return ( - point1.row < point2.row || (point1.row == point2.row && bColIsAfter) - ); - } - function $getTransformedPoint(delta, point, moveIfEqual) { - let deltaIsInsert = 'insert' == delta.action, - deltaRowShift = - (deltaIsInsert ? 1 : -1) * (delta.end.row - delta.start.row), - deltaColShift = - (deltaIsInsert ? 1 : -1) * - (delta.end.column - delta.start.column), - deltaStart = delta.start, - deltaEnd = deltaIsInsert ? deltaStart : delta.end; - return $pointsInOrder(point, deltaStart, moveIfEqual) - ? { row: point.row, column: point.column } - : $pointsInOrder(deltaEnd, point, !moveIfEqual) - ? { - row: point.row + deltaRowShift, - column: - point.column + - (point.row == deltaEnd.row ? deltaColShift : 0), - } - : { row: deltaStart.row, column: deltaStart.column }; - } - oop.implement(this, EventEmitter), - (this.getPosition = function () { - return this.$clipPositionToDocument(this.row, this.column); - }), - (this.getDocument = function () { - return this.document; - }), - (this.$insertRight = !1), - (this.onChange = function (delta) { - if ( - !( - (delta.start.row == delta.end.row && - delta.start.row != this.row) || - delta.start.row > this.row - ) - ) { - let point = $getTransformedPoint( - delta, - { row: this.row, column: this.column }, - this.$insertRight - ); - this.setPosition(point.row, point.column, !0); - } - }), - (this.setPosition = function (row, column, noClip) { - let pos; - if ( - ((pos = noClip - ? { row: row, column: column } - : this.$clipPositionToDocument(row, column)), - this.row != pos.row || this.column != pos.column) - ) { - let old = { row: this.row, column: this.column }; - (this.row = pos.row), - (this.column = pos.column), - this._signal('change', { old: old, value: pos }); - } - }), - (this.detach = function () { - this.document.removeEventListener('change', this.$onChange); - }), - (this.attach = function (doc) { - (this.document = doc || this.document), - this.document.on('change', this.$onChange); - }), - (this.$clipPositionToDocument = function (row, column) { - let pos = {}; - return ( - row >= this.document.getLength() - ? ((pos.row = Math.max(0, this.document.getLength() - 1)), - (pos.column = this.document.getLine(pos.row).length)) - : 0 > row - ? ((pos.row = 0), (pos.column = 0)) - : ((pos.row = row), - (pos.column = Math.min( - this.document.getLine(pos.row).length, - Math.max(0, column) - ))), - 0 > column && (pos.column = 0), - pos - ); - }); - }.call(Anchor.prototype)); - } -), -ace.define( - 'ace/document', - [ - 'require', - 'exports', - 'module', - 'ace/lib/oop', - 'ace/apply_delta', - 'ace/lib/event_emitter', - 'ace/range', - 'ace/anchor', - ], - function (acequire, exports) { - let oop = acequire('./lib/oop'), - applyDelta = acequire('./apply_delta').applyDelta, - EventEmitter = acequire('./lib/event_emitter').EventEmitter, - Range = acequire('./range').Range, - Anchor = acequire('./anchor').Anchor, - Document = function (textOrLines) { - (this.$lines = ['']), - 0 === textOrLines.length - ? (this.$lines = ['']) - : Array.isArray(textOrLines) - ? this.insertMergedLines({ row: 0, column: 0 }, textOrLines) - : this.insert({ row: 0, column: 0 }, textOrLines); - }; - (function () { - oop.implement(this, EventEmitter), - (this.setValue = function (text) { - let len = this.getLength() - 1; - this.remove(new Range(0, 0, len, this.getLine(len).length)), - this.insert({ row: 0, column: 0 }, text); - }), - (this.getValue = function () { - return this.getAllLines().join(this.getNewLineCharacter()); - }), - (this.createAnchor = function (row, column) { - return new Anchor(this, row, column); - }), - (this.$split = - 0 === 'aaa'.split(/a/).length - ? function (text) { - return text.replace(/\r\n|\r/g, '\n').split('\n'); - } - : function (text) { - return text.split(/\r\n|\r|\n/); - }), - (this.$detectNewLine = function (text) { - let match = text.match(/^.*?(\r\n|\r|\n)/m); - (this.$autoNewLine = match ? match[1] : '\n'), - this._signal('changeNewLineMode'); - }), - (this.getNewLineCharacter = function () { - switch (this.$newLineMode) { - case 'windows': - return '\r\n'; - case 'unix': - return '\n'; - default: - return this.$autoNewLine || '\n'; - } - }), - (this.$autoNewLine = ''), - (this.$newLineMode = 'auto'), - (this.setNewLineMode = function (newLineMode) { - this.$newLineMode !== newLineMode && - ((this.$newLineMode = newLineMode), - this._signal('changeNewLineMode')); - }), - (this.getNewLineMode = function () { - return this.$newLineMode; - }), - (this.isNewLine = function (text) { - return '\r\n' == text || '\r' == text || '\n' == text; - }), - (this.getLine = function (row) { - return this.$lines[row] || ''; - }), - (this.getLines = function (firstRow, lastRow) { - return this.$lines.slice(firstRow, lastRow + 1); - }), - (this.getAllLines = function () { - return this.getLines(0, this.getLength()); - }), - (this.getLength = function () { - return this.$lines.length; - }), - (this.getTextRange = function (range) { - return this.getLinesForRange(range).join( - this.getNewLineCharacter() - ); - }), - (this.getLinesForRange = function (range) { - let lines; - if (range.start.row === range.end.row) - {lines = [ - this.getLine(range.start.row).substring( - range.start.column, - range.end.column - ), - ];} - else { - (lines = this.getLines(range.start.row, range.end.row)), - (lines[0] = (lines[0] || '').substring(range.start.column)); - let l = lines.length - 1; - range.end.row - range.start.row == l && - (lines[l] = lines[l].substring(0, range.end.column)); - } - return lines; - }), - (this.insertLines = function (row, lines) { - return ( - console.warn( - 'Use of document.insertLines is deprecated. Use the insertFullLines method instead.' - ), - this.insertFullLines(row, lines) - ); - }), - (this.removeLines = function (firstRow, lastRow) { - return ( - console.warn( - 'Use of document.removeLines is deprecated. Use the removeFullLines method instead.' - ), - this.removeFullLines(firstRow, lastRow) - ); - }), - (this.insertNewLine = function (position) { - return ( - console.warn( - 'Use of document.insertNewLine is deprecated. Use insertMergedLines(position, [\'\', \'\']) instead.' - ), - this.insertMergedLines(position, ['', '']) - ); - }), - (this.insert = function (position, text) { - return ( - 1 >= this.getLength() && this.$detectNewLine(text), - this.insertMergedLines(position, this.$split(text)) - ); - }), - (this.insertInLine = function (position, text) { - let start = this.clippedPos(position.row, position.column), - end = this.pos(position.row, position.column + text.length); - return ( - this.applyDelta( - { start: start, end: end, action: 'insert', lines: [text] }, - !0 - ), - this.clonePos(end) - ); - }), - (this.clippedPos = function (row, column) { - let length = this.getLength(); - void 0 === row - ? (row = length) - : 0 > row - ? (row = 0) - : row >= length && ((row = length - 1), (column = void 0)); - let line = this.getLine(row); - return ( - void 0 == column && (column = line.length), - (column = Math.min(Math.max(column, 0), line.length)), - { row: row, column: column } - ); - }), - (this.clonePos = function (pos) { - return { row: pos.row, column: pos.column }; - }), - (this.pos = function (row, column) { - return { row: row, column: column }; - }), - (this.$clipPosition = function (position) { - let length = this.getLength(); - return ( - position.row >= length - ? ((position.row = Math.max(0, length - 1)), - (position.column = this.getLine(length - 1).length)) - : ((position.row = Math.max(0, position.row)), - (position.column = Math.min( - Math.max(position.column, 0), - this.getLine(position.row).length - ))), - position - ); - }), - (this.insertFullLines = function (row, lines) { - row = Math.min(Math.max(row, 0), this.getLength()); - let column = 0; - this.getLength() > row - ? ((lines = lines.concat([''])), (column = 0)) - : ((lines = [''].concat(lines)), - row--, - (column = this.$lines[row].length)), - this.insertMergedLines({ row: row, column: column }, lines); - }), - (this.insertMergedLines = function (position, lines) { - let start = this.clippedPos(position.row, position.column), - end = { - row: start.row + lines.length - 1, - column: - (1 == lines.length ? start.column : 0) + - lines[lines.length - 1].length, - }; - return ( - this.applyDelta({ - start: start, - end: end, - action: 'insert', - lines: lines, - }), - this.clonePos(end) - ); - }), - (this.remove = function (range) { - let start = this.clippedPos(range.start.row, range.start.column), - end = this.clippedPos(range.end.row, range.end.column); - return ( - this.applyDelta({ - start: start, - end: end, - action: 'remove', - lines: this.getLinesForRange({ start: start, end: end }), - }), - this.clonePos(start) - ); - }), - (this.removeInLine = function (row, startColumn, endColumn) { - let start = this.clippedPos(row, startColumn), - end = this.clippedPos(row, endColumn); - return ( - this.applyDelta( - { - start: start, - end: end, - action: 'remove', - lines: this.getLinesForRange({ start: start, end: end }), - }, - !0 - ), - this.clonePos(start) - ); - }), - (this.removeFullLines = function (firstRow, lastRow) { - (firstRow = Math.min(Math.max(0, firstRow), this.getLength() - 1)), - (lastRow = Math.min(Math.max(0, lastRow), this.getLength() - 1)); - let deleteFirstNewLine = - lastRow == this.getLength() - 1 && firstRow > 0, - deleteLastNewLine = this.getLength() - 1 > lastRow, - startRow = deleteFirstNewLine ? firstRow - 1 : firstRow, - startCol = deleteFirstNewLine ? this.getLine(startRow).length : 0, - endRow = deleteLastNewLine ? lastRow + 1 : lastRow, - endCol = deleteLastNewLine ? 0 : this.getLine(endRow).length, - range = new Range(startRow, startCol, endRow, endCol), - deletedLines = this.$lines.slice(firstRow, lastRow + 1); - return ( - this.applyDelta({ - start: range.start, - end: range.end, - action: 'remove', - lines: this.getLinesForRange(range), - }), - deletedLines - ); - }), - (this.removeNewLine = function (row) { - this.getLength() - 1 > row && - row >= 0 && - this.applyDelta({ - start: this.pos(row, this.getLine(row).length), - end: this.pos(row + 1, 0), - action: 'remove', - lines: ['', ''], - }); - }), - (this.replace = function (range, text) { - if ( - (range instanceof Range || - (range = Range.fromPoints(range.start, range.end)), - 0 === text.length && range.isEmpty()) - ) - {return range.start;} - if (text == this.getTextRange(range)) return range.end; - this.remove(range); - let end; - return (end = text ? this.insert(range.start, text) : range.start); - }), - (this.applyDeltas = function (deltas) { - for (let i = 0; deltas.length > i; i++) this.applyDelta(deltas[i]); - }), - (this.revertDeltas = function (deltas) { - for (let i = deltas.length - 1; i >= 0; i--) - {this.revertDelta(deltas[i]);} - }), - (this.applyDelta = function (delta, doNotValidate) { - let isInsert = 'insert' == delta.action; - (isInsert - ? 1 >= delta.lines.length && !delta.lines[0] - : !Range.comparePoints(delta.start, delta.end)) || - (isInsert && - delta.lines.length > 2e4 && - this.$splitAndapplyLargeDelta(delta, 2e4), - applyDelta(this.$lines, delta, doNotValidate), - this._signal('change', delta)); - }), - (this.$splitAndapplyLargeDelta = function (delta, MAX) { - for ( - let lines = delta.lines, - l = lines.length, - row = delta.start.row, - column = delta.start.column, - from = 0, - to = 0; - ; - - ) { - (from = to), (to += MAX - 1); - let chunk = lines.slice(from, to); - if (to > l) { - (delta.lines = chunk), - (delta.start.row = row + from), - (delta.start.column = column); - break; - } - chunk.push(''), - this.applyDelta( - { - start: this.pos(row + from, column), - end: this.pos(row + to, (column = 0)), - action: delta.action, - lines: chunk, - }, - !0 - ); - } - }), - (this.revertDelta = function (delta) { - this.applyDelta({ - start: this.clonePos(delta.start), - end: this.clonePos(delta.end), - action: 'insert' == delta.action ? 'remove' : 'insert', - lines: delta.lines.slice(), - }); - }), - (this.indexToPosition = function (index, startRow) { - for ( - var lines = this.$lines || this.getAllLines(), - newlineLength = this.getNewLineCharacter().length, - i = startRow || 0, - l = lines.length; - l > i; - i++ - ) - {if (((index -= lines[i].length + newlineLength), 0 > index)) - return { - row: i, - column: index + lines[i].length + newlineLength, - };} - return { row: l - 1, column: lines[l - 1].length }; - }), - (this.positionToIndex = function (pos, startRow) { - for ( - var lines = this.$lines || this.getAllLines(), - newlineLength = this.getNewLineCharacter().length, - index = 0, - row = Math.min(pos.row, lines.length), - i = startRow || 0; - row > i; - ++i - ) - {index += lines[i].length + newlineLength;} - return index + pos.column; - }); - }.call(Document.prototype), - (exports.Document = Document)); - } -), -ace.define('ace/lib/lang', ['require', 'exports', 'module'], function ( - acequire, - exports -) { - (exports.last = function (a) { - return a[a.length - 1]; - }), - (exports.stringReverse = function (string) { - return string - .split('') - .reverse() - .join(''); - }), - (exports.stringRepeat = function (string, count) { - for (var result = ''; count > 0;) - {1 & count && (result += string), (count >>= 1) && (string += string);} - return result; - }); - let trimBeginRegexp = /^\s\s*/, - trimEndRegexp = /\s\s*$/; - (exports.stringTrimLeft = function (string) { - return string.replace(trimBeginRegexp, ''); - }), - (exports.stringTrimRight = function (string) { - return string.replace(trimEndRegexp, ''); - }), - (exports.copyObject = function (obj) { - let copy = {}; - for (let key in obj) copy[key] = obj[key]; - return copy; - }), - (exports.copyArray = function (array) { - for (var copy = [], i = 0, l = array.length; l > i; i++) - {copy[i] = - array[i] && 'object' == typeof array[i] - ? this.copyObject(array[i]) - : array[i];} - return copy; - }), - (exports.deepCopy = function deepCopy(obj) { - if ('object' !== typeof obj || !obj) return obj; - let copy; - if (Array.isArray(obj)) { - copy = []; - for (var key = 0; obj.length > key; key++) - {copy[key] = deepCopy(obj[key]);} - return copy; - } - if ('[object Object]' !== Object.prototype.toString.call(obj)) - {return obj;} - copy = {}; - for (var key in obj) copy[key] = deepCopy(obj[key]); - return copy; - }), - (exports.arrayToMap = function (arr) { - for (var map = {}, i = 0; arr.length > i; i++) map[arr[i]] = 1; - return map; - }), - (exports.createMap = function (props) { - let map = Object.create(null); - for (let i in props) map[i] = props[i]; - return map; - }), - (exports.arrayRemove = function (array, value) { - for (let i = 0; array.length >= i; i++) - {value === array[i] && array.splice(i, 1);} - }), - (exports.escapeRegExp = function (str) { - return str.replace(/([.*+?^${}()|[\]\/\\])/g, '\\$1'); - }), - (exports.escapeHTML = function (str) { - return str - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(/'/g, ''') - .replace(/ i; i += 2) { - if (Array.isArray(data[i + 1])) - var d = { - action: 'insert', - start: data[i], - lines: data[i + 1], - }; - else - var d = { - action: 'remove', - start: data[i], - end: data[i + 1], - }; - doc.applyDelta(d, !0); - }} - return _self.$timeout - ? deferredUpdate.schedule(_self.$timeout) - : (_self.onUpdate(), void 0); - }); - }); - (function () { - (this.$timeout = 500), - (this.setTimeout = function (timeout) { - this.$timeout = timeout; - }), - (this.setValue = function (value) { - this.doc.setValue(value), - this.deferredUpdate.schedule(this.$timeout); - }), - (this.getValue = function (callbackId) { - this.sender.callback(this.doc.getValue(), callbackId); - }), - (this.onUpdate = function () {}), - (this.isPending = function () { - return this.deferredUpdate.isPending(); - }); - }.call(Mirror.prototype)); - } -), -ace.define('ace/lib/es5-shim', ['require', 'exports', 'module'], function () { - function Empty() {} - function doesDefinePropertyWork(object) { - try { - return ( - Object.defineProperty(object, 'sentinel', {}), 'sentinel' in object - ); - } catch (exception) {} - } - function toInteger(n) { - return ( - (n = +n), - n !== n - ? (n = 0) - : 0 !== n && - n !== 1 / 0 && - n !== -(1 / 0) && - (n = (n > 0 || -1) * Math.floor(Math.abs(n))), - n - ); - } - Function.prototype.bind || - (Function.prototype.bind = function (that) { - let target = this; - if ('function' !== typeof target) - {throw new TypeError( - 'Function.prototype.bind called on incompatible ' + target - );} - var args = slice.call(arguments, 1), - bound = function () { - if (this instanceof bound) { - let result = target.apply( - this, - args.concat(slice.call(arguments)) - ); - return Object(result) === result ? result : this; - } - return target.apply(that, args.concat(slice.call(arguments))); - }; - return ( - target.prototype && - ((Empty.prototype = target.prototype), - (bound.prototype = new Empty()), - (Empty.prototype = null)), - bound - ); - }); - var defineGetter, - defineSetter, - lookupGetter, - lookupSetter, - supportsAccessors, - call = Function.prototype.call, - prototypeOfArray = Array.prototype, - prototypeOfObject = Object.prototype, - slice = prototypeOfArray.slice, - _toString = call.bind(prototypeOfObject.toString), - owns = call.bind(prototypeOfObject.hasOwnProperty); - if ( - ((supportsAccessors = owns(prototypeOfObject, '__defineGetter__')) && - ((defineGetter = call.bind(prototypeOfObject.__defineGetter__)), - (defineSetter = call.bind(prototypeOfObject.__defineSetter__)), - (lookupGetter = call.bind(prototypeOfObject.__lookupGetter__)), - (lookupSetter = call.bind(prototypeOfObject.__lookupSetter__))), - 2 != [1, 2].splice(0).length) - ) - {if ( - (function() { - function makeArray(l) { - var a = Array(l + 2); - return (a[0] = a[1] = 0), a; - } - var lengthBefore, - array = []; - return ( - array.splice.apply(array, makeArray(20)), - array.splice.apply(array, makeArray(26)), - (lengthBefore = array.length), - array.splice(5, 0, 'XXX'), - lengthBefore + 1 == array.length, - lengthBefore + 1 == array.length ? !0 : void 0 - ); - })() - ) { - var array_splice = Array.prototype.splice; - Array.prototype.splice = function(start, deleteCount) { - return arguments.length - ? array_splice.apply( - this, - [ - void 0 === start ? 0 : start, - void 0 === deleteCount ? this.length - start : deleteCount, - ].concat(slice.call(arguments, 2)) - ) - : []; - }; - } else - Array.prototype.splice = function(pos, removeCount) { - var length = this.length; - pos > 0 - ? pos > length && (pos = length) - : void 0 == pos - ? (pos = 0) - : 0 > pos && (pos = Math.max(length + pos, 0)), - length > pos + removeCount || (removeCount = length - pos); - var removed = this.slice(pos, pos + removeCount), - insert = slice.call(arguments, 2), - add = insert.length; - if (pos === length) add && this.push.apply(this, insert); - else { - var remove = Math.min(removeCount, length - pos), - tailOldPos = pos + remove, - tailNewPos = tailOldPos + add - remove, - tailCount = length - tailOldPos, - lengthAfterRemove = length - remove; - if (tailOldPos > tailNewPos) - for (var i = 0; tailCount > i; ++i) - this[tailNewPos + i] = this[tailOldPos + i]; - else if (tailNewPos > tailOldPos) - for (i = tailCount; i--; ) - this[tailNewPos + i] = this[tailOldPos + i]; - if (add && pos === lengthAfterRemove) - (this.length = lengthAfterRemove), this.push.apply(this, insert); - else - for (this.length = lengthAfterRemove + add, i = 0; add > i; ++i) - this[pos + i] = insert[i]; - } - return removed; - };} - Array.isArray || - (Array.isArray = function (obj) { - return '[object Array]' == _toString(obj); - }); - let boxedString = Object('a'), - splitString = 'a' != boxedString[0] || !(0 in boxedString); - if ( - (Array.prototype.forEach || - (Array.prototype.forEach = function (fun) { - let object = toObject(this), - self = - splitString && '[object String]' == _toString(this) - ? this.split('') - : object, - thisp = arguments[1], - i = -1, - length = self.length >>> 0; - if ('[object Function]' != _toString(fun)) throw new TypeError(); - for (; length > ++i;) - {i in self && fun.call(thisp, self[i], i, object);} - }), - Array.prototype.map || - (Array.prototype.map = function (fun) { - let object = toObject(this), - self = - splitString && '[object String]' == _toString(this) - ? this.split('') - : object, - length = self.length >>> 0, - result = Array(length), - thisp = arguments[1]; - if ('[object Function]' != _toString(fun)) - {throw new TypeError(fun + ' is not a function');} - for (let i = 0; length > i; i++) - {i in self && (result[i] = fun.call(thisp, self[i], i, object));} - return result; - }), - Array.prototype.filter || - (Array.prototype.filter = function (fun) { - let value, - object = toObject(this), - self = - splitString && '[object String]' == _toString(this) - ? this.split('') - : object, - length = self.length >>> 0, - result = [], - thisp = arguments[1]; - if ('[object Function]' != _toString(fun)) - {throw new TypeError(fun + ' is not a function');} - for (let i = 0; length > i; i++) - {i in self && - ((value = self[i]), - fun.call(thisp, value, i, object) && result.push(value));} - return result; - }), - Array.prototype.every || - (Array.prototype.every = function (fun) { - let object = toObject(this), - self = - splitString && '[object String]' == _toString(this) - ? this.split('') - : object, - length = self.length >>> 0, - thisp = arguments[1]; - if ('[object Function]' != _toString(fun)) - {throw new TypeError(fun + ' is not a function');} - for (let i = 0; length > i; i++) - {if (i in self && !fun.call(thisp, self[i], i, object)) return !1;} - return !0; - }), - Array.prototype.some || - (Array.prototype.some = function (fun) { - let object = toObject(this), - self = - splitString && '[object String]' == _toString(this) - ? this.split('') - : object, - length = self.length >>> 0, - thisp = arguments[1]; - if ('[object Function]' != _toString(fun)) - {throw new TypeError(fun + ' is not a function');} - for (let i = 0; length > i; i++) - {if (i in self && fun.call(thisp, self[i], i, object)) return !0;} - return !1; - }), - Array.prototype.reduce || - (Array.prototype.reduce = function (fun) { - let object = toObject(this), - self = - splitString && '[object String]' == _toString(this) - ? this.split('') - : object, - length = self.length >>> 0; - if ('[object Function]' != _toString(fun)) - {throw new TypeError(fun + ' is not a function');} - if (!length && 1 == arguments.length) - {throw new TypeError('reduce of empty array with no initial value');} - let result, - i = 0; - if (arguments.length >= 2) result = arguments[1]; - else - {for (;;) { - if (i in self) { - result = self[i++]; - break; - } - if (++i >= length) - throw new TypeError( - 'reduce of empty array with no initial value' - ); - }} - for (; length > i; i++) - {i in self && - (result = fun.call(void 0, result, self[i], i, object));} - return result; - }), - Array.prototype.reduceRight || - (Array.prototype.reduceRight = function (fun) { - let object = toObject(this), - self = - splitString && '[object String]' == _toString(this) - ? this.split('') - : object, - length = self.length >>> 0; - if ('[object Function]' != _toString(fun)) - {throw new TypeError(fun + ' is not a function');} - if (!length && 1 == arguments.length) - {throw new TypeError( - 'reduceRight of empty array with no initial value' - );} - let result, - i = length - 1; - if (arguments.length >= 2) result = arguments[1]; - else - {for (;;) { - if (i in self) { - result = self[i--]; - break; - } - if (0 > --i) - throw new TypeError( - 'reduceRight of empty array with no initial value' - ); - }} - do - {i in this && - (result = fun.call(void 0, result, self[i], i, object));} - while (i--); - return result; - }), - (Array.prototype.indexOf && -1 == [0, 1].indexOf(1, 2)) || - (Array.prototype.indexOf = function (sought) { - let self = - splitString && '[object String]' == _toString(this) - ? this.split('') - : toObject(this), - length = self.length >>> 0; - if (!length) return -1; - let i = 0; - for ( - arguments.length > 1 && (i = toInteger(arguments[1])), - i = i >= 0 ? i : Math.max(0, length + i); - length > i; - i++ - ) - {if (i in self && self[i] === sought) return i;} - return -1; - }), - (Array.prototype.lastIndexOf && -1 == [0, 1].lastIndexOf(0, -3)) || - (Array.prototype.lastIndexOf = function (sought) { - let self = - splitString && '[object String]' == _toString(this) - ? this.split('') - : toObject(this), - length = self.length >>> 0; - if (!length) return -1; - let i = length - 1; - for ( - arguments.length > 1 && (i = Math.min(i, toInteger(arguments[1]))), - i = i >= 0 ? i : length - Math.abs(i); - i >= 0; - i-- - ) - {if (i in self && sought === self[i]) return i;} - return -1; - }), - Object.getPrototypeOf || - (Object.getPrototypeOf = function (object) { - return ( - object.__proto__ || - (object.constructor - ? object.constructor.prototype - : prototypeOfObject) - ); - }), - !Object.getOwnPropertyDescriptor) - ) { - let ERR_NON_OBJECT = - 'Object.getOwnPropertyDescriptor called on a non-object: '; - Object.getOwnPropertyDescriptor = function (object, property) { - if ( - ('object' !== typeof object && 'function' !== typeof object) || - null === object - ) - {throw new TypeError(ERR_NON_OBJECT + object);} - if (owns(object, property)) { - var descriptor, getter, setter; - if ( - ((descriptor = { enumerable: !0, configurable: !0 }), - supportsAccessors) - ) { - let prototype = object.__proto__; - object.__proto__ = prototypeOfObject; - var getter = lookupGetter(object, property), - setter = lookupSetter(object, property); - if (((object.__proto__ = prototype), getter || setter)) - {return ( - getter && (descriptor.get = getter), - setter && (descriptor.set = setter), - descriptor - );} - } - return (descriptor.value = object[property]), descriptor; - } - }; - } - if ( - (Object.getOwnPropertyNames || - (Object.getOwnPropertyNames = function (object) { - return Object.keys(object); - }), - !Object.create) - ) { - let createEmpty; - (createEmpty = - null === Object.prototype.__proto__ - ? function () { - return { __proto__: null }; - } - : function () { - let empty = {}; - for (let i in empty) empty[i] = null; - return ( - (empty.constructor = empty.hasOwnProperty = empty.propertyIsEnumerable = empty.isPrototypeOf = empty.toLocaleString = empty.toString = empty.valueOf = empty.__proto__ = null), - empty - ); - }), - (Object.create = function (prototype, properties) { - let object; - if (null === prototype) object = createEmpty(); - else { - if ('object' !== typeof prototype) - {throw new TypeError( - 'typeof prototype[' + typeof prototype + "] != 'object'" - );} - let Type = function () {}; - (Type.prototype = prototype), - (object = new Type()), - (object.__proto__ = prototype); - } - return ( - void 0 !== properties && - Object.defineProperties(object, properties), - object - ); - }); - } - if (Object.defineProperty) { - let definePropertyWorksOnObject = doesDefinePropertyWork({}), - definePropertyWorksOnDom = - 'undefined' === typeof document || - doesDefinePropertyWork(document.createElement('div')); - if (!definePropertyWorksOnObject || !definePropertyWorksOnDom) - {var definePropertyFallback = Object.defineProperty;} - } - if (!Object.defineProperty || definePropertyFallback) { - let ERR_NON_OBJECT_DESCRIPTOR = - 'Property description must be an object: ', - ERR_NON_OBJECT_TARGET = 'Object.defineProperty called on non-object: ', - ERR_ACCESSORS_NOT_SUPPORTED = - 'getters & setters can not be defined on this javascript engine'; - Object.defineProperty = function (object, property, descriptor) { - if ( - ('object' !== typeof object && 'function' !== typeof object) || - null === object - ) - {throw new TypeError(ERR_NON_OBJECT_TARGET + object);} - if ( - ('object' !== typeof descriptor && 'function' !== typeof descriptor) || - null === descriptor - ) - {throw new TypeError(ERR_NON_OBJECT_DESCRIPTOR + descriptor);} - if (definePropertyFallback) - {try { - return definePropertyFallback.call( - Object, - object, - property, - descriptor - ); - } catch (exception) {}} - if (owns(descriptor, 'value')) - {if ( - supportsAccessors && - (lookupGetter(object, property) || lookupSetter(object, property)) - ) { - var prototype = object.__proto__; - (object.__proto__ = prototypeOfObject), - delete object[property], - (object[property] = descriptor.value), - (object.__proto__ = prototype); - } else object[property] = descriptor.value;} - else { - if (!supportsAccessors) - {throw new TypeError(ERR_ACCESSORS_NOT_SUPPORTED);} - owns(descriptor, 'get') && - defineGetter(object, property, descriptor.get), - owns(descriptor, 'set') && - defineSetter(object, property, descriptor.set); - } - return object; - }; - } - Object.defineProperties || - (Object.defineProperties = function (object, properties) { - for (let property in properties) - {owns(properties, property) && - Object.defineProperty(object, property, properties[property]);} - return object; - }), - Object.seal || - (Object.seal = function (object) { - return object; - }), - Object.freeze || - (Object.freeze = function (object) { - return object; - }); - try { - Object.freeze(function () {}); - } catch (exception) { - Object.freeze = (function (freezeObject) { - return function (object) { - return 'function' === typeof object ? object : freezeObject(object); - }; - }(Object.freeze)); - } - if ( - (Object.preventExtensions || - (Object.preventExtensions = function (object) { - return object; - }), - Object.isSealed || - (Object.isSealed = function () { - return !1; - }), - Object.isFrozen || - (Object.isFrozen = function () { - return !1; - }), - Object.isExtensible || - (Object.isExtensible = function (object) { - if (Object(object) === object) throw new TypeError(); - for (var name = ''; owns(object, name);) name += '?'; - object[name] = !0; - let returnValue = owns(object, name); - return delete object[name], returnValue; - }), - !Object.keys) - ) { - let hasDontEnumBug = !0, - dontEnums = [ - 'toString', - 'toLocaleString', - 'valueOf', - 'hasOwnProperty', - 'isPrototypeOf', - 'propertyIsEnumerable', - 'constructor', - ], - dontEnumsLength = dontEnums.length; - for (let key in { toString: null }) hasDontEnumBug = !1; - Object.keys = function (object) { - if ( - ('object' !== typeof object && 'function' !== typeof object) || - null === object - ) - {throw new TypeError('Object.keys called on a non-object');} - let keys = []; - for (let name in object) owns(object, name) && keys.push(name); - if (hasDontEnumBug) - {for (var i = 0, ii = dontEnumsLength; ii > i; i++) { - var dontEnum = dontEnums[i]; - owns(object, dontEnum) && keys.push(dontEnum); - }} - return keys; - }; - } - Date.now || - (Date.now = function () { - return new Date().getTime(); - }); - let ws = ' \nv\f\r   ᠎              \u2028\u2029'; - if (!String.prototype.trim || ws.trim()) { - ws = '[' + ws + ']'; - let trimBeginRegexp = RegExp('^' + ws + ws + '*'), - trimEndRegexp = RegExp(ws + ws + '*$'); - String.prototype.trim = function () { - return (this + '') - .replace(trimBeginRegexp, '') - .replace(trimEndRegexp, ''); - }; - } - var toObject = function (o) { - if (null == o) throw new TypeError('can\'t convert ' + o + ' to object'); - return Object(o); - }; -}); -ace.define( - 'sense_editor/mode/worker_parser', - ['require', 'exports', 'module'], - function () { - let at, // The index of the current character - ch, // The current character - annos, // annotations - escapee = { - '"': '"', - '\\': '\\', - '/': '/', - b: '\b', - f: '\f', - n: '\n', - r: '\r', - t: '\t', - }, - text, - annotate = function (type, text) { - annos.push({ type: type, text: text, at: at }); - }, - error = function (m) { - throw { - name: 'SyntaxError', - message: m, - at: at, - text: text, - }; - }, - reset = function (newAt) { - ch = text.charAt(newAt); - at = newAt + 1; - }, - next = function (c) { - if (c && c !== ch) { - error('Expected \'' + c + '\' instead of \'' + ch + '\''); - } - - ch = text.charAt(at); - at += 1; - return ch; - }, - nextUpTo = function (upTo, errorMessage) { - let currentAt = at, - i = text.indexOf(upTo, currentAt); - if (i < 0) { - error(errorMessage || 'Expected \'' + upTo + '\''); - } - reset(i + upTo.length); - return text.substring(currentAt, i); - }, - peek = function (offset) { - return text.charAt(at + offset); - }, - number = function () { - let number, - string = ''; - - if (ch === '-') { - string = '-'; - next('-'); - } - while (ch >= '0' && ch <= '9') { - string += ch; - next(); - } - if (ch === '.') { - string += '.'; - while (next() && ch >= '0' && ch <= '9') { - string += ch; - } - } - if (ch === 'e' || ch === 'E') { - string += ch; - next(); - if (ch === '-' || ch === '+') { - string += ch; - next(); - } - while (ch >= '0' && ch <= '9') { - string += ch; - next(); - } - } - number = +string; - if (isNaN(number)) { - error('Bad number'); - } else { - return number; - } - }, - string = function () { - let hex, - i, - string = '', - uffff; - - if (ch === '"') { - // If the current and the next characters are equal to "", empty string or start of triple quoted strings - if (peek(0) === '"' && peek(1) === '"') { - // literal - next('"'); - next('"'); - return nextUpTo('"""', 'failed to find closing \'"""\''); - } else { - while (next()) { - if (ch === '"') { - next(); - return string; - } else if (ch === '\\') { - next(); - if (ch === 'u') { - uffff = 0; - for (i = 0; i < 4; i += 1) { - hex = parseInt(next(), 16); - if (!isFinite(hex)) { - break; - } - uffff = uffff * 16 + hex; - } - string += String.fromCharCode(uffff); - } else if (typeof escapee[ch] === 'string') { - string += escapee[ch]; - } else { - break; - } - } else { - string += ch; - } - } - } - } - error('Bad string'); - }, - white = function () { - while (ch) { - // Skip whitespace. - while (ch && ch <= ' ') { - next(); - } - // if the current char in iteration is '#' or the char and the next char is equal to '//' - // we are on the single line comment - if (ch === '#' || ch === '/' && peek(0) === '/') { - // Until we are on the new line, skip to the next char - while (ch && ch !== '\n') { - next(); - } - } else if (ch === '/' && peek(0) === '*') { - // If the chars starts with '/*', we are on the multiline comment - next(); - next(); - while (ch && !(ch === '*' && peek(0) === '/')) { - // Until we have closing tags '*/', skip to the next char - next(); - } - if (ch) { - next(); - next(); - } - } else break; - } - }, - strictWhite = function () { - while (ch && (ch == ' ' || ch == '\t')) { - next(); - } - }, - newLine = function () { - if (ch == '\n') next(); - }, - word = function () { - switch (ch) { - case 't': - next('t'); - next('r'); - next('u'); - next('e'); - return true; - case 'f': - next('f'); - next('a'); - next('l'); - next('s'); - next('e'); - return false; - case 'n': - next('n'); - next('u'); - next('l'); - next('l'); - return null; - } - error('Unexpected \'' + ch + '\''); - }, - // parses and returns the method - method = function () { - switch (ch) { - case 'g': - next('g'); - next('e'); - next('t'); - return 'get'; - case 'G': - next('G'); - next('E'); - next('T'); - return 'GET'; - case 'h': - next('h'); - next('e'); - next('a'); - next('d'); - return 'head'; - case 'H': - next('H'); - next('E'); - next('A'); - next('D'); - return 'HEAD'; - case 'd': - next('d'); - next('e'); - next('l'); - next('e'); - next('t'); - next('e'); - return 'delete'; - case 'D': - next('D'); - next('E'); - next('L'); - next('E'); - next('T'); - next('E'); - return 'DELETE'; - case 'p': - next('p'); - switch (ch) { - case 'a': - next('a'); - next('t'); - next('c'); - next('h'); - return 'patch'; - case 'u': - next('u'); - next('t'); - return 'put'; - case 'o': - next('o'); - next('s'); - next('t'); - return 'post'; - default: - error('Unexpected \'' + ch + '\''); - } - break; - case 'P': - next('P'); - switch (ch) { - case 'A': - next('A'); - next('T'); - next('C'); - next('H'); - return 'PATCH'; - case 'U': - next('U'); - next('T'); - return 'PUT'; - case 'O': - next('O'); - next('S'); - next('T'); - return 'POST'; - default: - error('Unexpected \'' + ch + '\''); - } - break; - default: - error('Expected one of GET/POST/PUT/DELETE/HEAD/PATCH'); - } - }, - value, // Place holder for the value function. - array = function () { - const array = []; - - if (ch === '[') { - next('['); - white(); - if (ch === ']') { - next(']'); - return array; // empty array - } - while (ch) { - array.push(value()); - white(); - if (ch === ']') { - next(']'); - return array; - } - next(','); - white(); - } - } - error('Bad array'); - }, - object = function () { - let key, - object = {}; - - if (ch === '{') { - next('{'); - white(); - if (ch === '}') { - next('}'); - return object; // empty object - } - while (ch) { - key = string(); - white(); - next(':'); - if (Object.hasOwnProperty.call(object, key)) { - error('Duplicate key "' + key + '"'); - } - object[key] = value(); - white(); - if (ch === '}') { - next('}'); - return object; - } - next(','); - white(); - } - } - error('Bad object'); - }; - - value = function () { - white(); - switch (ch) { - case '{': - return object(); - case '[': - return array(); - case '"': - return string(); - case '-': - return number(); - default: - return ch >= '0' && ch <= '9' ? number() : word(); - } - }; - - let url = function () { - let url = ''; - while (ch && ch != '\n') { - url += ch; - next(); - } - if (url == '') { - error('Missing url'); - } - return url; - }, - request = function () { - white(); - method(); - strictWhite(); - url(); - strictWhite(); // advance to one new line - newLine(); - strictWhite(); - if (ch == '{') { - object(); - } - // multi doc request - strictWhite(); // advance to one new line - newLine(); - strictWhite(); - while (ch == '{') { - // another object - object(); - strictWhite(); - newLine(); - strictWhite(); - } - }, - comment = function () { - while (ch == '#') { - while (ch && ch !== '\n') { - next(); - } - white(); - } - }, - multi_request = function () { - while (ch && ch != '') { - white(); - if (!ch) { - continue; - } - try { - comment(); - white(); - if (!ch) { - continue; - } - request(); - white(); - } catch (e) { - annotate('error', e.message); - // snap - const substring = text.substr(at); - const nextMatch = substring.search(/^POST|HEAD|GET|PUT|DELETE|PATCH/m); - if (nextMatch < 1) return; - reset(at + nextMatch); - } - } - }; - - return function (source, reviver) { - let result; - - text = source; - at = 0; - annos = []; - next(); - multi_request(); - white(); - if (ch) { - annotate('error', 'Syntax error'); - } - - result = { annotations: annos }; - - return typeof reviver === 'function' - ? (function walk(holder, key) { - let k, - v, - value = holder[key]; - if (value && typeof value === 'object') { - for (k in value) { - if (Object.hasOwnProperty.call(value, k)) { - v = walk(value, k); - if (v !== undefined) { - value[k] = v; - } else { - delete value[k]; - } - } - } - } - return reviver.call(holder, key, value); - }({ '': result }, '')) - : result; - }; - } -); - -ace.define( - 'sense_editor/mode/worker', - [ - 'require', - 'exports', - 'module', - 'ace/lib/oop', - 'ace/worker/mirror', - 'sense_editor/mode/worker_parser', - ], - function (require, exports) { - const oop = require('ace/lib/oop'); - const Mirror = require('ace/worker/mirror').Mirror; - const parse = require('sense_editor/mode/worker_parser'); - - const SenseWorker = (exports.SenseWorker = function (sender) { - Mirror.call(this, sender); - this.setTimeout(200); - }); - - oop.inherits(SenseWorker, Mirror); - - (function () { - this.id = 'senseWorker'; - this.onUpdate = function () { - const value = this.doc.getValue(); - let pos, result; - try { - result = parse(value); - } catch (e) { - pos = this.charToDocumentPosition(e.at - 1); - this.sender.emit('error', { - row: pos.row, - column: pos.column, - text: e.message, - type: 'error', - }); - return; - } - for (let i = 0; i < result.annotations.length; i++) { - pos = this.charToDocumentPosition(result.annotations[i].at - 1); - result.annotations[i].row = pos.row; - result.annotations[i].column = pos.column; - } - this.sender.emit('ok', result.annotations); - }; - - this.charToDocumentPosition = function (charPos) { - let i = 0; - const len = this.doc.getLength(); - const nl = this.doc.getNewLineCharacter().length; - - if (!len) { - return { row: 0, column: 0 }; - } - - let lineStart = 0, - line; - while (i < len) { - line = this.doc.getLine(i); - const lineLength = line.length + nl; - if (lineStart + lineLength > charPos) { - return { - row: i, - column: charPos - lineStart, - }; - } - - lineStart += lineLength; - i += 1; - } - - return { - row: i - 1, - column: line.length, - }; - }; - }.call(SenseWorker.prototype)); - } -); diff --git a/src/plugins/console/public/application/models/legacy_core_editor/output_tokenization.test.js b/src/plugins/console/public/application/models/legacy_core_editor/output_tokenization.test.js deleted file mode 100644 index e09bf06e48246..0000000000000 --- a/src/plugins/console/public/application/models/legacy_core_editor/output_tokenization.test.js +++ /dev/null @@ -1,91 +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 './legacy_core_editor.test.mocks'; -import $ from 'jquery'; -import RowParser from '../../../lib/row_parser'; -import ace from 'brace'; -import { createReadOnlyAceEditor } from './create_readonly'; -let output; -const tokenIterator = ace.acequire('ace/token_iterator'); - -describe('Output Tokenization', () => { - beforeEach(() => { - output = createReadOnlyAceEditor(document.querySelector('#ConAppOutput')); - $(output.container).show(); - }); - - afterEach(() => { - $(output.container).hide(); - }); - - function tokensAsList() { - const iter = new tokenIterator.TokenIterator(output.getSession(), 0, 0); - const ret = []; - let t = iter.getCurrentToken(); - const parser = new RowParser(output); - if (parser.isEmptyToken(t)) { - t = parser.nextNonEmptyToken(iter); - } - while (t) { - ret.push({ value: t.value, type: t.type }); - t = parser.nextNonEmptyToken(iter); - } - - return ret; - } - - let testCount = 0; - - function tokenTest(tokenList, data) { - if (data && typeof data !== 'string') { - data = JSON.stringify(data, null, 3); - } - - test('Token test ' + testCount++, function (done) { - output.update(data, function () { - const tokens = tokensAsList(); - const normTokenList = []; - for (let i = 0; i < tokenList.length; i++) { - normTokenList.push({ type: tokenList[i++], value: tokenList[i] }); - } - - expect(tokens).toEqual(normTokenList); - done(); - }); - }); - } - - tokenTest( - ['warning', '#! warning', 'comment', '# GET url', 'paren.lparen', '{', 'paren.rparen', '}'], - '#! warning\n' + '# GET url\n' + '{}' - ); - - tokenTest( - [ - 'comment', - '# GET url', - 'paren.lparen', - '{', - 'variable', - '"f"', - 'punctuation.colon', - ':', - 'punctuation.start_triple_quote', - '"""', - 'multi_string', - 'raw', - 'punctuation.end_triple_quote', - '"""', - 'paren.rparen', - '}', - ], - '# GET url\n' + '{ "f": """raw""" }' - ); -}); diff --git a/src/plugins/console/public/application/models/legacy_core_editor/smart_resize.ts b/src/plugins/console/public/application/models/legacy_core_editor/smart_resize.ts deleted file mode 100644 index c238e8c6a5da7..0000000000000 --- a/src/plugins/console/public/application/models/legacy_core_editor/smart_resize.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", 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 { get, throttle } from 'lodash'; -import type { Editor } from 'brace'; - -// eslint-disable-next-line import/no-default-export -export default function (editor: Editor) { - const resize = editor.resize; - - const throttledResize = throttle(() => { - resize.call(editor, false); - - // Keep current top line in view when resizing to avoid losing user context - const userRow = get(throttledResize, 'topRow', 0); - if (userRow !== 0) { - editor.renderer.scrollToLine(userRow, false, false, () => {}); - } - }, 35); - return throttledResize; -} diff --git a/src/plugins/console/public/application/models/legacy_core_editor/theme_sense_dark.js b/src/plugins/console/public/application/models/legacy_core_editor/theme_sense_dark.js deleted file mode 100644 index fd8e12bf1d703..0000000000000 --- a/src/plugins/console/public/application/models/legacy_core_editor/theme_sense_dark.js +++ /dev/null @@ -1,123 +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 ace from 'brace'; - -ace.define('ace/theme/sense-dark', ['require', 'exports', 'module'], function (require, exports) { - exports.isDark = true; - exports.cssClass = 'ace-sense-dark'; - exports.cssText = - '.ace-sense-dark .ace_gutter {\ -background: #2e3236;\ -color: #bbbfc2;\ -}\ -.ace-sense-dark .ace_print-margin {\ -width: 1px;\ -background: #555651\ -}\ -.ace-sense-dark .ace_scroller {\ -background-color: #202328;\ -}\ -.ace-sense-dark .ace_content {\ -}\ -.ace-sense-dark .ace_text-layer {\ -color: #F8F8F2\ -}\ -.ace-sense-dark .ace_cursor {\ -border-left: 2px solid #F8F8F0\ -}\ -.ace-sense-dark .ace_overwrite-cursors .ace_cursor {\ -border-left: 0px;\ -border-bottom: 1px solid #F8F8F0\ -}\ -.ace-sense-dark .ace_marker-layer .ace_selection {\ -background: #222\ -}\ -.ace-sense-dark.ace_multiselect .ace_selection.ace_start {\ -box-shadow: 0 0 3px 0px #272822;\ -border-radius: 2px\ -}\ -.ace-sense-dark .ace_marker-layer .ace_step {\ -background: rgb(102, 82, 0)\ -}\ -.ace-sense-dark .ace_marker-layer .ace_bracket {\ -margin: -1px 0 0 -1px;\ -border: 1px solid #49483E\ -}\ -.ace-sense-dark .ace_marker-layer .ace_active-line {\ -background: #202020\ -}\ -.ace-sense-dark .ace_gutter-active-line {\ -background-color: #272727\ -}\ -.ace-sense-dark .ace_marker-layer .ace_selected-word {\ -border: 1px solid #49483E\ -}\ -.ace-sense-dark .ace_invisible {\ -color: #49483E\ -}\ -.ace-sense-dark .ace_entity.ace_name.ace_tag,\ -.ace-sense-dark .ace_keyword,\ -.ace-sense-dark .ace_meta,\ -.ace-sense-dark .ace_storage {\ -color: #F92672\ -}\ -.ace-sense-dark .ace_constant.ace_character,\ -.ace-sense-dark .ace_constant.ace_language,\ -.ace-sense-dark .ace_constant.ace_numeric,\ -.ace-sense-dark .ace_constant.ace_other {\ -color: #AE81FF\ -}\ -.ace-sense-dark .ace_invalid {\ -color: #F8F8F0;\ -background-color: #F92672\ -}\ -.ace-sense-dark .ace_invalid.ace_deprecated {\ -color: #F8F8F0;\ -background-color: #AE81FF\ -}\ -.ace-sense-dark .ace_support.ace_constant,\ -.ace-sense-dark .ace_support.ace_function {\ -color: #66D9EF\ -}\ -.ace-sense-dark .ace_fold {\ -background-color: #A6E22E;\ -border-color: #F8F8F2\ -}\ -.ace-sense-dark .ace_storage.ace_type,\ -.ace-sense-dark .ace_support.ace_class,\ -.ace-sense-dark .ace_support.ace_type {\ -font-style: italic;\ -color: #66D9EF\ -}\ -.ace-sense-dark .ace_entity.ace_name.ace_function,\ -.ace-sense-dark .ace_entity.ace_other.ace_attribute-name,\ -.ace-sense-dark .ace_variable {\ -color: #A6E22E\ -}\ -.ace-sense-dark .ace_variable.ace_parameter {\ -font-style: italic;\ -color: #FD971F\ -}\ -.ace-sense-dark .ace_string {\ -color: #E6DB74\ -}\ -.ace-sense-dark .ace_comment {\ -color: #629755\ -}\ -.ace-sense-dark .ace_markup.ace_underline {\ -text-decoration: underline\ -}\ -.ace-sense-dark .ace_indent-guide {\ -background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAEklEQVQImWNQ11D6z7Bq1ar/ABCKBG6g04U2AAAAAElFTkSuQmCC) right repeat-y\ -}'; - - const dom = require('ace/lib/dom'); - dom.importCssString(exports.cssText, exports.cssClass); -}); diff --git a/src/plugins/console/public/application/models/sense_editor/__fixtures__/editor_input1.txt b/src/plugins/console/public/application/models/sense_editor/__fixtures__/editor_input1.txt deleted file mode 100644 index 517f22bd8ad6a..0000000000000 --- a/src/plugins/console/public/application/models/sense_editor/__fixtures__/editor_input1.txt +++ /dev/null @@ -1,37 +0,0 @@ -GET _search -{ - "query": { "match_all": {} } -} - -#preceeding comment -GET _stats?level=shards - -#in between comment - -PUT index_1/type1/1 -{ - "f": 1 -} - -PUT index_1/type1/2 -{ - "f": 2 -} - -# comment - - -GET index_1/type1/1/_source?_source_include=f - -DELETE index_2 - - -POST /_sql?format=txt -{ - "query": "SELECT prenom FROM claude_index WHERE prenom = 'claude' ", - "fetch_size": 1 -} - -GET ,,/_search?pretty - -GET kbn:/api/spaces/space \ No newline at end of file diff --git a/src/plugins/console/public/application/models/sense_editor/create.ts b/src/plugins/console/public/application/models/sense_editor/create.ts deleted file mode 100644 index 9c6c3e38471d5..0000000000000 --- a/src/plugins/console/public/application/models/sense_editor/create.ts +++ /dev/null @@ -1,22 +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 { SenseEditor } from './sense_editor'; -import * as core from '../legacy_core_editor'; - -export function create(element: HTMLElement) { - const coreEditor = core.create(element); - const senseEditor = new SenseEditor(coreEditor); - - /** - * Init the editor - */ - senseEditor.highlightCurrentRequestsAndUpdateActionBar(); - return senseEditor; -} diff --git a/src/plugins/console/public/application/models/sense_editor/curl.ts b/src/plugins/console/public/application/models/sense_editor/curl.ts deleted file mode 100644 index 9080610a0e8c5..0000000000000 --- a/src/plugins/console/public/application/models/sense_editor/curl.ts +++ /dev/null @@ -1,194 +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". - */ - -function detectCURLinLine(line: string) { - // returns true if text matches a curl request - return line.match(/^\s*?curl\s+(-X[A-Z]+)?\s*['"]?.*?['"]?(\s*$|\s+?-d\s*?['"])/); -} - -export function detectCURL(text: string) { - // returns true if text matches a curl request - if (!text) return false; - for (const line of text.split('\n')) { - if (detectCURLinLine(line)) { - return true; - } - } - return false; -} - -export function parseCURL(text: string) { - let state = 'NONE'; - const out = []; - let body: string[] = []; - let line = ''; - const lines = text.trim().split('\n'); - let matches; - - const EmptyLine = /^\s*$/; - const Comment = /^\s*(?:#|\/{2,})(.*)\n?$/; - const ExecutionComment = /^\s*#!/; - const ClosingSingleQuote = /^([^']*)'/; - const ClosingDoubleQuote = /^((?:[^\\"]|\\.)*)"/; - const EscapedQuotes = /^((?:[^\\"']|\\.)+)/; - - const LooksLikeCurl = /^\s*curl\s+/; - const CurlVerb = /-X ?(GET|HEAD|POST|PUT|DELETE|PATCH)/; - - const HasProtocol = /[\s"']https?:\/\//; - const CurlRequestWithProto = /[\s"']https?:\/\/[^\/ ]+\/+([^\s"']+)/; - const CurlRequestWithoutProto = /[\s"'][^\/ ]+\/+([^\s"']+)/; - const CurlData = /^.+\s(--data|-d)\s*/; - const SenseLine = /^\s*(GET|HEAD|POST|PUT|DELETE|PATCH)\s+\/?(.+)/; - - if (lines.length > 0 && ExecutionComment.test(lines[0])) { - lines.shift(); - } - - function nextLine() { - if (line.length > 0) { - return true; - } - if (lines.length === 0) { - return false; - } - line = lines.shift()!.replace(/[\r\n]+/g, '\n') + '\n'; - return true; - } - - function unescapeLastBodyEl() { - const str = body.pop()!.replace(/\\([\\"'])/g, '$1'); - body.push(str); - } - - // Is the next char a single or double quote? - // If so remove it - function detectQuote() { - if (line.substr(0, 1) === "'") { - line = line.substr(1); - state = 'SINGLE_QUOTE'; - } else if (line.substr(0, 1) === '"') { - line = line.substr(1); - state = 'DOUBLE_QUOTE'; - } else { - state = 'UNQUOTED'; - } - } - - // Body is finished - append to output with final LF - function addBodyToOut() { - if (body.length > 0) { - out.push(body.join('')); - body = []; - } - state = 'LF'; - out.push('\n'); - } - - // If the pattern matches, then the state is about to change, - // so add the capture to the body and detect the next state - // Otherwise add the whole line - function consumeMatching(pattern: string | RegExp) { - const result = line.match(pattern); - if (result) { - body.push(result[1]); - line = line.substr(result[0].length); - detectQuote(); - } else { - body.push(line); - line = ''; - } - } - - function parseCurlLine() { - let verb = 'GET'; - let request = ''; - let result; - if ((result = line.match(CurlVerb))) { - verb = result[1]; - } - - // JS regexen don't support possessive quantifiers, so - // we need two distinct patterns - const pattern = HasProtocol.test(line) ? CurlRequestWithProto : CurlRequestWithoutProto; - - if ((result = line.match(pattern))) { - request = result[1]; - } - - out.push(verb + ' /' + request + '\n'); - - if ((result = line.match(CurlData))) { - line = line.substr(result[0].length); - detectQuote(); - if (EmptyLine.test(line)) { - line = ''; - } - } else { - state = 'NONE'; - line = ''; - out.push(''); - } - } - - while (nextLine()) { - if (state === 'SINGLE_QUOTE') { - consumeMatching(ClosingSingleQuote); - } else if (state === 'DOUBLE_QUOTE') { - consumeMatching(ClosingDoubleQuote); - unescapeLastBodyEl(); - } else if (state === 'UNQUOTED') { - consumeMatching(EscapedQuotes); - if (body.length) { - unescapeLastBodyEl(); - } - if (state === 'UNQUOTED') { - addBodyToOut(); - line = ''; - } - } - - // the BODY state (used to match the body of a Sense request) - // can be terminated early if it encounters - // a comment or an empty line - else if (state === 'BODY') { - if (Comment.test(line) || EmptyLine.test(line)) { - addBodyToOut(); - } else { - body.push(line); - line = ''; - } - } else if (EmptyLine.test(line)) { - if (state !== 'LF') { - out.push('\n'); - state = 'LF'; - } - line = ''; - } else if ((matches = line.match(Comment))) { - out.push('#' + matches[1] + '\n'); - state = 'NONE'; - line = ''; - } else if (LooksLikeCurl.test(line)) { - parseCurlLine(); - } else if ((matches = line.match(SenseLine))) { - out.push(matches[1] + ' /' + matches[2] + '\n'); - line = ''; - state = 'BODY'; - } - - // Nothing else matches, so output with a prefix of ### for debugging purposes - else { - out.push('### ' + line); - line = ''; - } - } - - addBodyToOut(); - return out.join('').trim(); -} diff --git a/src/plugins/console/public/application/models/sense_editor/index.ts b/src/plugins/console/public/application/models/sense_editor/index.ts deleted file mode 100644 index 2bd44988dc02f..0000000000000 --- a/src/plugins/console/public/application/models/sense_editor/index.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", 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 * from './create'; -export * from '../legacy_core_editor/create_readonly'; -export { MODE } from '../../../lib/row_parser'; -export { SenseEditor } from './sense_editor'; -export { getEndpointFromPosition } from '../../../lib/autocomplete/get_endpoint_from_position'; diff --git a/src/plugins/console/public/application/models/sense_editor/integration.test.js b/src/plugins/console/public/application/models/sense_editor/integration.test.js deleted file mode 100644 index bed83293e31d6..0000000000000 --- a/src/plugins/console/public/application/models/sense_editor/integration.test.js +++ /dev/null @@ -1,1279 +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 './sense_editor.test.mocks'; -import { create } from './create'; -import _ from 'lodash'; -import $ from 'jquery'; - -import * as kb from '../../../lib/kb/kb'; -import { AutocompleteInfo, setAutocompleteInfo } from '../../../services'; -import { httpServiceMock } from '@kbn/core-http-browser-mocks'; -import { StorageMock } from '../../../services/storage.mock'; -import { SettingsMock } from '../../../services/settings.mock'; - -describe('Integration', () => { - let senseEditor; - let autocompleteInfo; - - beforeEach(() => { - // Set up our document body - document.body.innerHTML = - '
'; - - senseEditor = create(document.querySelector('#ConAppEditor')); - $(senseEditor.getCoreEditor().getContainer()).show(); - senseEditor.autocomplete._test.removeChangeListener(); - autocompleteInfo = new AutocompleteInfo(); - - const httpMock = httpServiceMock.createSetupContract(); - const storage = new StorageMock({}, 'test'); - const settingsMock = new SettingsMock(storage); - - settingsMock.getAutocomplete.mockReturnValue({ fields: true }); - - autocompleteInfo.mapping.setup(httpMock, settingsMock); - - setAutocompleteInfo(autocompleteInfo); - }); - afterEach(() => { - $(senseEditor.getCoreEditor().getContainer()).hide(); - senseEditor.autocomplete._test.addChangeListener(); - autocompleteInfo = null; - setAutocompleteInfo(null); - }); - - function processContextTest(data, mapping, kbSchemes, requestLine, testToRun) { - test(testToRun.name, function (done) { - let lineOffset = 0; // add one for the extra method line - let editorValue = data; - if (requestLine != null) { - if (data != null) { - editorValue = requestLine + '\n' + data; - lineOffset = 1; - } else { - editorValue = requestLine; - } - } - - testToRun.cursor.lineNumber += lineOffset; - - autocompleteInfo.clear(); - autocompleteInfo.mapping.loadMappings(mapping); - const json = {}; - json[test.name] = kbSchemes || {}; - const testApi = kb._test.loadApisFromJson(json); - if (kbSchemes) { - // if (kbSchemes.globals) { - // $.each(kbSchemes.globals, function (parent, rules) { - // testApi.addGlobalAutocompleteRules(parent, rules); - // }); - // } - if (kbSchemes.endpoints) { - $.each(kbSchemes.endpoints, function (endpoint, scheme) { - testApi.addEndpointDescription(endpoint, scheme); - }); - } - } - kb._test.setActiveApi(testApi); - const { cursor } = testToRun; - senseEditor.update(editorValue, true).then(() => { - senseEditor.getCoreEditor().moveCursorToPosition(cursor); - // allow ace rendering to move cursor so it will be seen during test - handy for debugging. - //setTimeout(function () { - senseEditor.completer = { - base: {}, - changeListener: function () {}, - }; // mimic auto complete - - senseEditor.autocomplete._test.getCompletions( - senseEditor, - null, - cursor, - '', - function (err, terms) { - if (testToRun.assertThrows) { - done(); - return; - } - - if (err) { - throw err; - } - - if (testToRun.no_context) { - expect(!terms || terms.length === 0).toBeTruthy(); - } else { - expect(terms).not.toBeNull(); - expect(terms.length).toBeGreaterThan(0); - } - - if (!terms || terms.length === 0) { - done(); - return; - } - - if (testToRun.autoCompleteSet) { - const expectedTerms = _.map(testToRun.autoCompleteSet, function (t) { - if (typeof t !== 'object') { - t = { name: t }; - } - return t; - }); - if (terms.length !== expectedTerms.length) { - expect(_.map(terms, 'name')).toEqual(_.map(expectedTerms, 'name')); - } else { - const filteredActualTerms = _.map(terms, function (actualTerm, i) { - const expectedTerm = expectedTerms[i]; - const filteredTerm = {}; - _.each(expectedTerm, function (v, p) { - filteredTerm[p] = actualTerm[p]; - }); - return filteredTerm; - }); - expect(filteredActualTerms).toEqual(expectedTerms); - } - } - - const context = terms[0].context; - const { - cursor: { lineNumber, column }, - } = testToRun; - senseEditor.autocomplete._test.addReplacementInfoToContext( - context, - { lineNumber, column }, - terms[0].value - ); - - function ac(prop, propTest) { - if (typeof testToRun[prop] !== 'undefined') { - if (propTest) { - propTest(context[prop], testToRun[prop], prop); - } else { - expect(context[prop]).toEqual(testToRun[prop]); - } - } - } - - function posCompare(actual, expected) { - expect(actual.lineNumber).toEqual(expected.lineNumber + lineOffset); - expect(actual.column).toEqual(expected.column); - } - - function rangeCompare(actual, expected, name) { - posCompare(actual.start, expected.start, name + '.start'); - posCompare(actual.end, expected.end, name + '.end'); - } - - ac('prefixToAdd'); - ac('suffixToAdd'); - ac('addTemplate'); - ac('textBoxPosition', posCompare); - ac('rangeToReplace', rangeCompare); - done(); - }, - { setAnnotation: () => {}, removeAnnotation: () => {} } - ); - }); - }); - } - - function contextTests(data, mapping, kbSchemes, requestLine, tests) { - if (data != null && typeof data !== 'string') { - data = JSON.stringify(data, null, 3); - } - for (let t = 0; t < tests.length; t++) { - processContextTest(data, mapping, kbSchemes, requestLine, tests[t]); - } - } - - const SEARCH_KB = { - endpoints: { - _search: { - methods: ['GET', 'POST'], - patterns: ['{index}/_search', '_search'], - data_autocomplete_rules: { - query: { - match_all: {}, - term: { '{field}': { __template: { f: 1 } } }, - }, - size: {}, - facets: { - __template: { - FIELD: {}, - }, - '*': { terms: { field: '{field}' } }, - }, - }, - }, - }, - }; - - const MAPPING = { - index1: { - properties: { - 'field1.1.1': { type: 'string' }, - 'field1.1.2': { type: 'string' }, - }, - }, - index2: { - properties: { - 'field2.1.1': { type: 'string' }, - 'field2.1.2': { type: 'string' }, - }, - }, - }; - - contextTests({}, MAPPING, SEARCH_KB, 'POST _search', [ - { - name: 'Empty doc', - cursor: { lineNumber: 1, column: 2 }, - initialValue: '', - addTemplate: true, - prefixToAdd: '', - suffixToAdd: '', - rangeToReplace: { - start: { lineNumber: 1, column: 2 }, - end: { lineNumber: 1, column: 2 }, - }, - autoCompleteSet: ['facets', 'query', 'size'], - }, - ]); - - contextTests({}, MAPPING, SEARCH_KB, 'POST _no_context', [ - { - name: 'Missing KB', - cursor: { lineNumber: 1, column: 2 }, - no_context: true, - }, - ]); - - contextTests( - { - query: { - f: 1, - }, - }, - MAPPING, - { - globals: { - query: { - t1: 2, - }, - }, - endpoints: {}, - }, - 'POST _no_context', - [ - { - name: 'Missing KB - global auto complete', - cursor: { lineNumber: 3, column: 6 }, - autoCompleteSet: ['t1'], - }, - ] - ); - - contextTests( - { - query: { - field: 'something', - }, - facets: {}, - size: 20, - }, - MAPPING, - SEARCH_KB, - 'POST _search', - [ - { - name: 'existing dictionary key, no template', - cursor: { lineNumber: 2, column: 6 }, - initialValue: 'query', - addTemplate: false, - prefixToAdd: '', - suffixToAdd: '', - rangeToReplace: { - start: { lineNumber: 2, column: 4 }, - end: { lineNumber: 2, column: 11 }, - }, - autoCompleteSet: ['facets', 'query', 'size'], - }, - { - name: 'existing inner dictionary key', - cursor: { lineNumber: 3, column: 8 }, - initialValue: 'field', - addTemplate: false, - prefixToAdd: '', - suffixToAdd: '', - rangeToReplace: { - start: { lineNumber: 3, column: 7 }, - end: { lineNumber: 3, column: 14 }, - }, - autoCompleteSet: ['match_all', 'term'], - }, - { - name: 'existing dictionary key, yes template', - cursor: { lineNumber: 5, column: 8 }, - initialValue: 'facets', - addTemplate: true, - prefixToAdd: '', - suffixToAdd: '', - rangeToReplace: { - start: { lineNumber: 5, column: 4 }, - end: { lineNumber: 5, column: 16 }, - }, - autoCompleteSet: ['facets', 'query', 'size'], - }, - { - name: 'ignoring meta keys', - cursor: { lineNumber: 5, column: 15 }, - no_context: true, - }, - ] - ); - - contextTests( - '{\n' + - ' "query": {\n' + - ' "field": "something"\n' + - ' },\n' + - ' "facets": {},\n' + - ' "size": 20\n' + - '}', - MAPPING, - SEARCH_KB, - 'POST _search', - [ - { - name: 'trailing comma, end of line', - cursor: { lineNumber: 5, column: 17 }, - initialValue: '', - addTemplate: true, - prefixToAdd: '', - suffixToAdd: ', ', - rangeToReplace: { - start: { lineNumber: 5, column: 17 }, - end: { lineNumber: 5, column: 17 }, - }, - autoCompleteSet: ['facets', 'query', 'size'], - }, - { - name: 'trailing comma, beginning of line', - cursor: { lineNumber: 6, column: 2 }, - initialValue: '', - addTemplate: true, - prefixToAdd: '', - suffixToAdd: ', ', - rangeToReplace: { - start: { lineNumber: 6, column: 2 }, - end: { lineNumber: 6, column: 2 }, - }, - autoCompleteSet: ['facets', 'query', 'size'], - }, - { - name: 'prefix comma, end of line', - cursor: { lineNumber: 7, column: 1 }, - initialValue: '', - addTemplate: true, - prefixToAdd: ',\n', - suffixToAdd: '', - rangeToReplace: { - start: { lineNumber: 6, column: 14 }, - end: { lineNumber: 7, column: 1 }, - }, - autoCompleteSet: ['facets', 'query', 'size'], - }, - ] - ); - - contextTests( - { - object: 1, - array: 1, - value_one_of: 1, - value: 2, - something_else: 5, - }, - MAPPING, - { - endpoints: { - _test: { - patterns: ['_test'], - data_autocomplete_rules: { - object: { bla: 1 }, - array: [1], - value_one_of: { __one_of: [1, 2] }, - value: 3, - '*': { __one_of: [4, 5] }, - }, - }, - }, - }, - 'GET _test', - [ - { - name: 'not matching object when { is not opened', - cursor: { lineNumber: 2, column: 13 }, - initialValue: '', - autoCompleteSet: ['{'], - }, - { - name: 'not matching array when [ is not opened', - cursor: { lineNumber: 3, column: 13 }, - initialValue: '', - autoCompleteSet: ['['], - }, - { - name: 'matching value with one_of', - cursor: { lineNumber: 4, column: 20 }, - initialValue: '', - autoCompleteSet: [1, 2], - }, - { - name: 'matching value', - cursor: { lineNumber: 5, column: 13 }, - initialValue: '', - autoCompleteSet: [3], - }, - { - name: 'matching any value with one_of', - cursor: { lineNumber: 6, column: 22 }, - initialValue: '', - autoCompleteSet: [4, 5], - }, - ] - ); - - contextTests( - { - query: { - field: 'something', - }, - facets: { - name: {}, - }, - size: 20, - }, - MAPPING, - SEARCH_KB, - 'GET _search', - [ - { - name: '* matching everything', - cursor: { lineNumber: 6, column: 16 }, - initialValue: '', - addTemplate: true, - prefixToAdd: '', - suffixToAdd: '', - rangeToReplace: { - start: { lineNumber: 6, column: 16 }, - end: { lineNumber: 6, column: 16 }, - }, - autoCompleteSet: [{ name: 'terms', meta: 'API' }], - }, - ] - ); - - contextTests( - { - index: '123', - }, - MAPPING, - { - endpoints: { - _test: { - patterns: ['_test'], - data_autocomplete_rules: { - index: '{index}', - }, - }, - }, - }, - 'GET _test', - [ - { - name: '{index} matching', - cursor: { lineNumber: 2, column: 16 }, - autoCompleteSet: [ - { name: 'index1', meta: 'index' }, - { name: 'index2', meta: 'index' }, - ], - }, - ] - ); - - function tt(term, template, meta) { - term = { name: term, template: template }; - if (meta) { - term.meta = meta; - } - return term; - } - - contextTests( - { - array: ['a'], - oneof: '1', - }, - MAPPING, - { - endpoints: { - _endpoint: { - patterns: ['_endpoint'], - data_autocomplete_rules: { - array: ['a', 'b'], - number: 1, - object: {}, - fixed: { __template: { a: 1 } }, - oneof: { __one_of: ['o1', 'o2'] }, - }, - }, - }, - }, - 'GET _endpoint', - [ - { - name: 'Templates 1', - cursor: { lineNumber: 2, column: 1 }, - autoCompleteSet: [ - tt('array', []), - tt('fixed', { a: 1 }), - tt('number', 1), - tt('object', {}), - tt('oneof', 'o1'), - ], - }, - { - name: 'Templates - one off', - cursor: { lineNumber: 5, column: 13 }, - autoCompleteSet: [tt('o1'), tt('o2')], - }, - ] - ); - - contextTests( - { - string: 'value', - context: {}, - }, - MAPPING, - { - endpoints: { - _endpoint: { - patterns: ['_endpoint'], - data_autocomplete_rules: { - context: { - __one_of: [ - { - __condition: { - lines_regex: 'value', - }, - match: {}, - }, - { - __condition: { - lines_regex: 'other', - }, - no_match: {}, - }, - { always: {} }, - ], - }, - }, - }, - }, - }, - 'GET _endpoint', - [ - { - name: 'Conditionals', - cursor: { lineNumber: 3, column: 16 }, - autoCompleteSet: [tt('always', {}), tt('match', {})], - }, - ] - ); - - contextTests( - { - any_of_numbers: [1], - any_of_obj: [ - { - a: 1, - }, - ], - any_of_mixed: [ - { - a: 1, - }, - 2, - ], - }, - MAPPING, - { - endpoints: { - _endpoint: { - patterns: ['_endpoint'], - data_autocomplete_rules: { - any_of_numbers: { __template: [1, 2], __any_of: [1, 2, 3] }, - any_of_obj: { - __template: [{ c: 1 }], - __any_of: [{ a: 1, b: 2 }, { c: 1 }], - }, - any_of_mixed: { - __any_of: [{ a: 1 }, 3], - }, - }, - }, - }, - }, - 'GET _endpoint', - [ - { - name: 'Any of - templates', - cursor: { lineNumber: 2, column: 1 }, - autoCompleteSet: [ - tt('any_of_mixed', []), - tt('any_of_numbers', [1, 2]), - tt('any_of_obj', [{ c: 1 }]), - ], - }, - { - name: 'Any of - numbers', - cursor: { lineNumber: 3, column: 3 }, - autoCompleteSet: [1, 2, 3], - }, - { - name: 'Any of - object', - cursor: { lineNumber: 7, column: 3 }, - autoCompleteSet: [tt('a', 1), tt('b', 2), tt('c', 1)], - }, - { - name: 'Any of - mixed - obj', - cursor: { lineNumber: 12, column: 3 }, - autoCompleteSet: [tt('a', 1)], - }, - { - name: 'Any of - mixed - both', - cursor: { lineNumber: 14, column: 3 }, - autoCompleteSet: [tt(3), tt('{')], - }, - ] - ); - - contextTests( - {}, - MAPPING, - { - endpoints: { - _endpoint: { - patterns: ['_endpoint'], - data_autocomplete_rules: { - query: '', - }, - }, - }, - }, - 'GET _endpoint', - [ - { - name: 'Empty string as default', - cursor: { lineNumber: 1, column: 2 }, - autoCompleteSet: [tt('query', '')], - }, - ] - ); - - // NOTE: This test emits "error while getting completion terms Error: failed to resolve link - // [GLOBAL.broken]: Error: failed to resolve global components for ['broken']". but that's - // expected. - contextTests( - { - a: { - b: {}, - c: {}, - d: { - t1a: {}, - }, - e: {}, - f: [{}], - g: {}, - h: {}, - }, - }, - MAPPING, - { - globals: { - gtarget: { - t1: 2, - t1a: { - __scope_link: '.', - }, - }, - }, - endpoints: { - _current: { - patterns: ['_current'], - data_autocomplete_rules: { - a: { - b: { - __scope_link: '.a', - }, - c: { - __scope_link: 'ext.target', - }, - d: { - __scope_link: 'GLOBAL.gtarget', - }, - e: { - __scope_link: 'ext', - }, - f: [ - { - __scope_link: 'ext.target', - }, - ], - g: { - __scope_link: function () { - return { - a: 1, - b: 2, - }; - }, - }, - h: { - __scope_link: 'GLOBAL.broken', - }, - }, - }, - }, - ext: { - patterns: ['ext'], - data_autocomplete_rules: { - target: { - t2: 1, - }, - }, - }, - }, - }, - 'GET _current', - [ - { - name: 'Relative scope link test', - cursor: { lineNumber: 3, column: 13 }, - autoCompleteSet: [ - tt('b', {}), - tt('c', {}), - tt('d', {}), - tt('e', {}), - tt('f', [{}]), - tt('g', {}), - tt('h', {}), - ], - }, - { - name: 'External scope link test', - cursor: { lineNumber: 4, column: 13 }, - autoCompleteSet: [tt('t2', 1)], - }, - { - name: 'Global scope link test', - cursor: { lineNumber: 5, column: 13 }, - autoCompleteSet: [tt('t1', 2), tt('t1a', {})], - }, - { - name: 'Global scope link with an internal scope link', - cursor: { lineNumber: 6, column: 18 }, - autoCompleteSet: [tt('t1', 2), tt('t1a', {})], - }, - { - name: 'Entire endpoint scope link test', - cursor: { lineNumber: 8, column: 13 }, - autoCompleteSet: [tt('target', {})], - }, - { - name: 'A scope link within an array', - cursor: { lineNumber: 10, column: 11 }, - autoCompleteSet: [tt('t2', 1)], - }, - { - name: 'A function based scope link', - cursor: { lineNumber: 12, column: 13 }, - autoCompleteSet: [tt('a', 1), tt('b', 2)], - }, - { - name: 'A global scope link with wrong link', - cursor: { lineNumber: 13, column: 13 }, - assertThrows: /broken/, - }, - ] - ); - - contextTests( - {}, - MAPPING, - { - globals: { - gtarget: { - t1: 2, - }, - }, - endpoints: { - _current: { - patterns: ['_current'], - id: 'GET _current', - data_autocomplete_rules: { - __scope_link: 'GLOBAL.gtarget', - }, - }, - }, - }, - 'GET _current', - [ - { - name: 'Top level scope link', - cursor: { lineNumber: 1, column: 2 }, - autoCompleteSet: [tt('t1', 2)], - }, - ] - ); - - contextTests( - { - a: {}, - }, - MAPPING, - { - endpoints: { - _endpoint: { - patterns: ['_endpoint'], - data_autocomplete_rules: { - a: {}, - b: {}, - }, - }, - }, - }, - 'GET _endpoint', - [ - { - name: 'Path after empty object', - cursor: { lineNumber: 2, column: 11 }, - autoCompleteSet: ['a', 'b'], - }, - ] - ); - - contextTests( - { - '': {}, - }, - MAPPING, - SEARCH_KB, - 'POST _search', - [ - { - name: 'Replace an empty string', - cursor: { lineNumber: 2, column: 5 }, - rangeToReplace: { - start: { lineNumber: 2, column: 4 }, - end: { lineNumber: 2, column: 10 }, - }, - }, - ] - ); - - contextTests( - { - a: [ - { - c: {}, - }, - ], - }, - MAPPING, - { - endpoints: { - _endpoint: { - patterns: ['_endpoint'], - data_autocomplete_rules: { - a: [{ b: 1 }], - }, - }, - }, - }, - 'GET _endpoint', - [ - { - name: 'List of objects - internal autocomplete', - cursor: { lineNumber: 4, column: 11 }, - autoCompleteSet: ['b'], - }, - { - name: 'List of objects - external template', - cursor: { lineNumber: 1, column: 2 }, - autoCompleteSet: [tt('a', [{}])], - }, - ] - ); - - contextTests( - { - query: { - term: { - field: 'something', - }, - }, - facets: { - test: { - terms: { - field: 'test', - }, - }, - }, - size: 20, - }, - MAPPING, - SEARCH_KB, - 'POST index1/_search', - [ - { - name: 'Field completion as scope', - cursor: { lineNumber: 4, column: 11 }, - autoCompleteSet: [ - tt('field1.1.1', { f: 1 }, 'string'), - tt('field1.1.2', { f: 1 }, 'string'), - ], - }, - { - name: 'Field completion as value', - cursor: { lineNumber: 10, column: 24 }, - autoCompleteSet: [ - { name: 'field1.1.1', meta: 'string' }, - { name: 'field1.1.2', meta: 'string' }, - ], - }, - ] - ); - - // NOTE: This test emits "Can't extract a valid url token path", but that's expected. - contextTests('POST _search\n', MAPPING, SEARCH_KB, null, [ - { - name: 'initial doc start', - cursor: { lineNumber: 2, column: 1 }, - autoCompleteSet: ['{'], - prefixToAdd: '', - suffixToAdd: '', - }, - ]); - - contextTests( - '{\n' + ' "query": {} \n' + '}\n' + '\n' + '\n', - MAPPING, - SEARCH_KB, - 'POST _search', - [ - { - name: 'Cursor rows after request end', - cursor: { lineNumber: 5, column: 1 }, - autoCompleteSet: ['GET', 'PUT', 'POST', 'DELETE', 'HEAD', 'PATCH'], - prefixToAdd: '', - suffixToAdd: ' ', - }, - { - name: 'Cursor just after request end', - cursor: { lineNumber: 3, column: 2 }, - no_context: true, - }, - ] - ); - - const CLUSTER_KB = { - endpoints: { - _search: { - patterns: ['_search', '{index}/_search'], - url_params: { - search_type: ['count', 'query_then_fetch'], - scroll: '10m', - }, - methods: ['GET'], - data_autocomplete_rules: {}, - }, - '_cluster/stats': { - patterns: ['_cluster/stats'], - indices_mode: 'none', - data_autocomplete_rules: {}, - methods: ['GET'], - }, - '_cluster/nodes/stats': { - patterns: ['_cluster/nodes/stats'], - data_autocomplete_rules: {}, - methods: ['GET'], - }, - }, - }; - - contextTests(null, MAPPING, CLUSTER_KB, 'GET _cluster', [ - { - name: 'Endpoints with slashes - no slash', - cursor: { lineNumber: 1, column: 9 }, - autoCompleteSet: ['_cluster/nodes/stats', '_cluster/stats', '_search', 'index1', 'index2'], - prefixToAdd: '', - suffixToAdd: '', - }, - ]); - - contextTests(null, MAPPING, CLUSTER_KB, 'GET _cluster/', [ - { - name: 'Endpoints with slashes - before slash', - cursor: { lineNumber: 1, column: 8 }, - autoCompleteSet: ['_cluster/nodes/stats', '_cluster/stats', '_search', 'index1', 'index2'], - prefixToAdd: '', - suffixToAdd: '', - }, - { - name: 'Endpoints with slashes - on slash', - cursor: { lineNumber: 1, column: 13 }, - autoCompleteSet: ['_cluster/nodes/stats', '_cluster/stats', '_search', 'index1', 'index2'], - prefixToAdd: '', - suffixToAdd: '', - }, - { - name: 'Endpoints with slashes - after slash', - cursor: { lineNumber: 1, column: 14 }, - autoCompleteSet: ['nodes/stats', 'stats'], - prefixToAdd: '', - suffixToAdd: '', - }, - ]); - - contextTests(null, MAPPING, CLUSTER_KB, 'GET _cluster/no', [ - { - name: 'Endpoints with slashes - after slash', - cursor: { lineNumber: 1, column: 15 }, - autoCompleteSet: [ - { name: 'nodes/stats', meta: 'endpoint' }, - { name: 'stats', meta: 'endpoint' }, - ], - prefixToAdd: '', - suffixToAdd: '', - initialValue: 'no', - }, - ]); - - contextTests(null, MAPPING, CLUSTER_KB, 'GET _cluster/nodes/st', [ - { - name: 'Endpoints with two slashes', - cursor: { lineNumber: 1, column: 21 }, - autoCompleteSet: ['stats'], - prefixToAdd: '', - suffixToAdd: '', - initialValue: 'st', - }, - ]); - - contextTests(null, MAPPING, CLUSTER_KB, 'GET ', [ - { - name: 'Immediately after space + method', - cursor: { lineNumber: 1, column: 5 }, - autoCompleteSet: [ - { name: '_cluster/nodes/stats', meta: 'endpoint' }, - { name: '_cluster/stats', meta: 'endpoint' }, - { name: '_search', meta: 'endpoint' }, - { name: 'index1', meta: 'index' }, - { name: 'index2', meta: 'index' }, - ], - prefixToAdd: '', - suffixToAdd: '', - initialValue: '', - }, - ]); - - contextTests(null, MAPPING, CLUSTER_KB, 'GET cl', [ - { - name: 'Endpoints by subpart GET', - cursor: { lineNumber: 1, column: 7 }, - autoCompleteSet: [ - { name: '_cluster/nodes/stats', meta: 'endpoint' }, - { name: '_cluster/stats', meta: 'endpoint' }, - { name: '_search', meta: 'endpoint' }, - { name: 'index1', meta: 'index' }, - { name: 'index2', meta: 'index' }, - ], - prefixToAdd: '', - suffixToAdd: '', - initialValue: 'cl', - method: 'GET', - }, - ]); - - contextTests(null, MAPPING, CLUSTER_KB, 'POST cl', [ - { - name: 'Endpoints by subpart POST', - cursor: { lineNumber: 1, column: 8 }, - no_context: true, - prefixToAdd: '', - suffixToAdd: '', - initialValue: 'cl', - }, - ]); - - contextTests(null, MAPPING, CLUSTER_KB, 'GET _search?', [ - { - name: 'Params just after ?', - cursor: { lineNumber: 1, column: 13 }, - autoCompleteSet: [ - { name: 'filter_path', meta: 'param', insertValue: 'filter_path=' }, - { name: 'format', meta: 'param', insertValue: 'format=' }, - { name: 'pretty', meta: 'flag' }, - { name: 'scroll', meta: 'param', insertValue: 'scroll=' }, - { name: 'search_type', meta: 'param', insertValue: 'search_type=' }, - ], - prefixToAdd: '', - suffixToAdd: '', - }, - ]); - - contextTests(null, MAPPING, CLUSTER_KB, 'GET _search?format=', [ - { - name: 'Params values', - cursor: { lineNumber: 1, column: 20 }, - autoCompleteSet: [ - { name: 'json', meta: 'format' }, - { name: 'yaml', meta: 'format' }, - ], - prefixToAdd: '', - suffixToAdd: '', - }, - ]); - - contextTests(null, MAPPING, CLUSTER_KB, 'GET _search?format=yaml&', [ - { - name: 'Params after amp', - cursor: { lineNumber: 1, column: 25 }, - autoCompleteSet: [ - { name: 'filter_path', meta: 'param', insertValue: 'filter_path=' }, - { name: 'format', meta: 'param', insertValue: 'format=' }, - { name: 'pretty', meta: 'flag' }, - { name: 'scroll', meta: 'param', insertValue: 'scroll=' }, - { name: 'search_type', meta: 'param', insertValue: 'search_type=' }, - ], - prefixToAdd: '', - suffixToAdd: '', - }, - ]); - - contextTests(null, MAPPING, CLUSTER_KB, 'GET _search?format=yaml&search', [ - { - name: 'Params on existing param', - cursor: { lineNumber: 1, column: 27 }, - rangeToReplace: { - start: { lineNumber: 1, column: 25 }, - end: { lineNumber: 1, column: 31 }, - }, - autoCompleteSet: [ - { name: 'filter_path', meta: 'param', insertValue: 'filter_path=' }, - { name: 'format', meta: 'param', insertValue: 'format=' }, - { name: 'pretty', meta: 'flag' }, - { name: 'scroll', meta: 'param', insertValue: 'scroll=' }, - { name: 'search_type', meta: 'param', insertValue: 'search_type=' }, - ], - prefixToAdd: '', - suffixToAdd: '', - }, - ]); - - contextTests(null, MAPPING, CLUSTER_KB, 'GET _search?format=yaml&search_type=cou', [ - { - name: 'Params on existing value', - cursor: { lineNumber: 1, column: 38 }, - rangeToReplace: { - start: { lineNumber: 1, column: 37 }, - end: { lineNumber: 1, column: 40 }, - }, - autoCompleteSet: [ - { name: 'count', meta: 'search_type' }, - { name: 'query_then_fetch', meta: 'search_type' }, - ], - prefixToAdd: '', - suffixToAdd: '', - }, - ]); - - contextTests(null, MAPPING, CLUSTER_KB, 'GET _search?format=yaml&search_type=cou', [ - { - name: 'Params on just after = with existing value', - cursor: { lineNumber: 1, column: 37 }, - rangeToReplace: { - start: { lineNumber: 1, column: 37 }, - end: { lineNumber: 1, column: 37 }, - }, - autoCompleteSet: [ - { name: 'count', meta: 'search_type' }, - { name: 'query_then_fetch', meta: 'search_type' }, - ], - prefixToAdd: '', - suffixToAdd: '', - }, - ]); - - contextTests( - { - query: { - field: 'something', - }, - facets: {}, - size: 20, - }, - MAPPING, - SEARCH_KB, - 'POST http://somehost/_search', - [ - { - name: 'fullurl - existing dictionary key, no template', - cursor: { lineNumber: 2, column: 7 }, - initialValue: 'query', - addTemplate: false, - prefixToAdd: '', - suffixToAdd: '', - rangeToReplace: { - start: { lineNumber: 2, column: 4 }, - end: { lineNumber: 2, column: 11 }, - }, - autoCompleteSet: ['facets', 'query', 'size'], - }, - { - name: 'fullurl - existing inner dictionary key', - cursor: { lineNumber: 3, column: 8 }, - initialValue: 'field', - addTemplate: false, - prefixToAdd: '', - suffixToAdd: '', - rangeToReplace: { - start: { lineNumber: 3, column: 7 }, - end: { lineNumber: 3, column: 14 }, - }, - autoCompleteSet: ['match_all', 'term'], - }, - { - name: 'fullurl - existing dictionary key, yes template', - cursor: { lineNumber: 5, column: 8 }, - initialValue: 'facets', - addTemplate: true, - prefixToAdd: '', - suffixToAdd: '', - rangeToReplace: { - start: { lineNumber: 5, column: 4 }, - end: { lineNumber: 5, column: 16 }, - }, - autoCompleteSet: ['facets', 'query', 'size'], - }, - ] - ); -}); diff --git a/src/plugins/console/public/application/models/sense_editor/sense_editor.test.js b/src/plugins/console/public/application/models/sense_editor/sense_editor.test.js deleted file mode 100644 index 19d782f1b8e87..0000000000000 --- a/src/plugins/console/public/application/models/sense_editor/sense_editor.test.js +++ /dev/null @@ -1,641 +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 './sense_editor.test.mocks'; - -import $ from 'jquery'; -import _ from 'lodash'; -import { URL } from 'url'; - -import { create } from './create'; -import { XJson } from '@kbn/es-ui-shared-plugin/public'; -import editorInput1 from './__fixtures__/editor_input1.txt'; -import { setStorage, createStorage } from '../../../services'; - -const { collapseLiteralStrings } = XJson; - -describe('Editor', () => { - let input; - let oldUrl; - let olldWindow; - let storage; - - beforeEach(function () { - // Set up our document body - document.body.innerHTML = `
-
-
-
-
`; - - input = create(document.querySelector('#ConAppEditor')); - $(input.getCoreEditor().getContainer()).show(); - input.autocomplete._test.removeChangeListener(); - oldUrl = global.URL; - olldWindow = { ...global.window }; - global.URL = URL; - Object.defineProperty(global, 'window', { - value: Object.create(window), - writable: true, - }); - Object.defineProperty(window, 'location', { - value: { - origin: 'http://localhost:5620', - }, - }); - storage = createStorage({ - engine: global.window.localStorage, - prefix: 'console_test', - }); - setStorage(storage); - }); - afterEach(function () { - global.URL = oldUrl; - global.window = olldWindow; - $(input.getCoreEditor().getContainer()).hide(); - input.autocomplete._test.addChangeListener(); - setStorage(null); - }); - - let testCount = 0; - - const callWithEditorMethod = (editorMethod, fn) => async (done) => { - const results = await input[editorMethod](); - fn(results, done); - }; - - function utilsTest(name, prefix, data, testToRun) { - const id = testCount++; - if (typeof data === 'function') { - testToRun = data; - data = null; - } - if (data && typeof data !== 'string') { - data = JSON.stringify(data, null, 3); - } - if (data) { - if (prefix) { - data = prefix + '\n' + data; - } - } else { - data = prefix; - } - - test('Utils test ' + id + ' : ' + name, function (done) { - input.update(data, true).then(() => { - testToRun(done); - }); - }); - } - - function compareRequest(requests, expected) { - if (!Array.isArray(requests)) { - requests = [requests]; - expected = [expected]; - } - - _.each(requests, function (r) { - delete r.range; - }); - expect(requests).toEqual(expected); - } - - const simpleRequest = { - prefix: 'POST _search', - data: ['{', ' "query": { "match_all": {} }', '}'].join('\n'), - }; - - const singleLineRequest = { - prefix: 'POST _search', - data: '{ "query": { "match_all": {} } }', - }; - - const getRequestNoData = { - prefix: 'GET _stats', - }; - - const multiDocRequest = { - prefix: 'POST _bulk', - data_as_array: ['{ "index": { "_index": "index", "_type":"type" } }', '{ "field": 1 }'], - }; - multiDocRequest.data = multiDocRequest.data_as_array.join('\n'); - - utilsTest( - 'simple request range', - simpleRequest.prefix, - simpleRequest.data, - callWithEditorMethod('getRequestRange', (range, done) => { - compareRequest(range, { - start: { lineNumber: 1, column: 1 }, - end: { lineNumber: 4, column: 2 }, - }); - done(); - }) - ); - - utilsTest( - 'simple request data', - simpleRequest.prefix, - simpleRequest.data, - callWithEditorMethod('getRequest', (request, done) => { - const expected = { - method: 'POST', - url: '_search', - data: [simpleRequest.data], - }; - compareRequest(request, expected); - done(); - }) - ); - - utilsTest( - 'simple request range, prefixed with spaces', - ' ' + simpleRequest.prefix, - simpleRequest.data, - callWithEditorMethod('getRequestRange', (range, done) => { - expect(range).toEqual({ - start: { lineNumber: 1, column: 1 }, - end: { lineNumber: 4, column: 2 }, - }); - done(); - }) - ); - - utilsTest( - 'simple request data, prefixed with spaces', - ' ' + simpleRequest.prefix, - simpleRequest.data, - callWithEditorMethod('getRequest', (request, done) => { - const expected = { - method: 'POST', - url: '_search', - data: [simpleRequest.data], - }; - - compareRequest(request, expected); - done(); - }) - ); - - utilsTest( - 'simple request range, suffixed with spaces', - simpleRequest.prefix + ' ', - simpleRequest.data + ' ', - callWithEditorMethod('getRequestRange', (range, done) => { - compareRequest(range, { - start: { lineNumber: 1, column: 1 }, - end: { lineNumber: 4, column: 2 }, - }); - done(); - }) - ); - - utilsTest( - 'simple request data, suffixed with spaces', - simpleRequest.prefix + ' ', - simpleRequest.data + ' ', - callWithEditorMethod('getRequest', (request, done) => { - const expected = { - method: 'POST', - url: '_search', - data: [simpleRequest.data], - }; - - compareRequest(request, expected); - done(); - }) - ); - - utilsTest( - 'single line request range', - singleLineRequest.prefix, - singleLineRequest.data, - callWithEditorMethod('getRequestRange', (range, done) => { - compareRequest(range, { - start: { lineNumber: 1, column: 1 }, - end: { lineNumber: 2, column: 33 }, - }); - done(); - }) - ); - - utilsTest( - 'full url: single line request data', - 'POST https://somehost/_search', - singleLineRequest.data, - callWithEditorMethod('getRequest', (request, done) => { - const expected = { - method: 'POST', - url: 'https://somehost/_search', - data: [singleLineRequest.data], - }; - compareRequest(request, expected); - done(); - }) - ); - - utilsTest( - 'request with no data followed by a new line', - getRequestNoData.prefix, - '\n', - callWithEditorMethod('getRequestRange', (range, done) => { - compareRequest(range, { - start: { lineNumber: 1, column: 1 }, - end: { lineNumber: 1, column: 11 }, - }); - done(); - }) - ); - - utilsTest( - 'request with no data followed by a new line (data)', - getRequestNoData.prefix, - '\n', - callWithEditorMethod('getRequest', (request, done) => { - const expected = { - method: 'GET', - url: '_stats', - data: [], - }; - compareRequest(request, expected); - done(); - }) - ); - - utilsTest( - 'request with no data', - getRequestNoData.prefix, - getRequestNoData.data, - callWithEditorMethod('getRequestRange', (range, done) => { - expect(range).toEqual({ - start: { lineNumber: 1, column: 1 }, - end: { lineNumber: 1, column: 11 }, - }); - done(); - }) - ); - - utilsTest( - 'request with no data (data)', - getRequestNoData.prefix, - getRequestNoData.data, - callWithEditorMethod('getRequest', (request, done) => { - const expected = { - method: 'GET', - url: '_stats', - data: [], - }; - compareRequest(request, expected); - done(); - }) - ); - - utilsTest( - 'multi doc request range', - multiDocRequest.prefix, - multiDocRequest.data, - callWithEditorMethod('getRequestRange', (range, done) => { - expect(range).toEqual({ - start: { lineNumber: 1, column: 1 }, - end: { lineNumber: 3, column: 15 }, - }); - done(); - }) - ); - - utilsTest( - 'multi doc request data', - multiDocRequest.prefix, - multiDocRequest.data, - callWithEditorMethod('getRequest', (request, done) => { - const expected = { - method: 'POST', - url: '_bulk', - data: multiDocRequest.data_as_array, - }; - compareRequest(request, expected); - done(); - }) - ); - - const scriptRequest = { - prefix: 'POST _search', - data: ['{', ' "query": { "script": """', ' some script ', ' """}', '}'].join('\n'), - }; - - utilsTest( - 'script request range', - scriptRequest.prefix, - scriptRequest.data, - callWithEditorMethod('getRequestRange', (range, done) => { - compareRequest(range, { - start: { lineNumber: 1, column: 1 }, - end: { lineNumber: 6, column: 2 }, - }); - done(); - }) - ); - - utilsTest( - 'simple request data', - simpleRequest.prefix, - simpleRequest.data, - callWithEditorMethod('getRequest', (request, done) => { - const expected = { - method: 'POST', - url: '_search', - data: [collapseLiteralStrings(simpleRequest.data)], - }; - - compareRequest(request, expected); - done(); - }) - ); - - function multiReqTest(name, editorInput, range, expected) { - utilsTest('multi request select - ' + name, editorInput, async function (done) { - const requests = await input.getRequestsInRange(range, false); - // convert to format returned by request. - _.each(expected, function (req) { - req.data = req.data == null ? [] : [JSON.stringify(req.data, null, 2)]; - }); - - compareRequest(requests, expected); - done(); - }); - } - - multiReqTest( - 'mid body to mid body', - editorInput1, - { start: { lineNumber: 13 }, end: { lineNumber: 18 } }, - [ - { - method: 'PUT', - url: 'index_1/type1/1', - data: { - f: 1, - }, - }, - { - method: 'PUT', - url: 'index_1/type1/2', - data: { - f: 2, - }, - }, - ] - ); - - multiReqTest( - 'single request start to end', - editorInput1, - { start: { lineNumber: 11 }, end: { lineNumber: 14 } }, - [ - { - method: 'PUT', - url: 'index_1/type1/1', - data: { - f: 1, - }, - }, - ] - ); - - multiReqTest( - 'start to end, with comment', - editorInput1, - { start: { lineNumber: 7 }, end: { lineNumber: 14 } }, - [ - { - method: 'GET', - url: '_stats?level=shards', - data: null, - }, - { - method: 'PUT', - url: 'index_1/type1/1', - data: { - f: 1, - }, - }, - ] - ); - - multiReqTest( - 'before start to after end, with comments', - editorInput1, - { start: { lineNumber: 5 }, end: { lineNumber: 15 } }, - [ - { - method: 'GET', - url: '_stats?level=shards', - data: null, - }, - { - method: 'PUT', - url: 'index_1/type1/1', - data: { - f: 1, - }, - }, - ] - ); - - multiReqTest( - 'between requests', - editorInput1, - { start: { lineNumber: 22 }, end: { lineNumber: 23 } }, - [] - ); - - multiReqTest( - 'between requests - with comment', - editorInput1, - { start: { lineNumber: 21 }, end: { lineNumber: 23 } }, - [] - ); - - multiReqTest( - 'between requests - before comment', - editorInput1, - { start: { lineNumber: 20 }, end: { lineNumber: 23 } }, - [] - ); - - function multiReqCopyAsCurlTest(name, editorInput, range, expected) { - utilsTest('multi request copy as curl - ' + name, editorInput, async function (done) { - const curl = await input.getRequestsAsCURL('http://localhost:9200', range); - expect(curl).toEqual(expected); - done(); - }); - } - - multiReqCopyAsCurlTest( - 'start to end, with comment', - editorInput1, - { start: { lineNumber: 7 }, end: { lineNumber: 14 } }, - ` -curl -XGET "http://localhost:9200/_stats?level=shards" -H "kbn-xsrf: reporting" - -#in between comment - -curl -XPUT "http://localhost:9200/index_1/type1/1" -H "kbn-xsrf: reporting" -H "Content-Type: application/json" -d' -{ - "f": 1 -}'`.trim() - ); - - multiReqCopyAsCurlTest( - 'with single quotes', - editorInput1, - { start: { lineNumber: 29 }, end: { lineNumber: 33 } }, - ` -curl -XPOST "http://localhost:9200/_sql?format=txt" -H "kbn-xsrf: reporting" -H "Content-Type: application/json" -d' -{ - "query": "SELECT prenom FROM claude_index WHERE prenom = '\\''claude'\\'' ", - "fetch_size": 1 -}'`.trim() - ); - - multiReqCopyAsCurlTest( - 'with date math index', - editorInput1, - { start: { lineNumber: 35 }, end: { lineNumber: 35 } }, - ` - curl -XGET "http://localhost:9200/%3Cindex_1-%7Bnow%2Fd-2d%7D%3E%2C%3Cindex_1-%7Bnow%2Fd-1d%7D%3E%2C%3Cindex_1-%7Bnow%2Fd%7D%3E%2F_search?pretty" -H "kbn-xsrf: reporting"`.trim() - ); - - multiReqCopyAsCurlTest( - 'with Kibana API request', - editorInput1, - { start: { lineNumber: 37 }, end: { lineNumber: 37 } }, - ` -curl -XGET "http://localhost:5620/api/spaces/space" -H \"kbn-xsrf: reporting\"`.trim() - ); - - describe('getRequestsAsCURL', () => { - it('should return empty string if no requests', async () => { - input?.getCoreEditor().setValue('', false); - const curl = await input.getRequestsAsCURL('http://localhost:9200', { - start: { lineNumber: 1 }, - end: { lineNumber: 1 }, - }); - expect(curl).toEqual(''); - }); - - it('should replace variables in the URL', async () => { - storage.set('variables', [{ name: 'exampleVariableA', value: 'valueA' }]); - input?.getCoreEditor().setValue('GET ${exampleVariableA}', false); - const curl = await input.getRequestsAsCURL('http://localhost:9200', { - start: { lineNumber: 1 }, - end: { lineNumber: 1 }, - }); - expect(curl).toContain('valueA'); - }); - - it('should replace variables in the body', async () => { - storage.set('variables', [{ name: 'exampleVariableB', value: 'valueB' }]); - console.log(storage.get('variables')); - input - ?.getCoreEditor() - .setValue('GET _search\n{\t\t"query": {\n\t\t\t"${exampleVariableB}": ""\n\t}\n}', false); - const curl = await input.getRequestsAsCURL('http://localhost:9200', { - start: { lineNumber: 1 }, - end: { lineNumber: 6 }, - }); - expect(curl).toContain('valueB'); - }); - - it('should strip comments in the URL', async () => { - input?.getCoreEditor().setValue('GET _search // comment', false); - const curl = await input.getRequestsAsCURL('http://localhost:9200', { - start: { lineNumber: 1 }, - end: { lineNumber: 6 }, - }); - expect(curl).not.toContain('comment'); - }); - - it('should strip comments in the body', async () => { - input - ?.getCoreEditor() - .setValue('{\n\t"query": {\n\t\t"match_all": {} // comment \n\t}\n}', false); - const curl = await input.getRequestsAsCURL('http://localhost:9200', { - start: { lineNumber: 1 }, - end: { lineNumber: 8 }, - }); - console.log('curl', curl); - expect(curl).not.toContain('comment'); - }); - - it('should strip multi-line comments in the body', async () => { - input - ?.getCoreEditor() - .setValue('{\n\t"query": {\n\t\t"match_all": {} /* comment */\n\t}\n}', false); - const curl = await input.getRequestsAsCURL('http://localhost:9200', { - start: { lineNumber: 1 }, - end: { lineNumber: 8 }, - }); - console.log('curl', curl); - expect(curl).not.toContain('comment'); - }); - - it('should replace multiple variables in the URL', async () => { - storage.set('variables', [ - { name: 'exampleVariableA', value: 'valueA' }, - { name: 'exampleVariableB', value: 'valueB' }, - ]); - input?.getCoreEditor().setValue('GET ${exampleVariableA}/${exampleVariableB}', false); - const curl = await input.getRequestsAsCURL('http://localhost:9200', { - start: { lineNumber: 1 }, - end: { lineNumber: 1 }, - }); - expect(curl).toContain('valueA'); - expect(curl).toContain('valueB'); - }); - - it('should replace multiple variables in the body', async () => { - storage.set('variables', [ - { name: 'exampleVariableA', value: 'valueA' }, - { name: 'exampleVariableB', value: 'valueB' }, - ]); - input - ?.getCoreEditor() - .setValue( - 'GET _search\n{\t\t"query": {\n\t\t\t"${exampleVariableA}": "${exampleVariableB}"\n\t}\n}', - false - ); - const curl = await input.getRequestsAsCURL('http://localhost:9200', { - start: { lineNumber: 1 }, - end: { lineNumber: 6 }, - }); - expect(curl).toContain('valueA'); - expect(curl).toContain('valueB'); - }); - - it('should replace variables in bulk request', async () => { - storage.set('variables', [ - { name: 'exampleVariableA', value: 'valueA' }, - { name: 'exampleVariableB', value: 'valueB' }, - ]); - input - ?.getCoreEditor() - .setValue( - 'POST _bulk\n{"index": {"_id": "0"}}\n{"field" : "${exampleVariableA}"}\n{"index": {"_id": "1"}}\n{"field" : "${exampleVariableB}"}\n', - false - ); - const curl = await input.getRequestsAsCURL('http://localhost:9200', { - start: { lineNumber: 1 }, - end: { lineNumber: 4 }, - }); - expect(curl).toContain('valueA'); - expect(curl).toContain('valueB'); - }); - }); -}); diff --git a/src/plugins/console/public/application/models/sense_editor/sense_editor.test.mocks.ts b/src/plugins/console/public/application/models/sense_editor/sense_editor.test.mocks.ts deleted file mode 100644 index f0ec279fb4ffe..0000000000000 --- a/src/plugins/console/public/application/models/sense_editor/sense_editor.test.mocks.ts +++ /dev/null @@ -1,20 +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". - */ - -/* eslint no-undef: 0 */ - -import '../legacy_core_editor/legacy_core_editor.test.mocks'; - -import jQuery from 'jquery'; -jest.spyOn(jQuery, 'ajax').mockImplementation( - () => - new Promise(() => { - // never resolve - }) as any -); diff --git a/src/plugins/console/public/application/models/sense_editor/sense_editor.ts b/src/plugins/console/public/application/models/sense_editor/sense_editor.ts deleted file mode 100644 index f6b0439cb283e..0000000000000 --- a/src/plugins/console/public/application/models/sense_editor/sense_editor.ts +++ /dev/null @@ -1,534 +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 _ from 'lodash'; -import { parse } from 'hjson'; -import { XJson } from '@kbn/es-ui-shared-plugin/public'; - -import RowParser from '../../../lib/row_parser'; -import * as utils from '../../../lib/utils'; -import { constructUrl } from '../../../lib/es/es'; - -import { CoreEditor, Position, Range } from '../../../types'; -import { createTokenIterator } from '../../factories'; -import createAutocompleter from '../../../lib/autocomplete/autocomplete'; -import { getStorage, StorageKeys } from '../../../services'; -import { DEFAULT_VARIABLES } from '../../../../common/constants'; - -const { collapseLiteralStrings } = XJson; - -export class SenseEditor { - currentReqRange: (Range & { markerRef: unknown }) | null; - parser: RowParser; - - private readonly autocomplete: ReturnType; - - constructor(private readonly coreEditor: CoreEditor) { - this.currentReqRange = null; - this.parser = new RowParser(this.coreEditor); - this.autocomplete = createAutocompleter({ - coreEditor, - parser: this.parser, - }); - this.coreEditor.registerAutocompleter(this.autocomplete.getCompletions); - this.coreEditor.on( - 'tokenizerUpdate', - this.highlightCurrentRequestsAndUpdateActionBar.bind(this) - ); - this.coreEditor.on('changeCursor', this.highlightCurrentRequestsAndUpdateActionBar.bind(this)); - this.coreEditor.on('changeScrollTop', this.updateActionsBar.bind(this)); - } - - prevRequestStart = (rowOrPos?: number | Position): Position => { - let curRow: number; - - if (rowOrPos == null) { - curRow = this.coreEditor.getCurrentPosition().lineNumber; - } else if (_.isObject(rowOrPos)) { - curRow = (rowOrPos as Position).lineNumber; - } else { - curRow = rowOrPos as number; - } - - while (curRow > 0 && !this.parser.isStartRequestRow(curRow, this.coreEditor)) curRow--; - - return { - lineNumber: curRow, - column: 1, - }; - }; - - nextRequestStart = (rowOrPos?: number | Position) => { - let curRow: number; - if (rowOrPos == null) { - curRow = this.coreEditor.getCurrentPosition().lineNumber; - } else if (_.isObject(rowOrPos)) { - curRow = (rowOrPos as Position).lineNumber; - } else { - curRow = rowOrPos as number; - } - const maxLines = this.coreEditor.getLineCount(); - for (; curRow < maxLines - 1; curRow++) { - if (this.parser.isStartRequestRow(curRow, this.coreEditor)) { - break; - } - } - return { - row: curRow, - column: 0, - }; - }; - - autoIndent = _.debounce(async () => { - await this.coreEditor.waitForLatestTokens(); - const reqRange = await this.getRequestRange(); - if (!reqRange) { - return; - } - const parsedReq = await this.getRequest(); - - if (!parsedReq) { - return; - } - - if (parsedReq.data.some((doc) => utils.hasComments(doc))) { - /** - * Comments require different approach for indentation and do not have condensed format - * We need to delegate indentation logic to coreEditor since it has access to session and other methods used for formatting and indenting the comments - */ - this.coreEditor.autoIndent(parsedReq.range); - return; - } - - if (parsedReq.data && parsedReq.data.length > 0) { - let indent = parsedReq.data.length === 1; // unindent multi docs by default - let formattedData = utils.formatRequestBodyDoc(parsedReq.data, indent); - if (!formattedData.changed) { - // toggle. - indent = !indent; - formattedData = utils.formatRequestBodyDoc(parsedReq.data, indent); - } - parsedReq.data = formattedData.data; - - this.replaceRequestRange(parsedReq, reqRange); - } - }, 25); - - update = async (data: string, reTokenizeAll = false) => { - return this.coreEditor.setValue(data, reTokenizeAll); - }; - - replaceRequestRange = ( - newRequest: { method: string; url: string; data: string | string[] }, - requestRange: Range - ) => { - const text = utils.textFromRequest(newRequest); - if (requestRange) { - this.coreEditor.replaceRange(requestRange, text); - } else { - // just insert where we are - this.coreEditor.insert(this.coreEditor.getCurrentPosition(), text); - } - }; - - getRequestRange = async (lineNumber?: number): Promise => { - await this.coreEditor.waitForLatestTokens(); - - if (this.parser.isInBetweenRequestsRow(lineNumber)) { - return null; - } - - const reqStart = this.prevRequestStart(lineNumber); - const reqEnd = this.nextRequestEnd(reqStart); - - return { - start: { - ...reqStart, - }, - end: { - ...reqEnd, - }, - }; - }; - - expandRangeToRequestEdges = async ( - range = this.coreEditor.getSelectionRange() - ): Promise => { - await this.coreEditor.waitForLatestTokens(); - - let startLineNumber = range.start.lineNumber; - let endLineNumber = range.end.lineNumber; - const maxLine = Math.max(1, this.coreEditor.getLineCount()); - - if (this.parser.isInBetweenRequestsRow(startLineNumber)) { - /* Do nothing... */ - } else { - for (; startLineNumber >= 1; startLineNumber--) { - if (this.parser.isStartRequestRow(startLineNumber)) { - break; - } - } - } - - if (startLineNumber < 1 || startLineNumber > endLineNumber) { - return null; - } - // move end row to the previous request end if between requests, otherwise walk forward - if (this.parser.isInBetweenRequestsRow(endLineNumber)) { - for (; endLineNumber >= startLineNumber; endLineNumber--) { - if (this.parser.isEndRequestRow(endLineNumber)) { - break; - } - } - } else { - for (; endLineNumber <= maxLine; endLineNumber++) { - if (this.parser.isEndRequestRow(endLineNumber)) { - break; - } - } - } - - if (endLineNumber < startLineNumber || endLineNumber > maxLine) { - return null; - } - - const endColumn = - (this.coreEditor.getLineValue(endLineNumber) || '').replace(/\s+$/, '').length + 1; - return { - start: { - lineNumber: startLineNumber, - column: 1, - }, - end: { - lineNumber: endLineNumber, - column: endColumn, - }, - }; - }; - - getRequestInRange = async (range?: Range) => { - await this.coreEditor.waitForLatestTokens(); - if (!range) { - return null; - } - const request: { - method: string; - data: string[]; - url: string; - range: Range; - } = { - method: '', - data: [], - url: '', - range, - }; - - const pos = range.start; - const tokenIter = createTokenIterator({ editor: this.coreEditor, position: pos }); - let t = tokenIter.getCurrentToken(); - if (this.parser.isEmptyToken(t)) { - // if the row starts with some spaces, skip them. - t = this.parser.nextNonEmptyToken(tokenIter); - } - if (t == null) { - return null; - } - - request.method = t.value; - t = this.parser.nextNonEmptyToken(tokenIter); - - if (!t || t.type === 'method') { - return null; - } - - request.url = ''; - - while (t && t.type && (t.type.indexOf('url') === 0 || t.type === 'variable.template')) { - request.url += t.value; - t = tokenIter.stepForward(); - } - if (this.parser.isEmptyToken(t)) { - // if the url row ends with some spaces, skip them. - t = this.parser.nextNonEmptyToken(tokenIter); - } - - // If the url row ends with a comment, skip it - while (this.parser.isCommentToken(t)) { - t = tokenIter.stepForward(); - } - - let bodyStartLineNumber = (t ? 0 : 1) + tokenIter.getCurrentPosition().lineNumber; // artificially increase end of docs. - let dataEndPos: Position; - while ( - bodyStartLineNumber < range.end.lineNumber || - (bodyStartLineNumber === range.end.lineNumber && 1 < range.end.column) - ) { - dataEndPos = this.nextDataDocEnd({ - lineNumber: bodyStartLineNumber, - column: 1, - }); - const bodyRange: Range = { - start: { - lineNumber: bodyStartLineNumber, - column: 1, - }, - end: dataEndPos, - }; - const data = this.coreEditor.getValueInRange(bodyRange)!; - request.data.push(data.trim()); - bodyStartLineNumber = dataEndPos.lineNumber + 1; - } - - return request; - }; - - getRequestsInRange = async ( - range = this.coreEditor.getSelectionRange(), - includeNonRequestBlocks = false - ): Promise => { - await this.coreEditor.waitForLatestTokens(); - if (!range) { - return []; - } - - const expandedRange = await this.expandRangeToRequestEdges(range); - if (!expandedRange) { - return []; - } - - const requests: unknown[] = []; - - let rangeStartCursor = expandedRange.start.lineNumber; - const endLineNumber = expandedRange.end.lineNumber; - - // move to the next request start (during the second iterations this may not be exactly on a request - let currentLineNumber = expandedRange.start.lineNumber; - - const flushNonRequestBlock = () => { - if (includeNonRequestBlocks) { - const nonRequestPrefixBlock = this.coreEditor - .getLines(rangeStartCursor, currentLineNumber - 1) - .join('\n'); - if (nonRequestPrefixBlock) { - requests.push(nonRequestPrefixBlock); - } - } - }; - - while (currentLineNumber <= endLineNumber) { - if (this.parser.isStartRequestRow(currentLineNumber)) { - flushNonRequestBlock(); - const request = await this.getRequest(currentLineNumber); - if (!request) { - // Something has probably gone wrong. - return requests; - } else { - requests.push(request); - rangeStartCursor = currentLineNumber = request.range.end.lineNumber + 1; - } - } else { - ++currentLineNumber; - } - } - - flushNonRequestBlock(); - - return requests; - }; - - getRequest = async (row?: number) => { - await this.coreEditor.waitForLatestTokens(); - if (this.parser.isInBetweenRequestsRow(row)) { - return null; - } - - const range = await this.getRequestRange(row); - return this.getRequestInRange(range!); - }; - - moveToPreviousRequestEdge = async () => { - await this.coreEditor.waitForLatestTokens(); - const pos = this.coreEditor.getCurrentPosition(); - for ( - pos.lineNumber--; - pos.lineNumber > 1 && !this.parser.isRequestEdge(pos.lineNumber); - pos.lineNumber-- - ) { - // loop for side effects - } - this.coreEditor.moveCursorToPosition({ - lineNumber: pos.lineNumber, - column: 1, - }); - }; - - moveToNextRequestEdge = async (moveOnlyIfNotOnEdge: boolean) => { - await this.coreEditor.waitForLatestTokens(); - const pos = this.coreEditor.getCurrentPosition(); - const maxRow = this.coreEditor.getLineCount(); - if (!moveOnlyIfNotOnEdge) { - pos.lineNumber++; - } - for ( - ; - pos.lineNumber < maxRow && !this.parser.isRequestEdge(pos.lineNumber); - pos.lineNumber++ - ) { - // loop for side effects - } - this.coreEditor.moveCursorToPosition({ - lineNumber: pos.lineNumber, - column: 1, - }); - }; - - nextRequestEnd = (pos: Position): Position => { - pos = pos || this.coreEditor.getCurrentPosition(); - const maxLines = this.coreEditor.getLineCount(); - let curLineNumber = pos.lineNumber; - for (; curLineNumber <= maxLines; ++curLineNumber) { - const curRowMode = this.parser.getRowParseMode(curLineNumber); - // eslint-disable-next-line no-bitwise - if ((curRowMode & this.parser.MODE.REQUEST_END) > 0) { - break; - } - // eslint-disable-next-line no-bitwise - if (curLineNumber !== pos.lineNumber && (curRowMode & this.parser.MODE.REQUEST_START) > 0) { - break; - } - } - - const column = - (this.coreEditor.getLineValue(curLineNumber) || '').replace(/\s+$/, '').length + 1; - - return { - lineNumber: curLineNumber, - column, - }; - }; - - nextDataDocEnd = (pos: Position): Position => { - pos = pos || this.coreEditor.getCurrentPosition(); - let curLineNumber = pos.lineNumber; - const maxLines = this.coreEditor.getLineCount(); - for (; curLineNumber < maxLines; curLineNumber++) { - const curRowMode = this.parser.getRowParseMode(curLineNumber); - // eslint-disable-next-line no-bitwise - if ((curRowMode & this.parser.MODE.REQUEST_END) > 0) { - break; - } - // eslint-disable-next-line no-bitwise - if ((curRowMode & this.parser.MODE.MULTI_DOC_CUR_DOC_END) > 0) { - break; - } - // eslint-disable-next-line no-bitwise - if (curLineNumber !== pos.lineNumber && (curRowMode & this.parser.MODE.REQUEST_START) > 0) { - break; - } - } - - const column = - (this.coreEditor.getLineValue(curLineNumber) || '').length + - 1; /* Range goes to 1 after last char */ - - return { - lineNumber: curLineNumber, - column, - }; - }; - - highlightCurrentRequestsAndUpdateActionBar = _.debounce(async () => { - await this.coreEditor.waitForLatestTokens(); - const expandedRange = await this.expandRangeToRequestEdges(); - if (expandedRange === null && this.currentReqRange === null) { - return; - } - if ( - expandedRange !== null && - this.currentReqRange !== null && - expandedRange.start.lineNumber === this.currentReqRange.start.lineNumber && - expandedRange.end.lineNumber === this.currentReqRange.end.lineNumber - ) { - // same request, now see if we are on the first line and update the action bar - const cursorLineNumber = this.coreEditor.getCurrentPosition().lineNumber; - if (cursorLineNumber === this.currentReqRange.start.lineNumber) { - this.updateActionsBar(); - } - return; // nothing to do.. - } - - if (this.currentReqRange) { - this.coreEditor.removeMarker(this.currentReqRange.markerRef); - } - - this.currentReqRange = expandedRange as any; - if (this.currentReqRange) { - this.currentReqRange.markerRef = this.coreEditor.addMarker(this.currentReqRange); - } - this.updateActionsBar(); - }, 25); - - getRequestsAsCURL = async (elasticsearchBaseUrl: string, range?: Range): Promise => { - const variables = getStorage().get(StorageKeys.VARIABLES, DEFAULT_VARIABLES); - let requests = await this.getRequestsInRange(range, true); - requests = utils.replaceVariables(requests, variables); - const result = _.map(requests, (req) => { - if (typeof req === 'string') { - // no request block - return req; - } - - const path = req.url; - const method = req.method; - const data = req.data; - - // this is the first url defined in elasticsearch.hosts - const url = constructUrl(elasticsearchBaseUrl, path); - - // Append 'kbn-xsrf' header to bypass (XSRF/CSRF) protections - let ret = `curl -X${method.toUpperCase()} "${url}" -H "kbn-xsrf: reporting"`; - - if (data && data.length) { - const joinedData = data.join('\n'); - let dataAsString: string; - - try { - ret += ` -H "Content-Type: application/json" -d'\n`; - - if (utils.hasComments(joinedData)) { - // if there are comments in the data, we need to strip them out - const dataWithoutComments = parse(joinedData); - dataAsString = collapseLiteralStrings(JSON.stringify(dataWithoutComments, null, 2)); - } else { - dataAsString = collapseLiteralStrings(joinedData); - } - // We escape single quoted strings that are wrapped in single quoted strings - ret += dataAsString.replace(/'/g, "'\\''"); - if (data.length > 1) { - ret += '\n'; - } // end with a new line - ret += "'"; - } catch (e) { - throw new Error(`Error parsing data: ${e.message}`); - } - } - return ret; - }); - - return result.join('\n'); - }; - - updateActionsBar = () => { - return this.coreEditor.legacyUpdateUI(this.currentReqRange); - }; - - getCoreEditor() { - return this.coreEditor; - } -} diff --git a/src/plugins/console/public/application/stores/editor.ts b/src/plugins/console/public/application/stores/editor.ts index 556f4f64337e6..8ae24e5a422b7 100644 --- a/src/plugins/console/public/application/stores/editor.ts +++ b/src/plugins/console/public/application/stores/editor.ts @@ -12,7 +12,6 @@ import { produce } from 'immer'; import { identity } from 'fp-ts/lib/function'; import { DevToolsSettings, DEFAULT_SETTINGS } from '../../services'; import { TextObject } from '../../../common/text_object'; -import { SenseEditor } from '../models'; import { SHELL_TAB_ID } from '../containers/main/constants'; import { MonacoEditorActionsProvider } from '../containers/editor/monaco_editor_actions_provider'; import { RequestToRestore } from '../../types'; @@ -39,7 +38,7 @@ export const initialValue: Store = produce( ); export type Action = - | { type: 'setInputEditor'; payload: SenseEditor | MonacoEditorActionsProvider } + | { type: 'setInputEditor'; payload: MonacoEditorActionsProvider } | { type: 'setCurrentTextObject'; payload: TextObject } | { type: 'updateSettings'; payload: DevToolsSettings } | { type: 'setCurrentView'; payload: string } diff --git a/src/plugins/console/public/lib/ace_token_provider/token_provider.test.ts b/src/plugins/console/public/lib/ace_token_provider/token_provider.test.ts deleted file mode 100644 index b36d9855414bd..0000000000000 --- a/src/plugins/console/public/lib/ace_token_provider/token_provider.test.ts +++ /dev/null @@ -1,223 +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 '../../application/models/sense_editor/sense_editor.test.mocks'; - -import $ from 'jquery'; - -// TODO: -// We import from application models as a convenient way to bootstrap loading up of an editor using -// this lib. We also need to import application specific mocks which is not ideal. -// In this situation, the token provider lib knows about app models in tests, which it really shouldn't. Should create -// a better sandbox in future. -import { create, SenseEditor } from '../../application/models/sense_editor'; - -import { Position, Token, TokensProvider } from '../../types'; - -interface RunTestArgs { - input: string; - done?: () => void; -} - -describe('Ace (legacy) token provider', () => { - let senseEditor: SenseEditor; - let tokenProvider: TokensProvider; - beforeEach(() => { - // Set up our document body - document.body.innerHTML = `
-
-
-
-
`; - - senseEditor = create(document.querySelector('#ConAppEditor')!); - - $(senseEditor.getCoreEditor().getContainer())!.show(); - - (senseEditor as any).autocomplete._test.removeChangeListener(); - tokenProvider = senseEditor.getCoreEditor().getTokenProvider(); - }); - - afterEach(async () => { - $(senseEditor.getCoreEditor().getContainer())!.hide(); - (senseEditor as any).autocomplete._test.addChangeListener(); - await senseEditor.update('', true); - }); - - describe('#getTokens', () => { - const runTest = ({ - input, - expectedTokens, - done, - lineNumber = 1, - }: RunTestArgs & { expectedTokens: Token[] | null; lineNumber?: number }) => { - senseEditor.update(input, true).then(() => { - const tokens = tokenProvider.getTokens(lineNumber); - expect(tokens).toEqual(expectedTokens); - if (done) done(); - }); - }; - - describe('base cases', () => { - test('case 1 - only url', (done) => { - runTest({ - input: `GET http://somehost/_search`, - expectedTokens: [ - { type: 'method', value: 'GET', position: { lineNumber: 1, column: 1 } }, - { type: 'whitespace', value: ' ', position: { lineNumber: 1, column: 4 } }, - { - type: 'url.protocol_host', - value: 'http://somehost', - position: { lineNumber: 1, column: 5 }, - }, - { type: 'url.slash', value: '/', position: { lineNumber: 1, column: 20 } }, - { type: 'url.part', value: '_search', position: { lineNumber: 1, column: 21 } }, - ], - done, - }); - }); - - test('case 2 - basic auth in host name', (done) => { - runTest({ - input: `GET http://test:user@somehost/`, - expectedTokens: [ - { type: 'method', value: 'GET', position: { lineNumber: 1, column: 1 } }, - { type: 'whitespace', value: ' ', position: { lineNumber: 1, column: 4 } }, - { - type: 'url.protocol_host', - value: 'http://test:user@somehost', - position: { lineNumber: 1, column: 5 }, - }, - { type: 'url.slash', value: '/', position: { lineNumber: 1, column: 30 } }, - ], - done, - }); - }); - - test('case 3 - handles empty lines', (done) => { - runTest({ - input: `POST abc - - -{ -`, - expectedTokens: [ - { type: 'method', value: 'POST', position: { lineNumber: 1, column: 1 } }, - { type: 'whitespace', value: ' ', position: { lineNumber: 1, column: 5 } }, - { type: 'url.part', value: 'abc', position: { lineNumber: 1, column: 6 } }, - ], - done, - lineNumber: 1, - }); - }); - }); - - describe('with newlines', () => { - test('case 1 - newlines base case', (done) => { - runTest({ - input: `GET http://test:user@somehost/ -{ - "wudup": "!" -}`, - expectedTokens: [ - { type: 'whitespace', value: ' ', position: { lineNumber: 3, column: 1 } }, - { type: 'variable', value: '"wudup"', position: { lineNumber: 3, column: 3 } }, - { type: 'punctuation.colon', value: ':', position: { lineNumber: 3, column: 10 } }, - { type: 'whitespace', value: ' ', position: { lineNumber: 3, column: 11 } }, - { type: 'string', value: '"!"', position: { lineNumber: 3, column: 12 } }, - ], - done, - lineNumber: 3, - }); - }); - }); - - describe('edge cases', () => { - test('case 1 - getting token outside of document', (done) => { - runTest({ - input: `GET http://test:user@somehost/ -{ - "wudup": "!" -}`, - expectedTokens: null, - done, - lineNumber: 100, - }); - }); - - test('case 2 - empty lines', (done) => { - runTest({ - input: `GET http://test:user@somehost/ - - - - -{ - "wudup": "!" -}`, - expectedTokens: [], - done, - lineNumber: 5, - }); - }); - }); - }); - - describe('#getTokenAt', () => { - const runTest = ({ - input, - expectedToken, - done, - position, - }: RunTestArgs & { expectedToken: Token | null; position: Position }) => { - senseEditor.update(input, true).then(() => { - const tokens = tokenProvider.getTokenAt(position); - expect(tokens).toEqual(expectedToken); - if (done) done(); - }); - }; - - describe('base cases', () => { - it('case 1 - gets a token from the url', (done) => { - const input = `GET http://test:user@somehost/`; - runTest({ - input, - expectedToken: { - position: { lineNumber: 1, column: 4 }, - type: 'whitespace', - value: ' ', - }, - position: { lineNumber: 1, column: 5 }, - }); - - runTest({ - input, - expectedToken: { - position: { lineNumber: 1, column: 5 }, - type: 'url.protocol_host', - value: 'http://test:user@somehost', - }, - position: { lineNumber: 1, column: input.length }, - done, - }); - }); - }); - - describe('special cases', () => { - it('case 1 - handles input outside of range', (done) => { - runTest({ - input: `GET abc`, - expectedToken: null, - done, - position: { lineNumber: 1, column: 99 }, - }); - }); - }); - }); -}); diff --git a/src/plugins/console/public/lib/ace_token_provider/token_provider.ts b/src/plugins/console/public/lib/ace_token_provider/token_provider.ts deleted file mode 100644 index 9e61771946771..0000000000000 --- a/src/plugins/console/public/lib/ace_token_provider/token_provider.ts +++ /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", 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 { IEditSession, TokenInfo as BraceTokenInfo } from 'brace'; -import { TokensProvider, Token, Position } from '../../types'; - -// Brace's token information types are not accurate. -interface TokenInfo extends BraceTokenInfo { - type: string; -} - -const toToken = (lineNumber: number, column: number, token: TokenInfo): Token => ({ - type: token.type, - value: token.value, - position: { - lineNumber, - column, - }, -}); - -const toTokens = (lineNumber: number, tokens: TokenInfo[]): Token[] => { - let acc = ''; - return tokens.map((token) => { - const column = acc.length + 1; - acc += token.value; - return toToken(lineNumber, column, token); - }); -}; - -const extractTokenFromAceTokenRow = ( - lineNumber: number, - column: number, - aceTokens: TokenInfo[] -) => { - let acc = ''; - for (const token of aceTokens) { - const start = acc.length + 1; - acc += token.value; - const end = acc.length; - if (column < start) continue; - if (column > end + 1) continue; - return toToken(lineNumber, start, token); - } - return null; -}; - -export class AceTokensProvider implements TokensProvider { - constructor(private readonly session: IEditSession) {} - - getTokens(lineNumber: number): Token[] | null { - if (lineNumber < 1) return null; - - // Important: must use a .session.getLength because this is a cached value. - // Calculating line length here will lead to performance issues because this function - // may be called inside of tight loops. - const lineCount = this.session.getLength(); - if (lineNumber > lineCount) { - return null; - } - - const tokens = this.session.getTokens(lineNumber - 1) as unknown as TokenInfo[]; - if (!tokens || !tokens.length) { - // We are inside of the document but have no tokens for this line. Return an empty - // array to represent this empty line. - return []; - } - - return toTokens(lineNumber, tokens); - } - - getTokenAt(pos: Position): Token | null { - const tokens = this.session.getTokens(pos.lineNumber - 1) as unknown as TokenInfo[]; - if (tokens) { - return extractTokenFromAceTokenRow(pos.lineNumber, pos.column, tokens); - } - return null; - } -} diff --git a/src/plugins/console/public/lib/autocomplete/autocomplete.ts b/src/plugins/console/public/lib/autocomplete/autocomplete.ts deleted file mode 100644 index 73ef1981cfc0b..0000000000000 --- a/src/plugins/console/public/lib/autocomplete/autocomplete.ts +++ /dev/null @@ -1,1316 +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 _ from 'lodash'; -import { i18n } from '@kbn/i18n'; - -// TODO: All of these imports need to be moved to the core editor so that it can inject components from there. -import { - getEndpointBodyCompleteComponents, - getGlobalAutocompleteComponents, - getTopLevelUrlCompleteComponents, - getUnmatchedEndpointComponents, -} from '../kb/kb'; - -import { createTokenIterator } from '../../application/factories'; -import type { CoreEditor, Position, Range, Token } from '../../types'; -import type RowParser from '../row_parser'; - -import * as utils from '../utils'; - -import { populateContext } from './engine'; -import type { AutoCompleteContext, DataAutoCompleteRulesOneOf, ResultTerm } from './types'; -import { URL_PATH_END_MARKER, ConstantComponent } from './components'; -import { looksLikeTypingIn } from './looks_like_typing_in'; - -let lastEvaluatedToken: Token | null = null; - -function isUrlParamsToken(token: { type: string } | null) { - switch ((token || {}).type) { - case 'url.param': - case 'url.equal': - case 'url.value': - case 'url.questionmark': - case 'url.amp': - return true; - default: - return false; - } -} - -/* Logs the provided arguments to the console if the `window.autocomplete_trace` flag is set to true. - * This function checks if the `autocomplete_trace` flag is enabled on the `window` object. This is - * only used when executing functional tests. - * If the flag is enabled, it logs each argument to the console. - * If an argument is an object, it is stringified before logging. - */ -const tracer = (...args: any[]) => { - // @ts-ignore - if (window.autocomplete_trace) { - // eslint-disable-next-line no-console - console.log.call( - console, - ..._.map(args, (arg) => { - return typeof arg === 'object' ? JSON.stringify(arg) : arg; - }) - ); - } -}; - -/** - * Get the method and token paths for a specific position in the current editor buffer. - * - * This function can be used for getting autocomplete information or for getting more information - * about the endpoint associated with autocomplete. In future, these concerns should be better - * separated. - * - */ -export function getCurrentMethodAndTokenPaths( - editor: CoreEditor, - pos: Position, - parser: RowParser, - forceEndOfUrl?: boolean /* Flag for indicating whether we want to avoid early escape optimization. */ -) { - const tokenIter = createTokenIterator({ - editor, - position: pos, - }); - const startPos = pos; - let bodyTokenPath: string[] | null = []; - const ret: AutoCompleteContext = {}; - - const STATES = { - looking_for_key: 0, // looking for a key but without jumping over anything but white space and colon. - looking_for_scope_start: 1, // skip everything until scope start - start: 3, - }; - let state = STATES.start; - - // initialization problems - - let t = tokenIter.getCurrentToken(); - if (t) { - if (startPos.column === 1) { - // if we are at the beginning of the line, the current token is the one after cursor, not before which - // deviates from the standard. - t = tokenIter.stepBackward(); - state = STATES.looking_for_scope_start; - } - } else { - if (startPos.column === 1) { - // empty lines do no have tokens, move one back - t = tokenIter.stepBackward(); - state = STATES.start; - } - } - - let walkedSomeBody = false; - - // climb one scope at a time and get the scope key - for (; t && t.type.indexOf('url') === -1 && t.type !== 'method'; t = tokenIter.stepBackward()) { - if (t.type !== 'whitespace') { - walkedSomeBody = true; - } // marks we saw something - - switch (t.type) { - case 'variable': - if (state === STATES.looking_for_key) { - bodyTokenPath.unshift(t.value.trim().replace(/"/g, '')); - } - state = STATES.looking_for_scope_start; // skip everything until the beginning of this scope - break; - - case 'paren.lparen': - bodyTokenPath.unshift(t.value); - if (state === STATES.looking_for_scope_start) { - // found it. go look for the relevant key - state = STATES.looking_for_key; - } - break; - case 'paren.rparen': - // reset he search for key - state = STATES.looking_for_scope_start; - // and ignore this sub scope.. - let parenCount = 1; - t = tokenIter.stepBackward(); - while (t && parenCount > 0) { - switch (t.type) { - case 'paren.lparen': - parenCount--; - break; - case 'paren.rparen': - parenCount++; - break; - } - if (parenCount > 0) { - t = tokenIter.stepBackward(); - } - } - if (!t) { - tracer(`paren.rparen: oops we run out.. we don't know what's up return null`); - return {}; - } - continue; - case 'punctuation.end_triple_quote': - // reset the search for key - state = STATES.looking_for_scope_start; - for (t = tokenIter.stepBackward(); t; t = tokenIter.stepBackward()) { - if (t.type === 'punctuation.start_triple_quote') { - t = tokenIter.stepBackward(); - break; - } - } - if (!t) { - tracer(`paren.rparen: oops we run out.. we don't know what's up return null`); - return {}; - } - continue; - case 'punctuation.start_triple_quote': - if (state === STATES.start) { - state = STATES.looking_for_key; - } else if (state === STATES.looking_for_key) { - state = STATES.looking_for_scope_start; - } - bodyTokenPath.unshift('"""'); - continue; - case 'string': - case 'constant.numeric': - case 'constant.language.boolean': - case 'text': - if (state === STATES.start) { - state = STATES.looking_for_key; - } else if (state === STATES.looking_for_key) { - state = STATES.looking_for_scope_start; - } - - break; - case 'punctuation.comma': - if (state === STATES.start) { - state = STATES.looking_for_scope_start; - } - break; - case 'punctuation.colon': - case 'whitespace': - if (state === STATES.start) { - state = STATES.looking_for_key; - } - break; // skip white space - } - } - - if (walkedSomeBody && (!bodyTokenPath || bodyTokenPath.length === 0) && !forceEndOfUrl) { - tracer( - 'we had some content and still no path', - '-> the cursor is position after a closed body', - '-> no auto complete' - ); - return {}; - } - - ret.urlTokenPath = []; - if (tokenIter.getCurrentPosition().lineNumber === startPos.lineNumber) { - if (t && (t.type === 'url.part' || t.type === 'url.param' || t.type === 'url.value')) { - // we are forcing the end of the url for the purposes of determining an endpoint - if (forceEndOfUrl && t.type === 'url.part') { - ret.urlTokenPath.push(t.value); - ret.urlTokenPath.push(URL_PATH_END_MARKER); - } - // we are on the same line as cursor and dealing with a url. Current token is not part of the context - t = tokenIter.stepBackward(); - // This will force method parsing - while (t!.type === 'whitespace') { - t = tokenIter.stepBackward(); - } - } - bodyTokenPath = null; // no not on a body line. - } - - ret.bodyTokenPath = bodyTokenPath; - - ret.urlParamsTokenPath = null; - ret.requestStartRow = tokenIter.getCurrentPosition().lineNumber; - let curUrlPart: - | null - | string - | Array> - | undefined - | Record; - - while (t && isUrlParamsToken(t)) { - switch (t.type) { - case 'url.value': - if (Array.isArray(curUrlPart)) { - curUrlPart.unshift(t.value); - } else if (curUrlPart) { - curUrlPart = [t.value, curUrlPart]; - } else { - curUrlPart = t.value; - } - break; - case 'url.comma': - if (!curUrlPart) { - curUrlPart = []; - } else if (!Array.isArray(curUrlPart)) { - curUrlPart = [curUrlPart]; - } - break; - case 'url.param': - const v = curUrlPart; - curUrlPart = {}; - curUrlPart[t.value] = v; - break; - case 'url.amp': - case 'url.questionmark': - if (!ret.urlParamsTokenPath) { - ret.urlParamsTokenPath = []; - } - ret.urlParamsTokenPath.unshift((curUrlPart as Record) || {}); - curUrlPart = null; - break; - } - t = tokenIter.stepBackward(); - } - - curUrlPart = null; - while (t && t.type.indexOf('url') !== -1) { - switch (t.type) { - case 'url.part': - if (Array.isArray(curUrlPart)) { - curUrlPart.unshift(t.value); - } else if (curUrlPart) { - curUrlPart = [t.value, curUrlPart]; - } else { - curUrlPart = t.value; - } - break; - case 'url.comma': - if (!curUrlPart) { - curUrlPart = []; - } else if (!Array.isArray(curUrlPart)) { - curUrlPart = [curUrlPart]; - } - break; - case 'url.slash': - if (curUrlPart) { - ret.urlTokenPath.unshift(curUrlPart as string); - curUrlPart = null; - } - break; - } - t = parser.prevNonEmptyToken(tokenIter); - } - - if (curUrlPart) { - ret.urlTokenPath.unshift(curUrlPart as string); - } - - if (!ret.bodyTokenPath && !ret.urlParamsTokenPath) { - if (ret.urlTokenPath.length > 0) { - // // started on the url, first token is current token - ret.otherTokenValues = ret.urlTokenPath[0]; - } - } else { - // mark the url as completed. - ret.urlTokenPath.push(URL_PATH_END_MARKER); - } - - if (t && t.type === 'method') { - ret.method = t.value; - } - return ret; -} - -// eslint-disable-next-line import/no-default-export -export default function ({ - coreEditor: editor, - parser, -}: { - coreEditor: CoreEditor; - parser: RowParser; -}) { - function isUrlPathToken(token: Token | null) { - switch ((token || ({} as Token)).type) { - case 'url.slash': - case 'url.comma': - case 'url.part': - return true; - default: - return false; - } - } - - function addMetaToTermsList(list: ResultTerm[], meta: string, template?: string): ResultTerm[] { - return _.map(list, function (t) { - if (typeof t !== 'object') { - t = { name: t }; - } - return _.defaults(t, { meta, template }); - }); - } - - function replaceLinesWithPrefixPieces(prefixPieces: string[], startLineNumber: number) { - const middlePiecesCount = prefixPieces.length - 1; - prefixPieces.forEach((piece, index) => { - if (index >= middlePiecesCount) { - return; - } - const line = startLineNumber + index + 1; - const column = editor.getLineValue(line).length - 1; - const start = { lineNumber: line, column: 0 }; - const end = { lineNumber: line, column }; - editor.replace({ start, end }, piece); - }); - } - - /** - * Get a different set of templates based on the value configured in the request. - * For example, when creating a snapshot repository of different types (`fs`, `url` etc), - * different properties are inserted in the textarea based on the type. - * E.g. https://github.com/elastic/kibana/blob/main/src/plugins/console/server/lib/spec_definitions/json/overrides/snapshot.create_repository.json - */ - function getConditionalTemplate( - name: string, - autocompleteRules: Record | null | undefined - ) { - const obj = autocompleteRules && autocompleteRules[name]; - - if (obj) { - const currentLineNumber = editor.getCurrentPosition().lineNumber; - - if (hasOneOfIn(obj)) { - // Get the line number of value that should provide different templates based on that - const startLine = getStartLineNumber(currentLineNumber, obj.__one_of); - // Join line values from start to current line - const lines = editor.getLines(startLine, currentLineNumber).join('\n'); - // Get the correct template by comparing the autocomplete rules against the lines - const prop = getProperty(lines, obj.__one_of); - if (prop && prop.__template) { - return prop.__template; - } - } - } - } - - /** - * Check if object has a property of '__one_of' - */ - function hasOneOfIn(value: unknown): value is { __one_of: DataAutoCompleteRulesOneOf[] } { - return typeof value === 'object' && value !== null && '__one_of' in value; - } - - /** - * Get the start line of value that matches the autocomplete rules condition - */ - function getStartLineNumber(currentLine: number, rules: DataAutoCompleteRulesOneOf[]): number { - if (currentLine === 1) { - return currentLine; - } - const value = editor.getLineValue(currentLine); - const prop = getProperty(value, rules); - if (prop) { - return currentLine; - } - return getStartLineNumber(currentLine - 1, rules); - } - - /** - * Get the matching property based on the given condition - */ - function getProperty(condition: string, rules: DataAutoCompleteRulesOneOf[]) { - return rules.find((rule) => { - if (rule.__condition && rule.__condition.lines_regex) { - return new RegExp(rule.__condition.lines_regex, 'm').test(condition); - } - return false; - }); - } - - function applyTerm(term: ResultTerm) { - const context = term.context!; - - if (context?.endpoint && term.value) { - const { data_autocomplete_rules: autocompleteRules } = context.endpoint; - const template = getConditionalTemplate(term.value, autocompleteRules); - if (template) { - term.template = template; - } - } - // make sure we get up to date replacement info. - addReplacementInfoToContext(context, editor.getCurrentPosition(), term.insertValue); - - let termAsString; - if (context.autoCompleteType === 'body') { - termAsString = - typeof term.insertValue === 'string' ? '"' + term.insertValue + '"' : term.insertValue + ''; - if (term.insertValue === '[' || term.insertValue === '{') { - termAsString = ''; - } - } else { - termAsString = term.insertValue + ''; - } - - let valueToInsert = termAsString; - let templateInserted = false; - if (context.addTemplate && !_.isUndefined(term.template) && !_.isNull(term.template)) { - let indentedTemplateLines; - // In order to allow triple quoted strings in template completion we check the `__raw_` - // attribute to determine whether this template should go through JSON formatting. - if (term.template.__raw && term.template.value) { - indentedTemplateLines = term.template.value.split('\n'); - } else { - indentedTemplateLines = utils.jsonToString(term.template, true).split('\n'); - } - let currentIndentation = editor.getLineValue(context.rangeToReplace!.start.lineNumber); - currentIndentation = currentIndentation.match(/^\s*/)![0]; - for ( - let i = 1; - i < indentedTemplateLines.length; - i++ // skip first line - ) { - indentedTemplateLines[i] = currentIndentation + indentedTemplateLines[i]; - } - - valueToInsert += ': ' + indentedTemplateLines.join('\n'); - templateInserted = true; - } else { - templateInserted = true; - if (term.value === '[') { - valueToInsert += '[]'; - } else if (term.value === '{') { - valueToInsert += '{}'; - } else { - templateInserted = false; - } - } - const linesToMoveDown = (context.prefixToAdd ?? '').match(/\n|\r/g)?.length ?? 0; - - let prefix = context.prefixToAdd ?? ''; - - // disable listening to the changes we are making. - editor.off('changeSelection', editorChangeListener); - - // if should add chars on the previous not empty line - if (linesToMoveDown) { - const [firstPart = '', ...prefixPieces] = context.prefixToAdd?.split(/\n|\r/g) ?? []; - const lastPart = _.last(prefixPieces) ?? ''; - const { start } = context.rangeToReplace!; - const end = { ...start, column: start.column + firstPart.length }; - - // adding only the content of prefix before newlines - editor.replace({ start, end }, firstPart); - - // replacing prefix pieces without the last one, which is handled separately - if (prefixPieces.length - 1 > 0) { - replaceLinesWithPrefixPieces(prefixPieces, start.lineNumber); - } - - // and the last prefix line, keeping the editor's own newlines. - prefix = lastPart; - context.rangeToReplace!.start.lineNumber = context.rangeToReplace!.end.lineNumber; - context.rangeToReplace!.start.column = 0; - } - - valueToInsert = prefix + valueToInsert + context.suffixToAdd; - - if (context.rangeToReplace!.start.column !== context.rangeToReplace!.end.column) { - editor.replace(context.rangeToReplace!, valueToInsert); - } else { - editor.insert(valueToInsert); - } - - editor.clearSelection(); // for some reason the above changes selection - - // go back to see whether we have one of ( : { & [ do not require a comma. All the rest do. - let newPos = { - lineNumber: context.rangeToReplace!.start.lineNumber, - column: - context.rangeToReplace!.start.column + - termAsString.length + - prefix.length + - (templateInserted ? 0 : context.suffixToAdd!.length), - }; - - const tokenIter = createTokenIterator({ - editor, - position: newPos, - }); - - if (context.autoCompleteType === 'body') { - // look for the next place stand, just after a comma, { - let nonEmptyToken = parser.nextNonEmptyToken(tokenIter); - switch (nonEmptyToken ? nonEmptyToken.type : 'NOTOKEN') { - case 'paren.rparen': - newPos = tokenIter.getCurrentPosition(); - break; - case 'punctuation.colon': - nonEmptyToken = parser.nextNonEmptyToken(tokenIter); - if ((nonEmptyToken || ({} as Token)).type === 'paren.lparen') { - nonEmptyToken = parser.nextNonEmptyToken(tokenIter); - newPos = tokenIter.getCurrentPosition(); - if (nonEmptyToken && nonEmptyToken.value.indexOf('"') === 0) { - newPos.column++; - } // don't stand on " - } - break; - case 'paren.lparen': - case 'punctuation.comma': - tokenIter.stepForward(); - newPos = tokenIter.getCurrentPosition(); - break; - } - editor.moveCursorToPosition(newPos); - } - - // re-enable listening to typing - editor.on('changeSelection', editorChangeListener); - } - - function getAutoCompleteContext(ctxEditor: CoreEditor, pos: Position) { - // deduces all the parameters need to position and insert the auto complete - const context: AutoCompleteContext = { - autoCompleteSet: null, // instructions for what can be here - endpoint: null, - urlPath: null, - method: null, - activeScheme: null, - editor: ctxEditor, - }; - - // context.updatedForToken = session.getTokenAt(pos.row, pos.column); - // - // if (!context.updatedForToken) - // context.updatedForToken = { value: "", start: pos.column }; // empty line - // - // context.updatedForToken.row = pos.row; // extend - - context.autoCompleteType = getAutoCompleteType(pos); - switch (context.autoCompleteType) { - case 'path': - addPathAutoCompleteSetToContext(context, pos); - break; - case 'url_params': - addUrlParamsAutoCompleteSetToContext(context, pos); - break; - case 'method': - addMethodAutoCompleteSetToContext(context); - break; - case 'body': - addBodyAutoCompleteSetToContext(context, pos); - break; - default: - return null; - } - - const isMappingsFetchingInProgress = - context.autoCompleteType === 'body' && !!context.asyncResultsState?.isLoading; - - if (!context.autoCompleteSet && !isMappingsFetchingInProgress) { - tracer('nothing to do..', context); - return null; - } - - addReplacementInfoToContext(context, pos); - - context.createdWithToken = _.clone(context.updatedForToken); - - return context; - } - - function getAutoCompleteType(pos: Position) { - // return "method", "path" or "body" to determine auto complete type. - - let rowMode = parser.getRowParseMode(); - - // eslint-disable-next-line no-bitwise - if (rowMode & parser.MODE.IN_REQUEST) { - return 'body'; - } - // eslint-disable-next-line no-bitwise - if (rowMode & parser.MODE.REQUEST_START) { - // on url path, url params or method. - const tokenIter = createTokenIterator({ - editor, - position: pos, - }); - let t = tokenIter.getCurrentToken(); - - while (t!.type === 'url.comma') { - t = tokenIter.stepBackward(); - } - switch (t!.type) { - case 'method': - return 'method'; - case 'whitespace': - t = parser.prevNonEmptyToken(tokenIter); - - switch ((t || ({} as Token)).type) { - case 'method': - // we moved one back - return 'path'; - break; - default: - if (isUrlPathToken(t)) { - return 'path'; - } - if (isUrlParamsToken(t)) { - return 'url_params'; - } - return null; - } - break; - default: - if (isUrlPathToken(t)) { - return 'path'; - } - if (isUrlParamsToken(t)) { - return 'url_params'; - } - return null; - } - } - - // after start to avoid single line url only requests - // eslint-disable-next-line no-bitwise - if (rowMode & parser.MODE.REQUEST_END) { - return 'body'; - } - - // in between request on an empty - if (editor.getLineValue(pos.lineNumber).trim() === '') { - // check if the previous line is a single line beginning of a new request - rowMode = parser.getRowParseMode(pos.lineNumber - 1); - if ( - // eslint-disable-next-line no-bitwise - rowMode & parser.MODE.REQUEST_START && - // eslint-disable-next-line no-bitwise - rowMode & parser.MODE.REQUEST_END - ) { - return 'body'; - } - // o.w suggest a method - return 'method'; - } - - return null; - } - - function addReplacementInfoToContext( - context: AutoCompleteContext, - pos: Position, - replacingTerm?: unknown - ) { - // extract the initial value, rangeToReplace & textBoxPosition - - // Scenarios for current token: - // - Nice token { "bla|" - // - Broken text token { bla| - // - No token : { | - // - Broken scenario { , bla| - // - Nice token, broken before: {, "bla" - - context.updatedForToken = _.clone( - editor.getTokenAt({ lineNumber: pos.lineNumber, column: pos.column }) - ); - if (!context.updatedForToken) { - context.updatedForToken = { - value: '', - type: '', - position: { column: pos.column, lineNumber: pos.lineNumber }, - }; - } // empty line - - let anchorToken = context.createdWithToken; - if (!anchorToken) { - anchorToken = context.updatedForToken; - } - - switch (context.updatedForToken.type) { - case 'variable': - case 'string': - case 'text': - case 'constant.numeric': - case 'constant.language.boolean': - case 'method': - case 'url.index': - case 'url.type': - case 'url.id': - case 'url.method': - case 'url.endpoint': - case 'url.part': - case 'url.param': - case 'url.value': - context.rangeToReplace = { - start: { lineNumber: pos.lineNumber, column: anchorToken.position.column }, - end: { - lineNumber: pos.lineNumber, - column: context.updatedForToken.position.column + context.updatedForToken.value.length, - }, - } as Range; - context.replacingToken = true; - break; - default: - if (replacingTerm && context.updatedForToken.value === replacingTerm) { - context.rangeToReplace = { - start: { lineNumber: pos.lineNumber, column: anchorToken.position.column }, - end: { - lineNumber: pos.lineNumber, - column: - context.updatedForToken.position.column + context.updatedForToken.value.length, - }, - } as Range; - context.replacingToken = true; - } else { - // standing on white space, quotes or another punctuation - no replacing - context.rangeToReplace = { - start: { lineNumber: pos.lineNumber, column: pos.column }, - end: { lineNumber: pos.lineNumber, column: pos.column }, - } as Range; - context.replacingToken = false; - } - break; - } - - context.textBoxPosition = { - lineNumber: context.rangeToReplace.start.lineNumber, - column: context.rangeToReplace.start.column, - }; - - switch (context.autoCompleteType) { - case 'path': - addPathPrefixSuffixToContext(context); - break; - case 'url_params': - addUrlParamsPrefixSuffixToContext(context); - break; - case 'method': - addMethodPrefixSuffixToContext(context); - break; - case 'body': - addBodyPrefixSuffixToContext(context); - break; - } - } - - function addCommaToPrefixOnAutocomplete( - nonEmptyToken: Token | null, - context: AutoCompleteContext, - charsToSkipOnSameLine: number = 1 - ) { - if (nonEmptyToken && nonEmptyToken.type.indexOf('url') < 0) { - const { position } = nonEmptyToken; - // if not on the first line - if (context.rangeToReplace && context.rangeToReplace.start?.lineNumber > 1) { - const prevTokenLineNumber = position.lineNumber; - const editorFromContext = context.editor as CoreEditor | undefined; - const line = editorFromContext?.getLineValue(prevTokenLineNumber) ?? ''; - const prevLineLength = line.length; - const linesToEnter = context.rangeToReplace.end.lineNumber - prevTokenLineNumber; - - const isTheSameLine = linesToEnter === 0; - let startColumn = prevLineLength + 1; - let spaces = context.rangeToReplace.start.column - 1; - - if (isTheSameLine) { - // prevent last char line from replacing - startColumn = position.column + charsToSkipOnSameLine; - // one char for pasted " and one for , - spaces = context.rangeToReplace.end.column - startColumn - 2; - } - - // go back to the end of the previous line - context.rangeToReplace = { - start: { lineNumber: prevTokenLineNumber, column: startColumn }, - end: { ...context.rangeToReplace.end }, - }; - - spaces = spaces >= 0 ? spaces : 0; - const spacesToEnter = isTheSameLine ? (spaces === 0 ? 1 : spaces) : spaces; - const newLineChars = `\n`.repeat(linesToEnter >= 0 ? linesToEnter : 0); - const whitespaceChars = ' '.repeat(spacesToEnter); - // add a comma at the end of the previous line, a new line and indentation - context.prefixToAdd = `,${newLineChars}${whitespaceChars}`; - } - } - } - - function addBodyPrefixSuffixToContext(context: AutoCompleteContext) { - // Figure out what happens next to the token to see whether it needs trailing commas etc. - - // Templates will be used if not destroying existing structure. - // -> token : {} or token ]/} or token , but not token : SOMETHING ELSE - - context.prefixToAdd = ''; - context.suffixToAdd = ''; - - let tokenIter = createTokenIterator({ - editor, - position: editor.getCurrentPosition()!, - }); - let nonEmptyToken = parser.nextNonEmptyToken(tokenIter); - switch (nonEmptyToken ? nonEmptyToken.type : 'NOTOKEN') { - case 'NOTOKEN': - case 'paren.lparen': - case 'paren.rparen': - case 'punctuation.comma': - context.addTemplate = true; - break; - case 'punctuation.colon': - // test if there is an empty object - if so we replace it - context.addTemplate = false; - - nonEmptyToken = parser.nextNonEmptyToken(tokenIter); - if (!(nonEmptyToken && nonEmptyToken.value === '{')) { - break; - } - nonEmptyToken = parser.nextNonEmptyToken(tokenIter); - if (!(nonEmptyToken && nonEmptyToken.value === '}')) { - break; - } - context.addTemplate = true; - // extend range to replace to include all up to token - context.rangeToReplace!.end.lineNumber = tokenIter.getCurrentTokenLineNumber() as number; - context.rangeToReplace!.end.column = - (tokenIter.getCurrentTokenColumn() as number) + nonEmptyToken.value.length; - - // move one more time to check if we need a trailing comma - nonEmptyToken = parser.nextNonEmptyToken(tokenIter); - switch (nonEmptyToken ? nonEmptyToken.type : 'NOTOKEN') { - case 'NOTOKEN': - case 'paren.rparen': - case 'punctuation.comma': - case 'punctuation.colon': - break; - default: - context.suffixToAdd = ', '; - } - - break; - default: - context.addTemplate = true; - context.suffixToAdd = ', '; - break; // for now play safe and do nothing. May be made smarter. - } - - // go back to see whether we have one of ( : { & [ do not require a comma. All the rest do. - tokenIter = createTokenIterator({ editor, position: editor.getCurrentPosition() }); - nonEmptyToken = tokenIter.getCurrentToken(); - let insertingRelativeToToken; // -1 is before token, 0 middle, +1 after token - if (context.replacingToken) { - insertingRelativeToToken = 0; - } else { - const pos = editor.getCurrentPosition(); - if (pos.column === context.updatedForToken!.position.column) { - insertingRelativeToToken = -1; - } else if ( - pos.column < - context.updatedForToken!.position.column + context.updatedForToken!.value.length - ) { - insertingRelativeToToken = 0; - } else { - insertingRelativeToToken = 1; - } - } - // we should actually look at what's happening before this token - if (parser.isEmptyToken(nonEmptyToken) || insertingRelativeToToken <= 0) { - nonEmptyToken = parser.prevNonEmptyToken(tokenIter); - } - - switch (nonEmptyToken ? nonEmptyToken.type : 'NOTOKEN') { - case 'NOTOKEN': - case 'paren.lparen': - case 'punctuation.comma': - case 'punctuation.colon': - case 'punctuation.start_triple_quote': - case 'method': - break; - case 'text': - case 'string': - case 'constant.numeric': - case 'constant.language.boolean': - case 'punctuation.end_triple_quote': - addCommaToPrefixOnAutocomplete(nonEmptyToken, context, nonEmptyToken?.value.length); - break; - default: - addCommaToPrefixOnAutocomplete(nonEmptyToken, context); - break; - } - - return context; - } - - function addUrlParamsPrefixSuffixToContext(context: AutoCompleteContext) { - context.prefixToAdd = ''; - context.suffixToAdd = ''; - } - - function addMethodPrefixSuffixToContext(context: AutoCompleteContext) { - context.prefixToAdd = ''; - context.suffixToAdd = ''; - const tokenIter = createTokenIterator({ editor, position: editor.getCurrentPosition() }); - const lineNumber = tokenIter.getCurrentPosition().lineNumber; - const t = parser.nextNonEmptyToken(tokenIter); - - if (tokenIter.getCurrentPosition().lineNumber !== lineNumber || !t) { - // we still have nothing next to the method, add a space.. - context.suffixToAdd = ' '; - } - } - - function addPathPrefixSuffixToContext(context: AutoCompleteContext) { - context.prefixToAdd = ''; - context.suffixToAdd = ''; - } - - function addMethodAutoCompleteSetToContext(context: AutoCompleteContext) { - context.autoCompleteSet = ['GET', 'PUT', 'POST', 'DELETE', 'HEAD', 'PATCH'].map((m, i) => ({ - name: m, - score: -i, - meta: i18n.translate('console.autocomplete.addMethodMetaText', { defaultMessage: 'method' }), - })); - } - - function addPathAutoCompleteSetToContext(context: AutoCompleteContext, pos: Position) { - const ret = getCurrentMethodAndTokenPaths(editor, pos, parser); - context.method = ret.method?.toUpperCase(); - context.token = ret.token; - context.otherTokenValues = ret.otherTokenValues; - context.urlTokenPath = ret.urlTokenPath; - - const components = getTopLevelUrlCompleteComponents(context.method); - let urlTokenPath = context.urlTokenPath; - let predicate: (term: ResultTerm) => boolean = () => true; - - const tokenIter = createTokenIterator({ editor, position: pos }); - const currentTokenType = tokenIter.getCurrentToken()?.type; - const previousTokenType = tokenIter.stepBackward()?.type; - if (!Array.isArray(urlTokenPath)) { - // skip checks for url.comma - } else if (previousTokenType === 'url.comma' && currentTokenType === 'url.comma') { - predicate = () => false; // two consecutive commas empty the autocomplete - } else if ( - (previousTokenType === 'url.part' && currentTokenType === 'url.comma') || - (previousTokenType === 'url.slash' && currentTokenType === 'url.comma') || - (previousTokenType === 'url.comma' && currentTokenType === 'url.part') - ) { - const lastUrlTokenPath = _.last(urlTokenPath) || []; // ['c', 'd'] from 'GET /a/b/c,d,' - const constantComponents = _.filter(components, (c) => c instanceof ConstantComponent); - const constantComponentNames = _.map(constantComponents, 'name'); - - // check if neither 'c' nor 'd' is a constant component name such as '_search' - if (_.every(lastUrlTokenPath, (token) => !_.includes(constantComponentNames, token))) { - urlTokenPath = urlTokenPath.slice(0, -1); // drop the last 'c,d,' part from the url path - predicate = (term) => term.meta === 'index'; // limit the autocomplete to indices only - } - } - - populateContext(urlTokenPath, context, editor, true, components); - context.autoCompleteSet = _.filter( - addMetaToTermsList(context.autoCompleteSet!, 'endpoint'), - predicate - ); - } - - function addUrlParamsAutoCompleteSetToContext(context: AutoCompleteContext, pos: Position) { - const ret = getCurrentMethodAndTokenPaths(editor, pos, parser); - context.method = ret.method; - context.otherTokenValues = ret.otherTokenValues; - context.urlTokenPath = ret.urlTokenPath; - if (!ret.urlTokenPath) { - // zero length tokenPath is true - - return context; - } - - populateContext( - ret.urlTokenPath, - context, - editor, - false, - getTopLevelUrlCompleteComponents(context.method) - ); - - if (!context.endpoint) { - return context; - } - - if (!ret.urlParamsTokenPath) { - // zero length tokenPath is true - return context; - } - let tokenPath: string[] = []; - const currentParam = ret.urlParamsTokenPath.pop(); - if (currentParam) { - tokenPath = Object.keys(currentParam); // single key object - context.otherTokenValues = currentParam[tokenPath[0]]; - } - - populateContext( - tokenPath, - context, - editor, - true, - context.endpoint.paramsAutocomplete.getTopLevelComponents(context.method) - ); - return context; - } - - function addBodyAutoCompleteSetToContext(context: AutoCompleteContext, pos: Position) { - const ret = getCurrentMethodAndTokenPaths(editor, pos, parser); - context.method = ret.method; - context.otherTokenValues = ret.otherTokenValues; - context.urlTokenPath = ret.urlTokenPath; - context.requestStartRow = ret.requestStartRow; - if (!ret.urlTokenPath) { - // zero length tokenPath is true - return context; - } - - populateContext( - ret.urlTokenPath, - context, - editor, - false, - getTopLevelUrlCompleteComponents(context.method) - ); - - context.bodyTokenPath = ret.bodyTokenPath; - if (!ret.bodyTokenPath) { - // zero length tokenPath is true - - return context; - } - - const t = editor.getTokenAt(pos); - if (t && t.type === 'punctuation.end_triple_quote' && pos.column !== t.position.column + 3) { - // skip to populate context as the current position is not on the edge of end_triple_quote - return context; - } - - // needed for scope linking + global term resolving - context.endpointComponentResolver = getEndpointBodyCompleteComponents; - context.globalComponentResolver = getGlobalAutocompleteComponents; - let components: unknown; - if (context.endpoint) { - components = context.endpoint.bodyAutocompleteRootComponents; - } else { - components = getUnmatchedEndpointComponents(); - } - populateContext(ret.bodyTokenPath, context, editor, true, components); - - return context; - } - - const evaluateCurrentTokenAfterAChange = _.debounce(function evaluateCurrentTokenAfterAChange( - pos: Position - ) { - let currentToken = editor.getTokenAt(pos)!; - tracer('has started evaluating current token', currentToken); - - if (!currentToken) { - lastEvaluatedToken = null; - currentToken = { position: { column: 0, lineNumber: 0 }, value: '', type: '' }; // empty row - } - - currentToken.position.lineNumber = pos.lineNumber; // extend token with row. Ace doesn't supply it by default - if (parser.isEmptyToken(currentToken)) { - // empty token. check what's coming next - const nextToken = editor.getTokenAt({ ...pos, column: pos.column + 1 })!; - if (parser.isEmptyToken(nextToken)) { - // Empty line, or we're not on the edge of current token. Save the current position as base - currentToken.position.column = pos.column; - lastEvaluatedToken = currentToken; - } else { - nextToken.position.lineNumber = pos.lineNumber; - lastEvaluatedToken = nextToken; - } - tracer('not starting autocomplete due to empty current token'); - return; - } - - if (!lastEvaluatedToken) { - lastEvaluatedToken = currentToken; - tracer('not starting autocomplete due to invalid last evaluated token'); - return; // wait for the next typing. - } - - if (!looksLikeTypingIn(lastEvaluatedToken, currentToken, editor)) { - tracer('not starting autocomplete', lastEvaluatedToken, '->', currentToken); - // not on the same place or nothing changed, cache and wait for the next time - lastEvaluatedToken = currentToken; - return; - } - - // don't automatically open the auto complete if some just hit enter (new line) or open a parentheses - switch (currentToken.type || 'UNKNOWN') { - case 'paren.lparen': - case 'paren.rparen': - case 'punctuation.colon': - case 'punctuation.comma': - case 'comment.line': - case 'comment.punctuation': - case 'comment.block': - case 'UNKNOWN': - tracer('not starting autocomplete for current token type', currentToken.type); - return; - } - - tracer('starting autocomplete', lastEvaluatedToken, '->', currentToken); - lastEvaluatedToken = currentToken; - editor.execCommand('startAutocomplete'); - }, - 100); - - function editorChangeListener() { - const position = editor.getCurrentPosition(); - tracer('editor changed', position); - if (position && !editor.isCompleterActive()) { - tracer('will start evaluating current token'); - evaluateCurrentTokenAfterAChange(position); - } - } - - /** - * Extracts terms from the autocomplete set. - * @param context - */ - function getTerms(context: AutoCompleteContext, autoCompleteSet: ResultTerm[]) { - const terms = _.map( - autoCompleteSet.filter((term) => Boolean(term) && term.name != null), - function (term) { - if (typeof term !== 'object') { - term = { - name: term, - }; - } else { - term = _.clone(term); - } - const defaults: { - value?: string; - meta: string; - score: number; - context: AutoCompleteContext; - completer?: { insertMatch: (v: unknown) => void }; - } = { - value: term.name + '', - meta: 'API', - score: 0, - context, - }; - // we only need our custom insertMatch behavior for the body - if (context.autoCompleteType === 'body') { - defaults.completer = { - insertMatch() { - return applyTerm(term); - }, - }; - } - return _.defaults(term, defaults); - } - ); - - terms.sort(function ( - t1: { score: number; name?: string | boolean }, - t2: { score: number; name?: string | boolean } - ) { - /* score sorts from high to low */ - if (t1.score > t2.score) { - return -1; - } - if (t1.score < t2.score) { - return 1; - } - /* names sort from low to high */ - if (t1.name! < t2.name!) { - return -1; - } - if (t1.name === t2.name) { - return 0; - } - return 1; - }); - - return terms; - } - - function getSuggestions(terms: ResultTerm[]) { - return _.map(terms, function (t, i) { - t.insertValue = t.insertValue || t.value; - t.value = '' + t.value; // normalize to strings - t.score = -i; - return t; - }); - } - - function getCompletions( - position: Position, - prefix: string, - callback: (e: Error | null, result: ResultTerm[] | null) => void, - annotationControls: { - setAnnotation: (text: string) => void; - removeAnnotation: () => void; - } - ) { - try { - const context = getAutoCompleteContext(editor, position); - - if (!context) { - tracer('zero suggestions due to invalid autocomplete context'); - callback(null, []); - } else { - if (!context.asyncResultsState?.isLoading) { - const terms = getTerms(context, context.autoCompleteSet!); - const suggestions = getSuggestions(terms); - tracer(suggestions?.length ?? 0, 'suggestions'); - callback(null, suggestions); - } - - if (context.asyncResultsState) { - annotationControls.setAnnotation( - i18n.translate('console.autocomplete.fieldsFetchingAnnotation', { - defaultMessage: 'Fields fetching is in progress', - }) - ); - - context.asyncResultsState.results.then((r) => { - const asyncSuggestions = getSuggestions(getTerms(context, r)); - tracer(asyncSuggestions?.length ?? 0, 'async suggestions'); - callback(null, asyncSuggestions); - annotationControls.removeAnnotation(); - }); - } - } - } catch (e) { - // eslint-disable-next-line no-console - console.error(e); - callback(e, null); - } - } - - editor.on('changeSelection', editorChangeListener); - - return { - getCompletions, - // TODO: This needs to be cleaned up - _test: { - getCompletions: ( - _editor: unknown, - _editSession: unknown, - pos: Position, - prefix: string, - callback: (e: Error | null, result: ResultTerm[] | null) => void, - annotationControls: { - setAnnotation: (text: string) => void; - removeAnnotation: () => void; - } - ) => getCompletions(pos, prefix, callback, annotationControls), - addReplacementInfoToContext, - addChangeListener: () => editor.on('changeSelection', editorChangeListener), - removeChangeListener: () => editor.off('changeSelection', editorChangeListener), - }, - }; -} diff --git a/src/plugins/console/public/lib/autocomplete/get_endpoint_from_position.ts b/src/plugins/console/public/lib/autocomplete/get_endpoint_from_position.ts deleted file mode 100644 index b65e277e41723..0000000000000 --- a/src/plugins/console/public/lib/autocomplete/get_endpoint_from_position.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", 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 { CoreEditor, Position } from '../../types'; -import { getCurrentMethodAndTokenPaths } from './autocomplete'; -import type RowParser from '../row_parser'; - -import { getTopLevelUrlCompleteComponents } from '../kb/kb'; -import { populateContext } from './engine'; - -export function getEndpointFromPosition(editor: CoreEditor, pos: Position, parser: RowParser) { - const lineValue = editor.getLineValue(pos.lineNumber); - const context = { - ...getCurrentMethodAndTokenPaths( - editor, - { - column: lineValue.length + 1 /* Go to the very end of the line */, - lineNumber: pos.lineNumber, - }, - parser, - true - ), - }; - const components = getTopLevelUrlCompleteComponents(context.method); - populateContext(context.urlTokenPath, context, editor, true, components); - return context.endpoint; -} diff --git a/src/plugins/console/public/lib/autocomplete/looks_like_typing_in.test.ts b/src/plugins/console/public/lib/autocomplete/looks_like_typing_in.test.ts deleted file mode 100644 index 101fd96a79024..0000000000000 --- a/src/plugins/console/public/lib/autocomplete/looks_like_typing_in.test.ts +++ /dev/null @@ -1,224 +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 '../../application/models/sense_editor/sense_editor.test.mocks'; - -import { looksLikeTypingIn } from './looks_like_typing_in'; -import { create } from '../../application/models'; -import type { SenseEditor } from '../../application/models'; -import type { CoreEditor, Position, Token, TokensProvider } from '../../types'; - -describe('looksLikeTypingIn', () => { - let editor: SenseEditor; - let coreEditor: CoreEditor; - let tokenProvider: TokensProvider; - - beforeEach(() => { - document.body.innerHTML = `
-
-
-
-
`; - editor = create(document.getElementById('ConAppEditor')!); - coreEditor = editor.getCoreEditor(); - tokenProvider = coreEditor.getTokenProvider(); - }); - - afterEach(async () => { - await editor.update('', true); - }); - - describe('general typing in', () => { - interface RunTestArgs { - preamble: string; - autocomplete?: string; - input: string; - } - - const runTest = async ({ preamble, autocomplete, input }: RunTestArgs) => { - const pos: Position = { lineNumber: 1, column: 1 }; - - await editor.update(preamble, true); - pos.column += preamble.length; - const lastEvaluatedToken = tokenProvider.getTokenAt(pos); - - if (autocomplete !== undefined) { - await editor.update(coreEditor.getValue() + autocomplete, true); - pos.column += autocomplete.length; - } - - await editor.update(coreEditor.getValue() + input, true); - pos.column += input.length; - const currentToken = tokenProvider.getTokenAt(pos); - - expect(lastEvaluatedToken).not.toBeNull(); - expect(currentToken).not.toBeNull(); - expect(looksLikeTypingIn(lastEvaluatedToken!, currentToken!, coreEditor)).toBe(true); - }; - - const cases: RunTestArgs[] = [ - { preamble: 'G', input: 'E' }, - { preamble: 'GET .kibana', input: '/' }, - { preamble: 'GET .kibana', input: ',' }, - { preamble: 'GET .kibana', input: '?' }, - { preamble: 'GET .kibana/', input: '_' }, - { preamble: 'GET .kibana/', input: '?' }, - { preamble: 'GET .kibana,', input: '.' }, - { preamble: 'GET .kibana,', input: '?' }, - { preamble: 'GET .kibana?', input: 'k' }, - { preamble: 'GET .kibana?k', input: '=' }, - { preamble: 'GET .kibana?k=', input: 'v' }, - { preamble: 'GET .kibana?k=v', input: '&' }, - { preamble: 'GET .kibana?k', input: '&' }, - { preamble: 'GET .kibana?k&', input: 'k' }, - { preamble: 'GET ', autocomplete: '.kibana', input: '/' }, - { preamble: 'GET ', autocomplete: '.kibana', input: ',' }, - { preamble: 'GET ', autocomplete: '.kibana', input: '?' }, - { preamble: 'GET .ki', autocomplete: 'bana', input: '/' }, - { preamble: 'GET .ki', autocomplete: 'bana', input: ',' }, - { preamble: 'GET .ki', autocomplete: 'bana', input: '?' }, - { preamble: 'GET _nodes/', autocomplete: 'stats', input: '/' }, - { preamble: 'GET _nodes/sta', autocomplete: 'ts', input: '/' }, - { preamble: 'GET _nodes/', autocomplete: 'jvm', input: ',' }, - { preamble: 'GET _nodes/j', autocomplete: 'vm', input: ',' }, - { preamble: 'GET _nodes/jvm,', autocomplete: 'os', input: ',' }, - { preamble: 'GET .kibana,', autocomplete: '.security', input: ',' }, - { preamble: 'GET .kibana,.sec', autocomplete: 'urity', input: ',' }, - { preamble: 'GET .kibana,', autocomplete: '.security', input: '/' }, - { preamble: 'GET .kibana,.sec', autocomplete: 'urity', input: '/' }, - { preamble: 'GET .kibana,', autocomplete: '.security', input: '?' }, - { preamble: 'GET .kibana,.sec', autocomplete: 'urity', input: '?' }, - { preamble: 'GET .kibana/', autocomplete: '_search', input: '?' }, - { preamble: 'GET .kibana/_se', autocomplete: 'arch', input: '?' }, - { preamble: 'GET .kibana/_search?', autocomplete: 'expand_wildcards', input: '=' }, - { preamble: 'GET .kibana/_search?exp', autocomplete: 'and_wildcards', input: '=' }, - { preamble: 'GET .kibana/_search?expand_wildcards=', autocomplete: 'all', input: '&' }, - { preamble: 'GET .kibana/_search?expand_wildcards=a', autocomplete: 'll', input: '&' }, - { preamble: 'GET _cat/indices?s=index&', autocomplete: 'expand_wildcards', input: '=' }, - { preamble: 'GET _cat/indices?s=index&exp', autocomplete: 'and_wildcards', input: '=' }, - { preamble: 'GET _cat/indices?v&', autocomplete: 'expand_wildcards', input: '=' }, - { preamble: 'GET _cat/indices?v&exp', autocomplete: 'and_wildcards', input: '=' }, - // autocomplete skips one iteration of token evaluation if user types in every letter - { preamble: 'GET .kibana', autocomplete: '/', input: '_' }, // token '/' may not be evaluated - { preamble: 'GET .kibana', autocomplete: ',', input: '.' }, // token ',' may not be evaluated - { preamble: 'GET .kibana', autocomplete: '?', input: 'k' }, // token '?' may not be evaluated - ]; - for (const c of cases) { - const name = - c.autocomplete === undefined - ? `'${c.preamble}' -> '${c.input}'` - : `'${c.preamble}' -> '${c.autocomplete}' (autocomplte) -> '${c.input}'`; - test(name, async () => runTest(c)); - } - }); - - describe('first typing in', () => { - test(`'' -> 'G'`, () => { - // this is based on an implementation within the evaluateCurrentTokenAfterAChange function - const lastEvaluatedToken = { position: { column: 0, lineNumber: 0 }, value: '', type: '' }; - lastEvaluatedToken.position.lineNumber = coreEditor.getCurrentPosition().lineNumber; - - const currentToken = { position: { column: 1, lineNumber: 1 }, value: 'G', type: 'method' }; - expect(looksLikeTypingIn(lastEvaluatedToken, currentToken, coreEditor)).toBe(true); - }); - }); - - const matrices = [ - ` -GET .kibana/ - - -` - .slice(1, -1) - .split('\n'), - ` - - POST test/_doc -{"message": "test"} - -GET /_cat/indices?v&s= - -DE -` - .slice(1, -1) - .split('\n'), - ` - -PUT test/_doc/1 -{"field": "value"} -` - .slice(1, -1) - .split('\n'), - ]; - - describe('navigating the editor via keyboard arrow keys', () => { - const runHorizontalZigzagWalkTest = async (matrix: string[]) => { - const width = matrix[0].length; - const height = matrix.length; - - await editor.update(matrix.join('\n'), true); - let lastEvaluatedToken = tokenProvider.getTokenAt(coreEditor.getCurrentPosition()); - let currentToken: Token | null; - - for (let i = 1; i < height * width * 2; i++) { - const pos = { - column: 1 + (i % width), - lineNumber: 1 + Math.floor(i / width), - }; - if (pos.lineNumber % 2 === 0) { - pos.column = width - pos.column + 1; - } - if (pos.lineNumber > height) { - pos.lineNumber = 2 * height - pos.lineNumber + 1; - } - - currentToken = tokenProvider.getTokenAt(pos); - expect(lastEvaluatedToken).not.toBeNull(); - expect(currentToken).not.toBeNull(); - expect(looksLikeTypingIn(lastEvaluatedToken!, currentToken!, coreEditor)).toBe(false); - lastEvaluatedToken = currentToken; - } - }; - - for (const matrix of matrices) { - test(`horizontal zigzag walk ${matrix[0].length}x${matrix.length} map`, () => - runHorizontalZigzagWalkTest(matrix)); - } - }); - - describe('clicking around the editor', () => { - const runRandomClickingTest = async (matrix: string[], attempts: number) => { - const width = matrix[0].length; - const height = matrix.length; - - await editor.update(matrix.join('\n'), true); - let lastEvaluatedToken = tokenProvider.getTokenAt(coreEditor.getCurrentPosition()); - let currentToken: Token | null; - - for (let i = 1; i < attempts; i++) { - const pos = { - column: Math.ceil(Math.random() * width), - lineNumber: Math.ceil(Math.random() * height), - }; - - currentToken = tokenProvider.getTokenAt(pos); - expect(lastEvaluatedToken).not.toBeNull(); - expect(currentToken).not.toBeNull(); - expect(looksLikeTypingIn(lastEvaluatedToken!, currentToken!, coreEditor)).toBe(false); - lastEvaluatedToken = currentToken; - } - }; - - for (const matrix of matrices) { - const attempts = 4 * matrix[0].length * matrix.length; - test(`random clicking ${matrix[0].length}x${matrix.length} map ${attempts} times`, () => - runRandomClickingTest(matrix, attempts)); - } - }); -}); diff --git a/src/plugins/console/public/lib/autocomplete/looks_like_typing_in.ts b/src/plugins/console/public/lib/autocomplete/looks_like_typing_in.ts deleted file mode 100644 index a22c985a943f6..0000000000000 --- a/src/plugins/console/public/lib/autocomplete/looks_like_typing_in.ts +++ /dev/null @@ -1,109 +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 { CoreEditor, Position, Token } from '../../types'; - -enum Move { - ForwardOneCharacter = 1, - ForwardOneToken, // the column position may jump to the next token by autocomplete - ForwardTwoTokens, // the column position could jump two tokens due to autocomplete -} - -const knownTypingInTokenTypes = new Map>>([ - [ - Move.ForwardOneCharacter, - new Map>([ - // a pair of the last evaluated token type and a set of the current token types - ['', new Set(['method'])], - ['url.amp', new Set(['url.param'])], - ['url.comma', new Set(['url.part', 'url.questionmark'])], - ['url.equal', new Set(['url.value'])], - ['url.param', new Set(['url.amp', 'url.equal'])], - ['url.questionmark', new Set(['url.param'])], - ['url.slash', new Set(['url.part', 'url.questionmark'])], - ['url.value', new Set(['url.amp'])], - ]), - ], - [ - Move.ForwardOneToken, - new Map>([ - ['method', new Set(['url.part'])], - ['url.amp', new Set(['url.amp', 'url.equal'])], - ['url.comma', new Set(['url.comma', 'url.questionmark', 'url.slash'])], - ['url.equal', new Set(['url.amp'])], - ['url.param', new Set(['url.equal'])], - ['url.part', new Set(['url.comma', 'url.questionmark', 'url.slash'])], - ['url.questionmark', new Set(['url.equal'])], - ['url.slash', new Set(['url.comma', 'url.questionmark', 'url.slash'])], - ['url.value', new Set(['url.amp'])], - ['whitespace', new Set(['url.comma', 'url.questionmark', 'url.slash'])], - ]), - ], - [ - Move.ForwardTwoTokens, - new Map>([['url.part', new Set(['url.param', 'url.part'])]]), - ], -]); - -const getOneCharacterNextOnTheRight = (pos: Position, coreEditor: CoreEditor): string => { - const range = { - start: { column: pos.column + 1, lineNumber: pos.lineNumber }, - end: { column: pos.column + 2, lineNumber: pos.lineNumber }, - }; - return coreEditor.getValueInRange(range); -}; - -/** - * Examines a change from the last evaluated to the current token and one - * character next to the current token position on the right. Returns true if - * the change looks like typing in, false otherwise. - * - * This function is supposed to filter out situations where autocomplete is not - * preferable, such as clicking around the editor, navigating the editor via - * keyboard arrow keys, etc. - */ -export const looksLikeTypingIn = ( - lastEvaluatedToken: Token, - currentToken: Token, - coreEditor: CoreEditor -): boolean => { - // if the column position moves to the right in the same line and the current - // token length is 1, then user is possibly typing in a character. - if ( - lastEvaluatedToken.position.column < currentToken.position.column && - lastEvaluatedToken.position.lineNumber === currentToken.position.lineNumber && - currentToken.value.length === 1 && - getOneCharacterNextOnTheRight(currentToken.position, coreEditor) === '' - ) { - const moves = - lastEvaluatedToken.position.column + 1 === currentToken.position.column - ? [Move.ForwardOneCharacter] - : [Move.ForwardOneToken, Move.ForwardTwoTokens]; - for (const move of moves) { - const tokenTypesPairs = knownTypingInTokenTypes.get(move) ?? new Map>(); - const currentTokenTypes = tokenTypesPairs.get(lastEvaluatedToken.type) ?? new Set(); - if (currentTokenTypes.has(currentToken.type)) { - return true; - } - } - } - - // if the column or the line number have changed for the last token or - // user did not provided a new value, then we should not show autocomplete - // this guards against triggering autocomplete when clicking around the editor - if ( - lastEvaluatedToken.position.column !== currentToken.position.column || - lastEvaluatedToken.position.lineNumber !== currentToken.position.lineNumber || - lastEvaluatedToken.value === currentToken.value - ) { - return false; - } - - return true; -}; diff --git a/src/plugins/console/public/lib/autocomplete_entities/autocomplete_entities.test.js b/src/plugins/console/public/lib/autocomplete_entities/autocomplete_entities.test.js index 5901c95b9a074..0cffb157abb4c 100644 --- a/src/plugins/console/public/lib/autocomplete_entities/autocomplete_entities.test.js +++ b/src/plugins/console/public/lib/autocomplete_entities/autocomplete_entities.test.js @@ -7,7 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import '../../application/models/sense_editor/sense_editor.test.mocks'; import { setAutocompleteInfo, AutocompleteInfo } from '../../services'; import { expandAliases } from './expand_aliases'; import { httpServiceMock } from '@kbn/core-http-browser-mocks'; diff --git a/src/plugins/console/public/lib/curl_parsing/__fixtures__/curl_parsing.txt b/src/plugins/console/public/lib/curl_parsing/__fixtures__/curl_parsing.txt deleted file mode 100644 index b6dd39479550d..0000000000000 --- a/src/plugins/console/public/lib/curl_parsing/__fixtures__/curl_parsing.txt +++ /dev/null @@ -1,146 +0,0 @@ -========== -Curl 1 -------------------------------------- -curl -XPUT 'http://localhost:9200/twitter/tweet/1' -d '{ - "user" : "kimchy", - "post_date" : "2009-11-15T14:12:12", - "message" : "trying out Elastic Search" -}' -------------------------------------- -PUT /twitter/tweet/1 -{ - "user" : "kimchy", - "post_date" : "2009-11-15T14:12:12", - "message" : "trying out Elastic Search" -} -========== -Curl 2 -------------------------------------- -curl -XGET "localhost/twitter/tweet/1?version=2" -d '{ - "message" : "elasticsearch now has versioning support, double cool!" -}' -------------------------------------- -GET /twitter/tweet/1?version=2 -{ - "message" : "elasticsearch now has versioning support, double cool!" -} -=========== -Curl 3 -------------------------------------- -curl -XPOST https://localhost/twitter/tweet/1?version=2 -d '{ - "message" : "elasticsearch now has versioning support, double cool!" -}' -------------------------------------- -POST /twitter/tweet/1?version=2 -{ - "message" : "elasticsearch now has versioning support, double cool!" -} -========= -Curl 4 -------------------------------------- -curl -XPOST https://localhost/twitter -------------------------------------- -POST /twitter -========== -Curl 5 -------------------------------------- -curl -X POST https://localhost/twitter/ -------------------------------------- -POST /twitter/ -============= -Curl 6 -------------------------------------- -curl -s -XPOST localhost:9200/missing-test -d' -{ - "mappings": { - } -}' -------------------------------------- -POST /missing-test -{ - "mappings": { - } -} -========================= -Curl 7 -------------------------------------- -curl 'localhost:9200/missing-test/doc/_search?pretty' -d' -{ - "query": { - }, -}' -------------------------------------- -GET /missing-test/doc/_search?pretty -{ - "query": { - }, -} -=========================== -Curl 8 -------------------------------------- -curl localhost:9200/ -d' -{ - "query": { - } -}' -------------------------------------- -GET / -{ - "query": { - } -} -==================================== -Curl Script -------------------------------------- -#!bin/sh - -// test something -curl 'localhost:9200/missing-test/doc/_search?pretty' -d' -{ - "query": { - }, -}' - - -curl -XPOST https://localhost/twitter - -#someother comments -curl localhost:9200/ -d' -{ - "query": { - } -}' - - -------------------- -# test something -GET /missing-test/doc/_search?pretty -{ - "query": { - }, -} - -POST /twitter - -#someother comments -GET / -{ - "query": { - } -} -==================================== -Curl with some text -------------------------------------- -This is what I meant: - -curl 'localhost:9200/missing-test/doc/_search?' - -This, however, does work: -curl 'localhost:9200/missing/doc/_search?' -------------------- -### This is what I meant: - -GET /missing-test/doc/_search? - -### This, however, does work: -GET /missing/doc/_search? diff --git a/src/plugins/console/public/lib/curl_parsing/curl.js b/src/plugins/console/public/lib/curl_parsing/curl.js deleted file mode 100644 index 4dd09d1b7d59b..0000000000000 --- a/src/plugins/console/public/lib/curl_parsing/curl.js +++ /dev/null @@ -1,194 +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". - */ - -function detectCURLinLine(line) { - // returns true if text matches a curl request - return line.match(/^\s*?curl\s+(-X[A-Z]+)?\s*['"]?.*?['"]?(\s*$|\s+?-d\s*?['"])/); -} - -export function detectCURL(text) { - // returns true if text matches a curl request - if (!text) return false; - for (const line of text.split('\n')) { - if (detectCURLinLine(line)) { - return true; - } - } - return false; -} - -export function parseCURL(text) { - let state = 'NONE'; - const out = []; - let body = []; - let line = ''; - const lines = text.trim().split('\n'); - let matches; - - const EmptyLine = /^\s*$/; - const Comment = /^\s*(?:#|\/{2,})(.*)\n?$/; - const ExecutionComment = /^\s*#!/; - const ClosingSingleQuote = /^([^']*)'/; - const ClosingDoubleQuote = /^((?:[^\\"]|\\.)*)"/; - const EscapedQuotes = /^((?:[^\\"']|\\.)+)/; - - const LooksLikeCurl = /^\s*curl\s+/; - const CurlVerb = /-X ?(GET|HEAD|POST|PUT|DELETE|PATCH)/; - - const HasProtocol = /[\s"']https?:\/\//; - const CurlRequestWithProto = /[\s"']https?:\/\/[^\/ ]+\/+([^\s"']+)/; - const CurlRequestWithoutProto = /[\s"'][^\/ ]+\/+([^\s"']+)/; - const CurlData = /^.+\s(--data|-d)\s*/; - const SenseLine = /^\s*(GET|HEAD|POST|PUT|DELETE|PATCH)\s+\/?(.+)/; - - if (lines.length > 0 && ExecutionComment.test(lines[0])) { - lines.shift(); - } - - function nextLine() { - if (line.length > 0) { - return true; - } - if (lines.length === 0) { - return false; - } - line = lines.shift().replace(/[\r\n]+/g, '\n') + '\n'; - return true; - } - - function unescapeLastBodyEl() { - const str = body.pop().replace(/\\([\\"'])/g, '$1'); - body.push(str); - } - - // Is the next char a single or double quote? - // If so remove it - function detectQuote() { - if (line.substr(0, 1) === "'") { - line = line.substr(1); - state = 'SINGLE_QUOTE'; - } else if (line.substr(0, 1) === '"') { - line = line.substr(1); - state = 'DOUBLE_QUOTE'; - } else { - state = 'UNQUOTED'; - } - } - - // Body is finished - append to output with final LF - function addBodyToOut() { - if (body.length > 0) { - out.push(body.join('')); - body = []; - } - state = 'LF'; - out.push('\n'); - } - - // If the pattern matches, then the state is about to change, - // so add the capture to the body and detect the next state - // Otherwise add the whole line - function consumeMatching(pattern) { - const matches = line.match(pattern); - if (matches) { - body.push(matches[1]); - line = line.substr(matches[0].length); - detectQuote(); - } else { - body.push(line); - line = ''; - } - } - - function parseCurlLine() { - let verb = 'GET'; - let request = ''; - let matches; - if ((matches = line.match(CurlVerb))) { - verb = matches[1]; - } - - // JS regexen don't support possessive quantifiers, so - // we need two distinct patterns - const pattern = HasProtocol.test(line) ? CurlRequestWithProto : CurlRequestWithoutProto; - - if ((matches = line.match(pattern))) { - request = matches[1]; - } - - out.push(verb + ' /' + request + '\n'); - - if ((matches = line.match(CurlData))) { - line = line.substr(matches[0].length); - detectQuote(); - if (EmptyLine.test(line)) { - line = ''; - } - } else { - state = 'NONE'; - line = ''; - out.push(''); - } - } - - while (nextLine()) { - if (state === 'SINGLE_QUOTE') { - consumeMatching(ClosingSingleQuote); - } else if (state === 'DOUBLE_QUOTE') { - consumeMatching(ClosingDoubleQuote); - unescapeLastBodyEl(); - } else if (state === 'UNQUOTED') { - consumeMatching(EscapedQuotes); - if (body.length) { - unescapeLastBodyEl(); - } - if (state === 'UNQUOTED') { - addBodyToOut(); - line = ''; - } - } - - // the BODY state (used to match the body of a Sense request) - // can be terminated early if it encounters - // a comment or an empty line - else if (state === 'BODY') { - if (Comment.test(line) || EmptyLine.test(line)) { - addBodyToOut(); - } else { - body.push(line); - line = ''; - } - } else if (EmptyLine.test(line)) { - if (state !== 'LF') { - out.push('\n'); - state = 'LF'; - } - line = ''; - } else if ((matches = line.match(Comment))) { - out.push('#' + matches[1] + '\n'); - state = 'NONE'; - line = ''; - } else if (LooksLikeCurl.test(line)) { - parseCurlLine(); - } else if ((matches = line.match(SenseLine))) { - out.push(matches[1] + ' /' + matches[2] + '\n'); - line = ''; - state = 'BODY'; - } - - // Nothing else matches, so output with a prefix of !!! for debugging purposes - else { - out.push('### ' + line); - line = ''; - } - } - - addBodyToOut(); - return out.join('').trim(); -} diff --git a/src/plugins/console/public/lib/curl_parsing/curl_parsing.test.js b/src/plugins/console/public/lib/curl_parsing/curl_parsing.test.js deleted file mode 100644 index 80a60cd259717..0000000000000 --- a/src/plugins/console/public/lib/curl_parsing/curl_parsing.test.js +++ /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", 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 _ from 'lodash'; -import { detectCURL, parseCURL } from './curl'; -import curlTests from './__fixtures__/curl_parsing.txt'; - -describe('CURL', () => { - const notCURLS = ['sldhfsljfhs', 's;kdjfsldkfj curl -XDELETE ""', '{ "hello": 1 }']; - _.each(notCURLS, function (notCURL, i) { - test('cURL Detection - broken strings ' + i, function () { - expect(detectCURL(notCURL)).toEqual(false); - }); - }); - - curlTests.split(/^=+$/m).forEach(function (fixture) { - if (fixture.trim() === '') { - return; - } - fixture = fixture.split(/^-+$/m); - const name = fixture[0].trim(); - const curlText = fixture[1]; - const response = fixture[2].trim(); - - test('cURL Detection - ' + name, function () { - expect(detectCURL(curlText)).toBe(true); - const r = parseCURL(curlText); - expect(r).toEqual(response); - }); - }); -}); diff --git a/src/plugins/console/public/lib/kb/kb.test.js b/src/plugins/console/public/lib/kb/kb.test.js index 70ea0ef33ae86..7560789718e58 100644 --- a/src/plugins/console/public/lib/kb/kb.test.js +++ b/src/plugins/console/public/lib/kb/kb.test.js @@ -10,7 +10,6 @@ import _ from 'lodash'; import { populateContext } from '../autocomplete/engine'; -import '../../application/models/sense_editor/sense_editor.test.mocks'; import * as kb from '.'; import { AutocompleteInfo, setAutocompleteInfo } from '../../services'; diff --git a/src/plugins/console/public/lib/row_parser.test.ts b/src/plugins/console/public/lib/row_parser.test.ts deleted file mode 100644 index 869822b7bf055..0000000000000 --- a/src/plugins/console/public/lib/row_parser.test.ts +++ /dev/null @@ -1,107 +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 '../application/models/legacy_core_editor/legacy_core_editor.test.mocks'; - -import RowParser from './row_parser'; -import { create, MODE } from '../application/models'; -import type { SenseEditor } from '../application/models'; -import type { CoreEditor } from '../types'; - -describe('RowParser', () => { - let editor: SenseEditor | null; - let parser: RowParser | null; - - beforeEach(function () { - // Set up our document body - document.body.innerHTML = `
-
-
-
-
`; - editor = create(document.getElementById('ConAppEditor')!); - parser = new RowParser(editor.getCoreEditor() as CoreEditor); - }); - - afterEach(function () { - editor?.getCoreEditor().destroy(); - editor = null; - parser = null; - }); - - describe('getRowParseMode', () => { - const forceRetokenize = false; - - it('should return MODE.BETWEEN_REQUESTS if line is empty', () => { - editor?.getCoreEditor().setValue('', forceRetokenize); - expect(parser?.getRowParseMode()).toBe(MODE.BETWEEN_REQUESTS); - }); - - it('should return MODE.BETWEEN_REQUESTS if line is a comment', () => { - editor?.getCoreEditor().setValue('// comment', forceRetokenize); - expect(parser?.getRowParseMode()).toBe(MODE.BETWEEN_REQUESTS); - }); - - it('should return MODE.REQUEST_START | MODE.REQUEST_END if line is a single line request', () => { - editor?.getCoreEditor().setValue('GET _search', forceRetokenize); - // eslint-disable-next-line no-bitwise - expect(parser?.getRowParseMode()).toBe(MODE.REQUEST_START | MODE.REQUEST_END); - }); - - it('should return MODE.IN_REQUEST if line is a request with an opening curly brace', () => { - editor?.getCoreEditor().setValue('{', forceRetokenize); - expect(parser?.getRowParseMode()).toBe(MODE.IN_REQUEST); - }); - - it('should return MODE.MULTI_DOC_CUR_DOC_END | MODE.IN_REQUEST if line is a multi doc request with an opening curly brace', () => { - editor?.getCoreEditor().setValue('GET _msearch\n{}\n{', forceRetokenize); - const lineNumber = editor?.getCoreEditor().getLineCount()! - 1; - expect(parser?.getRowParseMode(lineNumber)).toBe( - // eslint-disable-next-line no-bitwise - MODE.MULTI_DOC_CUR_DOC_END | MODE.IN_REQUEST - ); - }); - - it('should return MODE.MULTI_DOC_CUR_DOC_END | MODE.REQUEST_END if line is a multi doc request with a closing curly brace', () => { - editor?.getCoreEditor().setValue('GET _msearch\n{}\n{"foo": 1}\n', forceRetokenize); - const lineNumber = editor?.getCoreEditor().getLineCount()! - 1; - expect(parser?.getRowParseMode(lineNumber)).toBe( - // eslint-disable-next-line no-bitwise - MODE.MULTI_DOC_CUR_DOC_END | MODE.REQUEST_END - ); - }); - - it('should return MODE.REQUEST_START | MODE.REQUEST_END if line is a request with variables', () => { - editor?.getCoreEditor().setValue('GET /${exampleVariable}', forceRetokenize); - // eslint-disable-next-line no-bitwise - expect(parser?.getRowParseMode()).toBe(MODE.REQUEST_START | MODE.REQUEST_END); - }); - - it('should return MODE.REQUEST_START | MODE.REQUEST_END if a single request line ends with a closing curly brace', () => { - editor?.getCoreEditor().setValue('DELETE /_bar/_baz%{test}', forceRetokenize); - // eslint-disable-next-line no-bitwise - expect(parser?.getRowParseMode()).toBe(MODE.REQUEST_START | MODE.REQUEST_END); - }); - - it('should return correct modes for multiple bulk requests', () => { - editor - ?.getCoreEditor() - .setValue('POST _bulk\n{"index": {"_index": "test"}}\n{"foo": "bar"}\n', forceRetokenize); - expect(parser?.getRowParseMode(0)).toBe(MODE.BETWEEN_REQUESTS); - editor - ?.getCoreEditor() - .setValue('POST _bulk\n{"index": {"_index": "test"}}\n{"foo": "bar"}\n', forceRetokenize); - const lineNumber = editor?.getCoreEditor().getLineCount()! - 1; - expect(parser?.getRowParseMode(lineNumber)).toBe( - // eslint-disable-next-line no-bitwise - MODE.REQUEST_END | MODE.MULTI_DOC_CUR_DOC_END - ); - }); - }); -}); diff --git a/src/plugins/console/public/lib/row_parser.ts b/src/plugins/console/public/lib/row_parser.ts deleted file mode 100644 index 7078bb857d95b..0000000000000 --- a/src/plugins/console/public/lib/row_parser.ts +++ /dev/null @@ -1,161 +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 { CoreEditor, Token } from '../types'; -import { TokenIterator } from './token_iterator'; - -export const MODE = { - REQUEST_START: 2, - IN_REQUEST: 4, - MULTI_DOC_CUR_DOC_END: 8, - REQUEST_END: 16, - BETWEEN_REQUESTS: 32, -}; - -// eslint-disable-next-line import/no-default-export -export default class RowParser { - constructor(private readonly editor: CoreEditor) {} - - MODE = MODE; - - getRowParseMode(lineNumber = this.editor.getCurrentPosition().lineNumber) { - const linesCount = this.editor.getLineCount(); - if (lineNumber > linesCount || lineNumber < 1) { - return MODE.BETWEEN_REQUESTS; - } - const mode = this.editor.getLineState(lineNumber); - - if (!mode) { - return MODE.BETWEEN_REQUESTS; - } // shouldn't really happen - // If another "start" mode is added here because we want to allow for new language highlighting - // please see https://github.com/elastic/kibana/pull/51446 for a discussion on why - // should consider a different approach. - if (mode !== 'start' && mode !== 'start-sql') { - return MODE.IN_REQUEST; - } - let line = (this.editor.getLineValue(lineNumber) || '').trim(); - - if (!line || line.startsWith('#') || line.startsWith('//') || line.startsWith('/*')) { - return MODE.BETWEEN_REQUESTS; - } // empty line or a comment waiting for a new req to start - - // Check for multi doc requests - if (line.endsWith('}') && !this.isRequestLine(line)) { - // check for a multi doc request must start a new json doc immediately after this one end. - lineNumber++; - if (lineNumber < linesCount + 1) { - line = (this.editor.getLineValue(lineNumber) || '').trim(); - if (line.indexOf('{') === 0) { - // next line is another doc in a multi doc - // eslint-disable-next-line no-bitwise - return MODE.MULTI_DOC_CUR_DOC_END | MODE.IN_REQUEST; - } - } - // eslint-disable-next-line no-bitwise - return MODE.REQUEST_END | MODE.MULTI_DOC_CUR_DOC_END; // end of request - } - - // check for single line requests - lineNumber++; - if (lineNumber >= linesCount + 1) { - // eslint-disable-next-line no-bitwise - return MODE.REQUEST_START | MODE.REQUEST_END; - } - line = (this.editor.getLineValue(lineNumber) || '').trim(); - if (line.indexOf('{') !== 0) { - // next line is another request - // eslint-disable-next-line no-bitwise - return MODE.REQUEST_START | MODE.REQUEST_END; - } - - return MODE.REQUEST_START; - } - - rowPredicate(lineNumber: number | undefined, editor: CoreEditor, value: number) { - const mode = this.getRowParseMode(lineNumber); - // eslint-disable-next-line no-bitwise - return (mode & value) > 0; - } - - isEndRequestRow(row?: number, _e?: CoreEditor) { - const editor = _e || this.editor; - return this.rowPredicate(row, editor, MODE.REQUEST_END); - } - - isRequestEdge(row?: number, _e?: CoreEditor) { - const editor = _e || this.editor; - // eslint-disable-next-line no-bitwise - return this.rowPredicate(row, editor, MODE.REQUEST_END | MODE.REQUEST_START); - } - - isStartRequestRow(row?: number, _e?: CoreEditor) { - const editor = _e || this.editor; - return this.rowPredicate(row, editor, MODE.REQUEST_START); - } - - isInBetweenRequestsRow(row?: number, _e?: CoreEditor) { - const editor = _e || this.editor; - return this.rowPredicate(row, editor, MODE.BETWEEN_REQUESTS); - } - - isInRequestsRow(row?: number, _e?: CoreEditor) { - const editor = _e || this.editor; - return this.rowPredicate(row, editor, MODE.IN_REQUEST); - } - - isMultiDocDocEndRow(row?: number, _e?: CoreEditor) { - const editor = _e || this.editor; - return this.rowPredicate(row, editor, MODE.MULTI_DOC_CUR_DOC_END); - } - - isEmptyToken(tokenOrTokenIter: TokenIterator | Token | null) { - const token = - tokenOrTokenIter && (tokenOrTokenIter as TokenIterator).getCurrentToken - ? (tokenOrTokenIter as TokenIterator).getCurrentToken() - : tokenOrTokenIter; - return !token || (token as Token).type === 'whitespace'; - } - - isUrlOrMethodToken(tokenOrTokenIter: TokenIterator | Token) { - const t = (tokenOrTokenIter as TokenIterator)?.getCurrentToken() ?? (tokenOrTokenIter as Token); - return t && t.type && (t.type === 'method' || t.type.indexOf('url') === 0); - } - - nextNonEmptyToken(tokenIter: TokenIterator) { - let t = tokenIter.stepForward(); - while (t && this.isEmptyToken(t)) { - t = tokenIter.stepForward(); - } - return t; - } - - prevNonEmptyToken(tokenIter: TokenIterator) { - let t = tokenIter.stepBackward(); - // empty rows return null token. - while ((t || tokenIter.getCurrentPosition().lineNumber > 1) && this.isEmptyToken(t)) - t = tokenIter.stepBackward(); - return t; - } - - isCommentToken(token: Token | null) { - return ( - token && - token.type && - (token.type === 'comment.punctuation' || - token.type === 'comment.line' || - token.type === 'comment.block') - ); - } - - isRequestLine(line: string) { - const methods = ['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'PATCH', 'OPTIONS']; - return methods.some((m) => line.startsWith(m)); - } -} diff --git a/src/plugins/console/public/styles/_app.scss b/src/plugins/console/public/styles/_app.scss index 4c3ccb8b1cadc..0f0a671d920c3 100644 --- a/src/plugins/console/public/styles/_app.scss +++ b/src/plugins/console/public/styles/_app.scss @@ -36,8 +36,6 @@ width: 100%; display: flex; flex: 0 0 auto; - - // Required on IE11 to render ace editor correctly after first input. position: relative; &__spinner { @@ -55,46 +53,6 @@ height: 100%; display: flex; flex: 1 1 1px; - - .ace_badge { - font-family: $euiFontFamily; - font-size: $euiFontSizeXS; - font-weight: $euiFontWeightMedium; - line-height: $euiLineHeight; - padding: 0 $euiSizeS; - display: inline-block; - text-decoration: none; - border-radius: calc($euiBorderRadius / 2); - white-space: nowrap; - vertical-align: middle; - cursor: default; - max-width: 100%; - - &--success { - background-color: $euiColorVis0_behindText; - color: chooseLightOrDarkText($euiColorVis0_behindText); - } - - &--warning { - background-color: $euiColorVis5_behindText; - color: chooseLightOrDarkText($euiColorVis5_behindText); - } - - &--primary { - background-color: $euiColorVis1_behindText; - color: chooseLightOrDarkText($euiColorVis1_behindText); - } - - &--default { - background-color: $euiColorLightShade; - color: chooseLightOrDarkText($euiColorLightShade); - } - - &--danger { - background-color: $euiColorVis9_behindText; - color: chooseLightOrDarkText($euiColorVis9_behindText); - } - } } .conApp__editorContent, @@ -145,17 +103,6 @@ margin-inline: 0; } -// SASSTODO: This component seems to not be used anymore? -// Possibly replaced by the Ace version -.conApp__autoComplete { - position: absolute; - left: -1000px; - visibility: hidden; - /* by pass any other element in ace and resize bar, but not modal popups */ - z-index: $euiZLevel1 + 2; - margin-top: 22px; -} - .conApp__requestProgressBarContainer { position: relative; z-index: $euiZLevel2; diff --git a/src/plugins/console/public/types/core_editor.ts b/src/plugins/console/public/types/core_editor.ts index aa9bdf21c1c94..8d5ab2a582226 100644 --- a/src/plugins/console/public/types/core_editor.ts +++ b/src/plugins/console/public/types/core_editor.ts @@ -7,7 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { Editor } from 'brace'; import { ResultTerm } from '../lib/autocomplete/types'; import { TokensProvider } from './tokens_provider'; import { Token } from './token'; @@ -94,7 +93,7 @@ export enum LINE_MODE { /** * The CoreEditor is a component separate from the Editor implementation that provides Console * app specific business logic. The CoreEditor is an interface to the lower-level editor implementation - * being used which is usually vendor code such as Ace or Monaco. + * being used which is usually vendor code such as Monaco. */ export interface CoreEditor { /** @@ -260,7 +259,7 @@ export interface CoreEditor { */ registerKeyboardShortcut(opts: { keys: string | { win?: string; mac?: string }; - fn: (editor: Editor) => void; + fn: (editor: any) => void; name: string; }): void; diff --git a/src/plugins/console/tsconfig.json b/src/plugins/console/tsconfig.json index 2b0f6127cd4af..02e4e7a9b7689 100644 --- a/src/plugins/console/tsconfig.json +++ b/src/plugins/console/tsconfig.json @@ -18,10 +18,8 @@ "@kbn/i18n-react", "@kbn/shared-ux-utility", "@kbn/core-http-browser", - "@kbn/ace", "@kbn/config-schema", "@kbn/core-http-router-server-internal", - "@kbn/web-worker-stub", "@kbn/core-elasticsearch-server", "@kbn/core-http-browser-mocks", "@kbn/react-kibana-context-theme", 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 80644fa94dc36..1ede56a2b67a7 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 @@ -16,6 +16,10 @@ import { } from '../../lib/dashboard_panel_converters'; import { DashboardAttributesAndReferences, ParsedDashboardAttributesWithType } from '../../types'; import { DashboardAttributes, SavedDashboardPanel } from '../../content_management'; +import { + createExtract, + createInject, +} from '../../dashboard_container/persistable_state/dashboard_container_references'; export interface InjectExtractDeps { embeddablePersistableStateService: EmbeddablePersistableStateService; @@ -45,10 +49,8 @@ export function injectReferences( const parsedAttributes = parseDashboardAttributesWithType(attributes); // inject references back into panels via the Embeddable persistable state service. - const injectedState = deps.embeddablePersistableStateService.inject( - parsedAttributes, - references - ) as ParsedDashboardAttributesWithType; + const inject = createInject(deps.embeddablePersistableStateService); + const injectedState = inject(parsedAttributes, references) as ParsedDashboardAttributesWithType; const injectedPanels = convertPanelMapToSavedPanels(injectedState.panels); const newAttributes = { @@ -74,11 +76,11 @@ export function extractReferences( ); } - const { references: extractedReferences, state: extractedState } = - deps.embeddablePersistableStateService.extract(parsedAttributes) as { - references: Reference[]; - state: ParsedDashboardAttributesWithType; - }; + const extract = createExtract(deps.embeddablePersistableStateService); + const { references: extractedReferences, state: extractedState } = extract(parsedAttributes) as { + references: Reference[]; + state: ParsedDashboardAttributesWithType; + }; const extractedPanels = convertPanelMapToSavedPanels(extractedState.panels); const newAttributes = { diff --git a/src/plugins/dashboard/public/dashboard_container/external_api/dashboard_renderer.test.tsx b/src/plugins/dashboard/public/dashboard_container/external_api/dashboard_renderer.test.tsx index fd41fdd5e764d..6a81a8c4fd601 100644 --- a/src/plugins/dashboard/public/dashboard_container/external_api/dashboard_renderer.test.tsx +++ b/src/plugins/dashboard/public/dashboard_container/external_api/dashboard_renderer.test.tsx @@ -18,11 +18,12 @@ import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/common'; import { setStubKibanaServices as setPresentationPanelMocks } from '@kbn/presentation-panel-plugin/public/mocks'; import { BehaviorSubject } from 'rxjs'; import { DashboardContainerFactory } from '..'; -import { DASHBOARD_CONTAINER_TYPE, DashboardCreationOptions } from '../..'; -import { embeddableService } from '../../services/kibana_services'; +import { DashboardCreationOptions } from '../..'; import { DashboardContainer } from '../embeddable/dashboard_container'; import { DashboardRenderer } from './dashboard_renderer'; +jest.mock('../embeddable/dashboard_container_factory', () => ({})); + describe('dashboard renderer', () => { let mockDashboardContainer: DashboardContainer; let mockDashboardFactory: DashboardContainerFactory; @@ -38,7 +39,10 @@ describe('dashboard renderer', () => { mockDashboardFactory = { create: jest.fn().mockReturnValue(mockDashboardContainer), } as unknown as DashboardContainerFactory; - embeddableService.getEmbeddableFactory = jest.fn().mockReturnValue(mockDashboardFactory); + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('../embeddable/dashboard_container_factory').DashboardContainerFactoryDefinition = jest + .fn() + .mockReturnValue(mockDashboardFactory); setPresentationPanelMocks(); }); @@ -46,7 +50,6 @@ describe('dashboard renderer', () => { await act(async () => { mountWithIntl(); }); - expect(embeddableService.getEmbeddableFactory).toHaveBeenCalledWith(DASHBOARD_CONTAINER_TYPE); expect(mockDashboardFactory.create).toHaveBeenCalled(); }); @@ -103,7 +106,10 @@ describe('dashboard renderer', () => { mockDashboardFactory = { create: jest.fn().mockReturnValue(mockErrorEmbeddable), } as unknown as DashboardContainerFactory; - embeddableService.getEmbeddableFactory = jest.fn().mockReturnValue(mockDashboardFactory); + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('../embeddable/dashboard_container_factory').DashboardContainerFactoryDefinition = jest + .fn() + .mockReturnValue(mockDashboardFactory); let wrapper: ReactWrapper; await act(async () => { @@ -125,7 +131,10 @@ describe('dashboard renderer', () => { const mockErrorFactory = { create: jest.fn().mockReturnValue(mockErrorEmbeddable), } as unknown as DashboardContainerFactory; - embeddableService.getEmbeddableFactory = jest.fn().mockReturnValue(mockErrorFactory); + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('../embeddable/dashboard_container_factory').DashboardContainerFactoryDefinition = jest + .fn() + .mockReturnValue(mockErrorFactory); // render the dashboard - it should run into an error and render the error embeddable. let wrapper: ReactWrapper; @@ -146,7 +155,10 @@ describe('dashboard renderer', () => { const mockSuccessFactory = { create: jest.fn().mockReturnValue(mockSuccessEmbeddable), } as unknown as DashboardContainerFactory; - embeddableService.getEmbeddableFactory = jest.fn().mockReturnValue(mockSuccessFactory); + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('../embeddable/dashboard_container_factory').DashboardContainerFactoryDefinition = jest + .fn() + .mockReturnValue(mockSuccessFactory); // update the saved object id to trigger another dashboard load. await act(async () => { @@ -175,7 +187,10 @@ describe('dashboard renderer', () => { const mockErrorFactory = { create: jest.fn().mockReturnValue(mockErrorEmbeddable), } as unknown as DashboardContainerFactory; - embeddableService.getEmbeddableFactory = jest.fn().mockReturnValue(mockErrorFactory); + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('../embeddable/dashboard_container_factory').DashboardContainerFactoryDefinition = jest + .fn() + .mockReturnValue(mockErrorFactory); // render the dashboard - it should run into an error and render the error embeddable. let wrapper: ReactWrapper; @@ -238,7 +253,10 @@ describe('dashboard renderer', () => { const mockSuccessFactory = { create: jest.fn().mockReturnValue(mockSuccessEmbeddable), } as unknown as DashboardContainerFactory; - embeddableService.getEmbeddableFactory = jest.fn().mockReturnValue(mockSuccessFactory); + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('../embeddable/dashboard_container_factory').DashboardContainerFactoryDefinition = jest + .fn() + .mockReturnValue(mockSuccessFactory); let wrapper: ReactWrapper; await act(async () => { @@ -263,7 +281,10 @@ describe('dashboard renderer', () => { const mockUseMarginFalseFactory = { create: jest.fn().mockReturnValue(mockUseMarginFalseEmbeddable), } as unknown as DashboardContainerFactory; - embeddableService.getEmbeddableFactory = jest.fn().mockReturnValue(mockUseMarginFalseFactory); + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('../embeddable/dashboard_container_factory').DashboardContainerFactoryDefinition = jest + .fn() + .mockReturnValue(mockUseMarginFalseFactory); let wrapper: ReactWrapper; await act(async () => { diff --git a/src/plugins/dashboard/public/dashboard_container/external_api/dashboard_renderer.tsx b/src/plugins/dashboard/public/dashboard_container/external_api/dashboard_renderer.tsx index a43bd6ddbc75b..40b54e42e6ffa 100644 --- a/src/plugins/dashboard/public/dashboard_container/external_api/dashboard_renderer.tsx +++ b/src/plugins/dashboard/public/dashboard_container/external_api/dashboard_renderer.tsx @@ -20,15 +20,11 @@ import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/common'; import { useStateFromPublishingSubject } from '@kbn/presentation-publishing'; import { LocatorPublic } from '@kbn/share-plugin/common'; -import { DASHBOARD_CONTAINER_TYPE } from '..'; import { DashboardContainerInput } from '../../../common'; import { DashboardApi } from '../../dashboard_api/types'; import { embeddableService, screenshotModeService } from '../../services/kibana_services'; import type { DashboardContainer } from '../embeddable/dashboard_container'; -import { - DashboardContainerFactory, - DashboardContainerFactoryDefinition, -} from '../embeddable/dashboard_container_factory'; +import { DashboardContainerFactoryDefinition } from '../embeddable/dashboard_container_factory'; import type { DashboardCreationOptions } from '../..'; import { DashboardLocatorParams, DashboardRedirect } from '../types'; import { Dashboard404Page } from './dashboard_404'; @@ -91,12 +87,8 @@ export function DashboardRenderer({ (async () => { const creationOptions = await getCreationOptions?.(); - const dashboardFactory = embeddableService.getEmbeddableFactory( - DASHBOARD_CONTAINER_TYPE - ) as DashboardContainerFactory & { - create: DashboardContainerFactoryDefinition['create']; - }; - const container = await dashboardFactory?.create( + const dashboardFactory = new DashboardContainerFactoryDefinition(embeddableService); + const container = await dashboardFactory.create( { id } as unknown as DashboardContainerInput, // Input from creationOptions is used instead. undefined, creationOptions, diff --git a/src/plugins/dashboard/public/dashboard_container/index.ts b/src/plugins/dashboard/public/dashboard_container/index.ts index 16314f52d38f8..b4ecb30f3c25d 100644 --- a/src/plugins/dashboard/public/dashboard_container/index.ts +++ b/src/plugins/dashboard/public/dashboard_container/index.ts @@ -15,10 +15,7 @@ export const DASHBOARD_CONTAINER_TYPE = 'dashboard'; export const LATEST_DASHBOARD_CONTAINER_VERSION = convertNumberToDashboardVersion(LATEST_VERSION); export type { DashboardContainer } from './embeddable/dashboard_container'; -export { - type DashboardContainerFactory, - DashboardContainerFactoryDefinition, -} from './embeddable/dashboard_container_factory'; +export { type DashboardContainerFactory } from './embeddable/dashboard_container_factory'; export { LazyDashboardRenderer } from './external_api/lazy_dashboard_renderer'; export type { DashboardLocatorParams } from './types'; diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index b1d60adc84d0f..b7a920eb08ce3 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -72,13 +72,13 @@ import { LEGACY_DASHBOARD_APP_ID, SEARCH_SESSION_ID, } from './dashboard_constants'; -import { DashboardContainerFactoryDefinition } from './dashboard_container/embeddable/dashboard_container_factory'; import { GetPanelPlacementSettings, registerDashboardPanelPlacementSetting, } from './dashboard_container/panel_placement'; import type { FindDashboardsService } from './services/dashboard_content_management_service/types'; import { setKibanaServices, untilPluginStartServicesReady } from './services/kibana_services'; +import { buildAllDashboardActions } from './dashboard_actions'; export interface DashboardFeatureFlagConfig { allowByValueEmbeddables: boolean; @@ -227,14 +227,6 @@ export class DashboardPlugin }, }); - core.getStartServices().then(([, deps]) => { - const dashboardContainerFactory = new DashboardContainerFactoryDefinition(deps.embeddable); - embeddable.registerEmbeddableFactory( - dashboardContainerFactory.type, - dashboardContainerFactory - ); - }); - this.stopUrlTracking = () => { stopUrlTracker(); }; @@ -331,14 +323,12 @@ export class DashboardPlugin public start(core: CoreStart, plugins: DashboardStartDependencies): DashboardStart { setKibanaServices(core, plugins); - Promise.all([import('./dashboard_actions'), untilPluginStartServicesReady()]).then( - ([{ buildAllDashboardActions }]) => { - buildAllDashboardActions({ - plugins, - allowByValueEmbeddables: this.dashboardFeatureFlagConfig?.allowByValueEmbeddables, - }); - } - ); + untilPluginStartServicesReady().then(() => { + buildAllDashboardActions({ + plugins, + allowByValueEmbeddables: this.dashboardFeatureFlagConfig?.allowByValueEmbeddables, + }); + }); return { locator: this.locator, diff --git a/src/plugins/data/common/search/expressions/esql.ts b/src/plugins/data/common/search/expressions/esql.ts index b6cb039683c9b..966500710fd45 100644 --- a/src/plugins/data/common/search/expressions/esql.ts +++ b/src/plugins/data/common/search/expressions/esql.ts @@ -289,7 +289,7 @@ export const getEsqlFn = ({ getStartDependencies }: EsqlFnArguments) => { }), }) .json(params) - .ok({ json: rawResponse, requestParams }); + .ok({ json: { rawResponse }, requestParams }); }, error(error) { logInspectorRequest() diff --git a/src/plugins/data_view_management/kibana.jsonc b/src/plugins/data_view_management/kibana.jsonc index 479e357804140..5b827868ee1e8 100644 --- a/src/plugins/data_view_management/kibana.jsonc +++ b/src/plugins/data_view_management/kibana.jsonc @@ -20,6 +20,7 @@ ], "optionalPlugins": [ "noDataPage", + "share", "spaces" ], "requiredBundles": [ diff --git a/src/plugins/data_view_management/public/components/index_pattern_table/index_pattern_table.tsx b/src/plugins/data_view_management/public/components/index_pattern_table/index_pattern_table.tsx index cb93e01d1cc15..4512cb520c574 100644 --- a/src/plugins/data_view_management/public/components/index_pattern_table/index_pattern_table.tsx +++ b/src/plugins/data_view_management/public/components/index_pattern_table/index_pattern_table.tsx @@ -26,7 +26,7 @@ import { RouteComponentProps, useLocation, withRouter } from 'react-router-dom'; import useObservable from 'react-use/lib/useObservable'; import { reactRouterNavigate, useKibana } from '@kbn/kibana-react-plugin/public'; -import { NoDataViewsPromptComponent } from '@kbn/shared-ux-prompt-no-data-views'; +import { NoDataViewsPromptComponent, useOnTryESQL } from '@kbn/shared-ux-prompt-no-data-views'; import type { SpacesContextProps } from '@kbn/spaces-plugin/public'; import { DataViewType } from '@kbn/data-views-plugin/public'; import { RollupDeprecationTooltip } from '@kbn/rollup'; @@ -86,6 +86,7 @@ export const IndexPatternTable = ({ application, chrome, dataViews, + share, IndexPatternEditor, spaces, overlays, @@ -116,6 +117,12 @@ export const IndexPatternTable = ({ const hasDataView = useObservable(dataViewController.hasDataView$, defaults.hasDataView); const hasESData = useObservable(dataViewController.hasESData$, defaults.hasEsData); + const useOnTryESQLParams = { + locatorClient: share?.url.locators, + navigateToApp: application.navigateToApp, + }; + const onTryESQL = useOnTryESQL(useOnTryESQLParams); + const handleOnChange = ({ queryText, error }: { queryText: string; error: unknown }) => { if (!error) { setQuery(queryText); @@ -370,6 +377,8 @@ export const IndexPatternTable = ({ onClickCreate={() => setShowCreateDialog(true)} canCreateNewDataView={application.capabilities.indexPatterns.save as boolean} dataViewsDocLink={docLinks.links.indexPatterns.introduction} + onTryESQL={onTryESQL} + esqlDocLink={docLinks.links.query.queryESQL} emptyPromptColor={'subdued'} /> diff --git a/src/plugins/data_view_management/public/management_app/mount_management_section.tsx b/src/plugins/data_view_management/public/management_app/mount_management_section.tsx index 995d5ed977ed3..96e5ae6c96b0c 100644 --- a/src/plugins/data_view_management/public/management_app/mount_management_section.tsx +++ b/src/plugins/data_view_management/public/management_app/mount_management_section.tsx @@ -17,6 +17,7 @@ import { StartServicesAccessor } from '@kbn/core/public'; import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { ManagementAppMountParams } from '@kbn/management-plugin/public'; +import { NoDataViewsPromptKibanaProvider } from '@kbn/shared-ux-prompt-no-data-views'; import { IndexPatternTableWithRouter, EditIndexPatternContainer, @@ -64,11 +65,13 @@ export async function mountManagementSection( dataViews, fieldFormats, unifiedSearch, + share, spaces, savedObjectsManagement, }, indexPatternManagementStart, ] = await getStartServices(); + const canSave = dataViews.getCanSaveSync(); if (!canSave) { @@ -89,6 +92,7 @@ export async function mountManagementSection( chrome, uiSettings, settings, + share, notifications, overlays, unifiedSearch, @@ -115,23 +119,29 @@ export async function mountManagementSection( ReactDOM.render( - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + , params.element diff --git a/src/plugins/data_view_management/public/plugin.ts b/src/plugins/data_view_management/public/plugin.ts index 77e8c12a13ad0..0d03dc8896fd1 100644 --- a/src/plugins/data_view_management/public/plugin.ts +++ b/src/plugins/data_view_management/public/plugin.ts @@ -21,6 +21,7 @@ import { ManagementSetup } from '@kbn/management-plugin/public'; import { SavedObjectsManagementPluginStart } from '@kbn/saved-objects-management-plugin/public'; import { SpacesPluginStart } from '@kbn/spaces-plugin/public'; import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; +import { SharePluginStart } from '@kbn/share-plugin/public'; export interface IndexPatternManagementSetupDependencies { management: ManagementSetup; @@ -34,6 +35,7 @@ export interface IndexPatternManagementStartDependencies { dataViewEditor: DataViewEditorStart; dataViews: DataViewsPublicPluginStart; fieldFormats: FieldFormatsStart; + share?: SharePluginStart; spaces?: SpacesPluginStart; unifiedSearch: UnifiedSearchPublicPluginStart; savedObjectsManagement: SavedObjectsManagementPluginStart; diff --git a/src/plugins/data_view_management/public/types.ts b/src/plugins/data_view_management/public/types.ts index b7a9279de8001..161ee3b1e21de 100644 --- a/src/plugins/data_view_management/public/types.ts +++ b/src/plugins/data_view_management/public/types.ts @@ -29,6 +29,7 @@ import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; import type { SavedObjectsManagementPluginStart } from '@kbn/saved-objects-management-plugin/public'; import type { SettingsStart } from '@kbn/core-ui-settings-browser'; import type { NoDataPagePluginSetup } from '@kbn/no-data-page-plugin/public'; +import { SharePluginStart } from '@kbn/share-plugin/public'; import type { IndexPatternManagementStart } from '.'; import type { DataViewMgmtService } from './management_app/data_view_management_service'; @@ -53,6 +54,7 @@ export interface IndexPatternManagmentContext extends StartServices { fieldFormatEditors: IndexPatternFieldEditorStart['fieldFormatEditors']; IndexPatternEditor: DataViewEditorStart['IndexPatternEditorComponent']; fieldFormats: FieldFormatsStart; + share?: SharePluginStart; spaces?: SpacesPluginStart; savedObjectsManagement: SavedObjectsManagementPluginStart; noDataPage?: NoDataPagePluginSetup; diff --git a/src/plugins/data_view_management/tsconfig.json b/src/plugins/data_view_management/tsconfig.json index ea0c96cc66b74..9857dd44829fa 100644 --- a/src/plugins/data_view_management/tsconfig.json +++ b/src/plugins/data_view_management/tsconfig.json @@ -45,6 +45,7 @@ "@kbn/code-editor", "@kbn/react-kibana-mount", "@kbn/rollup", + "@kbn/share-plugin", ], "exclude": [ "target/**/*", diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/ace/_ui_ace_keyboard_mode.scss b/src/plugins/es_ui_shared/__packages_do_not_import__/ace/_ui_ace_keyboard_mode.scss deleted file mode 100644 index 2ad92f3506b20..0000000000000 --- a/src/plugins/es_ui_shared/__packages_do_not_import__/ace/_ui_ace_keyboard_mode.scss +++ /dev/null @@ -1,24 +0,0 @@ -.kbnUiAceKeyboardHint { - position: absolute; - top: 0; - bottom: 0; - right: 0; - left: 0; - background: transparentize($euiColorEmptyShade, .3); - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - text-align: center; - opacity: 0; - - &:focus { - opacity: 1; - border: 2px solid $euiColorPrimary; - z-index: $euiZLevel1; - } - - &.kbnUiAceKeyboardHint-isInactive { - display: none; - } -} diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/ace/index.ts b/src/plugins/es_ui_shared/__packages_do_not_import__/ace/index.ts deleted file mode 100644 index 6214a2609462c..0000000000000 --- a/src/plugins/es_ui_shared/__packages_do_not_import__/ace/index.ts +++ /dev/null @@ -1,10 +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 { useUIAceKeyboardMode } from './use_ui_ace_keyboard_mode'; diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/ace/use_ui_ace_keyboard_mode.tsx b/src/plugins/es_ui_shared/__packages_do_not_import__/ace/use_ui_ace_keyboard_mode.tsx deleted file mode 100644 index f1fe888104783..0000000000000 --- a/src/plugins/es_ui_shared/__packages_do_not_import__/ace/use_ui_ace_keyboard_mode.tsx +++ /dev/null @@ -1,113 +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 React, { useEffect, useRef } from 'react'; -import * as ReactDOM from 'react-dom'; -import { keys, EuiText } from '@elastic/eui'; -import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; - -import './_ui_ace_keyboard_mode.scss'; -import type { AnalyticsServiceStart, I18nStart, ThemeServiceStart } from '@kbn/core/public'; - -interface StartServices { - analytics: Pick; - i18n: I18nStart; - theme: Pick; -} - -const OverlayText = (startServices: StartServices) => ( - // The point of this element is for accessibility purposes, so ignore eslint error - // in this case - // - - - Press Enter to start editing. - - When you’re done, press Escape to stop editing. - -); - -export function useUIAceKeyboardMode( - aceTextAreaElement: HTMLTextAreaElement | null, - startServices: StartServices, - isAccessibilityOverlayEnabled: boolean = true -) { - const overlayMountNode = useRef(null); - const autoCompleteVisibleRef = useRef(false); - useEffect(() => { - function onDismissOverlay(event: KeyboardEvent) { - if (event.key === keys.ENTER) { - event.preventDefault(); - aceTextAreaElement!.focus(); - } - } - - function enableOverlay() { - if (overlayMountNode.current) { - overlayMountNode.current.focus(); - } - } - - const isAutoCompleteVisible = () => { - const autoCompleter = document.querySelector('.ace_autocomplete'); - if (!autoCompleter) { - return false; - } - // The autoComplete is just hidden when it's closed, not removed from the DOM. - return autoCompleter.style.display !== 'none'; - }; - - const documentKeyDownListener = () => { - autoCompleteVisibleRef.current = isAutoCompleteVisible(); - }; - - const aceKeydownListener = (event: KeyboardEvent) => { - if (event.key === keys.ESCAPE && !autoCompleteVisibleRef.current) { - event.preventDefault(); - event.stopPropagation(); - enableOverlay(); - } - }; - if (aceTextAreaElement && isAccessibilityOverlayEnabled) { - // We don't control HTML elements inside of ace so we imperatively create an element - // that acts as a container and insert it just before ace's textarea element - // so that the overlay lives at the correct spot in the DOM hierarchy. - overlayMountNode.current = document.createElement('div'); - overlayMountNode.current.className = 'kbnUiAceKeyboardHint'; - overlayMountNode.current.setAttribute('role', 'application'); - overlayMountNode.current.tabIndex = 0; - overlayMountNode.current.addEventListener('focus', enableOverlay); - overlayMountNode.current.addEventListener('keydown', onDismissOverlay); - - ReactDOM.render(, overlayMountNode.current); - - aceTextAreaElement.parentElement!.insertBefore(overlayMountNode.current, aceTextAreaElement); - aceTextAreaElement.setAttribute('tabindex', '-1'); - - // Order of events: - // 1. Document capture event fires first and we check whether an autocomplete menu is open on keydown - // (not ideal because this is scoped to the entire document). - // 2. Ace changes it's state (like hiding or showing autocomplete menu) - // 3. We check what button was pressed and whether autocomplete was visible then determine - // whether it should act like a dismiss or if we should display an overlay. - document.addEventListener('keydown', documentKeyDownListener, { capture: true }); - aceTextAreaElement.addEventListener('keydown', aceKeydownListener); - } - return () => { - if (aceTextAreaElement && isAccessibilityOverlayEnabled) { - document.removeEventListener('keydown', documentKeyDownListener, { capture: true }); - aceTextAreaElement.removeEventListener('keydown', aceKeydownListener); - const textAreaContainer = aceTextAreaElement.parentElement; - if (textAreaContainer && textAreaContainer.contains(overlayMountNode.current!)) { - textAreaContainer.removeChild(overlayMountNode.current!); - } - } - }; - }, [aceTextAreaElement, startServices, isAccessibilityOverlayEnabled]); -} diff --git a/src/plugins/es_ui_shared/public/ace/index.ts b/src/plugins/es_ui_shared/public/ace/index.ts deleted file mode 100644 index 9d010117e560e..0000000000000 --- a/src/plugins/es_ui_shared/public/ace/index.ts +++ /dev/null @@ -1,10 +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 { useUIAceKeyboardMode } from '../../__packages_do_not_import__/ace'; diff --git a/src/plugins/es_ui_shared/public/index.ts b/src/plugins/es_ui_shared/public/index.ts index 3b3ccc3fca08f..ddcdb84fa5758 100644 --- a/src/plugins/es_ui_shared/public/index.ts +++ b/src/plugins/es_ui_shared/public/index.ts @@ -12,7 +12,6 @@ * In the future, each top level folder should be exported like that to avoid naming collision */ import * as Forms from './forms'; -import * as ace from './ace'; import * as GlobalFlyout from './global_flyout'; import * as XJson from './xjson'; @@ -47,7 +46,7 @@ export { useAuthorizationContext, } from './authorization'; -export { Forms, ace, GlobalFlyout, XJson }; +export { Forms, GlobalFlyout, XJson }; export { extractQueryParams, attemptToURIDecode } from './url'; diff --git a/src/plugins/es_ui_shared/static/forms/components/index.ts b/src/plugins/es_ui_shared/static/forms/components/index.ts index 4ccfeed19dbfe..2e5dd03390eb7 100644 --- a/src/plugins/es_ui_shared/static/forms/components/index.ts +++ b/src/plugins/es_ui_shared/static/forms/components/index.ts @@ -7,22 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -/* -@TODO - -The react-ace and brace/mode/json imports below are loaded eagerly - before this plugin is explicitly loaded by users. This makes -the brace JSON mode, used for JSON syntax highlighting and grammar checking, available across all of Kibana plugins. - -This is not ideal because we are loading JS that is not necessary for Kibana to start, but the alternative -is breaking JSON mode for an unknown number of ace editors across Kibana - not all components reference the underlying -EuiCodeEditor (for instance, explicitly). - -Importing here is a way of preventing a more sophisticated solution to this problem since we want to, eventually, -migrate all code editors over to Monaco. Once that is done, we should remove this import. - */ -import 'react-ace'; -import 'brace/mode/json'; - export * from './field'; export * from './form_row'; export * from './fields'; diff --git a/src/plugins/es_ui_shared/tsconfig.json b/src/plugins/es_ui_shared/tsconfig.json index f3dc3bb39a31d..2747f41b0f370 100644 --- a/src/plugins/es_ui_shared/tsconfig.json +++ b/src/plugins/es_ui_shared/tsconfig.json @@ -24,7 +24,6 @@ "@kbn/storybook", "@kbn/shared-ux-link-redirect-app", "@kbn/code-editor", - "@kbn/react-kibana-context-render", "@kbn/core-application-common", ], "exclude": [ 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 dc2d2ad2c5de2..e5ddfbe4dd037 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -705,4 +705,10 @@ export const stackManagementSchema: MakeSchemaFrom = { _meta: { description: 'Non-default value of setting.' }, }, }, + 'observability:newLogsOverview': { + type: 'boolean', + _meta: { + description: 'Enable the new logs overview component.', + }, + }, }; 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 ef20ab223dfb6..2acb487e7ed08 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -56,6 +56,7 @@ export interface UsageStats { 'observability:logsExplorer:allowedDataViews': string[]; 'observability:logSources': string[]; 'observability:enableLogsStream': boolean; + 'observability:newLogsOverview': boolean; 'observability:aiAssistantSimulatedFunctionCalling': boolean; 'observability:aiAssistantSearchConnectorIndexPattern': string; 'visualization:heatmap:maxBuckets': number; diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 958280d9eba00..830cffc17cf1c 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -10768,6 +10768,12 @@ "description": "Non-default value of setting." } }, + "observability:newLogsOverview": { + "type": "boolean", + "_meta": { + "description": "Enable the new logs overview component." + } + }, "observability:searchExcludedDataTiers": { "type": "array", "items": { diff --git a/src/plugins/unified_histogram/public/services/lens_vis_service.suggestions.test.ts b/src/plugins/unified_histogram/public/services/lens_vis_service.suggestions.test.ts index 28819f7a5c54b..1719adebe7a49 100644 --- a/src/plugins/unified_histogram/public/services/lens_vis_service.suggestions.test.ts +++ b/src/plugins/unified_histogram/public/services/lens_vis_service.suggestions.test.ts @@ -12,6 +12,7 @@ import { DataViewField } from '@kbn/data-views-plugin/common'; import { deepMockedFields, buildDataViewMock } from '@kbn/discover-utils/src/__mocks__'; import { allSuggestionsMock } from '../__mocks__/suggestions'; import { getLensVisMock } from '../__mocks__/lens_vis'; +import { convertDatatableColumnToDataViewFieldSpec } from '@kbn/data-view-utils'; import { UnifiedHistogramSuggestionType } from '../types'; describe('LensVisService suggestions', () => { @@ -198,6 +199,11 @@ describe('LensVisService suggestions', () => { }); test('should return histogramSuggestion if no suggestions returned by the api with the breakdown field if it is given', async () => { + const breakdown = convertDatatableColumnToDataViewFieldSpec({ + name: 'var0', + id: 'var0', + meta: { type: 'number' }, + }); const lensVis = await getLensVisMock({ filters: [], query: { esql: 'from the-data-view | limit 100' }, @@ -207,7 +213,7 @@ describe('LensVisService suggestions', () => { from: '2023-09-03T08:00:00.000Z', to: '2023-09-04T08:56:28.274Z', }, - breakdownField: { name: 'var0' } as DataViewField, + breakdownField: breakdown as DataViewField, columns: [ { id: 'var0', @@ -247,4 +253,54 @@ describe('LensVisService suggestions', () => { expect(lensVis.visContext?.attributes.state.query).toStrictEqual(histogramQuery); }); + + test('should return histogramSuggestion if no suggestions returned by the api with a geo point breakdown field correctly', async () => { + const lensVis = await getLensVisMock({ + filters: [], + query: { esql: 'from the-data-view | limit 100' }, + dataView: dataViewMock, + timeInterval: 'auto', + timeRange: { + from: '2023-09-03T08:00:00.000Z', + to: '2023-09-04T08:56:28.274Z', + }, + breakdownField: { name: 'coordinates' } as DataViewField, + columns: [ + { + id: 'coordinates', + name: 'coordinates', + meta: { + type: 'geo_point', + }, + }, + ], + isPlainRecord: true, + allSuggestions: [], + hasHistogramSuggestionForESQL: true, + }); + + expect(lensVis.currentSuggestionContext?.type).toBe( + UnifiedHistogramSuggestionType.histogramForESQL + ); + expect(lensVis.currentSuggestionContext?.suggestion).toBeDefined(); + expect(lensVis.currentSuggestionContext?.suggestion?.visualizationState).toHaveProperty( + 'layers', + [ + { + layerId: '662552df-2cdc-4539-bf3b-73b9f827252c', + seriesType: 'bar_stacked', + xAccessor: '@timestamp every 30 second', + accessors: ['results'], + layerType: 'data', + }, + ] + ); + + const histogramQuery = { + esql: `from the-data-view | limit 100 +| EVAL timestamp=DATE_TRUNC(30 minute, @timestamp) | stats results = count(*) by timestamp, \`coordinates\` | rename timestamp as \`@timestamp every 30 minute\``, + }; + + expect(lensVis.visContext?.attributes.state.query).toStrictEqual(histogramQuery); + }); }); 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 eccfd663b2557..25bb8be6f6242 100644 --- a/src/plugins/unified_histogram/public/services/lens_vis_service.ts +++ b/src/plugins/unified_histogram/public/services/lens_vis_service.ts @@ -9,7 +9,11 @@ import { BehaviorSubject, distinctUntilChanged, map, Observable } from 'rxjs'; import { isEqual } from 'lodash'; -import { removeDropCommandsFromESQLQuery, appendToESQLQuery } from '@kbn/esql-utils'; +import { + removeDropCommandsFromESQLQuery, + appendToESQLQuery, + isESQLColumnSortable, +} from '@kbn/esql-utils'; import type { DataView, DataViewField } from '@kbn/data-views-plugin/common'; import type { CountIndexPatternColumn, @@ -553,12 +557,17 @@ export class LensVisService { const queryInterval = interval ?? computeInterval(timeRange, this.services.data); const language = getAggregateQueryMode(query); const safeQuery = removeDropCommandsFromESQLQuery(query[language]); - const breakdown = breakdownColumn - ? `, \`${breakdownColumn.name}\` | sort \`${breakdownColumn.name}\` asc` - : ''; + const breakdown = breakdownColumn ? `, \`${breakdownColumn.name}\`` : ''; + + // sort by breakdown column if it's sortable + const sortBy = + breakdownColumn && isESQLColumnSortable(breakdownColumn) + ? ` | sort \`${breakdownColumn.name}\` asc` + : ''; + return appendToESQLQuery( safeQuery, - `| EVAL timestamp=DATE_TRUNC(${queryInterval}, ${dataView.timeFieldName}) | stats results = count(*) by timestamp${breakdown} | rename timestamp as \`${dataView.timeFieldName} every ${queryInterval}\`` + `| EVAL timestamp=DATE_TRUNC(${queryInterval}, ${dataView.timeFieldName}) | stats results = count(*) by timestamp${breakdown}${sortBy} | rename timestamp as \`${dataView.timeFieldName} every ${queryInterval}\`` ); }; diff --git a/src/plugins/usage_collection/server/usage_counters/saved_objects.test.ts b/src/plugins/usage_collection/server/usage_counters/saved_objects.test.ts index ebced92622779..927869b6d0f89 100644 --- a/src/plugins/usage_collection/server/usage_counters/saved_objects.test.ts +++ b/src/plugins/usage_collection/server/usage_counters/saved_objects.test.ts @@ -80,6 +80,7 @@ describe('storeCounter', () => { ], Object { "namespace": "default", + "refresh": false, "upsertAttributes": Object { "counterName": "b", "counterType": "c", diff --git a/src/plugins/usage_collection/server/usage_counters/saved_objects.ts b/src/plugins/usage_collection/server/usage_counters/saved_objects.ts index 9c4e2832946e6..d5f49016e5296 100644 --- a/src/plugins/usage_collection/server/usage_counters/saved_objects.ts +++ b/src/plugins/usage_collection/server/usage_counters/saved_objects.ts @@ -122,6 +122,7 @@ export const storeCounter = async ({ metric, soRepository }: StoreCounterParams) counterType, source, }, + refresh: false, } ); }; diff --git a/src/plugins/usage_collection/server/usage_counters/usage_counters_service.test.ts b/src/plugins/usage_collection/server/usage_counters/usage_counters_service.test.ts index 6128b643918a1..1041cfb5ce36f 100644 --- a/src/plugins/usage_collection/server/usage_counters/usage_counters_service.test.ts +++ b/src/plugins/usage_collection/server/usage_counters/usage_counters_service.test.ts @@ -157,6 +157,7 @@ describe('UsageCountersService', () => { }, ], Object { + "refresh": false, "upsertAttributes": Object { "counterName": "counterA", "counterType": "count", @@ -175,6 +176,7 @@ describe('UsageCountersService', () => { }, ], Object { + "refresh": false, "upsertAttributes": Object { "counterName": "counterB", "counterType": "count", diff --git a/src/plugins/vis_default_editor/public/default_editor.tsx b/src/plugins/vis_default_editor/public/default_editor.tsx index f61450c8e85e0..dc9e83e8c3b43 100644 --- a/src/plugins/vis_default_editor/public/default_editor.tsx +++ b/src/plugins/vis_default_editor/public/default_editor.tsx @@ -8,7 +8,6 @@ */ import './index.scss'; -import 'brace/mode/json'; import React, { useEffect, useRef, useState, useCallback } from 'react'; import { EventEmitter } from 'events'; diff --git a/test/functional/apps/dashboard/group6/dashboard_esql_no_data.ts b/test/functional/apps/dashboard/group6/dashboard_esql_no_data.ts new file mode 100644 index 0000000000000..148cb95a82b11 --- /dev/null +++ b/test/functional/apps/dashboard/group6/dashboard_esql_no_data.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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, getPageObjects }: FtrProviderContext) { + const kibanaServer = getService('kibanaServer'); + const testSubjects = getService('testSubjects'); + const esql = getService('esql'); + const PageObjects = getPageObjects(['discover', 'dashboard']); + + describe('No Data Views: Try ES|QL', () => { + before(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + }); + + it('enables user to create a dashboard with ES|QL from no-data-prompt', async () => { + await PageObjects.dashboard.navigateToApp(); + + await testSubjects.existOrFail('noDataViewsPrompt'); + await testSubjects.click('tryESQLLink'); + + await PageObjects.discover.expectOnDiscover(); + await esql.expectEsqlStatement('FROM logs* | LIMIT 10'); + }); + }); +} diff --git a/test/functional/apps/dashboard/group6/index.ts b/test/functional/apps/dashboard/group6/index.ts index 302ca2e0480a0..340c9b425571b 100644 --- a/test/functional/apps/dashboard/group6/index.ts +++ b/test/functional/apps/dashboard/group6/index.ts @@ -37,5 +37,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./dashboard_snapshots')); loadTestFile(require.resolve('./embeddable_library')); loadTestFile(require.resolve('./dashboard_esql_chart')); + loadTestFile(require.resolve('./dashboard_esql_no_data')); }); } diff --git a/test/functional/apps/management/data_views/_scripted_fields.ts b/test/functional/apps/management/data_views/_scripted_fields.ts index 172537bf4e73a..f86ae72aa5047 100644 --- a/test/functional/apps/management/data_views/_scripted_fields.ts +++ b/test/functional/apps/management/data_views/_scripted_fields.ts @@ -19,10 +19,6 @@ // 3. Filter in Discover by the scripted field // 4. Visualize with aggregation on the scripted field by clicking unifiedFieldList.clickFieldListItemVisualize -// NOTE: Scripted field input is managed by Ace editor, which automatically -// appends closing braces, for exmaple, if you type opening square brace [ -// it will automatically insert a a closing square brace ], etc. - import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; diff --git a/test/functional/apps/management/data_views/_scripted_fields_classic_table.ts b/test/functional/apps/management/data_views/_scripted_fields_classic_table.ts index 063e0b960d52e..4f3d30222e496 100644 --- a/test/functional/apps/management/data_views/_scripted_fields_classic_table.ts +++ b/test/functional/apps/management/data_views/_scripted_fields_classic_table.ts @@ -19,10 +19,6 @@ // 3. Filter in Discover by the scripted field // 4. Visualize with aggregation on the scripted field by clicking unifiedFieldList.clickFieldListItemVisualize -// NOTE: Scripted field input is managed by Ace editor, which automatically -// appends closing braces, for exmaple, if you type opening square brace [ -// it will automatically insert a a closing square brace ], etc. - import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; diff --git a/test/functional/apps/management/data_views/_try_esql.ts b/test/functional/apps/management/data_views/_try_esql.ts new file mode 100644 index 0000000000000..276e61c4a721f --- /dev/null +++ b/test/functional/apps/management/data_views/_try_esql.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 { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const kibanaServer = getService('kibanaServer'); + const testSubjects = getService('testSubjects'); + const esql = getService('esql'); + const PageObjects = getPageObjects(['settings', 'common', 'discover']); + + describe('No Data Views: Try ES|QL', () => { + before(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + }); + + it('navigates to Discover and presents an ES|QL query', async () => { + await PageObjects.settings.navigateTo(); + await PageObjects.settings.clickKibanaIndexPatterns(); + + await testSubjects.existOrFail('noDataViewsPrompt'); + await testSubjects.click('tryESQLLink'); + + await PageObjects.discover.expectOnDiscover(); + await esql.expectEsqlStatement('FROM logs* | LIMIT 10'); + }); + }); +} diff --git a/test/functional/apps/management/index.ts b/test/functional/apps/management/index.ts index f3d26f2e1c6d7..2300543f06d51 100644 --- a/test/functional/apps/management/index.ts +++ b/test/functional/apps/management/index.ts @@ -38,6 +38,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./data_views/_legacy_url_redirect')); loadTestFile(require.resolve('./data_views/_exclude_index_pattern')); loadTestFile(require.resolve('./data_views/_index_pattern_filter')); + loadTestFile(require.resolve('./data_views/_try_esql')); loadTestFile(require.resolve('./data_views/_scripted_fields_filter')); loadTestFile(require.resolve('./_import_objects')); loadTestFile(require.resolve('./data_views/_test_huge_fields')); diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts index 1474e9d315538..ab6356075fd81 100644 --- a/test/functional/page_objects/discover_page.ts +++ b/test/functional/page_objects/discover_page.ts @@ -32,6 +32,12 @@ export class DiscoverPageObject extends FtrService { private readonly defaultFindTimeout = this.config.get('timeouts.find'); + /** Ensures that navigation to discover has completed */ + public async expectOnDiscover() { + await this.testSubjects.existOrFail('discoverNewButton'); + await this.testSubjects.existOrFail('discoverOpenButton'); + } + public async getChartTimespan() { return await this.testSubjects.getAttribute('unifiedHistogramChart', 'data-time-range'); } diff --git a/test/functional/services/esql.ts b/test/functional/services/esql.ts index 63836d2c5d2f5..c144c6e8993be 100644 --- a/test/functional/services/esql.ts +++ b/test/functional/services/esql.ts @@ -7,12 +7,19 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import expect from '@kbn/expect'; import { FtrService } from '../ftr_provider_context'; export class ESQLService extends FtrService { private readonly retry = this.ctx.getService('retry'); private readonly testSubjects = this.ctx.getService('testSubjects'); + /** Ensures that the ES|QL code editor is loaded with a given statement */ + public async expectEsqlStatement(statement: string) { + const codeEditor = await this.testSubjects.find('ESQLEditor'); + expect(await codeEditor.getAttribute('innerText')).to.contain(statement); + } + public async getHistoryItems(): Promise { const queryHistory = await this.testSubjects.find('ESQLEditor-queryHistory'); const tableBody = await this.retry.try(async () => queryHistory.findByTagName('tbody')); diff --git a/test/plugin_functional/test_suites/core_plugins/rendering.ts b/test/plugin_functional/test_suites/core_plugins/rendering.ts index 0054750a55b24..6c9d805c43b30 100644 --- a/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -315,6 +315,8 @@ export default function ({ getService }: PluginFunctionalProviderContext) { // 'xpack.reporting.poll.jobsRefresh.intervalErrorMultiplier (number)', 'xpack.rollup.ui.enabled (boolean?)', 'xpack.saved_object_tagging.cache_refresh_interval (duration?)', + + 'xpack.searchAssistant.ui.enabled (boolean?)', 'xpack.searchInferenceEndpoints.ui.enabled (boolean?)', 'xpack.searchPlayground.ui.enabled (boolean?)', 'xpack.security.loginAssistanceMessage (string?)', diff --git a/tsconfig.base.json b/tsconfig.base.json index 3df30d9cf8c30..188c96734d2ce 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -6,8 +6,6 @@ // START AUTOMATED PACKAGE LISTING "@kbn/aad-fixtures-plugin": ["x-pack/test/alerting_api_integration/common/plugins/aad"], "@kbn/aad-fixtures-plugin/*": ["x-pack/test/alerting_api_integration/common/plugins/aad/*"], - "@kbn/ace": ["packages/kbn-ace"], - "@kbn/ace/*": ["packages/kbn-ace/*"], "@kbn/actions-plugin": ["x-pack/plugins/actions"], "@kbn/actions-plugin/*": ["x-pack/plugins/actions/*"], "@kbn/actions-simulators-plugin": ["x-pack/test/alerting_api_integration/common/plugins/actions_simulators"], @@ -16,6 +14,8 @@ "@kbn/actions-types/*": ["packages/kbn-actions-types/*"], "@kbn/advanced-settings-plugin": ["src/plugins/advanced_settings"], "@kbn/advanced-settings-plugin/*": ["src/plugins/advanced_settings/*"], + "@kbn/ai-assistant": ["x-pack/packages/kbn-ai-assistant"], + "@kbn/ai-assistant/*": ["x-pack/packages/kbn-ai-assistant/*"], "@kbn/ai-assistant-management-plugin": ["src/plugins/ai_assistant_management/selection"], "@kbn/ai-assistant-management-plugin/*": ["src/plugins/ai_assistant_management/selection/*"], "@kbn/aiops-change-point-detection": ["x-pack/packages/ml/aiops_change_point_detection"], @@ -1298,6 +1298,8 @@ "@kbn/observability-get-padded-alert-time-range-util/*": ["x-pack/packages/observability/get_padded_alert_time_range_util/*"], "@kbn/observability-logs-explorer-plugin": ["x-pack/plugins/observability_solution/observability_logs_explorer"], "@kbn/observability-logs-explorer-plugin/*": ["x-pack/plugins/observability_solution/observability_logs_explorer/*"], + "@kbn/observability-logs-overview": ["x-pack/packages/observability/logs_overview"], + "@kbn/observability-logs-overview/*": ["x-pack/packages/observability/logs_overview/*"], "@kbn/observability-onboarding-e2e": ["x-pack/plugins/observability_solution/observability_onboarding/e2e"], "@kbn/observability-onboarding-e2e/*": ["x-pack/plugins/observability_solution/observability_onboarding/e2e/*"], "@kbn/observability-onboarding-plugin": ["x-pack/plugins/observability_solution/observability_onboarding"], diff --git a/updatecli-compose.yaml b/updatecli-compose.yaml new file mode 100644 index 0000000000000..8ad9bd6df8afb --- /dev/null +++ b/updatecli-compose.yaml @@ -0,0 +1,14 @@ +# Config file for `updatecli compose ...`. +# https://www.updatecli.io/docs/core/compose/ +policies: + - name: Handle ironbank bumps + policy: ghcr.io/elastic/oblt-updatecli-policies/ironbank/templates:0.3.0@sha256:b0c841d8fb294e6b58359462afbc83070dca375ac5dd0c5216c8926872a98bb1 + values: + - .github/updatecli/values.d/scm.yml + - .github/updatecli/values.d/ironbank.yml + + - name: Update Updatecli policies + policy: ghcr.io/updatecli/policies/autodiscovery/updatecli:0.4.0@sha256:254367f5b1454fd6032b88b314450cd3b6d5e8d5b6c953eb242a6464105eb869 + values: + - .github/updatecli/values.d/scm.yml + - .github/updatecli/values.d/updatecli-compose.yml \ No newline at end of file diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index a46e291093411..7afbc9dc704c4 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -8,6 +8,7 @@ "packages/ml/aiops_log_rate_analysis", "plugins/aiops" ], + "xpack.aiAssistant": "packages/kbn-ai-assistant", "xpack.alerting": "plugins/alerting", "xpack.eventLog": "plugins/event_log", "xpack.stackAlerts": "plugins/stack_alerts", @@ -44,9 +45,15 @@ "xpack.dataVisualizer": "plugins/data_visualizer", "xpack.exploratoryView": "plugins/observability_solution/exploratory_view", "xpack.fileUpload": "plugins/file_upload", - "xpack.globalSearch": ["plugins/global_search"], - "xpack.globalSearchBar": ["plugins/global_search_bar"], - "xpack.graph": ["plugins/graph"], + "xpack.globalSearch": [ + "plugins/global_search" + ], + "xpack.globalSearchBar": [ + "plugins/global_search_bar" + ], + "xpack.graph": [ + "plugins/graph" + ], "xpack.grokDebugger": "plugins/grokdebugger", "xpack.idxMgmt": "plugins/index_management", "xpack.idxMgmtPackage": "packages/index-management", @@ -68,9 +75,13 @@ "xpack.licenseMgmt": "plugins/license_management", "xpack.licensing": "plugins/licensing", "xpack.lists": "plugins/lists", - "xpack.logstash": ["plugins/logstash"], + "xpack.logstash": [ + "plugins/logstash" + ], "xpack.main": "legacy/plugins/xpack_main", - "xpack.maps": ["plugins/maps"], + "xpack.maps": [ + "plugins/maps" + ], "xpack.metricsData": "plugins/observability_solution/metrics_data_access", "xpack.ml": [ "packages/ml/anomaly_utils", @@ -85,7 +96,9 @@ "packages/ml/ui_actions", "plugins/ml" ], - "xpack.monitoring": ["plugins/monitoring"], + "xpack.monitoring": [ + "plugins/monitoring" + ], "xpack.observability": "plugins/observability_solution/observability", "xpack.observabilityAiAssistant": [ "plugins/observability_solution/observability_ai_assistant", @@ -95,12 +108,22 @@ "xpack.observabilityLogsExplorer": "plugins/observability_solution/observability_logs_explorer", "xpack.observability_onboarding": "plugins/observability_solution/observability_onboarding", "xpack.observabilityShared": "plugins/observability_solution/observability_shared", + "xpack.observabilityLogsOverview": [ + "packages/observability/logs_overview/src/components" + ], "xpack.osquery": ["plugins/osquery"], "xpack.painlessLab": "plugins/painless_lab", - "xpack.profiling": ["plugins/observability_solution/profiling"], + "xpack.profiling": [ + "plugins/observability_solution/profiling" + ], "xpack.remoteClusters": "plugins/remote_clusters", - "xpack.reporting": ["plugins/reporting"], - "xpack.rollupJobs": ["packages/rollup", "plugins/rollup"], + "xpack.reporting": [ + "plugins/reporting" + ], + "xpack.rollupJobs": [ + "packages/rollup", + "plugins/rollup" + ], "xpack.runtimeFields": "plugins/runtime_fields", "xpack.screenshotting": "plugins/screenshotting", "xpack.searchSharedUI": "packages/search/shared_ui", @@ -111,7 +134,10 @@ "xpack.searchInferenceEndpoints": "plugins/search_inference_endpoints", "xpack.searchAssistant": "plugins/search_assistant", "xpack.searchProfiler": "plugins/searchprofiler", - "xpack.security": ["plugins/security", "packages/security"], + "xpack.security": [ + "plugins/security", + "packages/security" + ], "xpack.server": "legacy/server", "xpack.serverless": "plugins/serverless", "xpack.serverlessSearch": "plugins/serverless_search", @@ -123,20 +149,30 @@ "xpack.slo": "plugins/observability_solution/slo", "xpack.snapshotRestore": "plugins/snapshot_restore", "xpack.spaces": "plugins/spaces", - "xpack.savedObjectsTagging": ["plugins/saved_objects_tagging"], + "xpack.savedObjectsTagging": [ + "plugins/saved_objects_tagging" + ], "xpack.taskManager": "legacy/plugins/task_manager", "xpack.threatIntelligence": "plugins/threat_intelligence", "xpack.timelines": "plugins/timelines", "xpack.transform": "plugins/transform", "xpack.triggersActionsUI": "plugins/triggers_actions_ui", "xpack.upgradeAssistant": "plugins/upgrade_assistant", - "xpack.uptime": ["plugins/observability_solution/uptime"], - "xpack.synthetics": ["plugins/observability_solution/synthetics"], - "xpack.ux": ["plugins/observability_solution/ux"], + "xpack.uptime": [ + "plugins/observability_solution/uptime" + ], + "xpack.synthetics": [ + "plugins/observability_solution/synthetics" + ], + "xpack.ux": [ + "plugins/observability_solution/ux" + ], "xpack.urlDrilldown": "plugins/drilldowns/url_drilldown", "xpack.watcher": "plugins/watcher" }, - "exclude": ["examples"], + "exclude": [ + "examples" + ], "translations": [ "@kbn/translations-plugin/translations/zh-CN.json", "@kbn/translations-plugin/translations/ja-JP.json", diff --git a/x-pack/packages/kbn-ai-assistant/README.md b/x-pack/packages/kbn-ai-assistant/README.md new file mode 100644 index 0000000000000..d28f93431baa9 --- /dev/null +++ b/x-pack/packages/kbn-ai-assistant/README.md @@ -0,0 +1,3 @@ +# @kbn/ai-assistant + +Provides components, types and context to render the AI Assistant in plugins. diff --git a/x-pack/packages/kbn-ai-assistant/index.ts b/x-pack/packages/kbn-ai-assistant/index.ts new file mode 100644 index 0000000000000..cf53082cfa4b0 --- /dev/null +++ b/x-pack/packages/kbn-ai-assistant/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 './src'; diff --git a/x-pack/packages/kbn-ai-assistant/jest.config.js b/x-pack/packages/kbn-ai-assistant/jest.config.js new file mode 100644 index 0000000000000..37d30bae01fa9 --- /dev/null +++ b/x-pack/packages/kbn-ai-assistant/jest.config.js @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +module.exports = { + coverageDirectory: '/target/kibana-coverage/jest/x-pack/packages/kbn_ai_assistant_src', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/x-pack/packages/kbn-ai-assistant/src/**/*.{ts,tsx}', + '!/x-pack/packages/kbn-ai-assistant/src/*.test.{ts,tsx}', + ], + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/x-pack/packages/kbn-ai-assistant'], +}; diff --git a/x-pack/packages/kbn-ai-assistant/kibana.jsonc b/x-pack/packages/kbn-ai-assistant/kibana.jsonc new file mode 100644 index 0000000000000..4cddd90431e39 --- /dev/null +++ b/x-pack/packages/kbn-ai-assistant/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "id": "@kbn/ai-assistant", + "owner": "@elastic/search-kibana", + "type": "shared-browser" +} diff --git a/x-pack/packages/kbn-ai-assistant/package.json b/x-pack/packages/kbn-ai-assistant/package.json new file mode 100644 index 0000000000000..159ed64f288fd --- /dev/null +++ b/x-pack/packages/kbn-ai-assistant/package.json @@ -0,0 +1,7 @@ +{ + "name": "@kbn/ai-assistant", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0", + "sideEffects": false +} diff --git a/x-pack/packages/kbn-ai-assistant/setup_tests.ts b/x-pack/packages/kbn-ai-assistant/setup_tests.ts new file mode 100644 index 0000000000000..72e0edd0d07f7 --- /dev/null +++ b/x-pack/packages/kbn-ai-assistant/setup_tests.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// eslint-disable-next-line import/no-extraneous-dependencies +import '@testing-library/jest-dom'; diff --git a/x-pack/packages/kbn-ai-assistant/src/assets/elastic_ai_assistant.png b/x-pack/packages/kbn-ai-assistant/src/assets/elastic_ai_assistant.png new file mode 100644 index 0000000000000..af10645579683 Binary files /dev/null and b/x-pack/packages/kbn-ai-assistant/src/assets/elastic_ai_assistant.png differ diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/buttons/ask_assistant_button.stories.tsx b/x-pack/packages/kbn-ai-assistant/src/buttons/ask_assistant_button.stories.tsx similarity index 100% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/buttons/ask_assistant_button.stories.tsx rename to x-pack/packages/kbn-ai-assistant/src/buttons/ask_assistant_button.stories.tsx diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/buttons/ask_assistant_button.tsx b/x-pack/packages/kbn-ai-assistant/src/buttons/ask_assistant_button.tsx similarity index 71% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/buttons/ask_assistant_button.tsx rename to x-pack/packages/kbn-ai-assistant/src/buttons/ask_assistant_button.tsx index e13ba34110434..624c3df9a1e84 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/buttons/ask_assistant_button.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/buttons/ask_assistant_button.tsx @@ -46,12 +46,13 @@ export function AskAssistantButton({ variant, onClick, }: AskAssistantButtonProps) { - const buttonLabel = i18n.translate( - 'xpack.observabilityAiAssistant.askAssistantButton.buttonLabel', - { - defaultMessage: 'Ask Assistant', - } - ); + const buttonLabel = i18n.translate('xpack.aiAssistant.askAssistantButton.buttonLabel', { + defaultMessage: 'Ask Assistant', + }); + + const aiAssistantLabel = i18n.translate('xpack.aiAssistant.aiAssistantLabel', { + defaultMessage: 'AI Assistant', + }); switch (variant) { case 'basic': @@ -84,23 +85,13 @@ export function AskAssistantButton({ return ( {props.isExpanded - ? i18n.translate('xpack.observabilityAiAssistant.hideExpandConversationButton.hide', { + ? i18n.translate('xpack.aiAssistant.hideExpandConversationButton.hide', { defaultMessage: 'Hide chats', }) - : i18n.translate('xpack.observabilityAiAssistant.hideExpandConversationButton.show', { + : i18n.translate('xpack.aiAssistant.hideExpandConversationButton.show', { defaultMessage: 'Show chats', })} diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/buttons/new_chat_button.stories.tsx b/x-pack/packages/kbn-ai-assistant/src/buttons/new_chat_button.stories.tsx similarity index 100% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/buttons/new_chat_button.stories.tsx rename to x-pack/packages/kbn-ai-assistant/src/buttons/new_chat_button.stories.tsx diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/buttons/new_chat_button.tsx b/x-pack/packages/kbn-ai-assistant/src/buttons/new_chat_button.tsx similarity index 92% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/buttons/new_chat_button.tsx rename to x-pack/packages/kbn-ai-assistant/src/buttons/new_chat_button.tsx index 75cede6344c59..3e515e87c2197 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/buttons/new_chat_button.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/buttons/new_chat_button.tsx @@ -18,7 +18,7 @@ export function NewChatButton( iconType="newChat" {...nextProps} > - {i18n.translate('xpack.observabilityAiAssistant.newChatButton', { + {i18n.translate('xpack.aiAssistant.newChatButton', { defaultMessage: 'New chat', })} diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_actions_menu.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/chat_actions_menu.tsx similarity index 65% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_actions_menu.tsx rename to x-pack/packages/kbn-ai-assistant/src/chat/chat_actions_menu.tsx index 713a0d2311e3c..ac25fe6c3703a 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_actions_menu.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/chat_actions_menu.tsx @@ -16,10 +16,8 @@ import { EuiToolTip, } from '@elastic/eui'; import { ConnectorSelectorBase } from '@kbn/observability-ai-assistant-plugin/public'; -import { useKibana } from '../../hooks/use_kibana'; -import { getSettingsHref } from '../../utils/get_settings_href'; -import { getSettingsKnowledgeBaseHref } from '../../utils/get_settings_kb_href'; -import type { UseGenAIConnectorsResult } from '../../hooks/use_genai_connectors'; +import type { UseGenAIConnectorsResult } from '../hooks/use_genai_connectors'; +import { useKibana } from '../hooks/use_kibana'; export function ChatActionsMenu({ connectors, @@ -32,14 +30,11 @@ export function ChatActionsMenu({ disabled: boolean; onCopyConversationClick: () => void; }) { - const { - application: { navigateToUrl, navigateToApp }, - http, - } = useKibana().services; + const { application, http } = useKibana().services; const [isOpen, setIsOpen] = useState(false); const handleNavigateToConnectors = () => { - navigateToApp('management', { + application?.navigateToApp('management', { path: '/insightsAndAlerting/triggersActionsConnectors/connectors', }); }; @@ -49,11 +44,17 @@ export function ChatActionsMenu({ }; const handleNavigateToSettings = () => { - navigateToUrl(getSettingsHref(http)); + application?.navigateToUrl( + http!.basePath.prepend(`/app/management/kibana/observabilityAiAssistantManagement`) + ); }; const handleNavigateToSettingsKnowledgeBase = () => { - navigateToUrl(getSettingsKnowledgeBaseHref(http)); + application?.navigateToUrl( + http!.basePath.prepend( + `/app/management/kibana/observabilityAiAssistantManagement?tab=knowledge_base` + ) + ); }; return ( @@ -61,10 +62,9 @@ export function ChatActionsMenu({ isOpen={isOpen} button={ @@ -87,24 +87,21 @@ export function ChatActionsMenu({ panels={[ { id: 0, - title: i18n.translate('xpack.observabilityAiAssistant.chatHeader.actions.title', { + title: i18n.translate('xpack.aiAssistant.chatHeader.actions.title', { defaultMessage: 'Actions', }), items: [ { - name: i18n.translate( - 'xpack.observabilityAiAssistant.chatHeader.actions.knowledgeBase', - { - defaultMessage: 'Manage knowledge base', - } - ), + name: i18n.translate('xpack.aiAssistant.chatHeader.actions.knowledgeBase', { + defaultMessage: 'Manage knowledge base', + }), onClick: () => { toggleActionsMenu(); handleNavigateToSettingsKnowledgeBase(); }, }, { - name: i18n.translate('xpack.observabilityAiAssistant.chatHeader.actions.settings', { + name: i18n.translate('xpack.aiAssistant.chatHeader.actions.settings', { defaultMessage: 'AI Assistant Settings', }), onClick: () => { @@ -115,7 +112,7 @@ export function ChatActionsMenu({ { name: (
- {i18n.translate('xpack.observabilityAiAssistant.chatHeader.actions.connector', { + {i18n.translate('xpack.aiAssistant.chatHeader.actions.connector', { defaultMessage: 'Connector', })}{' '} @@ -129,12 +126,9 @@ export function ChatActionsMenu({ panel: 1, }, { - name: i18n.translate( - 'xpack.observabilityAiAssistant.chatHeader.actions.copyConversation', - { - defaultMessage: 'Copy conversation', - } - ), + name: i18n.translate('xpack.aiAssistant.chatHeader.actions.copyConversation', { + defaultMessage: 'Copy conversation', + }), disabled: !conversationId, onClick: () => { toggleActionsMenu(); @@ -146,7 +140,7 @@ export function ChatActionsMenu({ { id: 1, width: 256, - title: i18n.translate('xpack.observabilityAiAssistant.chatHeader.actions.connector', { + title: i18n.translate('xpack.aiAssistant.chatHeader.actions.connector', { defaultMessage: 'Connector', }), content: ( @@ -159,10 +153,9 @@ export function ChatActionsMenu({ data-test-subj="settingsTabGoToConnectorsButton" onClick={handleNavigateToConnectors} > - {i18n.translate( - 'xpack.observabilityAiAssistant.settingsPage.goToConnectorsButtonLabel', - { defaultMessage: 'Manage connectors' } - )} + {i18n.translate('xpack.aiAssistant.settingsPage.goToConnectorsButtonLabel', { + defaultMessage: 'Manage connectors', + })} ), diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_body.stories.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/chat_body.stories.tsx similarity index 98% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_body.stories.tsx rename to x-pack/packages/kbn-ai-assistant/src/chat/chat_body.stories.tsx index 4e71ecdfd2c12..182cb046cba70 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_body.stories.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/chat_body.stories.tsx @@ -8,9 +8,9 @@ import { ComponentMeta, ComponentStoryObj } from '@storybook/react'; import React from 'react'; import { MessageRole } from '@kbn/observability-ai-assistant-plugin/public'; -import { KibanaReactStorybookDecorator } from '../../utils/storybook_decorator.stories'; +import { buildSystemMessage } from '../utils/builders'; +import { KibanaReactStorybookDecorator } from '../utils/storybook_decorator.stories'; import { ChatBody as Component } from './chat_body'; -import { buildSystemMessage } from '../../utils/builders'; const meta: ComponentMeta = { component: Component, diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_body.test.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/chat_body.test.tsx similarity index 100% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_body.test.tsx rename to x-pack/packages/kbn-ai-assistant/src/chat/chat_body.test.tsx diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_body.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/chat_body.tsx similarity index 93% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_body.tsx rename to x-pack/packages/kbn-ai-assistant/src/chat/chat_body.tsx index 0bf5a8009b635..c3989f6971fff 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_body.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/chat_body.tsx @@ -31,20 +31,20 @@ import type { AuthenticatedUser } from '@kbn/security-plugin/common'; import { euiThemeVars } from '@kbn/ui-theme'; import { findLastIndex } from 'lodash'; import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { useConversation } from '../../hooks/use_conversation'; -import { useGenAIConnectors } from '../../hooks/use_genai_connectors'; -import type { UseKnowledgeBaseResult } from '../../hooks/use_knowledge_base'; -import { useLicense } from '../../hooks/use_license'; -import { useObservabilityAIAssistantChatService } from '../../hooks/use_observability_ai_assistant_chat_service'; -import { useSimulatedFunctionCalling } from '../../hooks/use_simulated_function_calling'; -import { ASSISTANT_SETUP_TITLE, EMPTY_CONVERSATION_TITLE, UPGRADE_LICENSE_TITLE } from '../../i18n'; -import { PromptEditor } from '../prompt_editor/prompt_editor'; +import type { UseKnowledgeBaseResult } from '../hooks/use_knowledge_base'; +import { ASSISTANT_SETUP_TITLE, EMPTY_CONVERSATION_TITLE, UPGRADE_LICENSE_TITLE } from '../i18n'; +import { useAIAssistantChatService } from '../hooks/use_ai_assistant_chat_service'; +import { useSimulatedFunctionCalling } from '../hooks/use_simulated_function_calling'; +import { useGenAIConnectors } from '../hooks/use_genai_connectors'; +import { useConversation } from '../hooks/use_conversation'; import { FlyoutPositionMode } from './chat_flyout'; import { ChatHeader } from './chat_header'; import { ChatTimeline } from './chat_timeline'; import { IncorrectLicensePanel } from './incorrect_license_panel'; import { SimulatedFunctionCallingCallout } from './simulated_function_calling_callout'; import { WelcomeMessage } from './welcome_message'; +import { useLicense } from '../hooks/use_license'; +import { PromptEditor } from '../prompt_editor/prompt_editor'; const fullHeightClassName = css` height: 100%; @@ -110,7 +110,7 @@ export function ChatBody({ showLinkToConversationsApp, onConversationUpdate, onToggleFlyoutPositionMode, - onClose, + navigateToConversation, }: { connectors: ReturnType; currentUser?: Pick; @@ -122,14 +122,14 @@ export function ChatBody({ showLinkToConversationsApp: boolean; onConversationUpdate: (conversation: { conversation: Conversation['conversation'] }) => void; onToggleFlyoutPositionMode?: (flyoutPositionMode: FlyoutPositionMode) => void; - onClose?: () => void; + navigateToConversation: (conversationId?: string) => void; }) { const license = useLicense(); const hasCorrectLicense = license?.hasAtLeast('enterprise'); const euiTheme = useEuiTheme(); const scrollBarStyles = euiScrollBarStyles(euiTheme); - const chatService = useObservabilityAIAssistantChatService(); + const chatService = useAIAssistantChatService(); const { simulatedFunctionCallingEnabled } = useSimulatedFunctionCalling(); @@ -440,12 +440,12 @@ export function ChatBody({ - {i18n.translate('xpack.observabilityAiAssistant.couldNotFindConversationContent', { + {i18n.translate('xpack.aiAssistant.couldNotFindConversationContent', { defaultMessage: 'Could not find a conversation with id {conversationId}. Make sure the conversation exists and you have access to it.', values: { conversationId: initialConversationId }, @@ -470,12 +470,12 @@ export function ChatBody({ {conversation.error ? ( - {i18n.translate('xpack.observabilityAiAssistant.couldNotFindConversationContent', { + {i18n.translate('xpack.aiAssistant.couldNotFindConversationContent', { defaultMessage: 'Could not find a conversation with id {conversationId}. Make sure the conversation exists and you have access to it.', values: { conversationId: initialConversationId }, @@ -500,7 +500,7 @@ export function ChatBody({ saveTitle(newTitle); }} onToggleFlyoutPositionMode={onToggleFlyoutPositionMode} - onClose={onClose} + navigateToConversation={navigateToConversation} /> diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_consolidated_items.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/chat_consolidated_items.tsx similarity index 89% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_consolidated_items.tsx rename to x-pack/packages/kbn-ai-assistant/src/chat/chat_consolidated_items.tsx index f31796b8812d2..5771b1fd297d7 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_consolidated_items.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/chat_consolidated_items.tsx @@ -90,11 +90,11 @@ export function ChatConsolidatedItems({ > {!expanded - ? i18n.translate('xpack.observabilityAiAssistant.chatCollapsedItems.showEvents', { + ? i18n.translate('xpack.aiAssistant.chatCollapsedItems.showEvents', { defaultMessage: 'Show {count} events', values: { count: consolidatedItem.length }, }) - : i18n.translate('xpack.observabilityAiAssistant.chatCollapsedItems.hideEvents', { + : i18n.translate('xpack.aiAssistant.chatCollapsedItems.hideEvents', { defaultMessage: 'Hide {count} events', values: { count: consolidatedItem.length }, })} @@ -104,12 +104,9 @@ export function ChatConsolidatedItems({ username="" actions={ {}, + navigateToConversation: () => {}, }; export const ChatFlyout = Template.bind({}); diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_flyout.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/chat_flyout.tsx similarity index 88% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_flyout.tsx rename to x-pack/packages/kbn-ai-assistant/src/chat/chat_flyout.tsx index 67ac37a88d724..8d636374ac768 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_flyout.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/chat_flyout.tsx @@ -19,16 +19,16 @@ import { i18n } from '@kbn/i18n'; import { Message } from '@kbn/observability-ai-assistant-plugin/common'; import React, { useState } from 'react'; import ReactDOM from 'react-dom'; -import { useConversationKey } from '../../hooks/use_conversation_key'; -import { useConversationList } from '../../hooks/use_conversation_list'; -import { useCurrentUser } from '../../hooks/use_current_user'; -import { useGenAIConnectors } from '../../hooks/use_genai_connectors'; -import { useKibana } from '../../hooks/use_kibana'; -import { useKnowledgeBase } from '../../hooks/use_knowledge_base'; -import { NewChatButton } from '../buttons/new_chat_button'; +import { useConversationKey } from '../hooks/use_conversation_key'; +import { useConversationList } from '../hooks/use_conversation_list'; +import { useCurrentUser } from '../hooks/use_current_user'; +import { useGenAIConnectors } from '../hooks/use_genai_connectors'; import { ChatBody } from './chat_body'; import { ChatInlineEditingContent } from './chat_inline_edit'; import { ConversationList } from './conversation_list'; +import { useKibana } from '../hooks/use_kibana'; +import { useKnowledgeBase } from '../hooks/use_knowledge_base'; +import { NewChatButton } from '../buttons/new_chat_button'; const CONVERSATIONS_SIDEBAR_WIDTH = 260; const CONVERSATIONS_SIDEBAR_WIDTH_COLLAPSED = 34; @@ -46,12 +46,14 @@ export function ChatFlyout({ initialFlyoutPositionMode, isOpen, onClose, + navigateToConversation, }: { initialTitle: string; initialMessages: Message[]; initialFlyoutPositionMode?: FlyoutPositionMode; isOpen: boolean; onClose: () => void; + navigateToConversation(conversationId?: string): void; }) { const { euiTheme } = useEuiTheme(); const breakpoint = useCurrentEuiBreakpoint(); @@ -75,11 +77,7 @@ export function ChatFlyout({ const { services: { - plugins: { - start: { - observabilityAIAssistant: { ObservabilityAIAssistantMultipaneFlyoutContext }, - }, - }, + observabilityAIAssistant: { ObservabilityAIAssistantMultipaneFlyoutContext }, }, } = useKibana(); const conversationList = useConversationList(); @@ -148,8 +146,8 @@ export function ChatFlyout({ > { + if (onClose) onClose(); + navigateToConversation(newConversationId); + }} /> diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_header.stories.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/chat_header.stories.tsx similarity index 100% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_header.stories.tsx rename to x-pack/packages/kbn-ai-assistant/src/chat/chat_header.stories.tsx diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_header.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/chat_header.tsx similarity index 81% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_header.tsx rename to x-pack/packages/kbn-ai-assistant/src/chat/chat_header.tsx index c67596fbafd5e..c9f0588a1c90f 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_header.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/chat_header.tsx @@ -21,8 +21,7 @@ import { i18n } from '@kbn/i18n'; import { css } from '@emotion/css'; import { AssistantAvatar } from '@kbn/observability-ai-assistant-plugin/public'; import { ChatActionsMenu } from './chat_actions_menu'; -import { useObservabilityAIAssistantRouter } from '../../hooks/use_observability_ai_assistant_router'; -import type { UseGenAIConnectorsResult } from '../../hooks/use_genai_connectors'; +import type { UseGenAIConnectorsResult } from '../hooks/use_genai_connectors'; import { FlyoutPositionMode } from './chat_flyout'; // needed to prevent InlineTextEdit component from expanding container @@ -50,7 +49,7 @@ export function ChatHeader({ onCopyConversation, onSaveTitle, onToggleFlyoutPositionMode, - onClose, + navigateToConversation, }: { connectors: UseGenAIConnectorsResult; conversationId?: string; @@ -61,36 +60,17 @@ export function ChatHeader({ onCopyConversation: () => void; onSaveTitle: (title: string) => void; onToggleFlyoutPositionMode?: (newFlyoutPositionMode: FlyoutPositionMode) => void; - onClose?: () => void; + navigateToConversation: (nextConversationId?: string) => void; }) { const theme = useEuiTheme(); const breakpoint = useCurrentEuiBreakpoint(); - const router = useObservabilityAIAssistantRouter(); - const [newTitle, setNewTitle] = useState(title); useEffect(() => { setNewTitle(title); }, [title]); - const handleNavigateToConversations = () => { - if (onClose) { - onClose(); - } - - if (conversationId) { - router.push('/conversations/{conversationId}', { - path: { - conversationId, - }, - query: {}, - }); - } else { - router.push('/conversations/new', { path: {}, query: {} }); - } - }; - const handleToggleFlyoutPositionMode = () => { if (flyoutPositionMode) { onToggleFlyoutPositionMode?.( @@ -126,10 +106,9 @@ export function ChatHeader({ className={css` color: ${!!title ? theme.euiTheme.colors.text : theme.euiTheme.colors.subduedText}; `} - inputAriaLabel={i18n.translate( - 'xpack.observabilityAiAssistant.chatHeader.editConversationInput', - { defaultMessage: 'Edit conversation' } - )} + inputAriaLabel={i18n.translate('xpack.aiAssistant.chatHeader.editConversationInput', { + defaultMessage: 'Edit conversation', + })} isReadOnly={ !conversationId || !connectors.selectedConnector || @@ -162,11 +141,11 @@ export function ChatHeader({ content={ flyoutPositionMode === 'overlay' ? i18n.translate( - 'xpack.observabilityAiAssistant.chatHeader.euiToolTip.flyoutModeLabel.dock', + 'xpack.aiAssistant.chatHeader.euiToolTip.flyoutModeLabel.dock', { defaultMessage: 'Dock chat' } ) : i18n.translate( - 'xpack.observabilityAiAssistant.chatHeader.euiToolTip.flyoutModeLabel.undock', + 'xpack.aiAssistant.chatHeader.euiToolTip.flyoutModeLabel.undock', { defaultMessage: 'Undock chat' } ) } @@ -174,7 +153,7 @@ export function ChatHeader({ > navigateToConversation(conversationId)} /> } diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_inline_edit.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/chat_inline_edit.tsx similarity index 100% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_inline_edit.tsx rename to x-pack/packages/kbn-ai-assistant/src/chat/chat_inline_edit.tsx diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_item.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/chat_item.tsx similarity index 98% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_item.tsx rename to x-pack/packages/kbn-ai-assistant/src/chat/chat_item.tsx index a1f5d5eb88d2d..23bdbdaea3593 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_item.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/chat_item.tsx @@ -22,11 +22,11 @@ import { Feedback, TelemetryEventTypeWithPayload, } from '@kbn/observability-ai-assistant-plugin/public'; +import { getRoleTranslation } from '../utils/get_role_translation'; import { ChatItemActions } from './chat_item_actions'; import { ChatItemAvatar } from './chat_item_avatar'; import { ChatItemContentInlinePromptEditor } from './chat_item_content_inline_prompt_editor'; import { ChatTimelineItem } from './chat_timeline'; -import { getRoleTranslation } from '../../utils/get_role_translation'; export interface ChatItemProps extends Omit { onActionClick: ChatActionClickHandler; diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_item_actions.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/chat_item_actions.tsx similarity index 73% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_item_actions.tsx rename to x-pack/packages/kbn-ai-assistant/src/chat/chat_item_actions.tsx index 4995b0163b7be..cb1196baa6bc1 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_item_actions.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/chat_item_actions.tsx @@ -46,12 +46,9 @@ export function ChatItemActions({ <> {canEdit ? ( setIsPopoverOpen(undefined)} > - {i18n.translate( - 'xpack.observabilityAiAssistant.chatTimeline.actions.copyMessageSuccessful', - { - defaultMessage: 'Copied message', - } - )} + {i18n.translate('xpack.aiAssistant.chatTimeline.actions.copyMessageSuccessful', { + defaultMessage: 'Copied message', + })} ) : null} diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_item_avatar.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/chat_item_avatar.tsx similarity index 100% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_item_avatar.tsx rename to x-pack/packages/kbn-ai-assistant/src/chat/chat_item_avatar.tsx diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_item_content_inline_prompt_editor.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/chat_item_content_inline_prompt_editor.tsx similarity index 100% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_item_content_inline_prompt_editor.tsx rename to x-pack/packages/kbn-ai-assistant/src/chat/chat_item_content_inline_prompt_editor.tsx diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_item_title.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/chat_item_title.tsx similarity index 71% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_item_title.tsx rename to x-pack/packages/kbn-ai-assistant/src/chat/chat_item_title.tsx index 2749ef3635f40..d6a26b0287e46 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_item_title.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/chat_item_title.tsx @@ -7,6 +7,7 @@ import { euiThemeVars } from '@kbn/ui-theme'; import React, { ReactNode } from 'react'; +import { css } from '@emotion/react'; interface ChatItemTitleProps { actionsTrigger?: ReactNode; @@ -14,14 +15,15 @@ interface ChatItemTitleProps { } export function ChatItemTitle({ actionsTrigger, title }: ChatItemTitleProps) { + const containerCSS = css` + position: absolute; + top: 2; + right: ${euiThemeVars.euiSizeS}; + `; return ( <> {title} - {actionsTrigger ? ( -
- {actionsTrigger} -
- ) : null} + {actionsTrigger ?
{actionsTrigger}
: null} ); } diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_timeline.stories.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/chat_timeline.stories.tsx similarity index 98% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_timeline.stories.tsx rename to x-pack/packages/kbn-ai-assistant/src/chat/chat_timeline.stories.tsx index 88354f41ba293..0afb0c7e79fc0 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_timeline.stories.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/chat_timeline.stories.tsx @@ -18,7 +18,7 @@ import { buildFunctionResponseMessage, buildSystemMessage, buildUserMessage, -} from '../../utils/builders'; +} from '../utils/builders'; import { ChatTimeline as Component, type ChatTimelineProps } from './chat_timeline'; export default { @@ -86,11 +86,11 @@ const defaultProps: ComponentProps = { Mathematical Functions: In mathematics, a function maps input values to corresponding output values based on a specific rule or expression. The general process of how a mathematical function works can be summarized as follows: Step 1: Input - You provide an input value to the function, denoted as 'x' in the notation f(x). This value represents the independent variable. - + Step 2: Processing - The function takes the input value and applies a specific rule or algorithm to it. This rule is defined by the function itself and varies depending on the function's expression. - + Step 3: Output - After processing the input, the function produces an output value, denoted as 'f(x)' or 'y'. This output represents the dependent variable and is the result of applying the function's rule to the input. - + Step 4: Uniqueness - A well-defined mathematical function ensures that each input value corresponds to exactly one output value. In other words, the function should yield the same output for the same input whenever it is called.`, }, }), diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_timeline.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/chat_timeline.tsx similarity index 96% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_timeline.tsx rename to x-pack/packages/kbn-ai-assistant/src/chat/chat_timeline.tsx index ec2cf2ca68e7c..9b349f49f3904 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_timeline.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/chat_timeline.tsx @@ -18,10 +18,10 @@ import { type ObservabilityAIAssistantChatService, type TelemetryEventTypeWithPayload, } from '@kbn/observability-ai-assistant-plugin/public'; -import type { UseKnowledgeBaseResult } from '../../hooks/use_knowledge_base'; +import type { UseKnowledgeBaseResult } from '../hooks/use_knowledge_base'; import { ChatItem } from './chat_item'; import { ChatConsolidatedItems } from './chat_consolidated_items'; -import { getTimelineItemsfromConversation } from '../../utils/get_timeline_items_from_conversation'; +import { getTimelineItemsfromConversation } from '../utils/get_timeline_items_from_conversation'; export interface ChatTimelineItem extends Pick { diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/conversation_list.stories.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/conversation_list.stories.tsx similarity index 93% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/conversation_list.stories.tsx rename to x-pack/packages/kbn-ai-assistant/src/chat/conversation_list.stories.tsx index b0f72e80c5721..7405b477647fd 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/conversation_list.stories.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/conversation_list.stories.tsx @@ -7,8 +7,8 @@ import { ComponentMeta, ComponentStoryObj } from '@storybook/react'; import React from 'react'; -import { buildConversation } from '../../utils/builders'; -import { KibanaReactStorybookDecorator } from '../../utils/storybook_decorator.stories'; +import { buildConversation } from '../utils/builders'; +import { KibanaReactStorybookDecorator } from '../utils/storybook_decorator.stories'; import { ConversationList as Component } from './conversation_list'; type ConversationListProps = React.ComponentProps; diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/conversation_list.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/conversation_list.tsx similarity index 80% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/conversation_list.tsx rename to x-pack/packages/kbn-ai-assistant/src/chat/conversation_list.tsx index 1b26922bcaf69..e4a7022edc763 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/conversation_list.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/conversation_list.tsx @@ -21,10 +21,9 @@ import { import { css } from '@emotion/css'; import { i18n } from '@kbn/i18n'; import React, { MouseEvent } from 'react'; -import { useConfirmModal } from '../../hooks/use_confirm_modal'; -import type { UseConversationListResult } from '../../hooks/use_conversation_list'; -import { useObservabilityAIAssistantRouter } from '../../hooks/use_observability_ai_assistant_router'; -import { EMPTY_CONVERSATION_TITLE } from '../../i18n'; +import { useConfirmModal } from '../hooks/use_confirm_modal'; +import type { UseConversationListResult } from '../hooks/use_conversation_list'; +import { EMPTY_CONVERSATION_TITLE } from '../i18n'; import { NewChatButton } from '../buttons/new_chat_button'; const titleClassName = css` @@ -51,15 +50,17 @@ export function ConversationList({ selectedConversationId, onConversationSelect, onConversationDeleteClick, + newConversationHref, + getConversationHref, }: { conversations: UseConversationListResult['conversations']; isLoading: boolean; selectedConversationId?: string; onConversationSelect?: (conversationId?: string) => void; onConversationDeleteClick: (conversationId: string) => void; + newConversationHref?: string; + getConversationHref?: (conversationId: string) => string; }) { - const router = useObservabilityAIAssistantRouter(); - const euiTheme = useEuiTheme(); const scrollBarStyles = euiScrollBarStyles(euiTheme); @@ -70,21 +71,15 @@ export function ConversationList({ `; const { element: confirmDeleteElement, confirm: confirmDeleteCallback } = useConfirmModal({ - title: i18n.translate('xpack.observabilityAiAssistant.flyout.confirmDeleteConversationTitle', { + title: i18n.translate('xpack.aiAssistant.flyout.confirmDeleteConversationTitle', { defaultMessage: 'Delete this conversation?', }), - children: i18n.translate( - 'xpack.observabilityAiAssistant.flyout.confirmDeleteConversationContent', - { - defaultMessage: 'This action cannot be undone.', - } - ), - confirmButtonText: i18n.translate( - 'xpack.observabilityAiAssistant.flyout.confirmDeleteButtonText', - { - defaultMessage: 'Delete conversation', - } - ), + children: i18n.translate('xpack.aiAssistant.flyout.confirmDeleteConversationContent', { + defaultMessage: 'This action cannot be undone.', + }), + confirmButtonText: i18n.translate('xpack.aiAssistant.flyout.confirmDeleteButtonText', { + defaultMessage: 'Delete conversation', + }), }); const displayedConversations = [ @@ -94,7 +89,7 @@ export function ConversationList({ id: '', label: EMPTY_CONVERSATION_TITLE, lastUpdated: '', - href: router.link('/conversations/new'), + href: newConversationHref, }, ] : []), @@ -102,11 +97,7 @@ export function ConversationList({ id: conversation.id, label: conversation.title, lastUpdated: conversation.last_updated, - href: router.link('/conversations/{conversationId}', { - path: { - conversationId: conversation.id, - }, - }), + href: getConversationHref ? getConversationHref(conversation.id) : undefined, })), ]; @@ -123,7 +114,7 @@ export function ConversationList({ - {i18n.translate('xpack.observabilityAiAssistant.conversationList.title', { + {i18n.translate('xpack.aiAssistant.conversationList.title', { defaultMessage: 'Previously', })} @@ -147,12 +138,9 @@ export function ConversationList({ - {i18n.translate( - 'xpack.observabilityAiAssistant.conversationList.errorMessage', - { - defaultMessage: 'Failed to load', - } - )} + {i18n.translate('xpack.aiAssistant.conversationList.errorMessage', { + defaultMessage: 'Failed to load', + })} @@ -185,7 +173,7 @@ export function ConversationList({ ? { iconType: 'trash', 'aria-label': i18n.translate( - 'xpack.observabilityAiAssistant.conversationList.deleteConversationIconLabel', + 'xpack.aiAssistant.conversationList.deleteConversationIconLabel', { defaultMessage: 'Delete', } @@ -211,12 +199,9 @@ export function ConversationList({ {!isLoading && !conversations.error && !displayedConversations?.length ? ( - {i18n.translate( - 'xpack.observabilityAiAssistant.conversationList.noConversations', - { - defaultMessage: 'No conversations', - } - )} + {i18n.translate('xpack.aiAssistant.conversationList.noConversations', { + defaultMessage: 'No conversations', + })} ) : null} @@ -228,7 +213,7 @@ export function ConversationList({ | MouseEvent ) => { diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/disclaimer.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/disclaimer.tsx similarity index 91% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/disclaimer.tsx rename to x-pack/packages/kbn-ai-assistant/src/chat/disclaimer.tsx index e4eb5176469de..8f9c3abca0e71 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/disclaimer.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/disclaimer.tsx @@ -17,7 +17,7 @@ export function Disclaimer() { textAlign="center" data-test-subj="observabilityAiAssistantDisclaimer" > - {i18n.translate('xpack.observabilityAiAssistant.disclaimer.disclaimerLabel', { + {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.", })} diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/function_list_popover.stories.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/function_list_popover.stories.tsx similarity index 91% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/function_list_popover.stories.tsx rename to x-pack/packages/kbn-ai-assistant/src/chat/function_list_popover.stories.tsx index a8f1e23b8173d..62da0b2d14ff8 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/function_list_popover.stories.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/function_list_popover.stories.tsx @@ -7,7 +7,7 @@ import { ComponentStory } from '@storybook/react'; import React from 'react'; -import { KibanaReactStorybookDecorator } from '../../utils/storybook_decorator.stories'; +import { KibanaReactStorybookDecorator } from '../utils/storybook_decorator.stories'; import { FunctionListPopover as Component } from './function_list_popover'; export default { diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/function_list_popover.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/function_list_popover.tsx similarity index 85% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/function_list_popover.tsx rename to x-pack/packages/kbn-ai-assistant/src/chat/function_list_popover.tsx index 16df72f48c91b..d24aae12fd8c6 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/function_list_popover.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/function_list_popover.tsx @@ -22,7 +22,7 @@ import type { EuiSelectableOptionCheckedType } from '@elastic/eui/src/components import { i18n } from '@kbn/i18n'; import { FunctionVisibility } from '@kbn/observability-ai-assistant-plugin/public'; import type { FunctionDefinition } from '@kbn/observability-ai-assistant-plugin/common'; -import { useObservabilityAIAssistantChatService } from '../../hooks/use_observability_ai_assistant_chat_service'; +import { useAIAssistantChatService } from '../hooks/use_ai_assistant_chat_service'; interface FunctionListOption { label: string; @@ -40,7 +40,7 @@ export function FunctionListPopover({ onSelectFunction: (func: string | undefined) => void; disabled: boolean; }) { - const { getFunctions } = useObservabilityAIAssistantChatService(); + const { getFunctions } = useAIAssistantChatService(); const functions = getFunctions(); const [functionOptions, setFunctionOptions] = useState< @@ -80,21 +80,18 @@ export function FunctionListPopover({ content={ mode === 'prompt' ? i18n.translate( - 'xpack.observabilityAiAssistant.functionListPopover.euiToolTip.selectAFunctionLabel', + 'xpack.aiAssistant.functionListPopover.euiToolTip.selectAFunctionLabel', { defaultMessage: 'Select a function' } ) - : i18n.translate( - 'xpack.observabilityAiAssistant.functionListPopover.euiToolTip.clearFunction', - { - defaultMessage: 'Clear function', - } - ) + : i18n.translate('xpack.aiAssistant.functionListPopover.euiToolTip.clearFunction', { + defaultMessage: 'Clear function', + }) } display="block" > - +

{UPGRADE_LICENSE_TITLE}

- {i18n.translate('xpack.observabilityAiAssistant.incorrectLicense.body', { + {i18n.translate('xpack.aiAssistant.incorrectLicense.body', { defaultMessage: 'You need an Enterprise license to use the Elastic AI Assistant.', })} @@ -57,12 +57,9 @@ export function IncorrectLicensePanel() { href="https://www.elastic.co/subscriptions" target="_blank" > - {i18n.translate( - 'xpack.observabilityAiAssistant.incorrectLicense.subscriptionPlansButton', - { - defaultMessage: 'Subscription plans', - } - )} + {i18n.translate('xpack.aiAssistant.incorrectLicense.subscriptionPlansButton', { + defaultMessage: 'Subscription plans', + })}
@@ -70,7 +67,7 @@ export function IncorrectLicensePanel() { data-test-subj="observabilityAiAssistantIncorrectLicensePanelManageLicenseButton" onClick={handleNavigateToLicenseManagement} > - {i18n.translate('xpack.observabilityAiAssistant.incorrectLicense.manageLicense', { + {i18n.translate('xpack.aiAssistant.incorrectLicense.manageLicense', { defaultMessage: 'Manage license', })} diff --git a/x-pack/packages/kbn-ai-assistant/src/chat/index.ts b/x-pack/packages/kbn-ai-assistant/src/chat/index.ts new file mode 100644 index 0000000000000..4b04d7dec81c1 --- /dev/null +++ b/x-pack/packages/kbn-ai-assistant/src/chat/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './chat_body'; +export * from './chat_inline_edit'; +export * from './conversation_list'; +export * from './chat_flyout'; diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/knowledge_base_callout.stories.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/knowledge_base_callout.stories.tsx similarity index 95% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/knowledge_base_callout.stories.tsx rename to x-pack/packages/kbn-ai-assistant/src/chat/knowledge_base_callout.stories.tsx index d66729dc75a3d..e87aa161d80c3 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/knowledge_base_callout.stories.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/knowledge_base_callout.stories.tsx @@ -7,7 +7,7 @@ import { ComponentMeta, ComponentStoryObj } from '@storybook/react'; import { merge } from 'lodash'; -import { KibanaReactStorybookDecorator } from '../../utils/storybook_decorator.stories'; +import { KibanaReactStorybookDecorator } from '../utils/storybook_decorator.stories'; import { KnowledgeBaseCallout as Component } from './knowledge_base_callout'; const meta: ComponentMeta = { diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/knowledge_base_callout.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/knowledge_base_callout.tsx similarity index 85% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/knowledge_base_callout.tsx rename to x-pack/packages/kbn-ai-assistant/src/chat/knowledge_base_callout.tsx index 36d6842286aa8..abb296713b2d2 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/knowledge_base_callout.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/knowledge_base_callout.tsx @@ -17,7 +17,7 @@ import { EuiText, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { UseKnowledgeBaseResult } from '../../hooks/use_knowledge_base'; +import { UseKnowledgeBaseResult } from '../hooks/use_knowledge_base'; export function KnowledgeBaseCallout({ knowledgeBase }: { knowledgeBase: UseKnowledgeBaseResult }) { let content: React.ReactNode; @@ -32,7 +32,7 @@ export function KnowledgeBaseCallout({ knowledgeBase }: { knowledgeBase: UseKnow - {i18n.translate('xpack.observabilityAiAssistant.checkingKbAvailability', { + {i18n.translate('xpack.aiAssistant.checkingKbAvailability', { defaultMessage: 'Checking availability of knowledge base', })} @@ -43,7 +43,7 @@ export function KnowledgeBaseCallout({ knowledgeBase }: { knowledgeBase: UseKnow color = 'danger'; content = ( - {i18n.translate('xpack.observabilityAiAssistant.failedToGetStatus', { + {i18n.translate('xpack.aiAssistant.failedToGetStatus', { defaultMessage: 'Failed to get model status.', })} @@ -53,7 +53,7 @@ export function KnowledgeBaseCallout({ knowledgeBase }: { knowledgeBase: UseKnow content = ( {' '} - {i18n.translate('xpack.observabilityAiAssistant.poweredByModel', { + {i18n.translate('xpack.aiAssistant.poweredByModel', { defaultMessage: 'Powered by {model}', values: { model: 'ELSER', @@ -70,7 +70,7 @@ export function KnowledgeBaseCallout({ knowledgeBase }: { knowledgeBase: UseKnow - {i18n.translate('xpack.observabilityAiAssistant.installingKb', { + {i18n.translate('xpack.aiAssistant.installingKb', { defaultMessage: 'Setting up the knowledge base', })} @@ -81,7 +81,7 @@ export function KnowledgeBaseCallout({ knowledgeBase }: { knowledgeBase: UseKnow color = 'danger'; content = ( - {i18n.translate('xpack.observabilityAiAssistant.failedToSetupKnowledgeBase', { + {i18n.translate('xpack.aiAssistant.failedToSetupKnowledgeBase', { defaultMessage: 'Failed to set up knowledge base.', })} @@ -96,7 +96,7 @@ export function KnowledgeBaseCallout({ knowledgeBase }: { knowledgeBase: UseKnow > {' '} - {i18n.translate('xpack.observabilityAiAssistant.setupKb', { + {i18n.translate('xpack.aiAssistant.setupKb', { defaultMessage: 'Improve your experience by setting up the knowledge base.', })} diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/simulated_function_calling_callout.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/simulated_function_calling_callout.tsx similarity index 90% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/simulated_function_calling_callout.tsx rename to x-pack/packages/kbn-ai-assistant/src/chat/simulated_function_calling_callout.tsx index 41b14e683dd64..26eb589b25dfc 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/simulated_function_calling_callout.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/simulated_function_calling_callout.tsx @@ -17,7 +17,7 @@ export function SimulatedFunctionCallingCallout() { - {i18n.translate('xpack.observabilityAiAssistant.simulatedFunctionCallingCalloutLabel', { + {i18n.translate('xpack.aiAssistant.simulatedFunctionCallingCalloutLabel', { defaultMessage: 'Simulated function calling is enabled. You might see degradated performance.', })} diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/starter_prompts.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/starter_prompts.tsx similarity index 85% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/starter_prompts.tsx rename to x-pack/packages/kbn-ai-assistant/src/chat/starter_prompts.tsx index 1f5402978d41d..faaecc0024135 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/starter_prompts.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/starter_prompts.tsx @@ -16,9 +16,9 @@ import { } from '@elastic/eui'; import { css } from '@emotion/css'; import { uniq } from 'lodash'; -import { useObservabilityAIAssistantAppService } from '../../hooks/use_observability_ai_assistant_app_service'; -import { useGenAIConnectors } from '../../hooks/use_genai_connectors'; -import { nonNullable } from '../../utils/non_nullable'; +import { useAIAssistantAppService } from '../hooks/use_ai_assistant_app_service'; +import { useGenAIConnectors } from '../hooks/use_genai_connectors'; +import { nonNullable } from '../utils/non_nullable'; const starterPromptClassName = css` max-width: 50%; @@ -30,7 +30,7 @@ const starterPromptInnerClassName = css` `; export function StarterPrompts({ onSelectPrompt }: { onSelectPrompt: (prompt: string) => void }) { - const service = useObservabilityAIAssistantAppService(); + const service = useAIAssistantAppService(); const { connectors } = useGenAIConnectors(); diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/welcome_message.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/welcome_message.tsx similarity index 83% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/welcome_message.tsx rename to x-pack/packages/kbn-ai-assistant/src/chat/welcome_message.tsx index 18f4c5598c6fd..a449235ba44e6 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/welcome_message.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/welcome_message.tsx @@ -5,19 +5,19 @@ * 2.0. */ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { css } from '@emotion/css'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer, useCurrentEuiBreakpoint } from '@elastic/eui'; import type { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public'; import { GenerativeAIForObservabilityConnectorFeatureId } from '@kbn/actions-plugin/common'; import { isSupportedConnectorType } from '@kbn/observability-ai-assistant-plugin/public'; -import type { UseKnowledgeBaseResult } from '../../hooks/use_knowledge_base'; -import type { UseGenAIConnectorsResult } from '../../hooks/use_genai_connectors'; +import type { UseKnowledgeBaseResult } from '../hooks/use_knowledge_base'; +import type { UseGenAIConnectorsResult } from '../hooks/use_genai_connectors'; import { Disclaimer } from './disclaimer'; import { WelcomeMessageConnectors } from './welcome_message_connectors'; import { WelcomeMessageKnowledgeBase } from './welcome_message_knowledge_base'; -import { useKibana } from '../../hooks/use_kibana'; import { StarterPrompts } from './starter_prompts'; +import { useKibana } from '../hooks/use_kibana'; const fullHeightClassName = css` height: 100%; @@ -39,22 +39,15 @@ export function WelcomeMessage({ }) { const breakpoint = useCurrentEuiBreakpoint(); - const { - application: { navigateToApp, capabilities }, - plugins: { - start: { - triggersActionsUi: { getAddConnectorFlyout: ConnectorFlyout }, - }, - }, - } = useKibana().services; + const { application, triggersActionsUi } = useKibana().services; const [connectorFlyoutOpen, setConnectorFlyoutOpen] = useState(false); const handleConnectorClick = () => { - if (capabilities.management?.insightsAndAlerting?.triggersActions) { + if (application?.capabilities.management?.insightsAndAlerting?.triggersActions) { setConnectorFlyoutOpen(true); } else { - navigateToApp('management', { + application?.navigateToApp('management', { path: '/insightsAndAlerting/triggersActionsConnectors/connectors', }); } @@ -72,6 +65,11 @@ export function WelcomeMessage({ } }; + const ConnectorFlyout = useMemo( + () => triggersActionsUi.getAddConnectorFlyout, + [triggersActionsUi] + ); + return ( <> {isForbiddenError ? i18n.translate( - 'xpack.observabilityAiAssistant.welcomeMessageConnectors.connectorsForbiddenTextLabel', + 'xpack.aiAssistant.welcomeMessageConnectors.connectorsForbiddenTextLabel', { defaultMessage: 'Required privileges to get connectors are missing' } ) : i18n.translate( - 'xpack.observabilityAiAssistant.welcomeMessageConnectors.connectorsErrorTextLabel', + 'xpack.aiAssistant.welcomeMessageConnectors.connectorsErrorTextLabel', { defaultMessage: 'Could not load connectors' } )} @@ -72,21 +72,15 @@ export function WelcomeMessageConnectors({ return !connectors.loading && connectors.connectors?.length === 0 && onSetupConnectorClick ? (
- {i18n.translate( - 'xpack.observabilityAiAssistant.initialSetupPanel.setupConnector.description2', - { - defaultMessage: - 'Start working with the Elastic AI Assistant by setting up a connector for your AI provider. The model needs to support function calls. When using OpenAI or Azure, we recommend using GPT4.', - } - )} + {i18n.translate('xpack.aiAssistant.initialSetupPanel.setupConnector.description2', { + defaultMessage: + 'Start working with the Elastic AI Assistant by setting up a connector for your AI provider. The model needs to support function calls. When using OpenAI or Azure, we recommend using GPT4.', + })} - {i18n.translate( - 'xpack.observabilityAiAssistant.initialSetupPanel.setupConnector.buttonLabel', - { - defaultMessage: 'Set up GenAI connector', - } - )} + {i18n.translate('xpack.aiAssistant.initialSetupPanel.setupConnector.buttonLabel', { + defaultMessage: 'Set up GenAI connector', + })}
diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/welcome_message_knowledge_base.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/welcome_message_knowledge_base.tsx similarity index 81% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/welcome_message_knowledge_base.tsx rename to x-pack/packages/kbn-ai-assistant/src/chat/welcome_message_knowledge_base.tsx index afdbed9ed4c43..72653473c41ae 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/welcome_message_knowledge_base.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/welcome_message_knowledge_base.tsx @@ -22,8 +22,8 @@ import usePrevious from 'react-use/lib/usePrevious'; import useTimeoutFn from 'react-use/lib/useTimeoutFn'; import useInterval from 'react-use/lib/useInterval'; import { WelcomeMessageKnowledgeBaseSetupErrorPanel } from './welcome_message_knowledge_base_setup_error_panel'; -import type { UseKnowledgeBaseResult } from '../../hooks/use_knowledge_base'; -import type { UseGenAIConnectorsResult } from '../../hooks/use_genai_connectors'; +import type { UseKnowledgeBaseResult } from '../hooks/use_knowledge_base'; +import type { UseGenAIConnectorsResult } from '../hooks/use_genai_connectors'; export function WelcomeMessageKnowledgeBase({ connectors, @@ -80,13 +80,10 @@ export function WelcomeMessageKnowledgeBase({ {knowledgeBase.isInstalling ? ( <> - {i18n.translate( - 'xpack.observabilityAiAssistant.welcomeMessage.weAreSettingUpTextLabel', - { - defaultMessage: - 'We are setting up your knowledge base. This may take a few minutes. You can continue to use the Assistant while this process is underway.', - } - )} + {i18n.translate('xpack.aiAssistant.welcomeMessage.weAreSettingUpTextLabel', { + defaultMessage: + 'We are setting up your knowledge base. This may take a few minutes. You can continue to use the Assistant while this process is underway.', + })} @@ -96,10 +93,9 @@ export function WelcomeMessageKnowledgeBase({ isLoading onClick={noop} > - {i18n.translate( - 'xpack.observabilityAiAssistant.welcomeMessage.div.settingUpKnowledgeBaseLabel', - { defaultMessage: 'Setting up Knowledge base' } - )} + {i18n.translate('xpack.aiAssistant.welcomeMessage.div.settingUpKnowledgeBaseLabel', { + defaultMessage: 'Setting up Knowledge base', + })} ) : null} @@ -112,7 +108,7 @@ export function WelcomeMessageKnowledgeBase({ <> {i18n.translate( - 'xpack.observabilityAiAssistant.welcomeMessageKnowledgeBase.yourKnowledgeBaseIsNotSetUpCorrectlyLabel', + 'xpack.aiAssistant.welcomeMessageKnowledgeBase.yourKnowledgeBaseIsNotSetUpCorrectlyLabel', { defaultMessage: `Your Knowledge base hasn't been set up.` } )} @@ -130,12 +126,9 @@ export function WelcomeMessageKnowledgeBase({ iconType="importAction" onClick={handleRetryInstall} > - {i18n.translate( - 'xpack.observabilityAiAssistant.welcomeMessage.retryButtonLabel', - { - defaultMessage: 'Install Knowledge base', - } - )} + {i18n.translate('xpack.aiAssistant.welcomeMessage.retryButtonLabel', { + defaultMessage: 'Install Knowledge base', + })}
@@ -149,7 +142,7 @@ export function WelcomeMessageKnowledgeBase({ onClick={() => setIsPopoverOpen(!isPopoverOpen)} > {i18n.translate( - 'xpack.observabilityAiAssistant.welcomeMessage.inspectErrorsButtonEmptyLabel', + 'xpack.aiAssistant.welcomeMessage.inspectErrorsButtonEmptyLabel', { defaultMessage: 'Inspect issues' } )} @@ -180,7 +173,7 @@ export function WelcomeMessageKnowledgeBase({ {i18n.translate( - 'xpack.observabilityAiAssistant.welcomeMessage.knowledgeBaseSuccessfullyInstalledLabel', + 'xpack.aiAssistant.welcomeMessage.knowledgeBaseSuccessfullyInstalledLabel', { defaultMessage: 'Knowledge base successfully installed' } )} diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/welcome_message_knowledge_base_setup_error_panel.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/welcome_message_knowledge_base_setup_error_panel.tsx similarity index 80% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/welcome_message_knowledge_base_setup_error_panel.tsx rename to x-pack/packages/kbn-ai-assistant/src/chat/welcome_message_knowledge_base_setup_error_panel.tsx index a9a6fcff85240..eeff9c8afd7f3 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/welcome_message_knowledge_base_setup_error_panel.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/welcome_message_knowledge_base_setup_error_panel.tsx @@ -21,8 +21,8 @@ import { EuiPanel, } from '@elastic/eui'; import { css } from '@emotion/css'; -import { useKibana } from '../../hooks/use_kibana'; -import type { UseKnowledgeBaseResult } from '../../hooks/use_knowledge_base'; +import { useKibana } from '../hooks/use_kibana'; +import type { UseKnowledgeBaseResult } from '../hooks/use_knowledge_base'; const panelContainerClassName = css` width: 330px; @@ -47,10 +47,9 @@ export function WelcomeMessageKnowledgeBaseSetupErrorPanel({ - {i18n.translate( - 'xpack.observabilityAiAssistant.welcomeMessage.issuesDescriptionListTitleLabel', - { defaultMessage: 'Issues' } - )} + {i18n.translate('xpack.aiAssistant.welcomeMessage.issuesDescriptionListTitleLabel', { + defaultMessage: 'Issues', + })} @@ -61,7 +60,7 @@ export function WelcomeMessageKnowledgeBaseSetupErrorPanel({
  • {' '} {modelName}, @@ -75,7 +74,7 @@ export function WelcomeMessageKnowledgeBaseSetupErrorPanel({
  • {' '} {modelName}, @@ -92,7 +91,7 @@ export function WelcomeMessageKnowledgeBaseSetupErrorPanel({
  • {' '} {modelName}, @@ -113,7 +112,7 @@ export function WelcomeMessageKnowledgeBaseSetupErrorPanel({ {i18n.translate( - 'xpack.observabilityAiAssistant.welcomeMessageKnowledgeBaseSetupErrorPanel.retryInstallingLinkLabel', + 'xpack.aiAssistant.welcomeMessageKnowledgeBaseSetupErrorPanel.retryInstallingLinkLabel', { defaultMessage: 'Retry install' } )} @@ -133,13 +132,12 @@ export function WelcomeMessageKnowledgeBaseSetupErrorPanel({ - {i18n.translate( - 'xpack.observabilityAiAssistant.welcomeMessage.trainedModelsLinkLabel', - { defaultMessage: 'Trained Models' } - )} + {i18n.translate('xpack.aiAssistant.welcomeMessage.trainedModelsLinkLabel', { + defaultMessage: 'Trained Models', + })} ), }} diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/routes/conversations/conversation_view.tsx b/x-pack/packages/kbn-ai-assistant/src/conversation/conversation_view.tsx similarity index 71% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/routes/conversations/conversation_view.tsx rename to x-pack/packages/kbn-ai-assistant/src/conversation/conversation_view.tsx index da34c98b86fbc..260a7cb5c10ed 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/routes/conversations/conversation_view.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/conversation/conversation_view.tsx @@ -9,44 +9,44 @@ import { css } from '@emotion/css'; import { euiThemeVars } from '@kbn/ui-theme'; import React, { useEffect, useState } from 'react'; import ReactDOM from 'react-dom'; -import { useAbortableAsync } from '@kbn/observability-ai-assistant-plugin/public'; -import { ChatBody } from '../../components/chat/chat_body'; -import { ChatInlineEditingContent } from '../../components/chat/chat_inline_edit'; -import { ConversationList } from '../../components/chat/conversation_list'; -import { useCurrentUser } from '../../hooks/use_current_user'; -import { useGenAIConnectors } from '../../hooks/use_genai_connectors'; -import { useKnowledgeBase } from '../../hooks/use_knowledge_base'; -import { useObservabilityAIAssistantParams } from '../../hooks/use_observability_ai_assistant_params'; -import { useObservabilityAIAssistantRouter } from '../../hooks/use_observability_ai_assistant_router'; -import { useObservabilityAIAssistantAppService } from '../../hooks/use_observability_ai_assistant_app_service'; -import { useKibana } from '../../hooks/use_kibana'; -import { useConversationKey } from '../../hooks/use_conversation_key'; -import { useConversationList } from '../../hooks/use_conversation_list'; +import { useKibana } from '../hooks/use_kibana'; +import { ConversationList, ChatBody, ChatInlineEditingContent } from '../chat'; +import { useConversationKey } from '../hooks/use_conversation_key'; +import { useCurrentUser } from '../hooks/use_current_user'; +import { useGenAIConnectors } from '../hooks/use_genai_connectors'; +import { useKnowledgeBase } from '../hooks/use_knowledge_base'; +import { useAIAssistantAppService } from '../hooks/use_ai_assistant_app_service'; +import { useAbortableAsync } from '../hooks/use_abortable_async'; +import { useConversationList } from '../hooks/use_conversation_list'; const SECOND_SLOT_CONTAINER_WIDTH = 400; -export function ConversationView() { +interface ConversationViewProps { + conversationId?: string; + navigateToConversation: (nextConversationId?: string) => void; + getConversationHref?: (conversationId: string) => string; + newConversationHref?: string; +} + +export const ConversationView: React.FC = ({ + conversationId, + navigateToConversation, + getConversationHref, + newConversationHref, +}) => { const { euiTheme } = useEuiTheme(); const currentUser = useCurrentUser(); - const service = useObservabilityAIAssistantAppService(); + const service = useAIAssistantAppService(); const connectors = useGenAIConnectors(); const knowledgeBase = useKnowledgeBase(); - const observabilityAIAssistantRouter = useObservabilityAIAssistantRouter(); - - const { path } = useObservabilityAIAssistantParams('/conversations/*'); - const { services: { - plugins: { - start: { - observabilityAIAssistant: { ObservabilityAIAssistantChatServiceContext }, - }, - }, + observabilityAIAssistant: { ObservabilityAIAssistantChatServiceContext }, }, } = useKibana(); @@ -57,8 +57,6 @@ export function ConversationView() { [service] ); - const conversationId = 'conversationId' in path ? path.conversationId : undefined; - const { key: bodyKey, updateConversationIdInPlace } = useConversationKey(conversationId); const [secondSlotContainer, setSecondSlotContainer] = useState(null); @@ -66,19 +64,6 @@ export function ConversationView() { const conversationList = useConversationList(); - function navigateToConversation(nextConversationId?: string) { - if (nextConversationId) { - observabilityAIAssistantRouter.push('/conversations/{conversationId}', { - path: { - conversationId: nextConversationId, - }, - query: {}, - }); - } else { - observabilityAIAssistantRouter.push('/conversations/new', { path: {}, query: {} }); - } - } - function handleRefreshConversations() { conversationList.conversations.refresh(); } @@ -153,6 +138,9 @@ export function ConversationView() { } }); }} + newConversationHref={newConversationHref} + onConversationSelect={navigateToConversation} + getConversationHref={getConversationHref} /> @@ -176,6 +164,7 @@ export function ConversationView() { knowledgeBase={knowledgeBase} showLinkToConversationsApp={false} onConversationUpdate={handleConversationUpdate} + navigateToConversation={navigateToConversation} />
    @@ -189,4 +178,4 @@ export function ConversationView() { )} ); -} +}; diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/__storybook_mocks__/use_chat.ts b/x-pack/packages/kbn-ai-assistant/src/hooks/__storybook_mocks__/use_chat.ts similarity index 100% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/__storybook_mocks__/use_chat.ts rename to x-pack/packages/kbn-ai-assistant/src/hooks/__storybook_mocks__/use_chat.ts diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/__storybook_mocks__/use_conversation.ts b/x-pack/packages/kbn-ai-assistant/src/hooks/__storybook_mocks__/use_conversation.ts similarity index 100% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/__storybook_mocks__/use_conversation.ts rename to x-pack/packages/kbn-ai-assistant/src/hooks/__storybook_mocks__/use_conversation.ts diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/__storybook_mocks__/use_conversation_list.ts b/x-pack/packages/kbn-ai-assistant/src/hooks/__storybook_mocks__/use_conversation_list.ts similarity index 100% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/__storybook_mocks__/use_conversation_list.ts rename to x-pack/packages/kbn-ai-assistant/src/hooks/__storybook_mocks__/use_conversation_list.ts diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/__storybook_mocks__/use_conversations.ts b/x-pack/packages/kbn-ai-assistant/src/hooks/__storybook_mocks__/use_conversations.ts similarity index 100% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/__storybook_mocks__/use_conversations.ts rename to x-pack/packages/kbn-ai-assistant/src/hooks/__storybook_mocks__/use_conversations.ts diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/__storybook_mocks__/use_current_user.ts b/x-pack/packages/kbn-ai-assistant/src/hooks/__storybook_mocks__/use_current_user.ts similarity index 100% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/__storybook_mocks__/use_current_user.ts rename to x-pack/packages/kbn-ai-assistant/src/hooks/__storybook_mocks__/use_current_user.ts diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/__storybook_mocks__/use_genai_connectors.ts b/x-pack/packages/kbn-ai-assistant/src/hooks/__storybook_mocks__/use_genai_connectors.ts similarity index 100% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/__storybook_mocks__/use_genai_connectors.ts rename to x-pack/packages/kbn-ai-assistant/src/hooks/__storybook_mocks__/use_genai_connectors.ts diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/__storybook_mocks__/use_knowledge_base.ts b/x-pack/packages/kbn-ai-assistant/src/hooks/__storybook_mocks__/use_knowledge_base.ts similarity index 100% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/__storybook_mocks__/use_knowledge_base.ts rename to x-pack/packages/kbn-ai-assistant/src/hooks/__storybook_mocks__/use_knowledge_base.ts diff --git a/x-pack/packages/kbn-ai-assistant/src/hooks/index.ts b/x-pack/packages/kbn-ai-assistant/src/hooks/index.ts new file mode 100644 index 0000000000000..ee630d1caec82 --- /dev/null +++ b/x-pack/packages/kbn-ai-assistant/src/hooks/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 './use_ai_assistant_app_service'; +export * from './use_ai_assistant_chat_service'; +export * from './use_knowledge_base'; diff --git a/x-pack/packages/kbn-ai-assistant/src/hooks/use_abortable_async.ts b/x-pack/packages/kbn-ai-assistant/src/hooks/use_abortable_async.ts new file mode 100644 index 0000000000000..433ca877b0f62 --- /dev/null +++ b/x-pack/packages/kbn-ai-assistant/src/hooks/use_abortable_async.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { isPromise } from '@kbn/std'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +interface State { + error?: Error; + value?: T; + loading: boolean; +} + +export type AbortableAsyncState = (T extends Promise + ? State + : State) & { refresh: () => void }; + +export function useAbortableAsync( + fn: ({}: { signal: AbortSignal }) => T | Promise, + deps: any[], + options?: { clearValueOnNext?: boolean; defaultValue?: () => T } +): AbortableAsyncState { + const clearValueOnNext = options?.clearValueOnNext; + + const controllerRef = useRef(new AbortController()); + + const [refreshId, setRefreshId] = useState(0); + + const [error, setError] = useState(); + const [loading, setLoading] = useState(false); + const [value, setValue] = useState(options?.defaultValue); + + useEffect(() => { + controllerRef.current.abort(); + + const controller = new AbortController(); + controllerRef.current = controller; + + if (clearValueOnNext) { + setValue(undefined); + setError(undefined); + } + + try { + const response = fn({ signal: controller.signal }); + if (isPromise(response)) { + setLoading(true); + response + .then((nextValue) => { + setError(undefined); + setValue(nextValue); + }) + .catch((err) => { + setValue(undefined); + setError(err); + }) + .finally(() => setLoading(false)); + } else { + setError(undefined); + setValue(response); + setLoading(false); + } + } catch (err) { + setValue(undefined); + setError(err); + setLoading(false); + } + + return () => { + controller.abort(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps.concat(refreshId, clearValueOnNext)); + + return useMemo>(() => { + return { + error, + loading, + value, + refresh: () => { + setRefreshId((id) => id + 1); + }, + } as unknown as AbortableAsyncState; + }, [error, value, loading]); +} diff --git a/x-pack/packages/kbn-ai-assistant/src/hooks/use_ai_assistant_app_service.ts b/x-pack/packages/kbn-ai-assistant/src/hooks/use_ai_assistant_app_service.ts new file mode 100644 index 0000000000000..bb1f93079eb09 --- /dev/null +++ b/x-pack/packages/kbn-ai-assistant/src/hooks/use_ai_assistant_app_service.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useKibana } from './use_kibana'; + +export function useAIAssistantAppService() { + const { services } = useKibana(); + + if (!services.observabilityAIAssistant?.service) { + throw new Error( + 'AI Assistant Service is not available. Did you provide this service in your plugin contract?' + ); + } + + return services.observabilityAIAssistant.service; +} diff --git a/x-pack/packages/kbn-ai-assistant/src/hooks/use_ai_assistant_chat_service.ts b/x-pack/packages/kbn-ai-assistant/src/hooks/use_ai_assistant_chat_service.ts new file mode 100644 index 0000000000000..a3eefef196901 --- /dev/null +++ b/x-pack/packages/kbn-ai-assistant/src/hooks/use_ai_assistant_chat_service.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useKibana } from './use_kibana'; + +export function useAIAssistantChatService() { + const { + services: { observabilityAIAssistant }, + } = useKibana(); + + return observabilityAIAssistant.useObservabilityAIAssistantChatService(); +} diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_confirm_modal.tsx b/x-pack/packages/kbn-ai-assistant/src/hooks/use_confirm_modal.tsx similarity index 100% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_confirm_modal.tsx rename to x-pack/packages/kbn-ai-assistant/src/hooks/use_confirm_modal.tsx diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_conversation.test.tsx b/x-pack/packages/kbn-ai-assistant/src/hooks/use_conversation.test.tsx similarity index 94% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_conversation.test.tsx rename to x-pack/packages/kbn-ai-assistant/src/hooks/use_conversation.test.tsx index 150847a011207..4c4ced36c8796 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_conversation.test.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/hooks/use_conversation.test.tsx @@ -19,9 +19,8 @@ import { StreamingChatResponseEventType, StreamingChatResponseEventWithoutError, } from '@kbn/observability-ai-assistant-plugin/common'; -import { ObservabilityAIAssistantAppServiceProvider } from '../context/observability_ai_assistant_app_service_provider'; import { EMPTY_CONVERSATION_TITLE } from '../i18n'; -import type { ObservabilityAIAssistantAppService } from '../service/create_app_service'; +import type { AIAssistantAppService } from '../service/create_app_service'; import { useConversation, type UseConversationProps, @@ -35,9 +34,9 @@ import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; let hookResult: RenderHookResult; -type MockedService = DeeplyMockedKeys> & { +type MockedService = DeeplyMockedKeys> & { conversations: DeeplyMockedKeys< - Omit + Omit > & { predefinedConversation$: Observable; }; @@ -66,18 +65,15 @@ const useKibanaMockServices = { uiSettings: { get: jest.fn(), }, - plugins: { - start: { - observabilityAIAssistant: { - useChat: createUseChat({ - notifications: { - toasts: { - addError: addErrorMock, - }, - } as unknown as NotificationsStart, - }), - }, - }, + observabilityAIAssistant: { + useChat: createUseChat({ + notifications: { + toasts: { + addError: addErrorMock, + }, + } as unknown as NotificationsStart, + }), + service: mockService, }, }; @@ -87,11 +83,7 @@ describe('useConversation', () => { beforeEach(() => { jest.clearAllMocks(); wrapper = ({ children }: PropsWithChildren) => ( - - - {children} - - + {children} ); }); diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_conversation.ts b/x-pack/packages/kbn-ai-assistant/src/hooks/use_conversation.ts similarity index 91% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_conversation.ts rename to x-pack/packages/kbn-ai-assistant/src/hooks/use_conversation.ts index 617b1b302473f..744e071d5b1ba 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_conversation.ts +++ b/x-pack/packages/kbn-ai-assistant/src/hooks/use_conversation.ts @@ -12,16 +12,14 @@ import type { ConversationCreateRequest, Message, } from '@kbn/observability-ai-assistant-plugin/common'; -import { - ObservabilityAIAssistantChatService, - useAbortableAsync, -} from '@kbn/observability-ai-assistant-plugin/public'; +import type { ObservabilityAIAssistantChatService } from '@kbn/observability-ai-assistant-plugin/public'; import type { AbortableAsyncState } from '@kbn/observability-ai-assistant-plugin/public'; import type { UseChatResult } from '@kbn/observability-ai-assistant-plugin/public'; import { EMPTY_CONVERSATION_TITLE } from '../i18n'; +import { useAIAssistantAppService } from './use_ai_assistant_app_service'; import { useKibana } from './use_kibana'; import { useOnce } from './use_once'; -import { useObservabilityAIAssistantAppService } from './use_observability_ai_assistant_app_service'; +import { useAbortableAsync } from './use_abortable_async'; function createNewConversation({ title = EMPTY_CONVERSATION_TITLE, @@ -62,17 +60,13 @@ export function useConversation({ connectorId, onConversationUpdate, }: UseConversationProps): UseConversationResult { - const service = useObservabilityAIAssistantAppService(); + const service = useAIAssistantAppService(); const { scope } = service; const { services: { notifications, - plugins: { - start: { - observabilityAIAssistant: { useChat }, - }, - }, + observabilityAIAssistant: { useChat }, }, } = useKibana(); @@ -106,8 +100,8 @@ export function useConversation({ }, }) .catch((err) => { - notifications.toasts.addError(err, { - title: i18n.translate('xpack.observabilityAiAssistant.errorUpdatingConversation', { + notifications!.toasts.addError(err, { + title: i18n.translate('xpack.aiAssistant.errorUpdatingConversation', { defaultMessage: 'Could not update conversation', }), }); diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_conversation_key.ts b/x-pack/packages/kbn-ai-assistant/src/hooks/use_conversation_key.ts similarity index 100% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_conversation_key.ts rename to x-pack/packages/kbn-ai-assistant/src/hooks/use_conversation_key.ts diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_conversation_list.ts b/x-pack/packages/kbn-ai-assistant/src/hooks/use_conversation_list.ts similarity index 86% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_conversation_list.ts rename to x-pack/packages/kbn-ai-assistant/src/hooks/use_conversation_list.ts index 6fa6bc02e7b35..d0db7665a30b6 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_conversation_list.ts +++ b/x-pack/packages/kbn-ai-assistant/src/hooks/use_conversation_list.ts @@ -12,9 +12,8 @@ import { type Conversation, useAbortableAsync, } from '@kbn/observability-ai-assistant-plugin/public'; +import { useAIAssistantAppService } from './use_ai_assistant_app_service'; import { useKibana } from './use_kibana'; -import { useObservabilityAIAssistantAppService } from './use_observability_ai_assistant_app_service'; - export interface UseConversationListResult { isLoading: boolean; conversations: AbortableAsyncState<{ conversations: Conversation[] }>; @@ -22,7 +21,7 @@ export interface UseConversationListResult { } export function useConversationList(): UseConversationListResult { - const service = useObservabilityAIAssistantAppService(); + const service = useAIAssistantAppService(); const [isUpdatingList, setIsUpdatingList] = useState(false); @@ -62,8 +61,8 @@ export function useConversationList(): UseConversationListResult { conversations.refresh(); } catch (err) { - notifications.toasts.addError(err, { - title: i18n.translate('xpack.observabilityAiAssistant.flyout.failedToDeleteConversation', { + notifications!.toasts.addError(err, { + title: i18n.translate('xpack.aiAssistant.flyout.failedToDeleteConversation', { defaultMessage: 'Could not delete conversation', }), }); diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_current_user.ts b/x-pack/packages/kbn-ai-assistant/src/hooks/use_current_user.ts similarity index 84% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_current_user.ts rename to x-pack/packages/kbn-ai-assistant/src/hooks/use_current_user.ts index 82c13eb876117..c169358653a49 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_current_user.ts +++ b/x-pack/packages/kbn-ai-assistant/src/hooks/use_current_user.ts @@ -5,9 +5,9 @@ * 2.0. */ +import { useKibana } from '@kbn/kibana-react-plugin/public'; import { AuthenticatedUser } from '@kbn/security-plugin/common'; import { useEffect, useState } from 'react'; -import { useKibana } from './use_kibana'; export function useCurrentUser() { const { @@ -19,7 +19,7 @@ export function useCurrentUser() { useEffect(() => { const getCurrentUser = async () => { try { - const authenticatedUser = await security.authc.getCurrentUser(); + const authenticatedUser = await security!.authc.getCurrentUser(); setUser(authenticatedUser); } catch { setUser(undefined); diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_genai_connectors.ts b/x-pack/packages/kbn-ai-assistant/src/hooks/use_genai_connectors.ts similarity index 66% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_genai_connectors.ts rename to x-pack/packages/kbn-ai-assistant/src/hooks/use_genai_connectors.ts index 1b105513a2323..642bf9488f186 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_genai_connectors.ts +++ b/x-pack/packages/kbn-ai-assistant/src/hooks/use_genai_connectors.ts @@ -5,16 +5,13 @@ * 2.0. */ -import { useKibana } from './use_kibana'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { AIAssistantPluginStartDependencies } from '../types'; export function useGenAIConnectors() { const { - services: { - plugins: { - start: { observabilityAIAssistant }, - }, - }, - } = useKibana(); + services: { observabilityAIAssistant }, + } = useKibana(); return observabilityAIAssistant.useGenAIConnectors(); } diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_json_editor_model.ts b/x-pack/packages/kbn-ai-assistant/src/hooks/use_json_editor_model.ts similarity index 92% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_json_editor_model.ts rename to x-pack/packages/kbn-ai-assistant/src/hooks/use_json_editor_model.ts index 6f4535d84acef..1b14c504d935d 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_json_editor_model.ts +++ b/x-pack/packages/kbn-ai-assistant/src/hooks/use_json_editor_model.ts @@ -7,7 +7,7 @@ import { useEffect, useMemo, useState } from 'react'; import { monaco } from '@kbn/monaco'; import { createInitializedObject } from '../utils/create_initialized_object'; -import { useObservabilityAIAssistantChatService } from './use_observability_ai_assistant_chat_service'; +import { useAIAssistantChatService } from './use_ai_assistant_chat_service'; import { safeJsonParse } from '../utils/safe_json_parse'; const { editor, languages, Uri } = monaco; @@ -19,7 +19,7 @@ export const useJsonEditorModel = ({ functionName: string | undefined; initialJson?: string | undefined; }) => { - const chatService = useObservabilityAIAssistantChatService(); + const chatService = useAIAssistantChatService(); const functionDefinition = chatService.getFunctions().find((func) => func.name === functionName); diff --git a/x-pack/packages/kbn-ai-assistant/src/hooks/use_kibana.ts b/x-pack/packages/kbn-ai-assistant/src/hooks/use_kibana.ts new file mode 100644 index 0000000000000..44aec48a06467 --- /dev/null +++ b/x-pack/packages/kbn-ai-assistant/src/hooks/use_kibana.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { AIAssistantPluginStartDependencies } from '../types'; + +const useTypedKibana = () => useKibana(); + +export { useTypedKibana as useKibana }; diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_knowledge_base.tsx b/x-pack/packages/kbn-ai-assistant/src/hooks/use_knowledge_base.tsx similarity index 82% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_knowledge_base.tsx rename to x-pack/packages/kbn-ai-assistant/src/hooks/use_knowledge_base.tsx index bca9b38485695..0b949fcdbff0e 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_knowledge_base.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/hooks/use_knowledge_base.tsx @@ -15,7 +15,7 @@ import { useAbortableAsync, } from '@kbn/observability-ai-assistant-plugin/public'; import { useKibana } from './use_kibana'; -import { useObservabilityAIAssistantAppService } from './use_observability_ai_assistant_app_service'; +import { useAIAssistantAppService } from './use_ai_assistant_app_service'; export interface UseKnowledgeBaseResult { status: AbortableAsyncState<{ @@ -31,13 +31,8 @@ export interface UseKnowledgeBaseResult { } export function useKnowledgeBase(): UseKnowledgeBaseResult { - const { - notifications: { toasts }, - plugins: { - start: { ml }, - }, - } = useKibana().services; - const service = useObservabilityAIAssistantAppService(); + const { notifications, ml } = useKibana().services; + const service = useAIAssistantAppService(); const status = useAbortableAsync( ({ signal }) => { @@ -75,8 +70,8 @@ export function useKnowledgeBase(): UseKnowledgeBaseResult { return install(); } setInstallError(error); - toasts.addError(error, { - title: i18n.translate('xpack.observabilityAiAssistant.errorSettingUpKnowledgeBase', { + notifications!.toasts.addError(error, { + title: i18n.translate('xpack.aiAssistant.errorSettingUpKnowledgeBase', { defaultMessage: 'Could not set up Knowledge Base', }), }); @@ -92,5 +87,5 @@ export function useKnowledgeBase(): UseKnowledgeBaseResult { isInstalling, installError, }; - }, [status, isInstalling, installError, service, ml.mlApi?.savedObjects, toasts]); + }, [status, isInstalling, installError, service, ml, notifications]); } diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_last_used_prompts.ts b/x-pack/packages/kbn-ai-assistant/src/hooks/use_last_used_prompts.ts similarity index 100% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_last_used_prompts.ts rename to x-pack/packages/kbn-ai-assistant/src/hooks/use_last_used_prompts.ts diff --git a/x-pack/packages/kbn-ai-assistant/src/hooks/use_license.ts b/x-pack/packages/kbn-ai-assistant/src/hooks/use_license.ts new file mode 100644 index 0000000000000..6d146274c7f4d --- /dev/null +++ b/x-pack/packages/kbn-ai-assistant/src/hooks/use_license.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 { ILicense, LicenseType } from '@kbn/licensing-plugin/public'; +import { useCallback } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { useKibana } from './use_kibana'; + +interface UseLicenseReturnValue { + getLicense: () => ILicense | null; + hasAtLeast: (level: LicenseType) => boolean | undefined; +} + +export const useLicense = (): UseLicenseReturnValue => { + const { + services: { licensing }, + } = useKibana(); + + const license = useObservable(licensing.license$); + + return { + getLicense: () => license ?? null, + hasAtLeast: useCallback( + (level: LicenseType) => { + if (!license) return; + + return !!license && license.isAvailable && license.isActive && license.hasAtLeast(level); + }, + [license] + ), + }; +}; diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_license_management_locator.ts b/x-pack/packages/kbn-ai-assistant/src/hooks/use_license_management_locator.ts similarity index 89% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_license_management_locator.ts rename to x-pack/packages/kbn-ai-assistant/src/hooks/use_license_management_locator.ts index 1d5dd04203352..7e650affa2ca5 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_license_management_locator.ts +++ b/x-pack/packages/kbn-ai-assistant/src/hooks/use_license_management_locator.ts @@ -11,11 +11,7 @@ const LICENSE_MANAGEMENT_LOCATOR = 'LICENSE_MANAGEMENT_LOCATOR'; export const useLicenseManagementLocator = () => { const { - services: { - plugins: { - start: { share }, - }, - }, + services: { share }, } = useKibana(); const locator = share.url.locators.get(LICENSE_MANAGEMENT_LOCATOR); diff --git a/x-pack/packages/kbn-ai-assistant/src/hooks/use_local_storage.test.ts b/x-pack/packages/kbn-ai-assistant/src/hooks/use_local_storage.test.ts new file mode 100644 index 0000000000000..ab1d00392fdb9 --- /dev/null +++ b/x-pack/packages/kbn-ai-assistant/src/hooks/use_local_storage.test.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 { renderHook, act } from '@testing-library/react-hooks'; +import { useLocalStorage } from './use_local_storage'; + +describe('useLocalStorage', () => { + const key = 'testKey'; + const defaultValue = 'defaultValue'; + + beforeEach(() => { + localStorage.clear(); + }); + + it('should return the default value when local storage is empty', () => { + const { result } = renderHook(() => useLocalStorage(key, defaultValue)); + const [item] = result.current; + + expect(item).toBe(defaultValue); + }); + + it('should return the stored value when local storage has a value', () => { + const storedValue = 'storedValue'; + localStorage.setItem(key, JSON.stringify(storedValue)); + const { result } = renderHook(() => useLocalStorage(key, defaultValue)); + const [item] = result.current; + + expect(item).toBe(storedValue); + }); + + it('should save the value to local storage', () => { + const { result } = renderHook(() => useLocalStorage(key, defaultValue)); + const [, saveToStorage] = result.current; + const newValue = 'newValue'; + + act(() => { + saveToStorage(newValue); + }); + + expect(JSON.parse(localStorage.getItem(key) || '')).toBe(newValue); + }); + + it('should remove the value from local storage when the value is undefined', () => { + const { result } = renderHook(() => useLocalStorage(key, defaultValue)); + const [, saveToStorage] = result.current; + + act(() => { + saveToStorage(undefined as unknown as string); + }); + + expect(localStorage.getItem(key)).toBe(null); + }); + + it('should listen for storage events to window, and remove the listener upon unmount', () => { + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + + const { unmount } = renderHook(() => useLocalStorage(key, defaultValue)); + + expect(addEventListenerSpy).toHaveBeenCalled(); + + const eventTypes = addEventListenerSpy.mock.calls; + + expect(eventTypes).toContainEqual(['storage', expect.any(Function)]); + + unmount(); + + expect(removeEventListenerSpy).toHaveBeenCalled(); + + addEventListenerSpy.mockRestore(); + removeEventListenerSpy.mockRestore(); + }); +}); diff --git a/x-pack/packages/kbn-ai-assistant/src/hooks/use_local_storage.ts b/x-pack/packages/kbn-ai-assistant/src/hooks/use_local_storage.ts new file mode 100644 index 0000000000000..ea9e13163e4b0 --- /dev/null +++ b/x-pack/packages/kbn-ai-assistant/src/hooks/use_local_storage.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useEffect, useMemo, useCallback } from 'react'; + +export function useLocalStorage(key: string, defaultValue: T) { + // This is necessary to fix a race condition issue. + // It guarantees that the latest value will be always returned after the value is updated + const [storageUpdate, setStorageUpdate] = useState(0); + + const item = useMemo(() => { + return getFromStorage(key, defaultValue); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key, storageUpdate, defaultValue]); + + const saveToStorage = useCallback( + (value: T) => { + if (value === undefined) { + window.localStorage.removeItem(key); + } else { + window.localStorage.setItem(key, JSON.stringify(value)); + setStorageUpdate(storageUpdate + 1); + } + }, + [key, storageUpdate] + ); + + useEffect(() => { + function onUpdate(event: StorageEvent) { + if (event.key === key) { + setStorageUpdate(storageUpdate + 1); + } + } + window.addEventListener('storage', onUpdate); + return () => { + window.removeEventListener('storage', onUpdate); + }; + }, [key, setStorageUpdate, storageUpdate]); + + return useMemo(() => [item, saveToStorage] as const, [item, saveToStorage]); +} + +function getFromStorage(keyName: string, defaultValue: T) { + const storedItem = window.localStorage.getItem(keyName); + + if (storedItem !== null) { + try { + return JSON.parse(storedItem) as T; + } catch (err) { + window.localStorage.removeItem(keyName); + // eslint-disable-next-line no-console + console.log(`Unable to decode: ${keyName}`); + } + } + return defaultValue; +} diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_once.ts b/x-pack/packages/kbn-ai-assistant/src/hooks/use_once.ts similarity index 100% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_once.ts rename to x-pack/packages/kbn-ai-assistant/src/hooks/use_once.ts diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_simulated_function_calling.ts b/x-pack/packages/kbn-ai-assistant/src/hooks/use_simulated_function_calling.ts similarity index 89% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_simulated_function_calling.ts rename to x-pack/packages/kbn-ai-assistant/src/hooks/use_simulated_function_calling.ts index 4d441b03a3ddc..4515f2126dbfd 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_simulated_function_calling.ts +++ b/x-pack/packages/kbn-ai-assistant/src/hooks/use_simulated_function_calling.ts @@ -13,7 +13,7 @@ export function useSimulatedFunctionCalling() { services: { uiSettings }, } = useKibana(); - const simulatedFunctionCallingEnabled = uiSettings.get( + const simulatedFunctionCallingEnabled = uiSettings!.get( aiAssistantSimulatedFunctionCalling, false ); diff --git a/x-pack/packages/kbn-ai-assistant/src/i18n.ts b/x-pack/packages/kbn-ai-assistant/src/i18n.ts new file mode 100644 index 0000000000000..5c5be1633a07a --- /dev/null +++ b/x-pack/packages/kbn-ai-assistant/src/i18n.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const ASSISTANT_SETUP_TITLE = i18n.translate('xpack.aiAssistant.assistantSetup.title', { + defaultMessage: 'Welcome to the Elastic AI Assistant', +}); + +export const EMPTY_CONVERSATION_TITLE = i18n.translate('xpack.aiAssistant.emptyConversationTitle', { + defaultMessage: 'New conversation', +}); + +export const UPGRADE_LICENSE_TITLE = i18n.translate('xpack.aiAssistant.incorrectLicense.title', { + defaultMessage: 'Upgrade your license', +}); diff --git a/x-pack/packages/kbn-ai-assistant/src/index.ts b/x-pack/packages/kbn-ai-assistant/src/index.ts new file mode 100644 index 0000000000000..ba2265e88715f --- /dev/null +++ b/x-pack/packages/kbn-ai-assistant/src/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './conversation/conversation_view'; +export * from './service/create_app_service'; +export * from './hooks'; +export * from './chat'; diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/prompt_editor/prompt_editor.stories.tsx b/x-pack/packages/kbn-ai-assistant/src/prompt_editor/prompt_editor.stories.tsx similarity index 96% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/prompt_editor/prompt_editor.stories.tsx rename to x-pack/packages/kbn-ai-assistant/src/prompt_editor/prompt_editor.stories.tsx index f951653b152cc..ed2948e50f15e 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/prompt_editor/prompt_editor.stories.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/prompt_editor/prompt_editor.stories.tsx @@ -8,13 +8,13 @@ import React from 'react'; import { ComponentStory, ComponentStoryObj } from '@storybook/react'; import { MessageRole } from '@kbn/observability-ai-assistant-plugin/public'; +import { KibanaReactStorybookDecorator } from '../utils/storybook_decorator.stories'; import { PromptEditor as Component, PromptEditorProps } from './prompt_editor'; -import { KibanaReactStorybookDecorator } from '../../utils/storybook_decorator.stories'; /* JSON Schema validation in the PromptEditor compponent does not work when rendering the component from within Storybook. - + */ export default { component: Component, diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/prompt_editor/prompt_editor.tsx b/x-pack/packages/kbn-ai-assistant/src/prompt_editor/prompt_editor.tsx similarity index 97% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/prompt_editor/prompt_editor.tsx rename to x-pack/packages/kbn-ai-assistant/src/prompt_editor/prompt_editor.tsx index db7f3a8f11888..cc2fe761d6176 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/prompt_editor/prompt_editor.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/prompt_editor/prompt_editor.tsx @@ -14,10 +14,10 @@ import { type TelemetryEventTypeWithPayload, ObservabilityAIAssistantTelemetryEventType, } from '@kbn/observability-ai-assistant-plugin/public'; +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 { useLastUsedPrompts } from '../../hooks/use_last_used_prompts'; export interface PromptEditorProps { disabled: boolean; @@ -194,7 +194,7 @@ export function PromptEditor({ {functionName} {chatService.renderFunction(props.name, props.arguments, props.response, props.onActionClick)} diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/service/create_app_service.ts b/x-pack/packages/kbn-ai-assistant/src/service/create_app_service.ts similarity index 63% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/service/create_app_service.ts rename to x-pack/packages/kbn-ai-assistant/src/service/create_app_service.ts index dfb9b703bc4ed..bd01ab39a6d5c 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/service/create_app_service.ts +++ b/x-pack/packages/kbn-ai-assistant/src/service/create_app_service.ts @@ -6,15 +6,15 @@ */ import type { ObservabilityAIAssistantService } from '@kbn/observability-ai-assistant-plugin/public'; -import type { ObservabilityAIAssistantAppPluginStartDependencies } from '../types'; +import { AIAssistantPluginStartDependencies } from '../types'; -export type ObservabilityAIAssistantAppService = ObservabilityAIAssistantService; +export type AIAssistantAppService = ObservabilityAIAssistantService; export function createAppService({ pluginsStart, }: { - pluginsStart: ObservabilityAIAssistantAppPluginStartDependencies; -}): ObservabilityAIAssistantAppService { + pluginsStart: AIAssistantPluginStartDependencies; +}): AIAssistantAppService { return { ...pluginsStart.observabilityAIAssistant.service, }; diff --git a/x-pack/packages/kbn-ai-assistant/src/types/index.ts b/x-pack/packages/kbn-ai-assistant/src/types/index.ts new file mode 100644 index 0000000000000..afebbafd7e643 --- /dev/null +++ b/x-pack/packages/kbn-ai-assistant/src/types/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { LicensingPluginStart } from '@kbn/licensing-plugin/public'; +import type { MlPluginStart } from '@kbn/ml-plugin/public'; +import type { ObservabilityAIAssistantPublicStart } from '@kbn/observability-ai-assistant-plugin/public'; +import type { SharePluginStart } from '@kbn/share-plugin/public'; +import type { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public'; + +export interface AIAssistantPluginStartDependencies { + licensing: LicensingPluginStart; + ml: MlPluginStart; + observabilityAIAssistant: ObservabilityAIAssistantPublicStart; + share: SharePluginStart; + triggersActionsUi: TriggersAndActionsUIPublicPluginStart; +} diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/utils/builders.ts b/x-pack/packages/kbn-ai-assistant/src/utils/builders.ts similarity index 100% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/utils/builders.ts rename to x-pack/packages/kbn-ai-assistant/src/utils/builders.ts diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/utils/create_initialized_object.test.ts b/x-pack/packages/kbn-ai-assistant/src/utils/create_initialized_object.test.ts similarity index 100% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/utils/create_initialized_object.test.ts rename to x-pack/packages/kbn-ai-assistant/src/utils/create_initialized_object.test.ts diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/utils/create_initialized_object.ts b/x-pack/packages/kbn-ai-assistant/src/utils/create_initialized_object.ts similarity index 100% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/utils/create_initialized_object.ts rename to x-pack/packages/kbn-ai-assistant/src/utils/create_initialized_object.ts diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/utils/create_mock_chat_service.ts b/x-pack/packages/kbn-ai-assistant/src/utils/create_mock_chat_service.ts similarity index 100% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/utils/create_mock_chat_service.ts rename to x-pack/packages/kbn-ai-assistant/src/utils/create_mock_chat_service.ts diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/utils/get_role_translation.ts b/x-pack/packages/kbn-ai-assistant/src/utils/get_role_translation.ts similarity index 61% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/utils/get_role_translation.ts rename to x-pack/packages/kbn-ai-assistant/src/utils/get_role_translation.ts index f74c9f842e402..95421a089dea0 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/utils/get_role_translation.ts +++ b/x-pack/packages/kbn-ai-assistant/src/utils/get_role_translation.ts @@ -10,21 +10,18 @@ import { MessageRole } from '@kbn/observability-ai-assistant-plugin/public'; export function getRoleTranslation(role: MessageRole) { if (role === MessageRole.User) { - return i18n.translate('xpack.observabilityAiAssistant.chatTimeline.messages.user.label', { + return i18n.translate('xpack.aiAssistant.chatTimeline.messages.user.label', { defaultMessage: 'You', }); } if (role === MessageRole.System) { - return i18n.translate('xpack.observabilityAiAssistant.chatTimeline.messages.system.label', { + return i18n.translate('xpack.aiAssistant.chatTimeline.messages.system.label', { defaultMessage: 'System', }); } - return i18n.translate( - 'xpack.observabilityAiAssistant.chatTimeline.messages.elasticAssistant.label', - { - defaultMessage: 'Elastic Assistant', - } - ); + return i18n.translate('xpack.aiAssistant.chatTimeline.messages.elasticAssistant.label', { + defaultMessage: 'Elastic Assistant', + }); } diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/utils/get_timeline_items_from_conversation.test.tsx b/x-pack/packages/kbn-ai-assistant/src/utils/get_timeline_items_from_conversation.test.tsx similarity index 98% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/utils/get_timeline_items_from_conversation.test.tsx rename to x-pack/packages/kbn-ai-assistant/src/utils/get_timeline_items_from_conversation.test.tsx index 6fb7e1a323d08..337c11419209e 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/utils/get_timeline_items_from_conversation.test.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/utils/get_timeline_items_from_conversation.test.tsx @@ -23,12 +23,8 @@ function Providers({ children }: { children: React.ReactElement }) { mockChatService, - }, - }, + observabilityAIAssistant: { + useObservabilityAIAssistantChatService: () => mockChatService, }, }} > diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/utils/get_timeline_items_from_conversation.tsx b/x-pack/packages/kbn-ai-assistant/src/utils/get_timeline_items_from_conversation.tsx similarity index 94% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/utils/get_timeline_items_from_conversation.tsx rename to x-pack/packages/kbn-ai-assistant/src/utils/get_timeline_items_from_conversation.tsx index 9a3fed770b944..999ac4f095025 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/utils/get_timeline_items_from_conversation.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/utils/get_timeline_items_from_conversation.tsx @@ -18,9 +18,9 @@ import { ObservabilityAIAssistantChatService, } from '@kbn/observability-ai-assistant-plugin/public'; import type { ChatActionClickPayload } from '@kbn/observability-ai-assistant-plugin/public'; -import type { ChatTimelineItem } from '../components/chat/chat_timeline'; -import { RenderFunction } from '../components/render_function'; +import { RenderFunction } from '../render_function'; import { safeJsonParse } from './safe_json_parse'; +import type { ChatTimelineItem } from '../chat/chat_timeline'; function convertMessageToMarkdownCodeBlock(message: Message['message']) { let value: object; @@ -95,7 +95,7 @@ export function getTimelineItemsfromConversation({ '@timestamp': new Date().toISOString(), message: { role: MessageRole.User }, }, - title: i18n.translate('xpack.observabilityAiAssistant.conversationStartTitle', { + title: i18n.translate('xpack.aiAssistant.conversationStartTitle', { defaultMessage: 'started a conversation', }), role: MessageRole.User, @@ -149,7 +149,7 @@ export function getTimelineItemsfromConversation({ title = !isError ? ( , @@ -157,7 +157,7 @@ export function getTimelineItemsfromConversation({ /> ) : ( , @@ -189,7 +189,7 @@ export function getTimelineItemsfromConversation({ // User suggested a function title = ( , @@ -222,7 +222,7 @@ export function getTimelineItemsfromConversation({ if (message.message.function_call?.name) { title = ( , diff --git a/x-pack/plugins/entity_manager/server/lib/entities/helpers/is_backfill_enabled.ts b/x-pack/packages/kbn-ai-assistant/src/utils/non_nullable.ts similarity index 57% rename from x-pack/plugins/entity_manager/server/lib/entities/helpers/is_backfill_enabled.ts rename to x-pack/packages/kbn-ai-assistant/src/utils/non_nullable.ts index 4c34f5d3c0256..8618e44dbb823 100644 --- a/x-pack/plugins/entity_manager/server/lib/entities/helpers/is_backfill_enabled.ts +++ b/x-pack/packages/kbn-ai-assistant/src/utils/non_nullable.ts @@ -5,8 +5,6 @@ * 2.0. */ -import { EntityDefinition } from '@kbn/entities-schema'; - -export function isBackfillEnabled(definition: EntityDefinition) { - return definition.history.settings.backfillSyncDelay != null; +export function nonNullable(v: T): v is NonNullable { + return v !== null && v !== undefined; } diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/utils/get_settings_href.ts b/x-pack/packages/kbn-ai-assistant/src/utils/safe_json_parse.ts similarity index 57% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/utils/get_settings_href.ts rename to x-pack/packages/kbn-ai-assistant/src/utils/safe_json_parse.ts index 45a3083d66327..a4f2dfa5c2503 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/utils/get_settings_href.ts +++ b/x-pack/packages/kbn-ai-assistant/src/utils/safe_json_parse.ts @@ -5,8 +5,10 @@ * 2.0. */ -import { HttpStart } from '@kbn/core/public'; - -export function getSettingsHref(http: HttpStart) { - return http!.basePath.prepend(`/app/management/kibana/observabilityAiAssistantManagement`); +export function safeJsonParse(jsonStr: string) { + try { + return JSON.parse(jsonStr); + } catch (err) { + return jsonStr; + } } diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/utils/storybook_decorator.stories.tsx b/x-pack/packages/kbn-ai-assistant/src/utils/storybook_decorator.stories.tsx similarity index 57% rename from x-pack/plugins/observability_solution/observability_ai_assistant_app/public/utils/storybook_decorator.stories.tsx rename to x-pack/packages/kbn-ai-assistant/src/utils/storybook_decorator.stories.tsx index 9dc2e7057b951..d6292803b42af 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/utils/storybook_decorator.stories.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/utils/storybook_decorator.stories.tsx @@ -13,10 +13,9 @@ import { } from '@kbn/observability-ai-assistant-plugin/public'; import { Subject } from 'rxjs'; import { coreMock } from '@kbn/core/public/mocks'; -import { ObservabilityAIAssistantAppService } from '../service/create_app_service'; -import { ObservabilityAIAssistantAppServiceProvider } from '../context/observability_ai_assistant_app_service_provider'; +import { AIAssistantAppService } from '../service/create_app_service'; -const mockService: ObservabilityAIAssistantAppService = { +const mockService: AIAssistantAppService = { ...createStorybookService(), }; @@ -38,25 +37,17 @@ export function KibanaReactStorybookDecorator(Story: ComponentType) { licensing: { license$: new Subject(), }, - // observabilityAIAssistant: { - // ObservabilityAIAssistantChatServiceContext, - // ObservabilityAIAssistantMultipaneFlyoutContext, - // }, - plugins: { - start: { - observabilityAIAssistant: { - ObservabilityAIAssistantMultipaneFlyoutContext, - }, - triggersActionsUi: { getAddRuleFlyout: {}, getAddConnectorFlyout: {} }, - }, + observabilityAIAssistant: { + ObservabilityAIAssistantChatServiceContext, + ObservabilityAIAssistantMultipaneFlyoutContext, + service: mockService, }, + triggersActionsUi: { getAddRuleFlyout: {}, getAddConnectorFlyout: {} }, }} > - - - - - + + + ); } diff --git a/x-pack/packages/kbn-ai-assistant/tsconfig.json b/x-pack/packages/kbn-ai-assistant/tsconfig.json new file mode 100644 index 0000000000000..c8d91c9d37450 --- /dev/null +++ b/x-pack/packages/kbn-ai-assistant/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node", + "react", + "@emotion/react/types/css-prop" + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/core-http-browser", + "@kbn/i18n", + "@kbn/triggers-actions-ui-plugin", + "@kbn/actions-plugin", + "@kbn/i18n-react", + "@kbn/ui-theme", + "@kbn/core", + "@kbn/observability-ai-assistant-plugin", + "@kbn/security-plugin", + "@kbn/user-profile-components", + "@kbn/std", + "@kbn/utility-types-jest", + "@kbn/kibana-react-plugin", + "@kbn/monaco", + "@kbn/licensing-plugin", + "@kbn/code-editor", + "@kbn/ml-plugin", + "@kbn/share-plugin", + ] +} diff --git a/x-pack/packages/kbn-elastic-assistant-common/docs/openapi/ess/elastic_assistant_api_2023_10_31.bundled.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/docs/openapi/ess/elastic_assistant_api_2023_10_31.bundled.schema.yaml index 1e070b75322d4..8f80e61c07040 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/docs/openapi/ess/elastic_assistant_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/packages/kbn-elastic-assistant-common/docs/openapi/ess/elastic_assistant_api_2023_10_31.bundled.schema.yaml @@ -1194,6 +1194,7 @@ components: enum: - OpenAI - Azure OpenAI + - Other type: string Reader: additionalProperties: true diff --git a/x-pack/packages/kbn-elastic-assistant-common/docs/openapi/serverless/elastic_assistant_api_2023_10_31.bundled.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/docs/openapi/serverless/elastic_assistant_api_2023_10_31.bundled.schema.yaml index e13d7a05af41f..97c18a2f77b6e 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/docs/openapi/serverless/elastic_assistant_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/packages/kbn-elastic-assistant-common/docs/openapi/serverless/elastic_assistant_api_2023_10_31.bundled.schema.yaml @@ -1194,6 +1194,7 @@ components: enum: - OpenAI - Azure OpenAI + - Other type: string Reader: additionalProperties: true diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/common_attributes.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/common_attributes.gen.ts index 1ba701474b1f8..1dad26e1628db 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/common_attributes.gen.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/common_attributes.gen.ts @@ -46,7 +46,7 @@ export const Reader = z.object({}).catchall(z.unknown()); * Provider */ export type Provider = z.infer; -export const Provider = z.enum(['OpenAI', 'Azure OpenAI']); +export const Provider = z.enum(['OpenAI', 'Azure OpenAI', 'Other']); export type ProviderEnum = typeof Provider.enum; export const ProviderEnum = Provider.enum; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/common_attributes.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/common_attributes.schema.yaml index f6a8189182474..20423236f7423 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/common_attributes.schema.yaml +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/conversations/common_attributes.schema.yaml @@ -34,6 +34,7 @@ components: enum: - OpenAI - Azure OpenAI + - Other MessageRole: type: string diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/entries/common_attributes.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/entries/common_attributes.gen.ts index 1af5c46b1c130..c32517fec0860 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/entries/common_attributes.gen.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/entries/common_attributes.gen.ts @@ -106,7 +106,11 @@ export type BaseCreateProps = z.infer; export const BaseCreateProps = BaseRequiredFields.merge(BaseDefaultableFields); export type BaseUpdateProps = z.infer; -export const BaseUpdateProps = BaseCreateProps.partial(); +export const BaseUpdateProps = BaseCreateProps.partial().merge( + z.object({ + id: NonEmptyString, + }) +); export type BaseResponseProps = z.infer; export const BaseResponseProps = BaseRequiredFields.merge(BaseDefaultableFields.required()); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/entries/common_attributes.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/entries/common_attributes.schema.yaml index c1c551059f04b..af7f4dd8e4221 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/entries/common_attributes.schema.yaml +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/entries/common_attributes.schema.yaml @@ -112,6 +112,12 @@ components: allOf: - $ref: "#/components/schemas/BaseCreateProps" x-modify: partial + - type: object + properties: + id: + $ref: "../../common_attributes.schema.yaml#/components/schemas/NonEmptyString" + required: + - id BaseResponseProps: x-inline: true 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 ba6317329d350..75e78f2a06948 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 @@ -78,3 +78,20 @@ export const useInvalidateKnowledgeBaseStatus = () => { }); }, [queryClient]); }; + +/** + * Helper for determining if Knowledge Base setup is complete. + * + * Note: Consider moving to API + * + * @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 + ); +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/index.tsx index d81a56fb97eef..ef37506f2af17 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/index.tsx @@ -5,16 +5,13 @@ * 2.0. */ -import React, { useState, useMemo, useCallback } from 'react'; +import React, { useMemo, useCallback } from 'react'; import { QueryObserverResult, RefetchOptions, RefetchQueryFilters } from '@tanstack/react-query'; import { EuiFlexGroup, EuiFlexItem, - EuiPopover, - EuiContextMenu, EuiButtonIcon, EuiPanel, - EuiConfirmModal, EuiToolTip, EuiSkeletonTitle, } from '@elastic/eui'; @@ -29,6 +26,7 @@ import { FlyoutNavigation } from '../assistant_overlay/flyout_navigation'; import { AssistantSettingsButton } from '../settings/assistant_settings_button'; import * as i18n from './translations'; import { AIConnector } from '../../connectorland/connector_selector'; +import { SettingsContextMenu } from '../settings/settings_context_menu/settings_context_menu'; interface OwnProps { selectedConversation: Conversation | undefined; @@ -94,21 +92,6 @@ export const AssistantHeader: React.FC = ({ [selectedConversation?.apiConfig?.connectorId] ); - const [isPopoverOpen, setPopover] = useState(false); - - const onButtonClick = useCallback(() => { - setPopover(!isPopoverOpen); - }, [isPopoverOpen]); - - const closePopover = useCallback(() => { - setPopover(false); - }, []); - - const [isResetConversationModalVisible, setIsResetConversationModalVisible] = useState(false); - - const closeDestroyModal = useCallback(() => setIsResetConversationModalVisible(false), []); - const showDestroyModal = useCallback(() => setIsResetConversationModalVisible(true), []); - const onConversationChange = useCallback( (updatedConversation: Conversation) => { onConversationSelected({ @@ -119,32 +102,6 @@ export const AssistantHeader: React.FC = ({ [onConversationSelected] ); - const panels = useMemo( - () => [ - { - id: 0, - items: [ - { - name: i18n.RESET_CONVERSATION, - css: css` - color: ${euiThemeVars.euiColorDanger}; - `, - onClick: showDestroyModal, - icon: 'refresh', - 'data-test-subj': 'clear-chat', - }, - ], - }, - ], - [showDestroyModal] - ); - - const handleReset = useCallback(() => { - onChatCleared(); - closeDestroyModal(); - closePopover(); - }, [onChatCleared, closeDestroyModal, closePopover]); - return ( <> = ({ - - } - isOpen={isPopoverOpen} - closePopover={closePopover} - panelPaddingSize="none" - anchorPosition="downLeft" - > - - + - {isResetConversationModalVisible && ( - -

    {i18n.CLEAR_CHAT_CONFIRMATION}

    -
    - )} ); }; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/translations.ts index 68c926d2aa14c..e4f23e0970eb0 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/translations.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/translations.ts @@ -7,6 +7,34 @@ import { i18n } from '@kbn/i18n'; +export const AI_ASSISTANT_SETTINGS = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.aiAssistantSettings', + { + defaultMessage: 'AI Assistant settings', + } +); + +export const ANONYMIZATION = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.anonymization', + { + defaultMessage: 'Anonymization', + } +); + +export const KNOWLEDGE_BASE = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBase', + { + defaultMessage: 'Knowledge Base', + } +); + +export const ALERTS_TO_ANALYZE = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.alertsToAnalyze', + { + defaultMessage: 'Alerts to analyze', + } +); + export const RESET_CONVERSATION = i18n.translate( 'xpack.elasticAssistant.assistant.settings.resetConversation', { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/alerts/settings/alerts_settings.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings.test.tsx similarity index 89% rename from x-pack/packages/kbn-elastic-assistant/impl/alerts/settings/alerts_settings.test.tsx rename to x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings.test.tsx index 3e730451ba1d5..2a5cae76d5e77 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/alerts/settings/alerts_settings.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings.test.tsx @@ -9,8 +9,8 @@ import { render, screen, fireEvent } from '@testing-library/react'; import React from 'react'; import { AlertsSettings } from './alerts_settings'; -import { KnowledgeBaseConfig } from '../../assistant/types'; -import { DEFAULT_LATEST_ALERTS } from '../../assistant_context/constants'; +import { KnowledgeBaseConfig } from '../../types'; +import { DEFAULT_LATEST_ALERTS } from '../../../assistant_context/constants'; describe('AlertsSettings', () => { beforeEach(() => { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/alerts/settings/alerts_settings.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings.tsx similarity index 92% rename from x-pack/packages/kbn-elastic-assistant/impl/alerts/settings/alerts_settings.tsx rename to x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings.tsx index e73bfa15e66be..60078178a1771 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/alerts/settings/alerts_settings.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings.tsx @@ -9,9 +9,9 @@ import { EuiFlexGroup, EuiFormRow, EuiFlexItem, EuiSpacer, EuiText } from '@elas import { css } from '@emotion/react'; import React from 'react'; -import { KnowledgeBaseConfig } from '../../assistant/types'; -import { AlertsRange } from '../../knowledge_base/alerts_range'; -import * as i18n from '../../knowledge_base/translations'; +import { KnowledgeBaseConfig } from '../../types'; +import { AlertsRange } from '../../../knowledge_base/alerts_range'; +import * as i18n from '../../../knowledge_base/translations'; export const MIN_LATEST_ALERTS = 10; export const MAX_LATEST_ALERTS = 100; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/alerts/settings/alerts_settings_management.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings_management.tsx similarity index 68% rename from x-pack/packages/kbn-elastic-assistant/impl/alerts/settings/alerts_settings_management.tsx rename to x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings_management.tsx index d103c1a8c03c2..1a6f826bd415f 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/alerts/settings/alerts_settings_management.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings_management.tsx @@ -7,19 +7,24 @@ import { EuiPanel, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; import React from 'react'; -import { KnowledgeBaseConfig } from '../../assistant/types'; -import { AlertsRange } from '../../knowledge_base/alerts_range'; -import * as i18n from '../../knowledge_base/translations'; +import { KnowledgeBaseConfig } from '../../types'; +import { AlertsRange } from '../../../knowledge_base/alerts_range'; +import * as i18n from '../../../knowledge_base/translations'; interface Props { knowledgeBase: KnowledgeBaseConfig; setUpdatedKnowledgeBaseSettings: React.Dispatch>; + hasBorder?: boolean; } +/** + * Replaces the AlertsSettings component used in the existing settings modal. Once the modal is + * fully removed we can delete that component in favor of this one. + */ export const AlertsSettingsManagement: React.FC = React.memo( - ({ knowledgeBase, setUpdatedKnowledgeBaseSettings }) => { + ({ knowledgeBase, setUpdatedKnowledgeBaseSettings, hasBorder = true }) => { return ( - +

    {i18n.ALERTS_LABEL}

    diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_management.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_management.test.tsx index d8e207cbb23cd..dd472b3ee87ab 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_management.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_management.test.tsx @@ -25,6 +25,7 @@ import { SYSTEM_PROMPTS_TAB, } from './const'; import { mockSystemPrompts } from '../../mock/system_prompt'; +import { DataViewsContract } from '@kbn/data-views-plugin/public'; const mockConversations = { [alertConvo.title]: alertConvo, @@ -53,8 +54,13 @@ const mockContext = { }, }; +const mockDataViews = { + getIndices: jest.fn(), +} as unknown as DataViewsContract; + const testProps = { selectedConversation: welcomeConvo, + dataViews: mockDataViews, }; jest.mock('../../assistant_context'); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_management.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_management.tsx index 89c00fbf88773..4c50d14a5662e 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_management.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_management.tsx @@ -9,6 +9,7 @@ import React, { useEffect, useMemo } from 'react'; import { EuiAvatar, EuiPageTemplate, EuiTitle, useEuiShadow, useEuiTheme } from '@elastic/eui'; import { css } from '@emotion/react'; +import { DataViewsContract } from '@kbn/data-views-plugin/public'; import { Conversation } from '../../..'; import * as i18n from './translations'; import { useAssistantContext } from '../../assistant_context'; @@ -33,6 +34,7 @@ import { KnowledgeBaseSettingsManagement } from '../../knowledge_base/knowledge_ import { EvaluationSettings } from '.'; interface Props { + dataViews: DataViewsContract; selectedConversation: Conversation; } @@ -41,7 +43,7 @@ interface Props { * anonymization, knowledge base, and evaluation via the `isModelEvaluationEnabled` feature flag. */ export const AssistantSettingsManagement: React.FC = React.memo( - ({ selectedConversation: defaultSelectedConversation }) => { + ({ dataViews, selectedConversation: defaultSelectedConversation }) => { const { assistantFeatures: { assistantModelEvaluation: modelEvaluatorEnabled }, http, @@ -158,7 +160,9 @@ export const AssistantSettingsManagement: React.FC = React.memo( )} {selectedSettingsTab === QUICK_PROMPTS_TAB && } {selectedSettingsTab === ANONYMIZATION_TAB && } - {selectedSettingsTab === KNOWLEDGE_BASE_TAB && } + {selectedSettingsTab === KNOWLEDGE_BASE_TAB && ( + + )} {selectedSettingsTab === EVALUATION_TAB && } diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/settings_context_menu/settings_context_menu.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/settings_context_menu/settings_context_menu.tsx new file mode 100644 index 0000000000000..b7f33b9a6af5a --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/settings_context_menu/settings_context_menu.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 React, { ReactElement, useCallback, useMemo, useState } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiContextMenuPanel, + EuiContextMenuItem, + EuiConfirmModal, + EuiNotificationBadge, + EuiPopover, + EuiButtonIcon, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { useAssistantContext } from '../../../..'; +import * as i18n from '../../assistant_header/translations'; + +interface Params { + isDisabled?: boolean; + onChatCleared?: () => void; +} + +export const SettingsContextMenu: React.FC = React.memo( + ({ isDisabled = false, onChatCleared }: Params) => { + const { + navigateToApp, + knowledgeBase, + assistantFeatures: { assistantKnowledgeBaseByDefault: enableKnowledgeBaseByDefault }, + } = useAssistantContext(); + + const [isPopoverOpen, setPopover] = useState(false); + + const [isResetConversationModalVisible, setIsResetConversationModalVisible] = useState(false); + const closeDestroyModal = useCallback(() => setIsResetConversationModalVisible(false), []); + + const onButtonClick = useCallback(() => { + setPopover(!isPopoverOpen); + }, [isPopoverOpen]); + + const closePopover = useCallback(() => { + setPopover(false); + }, []); + + const showDestroyModal = useCallback(() => { + closePopover?.(); + setIsResetConversationModalVisible(true); + }, [closePopover]); + + const handleNavigateToSettings = useCallback( + () => + navigateToApp('management', { + path: 'kibana/securityAiAssistantManagement', + }), + [navigateToApp] + ); + + const handleNavigateToKnowledgeBase = useCallback( + () => + navigateToApp('management', { + path: 'kibana/securityAiAssistantManagement', + }), + [navigateToApp] + ); + + // We are migrating away from the settings modal in favor of the new Stack Management UI + // Currently behind `assistantKnowledgeBaseByDefault` FF + const newItems: ReactElement[] = useMemo( + () => [ + + {i18n.AI_ASSISTANT_SETTINGS} + , + + {i18n.ANONYMIZATION} + , + + {i18n.KNOWLEDGE_BASE} + , + + + {i18n.ALERTS_TO_ANALYZE} + + + {knowledgeBase.latestAlerts} + + + + , + ], + [handleNavigateToKnowledgeBase, handleNavigateToSettings, knowledgeBase] + ); + + const items = useMemo( + () => [ + ...(enableKnowledgeBaseByDefault ? newItems : []), + + {i18n.RESET_CONVERSATION} + , + ], + + [enableKnowledgeBaseByDefault, newItems, showDestroyModal] + ); + + const handleReset = useCallback(() => { + onChatCleared?.(); + closeDestroyModal(); + closePopover?.(); + }, [onChatCleared, closeDestroyModal, closePopover]); + + return ( + <> + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + panelPaddingSize="none" + anchorPosition="leftUp" + > + + + {isResetConversationModalVisible && ( + +

    {i18n.CLEAR_CHAT_CONFIRMATION}

    +
    + )} + + ); + } +); + +SettingsContextMenu.displayName = 'SettingsContextMenu'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/helpers.tsx b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/helpers.tsx index 2bbc74af5a45a..99550f1cafe75 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/helpers.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/helpers.tsx @@ -18,6 +18,7 @@ import { PRECONFIGURED_CONNECTOR } from './translations'; enum OpenAiProviderType { OpenAi = 'OpenAI', AzureAi = 'Azure OpenAI', + Other = 'Other', } interface GenAiConfig { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/alerts_range.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/alerts_range.tsx index 152f0a91a7d04..63bd86121dcc1 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/alerts_range.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/alerts_range.tsx @@ -12,7 +12,7 @@ import { MAX_LATEST_ALERTS, MIN_LATEST_ALERTS, TICK_INTERVAL, -} from '../alerts/settings/alerts_settings'; +} from '../assistant/settings/alerts_settings/alerts_settings'; import { KnowledgeBaseConfig } from '../assistant/types'; import { ALERTS_RANGE } from './translations'; 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 b56abafafd5db..aa873decdcd87 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 @@ -23,7 +23,7 @@ import { import { FormattedMessage } from '@kbn/i18n-react'; import { css } from '@emotion/react'; -import { AlertsSettings } from '../alerts/settings/alerts_settings'; +import { AlertsSettings } from '../assistant/settings/alerts_settings/alerts_settings'; import { useAssistantContext } from '../assistant_context'; import type { KnowledgeBaseConfig } from '../assistant/types'; import * as i18n from './translations'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/document_entry_editor.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/document_entry_editor.tsx index 016da27d2c051..b33f221bfde3b 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/document_entry_editor.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/document_entry_editor.tsx @@ -127,7 +127,6 @@ export const DocumentEntryEditor: React.FC = React.memo(({ entry, setEntr id="requiredKnowledge" onChange={onRequiredKnowledgeChanged} checked={entry?.required ?? false} - disabled={true} /> 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 a2097177a2ca4..5cf887ae3375d 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 @@ -6,8 +6,12 @@ */ import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, EuiInMemoryTable, EuiLink, + EuiLoadingSpinner, EuiPanel, EuiSearchBarProps, EuiSpacer, @@ -23,7 +27,9 @@ import { KnowledgeBaseEntryCreateProps, KnowledgeBaseEntryResponse, } from '@kbn/elastic-assistant-common'; -import { AlertsSettingsManagement } from '../../alerts/settings/alerts_settings_management'; +import { css } from '@emotion/react'; +import { DataViewsContract } from '@kbn/data-views-plugin/public'; +import { AlertsSettingsManagement } from '../../assistant/settings/alerts_settings/alerts_settings_management'; import { useKnowledgeBaseEntries } from '../../assistant/api/knowledge_base/entries/use_knowledge_base_entries'; import { useAssistantContext } from '../../assistant_context'; import { useKnowledgeBaseTable } from './use_knowledge_base_table'; @@ -51,14 +57,24 @@ import { useCreateKnowledgeBaseEntry } from '../../assistant/api/knowledge_base/ import { useUpdateKnowledgeBaseEntries } from '../../assistant/api/knowledge_base/entries/use_update_knowledge_base_entries'; import { SETTINGS_UPDATED_TOAST_TITLE } from '../../assistant/settings/translations'; import { KnowledgeBaseConfig } from '../../assistant/types'; +import { + isKnowledgeBaseSetup, + useKnowledgeBaseStatus, +} from '../../assistant/api/knowledge_base/use_knowledge_base_status'; + +interface Params { + dataViews: DataViewsContract; +} -export const KnowledgeBaseSettingsManagement: React.FC = React.memo(() => { +export const KnowledgeBaseSettingsManagement: React.FC = React.memo(({ dataViews }) => { const { assistantFeatures: { assistantKnowledgeBaseByDefault: enableKnowledgeBaseByDefault }, http, toasts, } = useAssistantContext(); const [hasPendingChanges, setHasPendingChanges] = useState(false); + const { data: kbStatus, isFetched } = useKnowledgeBaseStatus({ http }); + const isKbSetup = isKnowledgeBaseSetup(kbStatus); // Only needed for legacy settings management const { knowledgeBase, setUpdatedKnowledgeBaseSettings, resetSettings, saveSettings } = @@ -123,12 +139,12 @@ export const KnowledgeBaseSettingsManagement: React.FC = React.memo(() => { // Flyout Save/Cancel Actions const onSaveConfirmed = useCallback(() => { - if (isKnowledgeBaseEntryCreateProps(selectedEntry)) { - createEntry(selectedEntry); - closeFlyout(); - } else if (isKnowledgeBaseEntryResponse(selectedEntry)) { + if (isKnowledgeBaseEntryResponse(selectedEntry)) { updateEntries([selectedEntry]); closeFlyout(); + } else if (isKnowledgeBaseEntryCreateProps(selectedEntry)) { + createEntry(selectedEntry); + closeFlyout(); } }, [closeFlyout, selectedEntry, createEntry, updateEntries]); @@ -137,7 +153,11 @@ export const KnowledgeBaseSettingsManagement: React.FC = React.memo(() => { closeFlyout(); }, [closeFlyout]); - const { data: entries } = useKnowledgeBaseEntries({ + const { + data: entries, + isFetching: isFetchingEntries, + refetch: refetchEntries, + } = useKnowledgeBaseEntries({ http, toasts, enabled: enableKnowledgeBaseByDefault, @@ -169,6 +189,9 @@ export const KnowledgeBaseSettingsManagement: React.FC = React.memo(() => { [deleteEntry, entries.data, getColumns, openFlyout] ); + // Refresh button + const handleRefreshTable = useCallback(() => refetchEntries(), [refetchEntries]); + const onDocumentClicked = useCallback(() => { setSelectedEntry({ type: DocumentEntryType.value, kbResource: 'user', source: 'user' }); openFlyout(); @@ -182,7 +205,30 @@ export const KnowledgeBaseSettingsManagement: React.FC = React.memo(() => { const search: EuiSearchBarProps = useMemo( () => ({ toolsRight: ( - + + + + + + + + + + ), box: { incremental: true, @@ -190,7 +236,7 @@ export const KnowledgeBaseSettingsManagement: React.FC = React.memo(() => { }, filters: [], }), - [onDocumentClicked, onIndexClicked] + [isFetchingEntries, handleRefreshTable, onDocumentClicked, onIndexClicked] ); const flyoutTitle = useMemo(() => { @@ -247,15 +293,40 @@ export const KnowledgeBaseSettingsManagement: React.FC = React.memo(() => { ), }} /> - - + + + {!isFetched ? ( + + ) : isKbSetup ? ( + + ) : ( + <> + + + + + + + + + + + + + + )} + +
    { ) : ( >> } 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 19f8cfbbc52ba..f5dd2df3bcaac 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 @@ -17,14 +17,16 @@ import { } from '@elastic/eui'; import React, { useCallback } from 'react'; import { IndexEntry } from '@kbn/elastic-assistant-common'; +import { DataViewsContract } from '@kbn/data-views-plugin/public'; import * as i18n from './translations'; interface Props { + dataViews: DataViewsContract; entry?: IndexEntry; setEntry: React.Dispatch>>; } -export const IndexEntryEditor: React.FC = React.memo(({ entry, setEntry }) => { +export const IndexEntryEditor: React.FC = React.memo(({ dataViews, entry, setEntry }) => { // Name const setName = useCallback( (e: React.ChangeEvent) => @@ -74,9 +76,17 @@ export const IndexEntryEditor: React.FC = React.memo(({ entry, setEntry } entry?.users?.length === 0 ? sharingOptions[1].value : sharingOptions[0].value; // Index + // TODO: For index field autocomplete + // const indexOptions = useMemo(() => { + // const indices = await dataViews.getIndices({ + // pattern: e[0]?.value ?? '', + // isRollupIndex: () => false, + // }); + // }, [dataViews]); const setIndex = useCallback( - (e: Array>) => - setEntry((prevEntry) => ({ ...prevEntry, index: e[0].value })), + async (e: Array>) => { + setEntry((prevEntry) => ({ ...prevEntry, index: e[0]?.value })); + }, [setEntry] ); @@ -162,30 +172,51 @@ export const IndexEntryEditor: React.FC = React.memo(({ entry, setEntry } - + - + + + + ); }); 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 ed4a3676975b8..0cc16089fdaae 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 @@ -251,14 +251,44 @@ export const ENTRY_FIELD_INPUT_LABEL = i18n.translate( export const ENTRY_DESCRIPTION_INPUT_LABEL = i18n.translate( 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryDescriptionInputLabel', { - defaultMessage: 'Description', + defaultMessage: 'Data Description', + } +); + +export const ENTRY_DESCRIPTION_HELP_LABEL = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryDescriptionHelpLabel', + { + defaultMessage: + 'A description of the type of data in this index and/or when the assistant should look for data here.', } ); export const ENTRY_QUERY_DESCRIPTION_INPUT_LABEL = i18n.translate( 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryQueryDescriptionInputLabel', { - defaultMessage: 'Query Description', + defaultMessage: 'Query Instruction', + } +); + +export const ENTRY_QUERY_DESCRIPTION_HELP_LABEL = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryQueryDescriptionHelpLabel', + { + defaultMessage: 'Any instructions for extracting the search query from the user request.', + } +); + +export const ENTRY_OUTPUT_FIELDS_INPUT_LABEL = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryOutputFieldsInputLabel', + { + defaultMessage: 'Output Fields', + } +); + +export const ENTRY_OUTPUT_FIELDS_HELP_LABEL = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryOutputFieldsHelpLabel', + { + defaultMessage: + 'What fields should be sent to the LLM. Leave empty to send the entire document.', } ); @@ -269,6 +299,13 @@ export const ENTRY_INPUT_PLACEHOLDER = i18n.translate( } ); +export const ENTRY_FIELD_PLACEHOLDER = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryFieldPlaceholder', + { + defaultMessage: 'semantic_text', + } +); + export const KNOWLEDGE_BASE_DOCUMENTATION = i18n.translate( 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.knowledgeBaseDocumentation', { 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 5af360a598205..d0038169cd597 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 @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiAvatar, EuiBadge, EuiBasicTableColumn, EuiIcon, EuiLink, EuiText } from '@elastic/eui'; +import { EuiAvatar, EuiBadge, EuiBasicTableColumn, EuiIcon, EuiText } from '@elastic/eui'; import { css } from '@emotion/react'; import React, { useCallback } from 'react'; import { FormattedDate } from '@kbn/i18n-react'; @@ -32,7 +32,7 @@ export const useKnowledgeBaseTable = () => { if (['esql', 'security_labs'].includes(entry.kbResource)) { return 'logoElastic'; } - return 'visText'; + return 'document'; } else if (entry.type === IndexEntryType.value) { return 'index'; } @@ -61,9 +61,7 @@ export const useKnowledgeBaseTable = () => { }, { name: i18n.COLUMN_NAME, - render: (entry: KnowledgeBaseEntryResponse) => ( - onEntryNameClicked(entry)}>{entry.name} - ), + render: ({ name }: KnowledgeBaseEntryResponse) => name, sortable: ({ name }: KnowledgeBaseEntryResponse) => name, width: '30%', }, diff --git a/x-pack/packages/kbn-elastic-assistant/tsconfig.json b/x-pack/packages/kbn-elastic-assistant/tsconfig.json index ed2631b597bd6..8d19fa86f4d11 100644 --- a/x-pack/packages/kbn-elastic-assistant/tsconfig.json +++ b/x-pack/packages/kbn-elastic-assistant/tsconfig.json @@ -30,5 +30,6 @@ "@kbn/core-doc-links-browser", "@kbn/core", "@kbn/zod", + "@kbn/data-views-plugin", ] } diff --git a/x-pack/packages/kbn-entities-schema/src/schema/__snapshots__/common.test.ts.snap b/x-pack/packages/kbn-entities-schema/src/schema/__snapshots__/common.test.ts.snap index 9210d3b9991cf..766ce1c70ac3a 100644 --- a/x-pack/packages/kbn-entities-schema/src/schema/__snapshots__/common.test.ts.snap +++ b/x-pack/packages/kbn-entities-schema/src/schema/__snapshots__/common.test.ts.snap @@ -78,7 +78,8 @@ exports[`schemas metadataSchema should parse successfully with a source and desi Object { "data": Object { "aggregation": Object { - "limit": 1000, + "limit": 10, + "lookbackPeriod": undefined, "type": "terms", }, "destination": "hostName", @@ -92,7 +93,8 @@ exports[`schemas metadataSchema should parse successfully with an valid string 1 Object { "data": Object { "aggregation": Object { - "limit": 1000, + "limit": 10, + "lookbackPeriod": undefined, "type": "terms", }, "destination": "host.name", @@ -106,7 +108,8 @@ exports[`schemas metadataSchema should parse successfully with just a source 1`] Object { "data": Object { "aggregation": Object { - "limit": 1000, + "limit": 10, + "lookbackPeriod": undefined, "type": "terms", }, "destination": "host.name", @@ -120,7 +123,8 @@ exports[`schemas metadataSchema should parse successfully with valid object 1`] Object { "data": Object { "aggregation": Object { - "limit": 1000, + "limit": 10, + "lookbackPeriod": undefined, "type": "terms", }, "destination": "hostName", diff --git a/x-pack/packages/kbn-entities-schema/src/schema/common.test.ts b/x-pack/packages/kbn-entities-schema/src/schema/common.test.ts index 1a737ac3f4d9b..210e34943bd40 100644 --- a/x-pack/packages/kbn-entities-schema/src/schema/common.test.ts +++ b/x-pack/packages/kbn-entities-schema/src/schema/common.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { durationSchema, metadataSchema, semVerSchema, historySettingsSchema } from './common'; +import { durationSchema, metadataSchema, semVerSchema } from './common'; describe('schemas', () => { describe('metadataSchema', () => { @@ -66,7 +66,7 @@ describe('schemas', () => { expect(result.data).toEqual({ source: 'host.name', destination: 'hostName', - aggregation: { type: 'terms', limit: 1000 }, + aggregation: { type: 'terms', limit: 10, lookbackPeriod: undefined }, }); }); @@ -139,30 +139,4 @@ describe('schemas', () => { expect(result).toMatchSnapshot(); }); }); - - describe('historySettingsSchema', () => { - it('should return default values when not defined', () => { - let result = historySettingsSchema.safeParse(undefined); - expect(result.success).toBeTruthy(); - expect(result.data).toEqual({ lookbackPeriod: '1h' }); - - result = historySettingsSchema.safeParse({ syncDelay: '1m' }); - expect(result.success).toBeTruthy(); - expect(result.data).toEqual({ syncDelay: '1m', lookbackPeriod: '1h' }); - }); - - it('should return user defined values when defined', () => { - const result = historySettingsSchema.safeParse({ - lookbackPeriod: '30m', - syncField: 'event.ingested', - syncDelay: '5m', - }); - expect(result.success).toBeTruthy(); - expect(result.data).toEqual({ - lookbackPeriod: '30m', - syncField: 'event.ingested', - syncDelay: '5m', - }); - }); - }); }); diff --git a/x-pack/packages/kbn-entities-schema/src/schema/common.ts b/x-pack/packages/kbn-entities-schema/src/schema/common.ts index aa54dbd16c9aa..caecf48d88aac 100644 --- a/x-pack/packages/kbn-entities-schema/src/schema/common.ts +++ b/x-pack/packages/kbn-entities-schema/src/schema/common.ts @@ -85,7 +85,11 @@ export const keyMetricSchema = z.object({ export type KeyMetric = z.infer; export const metadataAggregation = z.union([ - z.object({ type: z.literal('terms'), limit: z.number().default(1000) }), + z.object({ + type: z.literal('terms'), + limit: z.number().default(10), + lookbackPeriod: z.optional(durationSchema), + }), z.object({ type: z.literal('top_value'), sort: z.record(z.string(), z.union([z.literal('asc'), z.literal('desc')])), @@ -99,13 +103,13 @@ export const metadataSchema = z destination: z.optional(z.string()), aggregation: z .optional(metadataAggregation) - .default({ type: z.literal('terms').value, limit: 1000 }), + .default({ type: z.literal('terms').value, limit: 10, lookbackPeriod: undefined }), }) .or( z.string().transform((value) => ({ source: value, destination: value, - aggregation: { type: z.literal('terms').value, limit: 1000 }, + aggregation: { type: z.literal('terms').value, limit: 10, lookbackPeriod: undefined }, })) ) .transform((metadata) => ({ diff --git a/x-pack/packages/kbn-entities-schema/src/schema/entity.ts b/x-pack/packages/kbn-entities-schema/src/schema/entity.ts index eae6873356c14..3eb87a797ef21 100644 --- a/x-pack/packages/kbn-entities-schema/src/schema/entity.ts +++ b/x-pack/packages/kbn-entities-schema/src/schema/entity.ts @@ -35,7 +35,6 @@ export const entityLatestSchema = z entity: entityBaseSchema.merge( z.object({ lastSeenTimestamp: z.string(), - firstSeenTimestamp: z.string(), }) ), }) diff --git a/x-pack/packages/kbn-entities-schema/src/schema/entity_definition.ts b/x-pack/packages/kbn-entities-schema/src/schema/entity_definition.ts index 74be36cc5d802..d9d8e6b610013 100644 --- a/x-pack/packages/kbn-entities-schema/src/schema/entity_definition.ts +++ b/x-pack/packages/kbn-entities-schema/src/schema/entity_definition.ts @@ -14,8 +14,6 @@ import { durationSchema, identityFieldsSchema, semVerSchema, - historySettingsSchema, - durationSchemaWithMinimum, } from './common'; export const entityDefinitionSchema = z.object({ @@ -32,22 +30,17 @@ export const entityDefinitionSchema = z.object({ metrics: z.optional(z.array(keyMetricSchema)), staticFields: z.optional(z.record(z.string(), z.string())), managed: z.optional(z.boolean()).default(false), - history: z.object({ + latest: z.object({ timestampField: z.string(), - interval: durationSchemaWithMinimum(1), - settings: historySettingsSchema, + lookbackPeriod: z.optional(durationSchema).default('24h'), + settings: z.optional( + z.object({ + syncField: z.optional(z.string()), + syncDelay: z.optional(durationSchema), + frequency: z.optional(durationSchema), + }) + ), }), - latest: z.optional( - z.object({ - settings: z.optional( - z.object({ - syncField: z.optional(z.string()), - syncDelay: z.optional(durationSchema), - frequency: z.optional(durationSchema), - }) - ), - }) - ), installStatus: z.optional( z.union([ z.literal('installing'), @@ -57,6 +50,18 @@ export const entityDefinitionSchema = z.object({ ]) ), installStartedAt: z.optional(z.string()), + installedComponents: z.optional( + z.array( + z.object({ + type: z.union([ + z.literal('transform'), + z.literal('ingest_pipeline'), + z.literal('template'), + ]), + id: z.string(), + }) + ) + ), }); export const entityDefinitionUpdateSchema = entityDefinitionSchema @@ -69,7 +74,7 @@ export const entityDefinitionUpdateSchema = entityDefinitionSchema .partial() .merge( z.object({ - history: z.optional(entityDefinitionSchema.shape.history.partial()), + latest: z.optional(entityDefinitionSchema.shape.latest.partial()), version: semVerSchema, }) ); diff --git a/x-pack/packages/kbn-langchain/server/language_models/chat_vertex/chat_vertex.test.ts b/x-pack/packages/kbn-langchain/server/language_models/chat_vertex/chat_vertex.test.ts index 37506922ff69b..07fe252bd5074 100644 --- a/x-pack/packages/kbn-langchain/server/language_models/chat_vertex/chat_vertex.test.ts +++ b/x-pack/packages/kbn-langchain/server/language_models/chat_vertex/chat_vertex.test.ts @@ -12,6 +12,7 @@ import { actionsClientMock } from '@kbn/actions-plugin/server/actions_client/act import { BaseMessage, HumanMessage, SystemMessage } from '@langchain/core/messages'; import { ActionsClientChatVertexAI } from './chat_vertex'; import { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager'; +import { GeminiContent } from '@langchain/google-common'; const connectorId = 'mock-connector-id'; @@ -54,8 +55,10 @@ const mockStreamExecute = jest.fn().mockImplementation(() => { }; }); +const systemInstruction = 'Answer the following questions truthfully and as best you can.'; + const callMessages = [ - new SystemMessage('Answer the following questions truthfully and as best you can.'), + new SystemMessage(systemInstruction), new HumanMessage('Question: Do you know my name?\n\n'), ] as unknown as BaseMessage[]; @@ -196,4 +199,32 @@ describe('ActionsClientChatVertexAI', () => { expect(handleLLMNewToken).toHaveBeenCalledWith('token3'); }); }); + + describe('message formatting', () => { + it('Properly sorts out the system role', async () => { + const actionsClientChatVertexAI = new ActionsClientChatVertexAI(defaultArgs); + + await actionsClientChatVertexAI._generate(callMessages, callOptions, callRunManager); + const params = actionsClient.execute.mock.calls[0][0].params.subActionParams as unknown as { + messages: GeminiContent[]; + systemInstruction: string; + }; + expect(params.messages.length).toEqual(1); + expect(params.messages[0].parts.length).toEqual(1); + expect(params.systemInstruction).toEqual(systemInstruction); + }); + it('Handles 2 messages in a row from the same role', async () => { + const actionsClientChatVertexAI = new ActionsClientChatVertexAI(defaultArgs); + + await actionsClientChatVertexAI._generate( + [...callMessages, new HumanMessage('Oh boy, another')], + callOptions, + callRunManager + ); + const { messages } = actionsClient.execute.mock.calls[0][0].params + .subActionParams as unknown as { messages: GeminiContent[] }; + expect(messages.length).toEqual(1); + expect(messages[0].parts.length).toEqual(2); + }); + }); }); diff --git a/x-pack/packages/kbn-langchain/server/language_models/chat_vertex/connection.ts b/x-pack/packages/kbn-langchain/server/language_models/chat_vertex/connection.ts index 0340d71b438db..dd3c1e1abdda0 100644 --- a/x-pack/packages/kbn-langchain/server/language_models/chat_vertex/connection.ts +++ b/x-pack/packages/kbn-langchain/server/language_models/chat_vertex/connection.ts @@ -7,6 +7,7 @@ import { ChatConnection, + GeminiContent, GoogleAbstractedClient, GoogleAIBaseLLMInput, GoogleLLMResponse, @@ -39,6 +40,22 @@ export class ActionsClientChatConnection extends ChatConnection { this.caller = caller; this.#model = fields.model; this.temperature = fields.temperature ?? 0; + const nativeFormatData = this.formatData.bind(this); + this.formatData = async (data, options) => { + const result = await nativeFormatData(data, options); + if (result?.contents != null && result?.contents.length) { + // ensure there are not 2 messages in a row from the same role, + // if there are combine them + result.contents = result.contents.reduce((acc: GeminiContent[], currentEntry) => { + if (currentEntry.role === acc[acc.length - 1]?.role) { + acc[acc.length - 1].parts = acc[acc.length - 1].parts.concat(currentEntry.parts); + return acc; + } + return [...acc, currentEntry]; + }, []); + } + return result; + }; } async _request( diff --git a/x-pack/packages/observability/logs_overview/README.md b/x-pack/packages/observability/logs_overview/README.md new file mode 100644 index 0000000000000..20d3f0f02b7df --- /dev/null +++ b/x-pack/packages/observability/logs_overview/README.md @@ -0,0 +1,3 @@ +# @kbn/observability-logs-overview + +Empty package generated by @kbn/generate diff --git a/x-pack/packages/observability/logs_overview/index.ts b/x-pack/packages/observability/logs_overview/index.ts new file mode 100644 index 0000000000000..057d1d3acd152 --- /dev/null +++ b/x-pack/packages/observability/logs_overview/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { + LogsOverview, + LogsOverviewErrorContent, + LogsOverviewLoadingContent, + type LogsOverviewDependencies, + type LogsOverviewErrorContentProps, + type LogsOverviewProps, +} from './src/components/logs_overview'; +export type { + DataViewLogsSourceConfiguration, + IndexNameLogsSourceConfiguration, + LogsSourceConfiguration, + SharedSettingLogsSourceConfiguration, +} from './src/utils/logs_source'; diff --git a/x-pack/packages/observability/logs_overview/jest.config.js b/x-pack/packages/observability/logs_overview/jest.config.js new file mode 100644 index 0000000000000..2ee88ee990253 --- /dev/null +++ b/x-pack/packages/observability/logs_overview/jest.config.js @@ -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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../..', + roots: ['/x-pack/packages/observability/logs_overview'], +}; diff --git a/x-pack/packages/observability/logs_overview/kibana.jsonc b/x-pack/packages/observability/logs_overview/kibana.jsonc new file mode 100644 index 0000000000000..90b3375086720 --- /dev/null +++ b/x-pack/packages/observability/logs_overview/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-browser", + "id": "@kbn/observability-logs-overview", + "owner": "@elastic/obs-ux-logs-team" +} diff --git a/x-pack/packages/observability/logs_overview/package.json b/x-pack/packages/observability/logs_overview/package.json new file mode 100644 index 0000000000000..77a529e7e59f7 --- /dev/null +++ b/x-pack/packages/observability/logs_overview/package.json @@ -0,0 +1,7 @@ +{ + "name": "@kbn/observability-logs-overview", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0", + "sideEffects": false +} diff --git a/x-pack/packages/observability/logs_overview/src/components/discover_link/discover_link.tsx b/x-pack/packages/observability/logs_overview/src/components/discover_link/discover_link.tsx new file mode 100644 index 0000000000000..fe108289985a9 --- /dev/null +++ b/x-pack/packages/observability/logs_overview/src/components/discover_link/discover_link.tsx @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import { EuiButton } from '@elastic/eui'; +import type { DiscoverAppLocatorParams } from '@kbn/discover-plugin/common'; +import { FilterStateStore, buildCustomFilter } from '@kbn/es-query'; +import { i18n } from '@kbn/i18n'; +import { getRouterLinkProps } from '@kbn/router-utils'; +import type { SharePluginStart } from '@kbn/share-plugin/public'; +import React, { useCallback, useMemo } from 'react'; +import type { IndexNameLogsSourceConfiguration } from '../../utils/logs_source'; + +export interface DiscoverLinkProps { + documentFilters?: QueryDslQueryContainer[]; + logsSource: IndexNameLogsSourceConfiguration; + timeRange: { + start: string; + end: string; + }; + dependencies: DiscoverLinkDependencies; +} + +export interface DiscoverLinkDependencies { + share: SharePluginStart; +} + +export const DiscoverLink = React.memo( + ({ dependencies: { share }, documentFilters, logsSource, timeRange }: DiscoverLinkProps) => { + const discoverLocatorParams = useMemo( + () => ({ + dataViewSpec: { + id: logsSource.indexName, + name: logsSource.indexName, + title: logsSource.indexName, + timeFieldName: logsSource.timestampField, + }, + timeRange: { + from: timeRange.start, + to: timeRange.end, + }, + filters: documentFilters?.map((filter) => + buildCustomFilter( + logsSource.indexName, + filter, + false, + false, + categorizedLogsFilterLabel, + FilterStateStore.APP_STATE + ) + ), + }), + [ + documentFilters, + logsSource.indexName, + logsSource.timestampField, + timeRange.end, + timeRange.start, + ] + ); + + const discoverLocator = useMemo( + () => share.url.locators.get('DISCOVER_APP_LOCATOR'), + [share.url.locators] + ); + + const discoverUrl = useMemo( + () => discoverLocator?.getRedirectUrl(discoverLocatorParams), + [discoverLocatorParams, discoverLocator] + ); + + const navigateToDiscover = useCallback(() => { + discoverLocator?.navigate(discoverLocatorParams); + }, [discoverLocatorParams, discoverLocator]); + + const discoverLinkProps = getRouterLinkProps({ + href: discoverUrl, + onClick: navigateToDiscover, + }); + + return ( + + {discoverLinkTitle} + + ); + } +); + +export const discoverLinkTitle = i18n.translate( + 'xpack.observabilityLogsOverview.discoverLinkTitle', + { + defaultMessage: 'Open in Discover', + } +); + +export const categorizedLogsFilterLabel = i18n.translate( + 'xpack.observabilityLogsOverview.categorizedLogsFilterLabel', + { + defaultMessage: 'Categorized log entries', + } +); diff --git a/x-pack/plugins/security_solution_serverless/server/common/services/index.ts b/x-pack/packages/observability/logs_overview/src/components/discover_link/index.ts similarity index 79% rename from x-pack/plugins/security_solution_serverless/server/common/services/index.ts rename to x-pack/packages/observability/logs_overview/src/components/discover_link/index.ts index a76f6359f7e5b..738bf51d4529d 100644 --- a/x-pack/plugins/security_solution_serverless/server/common/services/index.ts +++ b/x-pack/packages/observability/logs_overview/src/components/discover_link/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { usageReportingService } from './usage_reporting_service'; +export * from './discover_link'; diff --git a/x-pack/packages/observability/logs_overview/src/components/log_categories/index.ts b/x-pack/packages/observability/logs_overview/src/components/log_categories/index.ts new file mode 100644 index 0000000000000..786475396237c --- /dev/null +++ b/x-pack/packages/observability/logs_overview/src/components/log_categories/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 * from './log_categories'; diff --git a/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories.tsx b/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories.tsx new file mode 100644 index 0000000000000..6204667827281 --- /dev/null +++ b/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories.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 { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import { ISearchGeneric } from '@kbn/search-types'; +import { createConsoleInspector } from '@kbn/xstate-utils'; +import { useMachine } from '@xstate5/react'; +import React, { useCallback } from 'react'; +import { + categorizeLogsService, + createCategorizeLogsServiceImplementations, +} from '../../services/categorize_logs_service'; +import { IndexNameLogsSourceConfiguration } from '../../utils/logs_source'; +import { LogCategoriesErrorContent } from './log_categories_error_content'; +import { LogCategoriesLoadingContent } from './log_categories_loading_content'; +import { + LogCategoriesResultContent, + LogCategoriesResultContentDependencies, +} from './log_categories_result_content'; + +export interface LogCategoriesProps { + dependencies: LogCategoriesDependencies; + documentFilters?: QueryDslQueryContainer[]; + logsSource: IndexNameLogsSourceConfiguration; + // The time range could be made optional if we want to support an internal + // time range picker + timeRange: { + start: string; + end: string; + }; +} + +export type LogCategoriesDependencies = LogCategoriesResultContentDependencies & { + search: ISearchGeneric; +}; + +export const LogCategories: React.FC = ({ + dependencies, + documentFilters = [], + logsSource, + timeRange, +}) => { + const [categorizeLogsServiceState, sendToCategorizeLogsService] = useMachine( + categorizeLogsService.provide( + createCategorizeLogsServiceImplementations({ search: dependencies.search }) + ), + { + inspect: consoleInspector, + input: { + index: logsSource.indexName, + startTimestamp: timeRange.start, + endTimestamp: timeRange.end, + timeField: logsSource.timestampField, + messageField: logsSource.messageField, + documentFilters, + }, + } + ); + + const cancelOperation = useCallback(() => { + sendToCategorizeLogsService({ + type: 'cancel', + }); + }, [sendToCategorizeLogsService]); + + if (categorizeLogsServiceState.matches('done')) { + return ( + + ); + } else if (categorizeLogsServiceState.matches('failed')) { + return ; + } else if (categorizeLogsServiceState.matches('countingDocuments')) { + return ; + } else if ( + categorizeLogsServiceState.matches('fetchingSampledCategories') || + categorizeLogsServiceState.matches('fetchingRemainingCategories') + ) { + return ; + } else { + return null; + } +}; + +const consoleInspector = createConsoleInspector(); diff --git a/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_control_bar.tsx b/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_control_bar.tsx new file mode 100644 index 0000000000000..4538b0ec2fd5d --- /dev/null +++ b/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_control_bar.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import type { SharePluginStart } from '@kbn/share-plugin/public'; +import React from 'react'; +import type { IndexNameLogsSourceConfiguration } from '../../utils/logs_source'; +import { DiscoverLink } from '../discover_link'; + +export interface LogCategoriesControlBarProps { + documentFilters?: QueryDslQueryContainer[]; + logsSource: IndexNameLogsSourceConfiguration; + timeRange: { + start: string; + end: string; + }; + dependencies: LogCategoriesControlBarDependencies; +} + +export interface LogCategoriesControlBarDependencies { + share: SharePluginStart; +} + +export const LogCategoriesControlBar: React.FC = React.memo( + ({ dependencies, documentFilters, logsSource, timeRange }) => { + return ( + + + + + + ); + } +); diff --git a/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_error_content.tsx b/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_error_content.tsx new file mode 100644 index 0000000000000..1a335e3265294 --- /dev/null +++ b/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_error_content.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiCodeBlock, EuiEmptyPrompt } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +export interface LogCategoriesErrorContentProps { + error?: Error; +} + +export const LogCategoriesErrorContent: React.FC = ({ error }) => { + return ( + {logsOverviewErrorTitle}} + body={ + +

    {error?.stack ?? error?.toString() ?? unknownErrorDescription}

    +
    + } + layout="vertical" + /> + ); +}; + +const logsOverviewErrorTitle = i18n.translate( + 'xpack.observabilityLogsOverview.logCategories.errorTitle', + { + defaultMessage: 'Failed to categorize logs', + } +); + +const unknownErrorDescription = i18n.translate( + 'xpack.observabilityLogsOverview.logCategories.unknownErrorDescription', + { + defaultMessage: 'An unspecified error occurred.', + } +); diff --git a/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_grid.tsx b/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_grid.tsx new file mode 100644 index 0000000000000..d9e960685de99 --- /dev/null +++ b/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_grid.tsx @@ -0,0 +1,182 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiDataGrid, + EuiDataGridColumnSortingConfig, + EuiDataGridPaginationProps, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { createConsoleInspector } from '@kbn/xstate-utils'; +import { useMachine } from '@xstate5/react'; +import _ from 'lodash'; +import React, { useMemo } from 'react'; +import { assign, setup } from 'xstate5'; +import { LogCategory } from '../../types'; +import { + LogCategoriesGridCellDependencies, + LogCategoriesGridColumnId, + createCellContext, + logCategoriesGridColumnIds, + logCategoriesGridColumns, + renderLogCategoriesGridCell, +} from './log_categories_grid_cell'; + +export interface LogCategoriesGridProps { + dependencies: LogCategoriesGridDependencies; + logCategories: LogCategory[]; +} + +export type LogCategoriesGridDependencies = LogCategoriesGridCellDependencies; + +export const LogCategoriesGrid: React.FC = ({ + dependencies, + logCategories, +}) => { + const [gridState, dispatchGridEvent] = useMachine(gridStateService, { + input: { + visibleColumns: logCategoriesGridColumns.map(({ id }) => id), + }, + inspect: consoleInspector, + }); + + const sortedLogCategories = useMemo(() => { + const sortingCriteria = gridState.context.sortingColumns.map( + ({ id, direction }): [(logCategory: LogCategory) => any, 'asc' | 'desc'] => { + switch (id) { + case 'count': + return [(logCategory: LogCategory) => logCategory.documentCount, direction]; + case 'change_type': + // TODO: use better sorting weight for change types + return [(logCategory: LogCategory) => logCategory.change.type, direction]; + case 'change_time': + return [ + (logCategory: LogCategory) => + 'timestamp' in logCategory.change ? logCategory.change.timestamp ?? '' : '', + direction, + ]; + default: + return [_.identity, direction]; + } + } + ); + return _.orderBy( + logCategories, + sortingCriteria.map(([accessor]) => accessor), + sortingCriteria.map(([, direction]) => direction) + ); + }, [gridState.context.sortingColumns, logCategories]); + + return ( + + dispatchGridEvent({ type: 'changeVisibleColumns', visibleColumns }), + }} + cellContext={createCellContext(sortedLogCategories, dependencies)} + pagination={{ + ...gridState.context.pagination, + onChangeItemsPerPage: (pageSize) => dispatchGridEvent({ type: 'changePageSize', pageSize }), + onChangePage: (pageIndex) => dispatchGridEvent({ type: 'changePageIndex', pageIndex }), + }} + renderCellValue={renderLogCategoriesGridCell} + rowCount={sortedLogCategories.length} + sorting={{ + columns: gridState.context.sortingColumns, + onSort: (sortingColumns) => + dispatchGridEvent({ type: 'changeSortingColumns', sortingColumns }), + }} + /> + ); +}; + +const gridStateService = setup({ + types: { + context: {} as { + visibleColumns: string[]; + pagination: Pick; + sortingColumns: LogCategoriesGridSortingConfig[]; + }, + events: {} as + | { + type: 'changePageSize'; + pageSize: number; + } + | { + type: 'changePageIndex'; + pageIndex: number; + } + | { + type: 'changeSortingColumns'; + sortingColumns: EuiDataGridColumnSortingConfig[]; + } + | { + type: 'changeVisibleColumns'; + visibleColumns: string[]; + }, + input: {} as { + visibleColumns: string[]; + }, + }, +}).createMachine({ + id: 'logCategoriesGridState', + context: ({ input }) => ({ + visibleColumns: input.visibleColumns, + pagination: { pageIndex: 0, pageSize: 20, pageSizeOptions: [10, 20, 50] }, + sortingColumns: [{ id: 'change_time', direction: 'desc' }], + }), + on: { + changePageSize: { + actions: assign(({ context, event }) => ({ + pagination: { + ...context.pagination, + pageIndex: 0, + pageSize: event.pageSize, + }, + })), + }, + changePageIndex: { + actions: assign(({ context, event }) => ({ + pagination: { + ...context.pagination, + pageIndex: event.pageIndex, + }, + })), + }, + changeSortingColumns: { + actions: assign(({ event }) => ({ + sortingColumns: event.sortingColumns.filter( + (sortingConfig): sortingConfig is LogCategoriesGridSortingConfig => + (logCategoriesGridColumnIds as string[]).includes(sortingConfig.id) + ), + })), + }, + changeVisibleColumns: { + actions: assign(({ event }) => ({ + visibleColumns: event.visibleColumns, + })), + }, + }, +}); + +const consoleInspector = createConsoleInspector(); + +const logCategoriesGridLabel = i18n.translate( + 'xpack.observabilityLogsOverview.logCategoriesGrid.euiDataGrid.logCategoriesLabel', + { defaultMessage: 'Log categories' } +); + +interface TypedEuiDataGridColumnSortingConfig + extends EuiDataGridColumnSortingConfig { + id: ColumnId; +} + +type LogCategoriesGridSortingConfig = + TypedEuiDataGridColumnSortingConfig; diff --git a/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_grid_cell.tsx b/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_grid_cell.tsx new file mode 100644 index 0000000000000..d6ab4969eaf7b --- /dev/null +++ b/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_grid_cell.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiDataGridColumn, RenderCellValue } from '@elastic/eui'; +import React from 'react'; +import { LogCategory } from '../../types'; +import { + LogCategoriesGridChangeTimeCell, + LogCategoriesGridChangeTimeCellDependencies, + logCategoriesGridChangeTimeColumn, +} from './log_categories_grid_change_time_cell'; +import { + LogCategoriesGridChangeTypeCell, + logCategoriesGridChangeTypeColumn, +} from './log_categories_grid_change_type_cell'; +import { + LogCategoriesGridCountCell, + logCategoriesGridCountColumn, +} from './log_categories_grid_count_cell'; +import { + LogCategoriesGridHistogramCell, + LogCategoriesGridHistogramCellDependencies, + logCategoriesGridHistoryColumn, +} from './log_categories_grid_histogram_cell'; +import { + LogCategoriesGridPatternCell, + logCategoriesGridPatternColumn, +} from './log_categories_grid_pattern_cell'; + +export interface LogCategoriesGridCellContext { + dependencies: LogCategoriesGridCellDependencies; + logCategories: LogCategory[]; +} + +export type LogCategoriesGridCellDependencies = LogCategoriesGridHistogramCellDependencies & + LogCategoriesGridChangeTimeCellDependencies; + +export const renderLogCategoriesGridCell: RenderCellValue = ({ + rowIndex, + columnId, + isExpanded, + ...rest +}) => { + const { dependencies, logCategories } = getCellContext(rest); + + const logCategory = logCategories[rowIndex]; + + switch (columnId as LogCategoriesGridColumnId) { + case 'pattern': + return ; + case 'count': + return ; + case 'history': + return ( + + ); + case 'change_type': + return ; + case 'change_time': + return ( + + ); + default: + return <>-; + } +}; + +export const logCategoriesGridColumns = [ + logCategoriesGridPatternColumn, + logCategoriesGridCountColumn, + logCategoriesGridChangeTypeColumn, + logCategoriesGridChangeTimeColumn, + logCategoriesGridHistoryColumn, +] satisfies EuiDataGridColumn[]; + +export const logCategoriesGridColumnIds = logCategoriesGridColumns.map(({ id }) => id); + +export type LogCategoriesGridColumnId = (typeof logCategoriesGridColumns)[number]['id']; + +const cellContextKey = 'cellContext'; + +const getCellContext = (cellContext: object): LogCategoriesGridCellContext => + (cellContextKey in cellContext + ? cellContext[cellContextKey] + : {}) as LogCategoriesGridCellContext; + +export const createCellContext = ( + logCategories: LogCategory[], + dependencies: LogCategoriesGridCellDependencies +): { [cellContextKey]: LogCategoriesGridCellContext } => ({ + [cellContextKey]: { + dependencies, + logCategories, + }, +}); diff --git a/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_grid_change_time_cell.tsx b/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_grid_change_time_cell.tsx new file mode 100644 index 0000000000000..5ad8cbdd49346 --- /dev/null +++ b/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_grid_change_time_cell.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 { EuiDataGridColumn } from '@elastic/eui'; +import { SettingsStart } from '@kbn/core-ui-settings-browser'; +import { i18n } from '@kbn/i18n'; +import moment from 'moment'; +import React, { useMemo } from 'react'; +import { LogCategory } from '../../types'; + +export const logCategoriesGridChangeTimeColumn = { + id: 'change_time' as const, + display: i18n.translate( + 'xpack.observabilityLogsOverview.logCategoriesGrid.changeTimeColumnLabel', + { + defaultMessage: 'Change at', + } + ), + isSortable: true, + initialWidth: 220, + schema: 'datetime', +} satisfies EuiDataGridColumn; + +export interface LogCategoriesGridChangeTimeCellProps { + dependencies: LogCategoriesGridChangeTimeCellDependencies; + logCategory: LogCategory; +} + +export interface LogCategoriesGridChangeTimeCellDependencies { + uiSettings: SettingsStart; +} + +export const LogCategoriesGridChangeTimeCell: React.FC = ({ + dependencies, + logCategory, +}) => { + const dateFormat = useMemo( + () => dependencies.uiSettings.client.get('dateFormat'), + [dependencies.uiSettings.client] + ); + if (!('timestamp' in logCategory.change && logCategory.change.timestamp != null)) { + return null; + } + + if (dateFormat) { + return <>{moment(logCategory.change.timestamp).format(dateFormat)}; + } else { + return <>{logCategory.change.timestamp}; + } +}; diff --git a/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_grid_change_type_cell.tsx b/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_grid_change_type_cell.tsx new file mode 100644 index 0000000000000..af6349bd0e18c --- /dev/null +++ b/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_grid_change_type_cell.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 { EuiBadge, EuiDataGridColumn } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { LogCategory } from '../../types'; + +export const logCategoriesGridChangeTypeColumn = { + id: 'change_type' as const, + display: i18n.translate( + 'xpack.observabilityLogsOverview.logCategoriesGrid.changeTypeColumnLabel', + { + defaultMessage: 'Change type', + } + ), + isSortable: true, + initialWidth: 110, +} satisfies EuiDataGridColumn; + +export interface LogCategoriesGridChangeTypeCellProps { + logCategory: LogCategory; +} + +export const LogCategoriesGridChangeTypeCell: React.FC = ({ + logCategory, +}) => { + switch (logCategory.change.type) { + case 'dip': + return {dipBadgeLabel}; + case 'spike': + return {spikeBadgeLabel}; + case 'step': + return {stepBadgeLabel}; + case 'distribution': + return {distributionBadgeLabel}; + case 'rare': + return {rareBadgeLabel}; + case 'trend': + return {trendBadgeLabel}; + case 'other': + return {otherBadgeLabel}; + case 'none': + return <>-; + default: + return {unknownBadgeLabel}; + } +}; + +const dipBadgeLabel = i18n.translate( + 'xpack.observabilityLogsOverview.logCategories.dipChangeTypeBadgeLabel', + { + defaultMessage: 'Dip', + } +); + +const spikeBadgeLabel = i18n.translate( + 'xpack.observabilityLogsOverview.logCategories.spikeChangeTypeBadgeLabel', + { + defaultMessage: 'Spike', + } +); + +const stepBadgeLabel = i18n.translate( + 'xpack.observabilityLogsOverview.logCategories.spikeChangeTypeBadgeLabel', + { + defaultMessage: 'Step', + } +); + +const distributionBadgeLabel = i18n.translate( + 'xpack.observabilityLogsOverview.logCategories.distributionChangeTypeBadgeLabel', + { + defaultMessage: 'Distribution', + } +); + +const trendBadgeLabel = i18n.translate( + 'xpack.observabilityLogsOverview.logCategories.spikeChangeTypeBadgeLabel', + { + defaultMessage: 'Trend', + } +); + +const otherBadgeLabel = i18n.translate( + 'xpack.observabilityLogsOverview.logCategories.otherChangeTypeBadgeLabel', + { + defaultMessage: 'Other', + } +); + +const unknownBadgeLabel = i18n.translate( + 'xpack.observabilityLogsOverview.logCategories.unknownChangeTypeBadgeLabel', + { + defaultMessage: 'Unknown', + } +); + +const rareBadgeLabel = i18n.translate( + 'xpack.observabilityLogsOverview.logCategories.rareChangeTypeBadgeLabel', + { + defaultMessage: 'Rare', + } +); diff --git a/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_grid_count_cell.tsx b/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_grid_count_cell.tsx new file mode 100644 index 0000000000000..f2247aab5212e --- /dev/null +++ b/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_grid_count_cell.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 { EuiDataGridColumn } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedNumber } from '@kbn/i18n-react'; +import React from 'react'; +import { LogCategory } from '../../types'; + +export const logCategoriesGridCountColumn = { + id: 'count' as const, + display: i18n.translate('xpack.observabilityLogsOverview.logCategoriesGrid.countColumnLabel', { + defaultMessage: 'Events', + }), + isSortable: true, + schema: 'numeric', + initialWidth: 100, +} satisfies EuiDataGridColumn; + +export interface LogCategoriesGridCountCellProps { + logCategory: LogCategory; +} + +export const LogCategoriesGridCountCell: React.FC = ({ + logCategory, +}) => { + return ; +}; diff --git a/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_grid_histogram_cell.tsx b/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_grid_histogram_cell.tsx new file mode 100644 index 0000000000000..2fb50b0f2f3b4 --- /dev/null +++ b/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_grid_histogram_cell.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + BarSeries, + Chart, + LineAnnotation, + LineAnnotationStyle, + PartialTheme, + Settings, + Tooltip, + TooltipType, +} from '@elastic/charts'; +import { EuiDataGridColumn } from '@elastic/eui'; +import { ChartsPluginStart } from '@kbn/charts-plugin/public'; +import { i18n } from '@kbn/i18n'; +import { RecursivePartial } from '@kbn/utility-types'; +import React from 'react'; +import { LogCategory, LogCategoryHistogramBucket } from '../../types'; + +export const logCategoriesGridHistoryColumn = { + id: 'history' as const, + display: i18n.translate( + 'xpack.observabilityLogsOverview.logCategoriesGrid.histogramColumnLabel', + { + defaultMessage: 'Timeline', + } + ), + isSortable: false, + initialWidth: 250, + isExpandable: false, +} satisfies EuiDataGridColumn; + +export interface LogCategoriesGridHistogramCellProps { + dependencies: LogCategoriesGridHistogramCellDependencies; + logCategory: LogCategory; +} + +export interface LogCategoriesGridHistogramCellDependencies { + charts: ChartsPluginStart; +} + +export const LogCategoriesGridHistogramCell: React.FC = ({ + dependencies: { charts }, + logCategory, +}) => { + const baseTheme = charts.theme.useChartsBaseTheme(); + const sparklineTheme = charts.theme.useSparklineOverrides(); + + return ( + + + + + {'timestamp' in logCategory.change && logCategory.change.timestamp != null ? ( + + ) : null} + + ); +}; + +const localThemeOverrides: PartialTheme = { + scales: { + histogramPadding: 0.1, + }, + background: { + color: 'transparent', + }, +}; + +const annotationStyle: RecursivePartial = { + line: { + strokeWidth: 2, + }, +}; + +const timestampAccessor = (histogram: LogCategoryHistogramBucket) => + new Date(histogram.timestamp).getTime(); diff --git a/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_grid_pattern_cell.tsx b/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_grid_pattern_cell.tsx new file mode 100644 index 0000000000000..d507487a99e3c --- /dev/null +++ b/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_grid_pattern_cell.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiDataGridColumn, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import React, { useMemo } from 'react'; +import { LogCategory } from '../../types'; + +export const logCategoriesGridPatternColumn = { + id: 'pattern' as const, + display: i18n.translate('xpack.observabilityLogsOverview.logCategoriesGrid.patternColumnLabel', { + defaultMessage: 'Pattern', + }), + isSortable: false, + schema: 'string', +} satisfies EuiDataGridColumn; + +export interface LogCategoriesGridPatternCellProps { + logCategory: LogCategory; +} + +export const LogCategoriesGridPatternCell: React.FC = ({ + logCategory, +}) => { + const theme = useEuiTheme(); + const { euiTheme } = theme; + const termsList = useMemo(() => logCategory.terms.split(' '), [logCategory.terms]); + + const commonStyle = css` + display: inline-block; + font-family: ${euiTheme.font.familyCode}; + margin-right: ${euiTheme.size.xs}; + `; + + const termStyle = css` + ${commonStyle}; + `; + + const separatorStyle = css` + ${commonStyle}; + color: ${euiTheme.colors.successText}; + `; + + return ( +
    +      
    *
    + {termsList.map((term, index) => ( + +
    {term}
    +
    *
    +
    + ))} +
    + ); +}; diff --git a/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_loading_content.tsx b/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_loading_content.tsx new file mode 100644 index 0000000000000..0fde469fe717d --- /dev/null +++ b/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_loading_content.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButton, EuiEmptyPrompt, EuiLoadingSpinner } from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; + +export interface LogCategoriesLoadingContentProps { + onCancel?: () => void; + stage: 'counting' | 'categorizing'; +} + +export const LogCategoriesLoadingContent: React.FC = ({ + onCancel, + stage, +}) => { + return ( + } + title={ +

    + {stage === 'counting' + ? logCategoriesLoadingStateCountingTitle + : logCategoriesLoadingStateCategorizingTitle} +

    + } + actions={ + onCancel != null + ? [ + { + onCancel(); + }} + > + {logCategoriesLoadingStateCancelButtonLabel} + , + ] + : [] + } + /> + ); +}; + +const logCategoriesLoadingStateCountingTitle = i18n.translate( + 'xpack.observabilityLogsOverview.logCategoriesGrid.loadingStageCountingTitle', + { + defaultMessage: 'Estimating log volume', + } +); + +const logCategoriesLoadingStateCategorizingTitle = i18n.translate( + 'xpack.observabilityLogsOverview.logCategoriesGrid.loadingStageCategorizingTitle', + { + defaultMessage: 'Categorizing logs', + } +); + +const logCategoriesLoadingStateCancelButtonLabel = i18n.translate( + 'xpack.observabilityLogsOverview.logCategoriesGrid.loadingStateCancelButtonLabel', + { + defaultMessage: 'Cancel', + } +); diff --git a/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_result_content.tsx b/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_result_content.tsx new file mode 100644 index 0000000000000..e16bdda7cb44a --- /dev/null +++ b/x-pack/packages/observability/logs_overview/src/components/log_categories/log_categories_result_content.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import { EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { LogCategory } from '../../types'; +import { IndexNameLogsSourceConfiguration } from '../../utils/logs_source'; +import { + LogCategoriesControlBar, + LogCategoriesControlBarDependencies, +} from './log_categories_control_bar'; +import { LogCategoriesGrid, LogCategoriesGridDependencies } from './log_categories_grid'; + +export interface LogCategoriesResultContentProps { + dependencies: LogCategoriesResultContentDependencies; + documentFilters?: QueryDslQueryContainer[]; + logCategories: LogCategory[]; + logsSource: IndexNameLogsSourceConfiguration; + timeRange: { + start: string; + end: string; + }; +} + +export type LogCategoriesResultContentDependencies = LogCategoriesControlBarDependencies & + LogCategoriesGridDependencies; + +export const LogCategoriesResultContent: React.FC = ({ + dependencies, + documentFilters, + logCategories, + logsSource, + timeRange, +}) => { + if (logCategories.length === 0) { + return ; + } else { + return ( + + + + + + + + + ); + } +}; + +export const LogCategoriesEmptyResultContent: React.FC = () => { + return ( + {emptyResultContentDescription}

    } + color="subdued" + layout="horizontal" + title={

    {emptyResultContentTitle}

    } + titleSize="m" + /> + ); +}; + +const emptyResultContentTitle = i18n.translate( + 'xpack.observabilityLogsOverview.logCategories.emptyResultContentTitle', + { + defaultMessage: 'No log categories found', + } +); + +const emptyResultContentDescription = i18n.translate( + 'xpack.observabilityLogsOverview.logCategories.emptyResultContentDescription', + { + defaultMessage: + 'No suitable documents within the time range. Try searching for a longer time period.', + } +); diff --git a/x-pack/packages/observability/logs_overview/src/components/logs_overview/index.ts b/x-pack/packages/observability/logs_overview/src/components/logs_overview/index.ts new file mode 100644 index 0000000000000..878f634f078ad --- /dev/null +++ b/x-pack/packages/observability/logs_overview/src/components/logs_overview/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 './logs_overview'; +export * from './logs_overview_error_content'; +export * from './logs_overview_loading_content'; diff --git a/x-pack/packages/observability/logs_overview/src/components/logs_overview/logs_overview.tsx b/x-pack/packages/observability/logs_overview/src/components/logs_overview/logs_overview.tsx new file mode 100644 index 0000000000000..988656eb1571e --- /dev/null +++ b/x-pack/packages/observability/logs_overview/src/components/logs_overview/logs_overview.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import { type LogsDataAccessPluginStart } from '@kbn/logs-data-access-plugin/public'; +import React from 'react'; +import useAsync from 'react-use/lib/useAsync'; +import { LogsSourceConfiguration, normalizeLogsSource } from '../../utils/logs_source'; +import { LogCategories, LogCategoriesDependencies } from '../log_categories'; +import { LogsOverviewErrorContent } from './logs_overview_error_content'; +import { LogsOverviewLoadingContent } from './logs_overview_loading_content'; + +export interface LogsOverviewProps { + dependencies: LogsOverviewDependencies; + documentFilters?: QueryDslQueryContainer[]; + logsSource?: LogsSourceConfiguration; + timeRange: { + start: string; + end: string; + }; +} + +export type LogsOverviewDependencies = LogCategoriesDependencies & { + logsDataAccess: LogsDataAccessPluginStart; +}; + +export const LogsOverview: React.FC = React.memo( + ({ + dependencies, + documentFilters = defaultDocumentFilters, + logsSource = defaultLogsSource, + timeRange, + }) => { + const normalizedLogsSource = useAsync( + () => normalizeLogsSource({ logsDataAccess: dependencies.logsDataAccess })(logsSource), + [dependencies.logsDataAccess, logsSource] + ); + + if (normalizedLogsSource.loading) { + return ; + } + + if (normalizedLogsSource.error != null || normalizedLogsSource.value == null) { + return ; + } + + return ( + + ); + } +); + +const defaultDocumentFilters: QueryDslQueryContainer[] = []; + +const defaultLogsSource: LogsSourceConfiguration = { type: 'shared_setting' }; diff --git a/x-pack/packages/observability/logs_overview/src/components/logs_overview/logs_overview_error_content.tsx b/x-pack/packages/observability/logs_overview/src/components/logs_overview/logs_overview_error_content.tsx new file mode 100644 index 0000000000000..73586756bb908 --- /dev/null +++ b/x-pack/packages/observability/logs_overview/src/components/logs_overview/logs_overview_error_content.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiCodeBlock, EuiEmptyPrompt } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +export interface LogsOverviewErrorContentProps { + error?: Error; +} + +export const LogsOverviewErrorContent: React.FC = ({ error }) => { + return ( + {logsOverviewErrorTitle}} + body={ + +

    {error?.stack ?? error?.toString() ?? unknownErrorDescription}

    +
    + } + layout="vertical" + /> + ); +}; + +const logsOverviewErrorTitle = i18n.translate('xpack.observabilityLogsOverview.errorTitle', { + defaultMessage: 'Error', +}); + +const unknownErrorDescription = i18n.translate( + 'xpack.observabilityLogsOverview.unknownErrorDescription', + { + defaultMessage: 'An unspecified error occurred.', + } +); diff --git a/x-pack/packages/observability/logs_overview/src/components/logs_overview/logs_overview_loading_content.tsx b/x-pack/packages/observability/logs_overview/src/components/logs_overview/logs_overview_loading_content.tsx new file mode 100644 index 0000000000000..7645fdb90f0ac --- /dev/null +++ b/x-pack/packages/observability/logs_overview/src/components/logs_overview/logs_overview_loading_content.tsx @@ -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 { EuiEmptyPrompt, EuiLoadingSpinner } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +export const LogsOverviewLoadingContent: React.FC = ({}) => { + return ( + } + title={

    {logsOverviewLoadingTitle}

    } + /> + ); +}; + +const logsOverviewLoadingTitle = i18n.translate('xpack.observabilityLogsOverview.loadingTitle', { + defaultMessage: 'Loading', +}); diff --git a/x-pack/packages/observability/logs_overview/src/services/categorize_logs_service/categorize_documents.ts b/x-pack/packages/observability/logs_overview/src/services/categorize_logs_service/categorize_documents.ts new file mode 100644 index 0000000000000..7260efe63d435 --- /dev/null +++ b/x-pack/packages/observability/logs_overview/src/services/categorize_logs_service/categorize_documents.ts @@ -0,0 +1,282 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ISearchGeneric } from '@kbn/search-types'; +import { lastValueFrom } from 'rxjs'; +import { fromPromise } from 'xstate5'; +import { createRandomSamplerWrapper } from '@kbn/ml-random-sampler-utils'; +import { z } from '@kbn/zod'; +import { LogCategorizationParams } from './types'; +import { createCategorizationRequestParams } from './queries'; +import { LogCategory, LogCategoryChange } from '../../types'; + +// the fraction of a category's histogram below which the category is considered rare +const rarityThreshold = 0.2; +const maxCategoriesCount = 1000; + +export const categorizeDocuments = ({ search }: { search: ISearchGeneric }) => + fromPromise< + { + categories: LogCategory[]; + hasReachedLimit: boolean; + }, + LogCategorizationParams & { + samplingProbability: number; + ignoredCategoryTerms: string[]; + minDocsPerCategory: number; + } + >( + async ({ + input: { + index, + endTimestamp, + startTimestamp, + timeField, + messageField, + samplingProbability, + ignoredCategoryTerms, + documentFilters = [], + minDocsPerCategory, + }, + signal, + }) => { + const randomSampler = createRandomSamplerWrapper({ + probability: samplingProbability, + seed: 1, + }); + + const requestParams = createCategorizationRequestParams({ + index, + timeField, + messageField, + startTimestamp, + endTimestamp, + randomSampler, + additionalFilters: documentFilters, + ignoredCategoryTerms, + minDocsPerCategory, + maxCategoriesCount, + }); + + const { rawResponse } = await lastValueFrom( + search({ params: requestParams }, { abortSignal: signal }) + ); + + if (rawResponse.aggregations == null) { + throw new Error('No aggregations found in large categories response'); + } + + const logCategoriesAggResult = randomSampler.unwrap(rawResponse.aggregations); + + if (!('categories' in logCategoriesAggResult)) { + throw new Error('No categorization aggregation found in large categories response'); + } + + const logCategories = + (logCategoriesAggResult.categories.buckets as unknown[]).map(mapCategoryBucket) ?? []; + + return { + categories: logCategories, + hasReachedLimit: logCategories.length >= maxCategoriesCount, + }; + } + ); + +const mapCategoryBucket = (bucket: any): LogCategory => + esCategoryBucketSchema + .transform((parsedBucket) => ({ + change: mapChangePoint(parsedBucket), + documentCount: parsedBucket.doc_count, + histogram: parsedBucket.histogram, + terms: parsedBucket.key, + })) + .parse(bucket); + +const mapChangePoint = ({ change, histogram }: EsCategoryBucket): LogCategoryChange => { + switch (change.type) { + case 'stationary': + if (isRareInHistogram(histogram)) { + return { + type: 'rare', + timestamp: findFirstNonZeroBucket(histogram)?.timestamp ?? histogram[0].timestamp, + }; + } else { + return { + type: 'none', + }; + } + case 'dip': + case 'spike': + return { + type: change.type, + timestamp: change.bucket.key, + }; + case 'step_change': + return { + type: 'step', + timestamp: change.bucket.key, + }; + case 'distribution_change': + return { + type: 'distribution', + timestamp: change.bucket.key, + }; + case 'trend_change': + return { + type: 'trend', + timestamp: change.bucket.key, + correlationCoefficient: change.details.r_value, + }; + case 'unknown': + return { + type: 'unknown', + rawChange: change.rawChange, + }; + case 'non_stationary': + default: + return { + type: 'other', + }; + } +}; + +/** + * The official types are lacking the change_point aggregation + */ +const esChangePointBucketSchema = z.object({ + key: z.string().datetime(), + doc_count: z.number(), +}); + +const esChangePointDetailsSchema = z.object({ + p_value: z.number(), +}); + +const esChangePointCorrelationSchema = esChangePointDetailsSchema.extend({ + r_value: z.number(), +}); + +const esChangePointSchema = z.union([ + z + .object({ + bucket: esChangePointBucketSchema, + type: z.object({ + dip: esChangePointDetailsSchema, + }), + }) + .transform(({ bucket, type: { dip: details } }) => ({ + type: 'dip' as const, + bucket, + details, + })), + z + .object({ + bucket: esChangePointBucketSchema, + type: z.object({ + spike: esChangePointDetailsSchema, + }), + }) + .transform(({ bucket, type: { spike: details } }) => ({ + type: 'spike' as const, + bucket, + details, + })), + z + .object({ + bucket: esChangePointBucketSchema, + type: z.object({ + step_change: esChangePointDetailsSchema, + }), + }) + .transform(({ bucket, type: { step_change: details } }) => ({ + type: 'step_change' as const, + bucket, + details, + })), + z + .object({ + bucket: esChangePointBucketSchema, + type: z.object({ + trend_change: esChangePointCorrelationSchema, + }), + }) + .transform(({ bucket, type: { trend_change: details } }) => ({ + type: 'trend_change' as const, + bucket, + details, + })), + z + .object({ + bucket: esChangePointBucketSchema, + type: z.object({ + distribution_change: esChangePointDetailsSchema, + }), + }) + .transform(({ bucket, type: { distribution_change: details } }) => ({ + type: 'distribution_change' as const, + bucket, + details, + })), + z + .object({ + type: z.object({ + non_stationary: esChangePointCorrelationSchema.extend({ + trend: z.enum(['increasing', 'decreasing']), + }), + }), + }) + .transform(({ type: { non_stationary: details } }) => ({ + type: 'non_stationary' as const, + details, + })), + z + .object({ + type: z.object({ + stationary: z.object({}), + }), + }) + .transform(() => ({ type: 'stationary' as const })), + z + .object({ + type: z.object({}), + }) + .transform((value) => ({ type: 'unknown' as const, rawChange: JSON.stringify(value) })), +]); + +const esHistogramSchema = z + .object({ + buckets: z.array( + z + .object({ + key_as_string: z.string(), + doc_count: z.number(), + }) + .transform((bucket) => ({ + timestamp: bucket.key_as_string, + documentCount: bucket.doc_count, + })) + ), + }) + .transform(({ buckets }) => buckets); + +type EsHistogram = z.output; + +const esCategoryBucketSchema = z.object({ + key: z.string(), + doc_count: z.number(), + change: esChangePointSchema, + histogram: esHistogramSchema, +}); + +type EsCategoryBucket = z.output; + +const isRareInHistogram = (histogram: EsHistogram): boolean => + histogram.filter((bucket) => bucket.documentCount > 0).length < + histogram.length * rarityThreshold; + +const findFirstNonZeroBucket = (histogram: EsHistogram) => + histogram.find((bucket) => bucket.documentCount > 0); diff --git a/x-pack/packages/observability/logs_overview/src/services/categorize_logs_service/categorize_logs_service.ts b/x-pack/packages/observability/logs_overview/src/services/categorize_logs_service/categorize_logs_service.ts new file mode 100644 index 0000000000000..deeb758d2d737 --- /dev/null +++ b/x-pack/packages/observability/logs_overview/src/services/categorize_logs_service/categorize_logs_service.ts @@ -0,0 +1,250 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { MachineImplementationsFrom, assign, setup } from 'xstate5'; +import { LogCategory } from '../../types'; +import { getPlaceholderFor } from '../../utils/xstate5_utils'; +import { categorizeDocuments } from './categorize_documents'; +import { countDocuments } from './count_documents'; +import { CategorizeLogsServiceDependencies, LogCategorizationParams } from './types'; + +export const categorizeLogsService = setup({ + types: { + input: {} as LogCategorizationParams, + output: {} as { + categories: LogCategory[]; + documentCount: number; + hasReachedLimit: boolean; + samplingProbability: number; + }, + context: {} as { + categories: LogCategory[]; + documentCount: number; + error?: Error; + hasReachedLimit: boolean; + parameters: LogCategorizationParams; + samplingProbability: number; + }, + events: {} as { + type: 'cancel'; + }, + }, + actors: { + countDocuments: getPlaceholderFor(countDocuments), + categorizeDocuments: getPlaceholderFor(categorizeDocuments), + }, + actions: { + storeError: assign((_, params: { error: unknown }) => ({ + error: params.error instanceof Error ? params.error : new Error(String(params.error)), + })), + storeCategories: assign( + ({ context }, params: { categories: LogCategory[]; hasReachedLimit: boolean }) => ({ + categories: [...context.categories, ...params.categories], + hasReachedLimit: params.hasReachedLimit, + }) + ), + storeDocumentCount: assign( + (_, params: { documentCount: number; samplingProbability: number }) => ({ + documentCount: params.documentCount, + samplingProbability: params.samplingProbability, + }) + ), + }, + guards: { + hasTooFewDocuments: (_guardArgs, params: { documentCount: number }) => params.documentCount < 1, + requiresSampling: (_guardArgs, params: { samplingProbability: number }) => + params.samplingProbability < 1, + }, +}).createMachine({ + /** @xstate-layout N4IgpgJg5mDOIC5QGMCGAXMUD2AnAlgF5gAy2UsAdMtgK4B26+9UAItsrQLZiOwDEEbPTCVmAN2wBrUWkw4CxMhWp1GzNh2690sBBI4Z8wgNoAGALrmLiUAAdssfE2G2QAD0QBmMwA5KACy+AQFmob4AjABMwQBsADQgAJ6IkYEAnJkA7FmxZlERmQGxAL4liXJYeESk5FQ0DEws7Jw8fILCogYy1BhVirUqDerNWm26+vSScsb01iYRNkggDk4u9G6eCD7+QSFhftFxiSkIvgCsWZSxEVlRsbFZ52Zm515lFX0KNcr1ak2aVo6ARCERiKbSWRfapKOqqRoaFraPiTaZGUyWExRJb2RzOWabbx+QLBULhI7FE7eWL+F45GnRPIRZkfECVb6wob-RFjYH8MC4XB4Sh2AA2GAAZnguL15DDBn8EaMgSiDDMMVZLG5VvjXMstjsSftyTFKclEOdzgFKF5zukvA8zBFnl50udWez5b94SNAcjdPw0PRkGBRdZtXj1oTtsS9mTDqaEuaEBF8udKFkIr5fK6olkzOksgEPdCBt6JWB0MgABYaADKqC4YsgAGFS-g4B0wd0oXKBg2m6LW+24OHljqo-rEMzbpQos8-K7fC9CknTrF0rEbbb0oVMoWIgF3eU2e3OVQK1XaywB82IG2+x2BAKhbgReL0FLcDLPf3G3eH36J8x1xNYCSnFNmSuecXhzdJlydTcqQQLJfHSOc0PyLJN3SMxYiPEtH3PShLxret-yHe8RwEIMQzDLVx0jcDQC2GdoIXOCENXZDsyiOcAiiKJ0iiPDLi8V1CKA4jSOvKAACUwC4VBmA0QDvk7UEughHpfxqBSlJUlg1OqUcGNA3UNggrMs347IjzdaIvGQwSvECXI8k3Z43gEiJJI5BUSMrMiWH05T6FU6j+UFYUxUlaVZSksBQsMqBjIIUycRWJi9RY6dIn8KIAjsu1zkc5CAmiG1fBiaIzB8B0QmPT4iICmSNGS8KjMi2jQxArKwJyjw8pswriocqInOTLwIi3ASD1yQpswCd5WXobAIDgNxdPPCMBss3KEAAWjXRBDvTfcLsu9Jlr8r04WGAEkXGeBGL26MBOQzIt2ut4cwmirCt8W6yzhNqbwo4dH0216LOjTMIjnBdYhK1DYgdHjihtZbUIdWIXJuYGflBoLZI6iKoZe8zJwOw9KtGt1kbuTcsmQrwi0oeCQjzZ5blwt1Cek5TKN22GIIKZbAgKC45pyLyeLwtz4Kyabs1QgWAs0kXqaGhBxdcnzpaE2XXmch0MORmaBJeLwjbKMogA */ + id: 'categorizeLogs', + context: ({ input }) => ({ + categories: [], + documentCount: 0, + hasReachedLimit: false, + parameters: input, + samplingProbability: 1, + }), + initial: 'countingDocuments', + states: { + countingDocuments: { + invoke: { + src: 'countDocuments', + input: ({ context }) => context.parameters, + onDone: [ + { + target: 'done', + guard: { + type: 'hasTooFewDocuments', + params: ({ event }) => event.output, + }, + actions: [ + { + type: 'storeDocumentCount', + params: ({ event }) => event.output, + }, + ], + }, + { + target: 'fetchingSampledCategories', + guard: { + type: 'requiresSampling', + params: ({ event }) => event.output, + }, + actions: [ + { + type: 'storeDocumentCount', + params: ({ event }) => event.output, + }, + ], + }, + { + target: 'fetchingRemainingCategories', + actions: [ + { + type: 'storeDocumentCount', + params: ({ event }) => event.output, + }, + ], + }, + ], + onError: { + target: 'failed', + actions: [ + { + type: 'storeError', + params: ({ event }) => ({ error: event.error }), + }, + ], + }, + }, + + on: { + cancel: { + target: 'failed', + actions: [ + { + type: 'storeError', + params: () => ({ error: new Error('Counting cancelled') }), + }, + ], + }, + }, + }, + + fetchingSampledCategories: { + invoke: { + src: 'categorizeDocuments', + id: 'categorizeSampledCategories', + input: ({ context }) => ({ + ...context.parameters, + samplingProbability: context.samplingProbability, + ignoredCategoryTerms: [], + minDocsPerCategory: 10, + }), + onDone: { + target: 'fetchingRemainingCategories', + actions: [ + { + type: 'storeCategories', + params: ({ event }) => event.output, + }, + ], + }, + onError: { + target: 'failed', + actions: [ + { + type: 'storeError', + params: ({ event }) => ({ error: event.error }), + }, + ], + }, + }, + + on: { + cancel: { + target: 'failed', + actions: [ + { + type: 'storeError', + params: () => ({ error: new Error('Categorization cancelled') }), + }, + ], + }, + }, + }, + + fetchingRemainingCategories: { + invoke: { + src: 'categorizeDocuments', + id: 'categorizeRemainingCategories', + input: ({ context }) => ({ + ...context.parameters, + samplingProbability: 1, + ignoredCategoryTerms: context.categories.map((category) => category.terms), + minDocsPerCategory: 0, + }), + onDone: { + target: 'done', + actions: [ + { + type: 'storeCategories', + params: ({ event }) => event.output, + }, + ], + }, + onError: { + target: 'failed', + actions: [ + { + type: 'storeError', + params: ({ event }) => ({ error: event.error }), + }, + ], + }, + }, + + on: { + cancel: { + target: 'failed', + actions: [ + { + type: 'storeError', + params: () => ({ error: new Error('Categorization cancelled') }), + }, + ], + }, + }, + }, + + failed: { + type: 'final', + }, + + done: { + type: 'final', + }, + }, + output: ({ context }) => ({ + categories: context.categories, + documentCount: context.documentCount, + hasReachedLimit: context.hasReachedLimit, + samplingProbability: context.samplingProbability, + }), +}); + +export const createCategorizeLogsServiceImplementations = ({ + search, +}: CategorizeLogsServiceDependencies): MachineImplementationsFrom< + typeof categorizeLogsService +> => ({ + actors: { + categorizeDocuments: categorizeDocuments({ search }), + countDocuments: countDocuments({ search }), + }, +}); diff --git a/x-pack/packages/observability/logs_overview/src/services/categorize_logs_service/count_documents.ts b/x-pack/packages/observability/logs_overview/src/services/categorize_logs_service/count_documents.ts new file mode 100644 index 0000000000000..359f9ddac2bd8 --- /dev/null +++ b/x-pack/packages/observability/logs_overview/src/services/categorize_logs_service/count_documents.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getSampleProbability } from '@kbn/ml-random-sampler-utils'; +import { ISearchGeneric } from '@kbn/search-types'; +import { lastValueFrom } from 'rxjs'; +import { fromPromise } from 'xstate5'; +import { LogCategorizationParams } from './types'; +import { createCategorizationQuery } from './queries'; + +export const countDocuments = ({ search }: { search: ISearchGeneric }) => + fromPromise< + { + documentCount: number; + samplingProbability: number; + }, + LogCategorizationParams + >( + async ({ + input: { index, endTimestamp, startTimestamp, timeField, messageField, documentFilters }, + signal, + }) => { + const { rawResponse: totalHitsResponse } = await lastValueFrom( + search( + { + params: { + index, + size: 0, + track_total_hits: true, + query: createCategorizationQuery({ + messageField, + timeField, + startTimestamp, + endTimestamp, + additionalFilters: documentFilters, + }), + }, + }, + { abortSignal: signal } + ) + ); + + const documentCount = + totalHitsResponse.hits.total == null + ? 0 + : typeof totalHitsResponse.hits.total === 'number' + ? totalHitsResponse.hits.total + : totalHitsResponse.hits.total.value; + const samplingProbability = getSampleProbability(documentCount); + + return { + documentCount, + samplingProbability, + }; + } + ); diff --git a/x-pack/packages/observability/logs_overview/src/services/categorize_logs_service/index.ts b/x-pack/packages/observability/logs_overview/src/services/categorize_logs_service/index.ts new file mode 100644 index 0000000000000..149359b7d2015 --- /dev/null +++ b/x-pack/packages/observability/logs_overview/src/services/categorize_logs_service/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 * from './categorize_logs_service'; 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 new file mode 100644 index 0000000000000..aef12da303bcc --- /dev/null +++ b/x-pack/packages/observability/logs_overview/src/services/categorize_logs_service/queries.ts @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { 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'; + +const isoTimestampFormat = "YYYY-MM-DD'T'HH:mm:ss.SSS'Z'"; + +export const createCategorizationQuery = ({ + messageField, + timeField, + startTimestamp, + endTimestamp, + additionalFilters = [], + ignoredCategoryTerms = [], +}: { + messageField: string; + timeField: string; + startTimestamp: string; + endTimestamp: string; + additionalFilters?: QueryDslQueryContainer[]; + ignoredCategoryTerms?: string[]; +}): QueryDslQueryContainer => { + return { + bool: { + filter: [ + { + exists: { + field: messageField, + }, + }, + { + range: { + [timeField]: { + gte: startTimestamp, + lte: endTimestamp, + format: 'strict_date_time', + }, + }, + }, + ...additionalFilters, + ], + must_not: ignoredCategoryTerms.map(createCategoryQuery(messageField)), + }, + }; +}; + +export const createCategorizationRequestParams = ({ + index, + timeField, + messageField, + startTimestamp, + endTimestamp, + randomSampler, + minDocsPerCategory = 0, + additionalFilters = [], + ignoredCategoryTerms = [], + maxCategoriesCount = 1000, +}: { + startTimestamp: string; + endTimestamp: string; + index: string; + timeField: string; + messageField: string; + randomSampler: RandomSamplerWrapper; + minDocsPerCategory?: number; + additionalFilters?: QueryDslQueryContainer[]; + ignoredCategoryTerms?: string[]; + maxCategoriesCount?: number; +}) => { + const startMoment = moment(startTimestamp, isoTimestampFormat); + const endMoment = moment(endTimestamp, isoTimestampFormat); + const fixedIntervalDuration = calculateAuto.atLeast( + 24, + moment.duration(endMoment.diff(startMoment)) + ); + const fixedIntervalSize = `${Math.ceil(fixedIntervalDuration?.asMinutes() ?? 1)}m`; + + return { + index, + size: 0, + track_total_hits: false, + query: createCategorizationQuery({ + messageField, + timeField, + startTimestamp, + endTimestamp, + additionalFilters, + ignoredCategoryTerms, + }), + aggs: randomSampler.wrap({ + histogram: { + date_histogram: { + field: timeField, + fixed_interval: fixedIntervalSize, + extended_bounds: { + min: startTimestamp, + max: endTimestamp, + }, + }, + }, + categories: { + categorize_text: { + field: messageField, + size: maxCategoriesCount, + categorization_analyzer: { + tokenizer: 'standard', + }, + ...(minDocsPerCategory > 0 ? { min_doc_count: minDocsPerCategory } : {}), + }, + aggs: { + histogram: { + date_histogram: { + field: timeField, + fixed_interval: fixedIntervalSize, + extended_bounds: { + min: startTimestamp, + max: endTimestamp, + }, + }, + }, + change: { + // @ts-expect-error the official types don't support the change_point aggregation + change_point: { + buckets_path: 'histogram>_count', + }, + }, + }, + }, + }), + }; +}; + +export const createCategoryQuery = + (messageField: string) => + (categoryTerms: string): QueryDslQueryContainer => ({ + match: { + [messageField]: { + query: categoryTerms, + operator: 'AND' as const, + fuzziness: 0, + auto_generate_synonyms_phrase_query: false, + }, + }, + }); diff --git a/x-pack/packages/observability/logs_overview/src/services/categorize_logs_service/types.ts b/x-pack/packages/observability/logs_overview/src/services/categorize_logs_service/types.ts new file mode 100644 index 0000000000000..e094317a98d62 --- /dev/null +++ b/x-pack/packages/observability/logs_overview/src/services/categorize_logs_service/types.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 { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import { ISearchGeneric } from '@kbn/search-types'; + +export interface CategorizeLogsServiceDependencies { + search: ISearchGeneric; +} + +export interface LogCategorizationParams { + documentFilters: QueryDslQueryContainer[]; + endTimestamp: string; + index: string; + messageField: string; + startTimestamp: string; + timeField: string; +} diff --git a/x-pack/packages/observability/logs_overview/src/types.ts b/x-pack/packages/observability/logs_overview/src/types.ts new file mode 100644 index 0000000000000..4c3d27eca7e7c --- /dev/null +++ b/x-pack/packages/observability/logs_overview/src/types.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface LogCategory { + change: LogCategoryChange; + documentCount: number; + histogram: LogCategoryHistogramBucket[]; + terms: string; +} + +export type LogCategoryChange = + | LogCategoryNoChange + | LogCategoryRareChange + | LogCategorySpikeChange + | LogCategoryDipChange + | LogCategoryStepChange + | LogCategoryDistributionChange + | LogCategoryTrendChange + | LogCategoryOtherChange + | LogCategoryUnknownChange; + +export interface LogCategoryNoChange { + type: 'none'; +} + +export interface LogCategoryRareChange { + type: 'rare'; + timestamp: string; +} + +export interface LogCategorySpikeChange { + type: 'spike'; + timestamp: string; +} + +export interface LogCategoryDipChange { + type: 'dip'; + timestamp: string; +} + +export interface LogCategoryStepChange { + type: 'step'; + timestamp: string; +} + +export interface LogCategoryTrendChange { + type: 'trend'; + timestamp: string; + correlationCoefficient: number; +} + +export interface LogCategoryDistributionChange { + type: 'distribution'; + timestamp: string; +} + +export interface LogCategoryOtherChange { + type: 'other'; + timestamp?: string; +} + +export interface LogCategoryUnknownChange { + type: 'unknown'; + rawChange: string; +} + +export interface LogCategoryHistogramBucket { + documentCount: number; + timestamp: string; +} diff --git a/x-pack/packages/observability/logs_overview/src/utils/logs_source.ts b/x-pack/packages/observability/logs_overview/src/utils/logs_source.ts new file mode 100644 index 0000000000000..0c8767c8702d4 --- /dev/null +++ b/x-pack/packages/observability/logs_overview/src/utils/logs_source.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 AbstractDataView } from '@kbn/data-views-plugin/common'; +import { LogsDataAccessPluginStart } from '@kbn/logs-data-access-plugin/public'; + +export type LogsSourceConfiguration = + | SharedSettingLogsSourceConfiguration + | IndexNameLogsSourceConfiguration + | DataViewLogsSourceConfiguration; + +export interface SharedSettingLogsSourceConfiguration { + type: 'shared_setting'; + timestampField?: string; + messageField?: string; +} + +export interface IndexNameLogsSourceConfiguration { + type: 'index_name'; + indexName: string; + timestampField: string; + messageField: string; +} + +export interface DataViewLogsSourceConfiguration { + type: 'data_view'; + dataView: AbstractDataView; + messageField?: string; +} + +export const normalizeLogsSource = + ({ logsDataAccess }: { logsDataAccess: LogsDataAccessPluginStart }) => + async (logsSource: LogsSourceConfiguration): Promise => { + switch (logsSource.type) { + case 'index_name': + return logsSource; + case 'shared_setting': + const logSourcesFromSharedSettings = + await logsDataAccess.services.logSourcesService.getLogSources(); + return { + type: 'index_name', + indexName: logSourcesFromSharedSettings + .map((logSource) => logSource.indexPattern) + .join(','), + timestampField: logsSource.timestampField ?? '@timestamp', + messageField: logsSource.messageField ?? 'message', + }; + case 'data_view': + return { + type: 'index_name', + indexName: logsSource.dataView.getIndexPattern(), + timestampField: logsSource.dataView.timeFieldName ?? '@timestamp', + messageField: logsSource.messageField ?? 'message', + }; + } + }; diff --git a/x-pack/packages/observability/logs_overview/src/utils/xstate5_utils.ts b/x-pack/packages/observability/logs_overview/src/utils/xstate5_utils.ts new file mode 100644 index 0000000000000..3df0bf4ea3988 --- /dev/null +++ b/x-pack/packages/observability/logs_overview/src/utils/xstate5_utils.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getPlaceholderFor = any>( + implementationFactory: ImplementationFactory +): ReturnType => + (() => { + throw new Error('Not implemented'); + }) as ReturnType; diff --git a/x-pack/packages/observability/logs_overview/tsconfig.json b/x-pack/packages/observability/logs_overview/tsconfig.json new file mode 100644 index 0000000000000..886062ae8855f --- /dev/null +++ b/x-pack/packages/observability/logs_overview/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node", + "react", + "@kbn/ambient-ui-types", + "@kbn/ambient-storybook-types", + "@emotion/react/types/css-prop" + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/data-views-plugin", + "@kbn/i18n", + "@kbn/search-types", + "@kbn/xstate-utils", + "@kbn/core-ui-settings-browser", + "@kbn/i18n-react", + "@kbn/charts-plugin", + "@kbn/utility-types", + "@kbn/logs-data-access-plugin", + "@kbn/ml-random-sampler-utils", + "@kbn/zod", + "@kbn/calculate-auto", + "@kbn/discover-plugin", + "@kbn/es-query", + "@kbn/router-utils", + "@kbn/share-plugin", + ] +} diff --git a/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.stories.tsx b/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.stories.tsx index 90b6887636c8a..c1b292c3f08cc 100644 --- a/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.stories.tsx +++ b/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.stories.tsx @@ -70,6 +70,14 @@ export const DistributionBar = () => { , + + +

    {'Hide last tooltip'}

    +
    + + + +
    ,

    {'Empty state'}

    diff --git a/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.test.tsx b/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.test.tsx index d4bdf4c20f133..e83b66e5e01e7 100644 --- a/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.test.tsx +++ b/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.test.tsx @@ -79,5 +79,67 @@ describe('DistributionBar', () => { }); }); + it('should render last tooltip by default', () => { + const stats = [ + { + key: 'low', + count: 9, + color: 'green', + }, + { + key: 'medium', + count: 90, + color: 'red', + }, + { + key: 'high', + count: 900, + color: 'red', + }, + ]; + + const { container } = render( + + ); + expect(container).toBeInTheDocument(); + const parts = container.querySelectorAll(`[classname*="distribution_bar--tooltip"]`); + parts.forEach((part, index) => { + if (index < parts.length - 1) { + expect(part).toHaveStyle({ opacity: 0 }); + } else { + expect(part).toHaveStyle({ opacity: 1 }); + } + }); + }); + + it('should not render last tooltip when hideLastTooltip is true', () => { + const stats = [ + { + key: 'low', + count: 9, + color: 'green', + }, + { + key: 'medium', + count: 90, + color: 'red', + }, + { + key: 'high', + count: 900, + color: 'red', + }, + ]; + + const { container } = render( + + ); + expect(container).toBeInTheDocument(); + const parts = container.querySelectorAll(`[classname*="distribution_bar--tooltip"]`); + parts.forEach((part) => { + expect(part).toHaveStyle({ opacity: 0 }); + }); + }); + // todo: test tooltip visibility logic }); diff --git a/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.tsx b/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.tsx index 28d8ca4a8a148..5b06292813ccd 100644 --- a/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.tsx +++ b/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.tsx @@ -13,6 +13,8 @@ import { css } from '@emotion/react'; export interface DistributionBarProps { /** distribution data points */ stats: Array<{ key: string; count: number; color: string; label?: React.ReactNode }>; + /** hide the label above the bar at first render */ + hideLastTooltip?: boolean; /** data-test-subj used for querying the component in tests */ ['data-test-subj']?: string; } @@ -136,18 +138,21 @@ export const DistributionBar: React.FC = React.memo(functi props ) { const styles = useStyles(); - const { stats, 'data-test-subj': dataTestSubj } = props; + const { stats, 'data-test-subj': dataTestSubj, hideLastTooltip } = props; const parts = stats.map((stat) => { const partStyle = [ styles.part.base, styles.part.tick, styles.part.hover, - styles.part.lastTooltip, css` background-color: ${stat.color}; flex: ${stat.count}; `, ]; + if (!hideLastTooltip) { + partStyle.push(styles.part.lastTooltip); + } + const prettyNumber = numeral(stat.count).format('0,0a'); return ( diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md index 7cab1ffe0c0b3..4e7f20e47cb7d 100644 --- a/x-pack/plugins/actions/README.md +++ b/x-pack/plugins/actions/README.md @@ -89,13 +89,16 @@ The following table describes the properties of the `options` object. | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | | id | Unique identifier for the action type. For convention, ids starting with `.` are reserved for built in action types. We recommend using a convention like `.mySpecialAction` for your action types. | string | | name | A user-friendly name for the action type. These will be displayed in dropdowns when chosing action types. | string | -| maxAttempts | The maximum number of times this action will attempt to run when scheduled. | number | +| maxAttempts | The maximum number of times this action will attempt to run when scheduled. | number | | minimumLicenseRequired | The license required to use the action type. | string | | supportedFeatureIds | List of IDs of the features that this action type is available in. Allowed values are `alerting`, `siem`, `uptime`, `cases`. See `x-pack/plugins/actions/common/connector_feature_config.ts` for the most up to date list. | string[] | | validate.params | When developing an action type, it needs to accept parameters to know what to do with the action. (Example `to`, `from`, `subject`, `body` of an email). See the current built-in email action type for an example of the state-of-the-art validation.

    Technically, the value of this property should have a property named `validate()` which is a function that takes a params object to validate and returns a sanitized version of that object to pass to the execution function. Validation errors should be thrown from the `validate()` function and will be available as an error message | schema / validation function | | validate.config | Similar to params, a config may be required when creating an action (for example `host` and `port` for an email server). | schema / validation function | | validate.secrets | Similar to params, a secrets object may be required when creating an action (for example `user` and `password` for an email server). | schema / validation function | -| executor | This is where the code of an action type lives. This is a function gets called for generating an action from either alerting or manually by using the exposed function (see firing actions). For full details, see executor section below. | Function | +| executor | This is where the code of an action type lives. This is a function gets called for generating an action from either alerting or manually by using the exposed function (see firing actions). For full details, see executor section below. | Function | +| preSaveHook | This optional function is called before the connector saved object is saved. For full details, see hooks section below. | Function | +| postSaveHook | This optional function is called after the connector saved object is saved. For full details, see hooks section below. | Function | +| postDeleteHook | This optional function is called after the connector saved object is deleted. For full details, see hooks section below. | Function | | renderParameterTemplates | Optionally define a function to provide custom rendering for this action type. | Function | **Important** - The config object is persisted in ElasticSearch and updated via the ElasticSearch update document API. This API allows "partial updates" - and this can cause issues with the encryption used on specified properties. So, a `validate()` function should return values for all configuration properties, so that partial updates do not occur. Setting property values to `null` rather than `undefined`, or not including a property in the config object, is all you need to do to ensure partial updates won't occur. @@ -116,6 +119,71 @@ This is the primary function for an action type. Whenever the action needs to ru | services.savedObjectsClient | This is an instance of the saved objects client. This provides the ability to do CRUD on any saved objects within the same space the alert lives in.

    The scope of the saved objects client is tied to the user in context calling the execute API or the API key provided to the execute plugin function (only when security isenabled). | | services.log(tags, [data], [timestamp]) | Use this to create server logs. (This is the same function as server.log) | +### Hooks + +Hooks allow a connector implementation to be called during connector creation, update, and delete. When not using hooks, the connector implementation is not involved in creation, update and delete, except for the schema validation that happens for creation and update. Hooks can be used to force a create or update to fail, or run arbitrary code before and after update and create, and after delete. We don't have a need for a hook before delete at the moment, so that hook is currently not available. + +Hooks are passed the following parameters: + +```ts +interface PreSaveConnectorHookParams { + connectorId: string; + config: Config; + secrets: Secrets; + logger: Logger; + request: KibanaRequest; + services: HookServices; + isUpdate: boolean; +} + +interface PostSaveConnectorHookParams { + connectorId: string; + config: Config; + secrets: Secrets; + logger: Logger; + request: KibanaRequest; + services: HookServices; + isUpdate: boolean; + wasSuccessful: boolean; +} + +interface PostDeleteConnectorHookParams { + connectorId: string; + config: Config; + // secrets not provided, yet + logger: Logger; + request: KibanaRequest; + services: HookServices; +} +``` + +| parameter | description +| --------- | ----------- +| `connectorId` | The id of the connector. +| `config` | The connector's `config` object. +| `secrets` | The connector's `secrets` object. +| `logger` | A standard Kibana logger. +| `request` | The request causing this operation +| `services` | Common service objects, see below. +| `isUpdate` | For the `PreSave` and `PostSave` hooks, `isUpdate` is false for create operations, and true for update operations. +| `wasSuccessful` | For the `PostSave` hook, this indicates if the connector was persisted as a Saved Object successfully. + +The `services` object contains the following properties: + +| property | description +| --------- | ----------- +| `scopedClusterClient` | A standard `scopeClusterClient` object. + +The hooks are called just before, and just after, the Saved Object operation for the client methods is invoked. + +The `PostDelete` hook does not have a `wasSuccessful` property, as the hook is not called if the delete operation fails. The saved object will still exist. Only a successful call to delete the connector will cause the hook to run. + +The `PostSave` hook is useful if the `PreSave` hook is creating / modifying other resources. The `PreSave` hook is called just before the connector SO is actually created/updated, and of course that create/update could fail for some reason. In those cases, the `PostSave` hook is passed `wasSuccessful: false` and can "undo" any work it did in the `PreSave` hook. + +The `PreSave` hook can be used to cancel a create or update, by throwing an exception. The `PostSave` and `PostDelete` invocations will have thrown exceptions caught and logged to the Kibana log, and will not cancel the operation. + +When throwing an error in the `PreSave` hook, the Error's message will be used as the error failing the operation, so should include a human-readable description of what it was doing, along with any message from an underlying API that failed, if available. When an error is thrown from a `PreSave` hook, the `PostSave` hook will **NOT** be run. + ### Example The built-in email action type provides a good example of creating an action type with non-trivial configuration and params: 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 46e73f7bb3591..7f15dd6287d6b 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 @@ -113,6 +113,9 @@ const mockTaskManager = taskManagerMock.createSetup(); const configurationUtilities = actionsConfigMock.create(); const eventLogClient = eventLogClientMock.create(); const getEventLogClient = jest.fn(); +const preSaveHook = jest.fn(); +const postSaveHook = jest.fn(); +const postDeleteHook = jest.fn(); let actionsClient: ActionsClient; let mockedLicenseState: jest.Mocked; @@ -392,6 +395,8 @@ describe('create()', () => { params: { schema: schema.object({}) }, }, executor, + preSaveHook, + postSaveHook, }); unsecuredSavedObjectsClient.create.mockResolvedValueOnce(savedObjectCreateResult); const result = await actionsClient.create({ @@ -428,6 +433,8 @@ describe('create()', () => { }, ] `); + expect(preSaveHook).toHaveBeenCalledTimes(1); + expect(postSaveHook).toHaveBeenCalledTimes(1); }); test('validates config', async () => { @@ -1973,6 +1980,33 @@ describe('getOAuthAccessToken()', () => { }); describe('delete()', () => { + beforeEach(() => { + actionTypeRegistry.register({ + id: 'my-action-delete', + name: 'My action type', + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting'], + validate: { + config: { schema: schema.object({}) }, + secrets: { schema: schema.object({}) }, + params: { schema: schema.object({}) }, + }, + executor, + postDeleteHook: async (options) => postDeleteHook(options), + }); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'action', + attributes: { + actionTypeId: 'my-action-delete', + isMissingSecrets: false, + config: {}, + secrets: {}, + }, + references: [], + }); + }); + describe('authorization', () => { test('ensures user is authorised to delete actions', async () => { await actionsClient.delete({ id: '1' }); @@ -2052,6 +2086,16 @@ describe('delete()', () => { `); }); + test('calls postDeleteHook', async () => { + const expectedResult = Symbol(); + unsecuredSavedObjectsClient.delete.mockResolvedValueOnce(expectedResult); + + const result = await actionsClient.delete({ id: '1' }); + expect(result).toEqual(expectedResult); + expect(unsecuredSavedObjectsClient.delete).toHaveBeenCalledTimes(1); + expect(postDeleteHook).toHaveBeenCalledTimes(1); + }); + it('throws when trying to delete a preconfigured connector', async () => { actionsClient = new ActionsClient({ logger, @@ -2250,6 +2294,8 @@ describe('update()', () => { params: { schema: schema.object({}) }, }, executor, + preSaveHook, + postSaveHook, }); unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', @@ -2315,6 +2361,9 @@ describe('update()', () => { "my-action", ] `); + + expect(preSaveHook).toHaveBeenCalledTimes(1); + expect(postSaveHook).toHaveBeenCalledTimes(1); }); test('updates an action with isMissingSecrets "true" (set true as the import result), to isMissingSecrets', async () => { diff --git a/x-pack/plugins/actions/server/actions_client/actions_client.ts b/x-pack/plugins/actions/server/actions_client/actions_client.ts index 7e4d72faedaed..f485d82b2f120 100644 --- a/x-pack/plugins/actions/server/actions_client/actions_client.ts +++ b/x-pack/plugins/actions/server/actions_client/actions_client.ts @@ -43,6 +43,7 @@ import { validateConnector, ActionExecutionSource, parseDate, + tryCatch, } from '../lib'; import { ActionResult, @@ -50,6 +51,7 @@ import { InMemoryConnector, ActionTypeExecutorResult, ConnectorTokenClientContract, + HookServices, } from '../types'; import { PreconfiguredActionDisabledModificationError } from '../lib/errors/preconfigured_action_disabled_modification'; import { ExecuteOptions } from '../lib/action_executor'; @@ -246,6 +248,33 @@ export class ActionsClient { } this.context.actionTypeRegistry.ensureActionTypeEnabled(actionTypeId); + const hookServices: HookServices = { + scopedClusterClient: this.context.scopedClusterClient, + }; + + if (actionType.preSaveHook) { + try { + await actionType.preSaveHook({ + connectorId: id, + config, + secrets, + logger: this.context.logger, + request: this.context.request, + services: hookServices, + isUpdate: false, + }); + } catch (error) { + this.context.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.CREATE, + savedObject: { type: 'action', id }, + error, + }) + ); + throw error; + } + } + this.context.auditLogger?.log( connectorAuditEvent({ action: ConnectorAuditAction.CREATE, @@ -254,18 +283,48 @@ export class ActionsClient { }) ); - const result = await this.context.unsecuredSavedObjectsClient.create( - 'action', - { - actionTypeId, - name, - isMissingSecrets: false, - config: validatedActionTypeConfig as SavedObjectAttributes, - secrets: validatedActionTypeSecrets as SavedObjectAttributes, - }, - { id } + const result = await tryCatch( + async () => + await this.context.unsecuredSavedObjectsClient.create( + 'action', + { + actionTypeId, + name, + isMissingSecrets: false, + config: validatedActionTypeConfig as SavedObjectAttributes, + secrets: validatedActionTypeSecrets as SavedObjectAttributes, + }, + { id } + ) ); + const wasSuccessful = !(result instanceof Error); + const label = `connectorId: "${id}"; type: ${actionTypeId}`; + const tags = ['post-save-hook', id]; + + if (actionType.postSaveHook) { + try { + await actionType.postSaveHook({ + connectorId: id, + config, + secrets, + logger: this.context.logger, + request: this.context.request, + services: hookServices, + isUpdate: false, + wasSuccessful, + }); + } catch (err) { + this.context.logger.error(`postSaveHook create error for ${label}: ${err.message}`, { + tags, + }); + } + } + + if (!wasSuccessful) { + throw result; + } + return { id: result.id, actionTypeId: result.attributes.actionTypeId, @@ -558,7 +617,36 @@ export class ActionsClient { ); } - return await this.context.unsecuredSavedObjectsClient.delete('action', id); + const rawAction = await this.context.unsecuredSavedObjectsClient.get('action', id); + const { + attributes: { actionTypeId, config }, + } = rawAction; + + const actionType = this.context.actionTypeRegistry.get(actionTypeId); + const result = await this.context.unsecuredSavedObjectsClient.delete('action', id); + + const hookServices: HookServices = { + scopedClusterClient: this.context.scopedClusterClient, + }; + + if (actionType.postDeleteHook) { + try { + await actionType.postDeleteHook({ + connectorId: id, + config, + logger: this.context.logger, + request: this.context.request, + services: hookServices, + }); + } catch (error) { + const tags = ['post-delete-hook', id]; + this.context.logger.error( + `The post delete hook failed for for connector "${id}": ${error.message}`, + { tags } + ); + } + } + return result; } private getSystemActionKibanaPrivileges(connectorId: string, params?: ExecuteOptions['params']) { diff --git a/x-pack/plugins/actions/server/actions_client/actions_client_hooks.test.ts b/x-pack/plugins/actions/server/actions_client/actions_client_hooks.test.ts new file mode 100644 index 0000000000000..7a1a0fb5e3d91 --- /dev/null +++ b/x-pack/plugins/actions/server/actions_client/actions_client_hooks.test.ts @@ -0,0 +1,385 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { schema } from '@kbn/config-schema'; +import { MockedLogger, loggerMock } from '@kbn/logging-mocks'; +import { ActionTypeRegistry, ActionTypeRegistryOpts } from '../action_type_registry'; +import { ActionsClient } from './actions_client'; +import { ExecutorType } from '../types'; +import { ActionExecutor, TaskRunnerFactory, ILicenseState } from '../lib'; +import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; +import { actionsConfigMock } from '../actions_config.mock'; +import { licenseStateMock } from '../lib/license_state.mock'; +import { licensingMock } from '@kbn/licensing-plugin/server/mocks'; +import { + httpServerMock, + elasticsearchServiceMock, + savedObjectsClientMock, +} from '@kbn/core/server/mocks'; +import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; +import { usageCountersServiceMock } from '@kbn/usage-collection-plugin/server/usage_counters/usage_counters_service.mock'; +import { actionExecutorMock } from '../lib/action_executor.mock'; +import { ActionsAuthorization } from '../authorization/actions_authorization'; +import { actionsAuthorizationMock } from '../authorization/actions_authorization.mock'; +import { connectorTokenClientMock } from '../lib/connector_token_client.mock'; +import { inMemoryMetricsMock } from '../monitoring/in_memory_metrics.mock'; + +jest.mock('uuid', () => ({ + v4: () => ConnectorSavedObject.id, +})); + +const kibanaIndices = ['.kibana']; +const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); +const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); +const actionExecutor = actionExecutorMock.create(); +const authorization = actionsAuthorizationMock.create(); +const ephemeralExecutionEnqueuer = jest.fn(); +const bulkExecutionEnqueuer = jest.fn(); +const request = httpServerMock.createKibanaRequest(); +const auditLogger = auditLoggerMock.create(); +const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); +const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); +const mockTaskManager = taskManagerMock.createSetup(); +const getEventLogClient = jest.fn(); +const preSaveHook = jest.fn(); +const postSaveHook = jest.fn(); +const postDeleteHook = jest.fn(); + +let actionsClient: ActionsClient; +let mockedLicenseState: jest.Mocked; +let actionTypeRegistry: ActionTypeRegistry; +let actionTypeRegistryParams: ActionTypeRegistryOpts; +const executor: ExecutorType<{}, {}, {}, void> = async (options) => { + return { status: 'ok', actionId: options.actionId }; +}; + +const ConnectorSavedObject = { + id: 'connector-id-uuid', + type: 'action', + attributes: { + actionTypeId: 'hooked-action-type', + isMissingSecrets: false, + name: 'Hooked Action', + config: { foo: 42 }, + secrets: { bar: 2001 }, + }, + references: [], +}; + +const CreateParms = { + action: { + name: ConnectorSavedObject.attributes.name, + actionTypeId: ConnectorSavedObject.attributes.actionTypeId, + config: ConnectorSavedObject.attributes.config, + secrets: ConnectorSavedObject.attributes.secrets, + }, +}; + +const UpdateParms = { + id: ConnectorSavedObject.id, + action: { + name: ConnectorSavedObject.attributes.name, + config: ConnectorSavedObject.attributes.config, + secrets: ConnectorSavedObject.attributes.secrets, + }, +}; + +const CoreHookParams = { + connectorId: ConnectorSavedObject.id, + config: ConnectorSavedObject.attributes.config, + secrets: ConnectorSavedObject.attributes.secrets, + request, + services: { + // this will be checked with a function test + scopedClusterClient: expect.any(Object), + }, +}; + +const connectorTokenClient = connectorTokenClientMock.create(); +const inMemoryMetrics = inMemoryMetricsMock.create(); + +let logger: MockedLogger; + +beforeEach(() => { + jest.resetAllMocks(); + logger = loggerMock.create(); + mockedLicenseState = licenseStateMock.create(); + + actionTypeRegistryParams = { + licensing: licensingMock.createSetup(), + taskManager: mockTaskManager, + taskRunnerFactory: new TaskRunnerFactory( + new ActionExecutor({ isESOCanEncrypt: true }), + inMemoryMetrics + ), + actionsConfigUtils: actionsConfigMock.create(), + licenseState: mockedLicenseState, + inMemoryConnectors: [], + }; + + actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + actionsClient = new ActionsClient({ + logger, + actionTypeRegistry, + unsecuredSavedObjectsClient, + scopedClusterClient, + kibanaIndices, + inMemoryConnectors: [], + actionExecutor, + ephemeralExecutionEnqueuer, + bulkExecutionEnqueuer, + request, + authorization: authorization as unknown as ActionsAuthorization, + auditLogger, + usageCounter: mockUsageCounter, + connectorTokenClient, + getEventLogClient, + }); + + actionTypeRegistry.register({ + id: 'hooked-action-type', + name: 'Hooked action type', + minimumLicenseRequired: 'gold', + supportedFeatureIds: ['alerting'], + validate: { + config: { schema: schema.object({ foo: schema.number() }) }, + secrets: { schema: schema.object({ bar: schema.number() }) }, + params: { schema: schema.object({}) }, + }, + executor, + preSaveHook, + postSaveHook, + postDeleteHook, + }); +}); + +describe('connector type hooks', () => { + describe('successful operation and successful hook', () => { + test('for create', async () => { + unsecuredSavedObjectsClient.create.mockResolvedValueOnce(ConnectorSavedObject); + const result = await actionsClient.create(CreateParms); + expect(result.id).toBe(ConnectorSavedObject.id); + + const preParams = { ...CoreHookParams, logger, isUpdate: false }; + const postParams = { ...preParams, wasSuccessful: true }; + + expect(preSaveHook).toHaveBeenCalledTimes(1); + expect(preSaveHook.mock.calls[0]).toStrictEqual([preParams]); + + expect(postSaveHook).toHaveBeenCalledTimes(1); + expect(postSaveHook.mock.calls[0]).toStrictEqual([postParams]); + }); + + test('for update', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(ConnectorSavedObject); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce(ConnectorSavedObject); + const result = await actionsClient.update(UpdateParms); + expect(result.id).toBe(ConnectorSavedObject.id); + + const preParams = { ...CoreHookParams, logger, isUpdate: true }; + const postParams = { ...preParams, wasSuccessful: true }; + + expect(preSaveHook).toHaveBeenCalledTimes(1); + expect(preSaveHook.mock.calls[0]).toStrictEqual([preParams]); + + expect(postSaveHook).toHaveBeenCalledTimes(1); + expect(postSaveHook.mock.calls[0]).toStrictEqual([postParams]); + }); + + test('for delete', async () => { + const expectedResult = Symbol(); + unsecuredSavedObjectsClient.delete.mockResolvedValueOnce(expectedResult); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(ConnectorSavedObject); + + const result = await actionsClient.delete({ id: ConnectorSavedObject.id }); + expect(result).toBe(expectedResult); + + const postParamsWithSecrets = { ...CoreHookParams, logger }; + const postParams = omit(postParamsWithSecrets, 'secrets'); + + expect(postDeleteHook).toHaveBeenCalledTimes(1); + expect(postDeleteHook.mock.calls[0]).toEqual([postParams]); + }); + }); + + describe('unsuccessful operation and successful hook', () => { + test('for create', async () => { + unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('OMG create')); + await expect(actionsClient.create(CreateParms)).rejects.toMatchInlineSnapshot( + `[Error: OMG create]` + ); + + const preParams = { ...CoreHookParams, logger, isUpdate: false }; + const postParams = { ...preParams, wasSuccessful: false }; + + expect(preSaveHook).toHaveBeenCalledTimes(1); + expect(preSaveHook.mock.calls[0]).toStrictEqual([preParams]); + + expect(postSaveHook).toHaveBeenCalledTimes(1); + expect(postSaveHook.mock.calls[0]).toStrictEqual([postParams]); + }); + + test('for update', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(ConnectorSavedObject); + unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('OMG update')); + await expect(actionsClient.update(UpdateParms)).rejects.toMatchInlineSnapshot( + `[Error: OMG update]` + ); + + const preParams = { ...CoreHookParams, logger, isUpdate: true }; + const postParams = { ...preParams, wasSuccessful: false }; + + expect(preSaveHook).toHaveBeenCalledTimes(1); + expect(preSaveHook.mock.calls[0]).toStrictEqual([preParams]); + + expect(postSaveHook).toHaveBeenCalledTimes(1); + expect(postSaveHook.mock.calls[0]).toStrictEqual([postParams]); + }); + + test('for delete', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(ConnectorSavedObject); + unsecuredSavedObjectsClient.delete.mockRejectedValueOnce(new Error('OMG delete')); + + await expect( + actionsClient.delete({ id: ConnectorSavedObject.id }) + ).rejects.toMatchInlineSnapshot(`[Error: OMG delete]`); + + expect(postDeleteHook).toHaveBeenCalledTimes(0); + }); + }); + + describe('successful operation and unsuccessful hook', () => { + test('for create pre hook', async () => { + preSaveHook.mockRejectedValueOnce(new Error('OMG create pre save')); + + await expect(actionsClient.create(CreateParms)).rejects.toMatchInlineSnapshot( + `[Error: OMG create pre save]` + ); + + const preParams = { ...CoreHookParams, logger, isUpdate: false }; + + expect(preSaveHook).toHaveBeenCalledTimes(1); + expect(preSaveHook.mock.calls[0]).toStrictEqual([preParams]); + + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(0); + expect(postSaveHook).toHaveBeenCalledTimes(0); + }); + + test('for create post hook', async () => { + postSaveHook.mockRejectedValueOnce(new Error('OMG create post save')); + + unsecuredSavedObjectsClient.create.mockResolvedValueOnce(ConnectorSavedObject); + const result = await actionsClient.create(CreateParms); + expect(result.id).toBe(ConnectorSavedObject.id); + + const preParams = { ...CoreHookParams, logger, isUpdate: false }; + const postParams = { ...preParams, wasSuccessful: true }; + + expect(preSaveHook).toHaveBeenCalledTimes(1); + expect(preSaveHook.mock.calls[0]).toStrictEqual([preParams]); + + expect(postSaveHook).toHaveBeenCalledTimes(1); + expect(postSaveHook.mock.calls[0]).toStrictEqual([postParams]); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "postSaveHook create error for connectorId: \\"connector-id-uuid\\"; type: hooked-action-type: OMG create post save", + Object { + "tags": Array [ + "post-save-hook", + "connector-id-uuid", + ], + }, + ], + ] + `); + }); + + test('for update pre hook', async () => { + preSaveHook.mockRejectedValueOnce(new Error('OMG update pre save')); + + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(ConnectorSavedObject); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce(ConnectorSavedObject); + await expect(actionsClient.update(UpdateParms)).rejects.toMatchInlineSnapshot( + `[Error: OMG update pre save]` + ); + + const preParams = { ...CoreHookParams, logger, isUpdate: true }; + + expect(preSaveHook).toHaveBeenCalledTimes(1); + expect(preSaveHook.mock.calls[0]).toStrictEqual([preParams]); + + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(0); + expect(postSaveHook).toHaveBeenCalledTimes(0); + }); + + test('for update post hook', async () => { + postSaveHook.mockRejectedValueOnce(new Error('OMG update post save')); + + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(ConnectorSavedObject); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce(ConnectorSavedObject); + const result = await actionsClient.update(UpdateParms); + expect(result.id).toBe(ConnectorSavedObject.id); + + const preParams = { ...CoreHookParams, logger, isUpdate: true }; + const postParams = { ...preParams, wasSuccessful: true }; + + expect(preSaveHook).toHaveBeenCalledTimes(1); + expect(preSaveHook.mock.calls[0]).toStrictEqual([preParams]); + + expect(postSaveHook).toHaveBeenCalledTimes(1); + expect(postSaveHook.mock.calls[0]).toStrictEqual([postParams]); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "postSaveHook update error for connectorId: \\"connector-id-uuid\\"; type: hooked-action-type: OMG update post save", + Object { + "tags": Array [ + "post-save-hook", + "connector-id-uuid", + ], + }, + ], + ] + `); + }); + + test('for delete post hook', async () => { + postDeleteHook.mockRejectedValueOnce(new Error('OMG delete post delete')); + + const expectedResult = Symbol(); + unsecuredSavedObjectsClient.delete.mockResolvedValueOnce(expectedResult); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(ConnectorSavedObject); + + const result = await actionsClient.delete({ id: ConnectorSavedObject.id }); + expect(result).toBe(expectedResult); + + const postParamsWithSecrets = { ...CoreHookParams, logger }; + const postParams = omit(postParamsWithSecrets, 'secrets'); + + expect(postDeleteHook).toHaveBeenCalledTimes(1); + expect(postDeleteHook.mock.calls[0]).toEqual([postParams]); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "The post delete hook failed for for connector \\"connector-id-uuid\\": OMG delete post delete", + Object { + "tags": Array [ + "post-delete-hook", + "connector-id-uuid", + ], + }, + ], + ] + `); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/application/connector/methods/update/update.ts b/x-pack/plugins/actions/server/application/connector/methods/update/update.ts index 7baa099a29029..e22715c31d149 100644 --- a/x-pack/plugins/actions/server/application/connector/methods/update/update.ts +++ b/x-pack/plugins/actions/server/application/connector/methods/update/update.ts @@ -15,7 +15,8 @@ import { PreconfiguredActionDisabledModificationError } from '../../../../lib/er import { ConnectorAuditAction, connectorAuditEvent } from '../../../../lib/audit_events'; import { validateConfig, validateConnector, validateSecrets } from '../../../../lib'; import { isConnectorDeprecated } from '../../lib'; -import { RawAction } from '../../../../types'; +import { RawAction, HookServices } from '../../../../types'; +import { tryCatch } from '../../../../lib'; export async function update({ context, id, action }: ConnectorUpdateParams): Promise { try { @@ -75,6 +76,33 @@ export async function update({ context, id, action }: ConnectorUpdateParams): Pr context.actionTypeRegistry.ensureActionTypeEnabled(actionTypeId); + const hookServices: HookServices = { + scopedClusterClient: context.scopedClusterClient, + }; + + if (actionType.preSaveHook) { + try { + await actionType.preSaveHook({ + connectorId: id, + config, + secrets, + logger: context.logger, + request: context.request, + services: hookServices, + isUpdate: true, + }); + } catch (error) { + context.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.UPDATE, + savedObject: { type: 'action', id }, + error, + }) + ); + throw error; + } + } + context.auditLogger?.log( connectorAuditEvent({ action: ConnectorAuditAction.UPDATE, @@ -83,27 +111,57 @@ export async function update({ context, id, action }: ConnectorUpdateParams): Pr }) ); - const result = await context.unsecuredSavedObjectsClient.create( - 'action', - { - ...attributes, - actionTypeId, - name, - isMissingSecrets: false, - config: validatedActionTypeConfig as SavedObjectAttributes, - secrets: validatedActionTypeSecrets as SavedObjectAttributes, - }, - omitBy( - { - id, - overwrite: true, - references, - version, - }, - isUndefined - ) + const result = await tryCatch( + async () => + await context.unsecuredSavedObjectsClient.create( + 'action', + { + ...attributes, + actionTypeId, + name, + isMissingSecrets: false, + config: validatedActionTypeConfig as SavedObjectAttributes, + secrets: validatedActionTypeSecrets as SavedObjectAttributes, + }, + omitBy( + { + id, + overwrite: true, + references, + version, + }, + isUndefined + ) + ) ); + const wasSuccessful = !(result instanceof Error); + const label = `connectorId: "${id}"; type: ${actionTypeId}`; + const tags = ['post-save-hook', id]; + + if (actionType.postSaveHook) { + try { + await actionType.postSaveHook({ + connectorId: id, + config, + secrets, + logger: context.logger, + request: context.request, + services: hookServices, + isUpdate: true, + wasSuccessful, + }); + } catch (err) { + context.logger.error(`postSaveHook update error for ${label}: ${err.message}`, { + tags, + }); + } + } + + if (!wasSuccessful) { + throw result; + } + try { await context.connectorTokenClient.deleteConnectorTokens({ connectorId: id }); } catch (e) { diff --git a/x-pack/plugins/actions/server/create_execute_function.test.ts b/x-pack/plugins/actions/server/create_execute_function.test.ts index a1ab85933d9bc..7be187743e634 100644 --- a/x-pack/plugins/actions/server/create_execute_function.test.ts +++ b/x-pack/plugins/actions/server/create_execute_function.test.ts @@ -1088,6 +1088,7 @@ describe('bulkExecute()', () => { "actionTypeId": "mock-action", "id": "123", "response": "queuedActionsLimitError", + "uuid": undefined, }, ], } @@ -1099,4 +1100,93 @@ describe('bulkExecute()', () => { ] `); }); + + test('passes through action uuid if provided', async () => { + mockTaskManager.aggregate.mockResolvedValue({ + took: 1, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { total: { value: 2, relation: 'eq' }, max_score: null, hits: [] }, + aggregations: {}, + }); + mockActionsConfig.getMaxQueued.mockReturnValueOnce(3); + const executeFn = createBulkExecutionEnqueuerFunction({ + taskManager: mockTaskManager, + actionTypeRegistry: actionTypeRegistryMock.create(), + isESOCanEncrypt: true, + inMemoryConnectors: [], + configurationUtilities: mockActionsConfig, + logger: mockLogger, + }); + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [ + { id: '123', type: 'action', attributes: { actionTypeId: 'mock-action' }, references: [] }, + ], + }); + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ + saved_objects: [ + { id: '234', type: 'action_task_params', attributes: { actionId: '123' }, references: [] }, + ], + }); + expect( + await executeFn(savedObjectsClient, [ + { + id: '123', + params: { baz: false }, + spaceId: 'default', + executionId: '123abc', + apiKey: null, + source: asHttpRequestExecutionSource(request), + actionTypeId: 'mock-action', + uuid: 'aaa', + }, + { + id: '123', + params: { baz: false }, + spaceId: 'default', + executionId: '456xyz', + apiKey: null, + source: asHttpRequestExecutionSource(request), + actionTypeId: 'mock-action', + uuid: 'bbb', + }, + ]) + ).toMatchInlineSnapshot(` + Object { + "errors": true, + "items": Array [ + Object { + "actionTypeId": "mock-action", + "id": "123", + "response": "success", + "uuid": "aaa", + }, + Object { + "actionTypeId": "mock-action", + "id": "123", + "response": "queuedActionsLimitError", + "uuid": "bbb", + }, + ], + } + `); + expect(mockTaskManager.bulkSchedule).toHaveBeenCalledTimes(1); + expect(mockTaskManager.bulkSchedule.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "params": Object { + "actionTaskParamsId": "234", + "spaceId": "default", + }, + "scope": Array [ + "actions", + ], + "state": Object {}, + "taskType": "actions:mock-action", + }, + ], + ] + `); + }); }); diff --git a/x-pack/plugins/actions/server/create_execute_function.ts b/x-pack/plugins/actions/server/create_execute_function.ts index e8f9c859747ff..a92bff9719559 100644 --- a/x-pack/plugins/actions/server/create_execute_function.ts +++ b/x-pack/plugins/actions/server/create_execute_function.ts @@ -31,6 +31,7 @@ interface CreateExecuteFunctionOptions { export interface ExecuteOptions extends Pick { id: string; + uuid?: string; spaceId: string; apiKey: string | null; executionId: string; @@ -71,6 +72,7 @@ export interface ExecutionResponse { export interface ExecutionResponseItem { id: string; + uuid?: string; actionTypeId: string; response: ExecutionResponseType; } @@ -197,12 +199,14 @@ export function createBulkExecutionEnqueuerFunction({ items: runnableActions .map((a) => ({ id: a.id, + uuid: a.uuid, actionTypeId: a.actionTypeId, response: ExecutionResponseType.SUCCESS, })) .concat( actionsOverLimit.map((a) => ({ id: a.id, + uuid: a.uuid, actionTypeId: a.actionTypeId, response: ExecutionResponseType.QUEUED_ACTIONS_LIMIT_ERROR, })) diff --git a/x-pack/plugins/actions/server/lib/index.ts b/x-pack/plugins/actions/server/lib/index.ts index 9b8d452f446a9..e13fb85008a84 100644 --- a/x-pack/plugins/actions/server/lib/index.ts +++ b/x-pack/plugins/actions/server/lib/index.ts @@ -38,3 +38,4 @@ export { export { parseDate } from './parse_date'; export type { RelatedSavedObjects } from './related_saved_objects'; export { getBasicAuthHeader, combineHeadersWithBasicAuthHeader } from './get_basic_auth_header'; +export { tryCatch } from './try_catch'; diff --git a/x-pack/plugins/actions/server/lib/try_catch.ts b/x-pack/plugins/actions/server/lib/try_catch.ts new file mode 100644 index 0000000000000..a9932601c8256 --- /dev/null +++ b/x-pack/plugins/actions/server/lib/try_catch.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. + */ + +// functional version of try/catch, allows you to not have to use +// `let` vars initialied to `undefined` to capture the result value + +export async function tryCatch(fn: () => Promise): Promise { + try { + return await fn(); + } catch (err) { + return err; + } +} diff --git a/x-pack/plugins/actions/server/sub_action_framework/register.test.ts b/x-pack/plugins/actions/server/sub_action_framework/register.test.ts index a0e56c1a39b80..8ae7f3cf3350f 100644 --- a/x-pack/plugins/actions/server/sub_action_framework/register.test.ts +++ b/x-pack/plugins/actions/server/sub_action_framework/register.test.ts @@ -21,6 +21,9 @@ import { ServiceParams } from './types'; describe('Registration', () => { const renderedVariables = { body: '' }; const mockRenderParameterTemplates = jest.fn().mockReturnValue(renderedVariables); + const mockPreSaveHook = jest.fn(); + const mockPostSaveHook = jest.fn(); + const mockPostDeleteHook = jest.fn(); const connector = { id: '.test', @@ -47,7 +50,12 @@ describe('Registration', () => { it('registers the connector correctly', async () => { register({ actionTypeRegistry, - connector, + connector: { + ...connector, + preSaveHook: mockPreSaveHook, + postSaveHook: mockPostSaveHook, + postDeleteHook: mockPostDeleteHook, + }, configurationUtilities: mockedActionsConfig, logger, }); @@ -62,6 +70,9 @@ describe('Registration', () => { executor: expect.any(Function), getService: expect.any(Function), renderParameterTemplates: expect.any(Function), + preSaveHook: expect.any(Function), + postSaveHook: expect.any(Function), + postDeleteHook: expect.any(Function), }); }); diff --git a/x-pack/plugins/actions/server/sub_action_framework/register.ts b/x-pack/plugins/actions/server/sub_action_framework/register.ts index dd05cc4e99967..04e7f0d9ea417 100644 --- a/x-pack/plugins/actions/server/sub_action_framework/register.ts +++ b/x-pack/plugins/actions/server/sub_action_framework/register.ts @@ -43,5 +43,8 @@ export const register = { /** @@ -76,6 +77,35 @@ export type Validators = Array< ConfigValidator | SecretsValidator >; +export interface PreSaveConnectorHookParams { + connectorId: string; + config: Config; + secrets: Secrets; + logger: Logger; + request: KibanaRequest; + services: HookServices; + isUpdate: boolean; +} + +export interface PostSaveConnectorHookParams { + connectorId: string; + config: Config; + secrets: Secrets; + logger: Logger; + request: KibanaRequest; + services: HookServices; + isUpdate: boolean; + wasSuccessful: boolean; +} + +export interface PostDeleteConnectorHookParams { + connectorId: string; + config: Config; + logger: Logger; + services: HookServices; + request: KibanaRequest; +} + export interface SubActionConnectorType { id: string; name: string; @@ -92,6 +122,9 @@ export interface SubActionConnectorType { getKibanaPrivileges?: (args?: { params?: { subAction: string; subActionParams: Record }; }) => string[]; + preSaveHook?: (params: PreSaveConnectorHookParams) => Promise; + postSaveHook?: (params: PostSaveConnectorHookParams) => Promise; + postDeleteHook?: (params: PostDeleteConnectorHookParams) => Promise; } export interface ExecutorParams extends ActionTypeParams { diff --git a/x-pack/plugins/actions/server/types.ts b/x-pack/plugins/actions/server/types.ts index 487e7630d40f9..d7c3497edc376 100644 --- a/x-pack/plugins/actions/server/types.ts +++ b/x-pack/plugins/actions/server/types.ts @@ -16,6 +16,7 @@ import { SavedObjectReference, Logger, ISavedObjectsRepository, + IScopedClusterClient, } from '@kbn/core/server'; import { AnySchema } from 'joi'; import { SubActionConnector } from './sub_action_framework/sub_action_connector'; @@ -57,6 +58,10 @@ export interface UnsecuredServices { connectorTokenClient: ConnectorTokenClient; } +export interface HookServices { + scopedClusterClient: IScopedClusterClient; +} + export interface ActionsApiRequestHandlerContext { getActionsClient: () => ActionsClient; listTypes: ActionTypeRegistry['list']; @@ -138,6 +143,44 @@ export type RenderParameterTemplates = ( actionId?: string ) => Params; +export interface PreSaveConnectorHookParams< + Config extends ActionTypeConfig = ActionTypeConfig, + Secrets extends ActionTypeSecrets = ActionTypeSecrets +> { + connectorId: string; + config: Config; + secrets: Secrets; + logger: Logger; + request: KibanaRequest; + services: HookServices; + isUpdate: boolean; +} + +export interface PostSaveConnectorHookParams< + Config extends ActionTypeConfig = ActionTypeConfig, + Secrets extends ActionTypeSecrets = ActionTypeSecrets +> { + connectorId: string; + config: Config; + secrets: Secrets; + logger: Logger; + request: KibanaRequest; + services: HookServices; + isUpdate: boolean; + wasSuccessful: boolean; +} + +export interface PostDeleteConnectorHookParams< + Config extends ActionTypeConfig = ActionTypeConfig, + Secrets extends ActionTypeSecrets = ActionTypeSecrets +> { + connectorId: string; + config: Config; + logger: Logger; + request: KibanaRequest; + services: HookServices; +} + export interface ActionType< Config extends ActionTypeConfig = ActionTypeConfig, Secrets extends ActionTypeSecrets = ActionTypeSecrets, @@ -171,6 +214,9 @@ export interface ActionType< renderParameterTemplates?: RenderParameterTemplates; executor: ExecutorType; getService?: (params: ServiceParams) => SubActionConnector; + preSaveHook?: (params: PreSaveConnectorHookParams) => Promise; + postSaveHook?: (params: PostSaveConnectorHookParams) => Promise; + postDeleteHook?: (params: PostDeleteConnectorHookParams) => Promise; } export interface RawAction extends Record { diff --git a/x-pack/plugins/actions/server/usage/actions_telemetry.test.ts b/x-pack/plugins/actions/server/usage/actions_telemetry.test.ts index b4f6d785584a4..26c37b36566e4 100644 --- a/x-pack/plugins/actions/server/usage/actions_telemetry.test.ts +++ b/x-pack/plugins/actions/server/usage/actions_telemetry.test.ts @@ -1025,15 +1025,17 @@ describe('actions telemetry', () => { '.d3security': 2, '.gen-ai__Azure OpenAI': 3, '.gen-ai__OpenAI': 1, + '.gen-ai__Other': 1, }; const { countByType, countGenAiProviderTypes } = getCounts(aggs); expect(countByType).toEqual({ __d3security: 2, - '__gen-ai': 4, + '__gen-ai': 5, }); expect(countGenAiProviderTypes).toEqual({ 'Azure OpenAI': 3, OpenAI: 1, + Other: 1, }); }); }); diff --git a/x-pack/plugins/actions/server/usage/types.ts b/x-pack/plugins/actions/server/usage/types.ts index d9fe796c2b4e0..6bdfe316c76e2 100644 --- a/x-pack/plugins/actions/server/usage/types.ts +++ b/x-pack/plugins/actions/server/usage/types.ts @@ -51,6 +51,7 @@ export const byGenAiProviderTypeSchema: MakeSchemaFrom['count_by_t // Known providers: ['Azure OpenAI']: { type: 'long' }, ['OpenAI']: { type: 'long' }, + ['Other']: { type: 'long' }, }; export const byServiceProviderTypeSchema: MakeSchemaFrom['count_active_email_connectors_by_service_type'] = diff --git a/x-pack/plugins/alerting/common/rules_settings.ts b/x-pack/plugins/alerting/common/rules_settings.ts index 2a4162ca2c5d3..6dcfd377eeb7c 100644 --- a/x-pack/plugins/alerting/common/rules_settings.ts +++ b/x-pack/plugins/alerting/common/rules_settings.ts @@ -5,38 +5,28 @@ * 2.0. */ -export interface RulesSettingsModificationMetadata { - createdBy: string | null; - updatedBy: string | null; - createdAt: string; - updatedAt: string; -} +import type { + RulesSettingsFlappingProperties, + RulesSettingsQueryDelayProperties, +} from '@kbn/alerting-types'; -export interface RulesSettingsFlappingProperties { - enabled: boolean; - lookBackWindow: number; - statusChangeThreshold: number; -} +export { + MIN_LOOK_BACK_WINDOW, + MAX_LOOK_BACK_WINDOW, + MIN_STATUS_CHANGE_THRESHOLD, + MAX_STATUS_CHANGE_THRESHOLD, +} from '@kbn/alerting-types/flapping/latest'; -export type RulesSettingsFlapping = RulesSettingsFlappingProperties & - RulesSettingsModificationMetadata; - -export interface RulesSettingsQueryDelayProperties { - delay: number; -} - -export type RulesSettingsQueryDelay = RulesSettingsQueryDelayProperties & - RulesSettingsModificationMetadata; - -export interface RulesSettingsProperties { - flapping?: RulesSettingsFlappingProperties; - queryDelay?: RulesSettingsQueryDelayProperties; -} - -export interface RulesSettings { - flapping?: RulesSettingsFlapping; - queryDelay?: RulesSettingsQueryDelay; -} +export type { + RulesSettingsModificationMetadata, + RulesSettingsFlappingProperties, + RulesSettingsQueryDelayProperties, + RuleSpecificFlappingProperties, + RulesSettingsFlapping, + RulesSettingsQueryDelay, + RulesSettingsProperties, + RulesSettings, +} from '@kbn/alerting-types'; export const MIN_QUERY_DELAY = 0; export const MAX_QUERY_DELAY = 60; diff --git a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts index 82e8663bd6bf8..082d5ea6381df 100644 --- a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts +++ b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts @@ -807,6 +807,15 @@ describe('AlertingEventLogger', () => { expect(eventLogger.logEvent).toHaveBeenCalledWith(event); }); + + test('should log action event with uuid', () => { + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }); + alertingEventLogger.logAction({ ...action, uuid: 'abcdefg' }); + + const event = createActionExecuteRecord(ruleContext, ruleData, [alertSO], action); + + expect(eventLogger.logEvent).toHaveBeenCalledWith(event); + }); }); describe('done()', () => { diff --git a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts index f29e1e00473b2..1607f6090b10c 100644 --- a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts +++ b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts @@ -78,6 +78,9 @@ interface AlertOpts { export interface ActionOpts { id: string; + // uuid is typed as optional but in reality it is always + // populated - https://github.com/elastic/kibana/issues/195255 + uuid?: string; typeId: string; alertId?: string; alertGroup?: string; diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/action_scheduler.test.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/action_scheduler.test.ts index 600f6aedbe039..b6f250b47205e 100644 --- a/x-pack/plugins/alerting/server/task_runner/action_scheduler/action_scheduler.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/action_scheduler.test.ts @@ -60,7 +60,9 @@ const defaultSchedulerContext = getDefaultSchedulerContext( const defaultExecutionResponse = { errors: false, - items: [{ actionTypeId: 'test', id: '1', response: ExecutionResponseType.SUCCESS }], + items: [ + { actionTypeId: 'test', id: '1', uuid: '111-111', response: ExecutionResponseType.SUCCESS }, + ], }; let ruleRunMetricsStore: RuleRunMetricsStore; @@ -99,7 +101,7 @@ describe('Action Scheduler', () => { }); afterAll(() => clock.restore()); - test('enqueues execution per selected action', async () => { + test('schedules execution per selected action', async () => { const alerts = generateAlert({ id: 1 }); const actionScheduler = new ActionScheduler(getSchedulerContext()); await actionScheduler.run(alerts); @@ -138,6 +140,7 @@ describe('Action Scheduler', () => { "type": "SAVED_OBJECT", }, "spaceId": "test1", + "uuid": "111-111", }, ], ] @@ -146,6 +149,7 @@ describe('Action Scheduler', () => { expect(alertingEventLogger.logAction).toHaveBeenCalledTimes(1); expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith(1, { id: '1', + uuid: '111-111', typeId: 'test', alertId: '1', alertGroup: 'default', @@ -368,6 +372,7 @@ describe('Action Scheduler', () => { "type": "SAVED_OBJECT", }, "spaceId": "test1", + "uuid": "111-111", }, ], ] @@ -409,6 +414,7 @@ describe('Action Scheduler', () => { "type": "SAVED_OBJECT", }, "spaceId": "test1", + "uuid": "111-111", }, ], ] @@ -437,11 +443,13 @@ describe('Action Scheduler', () => { { actionTypeId: 'test2', id: '1', + uuid: '111-111', response: ExecutionResponseType.SUCCESS, }, { actionTypeId: 'test2', id: '2', + uuid: '222-222', response: ExecutionResponseType.SUCCESS, }, ], @@ -508,20 +516,23 @@ describe('Action Scheduler', () => { actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ errors: false, items: [ - { actionTypeId: 'test', id: '1', response: ExecutionResponseType.SUCCESS }, + { actionTypeId: 'test', id: '1', uuid: '222-222', response: ExecutionResponseType.SUCCESS }, { actionTypeId: 'test-action-type-id', id: '2', + uuid: '222-222', response: ExecutionResponseType.SUCCESS, }, { actionTypeId: 'another-action-type-id', id: '4', + uuid: '444-444', response: ExecutionResponseType.SUCCESS, }, { actionTypeId: 'another-action-type-id', id: '5', + uuid: '555-555', response: ExecutionResponseType.SUCCESS, }, ], @@ -537,6 +548,7 @@ describe('Action Scheduler', () => { contextVal: 'My other {{context.value}} goes here', stateVal: 'My other {{state.value}} goes here', }, + uuid: '222-222', }, { id: '3', @@ -547,6 +559,7 @@ describe('Action Scheduler', () => { contextVal: '{{context.value}} goes here', stateVal: '{{state.value}} goes here', }, + uuid: '333-333', }, { id: '4', @@ -557,6 +570,7 @@ describe('Action Scheduler', () => { contextVal: '{{context.value}} goes here', stateVal: '{{state.value}} goes here', }, + uuid: '444-444', }, { id: '5', @@ -567,6 +581,7 @@ describe('Action Scheduler', () => { contextVal: '{{context.value}} goes here', stateVal: '{{state.value}} goes here', }, + uuid: '555-555', }, ]; const actionScheduler = new ActionScheduler( @@ -612,16 +627,19 @@ describe('Action Scheduler', () => { { actionTypeId: 'test', id: '1', + uuid: '111-111', response: ExecutionResponseType.SUCCESS, }, { actionTypeId: 'test', id: '2', + uuid: '222-222', response: ExecutionResponseType.SUCCESS, }, { actionTypeId: 'test', id: '3', + uuid: '333-333', response: ExecutionResponseType.QUEUED_ACTIONS_LIMIT_ERROR, }, ], @@ -636,6 +654,7 @@ describe('Action Scheduler', () => { contextVal: 'My other {{context.value}} goes here', stateVal: 'My other {{state.value}} goes here', }, + uuid: '111-111', }, { id: '2', @@ -646,6 +665,7 @@ describe('Action Scheduler', () => { contextVal: 'My other {{context.value}} goes here', stateVal: 'My other {{state.value}} goes here', }, + uuid: '222-222', }, { id: '3', @@ -656,6 +676,7 @@ describe('Action Scheduler', () => { contextVal: '{{context.value}} goes here', stateVal: '{{state.value}} goes here', }, + uuid: '333-333', }, ]; const actionScheduler = new ActionScheduler( @@ -679,7 +700,7 @@ describe('Action Scheduler', () => { test('schedules alerts with recovered actions', async () => { const actions = [ { - id: '1', + id: 'action-2', group: 'recovered', actionTypeId: 'test', params: { @@ -689,6 +710,7 @@ describe('Action Scheduler', () => { alertVal: 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', }, + uuid: '222-222', }, ]; const actionScheduler = new ActionScheduler( @@ -711,7 +733,7 @@ describe('Action Scheduler', () => { "apiKey": "MTIzOmFiYw==", "consumer": "rule-consumer", "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", - "id": "1", + "id": "action-2", "params": Object { "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 1 goes here", "contextVal": "My goes here", @@ -734,6 +756,7 @@ describe('Action Scheduler', () => { "type": "SAVED_OBJECT", }, "spaceId": "test1", + "uuid": "222-222", }, ], ] @@ -883,6 +906,7 @@ describe('Action Scheduler', () => { { actionTypeId: 'testActionTypeId', id: '1', + uuid: '111-111', response: ExecutionResponseType.SUCCESS, }, ], @@ -914,6 +938,7 @@ describe('Action Scheduler', () => { message: 'New: {{alerts.new.count}} Ongoing: {{alerts.ongoing.count}} Recovered: {{alerts.recovered.count}}', }, + uuid: '111-111', }, ], }, @@ -957,6 +982,7 @@ describe('Action Scheduler', () => { "type": "SAVED_OBJECT", }, "spaceId": "test1", + "uuid": "111-111", }, ], ] @@ -964,6 +990,7 @@ describe('Action Scheduler', () => { expect(alertingEventLogger.logAction).toBeCalledWith({ alertSummary: { new: 1, ongoing: 0, recovered: 0 }, id: '1', + uuid: '111-111', typeId: 'testActionTypeId', }); }); @@ -1012,6 +1039,7 @@ describe('Action Scheduler', () => { { actionTypeId: 'testActionTypeId', id: '1', + uuid: '111-111', response: ExecutionResponseType.SUCCESS, }, ], @@ -1095,6 +1123,7 @@ describe('Action Scheduler', () => { "type": "SAVED_OBJECT", }, "spaceId": "test1", + "uuid": "111-111", }, ], ] @@ -1102,6 +1131,7 @@ describe('Action Scheduler', () => { expect(alertingEventLogger.logAction).toBeCalledWith({ alertSummary: { new: 1, ongoing: 0, recovered: 0 }, id: '1', + uuid: '111-111', typeId: 'testActionTypeId', }); }); @@ -1256,10 +1286,11 @@ describe('Action Scheduler', () => { actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ errors: false, items: [ - { actionTypeId: 'test', id: '1', response: ExecutionResponseType.SUCCESS }, + { actionTypeId: 'test', id: '1', uuid: '111-111', response: ExecutionResponseType.SUCCESS }, { actionTypeId: 'test', id: '2', + uuid: '222-222', response: ExecutionResponseType.SUCCESS, }, ], @@ -1276,6 +1307,7 @@ describe('Action Scheduler', () => { alertVal: 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', }, + uuid: '111-111', }, { id: '2', @@ -1288,6 +1320,7 @@ describe('Action Scheduler', () => { alertVal: 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', }, + uuid: '222-222', }, ]; const actionScheduler = new ActionScheduler( @@ -1333,6 +1366,7 @@ describe('Action Scheduler', () => { "type": "SAVED_OBJECT", }, "spaceId": "test1", + "uuid": "111-111", }, Object { "actionTypeId": "test", @@ -1362,6 +1396,7 @@ describe('Action Scheduler', () => { "type": "SAVED_OBJECT", }, "spaceId": "test1", + "uuid": "222-222", }, ], ] @@ -1448,6 +1483,7 @@ describe('Action Scheduler', () => { { actionTypeId: 'testActionTypeId', id: '1', + uuid: '111-111', response: ExecutionResponseType.SUCCESS, }, ], @@ -1518,6 +1554,7 @@ describe('Action Scheduler', () => { { actionTypeId: 'testActionTypeId', id: '1', + uuid: '111-111', response: ExecutionResponseType.SUCCESS, }, ], @@ -1541,7 +1578,7 @@ describe('Action Scheduler', () => { actions: [ { id: '1', - uuid: '111', + uuid: '111-111', group: 'default', actionTypeId: 'testActionTypeId', frequency: { @@ -1587,17 +1624,19 @@ describe('Action Scheduler', () => { ], source: { source: { id: '1', type: RULE_SAVED_OBJECT_TYPE }, type: 'SAVED_OBJECT' }, spaceId: 'test1', + uuid: '111-111', }, ]); expect(alertingEventLogger.logAction).toHaveBeenCalledWith({ alertGroup: 'default', alertId: '1', id: '1', + uuid: '111-111', typeId: 'testActionTypeId', }); expect(defaultSchedulerContext.logger.debug).toHaveBeenCalledTimes(1); expect(defaultSchedulerContext.logger.debug).toHaveBeenCalledWith( - '(2) alerts have been filtered out for: testActionTypeId:111' + '(2) alerts have been filtered out for: testActionTypeId:111-111' ); }); @@ -1840,6 +1879,7 @@ describe('Action Scheduler', () => { "type": "SAVED_OBJECT", }, "spaceId": "test1", + "uuid": "111-111", }, Object { "actionTypeId": "test", @@ -1869,6 +1909,7 @@ describe('Action Scheduler', () => { "type": "SAVED_OBJECT", }, "spaceId": "test1", + "uuid": "111-111", }, Object { "actionTypeId": "test", @@ -1898,6 +1939,7 @@ describe('Action Scheduler', () => { "type": "SAVED_OBJECT", }, "spaceId": "test1", + "uuid": "111-111", }, ], ] @@ -2261,12 +2303,13 @@ describe('Action Scheduler', () => { const executorParams = getSchedulerContext({ rule: { ...defaultSchedulerContext.rule, + actions: [], systemActions: [ { id: '1', actionTypeId: '.test-system-action', params: actionsParams, - uui: 'test', + uuid: 'test', }, ], }, @@ -2360,6 +2403,7 @@ describe('Action Scheduler', () => { "type": "SAVED_OBJECT", }, "spaceId": "test1", + "uuid": "test", }, ], ] @@ -2368,6 +2412,7 @@ describe('Action Scheduler', () => { expect(alertingEventLogger.logAction).toBeCalledWith({ alertSummary: { new: 1, ongoing: 0, recovered: 0 }, id: '1', + uuid: 'test', typeId: '.test-system-action', }); }); @@ -2387,6 +2432,7 @@ describe('Action Scheduler', () => { const executorParams = getSchedulerContext({ rule: { ...defaultSchedulerContext.rule, + actions: [], systemActions: [ { id: 'action-id', @@ -2443,6 +2489,7 @@ describe('Action Scheduler', () => { }, rule: { ...defaultSchedulerContext.rule, + actions: [], systemActions: [ { id: 'action-id', @@ -2477,6 +2524,7 @@ describe('Action Scheduler', () => { const executorParams = getSchedulerContext({ rule: { ...defaultSchedulerContext.rule, + actions: [], systemActions: [ { id: '1', diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/action_scheduler.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/action_scheduler.ts index 3b804ce3da413..44822657ba86f 100644 --- a/x-pack/plugins/alerting/server/task_runner/action_scheduler/action_scheduler.ts +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/action_scheduler.ts @@ -5,8 +5,6 @@ * 2.0. */ -import { getRuleDetailsRoute, triggersActionsRoute } from '@kbn/rule-data-utils'; -import { asSavedObjectExecutionSource } from '@kbn/actions-plugin/server'; import { createTaskRunError, isEphemeralTaskRejectedDueToCapacityError, @@ -19,77 +17,21 @@ import { } from '@kbn/actions-plugin/server/create_execute_function'; import { ActionsCompletion } from '@kbn/alerting-state-types'; import { chunk } from 'lodash'; -import { CombinedSummarizedAlerts, ThrottledActions } from '../../types'; -import { injectActionParams } from '../inject_action_params'; -import { ActionSchedulerOptions, IActionScheduler, RuleUrl } from './types'; -import { - transformActionParams, - TransformActionParamsOptions, - transformSummaryActionParams, -} from '../transform_action_params'; +import { ThrottledActions } from '../../types'; +import { ActionSchedulerOptions, ActionsToSchedule, IActionScheduler } from './types'; import { Alert } from '../../alert'; import { AlertInstanceContext, AlertInstanceState, - RuleAction, RuleTypeParams, RuleTypeState, - SanitizedRule, RuleAlertData, - RuleSystemAction, } from '../../../common'; -import { - generateActionHash, - getSummaryActionsFromTaskState, - getSummaryActionTimeBounds, - isActionOnInterval, -} from './rule_action_helper'; -import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; -import { ConnectorAdapter } from '../../connector_adapters/types'; +import { getSummaryActionsFromTaskState } from './lib'; import { withAlertingSpan } from '../lib'; import * as schedulers from './schedulers'; -interface LogAction { - id: string; - typeId: string; - alertId?: string; - alertGroup?: string; - alertSummary?: { - new: number; - ongoing: number; - recovered: number; - }; -} - -interface RunSummarizedActionArgs { - action: RuleAction; - summarizedAlerts: CombinedSummarizedAlerts; - spaceId: string; - bulkActions: EnqueueExecutionOptions[]; -} - -interface RunSystemActionArgs { - action: RuleSystemAction; - connectorAdapter: ConnectorAdapter; - summarizedAlerts: CombinedSummarizedAlerts; - rule: SanitizedRule; - ruleProducer: string; - spaceId: string; - bulkActions: EnqueueExecutionOptions[]; -} - -interface RunActionArgs< - State extends AlertInstanceState, - Context extends AlertInstanceContext, - ActionGroupIds extends string, - RecoveryActionGroupId extends string -> { - action: RuleAction; - alert: Alert; - ruleId: string; - spaceId: string; - bulkActions: EnqueueExecutionOptions[]; -} +const BULK_SCHEDULE_CHUNK_SIZE = 1000; export interface RunResult { throttledSummaryActions: ThrottledActions; @@ -110,9 +52,6 @@ export class ActionScheduler< > = []; private ephemeralActionsToSchedule: number; - private CHUNK_SIZE = 1000; - private ruleTypeActionGroups?: Map; - private previousStartedAt: Date | null; constructor( private readonly context: ActionSchedulerOptions< @@ -127,11 +66,6 @@ export class ActionScheduler< > ) { this.ephemeralActionsToSchedule = context.taskRunnerContext.maxEphemeralActionsPerRule; - this.ruleTypeActionGroups = new Map( - context.ruleType.actionGroups.map((actionGroup) => [actionGroup.id, actionGroup.name]) - ); - this.previousStartedAt = context.previousStartedAt; - for (const [_, scheduler] of Object.entries(schedulers)) { this.schedulers.push(new scheduler(context)); } @@ -148,148 +82,30 @@ export class ActionScheduler< summaryActions: this.context.taskInstance.state?.summaryActions, }); - const executables = []; + const allActionsToScheduleResult: ActionsToSchedule[] = []; for (const scheduler of this.schedulers) { - executables.push( - ...(await scheduler.generateExecutables({ alerts, throttledSummaryActions })) + allActionsToScheduleResult.push( + ...(await scheduler.getActionsToSchedule({ alerts, throttledSummaryActions })) ); } - if (executables.length === 0) { + if (allActionsToScheduleResult.length === 0) { return { throttledSummaryActions }; } - const { - CHUNK_SIZE, - context: { - logger, - alertingEventLogger, - ruleRunMetricsStore, - taskRunnerContext: { actionsConfigMap }, - taskInstance: { - params: { spaceId, alertId: ruleId }, - }, - }, - } = this; - - const logActions: Record = {}; - const bulkActions: EnqueueExecutionOptions[] = []; - let bulkActionsResponse: ExecutionResponseItem[] = []; + const bulkScheduleRequest: EnqueueExecutionOptions[] = []; - this.context.ruleRunMetricsStore.incrementNumberOfGeneratedActions(executables.length); - - for (const { action, alert, summarizedAlerts } of executables) { - const { actionTypeId } = action; - - ruleRunMetricsStore.incrementNumberOfGeneratedActionsByConnectorType(actionTypeId); - if (ruleRunMetricsStore.hasReachedTheExecutableActionsLimit(actionsConfigMap)) { - ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType({ - actionTypeId, - status: ActionsCompletion.PARTIAL, - }); - logger.debug( - `Rule "${this.context.rule.id}" skipped scheduling action "${action.id}" because the maximum number of allowed actions has been reached.` - ); - break; - } - - if ( - ruleRunMetricsStore.hasReachedTheExecutableActionsLimitByConnectorType({ - actionTypeId, - actionsConfigMap, - }) - ) { - if (!ruleRunMetricsStore.hasConnectorTypeReachedTheLimit(actionTypeId)) { - logger.debug( - `Rule "${this.context.rule.id}" skipped scheduling action "${action.id}" because the maximum number of allowed actions for connector type ${actionTypeId} has been reached.` - ); - } - ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType({ - actionTypeId, - status: ActionsCompletion.PARTIAL, - }); - continue; - } - - if (!this.isExecutableAction(action)) { - this.context.logger.warn( - `Rule "${this.context.taskInstance.params.alertId}" skipped scheduling action "${action.id}" because it is disabled` - ); - continue; - } - - ruleRunMetricsStore.incrementNumberOfTriggeredActions(); - ruleRunMetricsStore.incrementNumberOfTriggeredActionsByConnectorType(actionTypeId); - - if (!this.isSystemAction(action) && summarizedAlerts) { - const defaultAction = action as RuleAction; - if (isActionOnInterval(action)) { - throttledSummaryActions[defaultAction.uuid!] = { date: new Date().toISOString() }; - } - - logActions[defaultAction.id] = await this.runSummarizedAction({ - action, - summarizedAlerts, - spaceId, - bulkActions, - }); - } else if (summarizedAlerts && this.isSystemAction(action)) { - const hasConnectorAdapter = this.context.taskRunnerContext.connectorAdapterRegistry.has( - action.actionTypeId - ); - /** - * System actions without an adapter - * cannot be executed - * - */ - if (!hasConnectorAdapter) { - this.context.logger.warn( - `Rule "${this.context.taskInstance.params.alertId}" skipped scheduling system action "${action.id}" because no connector adapter is configured` - ); - - continue; - } - - const connectorAdapter = this.context.taskRunnerContext.connectorAdapterRegistry.get( - action.actionTypeId - ); - logActions[action.id] = await this.runSystemAction({ - action, - connectorAdapter, - summarizedAlerts, - rule: this.context.rule, - ruleProducer: this.context.ruleType.producer, - spaceId, - bulkActions, - }); - } else if (!this.isSystemAction(action) && alert) { - const defaultAction = action as RuleAction; - logActions[defaultAction.id] = await this.runAction({ - action, - spaceId, - alert, - ruleId, - bulkActions, - }); - - const actionGroup = defaultAction.group; - if (!this.isRecoveredAlert(actionGroup)) { - if (isActionOnInterval(action)) { - alert.updateLastScheduledActions( - defaultAction.group as ActionGroupIds, - generateActionHash(action), - defaultAction.uuid - ); - } else { - alert.updateLastScheduledActions(defaultAction.group as ActionGroupIds); - } - alert.unscheduleActions(); - } - } + for (const result of allActionsToScheduleResult) { + await this.runActionAsEphemeralOrAddToBulkScheduleRequest({ + enqueueOptions: result.actionToEnqueue, + bulkScheduleRequest, + }); } - if (!!bulkActions.length) { - for (const c of chunk(bulkActions, CHUNK_SIZE)) { + let bulkScheduleResponse: ExecutionResponseItem[] = []; + + if (!!bulkScheduleRequest.length) { + for (const c of chunk(bulkScheduleRequest, BULK_SCHEDULE_CHUNK_SIZE)) { let enqueueResponse; try { enqueueResponse = await withAlertingSpan('alerting:bulk-enqueue-actions', () => @@ -302,7 +118,7 @@ export class ActionScheduler< throw createTaskRunError(e, TaskErrorSource.FRAMEWORK); } if (enqueueResponse.errors) { - bulkActionsResponse = bulkActionsResponse.concat( + bulkScheduleResponse = bulkScheduleResponse.concat( enqueueResponse.items.filter( (i) => i.response === ExecutionResponseType.QUEUED_ACTIONS_LIMIT_ERROR ) @@ -311,280 +127,53 @@ export class ActionScheduler< } } - if (!!bulkActionsResponse.length) { - for (const r of bulkActionsResponse) { + const actionsToNotLog: string[] = []; + if (!!bulkScheduleResponse.length) { + for (const r of bulkScheduleResponse) { if (r.response === ExecutionResponseType.QUEUED_ACTIONS_LIMIT_ERROR) { - ruleRunMetricsStore.setHasReachedQueuedActionsLimit(true); - ruleRunMetricsStore.decrementNumberOfTriggeredActions(); - ruleRunMetricsStore.decrementNumberOfTriggeredActionsByConnectorType(r.actionTypeId); - ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType({ + this.context.ruleRunMetricsStore.setHasReachedQueuedActionsLimit(true); + this.context.ruleRunMetricsStore.decrementNumberOfTriggeredActions(); + this.context.ruleRunMetricsStore.decrementNumberOfTriggeredActionsByConnectorType( + r.actionTypeId + ); + this.context.ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType({ actionTypeId: r.actionTypeId, status: ActionsCompletion.PARTIAL, }); - logger.debug( + this.context.logger.debug( `Rule "${this.context.rule.id}" skipped scheduling action "${r.id}" because the maximum number of queued actions has been reached.` ); - delete logActions[r.id]; + const uuid = r.uuid; + // uuid is typed as optional but in reality it is always + // populated - https://github.com/elastic/kibana/issues/195255 + if (uuid) { + actionsToNotLog.push(uuid); + } } } } - const logActionsValues = Object.values(logActions); - if (!!logActionsValues.length) { - for (const action of logActionsValues) { - alertingEventLogger.logAction(action); - } - } - - return { throttledSummaryActions }; - } - - private async runSummarizedAction({ - action, - summarizedAlerts, - spaceId, - bulkActions, - }: RunSummarizedActionArgs): Promise { - const { start, end } = getSummaryActionTimeBounds( - action, - this.context.rule.schedule, - this.previousStartedAt + const actionsToLog = allActionsToScheduleResult.filter( + (result) => result.actionToLog.uuid && !actionsToNotLog.includes(result.actionToLog.uuid) ); - const ruleUrl = this.buildRuleUrl(spaceId, start, end); - const actionToRun = { - ...action, - params: injectActionParams({ - actionTypeId: action.actionTypeId, - ruleUrl, - ruleName: this.context.rule.name, - actionParams: transformSummaryActionParams({ - alerts: summarizedAlerts, - rule: this.context.rule, - ruleTypeId: this.context.ruleType.id, - actionId: action.id, - actionParams: action.params, - spaceId, - actionsPlugin: this.context.taskRunnerContext.actionsPlugin, - actionTypeId: action.actionTypeId, - kibanaBaseUrl: this.context.taskRunnerContext.kibanaBaseUrl, - ruleUrl: ruleUrl?.absoluteUrl, - }), - }), - }; - await this.actionRunOrAddToBulk({ - enqueueOptions: this.getEnqueueOptions(actionToRun), - bulkActions, - }); - - return { - id: action.id, - typeId: action.actionTypeId, - alertSummary: { - new: summarizedAlerts.new.count, - ongoing: summarizedAlerts.ongoing.count, - recovered: summarizedAlerts.recovered.count, - }, - }; - } - - private async runSystemAction({ - action, - spaceId, - connectorAdapter, - summarizedAlerts, - rule, - ruleProducer, - bulkActions, - }: RunSystemActionArgs): Promise { - const ruleUrl = this.buildRuleUrl(spaceId); - - const connectorAdapterActionParams = connectorAdapter.buildActionParams({ - alerts: summarizedAlerts, - rule: { - id: rule.id, - tags: rule.tags, - name: rule.name, - consumer: rule.consumer, - producer: ruleProducer, - }, - ruleUrl: ruleUrl?.absoluteUrl, - spaceId, - params: action.params, - }); - - const actionToRun = Object.assign(action, { params: connectorAdapterActionParams }); - - await this.actionRunOrAddToBulk({ - enqueueOptions: this.getEnqueueOptions(actionToRun), - bulkActions, - }); - - return { - id: action.id, - typeId: action.actionTypeId, - alertSummary: { - new: summarizedAlerts.new.count, - ongoing: summarizedAlerts.ongoing.count, - recovered: summarizedAlerts.recovered.count, - }, - }; - } - - private async runAction({ - action, - spaceId, - alert, - ruleId, - bulkActions, - }: RunActionArgs): Promise { - const ruleUrl = this.buildRuleUrl(spaceId); - const executableAlert = alert!; - const actionGroup = action.group as ActionGroupIds; - const transformActionParamsOptions: TransformActionParamsOptions = { - actionsPlugin: this.context.taskRunnerContext.actionsPlugin, - alertId: ruleId, - alertType: this.context.ruleType.id, - actionTypeId: action.actionTypeId, - alertName: this.context.rule.name, - spaceId, - tags: this.context.rule.tags, - alertInstanceId: executableAlert.getId(), - alertUuid: executableAlert.getUuid(), - alertActionGroup: actionGroup, - alertActionGroupName: this.ruleTypeActionGroups!.get(actionGroup)!, - context: executableAlert.getContext(), - actionId: action.id, - state: executableAlert.getState(), - kibanaBaseUrl: this.context.taskRunnerContext.kibanaBaseUrl, - alertParams: this.context.rule.params, - actionParams: action.params, - flapping: executableAlert.getFlapping(), - ruleUrl: ruleUrl?.absoluteUrl, - }; - - if (executableAlert.isAlertAsData()) { - transformActionParamsOptions.aadAlert = executableAlert.getAlertAsData(); - } - const actionToRun = { - ...action, - params: injectActionParams({ - actionTypeId: action.actionTypeId, - ruleUrl, - ruleName: this.context.rule.name, - actionParams: transformActionParams(transformActionParamsOptions), - }), - }; - - await this.actionRunOrAddToBulk({ - enqueueOptions: this.getEnqueueOptions(actionToRun), - bulkActions, - }); - - return { - id: action.id, - typeId: action.actionTypeId, - alertId: alert.getId(), - alertGroup: action.group, - }; - } - - private isExecutableAction(action: RuleAction | RuleSystemAction) { - return this.context.taskRunnerContext.actionsPlugin.isActionExecutable( - action.id, - action.actionTypeId, - { - notifyUsage: true, + if (!!actionsToLog.length) { + for (const action of actionsToLog) { + this.context.alertingEventLogger.logAction(action.actionToLog); } - ); - } - - private isSystemAction(action?: RuleAction | RuleSystemAction): action is RuleSystemAction { - return this.context.taskRunnerContext.actionsPlugin.isSystemActionConnector(action?.id ?? ''); - } - - private isRecoveredAlert(actionGroup: string) { - return actionGroup === this.context.ruleType.recoveryActionGroup.id; - } - - private buildRuleUrl(spaceId: string, start?: number, end?: number): RuleUrl | undefined { - if (!this.context.taskRunnerContext.kibanaBaseUrl) { - return; } - const relativePath = this.context.ruleType.getViewInAppRelativeUrl - ? this.context.ruleType.getViewInAppRelativeUrl({ rule: this.context.rule, start, end }) - : `${triggersActionsRoute}${getRuleDetailsRoute(this.context.rule.id)}`; - - try { - const basePathname = new URL(this.context.taskRunnerContext.kibanaBaseUrl).pathname; - const basePathnamePrefix = basePathname !== '/' ? `${basePathname}` : ''; - const spaceIdSegment = spaceId !== 'default' ? `/s/${spaceId}` : ''; - - const ruleUrl = new URL( - [basePathnamePrefix, spaceIdSegment, relativePath].join(''), - this.context.taskRunnerContext.kibanaBaseUrl - ); - - return { - absoluteUrl: ruleUrl.toString(), - kibanaBaseUrl: this.context.taskRunnerContext.kibanaBaseUrl, - basePathname: basePathnamePrefix, - spaceIdSegment, - relativePath, - }; - } catch (error) { - this.context.logger.debug( - `Rule "${this.context.rule.id}" encountered an error while constructing the rule.url variable: ${error.message}` - ); - return; - } - } - - private getEnqueueOptions(action: RuleAction | RuleSystemAction): EnqueueExecutionOptions { - const { - context: { - apiKey, - ruleConsumer, - executionId, - taskInstance: { - params: { spaceId, alertId: ruleId }, - }, - }, - } = this; - - const namespace = spaceId === 'default' ? {} : { namespace: spaceId }; - return { - id: action.id, - params: action.params, - spaceId, - apiKey: apiKey ?? null, - consumer: ruleConsumer, - source: asSavedObjectExecutionSource({ - id: ruleId, - type: RULE_SAVED_OBJECT_TYPE, - }), - executionId, - relatedSavedObjects: [ - { - id: ruleId, - type: RULE_SAVED_OBJECT_TYPE, - namespace: namespace.namespace, - typeId: this.context.ruleType.id, - }, - ], - actionTypeId: action.actionTypeId, - }; + return { throttledSummaryActions }; } - private async actionRunOrAddToBulk({ + private async runActionAsEphemeralOrAddToBulkScheduleRequest({ enqueueOptions, - bulkActions, + bulkScheduleRequest, }: { enqueueOptions: EnqueueExecutionOptions; - bulkActions: EnqueueExecutionOptions[]; + bulkScheduleRequest: EnqueueExecutionOptions[]; }) { if ( this.context.taskRunnerContext.supportsEphemeralTasks && @@ -595,11 +184,11 @@ export class ActionScheduler< await this.context.actionsClient!.ephemeralEnqueuedExecution(enqueueOptions); } catch (err) { if (isEphemeralTaskRejectedDueToCapacityError(err)) { - bulkActions.push(enqueueOptions); + bulkScheduleRequest.push(enqueueOptions); } } } else { - bulkActions.push(enqueueOptions); + bulkScheduleRequest.push(enqueueOptions); } } } diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/build_rule_url.test.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/build_rule_url.test.ts new file mode 100644 index 0000000000000..cb1f3c60fd992 --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/build_rule_url.test.ts @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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-logging-server-mocks'; +import { buildRuleUrl } from './build_rule_url'; +import { getRule } from '../test_fixtures'; + +const logger = loggingSystemMock.create().get(); +const rule = getRule(); + +describe('buildRuleUrl', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should return undefined if kibanaBaseUrl is not provided', () => { + expect( + buildRuleUrl({ + kibanaBaseUrl: undefined, + logger, + rule, + spaceId: 'default', + }) + ).toBeUndefined(); + }); + + test('should return the expected URL', () => { + expect( + buildRuleUrl({ + kibanaBaseUrl: 'http://localhost:5601', + logger, + rule, + spaceId: 'default', + }) + ).toEqual({ + absoluteUrl: + 'http://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/1', + basePathname: '', + kibanaBaseUrl: 'http://localhost:5601', + relativePath: '/app/management/insightsAndAlerting/triggersActions/rule/1', + spaceIdSegment: '', + }); + }); + + test('should return the expected URL for custom space', () => { + expect( + buildRuleUrl({ + kibanaBaseUrl: 'http://localhost:5601', + logger, + rule, + spaceId: 'my-special-space', + }) + ).toEqual({ + absoluteUrl: + 'http://localhost:5601/s/my-special-space/app/management/insightsAndAlerting/triggersActions/rule/1', + basePathname: '', + kibanaBaseUrl: 'http://localhost:5601', + relativePath: '/app/management/insightsAndAlerting/triggersActions/rule/1', + spaceIdSegment: '/s/my-special-space', + }); + }); + + test('should return the expected URL when getViewInAppRelativeUrl is defined', () => { + expect( + buildRuleUrl({ + getViewInAppRelativeUrl: ({ rule: r }) => `/app/test/my-custom-rule-page/${r.id}`, + kibanaBaseUrl: 'http://localhost:5601', + logger, + rule, + spaceId: 'default', + }) + ).toEqual({ + absoluteUrl: 'http://localhost:5601/app/test/my-custom-rule-page/1', + basePathname: '', + kibanaBaseUrl: 'http://localhost:5601', + relativePath: '/app/test/my-custom-rule-page/1', + spaceIdSegment: '', + }); + }); + + test('should return the expected URL when start, end and getViewInAppRelativeUrl is defined', () => { + expect( + buildRuleUrl({ + end: 987654321, + getViewInAppRelativeUrl: ({ rule: r, start: s, end: e }) => + `/app/test/my-custom-rule-page/${r.id}?start=${s}&end=${e}`, + kibanaBaseUrl: 'http://localhost:5601', + logger, + rule, + start: 123456789, + spaceId: 'default', + }) + ).toEqual({ + absoluteUrl: + 'http://localhost:5601/app/test/my-custom-rule-page/1?start=123456789&end=987654321', + basePathname: '', + kibanaBaseUrl: 'http://localhost:5601', + relativePath: '/app/test/my-custom-rule-page/1?start=123456789&end=987654321', + spaceIdSegment: '', + }); + }); + + test('should return the expected URL when start and end are defined but getViewInAppRelativeUrl is undefined', () => { + expect( + buildRuleUrl({ + end: 987654321, + kibanaBaseUrl: 'http://localhost:5601', + logger, + rule, + start: 123456789, + spaceId: 'default', + }) + ).toEqual({ + absoluteUrl: + 'http://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/1', + basePathname: '', + kibanaBaseUrl: 'http://localhost:5601', + relativePath: '/app/management/insightsAndAlerting/triggersActions/rule/1', + spaceIdSegment: '', + }); + }); + + test('should return undefined if base url is invalid', () => { + expect( + buildRuleUrl({ + kibanaBaseUrl: 'foo-url', + logger, + rule, + spaceId: 'default', + }) + ).toBeUndefined(); + + expect(logger.debug).toHaveBeenCalledWith( + `Rule "1" encountered an error while constructing the rule.url variable: Invalid URL: foo-url` + ); + }); +}); diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/build_rule_url.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/build_rule_url.ts new file mode 100644 index 0000000000000..3df27a512c7f9 --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/build_rule_url.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 { Logger } from '@kbn/logging'; +import { RuleTypeParams, SanitizedRule } from '@kbn/alerting-types'; +import { getRuleDetailsRoute, triggersActionsRoute } from '@kbn/rule-data-utils'; +import { GetViewInAppRelativeUrlFn } from '../../../types'; + +interface BuildRuleUrlOpts { + end?: number; + getViewInAppRelativeUrl?: GetViewInAppRelativeUrlFn; + kibanaBaseUrl: string | undefined; + logger: Logger; + rule: SanitizedRule; + spaceId: string; + start?: number; +} + +interface BuildRuleUrlResult { + absoluteUrl: string; + basePathname: string; + kibanaBaseUrl: string; + relativePath: string; + spaceIdSegment: string; +} + +export const buildRuleUrl = ( + opts: BuildRuleUrlOpts +): BuildRuleUrlResult | undefined => { + if (!opts.kibanaBaseUrl) { + return; + } + + const relativePath = opts.getViewInAppRelativeUrl + ? opts.getViewInAppRelativeUrl({ rule: opts.rule, start: opts.start, end: opts.end }) + : `${triggersActionsRoute}${getRuleDetailsRoute(opts.rule.id)}`; + + try { + const basePathname = new URL(opts.kibanaBaseUrl).pathname; + const basePathnamePrefix = basePathname !== '/' ? `${basePathname}` : ''; + const spaceIdSegment = opts.spaceId !== 'default' ? `/s/${opts.spaceId}` : ''; + + const ruleUrl = new URL( + [basePathnamePrefix, spaceIdSegment, relativePath].join(''), + opts.kibanaBaseUrl + ); + + return { + absoluteUrl: ruleUrl.toString(), + kibanaBaseUrl: opts.kibanaBaseUrl, + basePathname: basePathnamePrefix, + spaceIdSegment, + relativePath, + }; + } catch (error) { + opts.logger.debug( + `Rule "${opts.rule.id}" encountered an error while constructing the rule.url variable: ${error.message}` + ); + return; + } +}; diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/format_action_to_enqueue.test.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/format_action_to_enqueue.test.ts new file mode 100644 index 0000000000000..02ff513c5b639 --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/format_action_to_enqueue.test.ts @@ -0,0 +1,222 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RULE_SAVED_OBJECT_TYPE } from '../../..'; +import { formatActionToEnqueue } from './format_action_to_enqueue'; + +describe('formatActionToEnqueue', () => { + test('should format a rule action as expected', () => { + expect( + formatActionToEnqueue({ + action: { + id: '1', + group: 'default', + actionTypeId: 'test', + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '111-111', + }, + apiKey: 'MTIzOmFiYw==', + executionId: '123', + ruleConsumer: 'rule-consumer', + ruleId: 'aaa', + ruleTypeId: 'security-rule', + spaceId: 'default', + }) + ).toEqual({ + id: '1', + uuid: '111-111', + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + spaceId: 'default', + apiKey: 'MTIzOmFiYw==', + consumer: 'rule-consumer', + source: { + source: { + id: 'aaa', + type: RULE_SAVED_OBJECT_TYPE, + }, + type: 'SAVED_OBJECT', + }, + executionId: '123', + relatedSavedObjects: [ + { + id: 'aaa', + type: RULE_SAVED_OBJECT_TYPE, + namespace: undefined, + typeId: 'security-rule', + }, + ], + actionTypeId: 'test', + }); + }); + + test('should format a rule action with null apiKey as expected', () => { + expect( + formatActionToEnqueue({ + action: { + id: '1', + group: 'default', + actionTypeId: 'test', + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '111-111', + }, + apiKey: null, + executionId: '123', + ruleConsumer: 'rule-consumer', + ruleId: 'aaa', + ruleTypeId: 'security-rule', + spaceId: 'default', + }) + ).toEqual({ + id: '1', + uuid: '111-111', + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + spaceId: 'default', + apiKey: null, + consumer: 'rule-consumer', + source: { + source: { + id: 'aaa', + type: RULE_SAVED_OBJECT_TYPE, + }, + type: 'SAVED_OBJECT', + }, + executionId: '123', + relatedSavedObjects: [ + { + id: 'aaa', + type: RULE_SAVED_OBJECT_TYPE, + namespace: undefined, + typeId: 'security-rule', + }, + ], + actionTypeId: 'test', + }); + }); + + test('should format a rule action in a custom space as expected', () => { + expect( + formatActionToEnqueue({ + action: { + id: '1', + group: 'default', + actionTypeId: 'test', + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '111-111', + }, + apiKey: 'MTIzOmFiYw==', + executionId: '123', + ruleConsumer: 'rule-consumer', + ruleId: 'aaa', + ruleTypeId: 'security-rule', + spaceId: 'my-special-space', + }) + ).toEqual({ + id: '1', + uuid: '111-111', + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + spaceId: 'my-special-space', + apiKey: 'MTIzOmFiYw==', + consumer: 'rule-consumer', + source: { + source: { + id: 'aaa', + type: RULE_SAVED_OBJECT_TYPE, + }, + type: 'SAVED_OBJECT', + }, + executionId: '123', + relatedSavedObjects: [ + { + id: 'aaa', + type: RULE_SAVED_OBJECT_TYPE, + namespace: 'my-special-space', + typeId: 'security-rule', + }, + ], + actionTypeId: 'test', + }); + }); + + test('should format a system action as expected', () => { + expect( + formatActionToEnqueue({ + action: { + id: '1', + actionTypeId: '.test-system-action', + params: { myParams: 'test' }, + uuid: 'xxxyyyyzzzz', + }, + apiKey: 'MTIzOmFiYw==', + executionId: '123', + ruleConsumer: 'rule-consumer', + ruleId: 'aaa', + ruleTypeId: 'security-rule', + spaceId: 'default', + }) + ).toEqual({ + id: '1', + uuid: 'xxxyyyyzzzz', + params: { myParams: 'test' }, + spaceId: 'default', + apiKey: 'MTIzOmFiYw==', + consumer: 'rule-consumer', + source: { + source: { + id: 'aaa', + type: RULE_SAVED_OBJECT_TYPE, + }, + type: 'SAVED_OBJECT', + }, + executionId: '123', + relatedSavedObjects: [ + { + id: 'aaa', + type: RULE_SAVED_OBJECT_TYPE, + namespace: undefined, + typeId: 'security-rule', + }, + ], + actionTypeId: '.test-system-action', + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/format_action_to_enqueue.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/format_action_to_enqueue.ts new file mode 100644 index 0000000000000..af560a19ab9be --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/format_action_to_enqueue.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RuleAction, RuleSystemAction } from '@kbn/alerting-types'; +import { asSavedObjectExecutionSource } from '@kbn/actions-plugin/server'; +import { RULE_SAVED_OBJECT_TYPE } from '../../..'; + +interface FormatActionToEnqueueOpts { + action: RuleAction | RuleSystemAction; + apiKey: string | null; + executionId: string; + ruleConsumer: string; + ruleId: string; + ruleTypeId: string; + spaceId: string; +} + +export const formatActionToEnqueue = (opts: FormatActionToEnqueueOpts) => { + const { action, apiKey, executionId, ruleConsumer, ruleId, ruleTypeId, spaceId } = opts; + + const namespace = spaceId === 'default' ? {} : { namespace: spaceId }; + return { + id: action.id, + uuid: action.uuid, + params: action.params, + spaceId, + apiKey: apiKey ?? null, + consumer: ruleConsumer, + source: asSavedObjectExecutionSource({ + id: ruleId, + type: RULE_SAVED_OBJECT_TYPE, + }), + executionId, + relatedSavedObjects: [ + { + id: ruleId, + type: RULE_SAVED_OBJECT_TYPE, + namespace: namespace.namespace, + typeId: ruleTypeId, + }, + ], + actionTypeId: action.actionTypeId, + }; +}; diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/get_summarized_alerts.test.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/get_summarized_alerts.test.ts similarity index 95% rename from x-pack/plugins/alerting/server/task_runner/action_scheduler/get_summarized_alerts.test.ts rename to x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/get_summarized_alerts.test.ts index 9afd0647094eb..036c49c51d1be 100644 --- a/x-pack/plugins/alerting/server/task_runner/action_scheduler/get_summarized_alerts.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/get_summarized_alerts.test.ts @@ -6,10 +6,10 @@ */ import { getSummarizedAlerts } from './get_summarized_alerts'; -import { alertsClientMock } from '../../alerts_client/alerts_client.mock'; -import { mockAAD } from '../fixtures'; +import { alertsClientMock } from '../../../alerts_client/alerts_client.mock'; +import { mockAAD } from '../../fixtures'; import { ALERT_UUID } from '@kbn/rule-data-utils'; -import { generateAlert } from './test_fixtures'; +import { generateAlert } from '../test_fixtures'; import { getErrorSource } from '@kbn/task-manager-plugin/server/task_running'; const alertsClient = alertsClientMock.create(); diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/get_summarized_alerts.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/get_summarized_alerts.ts similarity index 98% rename from x-pack/plugins/alerting/server/task_runner/action_scheduler/get_summarized_alerts.ts rename to x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/get_summarized_alerts.ts index df667a3e20775..00e155856d946 100644 --- a/x-pack/plugins/alerting/server/task_runner/action_scheduler/get_summarized_alerts.ts +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/get_summarized_alerts.ts @@ -7,13 +7,13 @@ import { ALERT_UUID } from '@kbn/rule-data-utils'; import { createTaskRunError, TaskErrorSource } from '@kbn/task-manager-plugin/server'; -import { GetSummarizedAlertsParams, IAlertsClient } from '../../alerts_client/types'; +import { GetSummarizedAlertsParams, IAlertsClient } from '../../../alerts_client/types'; import { AlertInstanceContext, AlertInstanceState, CombinedSummarizedAlerts, RuleAlertData, -} from '../../types'; +} from '../../../types'; interface GetSummarizedAlertsOpts< State extends AlertInstanceState, diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/index.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/index.ts new file mode 100644 index 0000000000000..1bd78f302d00c --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { buildRuleUrl } from './build_rule_url'; +export { formatActionToEnqueue } from './format_action_to_enqueue'; +export { getSummarizedAlerts } from './get_summarized_alerts'; +export { + isSummaryAction, + isActionOnInterval, + isSummaryActionThrottled, + generateActionHash, + getSummaryActionsFromTaskState, + getSummaryActionTimeBounds, + logNumberOfFilteredAlerts, +} from './rule_action_helper'; +export { shouldScheduleAction } from './should_schedule_action'; diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/rule_action_helper.test.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/rule_action_helper.test.ts similarity index 99% rename from x-pack/plugins/alerting/server/task_runner/action_scheduler/rule_action_helper.test.ts rename to x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/rule_action_helper.test.ts index cc8a0a1b0cde5..1adb68a951351 100644 --- a/x-pack/plugins/alerting/server/task_runner/action_scheduler/rule_action_helper.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/rule_action_helper.test.ts @@ -7,7 +7,7 @@ import { Logger } from '@kbn/logging'; import { loggingSystemMock } from '@kbn/core/server/mocks'; -import { RuleAction } from '../../types'; +import { RuleAction } from '../../../types'; import { generateActionHash, getSummaryActionsFromTaskState, diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/rule_action_helper.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/rule_action_helper.ts similarity index 99% rename from x-pack/plugins/alerting/server/task_runner/action_scheduler/rule_action_helper.ts rename to x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/rule_action_helper.ts index 67223b0728689..c3ef79b3086d8 100644 --- a/x-pack/plugins/alerting/server/task_runner/action_scheduler/rule_action_helper.ts +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/rule_action_helper.ts @@ -12,7 +12,7 @@ import { RuleAction, RuleNotifyWhenTypeValues, ThrottledActions, -} from '../../../common'; +} from '../../../../common'; export const isSummaryAction = (action?: RuleAction) => { return action?.frequency?.summary ?? false; diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/should_schedule_action.test.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/should_schedule_action.test.ts new file mode 100644 index 0000000000000..7ebd65fab005d --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/should_schedule_action.test.ts @@ -0,0 +1,195 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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-logging-server-mocks'; +import { shouldScheduleAction } from './should_schedule_action'; +import { ruleRunMetricsStoreMock } from '../../../lib/rule_run_metrics_store.mock'; +import { ActionsCompletion } from '@kbn/alerting-state-types'; + +const logger = loggingSystemMock.create().get(); +const ruleRunMetricsStore = ruleRunMetricsStoreMock.create(); + +describe('shouldScheduleAction', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should return false if the the limit of executable actions has been reached', () => { + ruleRunMetricsStore.hasReachedTheExecutableActionsLimit.mockReturnValueOnce(true); + expect( + shouldScheduleAction({ + action: { + id: '1', + group: 'default', + actionTypeId: 'test-action-type-id', + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '111-111', + }, + actionsConfigMap: { + default: { max: 4 }, + 'test-action-type-id': { max: 2 }, + }, + isActionExecutable: () => true, + logger, + ruleId: '1', + ruleRunMetricsStore, + }) + ).toEqual(false); + + expect(ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType).toHaveBeenCalledWith({ + actionTypeId: 'test-action-type-id', + status: ActionsCompletion.PARTIAL, + }); + expect(logger.debug).toHaveBeenCalledWith( + `Rule "1" skipped scheduling action "1" because the maximum number of allowed actions has been reached.` + ); + }); + + test('should return false if the the limit of executable actions for this action type has been reached', () => { + ruleRunMetricsStore.hasReachedTheExecutableActionsLimitByConnectorType.mockReturnValueOnce( + true + ); + ruleRunMetricsStore.hasConnectorTypeReachedTheLimit.mockReturnValueOnce(true); + expect( + shouldScheduleAction({ + action: { + id: '1', + group: 'default', + actionTypeId: 'test-action-type-id', + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '111-111', + }, + actionsConfigMap: { + default: { max: 4 }, + 'test-action-type-id': { max: 2 }, + }, + isActionExecutable: () => true, + logger, + ruleId: '1', + ruleRunMetricsStore, + }) + ).toEqual(false); + + expect(ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType).toHaveBeenCalledWith({ + actionTypeId: 'test-action-type-id', + status: ActionsCompletion.PARTIAL, + }); + expect(logger.debug).not.toHaveBeenCalled(); + }); + + test('should return false and log if the the limit of executable actions for this action type has been reached', () => { + ruleRunMetricsStore.hasReachedTheExecutableActionsLimitByConnectorType.mockReturnValueOnce( + true + ); + ruleRunMetricsStore.hasConnectorTypeReachedTheLimit.mockReturnValueOnce(false); + expect( + shouldScheduleAction({ + action: { + id: '1', + group: 'default', + actionTypeId: 'test-action-type-id', + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '111-111', + }, + actionsConfigMap: { + default: { max: 4 }, + 'test-action-type-id': { max: 2 }, + }, + isActionExecutable: () => true, + logger, + ruleId: '1', + ruleRunMetricsStore, + }) + ).toEqual(false); + + expect(ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType).toHaveBeenCalledWith({ + actionTypeId: 'test-action-type-id', + status: ActionsCompletion.PARTIAL, + }); + expect(logger.debug).toHaveBeenCalledWith( + `Rule "1" skipped scheduling action "1" because the maximum number of allowed actions for connector type test-action-type-id has been reached.` + ); + }); + + test('should return false the action is not executable', () => { + expect( + shouldScheduleAction({ + action: { + id: '1', + group: 'default', + actionTypeId: 'test-action-type-id', + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '111-111', + }, + actionsConfigMap: { + default: { max: 4 }, + 'test-action-type-id': { max: 2 }, + }, + isActionExecutable: () => false, + logger, + ruleId: '1', + ruleRunMetricsStore, + }) + ).toEqual(false); + + expect(logger.warn).toHaveBeenCalledWith( + `Rule "1" skipped scheduling action "1" because it is disabled` + ); + }); + + test('should return true if the action is executable and no limits have been reached', () => { + expect( + shouldScheduleAction({ + action: { + id: '1', + group: 'default', + actionTypeId: 'test-action-type-id', + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '111-111', + }, + actionsConfigMap: { + default: { max: 4 }, + 'test-action-type-id': { max: 2 }, + }, + isActionExecutable: () => true, + logger, + ruleId: '1', + ruleRunMetricsStore, + }) + ).toEqual(true); + }); +}); diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/should_schedule_action.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/should_schedule_action.ts new file mode 100644 index 0000000000000..99fa3c42ad3df --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/should_schedule_action.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 { Logger } from '@kbn/logging'; +import { ActionsCompletion } from '@kbn/alerting-state-types'; +import { RuleAction, RuleSystemAction } from '@kbn/alerting-types'; +import { RuleRunMetricsStore } from '../../../lib/rule_run_metrics_store'; +import { ActionsConfigMap } from '../../../lib/get_actions_config_map'; + +interface ShouldScheduleActionOpts { + action: RuleAction | RuleSystemAction; + actionsConfigMap: ActionsConfigMap; + isActionExecutable( + actionId: string, + actionTypeId: string, + options?: { notifyUsage: boolean } + ): boolean; + logger: Logger; + ruleId: string; + ruleRunMetricsStore: RuleRunMetricsStore; +} + +export const shouldScheduleAction = (opts: ShouldScheduleActionOpts): boolean => { + const { actionsConfigMap, action, logger, ruleRunMetricsStore } = opts; + + // keep track of how many actions we want to schedule by connector type + ruleRunMetricsStore.incrementNumberOfGeneratedActionsByConnectorType(action.actionTypeId); + + if (ruleRunMetricsStore.hasReachedTheExecutableActionsLimit(actionsConfigMap)) { + ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType({ + actionTypeId: action.actionTypeId, + status: ActionsCompletion.PARTIAL, + }); + logger.debug( + `Rule "${opts.ruleId}" skipped scheduling action "${action.id}" because the maximum number of allowed actions has been reached.` + ); + return false; + } + + if ( + ruleRunMetricsStore.hasReachedTheExecutableActionsLimitByConnectorType({ + actionTypeId: action.actionTypeId, + actionsConfigMap, + }) + ) { + if (!ruleRunMetricsStore.hasConnectorTypeReachedTheLimit(action.actionTypeId)) { + logger.debug( + `Rule "${opts.ruleId}" skipped scheduling action "${action.id}" because the maximum number of allowed actions for connector type ${action.actionTypeId} has been reached.` + ); + } + ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType({ + actionTypeId: action.actionTypeId, + status: ActionsCompletion.PARTIAL, + }); + return false; + } + + if (!opts.isActionExecutable(action.id, action.actionTypeId, { notifyUsage: true })) { + logger.warn( + `Rule "${opts.ruleId}" skipped scheduling action "${action.id}" because it is disabled` + ); + return false; + } + + return true; +}; diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/per_alert_action_scheduler.test.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/per_alert_action_scheduler.test.ts index 53e75245d94d0..99a693133a2a6 100644 --- a/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/per_alert_action_scheduler.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/per_alert_action_scheduler.test.ts @@ -16,6 +16,12 @@ import { PerAlertActionScheduler } from './per_alert_action_scheduler'; import { getRule, getRuleType, getDefaultSchedulerContext, generateAlert } from '../test_fixtures'; import { SanitizedRuleAction } from '@kbn/alerting-types'; import { ALERT_UUID } from '@kbn/rule-data-utils'; +import { Alert } from '../../../alert'; +import { + ActionsCompletion, + AlertInstanceContext, + AlertInstanceState, +} from '@kbn/alerting-state-types'; const alertingEventLogger = alertingEventLoggerMock.create(); const actionsClient = actionsClientMock.create(); @@ -25,9 +31,10 @@ const logger = loggingSystemMock.create().get(); let ruleRunMetricsStore: RuleRunMetricsStore; const rule = getRule({ + id: 'rule-id-1', actions: [ { - id: '1', + id: 'action-1', group: 'default', actionTypeId: 'test', frequency: { summary: false, notifyWhen: 'onActiveAlert', throttle: null }, @@ -41,7 +48,7 @@ const rule = getRule({ uuid: '111-111', }, { - id: '2', + id: 'action-2', group: 'default', actionTypeId: 'test', frequency: { summary: false, notifyWhen: 'onActiveAlert', throttle: null }, @@ -55,7 +62,7 @@ const rule = getRule({ uuid: '222-222', }, { - id: '3', + id: 'action-3', group: 'default', actionTypeId: 'test', frequency: { summary: true, notifyWhen: 'onActiveAlert' }, @@ -84,6 +91,21 @@ const getSchedulerContext = (params = {}) => { return { ...defaultSchedulerContext, rule, ...params, ruleRunMetricsStore }; }; +const getResult = (actionId: string, alertId: string, actionUuid: string) => ({ + actionToEnqueue: { + actionTypeId: 'test', + apiKey: 'MTIzOmFiYw==', + consumer: 'rule-consumer', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: actionId, + uuid: actionUuid, + relatedSavedObjects: [{ id: 'rule-id-1', namespace: 'test1', type: 'alert', typeId: 'test' }], + source: { source: { id: 'rule-id-1', type: 'alert' }, type: 'SAVED_OBJECT' }, + spaceId: 'test1', + }, + actionToLog: { alertGroup: 'default', alertId, id: actionId, uuid: actionUuid, typeId: 'test' }, +}); + let clock: sinon.SinonFakeTimers; describe('Per-Alert Action Scheduler', () => { @@ -93,6 +115,7 @@ describe('Per-Alert Action Scheduler', () => { beforeEach(() => { jest.resetAllMocks(); + jest.clearAllMocks(); mockActionsPlugin.isActionTypeEnabled.mockReturnValue(true); mockActionsPlugin.isActionExecutable.mockReturnValue(true); mockActionsPlugin.getActionsClientWithRequest.mockResolvedValue(actionsClient); @@ -163,67 +186,93 @@ describe('Per-Alert Action Scheduler', () => { expect(scheduler.actions).toEqual([actions[0]]); expect(logger.error).toHaveBeenCalledTimes(1); expect(logger.error).toHaveBeenCalledWith( - `Skipping action \"2\" for rule \"1\" because the rule type \"Test\" does not support alert-as-data.` + `Skipping action \"2\" for rule \"rule-id-1\" because the rule type \"Test\" does not support alert-as-data.` ); }); - describe('generateExecutables', () => { - const newAlert1 = generateAlert({ id: 1 }); - const newAlert2 = generateAlert({ id: 2 }); - const alerts = { ...newAlert1, ...newAlert2 }; + describe('getActionsToSchedule', () => { + let newAlert1: Record< + string, + Alert + >; + let newAlert2: Record< + string, + Alert + >; + let alerts: Record< + string, + Alert + >; + + beforeEach(() => { + newAlert1 = generateAlert({ id: 1 }); + newAlert2 = generateAlert({ id: 2 }); + alerts = { ...newAlert1, ...newAlert2 }; + }); - test('should generate executable for each alert and each action', async () => { + test('should create action to schedule for each alert and each action', async () => { + // 2 per-alert actions * 2 alerts = 4 actions to schedule const scheduler = new PerAlertActionScheduler(getSchedulerContext()); - const executables = await scheduler.generateExecutables({ - alerts, - throttledSummaryActions: {}, - }); + const results = await scheduler.getActionsToSchedule({ alerts }); expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); expect(logger.debug).not.toHaveBeenCalled(); - expect(executables).toHaveLength(4); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(4); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(4); + expect(ruleRunMetricsStore.getStatusByConnectorType('test')).toEqual({ + numberOfGeneratedActions: 4, + numberOfTriggeredActions: 4, + }); - expect(executables).toEqual([ - { action: rule.actions[0], alert: alerts['1'] }, - { action: rule.actions[0], alert: alerts['2'] }, - { action: rule.actions[1], alert: alerts['1'] }, - { action: rule.actions[1], alert: alerts['2'] }, + expect(results).toHaveLength(4); + expect(results).toEqual([ + getResult('action-1', '1', '111-111'), + getResult('action-1', '2', '111-111'), + getResult('action-2', '1', '222-222'), + getResult('action-2', '2', '222-222'), ]); }); - test('should skip generating executable when alert has maintenance window', async () => { + test('should skip creating actions to schedule when alert has maintenance window', async () => { + // 2 per-alert actions * 2 alerts = 4 actions to schedule + // but alert 1 has maintenance window, so only actions for alert 2 should be scheduled const scheduler = new PerAlertActionScheduler(getSchedulerContext()); const newAlertWithMaintenanceWindow = generateAlert({ id: 1, maintenanceWindowIds: ['mw-1'], }); const alertsWithMaintenanceWindow = { ...newAlertWithMaintenanceWindow, ...newAlert2 }; - const executables = await scheduler.generateExecutables({ - alerts: alertsWithMaintenanceWindow, - throttledSummaryActions: {}, - }); + const results = await scheduler.getActionsToSchedule({ alerts: alertsWithMaintenanceWindow }); expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); expect(logger.debug).toHaveBeenCalledTimes(2); expect(logger.debug).toHaveBeenNthCalledWith( 1, - `no scheduling of summary actions \"1\" for rule \"1\": has active maintenance windows mw-1.` + `no scheduling of summary actions \"action-1\" for rule \"rule-id-1\": has active maintenance windows mw-1.` ); expect(logger.debug).toHaveBeenNthCalledWith( 2, - `no scheduling of summary actions \"2\" for rule \"1\": has active maintenance windows mw-1.` + `no scheduling of summary actions \"action-2\" for rule \"rule-id-1\": has active maintenance windows mw-1.` ); - expect(executables).toHaveLength(2); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(2); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(2); + expect(ruleRunMetricsStore.getStatusByConnectorType('test')).toEqual({ + numberOfGeneratedActions: 2, + numberOfTriggeredActions: 2, + }); - expect(executables).toEqual([ - { action: rule.actions[0], alert: alerts['2'] }, - { action: rule.actions[1], alert: alerts['2'] }, + expect(results).toHaveLength(2); + expect(results).toEqual([ + getResult('action-1', '2', '111-111'), + getResult('action-2', '2', '222-222'), ]); }); - test('should skip generating executable when alert has invalid action group', async () => { + test('should skip creating actions to schedule when alert has invalid action group', async () => { + // 2 per-alert actions * 2 alerts = 4 actions to schedule + // but alert 1 has invalid action group, so only actions for alert 2 should be scheduled const scheduler = new PerAlertActionScheduler(getSchedulerContext()); const newAlertInvalidActionGroup = generateAlert({ id: 1, @@ -231,9 +280,8 @@ describe('Per-Alert Action Scheduler', () => { group: 'invalid', }); const alertsWithInvalidActionGroup = { ...newAlertInvalidActionGroup, ...newAlert2 }; - const executables = await scheduler.generateExecutables({ + const results = await scheduler.getActionsToSchedule({ alerts: alertsWithInvalidActionGroup, - throttledSummaryActions: {}, }); expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); @@ -247,15 +295,23 @@ describe('Per-Alert Action Scheduler', () => { `Invalid action group \"invalid\" for rule \"test\".` ); - expect(executables).toHaveLength(2); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(2); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(2); + expect(ruleRunMetricsStore.getStatusByConnectorType('test')).toEqual({ + numberOfGeneratedActions: 2, + numberOfTriggeredActions: 2, + }); - expect(executables).toEqual([ - { action: rule.actions[0], alert: alerts['2'] }, - { action: rule.actions[1], alert: alerts['2'] }, + expect(results).toHaveLength(2); + expect(results).toEqual([ + getResult('action-1', '2', '111-111'), + getResult('action-2', '2', '222-222'), ]); }); - test('should skip generating executable when alert has pending recovered count greater than 0 and notifyWhen is onActiveAlert', async () => { + test('should skip creating actions to schedule when alert has pending recovered count greater than 0 and notifyWhen is onActiveAlert', async () => { + // 2 per-alert actions * 2 alerts = 4 actions to schedule + // but alert 1 has a pending recovered count > 0 & notifyWhen is onActiveAlert, so only actions for alert 2 should be scheduled const scheduler = new PerAlertActionScheduler(getSchedulerContext()); const newAlertWithPendingRecoveredCount = generateAlert({ id: 1, @@ -265,23 +321,31 @@ describe('Per-Alert Action Scheduler', () => { ...newAlertWithPendingRecoveredCount, ...newAlert2, }; - const executables = await scheduler.generateExecutables({ + const results = await scheduler.getActionsToSchedule({ alerts: alertsWithPendingRecoveredCount, - throttledSummaryActions: {}, }); expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); - expect(executables).toHaveLength(2); - expect(executables).toEqual([ - { action: rule.actions[0], alert: alerts['2'] }, - { action: rule.actions[1], alert: alerts['2'] }, + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(2); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(2); + expect(ruleRunMetricsStore.getStatusByConnectorType('test')).toEqual({ + numberOfGeneratedActions: 2, + numberOfTriggeredActions: 2, + }); + + expect(results).toHaveLength(2); + expect(results).toEqual([ + getResult('action-1', '2', '111-111'), + getResult('action-2', '2', '222-222'), ]); }); - test('should skip generating executable when alert has pending recovered count greater than 0 and notifyWhen is onThrottleInterval', async () => { + test('should skip creating actions to schedule when alert has pending recovered count greater than 0 and notifyWhen is onThrottleInterval', async () => { + // 2 per-alert actions * 2 alerts = 4 actions to schedule + // but alert 1 has a pending recovered count > 0 & notifyWhen is onThrottleInterval, so only actions for alert 2 should be scheduled const onThrottleIntervalAction: SanitizedRuleAction = { - id: '2', + id: 'action-4', group: 'default', actionTypeId: 'test', frequency: { summary: false, notifyWhen: 'onThrottleInterval', throttle: '1h' }, @@ -292,43 +356,45 @@ describe('Per-Alert Action Scheduler', () => { alertVal: 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', }, - uuid: '222-222', + uuid: '444-444', }; const scheduler = new PerAlertActionScheduler({ ...getSchedulerContext(), rule: { ...rule, actions: [rule.actions[0], onThrottleIntervalAction] }, }); - const newAlertWithPendingRecoveredCount = generateAlert({ - id: 1, - pendingRecoveredCount: 3, - }); + const newAlertWithPendingRecoveredCount = generateAlert({ id: 1, pendingRecoveredCount: 3 }); const alertsWithPendingRecoveredCount = { ...newAlertWithPendingRecoveredCount, ...newAlert2, }; - const executables = await scheduler.generateExecutables({ + const results = await scheduler.getActionsToSchedule({ alerts: alertsWithPendingRecoveredCount, - throttledSummaryActions: {}, }); expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); - expect(executables).toHaveLength(2); - expect(executables).toEqual([ - { action: rule.actions[0], alert: alerts['2'] }, - { action: onThrottleIntervalAction, alert: alerts['2'] }, + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(2); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(2); + expect(ruleRunMetricsStore.getStatusByConnectorType('test')).toEqual({ + numberOfGeneratedActions: 2, + numberOfTriggeredActions: 2, + }); + + expect(results).toHaveLength(2); + expect(results).toEqual([ + getResult('action-1', '2', '111-111'), + getResult('action-4', '2', '444-444'), ]); }); - test('should skip generating executable when alert is muted', async () => { + test('should skip creating actions to schedule when alert is muted', async () => { + // 2 per-alert actions * 2 alerts = 4 actions to schedule + // but alert 2 is muted, so only actions for alert 1 should be scheduled const scheduler = new PerAlertActionScheduler({ ...getSchedulerContext(), rule: { ...rule, mutedInstanceIds: ['2'] }, }); - const executables = await scheduler.generateExecutables({ - alerts, - throttledSummaryActions: {}, - }); + const results = await scheduler.getActionsToSchedule({ alerts }); expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); expect(logger.debug).toHaveBeenCalledTimes(1); @@ -336,20 +402,27 @@ describe('Per-Alert Action Scheduler', () => { 1, `skipping scheduling of actions for '2' in rule rule-label: rule is muted` ); - expect(executables).toHaveLength(2); - // @ts-expect-error private variable - expect(scheduler.skippedAlerts).toEqual({ '2': { reason: 'muted' } }); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(2); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(2); + expect(ruleRunMetricsStore.getStatusByConnectorType('test')).toEqual({ + numberOfGeneratedActions: 2, + numberOfTriggeredActions: 2, + }); - expect(executables).toEqual([ - { action: rule.actions[0], alert: alerts['1'] }, - { action: rule.actions[1], alert: alerts['1'] }, + expect(results).toHaveLength(2); + expect(results).toEqual([ + getResult('action-1', '1', '111-111'), + getResult('action-2', '1', '222-222'), ]); + + // @ts-expect-error private variable + expect(scheduler.skippedAlerts).toEqual({ '2': { reason: 'muted' } }); }); - test('should skip generating executable when alert action group has not changed and notifyWhen is onActionGroupChange', async () => { + test('should skip creating actions to schedule when alert action group has not changed and notifyWhen is onActionGroupChange', async () => { const onActionGroupChangeAction: SanitizedRuleAction = { - id: '2', + id: 'action-4', group: 'default', actionTypeId: 'test', frequency: { summary: false, notifyWhen: 'onActionGroupChange', throttle: null }, @@ -360,7 +433,7 @@ describe('Per-Alert Action Scheduler', () => { alertVal: 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', }, - uuid: '222-222', + uuid: '444-444', }; const activeAlert1 = generateAlert({ @@ -380,10 +453,7 @@ describe('Per-Alert Action Scheduler', () => { rule: { ...rule, actions: [rule.actions[0], onActionGroupChangeAction] }, }); - const executables = await scheduler.generateExecutables({ - alerts: alertsWithOngoingAlert, - throttledSummaryActions: {}, - }); + const results = await scheduler.getActionsToSchedule({ alerts: alertsWithOngoingAlert }); expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); expect(logger.debug).toHaveBeenCalledTimes(1); @@ -391,21 +461,28 @@ describe('Per-Alert Action Scheduler', () => { 1, `skipping scheduling of actions for '2' in rule rule-label: alert is active but action group has not changed` ); - expect(executables).toHaveLength(3); - // @ts-expect-error private variable - expect(scheduler.skippedAlerts).toEqual({ '2': { reason: 'actionGroupHasNotChanged' } }); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(3); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(3); + expect(ruleRunMetricsStore.getStatusByConnectorType('test')).toEqual({ + numberOfGeneratedActions: 3, + numberOfTriggeredActions: 3, + }); - expect(executables).toEqual([ - { action: rule.actions[0], alert: alertsWithOngoingAlert['1'] }, - { action: rule.actions[0], alert: alertsWithOngoingAlert['2'] }, - { action: onActionGroupChangeAction, alert: alertsWithOngoingAlert['1'] }, + expect(results).toHaveLength(3); + expect(results).toEqual([ + getResult('action-1', '1', '111-111'), + getResult('action-1', '2', '111-111'), + getResult('action-4', '1', '444-444'), ]); + + // @ts-expect-error private variable + expect(scheduler.skippedAlerts).toEqual({ '2': { reason: 'actionGroupHasNotChanged' } }); }); - test('should skip generating executable when throttle interval has not passed and notifyWhen is onThrottleInterval', async () => { + test('should skip creating actions to schedule when throttle interval has not passed and notifyWhen is onThrottleInterval', async () => { const onThrottleIntervalAction: SanitizedRuleAction = { - id: '2', + id: 'action-5', group: 'default', actionTypeId: 'test', frequency: { summary: false, notifyWhen: 'onThrottleInterval', throttle: '1h' }, @@ -416,13 +493,13 @@ describe('Per-Alert Action Scheduler', () => { alertVal: 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', }, - uuid: '222-222', + uuid: '555-555', }; const activeAlert2 = generateAlert({ id: 2, lastScheduledActionsGroup: 'default', - throttledActions: { '222-222': { date: '1969-12-31T23:10:00.000Z' } }, + throttledActions: { '555-555': { date: '1969-12-31T23:10:00.000Z' } }, }); const alertsWithOngoingAlert = { ...newAlert1, ...activeAlert2 }; @@ -431,10 +508,7 @@ describe('Per-Alert Action Scheduler', () => { rule: { ...rule, actions: [rule.actions[0], onThrottleIntervalAction] }, }); - const executables = await scheduler.generateExecutables({ - alerts: alertsWithOngoingAlert, - throttledSummaryActions: {}, - }); + const results = await scheduler.getActionsToSchedule({ alerts: alertsWithOngoingAlert }); expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); expect(logger.debug).toHaveBeenCalledTimes(1); @@ -442,21 +516,28 @@ describe('Per-Alert Action Scheduler', () => { 1, `skipping scheduling of actions for '2' in rule rule-label: rule is throttled` ); - expect(executables).toHaveLength(3); - // @ts-expect-error private variable - expect(scheduler.skippedAlerts).toEqual({ '2': { reason: 'throttled' } }); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(3); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(3); + expect(ruleRunMetricsStore.getStatusByConnectorType('test')).toEqual({ + numberOfGeneratedActions: 3, + numberOfTriggeredActions: 3, + }); - expect(executables).toEqual([ - { action: rule.actions[0], alert: alertsWithOngoingAlert['1'] }, - { action: rule.actions[0], alert: alertsWithOngoingAlert['2'] }, - { action: onThrottleIntervalAction, alert: alertsWithOngoingAlert['1'] }, + expect(results).toHaveLength(3); + expect(results).toEqual([ + getResult('action-1', '1', '111-111'), + getResult('action-1', '2', '111-111'), + getResult('action-5', '1', '555-555'), ]); + + // @ts-expect-error private variable + expect(scheduler.skippedAlerts).toEqual({ '2': { reason: 'throttled' } }); }); - test('should not skip generating executable when throttle interval has passed and notifyWhen is onThrottleInterval', async () => { + test('should not skip creating actions to schedule when throttle interval has passed and notifyWhen is onThrottleInterval', async () => { const onThrottleIntervalAction: SanitizedRuleAction = { - id: '2', + id: 'action-5', group: 'default', actionTypeId: 'test', frequency: { summary: false, notifyWhen: 'onThrottleInterval', throttle: '1h' }, @@ -467,7 +548,7 @@ describe('Per-Alert Action Scheduler', () => { alertVal: 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', }, - uuid: '222-222', + uuid: '555-555', }; const activeAlert2 = generateAlert({ @@ -482,24 +563,28 @@ describe('Per-Alert Action Scheduler', () => { rule: { ...rule, actions: [rule.actions[0], onThrottleIntervalAction] }, }); - const executables = await scheduler.generateExecutables({ - alerts: alertsWithOngoingAlert, - throttledSummaryActions: {}, - }); + const results = await scheduler.getActionsToSchedule({ alerts: alertsWithOngoingAlert }); expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); expect(logger.debug).not.toHaveBeenCalled(); - expect(executables).toHaveLength(4); - // @ts-expect-error private variable - expect(scheduler.skippedAlerts).toEqual({}); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(4); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(4); + expect(ruleRunMetricsStore.getStatusByConnectorType('test')).toEqual({ + numberOfGeneratedActions: 4, + numberOfTriggeredActions: 4, + }); - expect(executables).toEqual([ - { action: rule.actions[0], alert: alertsWithOngoingAlert['1'] }, - { action: rule.actions[0], alert: alertsWithOngoingAlert['2'] }, - { action: onThrottleIntervalAction, alert: alertsWithOngoingAlert['1'] }, - { action: onThrottleIntervalAction, alert: alertsWithOngoingAlert['2'] }, + expect(results).toHaveLength(4); + expect(results).toEqual([ + getResult('action-1', '1', '111-111'), + getResult('action-1', '2', '111-111'), + getResult('action-5', '1', '555-555'), + getResult('action-5', '2', '555-555'), ]); + + // @ts-expect-error private variable + expect(scheduler.skippedAlerts).toEqual({}); }); test('should query for summarized alerts if useAlertDataForTemplate is true', async () => { @@ -517,7 +602,7 @@ describe('Per-Alert Action Scheduler', () => { }; alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); const actionWithUseAlertDataForTemplate: SanitizedRuleAction = { - id: '1', + id: 'action-6', group: 'default', actionTypeId: 'test', frequency: { summary: false, notifyWhen: 'onActiveAlert', throttle: null }, @@ -528,33 +613,36 @@ describe('Per-Alert Action Scheduler', () => { alertVal: 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', }, - uuid: '111-111', + uuid: '666-666', useAlertDataForTemplate: true, }; const scheduler = new PerAlertActionScheduler({ ...getSchedulerContext(), rule: { ...rule, actions: [rule.actions[0], actionWithUseAlertDataForTemplate] }, }); - const executables = await scheduler.generateExecutables({ - alerts, - throttledSummaryActions: {}, - }); + const results = await scheduler.getActionsToSchedule({ alerts }); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ excludedAlertInstanceIds: [], executionUuid: defaultSchedulerContext.executionId, - ruleId: '1', + ruleId: 'rule-id-1', spaceId: 'test1', }); - expect(executables).toHaveLength(4); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(4); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(4); + expect(ruleRunMetricsStore.getStatusByConnectorType('test')).toEqual({ + numberOfGeneratedActions: 4, + numberOfTriggeredActions: 4, + }); - expect(executables).toEqual([ - { action: rule.actions[0], alert: alerts['1'] }, - { action: rule.actions[0], alert: alerts['2'] }, - { action: actionWithUseAlertDataForTemplate, alert: alerts['1'] }, - { action: actionWithUseAlertDataForTemplate, alert: alerts['2'] }, + expect(results).toHaveLength(4); + expect(results).toEqual([ + getResult('action-1', '1', '111-111'), + getResult('action-1', '2', '111-111'), + getResult('action-6', '1', '666-666'), + getResult('action-6', '2', '666-666'), ]); }); @@ -573,7 +661,7 @@ describe('Per-Alert Action Scheduler', () => { }; alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); const actionWithUseAlertDataForTemplate: SanitizedRuleAction = { - id: '1', + id: 'action-6', group: 'default', actionTypeId: 'test', frequency: { summary: false, notifyWhen: 'onThrottleInterval', throttle: '1h' }, @@ -584,34 +672,37 @@ describe('Per-Alert Action Scheduler', () => { alertVal: 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', }, - uuid: '111-111', + uuid: '666-666', useAlertDataForTemplate: true, }; const scheduler = new PerAlertActionScheduler({ ...getSchedulerContext(), rule: { ...rule, actions: [rule.actions[0], actionWithUseAlertDataForTemplate] }, }); - const executables = await scheduler.generateExecutables({ - alerts, - throttledSummaryActions: {}, - }); + const results = await scheduler.getActionsToSchedule({ alerts }); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ excludedAlertInstanceIds: [], - ruleId: '1', + ruleId: 'rule-id-1', spaceId: 'test1', start: new Date('1969-12-31T23:00:00.000Z'), end: new Date('1970-01-01T00:00:00.000Z'), }); - expect(executables).toHaveLength(4); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(4); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(4); + expect(ruleRunMetricsStore.getStatusByConnectorType('test')).toEqual({ + numberOfGeneratedActions: 4, + numberOfTriggeredActions: 4, + }); - expect(executables).toEqual([ - { action: rule.actions[0], alert: alerts['1'] }, - { action: rule.actions[0], alert: alerts['2'] }, - { action: actionWithUseAlertDataForTemplate, alert: alerts['1'] }, - { action: actionWithUseAlertDataForTemplate, alert: alerts['2'] }, + expect(results).toHaveLength(4); + expect(results).toEqual([ + getResult('action-1', '1', '111-111'), + getResult('action-1', '2', '111-111'), + getResult('action-6', '1', '666-666'), + getResult('action-6', '2', '666-666'), ]); }); @@ -630,7 +721,7 @@ describe('Per-Alert Action Scheduler', () => { }; alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); const actionWithAlertsFilter: SanitizedRuleAction = { - id: '1', + id: 'action-7', group: 'default', actionTypeId: 'test', frequency: { summary: false, notifyWhen: 'onActiveAlert', throttle: null }, @@ -641,34 +732,37 @@ describe('Per-Alert Action Scheduler', () => { alertVal: 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', }, - uuid: '111-111', + uuid: '777-777', alertsFilter: { query: { kql: 'kibana.alert.rule.name:foo', filters: [] } }, }; const scheduler = new PerAlertActionScheduler({ ...getSchedulerContext(), rule: { ...rule, actions: [rule.actions[0], actionWithAlertsFilter] }, }); - const executables = await scheduler.generateExecutables({ - alerts, - throttledSummaryActions: {}, - }); + const results = await scheduler.getActionsToSchedule({ alerts }); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ excludedAlertInstanceIds: [], executionUuid: defaultSchedulerContext.executionId, - ruleId: '1', + ruleId: 'rule-id-1', spaceId: 'test1', alertsFilter: { query: { kql: 'kibana.alert.rule.name:foo', filters: [] } }, }); - expect(executables).toHaveLength(4); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(4); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(4); + expect(ruleRunMetricsStore.getStatusByConnectorType('test')).toEqual({ + numberOfGeneratedActions: 4, + numberOfTriggeredActions: 4, + }); - expect(executables).toEqual([ - { action: rule.actions[0], alert: alerts['1'] }, - { action: rule.actions[0], alert: alerts['2'] }, - { action: actionWithAlertsFilter, alert: alerts['1'] }, - { action: actionWithAlertsFilter, alert: alerts['2'] }, + expect(results).toHaveLength(4); + expect(results).toEqual([ + getResult('action-1', '1', '111-111'), + getResult('action-1', '2', '111-111'), + getResult('action-7', '1', '777-777'), + getResult('action-7', '2', '777-777'), ]); }); @@ -687,7 +781,7 @@ describe('Per-Alert Action Scheduler', () => { }; alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); const actionWithAlertsFilter: SanitizedRuleAction = { - id: '1', + id: 'action-7', group: 'default', actionTypeId: 'test', frequency: { summary: false, notifyWhen: 'onThrottleInterval', throttle: '6h' }, @@ -698,39 +792,42 @@ describe('Per-Alert Action Scheduler', () => { alertVal: 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', }, - uuid: '111-111', + uuid: '777-777', alertsFilter: { query: { kql: 'kibana.alert.rule.name:foo', filters: [] } }, }; const scheduler = new PerAlertActionScheduler({ ...getSchedulerContext(), rule: { ...rule, actions: [rule.actions[0], actionWithAlertsFilter] }, }); - const executables = await scheduler.generateExecutables({ - alerts, - throttledSummaryActions: {}, - }); + const results = await scheduler.getActionsToSchedule({ alerts }); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ excludedAlertInstanceIds: [], - ruleId: '1', + ruleId: 'rule-id-1', spaceId: 'test1', alertsFilter: { query: { kql: 'kibana.alert.rule.name:foo', filters: [] } }, start: new Date('1969-12-31T18:00:00.000Z'), end: new Date('1970-01-01T00:00:00.000Z'), }); - expect(executables).toHaveLength(4); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(4); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(4); + expect(ruleRunMetricsStore.getStatusByConnectorType('test')).toEqual({ + numberOfGeneratedActions: 4, + numberOfTriggeredActions: 4, + }); - expect(executables).toEqual([ - { action: rule.actions[0], alert: alerts['1'] }, - { action: rule.actions[0], alert: alerts['2'] }, - { action: actionWithAlertsFilter, alert: alerts['1'] }, - { action: actionWithAlertsFilter, alert: alerts['2'] }, + expect(results).toHaveLength(4); + expect(results).toEqual([ + getResult('action-1', '1', '111-111'), + getResult('action-1', '2', '111-111'), + getResult('action-7', '1', '777-777'), + getResult('action-7', '2', '777-777'), ]); }); - test('should skip generating executable if alert does not match any alerts in summarized alerts', async () => { + test('should skip creating actions to schedule if alert does not match any alerts in summarized alerts', async () => { alertsClient.getProcessedAlerts.mockReturnValue(alerts); const summarizedAlerts = { new: { @@ -745,7 +842,7 @@ describe('Per-Alert Action Scheduler', () => { }; alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); const actionWithAlertsFilter: SanitizedRuleAction = { - id: '1', + id: 'action-8', group: 'default', actionTypeId: 'test', frequency: { summary: false, notifyWhen: 'onActiveAlert', throttle: null }, @@ -756,33 +853,36 @@ describe('Per-Alert Action Scheduler', () => { alertVal: 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', }, - uuid: '111-111', + uuid: '888-888', alertsFilter: { query: { kql: 'kibana.alert.rule.name:foo', filters: [] } }, }; const scheduler = new PerAlertActionScheduler({ ...getSchedulerContext(), rule: { ...rule, actions: [rule.actions[0], actionWithAlertsFilter] }, }); - const executables = await scheduler.generateExecutables({ - alerts, - throttledSummaryActions: {}, - }); + const results = await scheduler.getActionsToSchedule({ alerts }); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ excludedAlertInstanceIds: [], executionUuid: defaultSchedulerContext.executionId, - ruleId: '1', + ruleId: 'rule-id-1', spaceId: 'test1', alertsFilter: { query: { kql: 'kibana.alert.rule.name:foo', filters: [] } }, }); - expect(executables).toHaveLength(3); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(3); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(3); + expect(ruleRunMetricsStore.getStatusByConnectorType('test')).toEqual({ + numberOfGeneratedActions: 3, + numberOfTriggeredActions: 3, + }); - expect(executables).toEqual([ - { action: rule.actions[0], alert: alerts['1'] }, - { action: rule.actions[0], alert: alerts['2'] }, - { action: actionWithAlertsFilter, alert: alerts['1'] }, + expect(results).toHaveLength(3); + expect(results).toEqual([ + getResult('action-1', '1', '111-111'), + getResult('action-1', '2', '111-111'), + getResult('action-8', '1', '888-888'), ]); }); @@ -801,7 +901,7 @@ describe('Per-Alert Action Scheduler', () => { }; alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); const actionWithAlertsFilter: SanitizedRuleAction = { - id: '1', + id: 'action-9', group: 'default', actionTypeId: 'test', frequency: { summary: false, notifyWhen: 'onActiveAlert', throttle: null }, @@ -812,38 +912,168 @@ describe('Per-Alert Action Scheduler', () => { alertVal: 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', }, - uuid: '111-111', + uuid: '999-999', alertsFilter: { query: { kql: 'kibana.alert.rule.name:foo', filters: [] } }, }; const scheduler = new PerAlertActionScheduler({ ...getSchedulerContext(), rule: { ...rule, actions: [rule.actions[0], actionWithAlertsFilter] }, }); - const executables = await scheduler.generateExecutables({ - alerts, - throttledSummaryActions: {}, - }); + const results = await scheduler.getActionsToSchedule({ alerts }); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ excludedAlertInstanceIds: [], executionUuid: defaultSchedulerContext.executionId, - ruleId: '1', + ruleId: 'rule-id-1', spaceId: 'test1', alertsFilter: { query: { kql: 'kibana.alert.rule.name:foo', filters: [] } }, }); - expect(executables).toHaveLength(4); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(4); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(4); + expect(ruleRunMetricsStore.getStatusByConnectorType('test')).toEqual({ + numberOfGeneratedActions: 4, + numberOfTriggeredActions: 4, + }); + + expect(results).toHaveLength(4); + expect(results).toEqual([ + getResult('action-1', '1', '111-111'), + getResult('action-1', '2', '111-111'), + getResult('action-9', '1', '999-999'), + getResult('action-9', '2', '999-999'), + ]); expect(alerts['1'].getAlertAsData()).not.toBeUndefined(); expect(alerts['2'].getAlertAsData()).not.toBeUndefined(); + }); + + test('should skip creating actions to schedule if overall max actions limit exceeded', async () => { + const defaultContext = getSchedulerContext(); + const scheduler = new PerAlertActionScheduler({ + ...defaultContext, + taskRunnerContext: { + ...defaultContext.taskRunnerContext, + actionsConfigMap: { + default: { max: 3 }, + }, + }, + }); + const results = await scheduler.getActionsToSchedule({ alerts }); + + expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); - expect(executables).toEqual([ - { action: rule.actions[0], alert: alerts['1'] }, - { action: rule.actions[0], alert: alerts['2'] }, - { action: actionWithAlertsFilter, alert: alerts['1'] }, - { action: actionWithAlertsFilter, alert: alerts['2'] }, + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(4); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(3); + expect(ruleRunMetricsStore.getStatusByConnectorType('test')).toEqual({ + numberOfGeneratedActions: 4, + numberOfTriggeredActions: 3, + triggeredActionsStatus: ActionsCompletion.PARTIAL, + }); + + expect(logger.debug).toHaveBeenCalledWith( + `Rule "rule-id-1" skipped scheduling action "action-2" because the maximum number of allowed actions has been reached.` + ); + + expect(results).toHaveLength(3); + expect(results).toEqual([ + getResult('action-1', '1', '111-111'), + getResult('action-1', '2', '111-111'), + getResult('action-2', '1', '222-222'), ]); }); + + test('should skip creating actions to schedule if connector type max actions limit exceeded', async () => { + const defaultContext = getSchedulerContext(); + const scheduler = new PerAlertActionScheduler({ + ...defaultContext, + taskRunnerContext: { + ...defaultContext.taskRunnerContext, + actionsConfigMap: { + default: { max: 1000 }, + test: { max: 1 }, + }, + }, + }); + const results = await scheduler.getActionsToSchedule({ alerts }); + + expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); + + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(4); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(1); + expect(ruleRunMetricsStore.getStatusByConnectorType('test')).toEqual({ + numberOfGeneratedActions: 4, + numberOfTriggeredActions: 1, + triggeredActionsStatus: ActionsCompletion.PARTIAL, + }); + + expect(logger.debug).toHaveBeenCalledWith( + `Rule "rule-id-1" skipped scheduling action "action-1" because the maximum number of allowed actions for connector type test has been reached.` + ); + + expect(results).toHaveLength(1); + expect(results).toEqual([getResult('action-1', '1', '111-111')]); + }); + + test('should correctly update last scheduled actions for alert when action is "onActiveAlert"', async () => { + const alert = new Alert('1', { + state: { test: true }, + meta: {}, + }); + alert.scheduleActions('default'); + const scheduler = new PerAlertActionScheduler({ + ...getSchedulerContext(), + rule: { ...rule, actions: [rule.actions[0]] }, + }); + + expect(alert.getLastScheduledActions()).toBeUndefined(); + expect(alert.hasScheduledActions()).toBe(true); + await scheduler.getActionsToSchedule({ alerts: { '1': alert } }); + + expect(alert.getLastScheduledActions()).toEqual({ + date: '1970-01-01T00:00:00.000Z', + group: 'default', + }); + expect(alert.hasScheduledActions()).toBe(false); + }); + + test('should correctly update last scheduled actions for alert', async () => { + const alert = new Alert('1', { + state: { test: true }, + meta: {}, + }); + alert.scheduleActions('default'); + const onThrottleIntervalAction: SanitizedRuleAction = { + id: 'action-4', + group: 'default', + actionTypeId: 'test', + frequency: { summary: false, notifyWhen: 'onThrottleInterval', throttle: '1h' }, + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '222-222', + }; + + expect(alert.getLastScheduledActions()).toBeUndefined(); + expect(alert.hasScheduledActions()).toBe(true); + const scheduler = new PerAlertActionScheduler({ + ...getSchedulerContext(), + rule: { ...rule, actions: [onThrottleIntervalAction] }, + }); + + await scheduler.getActionsToSchedule({ alerts: { '1': alert } }); + + expect(alert.getLastScheduledActions()).toEqual({ + date: '1970-01-01T00:00:00.000Z', + group: 'default', + actions: { '222-222': { date: '1970-01-01T00:00:00.000Z' } }, + }); + expect(alert.hasScheduledActions()).toBe(false); + }); }); }); diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/per_alert_action_scheduler.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/per_alert_action_scheduler.ts index 602d3c31688c1..b35d86dff0105 100644 --- a/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/per_alert_action_scheduler.ts +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/per_alert_action_scheduler.ts @@ -12,19 +12,24 @@ import { RuleTypeState, RuleAlertData, parseDuration } from '../../../../common' import { GetSummarizedAlertsParams } from '../../../alerts_client/types'; import { AlertHit } from '../../../types'; import { Alert } from '../../../alert'; -import { getSummarizedAlerts } from '../get_summarized_alerts'; import { + buildRuleUrl, + formatActionToEnqueue, generateActionHash, + getSummarizedAlerts, isActionOnInterval, isSummaryAction, logNumberOfFilteredAlerts, -} from '../rule_action_helper'; + shouldScheduleAction, +} from '../lib'; import { ActionSchedulerOptions, - Executable, - GenerateExecutablesOpts, + ActionsToSchedule, + GetActionsToScheduleOpts, IActionScheduler, } from '../types'; +import { TransformActionParamsOptions, transformActionParams } from '../../transform_action_params'; +import { injectActionParams } from '../../inject_action_params'; enum Reasons { MUTED = 'muted', @@ -90,12 +95,16 @@ export class PerAlertActionScheduler< return 2; } - public async generateExecutables({ + public async getActionsToSchedule({ alerts, - }: GenerateExecutablesOpts): Promise< - Array> + }: GetActionsToScheduleOpts): Promise< + ActionsToSchedule[] > { - const executables = []; + const executables: Array<{ + action: RuleAction; + alert: Alert; + }> = []; + const results: ActionsToSchedule[] = []; const alertsArray = Object.entries(alerts); for (const action of this.actions) { @@ -104,7 +113,7 @@ export class PerAlertActionScheduler< if (action.useAlertDataForTemplate || action.alertsFilter) { const optionsBase = { spaceId: this.context.taskInstance.params.spaceId, - ruleId: this.context.taskInstance.params.alertId, + ruleId: this.context.rule.id, excludedAlertInstanceIds: this.context.rule.mutedInstanceIds, alertsFilter: action.alertsFilter, }; @@ -135,7 +144,7 @@ export class PerAlertActionScheduler< if (alertMaintenanceWindowIds.length !== 0) { this.context.logger.debug( `no scheduling of summary actions "${action.id}" for rule "${ - this.context.taskInstance.params.alertId + this.context.rule.id }": has active maintenance windows ${alertMaintenanceWindowIds.join(', ')}.` ); continue; @@ -185,7 +194,112 @@ export class PerAlertActionScheduler< } } - return executables; + if (executables.length === 0) return []; + + this.context.ruleRunMetricsStore.incrementNumberOfGeneratedActions(executables.length); + + const ruleUrl = buildRuleUrl({ + getViewInAppRelativeUrl: this.context.ruleType.getViewInAppRelativeUrl, + kibanaBaseUrl: this.context.taskRunnerContext.kibanaBaseUrl, + logger: this.context.logger, + rule: this.context.rule, + spaceId: this.context.taskInstance.params.spaceId, + }); + + for (const { action, alert } of executables) { + const { actionTypeId } = action; + + if ( + !shouldScheduleAction({ + action, + actionsConfigMap: this.context.taskRunnerContext.actionsConfigMap, + isActionExecutable: this.context.taskRunnerContext.actionsPlugin.isActionExecutable, + logger: this.context.logger, + ruleId: this.context.rule.id, + ruleRunMetricsStore: this.context.ruleRunMetricsStore, + }) + ) { + continue; + } + + this.context.ruleRunMetricsStore.incrementNumberOfTriggeredActions(); + this.context.ruleRunMetricsStore.incrementNumberOfTriggeredActionsByConnectorType( + actionTypeId + ); + + const actionGroup = action.group as ActionGroupIds; + const transformActionParamsOptions: TransformActionParamsOptions = { + actionsPlugin: this.context.taskRunnerContext.actionsPlugin, + alertId: this.context.rule.id, + alertType: this.context.ruleType.id, + actionTypeId: action.actionTypeId, + alertName: this.context.rule.name, + spaceId: this.context.taskInstance.params.spaceId, + tags: this.context.rule.tags, + alertInstanceId: alert.getId(), + alertUuid: alert.getUuid(), + alertActionGroup: actionGroup, + alertActionGroupName: this.ruleTypeActionGroups!.get(actionGroup)!, + context: alert.getContext(), + actionId: action.id, + state: alert.getState(), + kibanaBaseUrl: this.context.taskRunnerContext.kibanaBaseUrl, + alertParams: this.context.rule.params, + actionParams: action.params, + flapping: alert.getFlapping(), + ruleUrl: ruleUrl?.absoluteUrl, + }; + + if (alert.isAlertAsData()) { + transformActionParamsOptions.aadAlert = alert.getAlertAsData(); + } + + const actionToRun = { + ...action, + params: injectActionParams({ + actionTypeId: action.actionTypeId, + ruleUrl, + ruleName: this.context.rule.name, + actionParams: transformActionParams(transformActionParamsOptions), + }), + }; + + results.push({ + actionToEnqueue: formatActionToEnqueue({ + action: actionToRun, + apiKey: this.context.apiKey, + executionId: this.context.executionId, + ruleConsumer: this.context.ruleConsumer, + ruleId: this.context.rule.id, + ruleTypeId: this.context.ruleType.id, + spaceId: this.context.taskInstance.params.spaceId, + }), + actionToLog: { + id: action.id, + // uuid is typed as optional but in reality it is always + // populated - https://github.com/elastic/kibana/issues/195255 + uuid: action.uuid, + typeId: action.actionTypeId, + alertId: alert.getId(), + alertGroup: action.group, + }, + }); + + if (!this.isRecoveredAlert(actionGroup)) { + if (isActionOnInterval(action)) { + alert.updateLastScheduledActions( + action.group as ActionGroupIds, + generateActionHash(action), + action.uuid + ); + } else { + alert.updateLastScheduledActions(action.group as ActionGroupIds); + } + alert.unscheduleActions(); + } + } + + return results; } private isAlertMuted(alertId: string) { diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/summary_action_scheduler.test.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/summary_action_scheduler.test.ts index 600dd0e1951d5..fc810fc4ef34c 100644 --- a/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/summary_action_scheduler.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/summary_action_scheduler.test.ts @@ -20,6 +20,8 @@ import { getErrorSource, TaskErrorSource, } from '@kbn/task-manager-plugin/server/task_running/errors'; +import { CombinedSummarizedAlerts } from '../../../types'; +import { ActionsCompletion } from '@kbn/alerting-state-types'; const alertingEventLogger = alertingEventLoggerMock.create(); const actionsClient = actionsClientMock.create(); @@ -29,9 +31,10 @@ const logger = loggingSystemMock.create().get(); let ruleRunMetricsStore: RuleRunMetricsStore; const rule = getRule({ + id: 'rule-id-1', actions: [ { - id: '1', + id: 'action-1', group: 'default', actionTypeId: 'test', frequency: { summary: false, notifyWhen: 'onActiveAlert', throttle: null }, @@ -45,7 +48,7 @@ const rule = getRule({ uuid: '111-111', }, { - id: '2', + id: 'action-2', group: 'default', actionTypeId: 'test', frequency: { summary: true, notifyWhen: 'onActiveAlert', throttle: null }, @@ -59,7 +62,7 @@ const rule = getRule({ uuid: '222-222', }, { - id: '3', + id: 'action-3', group: 'default', actionTypeId: 'test', frequency: { summary: true, notifyWhen: 'onActiveAlert', throttle: null }, @@ -88,6 +91,30 @@ const getSchedulerContext = (params = {}) => { return { ...defaultSchedulerContext, rule, ...params, ruleRunMetricsStore }; }; +const getResult = (actionId: string, actionUuid: string, summary: CombinedSummarizedAlerts) => ({ + actionToEnqueue: { + actionTypeId: 'test', + apiKey: 'MTIzOmFiYw==', + consumer: 'rule-consumer', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: actionId, + uuid: actionUuid, + relatedSavedObjects: [{ id: 'rule-id-1', namespace: 'test1', type: 'alert', typeId: 'test' }], + source: { source: { id: 'rule-id-1', type: 'alert' }, type: 'SAVED_OBJECT' }, + spaceId: 'test1', + }, + actionToLog: { + alertSummary: { + new: summary.new.count, + ongoing: summary.ongoing.count, + recovered: summary.recovered.count, + }, + id: actionId, + uuid: actionUuid, + typeId: 'test', + }, +}); + let clock: sinon.SinonFakeTimers; describe('Summary Action Scheduler', () => { @@ -127,21 +154,21 @@ describe('Summary Action Scheduler', () => { expect(logger.error).toHaveBeenCalledTimes(2); expect(logger.error).toHaveBeenNthCalledWith( 1, - `Skipping action \"2\" for rule \"1\" because the rule type \"Test\" does not support alert-as-data.` + `Skipping action \"action-2\" for rule \"rule-id-1\" because the rule type \"Test\" does not support alert-as-data.` ); expect(logger.error).toHaveBeenNthCalledWith( 2, - `Skipping action \"3\" for rule \"1\" because the rule type \"Test\" does not support alert-as-data.` + `Skipping action \"action-3\" for rule \"rule-id-1\" because the rule type \"Test\" does not support alert-as-data.` ); }); - describe('generateExecutables', () => { + describe('getActionsToSchedule', () => { const newAlert1 = generateAlert({ id: 1 }); const newAlert2 = generateAlert({ id: 2 }); const alerts = { ...newAlert1, ...newAlert2 }; const summaryActionWithAlertFilter: RuleAction = { - id: '2', + id: 'action-3', group: 'default', actionTypeId: 'test', frequency: { @@ -157,11 +184,11 @@ describe('Summary Action Scheduler', () => { 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', }, alertsFilter: { query: { kql: 'kibana.alert.rule.name:foo', dsl: '{}', filters: [] } }, - uuid: '222-222', + uuid: '333-333', }; const summaryActionWithThrottle: RuleAction = { - id: '2', + id: 'action-4', group: 'default', actionTypeId: 'test', frequency: { @@ -176,10 +203,10 @@ describe('Summary Action Scheduler', () => { alertVal: 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', }, - uuid: '222-222', + uuid: '444-444', }; - test('should generate executable for summary action when summary action is per rule run', async () => { + test('should create action to schedule for summary action when summary action is per rule run', async () => { alertsClient.getProcessedAlerts.mockReturnValue(alerts); const summarizedAlerts = { new: { count: 2, data: [mockAAD, mockAAD] }, @@ -188,37 +215,43 @@ describe('Summary Action Scheduler', () => { }; alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); + const throttledSummaryActions = {}; const scheduler = new SummaryActionScheduler(getSchedulerContext()); - const executables = await scheduler.generateExecutables({ - alerts, - throttledSummaryActions: {}, - }); + const results = await scheduler.getActionsToSchedule({ alerts, throttledSummaryActions }); + expect(throttledSummaryActions).toEqual({}); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(2); expect(alertsClient.getSummarizedAlerts).toHaveBeenNthCalledWith(1, { excludedAlertInstanceIds: [], executionUuid: defaultSchedulerContext.executionId, - ruleId: '1', + ruleId: 'rule-id-1', spaceId: 'test1', }); expect(alertsClient.getSummarizedAlerts).toHaveBeenNthCalledWith(2, { excludedAlertInstanceIds: [], executionUuid: defaultSchedulerContext.executionId, - ruleId: '1', + ruleId: 'rule-id-1', spaceId: 'test1', }); expect(logger.debug).not.toHaveBeenCalled(); - expect(executables).toHaveLength(2); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(2); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(2); + expect(ruleRunMetricsStore.getStatusByConnectorType('test')).toEqual({ + numberOfGeneratedActions: 2, + numberOfTriggeredActions: 2, + }); + + expect(results).toHaveLength(2); const finalSummary = { ...summarizedAlerts, all: { count: 2, data: [mockAAD, mockAAD] } }; - expect(executables).toEqual([ - { action: rule.actions[1], summarizedAlerts: finalSummary }, - { action: rule.actions[2], summarizedAlerts: finalSummary }, + expect(results).toEqual([ + getResult('action-2', '222-222', finalSummary), + getResult('action-3', '333-333', finalSummary), ]); }); - test('should generate executable for summary action when summary action has alertsFilter', async () => { + test('should create actions to schedule for summary action when summary action has alertsFilter', async () => { alertsClient.getProcessedAlerts.mockReturnValue(alerts); const summarizedAlerts = { new: { count: 2, data: [mockAAD, mockAAD] }, @@ -232,30 +265,34 @@ describe('Summary Action Scheduler', () => { rule: { ...rule, actions: [summaryActionWithAlertFilter] }, }); - const executables = await scheduler.generateExecutables({ - alerts, - throttledSummaryActions: {}, - }); + const throttledSummaryActions = {}; + const results = await scheduler.getActionsToSchedule({ alerts, throttledSummaryActions }); + expect(throttledSummaryActions).toEqual({}); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ excludedAlertInstanceIds: [], executionUuid: defaultSchedulerContext.executionId, - ruleId: '1', + ruleId: 'rule-id-1', spaceId: 'test1', alertsFilter: { query: { kql: 'kibana.alert.rule.name:foo', dsl: '{}', filters: [] } }, }); expect(logger.debug).not.toHaveBeenCalled(); - expect(executables).toHaveLength(1); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(1); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(1); + expect(ruleRunMetricsStore.getStatusByConnectorType('test')).toEqual({ + numberOfGeneratedActions: 1, + numberOfTriggeredActions: 1, + }); + + expect(results).toHaveLength(1); const finalSummary = { ...summarizedAlerts, all: { count: 2, data: [mockAAD, mockAAD] } }; - expect(executables).toEqual([ - { action: summaryActionWithAlertFilter, summarizedAlerts: finalSummary }, - ]); + expect(results).toEqual([getResult('action-3', '333-333', finalSummary)]); }); - test('should generate executable for summary action when summary action is throttled with no throttle history', async () => { + test('should create actions to schedule for summary action when summary action is throttled with no throttle history', async () => { alertsClient.getProcessedAlerts.mockReturnValue(alerts); const summarizedAlerts = { new: { count: 2, data: [mockAAD, mockAAD] }, @@ -269,48 +306,52 @@ describe('Summary Action Scheduler', () => { rule: { ...rule, actions: [summaryActionWithThrottle] }, }); - const executables = await scheduler.generateExecutables({ - alerts, - throttledSummaryActions: {}, - }); + const throttledSummaryActions = {}; + const results = await scheduler.getActionsToSchedule({ alerts, throttledSummaryActions }); + expect(throttledSummaryActions).toEqual({ '444-444': { date: '1970-01-01T00:00:00.000Z' } }); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ excludedAlertInstanceIds: [], - ruleId: '1', + ruleId: 'rule-id-1', spaceId: 'test1', start: new Date('1969-12-31T00:00:00.000Z'), end: new Date(), }); expect(logger.debug).not.toHaveBeenCalled(); - expect(executables).toHaveLength(1); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(1); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(1); + expect(ruleRunMetricsStore.getStatusByConnectorType('test')).toEqual({ + numberOfGeneratedActions: 1, + numberOfTriggeredActions: 1, + }); + + expect(results).toHaveLength(1); const finalSummary = { ...summarizedAlerts, all: { count: 2, data: [mockAAD, mockAAD] } }; - expect(executables).toEqual([ - { action: summaryActionWithThrottle, summarizedAlerts: finalSummary }, - ]); + expect(results).toEqual([getResult('action-4', '444-444', finalSummary)]); }); - test('should skip generating executable for summary action when summary action is throttled', async () => { + test('should skip creating actions to schedule for summary action when summary action is throttled', async () => { const scheduler = new SummaryActionScheduler({ ...getSchedulerContext(), rule: { ...rule, actions: [summaryActionWithThrottle] }, }); - const executables = await scheduler.generateExecutables({ - alerts, - throttledSummaryActions: { - '222-222': { date: '1969-12-31T13:00:00.000Z' }, - }, - }); + const throttledSummaryActions = { '444-444': { date: '1969-12-31T13:00:00.000Z' } }; + const results = await scheduler.getActionsToSchedule({ alerts, throttledSummaryActions }); + expect(throttledSummaryActions).toEqual({ '444-444': { date: '1969-12-31T13:00:00.000Z' } }); expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); expect(logger.debug).toHaveBeenCalledWith( - `skipping scheduling the action 'test:2', summary action is still being throttled` + `skipping scheduling the action 'test:action-4', summary action is still being throttled` ); - expect(executables).toHaveLength(0); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(0); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(0); + + expect(results).toHaveLength(0); }); test('should remove new alerts from summary if suppressed by maintenance window', async () => { @@ -332,22 +373,21 @@ describe('Summary Action Scheduler', () => { alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); const scheduler = new SummaryActionScheduler(getSchedulerContext()); - const executables = await scheduler.generateExecutables({ - alerts, - throttledSummaryActions: {}, - }); + const throttledSummaryActions = {}; + const results = await scheduler.getActionsToSchedule({ alerts, throttledSummaryActions }); + expect(throttledSummaryActions).toEqual({}); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(2); expect(alertsClient.getSummarizedAlerts).toHaveBeenNthCalledWith(1, { excludedAlertInstanceIds: [], executionUuid: defaultSchedulerContext.executionId, - ruleId: '1', + ruleId: 'rule-id-1', spaceId: 'test1', }); expect(alertsClient.getSummarizedAlerts).toHaveBeenNthCalledWith(2, { excludedAlertInstanceIds: [], executionUuid: defaultSchedulerContext.executionId, - ruleId: '1', + ruleId: 'rule-id-1', spaceId: 'test1', }); expect(logger.debug).toHaveBeenCalledTimes(2); @@ -360,7 +400,14 @@ describe('Summary Action Scheduler', () => { `(1) alert has been filtered out for: test:333-333` ); - expect(executables).toHaveLength(2); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(2); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(2); + expect(ruleRunMetricsStore.getStatusByConnectorType('test')).toEqual({ + numberOfGeneratedActions: 2, + numberOfTriggeredActions: 2, + }); + + expect(results).toHaveLength(2); const finalSummary = { all: { count: 1, data: [newAADAlerts[1]] }, @@ -368,13 +415,13 @@ describe('Summary Action Scheduler', () => { ongoing: { count: 0, data: [] }, recovered: { count: 0, data: [] }, }; - expect(executables).toEqual([ - { action: rule.actions[1], summarizedAlerts: finalSummary }, - { action: rule.actions[2], summarizedAlerts: finalSummary }, + expect(results).toEqual([ + getResult('action-2', '222-222', finalSummary), + getResult('action-3', '333-333', finalSummary), ]); }); - test('should generate executable for summary action and log when alerts have been filtered out by action condition', async () => { + test('should create alerts to schedule for summary action and log when alerts have been filtered out by action condition', async () => { alertsClient.getProcessedAlerts.mockReturnValue(alerts); const summarizedAlerts = { new: { count: 1, data: [mockAAD] }, @@ -388,33 +435,37 @@ describe('Summary Action Scheduler', () => { rule: { ...rule, actions: [summaryActionWithAlertFilter] }, }); - const executables = await scheduler.generateExecutables({ - alerts, - throttledSummaryActions: {}, - }); + const throttledSummaryActions = {}; + const results = await scheduler.getActionsToSchedule({ alerts, throttledSummaryActions }); + expect(throttledSummaryActions).toEqual({}); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ excludedAlertInstanceIds: [], executionUuid: defaultSchedulerContext.executionId, - ruleId: '1', + ruleId: 'rule-id-1', spaceId: 'test1', alertsFilter: { query: { kql: 'kibana.alert.rule.name:foo', dsl: '{}', filters: [] } }, }); expect(logger.debug).toHaveBeenCalledTimes(1); expect(logger.debug).toHaveBeenCalledWith( - `(1) alert has been filtered out for: test:222-222` + `(1) alert has been filtered out for: test:333-333` ); - expect(executables).toHaveLength(1); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(1); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(1); + expect(ruleRunMetricsStore.getStatusByConnectorType('test')).toEqual({ + numberOfGeneratedActions: 1, + numberOfTriggeredActions: 1, + }); + + expect(results).toHaveLength(1); const finalSummary = { ...summarizedAlerts, all: { count: 1, data: [mockAAD] } }; - expect(executables).toEqual([ - { action: summaryActionWithAlertFilter, summarizedAlerts: finalSummary }, - ]); + expect(results).toEqual([getResult('action-3', '333-333', finalSummary)]); }); - test('should skip generating executable for summary action when no alerts found', async () => { + test('should skip creating actions to schedule for summary action when no alerts found', async () => { alertsClient.getProcessedAlerts.mockReturnValue(alerts); const summarizedAlerts = { new: { count: 0, data: [] }, @@ -428,22 +479,23 @@ describe('Summary Action Scheduler', () => { rule: { ...rule, actions: [summaryActionWithThrottle] }, }); - const executables = await scheduler.generateExecutables({ - alerts, - throttledSummaryActions: {}, - }); + const throttledSummaryActions = {}; + const results = await scheduler.getActionsToSchedule({ alerts, throttledSummaryActions }); + expect(throttledSummaryActions).toEqual({}); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ excludedAlertInstanceIds: [], - ruleId: '1', + ruleId: 'rule-id-1', spaceId: 'test1', start: new Date('1969-12-31T00:00:00.000Z'), end: new Date(), }); expect(logger.debug).not.toHaveBeenCalled(); - expect(executables).toHaveLength(0); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(0); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(0); + expect(results).toHaveLength(0); }); test('should throw framework error if getSummarizedAlerts throws error', async () => { @@ -455,14 +507,117 @@ describe('Summary Action Scheduler', () => { const scheduler = new SummaryActionScheduler(getSchedulerContext()); try { - await scheduler.generateExecutables({ - alerts, - throttledSummaryActions: {}, - }); + await scheduler.getActionsToSchedule({ alerts, throttledSummaryActions: {} }); } catch (err) { expect(err.message).toEqual(`no alerts for you`); expect(getErrorSource(err)).toBe(TaskErrorSource.FRAMEWORK); } }); + + test('should skip creating actions to schedule if overall max actions limit exceeded', async () => { + alertsClient.getProcessedAlerts.mockReturnValue(alerts); + const summarizedAlerts = { + new: { count: 2, data: [mockAAD, mockAAD] }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }; + alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); + + const defaultContext = getSchedulerContext(); + const scheduler = new SummaryActionScheduler({ + ...defaultContext, + taskRunnerContext: { + ...defaultContext.taskRunnerContext, + actionsConfigMap: { + default: { max: 1 }, + }, + }, + }); + const results = await scheduler.getActionsToSchedule({ alerts, throttledSummaryActions: {} }); + + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(2); + expect(alertsClient.getSummarizedAlerts).toHaveBeenNthCalledWith(1, { + excludedAlertInstanceIds: [], + executionUuid: defaultSchedulerContext.executionId, + ruleId: 'rule-id-1', + spaceId: 'test1', + }); + expect(alertsClient.getSummarizedAlerts).toHaveBeenNthCalledWith(2, { + excludedAlertInstanceIds: [], + executionUuid: defaultSchedulerContext.executionId, + ruleId: 'rule-id-1', + spaceId: 'test1', + }); + + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(2); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(1); + expect(ruleRunMetricsStore.getStatusByConnectorType('test')).toEqual({ + numberOfGeneratedActions: 2, + numberOfTriggeredActions: 1, + triggeredActionsStatus: ActionsCompletion.PARTIAL, + }); + + expect(logger.debug).toHaveBeenCalledWith( + `Rule "rule-id-1" skipped scheduling action "action-3" because the maximum number of allowed actions has been reached.` + ); + + expect(results).toHaveLength(1); + + const finalSummary = { ...summarizedAlerts, all: { count: 2, data: [mockAAD, mockAAD] } }; + expect(results).toEqual([getResult('action-2', '222-222', finalSummary)]); + }); + + test('should skip creating actions to schedule if connector type max actions limit exceeded', async () => { + alertsClient.getProcessedAlerts.mockReturnValue(alerts); + const summarizedAlerts = { + new: { count: 2, data: [mockAAD, mockAAD] }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }; + alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); + + const defaultContext = getSchedulerContext(); + const scheduler = new SummaryActionScheduler({ + ...defaultContext, + taskRunnerContext: { + ...defaultContext.taskRunnerContext, + actionsConfigMap: { + default: { max: 1000 }, + test: { max: 1 }, + }, + }, + }); + const results = await scheduler.getActionsToSchedule({ alerts, throttledSummaryActions: {} }); + + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(2); + expect(alertsClient.getSummarizedAlerts).toHaveBeenNthCalledWith(1, { + excludedAlertInstanceIds: [], + executionUuid: defaultSchedulerContext.executionId, + ruleId: 'rule-id-1', + spaceId: 'test1', + }); + expect(alertsClient.getSummarizedAlerts).toHaveBeenNthCalledWith(2, { + excludedAlertInstanceIds: [], + executionUuid: defaultSchedulerContext.executionId, + ruleId: 'rule-id-1', + spaceId: 'test1', + }); + + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(2); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(1); + expect(ruleRunMetricsStore.getStatusByConnectorType('test')).toEqual({ + numberOfGeneratedActions: 2, + numberOfTriggeredActions: 1, + triggeredActionsStatus: ActionsCompletion.PARTIAL, + }); + + expect(logger.debug).toHaveBeenCalledWith( + `Rule "rule-id-1" skipped scheduling action "action-3" because the maximum number of allowed actions for connector type test has been reached.` + ); + + expect(results).toHaveLength(1); + const finalSummary = { ...summarizedAlerts, all: { count: 2, data: [mockAAD, mockAAD] } }; + expect(results).toEqual([getResult('action-2', '222-222', finalSummary)]); + }); }); }); diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/summary_action_scheduler.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/summary_action_scheduler.ts index 9b67c37e6216e..050eea352f0d5 100644 --- a/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/summary_action_scheduler.ts +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/summary_action_scheduler.ts @@ -8,21 +8,28 @@ import { AlertInstanceState, AlertInstanceContext } from '@kbn/alerting-state-types'; import { RuleAction, RuleTypeParams } from '@kbn/alerting-types'; import { compact } from 'lodash'; +import { CombinedSummarizedAlerts } from '../../../types'; import { RuleTypeState, RuleAlertData, parseDuration } from '../../../../common'; import { GetSummarizedAlertsParams } from '../../../alerts_client/types'; -import { getSummarizedAlerts } from '../get_summarized_alerts'; import { + buildRuleUrl, + formatActionToEnqueue, + getSummarizedAlerts, + getSummaryActionTimeBounds, isActionOnInterval, isSummaryAction, isSummaryActionThrottled, logNumberOfFilteredAlerts, -} from '../rule_action_helper'; + shouldScheduleAction, +} from '../lib'; import { ActionSchedulerOptions, - Executable, - GenerateExecutablesOpts, + ActionsToSchedule, + GetActionsToScheduleOpts, IActionScheduler, } from '../types'; +import { injectActionParams } from '../../inject_action_params'; +import { transformSummaryActionParams } from '../../transform_action_params'; export class SummaryActionScheduler< Params extends RuleTypeParams, @@ -73,13 +80,18 @@ export class SummaryActionScheduler< return 0; } - public async generateExecutables({ + public async getActionsToSchedule({ alerts, throttledSummaryActions, - }: GenerateExecutablesOpts): Promise< - Array> + }: GetActionsToScheduleOpts): Promise< + ActionsToSchedule[] > { - const executables = []; + const executables: Array<{ + action: RuleAction; + summarizedAlerts: CombinedSummarizedAlerts; + }> = []; + const results: ActionsToSchedule[] = []; + for (const action of this.actions) { if ( // if summary action is throttled, we won't send any notifications @@ -88,7 +100,7 @@ export class SummaryActionScheduler< const actionHasThrottleInterval = isActionOnInterval(action); const optionsBase = { spaceId: this.context.taskInstance.params.spaceId, - ruleId: this.context.taskInstance.params.alertId, + ruleId: this.context.rule.id, excludedAlertInstanceIds: this.context.rule.mutedInstanceIds, alertsFilter: action.alertsFilter, }; @@ -122,6 +134,95 @@ export class SummaryActionScheduler< } } - return executables; + if (executables.length === 0) return []; + + this.context.ruleRunMetricsStore.incrementNumberOfGeneratedActions(executables.length); + + for (const { action, summarizedAlerts } of executables) { + const { actionTypeId } = action; + + if ( + !shouldScheduleAction({ + action, + actionsConfigMap: this.context.taskRunnerContext.actionsConfigMap, + isActionExecutable: this.context.taskRunnerContext.actionsPlugin.isActionExecutable, + logger: this.context.logger, + ruleId: this.context.rule.id, + ruleRunMetricsStore: this.context.ruleRunMetricsStore, + }) + ) { + continue; + } + + this.context.ruleRunMetricsStore.incrementNumberOfTriggeredActions(); + this.context.ruleRunMetricsStore.incrementNumberOfTriggeredActionsByConnectorType( + actionTypeId + ); + + if (isActionOnInterval(action) && throttledSummaryActions) { + throttledSummaryActions[action.uuid!] = { date: new Date().toISOString() }; + } + + const { start, end } = getSummaryActionTimeBounds( + action, + this.context.rule.schedule, + this.context.previousStartedAt + ); + + const ruleUrl = buildRuleUrl({ + end, + getViewInAppRelativeUrl: this.context.ruleType.getViewInAppRelativeUrl, + kibanaBaseUrl: this.context.taskRunnerContext.kibanaBaseUrl, + logger: this.context.logger, + rule: this.context.rule, + spaceId: this.context.taskInstance.params.spaceId, + start, + }); + + const actionToRun = { + ...action, + params: injectActionParams({ + actionTypeId: action.actionTypeId, + ruleUrl, + ruleName: this.context.rule.name, + actionParams: transformSummaryActionParams({ + alerts: summarizedAlerts, + rule: this.context.rule, + ruleTypeId: this.context.ruleType.id, + actionId: action.id, + actionParams: action.params, + spaceId: this.context.taskInstance.params.spaceId, + actionsPlugin: this.context.taskRunnerContext.actionsPlugin, + actionTypeId: action.actionTypeId, + kibanaBaseUrl: this.context.taskRunnerContext.kibanaBaseUrl, + ruleUrl: ruleUrl?.absoluteUrl, + }), + }), + }; + + results.push({ + actionToEnqueue: formatActionToEnqueue({ + action: actionToRun, + apiKey: this.context.apiKey, + executionId: this.context.executionId, + ruleConsumer: this.context.ruleConsumer, + ruleId: this.context.rule.id, + ruleTypeId: this.context.ruleType.id, + spaceId: this.context.taskInstance.params.spaceId, + }), + actionToLog: { + id: action.id, + uuid: action.uuid, + typeId: action.actionTypeId, + alertSummary: { + new: summarizedAlerts.new.count, + ongoing: summarizedAlerts.ongoing.count, + recovered: summarizedAlerts.recovered.count, + }, + }, + }); + } + + return results; } } diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/system_action_scheduler.test.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/system_action_scheduler.test.ts index fd4db6ce34678..28bf58a30c689 100644 --- a/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/system_action_scheduler.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/system_action_scheduler.test.ts @@ -12,6 +12,12 @@ import { alertsClientMock } from '../../../alerts_client/alerts_client.mock'; import { alertingEventLoggerMock } from '../../../lib/alerting_event_logger/alerting_event_logger.mock'; import { RuleRunMetricsStore } from '../../../lib/rule_run_metrics_store'; import { mockAAD } from '../../fixtures'; +import { Alert } from '../../../alert'; +import { + ActionsCompletion, + AlertInstanceContext, + AlertInstanceState, +} from '@kbn/alerting-state-types'; import { getRule, getRuleType, getDefaultSchedulerContext, generateAlert } from '../test_fixtures'; import { SystemActionScheduler } from './system_action_scheduler'; import { ALERT_UUID } from '@kbn/rule-data-utils'; @@ -19,6 +25,8 @@ import { getErrorSource, TaskErrorSource, } from '@kbn/task-manager-plugin/server/task_running/errors'; +import { CombinedSummarizedAlerts } from '../../../types'; +import { schema } from '@kbn/config-schema'; const alertingEventLogger = alertingEventLoggerMock.create(); const actionsClient = actionsClientMock.create(); @@ -28,12 +36,13 @@ const logger = loggingSystemMock.create().get(); let ruleRunMetricsStore: RuleRunMetricsStore; const rule = getRule({ + id: 'rule-id-1', systemActions: [ { - id: '1', + id: 'system-action-1', actionTypeId: '.test-system-action', params: { myParams: 'test' }, - uui: 'test', + uuid: 'xxx-xxx', }, ], }); @@ -46,11 +55,43 @@ const defaultSchedulerContext = getDefaultSchedulerContext( alertsClient ); +const actionsParams = { myParams: 'test' }; +const buildActionParams = jest.fn().mockReturnValue({ ...actionsParams, foo: 'bar' }); +defaultSchedulerContext.taskRunnerContext.connectorAdapterRegistry.register({ + connectorTypeId: '.test-system-action', + ruleActionParamsSchema: schema.object({}), + buildActionParams, +}); + // @ts-ignore const getSchedulerContext = (params = {}) => { return { ...defaultSchedulerContext, rule, ...params, ruleRunMetricsStore }; }; +const getResult = (actionId: string, actionUuid: string, summary: CombinedSummarizedAlerts) => ({ + actionToEnqueue: { + actionTypeId: '.test-system-action', + apiKey: 'MTIzOmFiYw==', + consumer: 'rule-consumer', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: actionId, + uuid: actionUuid, + relatedSavedObjects: [{ id: 'rule-id-1', namespace: 'test1', type: 'alert', typeId: 'test' }], + source: { source: { id: 'rule-id-1', type: 'alert' }, type: 'SAVED_OBJECT' }, + spaceId: 'test1', + }, + actionToLog: { + alertSummary: { + new: summary.new.count, + ongoing: summary.ongoing.count, + recovered: summary.recovered.count, + }, + id: actionId, + uuid: actionUuid, + typeId: '.test-system-action', + }, +}); + let clock: sinon.SinonFakeTimers; describe('System Action Scheduler', () => { @@ -88,13 +129,29 @@ describe('System Action Scheduler', () => { expect(scheduler.actions).toHaveLength(0); }); - describe('generateExecutables', () => { - const newAlert1 = generateAlert({ id: 1 }); - const newAlert2 = generateAlert({ id: 2 }); - const alerts = { ...newAlert1, ...newAlert2 }; + describe('getActionsToSchedule', () => { + let newAlert1: Record< + string, + Alert + >; + let newAlert2: Record< + string, + Alert + >; + let alerts: Record< + string, + Alert + >; - test('should generate executable for each system action', async () => { + beforeEach(() => { + newAlert1 = generateAlert({ id: 1 }); + newAlert2 = generateAlert({ id: 2 }); + alerts = { ...newAlert1, ...newAlert2 }; + }); + + test('should create actions to schedule for each system action', async () => { alertsClient.getProcessedAlerts.mockReturnValue(alerts); + const summarizedAlerts = { new: { count: 2, data: [mockAAD, mockAAD] }, ongoing: { count: 0, data: [] }, @@ -103,25 +160,27 @@ describe('System Action Scheduler', () => { alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); const scheduler = new SystemActionScheduler(getSchedulerContext()); - const executables = await scheduler.generateExecutables({ - alerts, - throttledSummaryActions: {}, - }); + const results = await scheduler.getActionsToSchedule({ alerts }); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ excludedAlertInstanceIds: [], executionUuid: defaultSchedulerContext.executionId, - ruleId: '1', + ruleId: 'rule-id-1', spaceId: 'test1', }); - expect(executables).toHaveLength(1); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(1); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(1); + expect(ruleRunMetricsStore.getStatusByConnectorType('.test-system-action')).toEqual({ + numberOfGeneratedActions: 1, + numberOfTriggeredActions: 1, + }); + + expect(results).toHaveLength(1); const finalSummary = { ...summarizedAlerts, all: { count: 2, data: [mockAAD, mockAAD] } }; - expect(executables).toEqual([ - { action: rule.systemActions?.[0], summarizedAlerts: finalSummary }, - ]); + expect(results).toEqual([getResult('system-action-1', 'xxx-xxx', finalSummary)]); }); test('should remove new alerts from summary if suppressed by maintenance window', async () => { @@ -141,22 +200,26 @@ describe('System Action Scheduler', () => { recovered: { count: 0, data: [] }, }; alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); - const scheduler = new SystemActionScheduler(getSchedulerContext()); - const executables = await scheduler.generateExecutables({ - alerts, - throttledSummaryActions: {}, - }); + const scheduler = new SystemActionScheduler(getSchedulerContext()); + const results = await scheduler.getActionsToSchedule({ alerts }); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ excludedAlertInstanceIds: [], executionUuid: defaultSchedulerContext.executionId, - ruleId: '1', + ruleId: 'rule-id-1', spaceId: 'test1', }); - expect(executables).toHaveLength(1); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(1); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(1); + expect(ruleRunMetricsStore.getStatusByConnectorType('.test-system-action')).toEqual({ + numberOfGeneratedActions: 1, + numberOfTriggeredActions: 1, + }); + + expect(results).toHaveLength(1); const finalSummary = { all: { count: 1, data: [newAADAlerts[1]] }, @@ -164,12 +227,10 @@ describe('System Action Scheduler', () => { ongoing: { count: 0, data: [] }, recovered: { count: 0, data: [] }, }; - expect(executables).toEqual([ - { action: rule.systemActions?.[0], summarizedAlerts: finalSummary }, - ]); + expect(results).toEqual([getResult('system-action-1', 'xxx-xxx', finalSummary)]); }); - test('should skip generating executable for summary action when no alerts found', async () => { + test('should skip creating actions to schedule for summary action when no alerts found', async () => { alertsClient.getProcessedAlerts.mockReturnValue(alerts); const summarizedAlerts = { new: { count: 0, data: [] }, @@ -179,21 +240,20 @@ describe('System Action Scheduler', () => { alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); const scheduler = new SystemActionScheduler(getSchedulerContext()); - - const executables = await scheduler.generateExecutables({ - alerts, - throttledSummaryActions: {}, - }); + const results = await scheduler.getActionsToSchedule({ alerts }); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ excludedAlertInstanceIds: [], executionUuid: defaultSchedulerContext.executionId, - ruleId: '1', + ruleId: 'rule-id-1', spaceId: 'test1', }); - expect(executables).toHaveLength(0); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(0); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(0); + + expect(results).toHaveLength(0); }); test('should throw framework error if getSummarizedAlerts throws error', async () => { @@ -205,14 +265,175 @@ describe('System Action Scheduler', () => { const scheduler = new SystemActionScheduler(getSchedulerContext()); try { - await scheduler.generateExecutables({ - alerts, - throttledSummaryActions: {}, - }); + await scheduler.getActionsToSchedule({ alerts }); } catch (err) { expect(err.message).toEqual(`no alerts for you`); expect(getErrorSource(err)).toBe(TaskErrorSource.FRAMEWORK); } }); + + test('should skip creating actions to schedule if overall max actions limit exceeded', async () => { + const anotherSystemAction = { + id: 'system-action-1', + actionTypeId: '.test-system-action', + params: { myParams: 'foo' }, + uuid: 'yyy-yyy', + }; + + alertsClient.getProcessedAlerts.mockReturnValue(alerts); + const summarizedAlerts = { + new: { count: 2, data: [mockAAD, mockAAD] }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }; + alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); + + const defaultContext = getSchedulerContext(); + const scheduler = new SystemActionScheduler({ + ...defaultContext, + rule: { ...rule, systemActions: [rule.systemActions?.[0]!, anotherSystemAction] }, + taskRunnerContext: { + ...defaultContext.taskRunnerContext, + actionsConfigMap: { + default: { max: 1 }, + }, + }, + }); + const results = await scheduler.getActionsToSchedule({ alerts }); + + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(2); + expect(alertsClient.getSummarizedAlerts).toHaveBeenNthCalledWith(1, { + excludedAlertInstanceIds: [], + executionUuid: defaultSchedulerContext.executionId, + ruleId: 'rule-id-1', + spaceId: 'test1', + }); + expect(alertsClient.getSummarizedAlerts).toHaveBeenNthCalledWith(2, { + excludedAlertInstanceIds: [], + executionUuid: defaultSchedulerContext.executionId, + ruleId: 'rule-id-1', + spaceId: 'test1', + }); + + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(2); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(1); + expect(ruleRunMetricsStore.getStatusByConnectorType('.test-system-action')).toEqual({ + numberOfGeneratedActions: 2, + numberOfTriggeredActions: 1, + triggeredActionsStatus: ActionsCompletion.PARTIAL, + }); + + expect(logger.debug).toHaveBeenCalledWith( + `Rule "rule-id-1" skipped scheduling action "system-action-1" because the maximum number of allowed actions has been reached.` + ); + + expect(results).toHaveLength(1); + + const finalSummary = { ...summarizedAlerts, all: { count: 2, data: [mockAAD, mockAAD] } }; + expect(results).toEqual([getResult('system-action-1', 'xxx-xxx', finalSummary)]); + }); + + test('should skip creating actions to schedule if connector type max actions limit exceeded', async () => { + const anotherSystemAction = { + id: 'system-action-1', + actionTypeId: '.test-system-action', + params: { myParams: 'foo' }, + uuid: 'yyy-yyy', + }; + + alertsClient.getProcessedAlerts.mockReturnValue(alerts); + const summarizedAlerts = { + new: { count: 2, data: [mockAAD, mockAAD] }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }; + alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); + + const defaultContext = getSchedulerContext(); + const scheduler = new SystemActionScheduler({ + ...defaultContext, + rule: { ...rule, systemActions: [rule.systemActions?.[0]!, anotherSystemAction] }, + taskRunnerContext: { + ...defaultContext.taskRunnerContext, + actionsConfigMap: { + default: { max: 1000 }, + '.test-system-action': { max: 1 }, + }, + }, + }); + const results = await scheduler.getActionsToSchedule({ alerts }); + + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(2); + expect(alertsClient.getSummarizedAlerts).toHaveBeenNthCalledWith(1, { + excludedAlertInstanceIds: [], + executionUuid: defaultSchedulerContext.executionId, + ruleId: 'rule-id-1', + spaceId: 'test1', + }); + expect(alertsClient.getSummarizedAlerts).toHaveBeenNthCalledWith(2, { + excludedAlertInstanceIds: [], + executionUuid: defaultSchedulerContext.executionId, + ruleId: 'rule-id-1', + spaceId: 'test1', + }); + + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(2); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(1); + expect(ruleRunMetricsStore.getStatusByConnectorType('.test-system-action')).toEqual({ + numberOfGeneratedActions: 2, + numberOfTriggeredActions: 1, + triggeredActionsStatus: ActionsCompletion.PARTIAL, + }); + + expect(logger.debug).toHaveBeenCalledWith( + `Rule "rule-id-1" skipped scheduling action "system-action-1" because the maximum number of allowed actions for connector type .test-system-action has been reached.` + ); + + expect(results).toHaveLength(1); + + const finalSummary = { ...summarizedAlerts, all: { count: 2, data: [mockAAD, mockAAD] } }; + expect(results).toEqual([getResult('system-action-1', 'xxx-xxx', finalSummary)]); + }); + + test('should skip creating actions to schedule if no connector adapter exists for connector type', async () => { + const differentSystemAction = { + id: 'different-action-1', + actionTypeId: '.test-bad-system-action', + params: { myParams: 'foo' }, + uuid: 'zzz-zzz', + }; + + alertsClient.getProcessedAlerts.mockReturnValue(alerts); + const summarizedAlerts = { + new: { count: 2, data: [mockAAD, mockAAD] }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }; + alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); + + const defaultContext = getSchedulerContext(); + const scheduler = new SystemActionScheduler({ + ...defaultContext, + rule: { ...rule, systemActions: [differentSystemAction] }, + }); + const results = await scheduler.getActionsToSchedule({ alerts }); + + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ + excludedAlertInstanceIds: [], + executionUuid: defaultSchedulerContext.executionId, + ruleId: 'rule-id-1', + spaceId: 'test1', + }); + + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(1); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(0); + + expect(logger.warn).toHaveBeenCalledWith( + `Rule "rule-id-1" skipped scheduling system action "different-action-1" because no connector adapter is configured` + ); + + expect(results).toHaveLength(0); + }); }); }); diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/system_action_scheduler.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/system_action_scheduler.ts index b923baf8fbf38..0c5cceb0f0a52 100644 --- a/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/system_action_scheduler.ts +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/system_action_scheduler.ts @@ -7,13 +7,19 @@ import { AlertInstanceState, AlertInstanceContext } from '@kbn/alerting-state-types'; import { RuleSystemAction, RuleTypeParams } from '@kbn/alerting-types'; +import { CombinedSummarizedAlerts } from '../../../types'; import { RuleTypeState, RuleAlertData } from '../../../../common'; import { GetSummarizedAlertsParams } from '../../../alerts_client/types'; -import { getSummarizedAlerts } from '../get_summarized_alerts'; +import { + buildRuleUrl, + formatActionToEnqueue, + getSummarizedAlerts, + shouldScheduleAction, +} from '../lib'; import { ActionSchedulerOptions, - Executable, - GenerateExecutablesOpts, + ActionsToSchedule, + GetActionsToScheduleOpts, IActionScheduler, } from '../types'; @@ -53,14 +59,19 @@ export class SystemActionScheduler< return 1; } - public async generateExecutables( - _: GenerateExecutablesOpts - ): Promise>> { - const executables = []; + public async getActionsToSchedule( + _: GetActionsToScheduleOpts + ): Promise { + const executables: Array<{ + action: RuleSystemAction; + summarizedAlerts: CombinedSummarizedAlerts; + }> = []; + const results: ActionsToSchedule[] = []; + for (const action of this.actions) { const options: GetSummarizedAlertsParams = { spaceId: this.context.taskInstance.params.spaceId, - ruleId: this.context.taskInstance.params.alertId, + ruleId: this.context.rule.id, excludedAlertInstanceIds: this.context.rule.mutedInstanceIds, executionUuid: this.context.executionId, }; @@ -75,6 +86,95 @@ export class SystemActionScheduler< } } - return executables; + if (executables.length === 0) return []; + + this.context.ruleRunMetricsStore.incrementNumberOfGeneratedActions(executables.length); + + const ruleUrl = buildRuleUrl({ + getViewInAppRelativeUrl: this.context.ruleType.getViewInAppRelativeUrl, + kibanaBaseUrl: this.context.taskRunnerContext.kibanaBaseUrl, + logger: this.context.logger, + rule: this.context.rule, + spaceId: this.context.taskInstance.params.spaceId, + }); + + for (const { action, summarizedAlerts } of executables) { + const { actionTypeId } = action; + + if ( + !shouldScheduleAction({ + action, + actionsConfigMap: this.context.taskRunnerContext.actionsConfigMap, + isActionExecutable: this.context.taskRunnerContext.actionsPlugin.isActionExecutable, + logger: this.context.logger, + ruleId: this.context.rule.id, + ruleRunMetricsStore: this.context.ruleRunMetricsStore, + }) + ) { + continue; + } + + const hasConnectorAdapter = this.context.taskRunnerContext.connectorAdapterRegistry.has( + action.actionTypeId + ); + + // System actions without an adapter cannot be executed + if (!hasConnectorAdapter) { + this.context.logger.warn( + `Rule "${this.context.rule.id}" skipped scheduling system action "${action.id}" because no connector adapter is configured` + ); + + continue; + } + + this.context.ruleRunMetricsStore.incrementNumberOfTriggeredActions(); + this.context.ruleRunMetricsStore.incrementNumberOfTriggeredActionsByConnectorType( + actionTypeId + ); + + const connectorAdapter = this.context.taskRunnerContext.connectorAdapterRegistry.get( + action.actionTypeId + ); + + const connectorAdapterActionParams = connectorAdapter.buildActionParams({ + alerts: summarizedAlerts, + rule: { + id: this.context.rule.id, + tags: this.context.rule.tags, + name: this.context.rule.name, + consumer: this.context.rule.consumer, + producer: this.context.ruleType.producer, + }, + ruleUrl: ruleUrl?.absoluteUrl, + spaceId: this.context.taskInstance.params.spaceId, + params: action.params, + }); + + const actionToRun = Object.assign(action, { params: connectorAdapterActionParams }); + + results.push({ + actionToEnqueue: formatActionToEnqueue({ + action: actionToRun, + apiKey: this.context.apiKey, + executionId: this.context.executionId, + ruleConsumer: this.context.ruleConsumer, + ruleId: this.context.rule.id, + ruleTypeId: this.context.ruleType.id, + spaceId: this.context.taskInstance.params.spaceId, + }), + actionToLog: { + id: action.id, + uuid: action.uuid, + typeId: action.actionTypeId, + alertSummary: { + new: summarizedAlerts.new.count, + ongoing: summarizedAlerts.ongoing.count, + recovered: summarizedAlerts.recovered.count, + }, + }, + }); + } + + return results; } } diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/types.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/types.ts index efcb51fcb2698..b90ffb88d541b 100644 --- a/x-pack/plugins/alerting/server/task_runner/action_scheduler/types.ts +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/types.ts @@ -8,6 +8,7 @@ import type { Logger } from '@kbn/core/server'; import { PublicMethodsOf } from '@kbn/utility-types'; import { ActionsClient } from '@kbn/actions-plugin/server/actions_client'; +import { ExecuteOptions as EnqueueExecutionOptions } from '@kbn/actions-plugin/server/create_execute_function'; import { IAlertsClient } from '../../alerts_client/types'; import { Alert } from '../../alert'; import { @@ -24,7 +25,10 @@ import { import { NormalizedRuleType } from '../../rule_type_registry'; import { CombinedSummarizedAlerts, RawRule } from '../../types'; import { RuleRunMetricsStore } from '../../lib/rule_run_metrics_store'; -import { AlertingEventLogger } from '../../lib/alerting_event_logger/alerting_event_logger'; +import { + ActionOpts, + AlertingEventLogger, +} from '../../lib/alerting_event_logger/alerting_event_logger'; import { RuleTaskInstance, TaskRunnerContext } from '../types'; export interface ActionSchedulerOptions< @@ -80,14 +84,19 @@ export type Executable< } ); -export interface GenerateExecutablesOpts< +export interface GetActionsToScheduleOpts< State extends AlertInstanceState, Context extends AlertInstanceContext, ActionGroupIds extends string, RecoveryActionGroupId extends string > { alerts: Record>; - throttledSummaryActions: ThrottledActions; + throttledSummaryActions?: ThrottledActions; +} + +export interface ActionsToSchedule { + actionToEnqueue: EnqueueExecutionOptions; + actionToLog: ActionOpts; } export interface IActionScheduler< @@ -97,9 +106,9 @@ export interface IActionScheduler< RecoveryActionGroupId extends string > { get priority(): number; - generateExecutables( - opts: GenerateExecutablesOpts - ): Promise>>; + getActionsToSchedule( + opts: GetActionsToScheduleOpts + ): Promise; } export interface RuleUrl { diff --git a/x-pack/plugins/alerting/server/task_runner/fixtures.ts b/x-pack/plugins/alerting/server/task_runner/fixtures.ts index 5174aa9b965ec..d820f2690caeb 100644 --- a/x-pack/plugins/alerting/server/task_runner/fixtures.ts +++ b/x-pack/plugins/alerting/server/task_runner/fixtures.ts @@ -21,7 +21,7 @@ import { import { getDefaultMonitoring } from '../lib/monitoring'; import { UntypedNormalizedRuleType } from '../rule_type_registry'; import { EVENT_LOG_ACTIONS } from '../plugin'; -import { RawRule } from '../types'; +import { AlertHit, RawRule } from '../types'; import { RULE_SAVED_OBJECT_TYPE } from '../saved_objects'; interface GeneratorParams { @@ -349,9 +349,10 @@ export const generateAlertOpts = ({ }; }; -export const generateActionOpts = ({ id, alertGroup, alertId }: GeneratorParams = {}) => ({ +export const generateActionOpts = ({ id, alertGroup, alertId, uuid }: GeneratorParams = {}) => ({ id: id ?? '1', typeId: 'action', + uuid: uuid ?? '111-111', alertId: alertId ?? '1', alertGroup: alertGroup ?? 'default', }); @@ -403,11 +404,13 @@ export const generateRunnerResult = ({ export const generateEnqueueFunctionInput = ({ id = '1', + uuid = '111-111', isBulk = false, isResolved, foo, actionTypeId, }: { + uuid?: string; id: string; isBulk?: boolean; isResolved?: boolean; @@ -419,6 +422,7 @@ export const generateEnqueueFunctionInput = ({ apiKey: 'MTIzOmFiYw==', executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', id, + uuid, params: { ...(isResolved !== undefined ? { isResolved } : {}), ...(foo !== undefined ? { foo } : {}), @@ -504,4 +508,4 @@ export const mockAAD = { }, }, }, -}; +} as unknown as AlertHit; diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index eb531f0e00b88..b6e59402ba4c6 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -1420,7 +1420,7 @@ describe('Task Runner', () => { expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith(1, generateActionOpts({})); expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith( 2, - generateActionOpts({ id: '2', alertId: '2', alertGroup: 'recovered' }) + generateActionOpts({ id: '2', alertId: '2', alertGroup: 'recovered', uuid: '222-222' }) ); expect(enqueueFunction).toHaveBeenCalledTimes(isBulk ? 1 : 2); @@ -1428,7 +1428,12 @@ describe('Task Runner', () => { isBulk ? [ generateEnqueueFunctionInput({ isBulk: false, id: '1', foo: true }), - generateEnqueueFunctionInput({ isBulk: false, id: '2', isResolved: true }), + generateEnqueueFunctionInput({ + isBulk: false, + id: '2', + isResolved: true, + uuid: '222-222', + }), ] : generateEnqueueFunctionInput({ isBulk: false, id: '1', foo: true }) ); @@ -1645,7 +1650,12 @@ describe('Task Runner', () => { isBulk ? [ generateEnqueueFunctionInput({ isBulk: false, id: '1', foo: true }), - generateEnqueueFunctionInput({ isBulk: false, id: '2', isResolved: true }), + generateEnqueueFunctionInput({ + isBulk: false, + id: '2', + isResolved: true, + uuid: '222-222', + }), ] : generateEnqueueFunctionInput({ isBulk: false, id: '1', foo: true }) ); @@ -2891,26 +2901,31 @@ describe('Task Runner', () => { { group: 'default', id: '1', + uuid: '111-111', actionTypeId: 'action', }, { group: 'default', id: '2', + uuid: '222-222', actionTypeId: 'action', }, { group: 'default', id: '3', + uuid: '333-333', actionTypeId: 'action', }, { group: 'default', id: '4', + uuid: '444-444', actionTypeId: 'action', }, { group: 'default', id: '5', + uuid: '555-555', actionTypeId: 'action', }, ]; @@ -2975,7 +2990,7 @@ describe('Task Runner', () => { }) ); - expect(logger.debug).toHaveBeenCalledTimes(7); + expect(logger.debug).toHaveBeenCalledTimes(8); expect(logger.debug).nthCalledWith( 3, @@ -3012,11 +3027,11 @@ describe('Task Runner', () => { expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith(1, generateActionOpts({})); expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith( 2, - generateActionOpts({ id: '2' }) + generateActionOpts({ id: '2', uuid: '222-222' }) ); expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith( 3, - generateActionOpts({ id: '3' }) + generateActionOpts({ id: '3', uuid: '333-333' }) ); }); @@ -3061,26 +3076,31 @@ describe('Task Runner', () => { { group: 'default', id: '1', + uuid: '111-111', actionTypeId: '.server-log', }, { group: 'default', id: '2', + uuid: '222-222', actionTypeId: '.server-log', }, { group: 'default', id: '3', + uuid: '333-333', actionTypeId: '.server-log', }, { group: 'default', id: '4', + uuid: '444-444', actionTypeId: 'any-action', }, { group: 'default', id: '5', + uuid: '555-555', actionTypeId: 'any-action', }, ] as RuleAction[], @@ -3176,7 +3196,7 @@ describe('Task Runner', () => { status: 'warning', errorReason: `maxExecutableActions`, logAlert: 4, - logAction: 3, + logAction: 5, }); }); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts index f6f27e15ee7a4..955f5a45a9743 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts @@ -21,6 +21,7 @@ import type { CspFinding } from '@kbn/cloud-security-posture-common'; import type { CspBenchmarkRulesStates } from '@kbn/cloud-security-posture-common/schema/rules/latest'; import type { FindingsBaseEsQuery } from '@kbn/cloud-security-posture'; import { useGetCspBenchmarkRulesStatesApi } from '@kbn/cloud-security-posture/src/hooks/use_get_benchmark_rules_state_api'; +import type { RuntimePrimitiveTypes } from '@kbn/data-views-plugin/common'; import { useKibana } from '../../../common/hooks/use_kibana'; import { getAggregationCount, getFindingsCountAggQuery } from '../utils/utils'; @@ -39,6 +40,20 @@ interface FindingsAggs { count: estypes.AggregationsMultiBucketAggregateBase; } +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 === '@timestamp' ? 'date' : 'keyword'; + + return { + ...acc, + [field]: { + type, + }, + }; + }, {}); +}; + export const getFindingsQuery = ( { query, sort }: UseFindingsOptions, rulesStates: CspBenchmarkRulesStates, @@ -49,6 +64,7 @@ export const getFindingsQuery = ( return { index: CDR_MISCONFIGURATIONS_INDEX_PATTERN, sort: getMultiFieldsSort(sort), + runtime_mappings: getRuntimeMappingsFromSort(sort), size: MAX_FINDINGS_TO_LOAD, aggs: getFindingsCountAggQuery(), ignore_unavailable: true, 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 cc409fb95024d..6482d864347a1 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 @@ -114,6 +114,72 @@ const getAggregationsByGroupField = (field: string): NamedAggregation[] => { return aggMetrics; }; +/** + * Get runtime mappings for the given group field + * Some fields require additional runtime mappings to aggregate additional information + * Fallback to keyword type to support custom fields grouping + */ +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]: { + 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', + }, + }; + } +}; + /** * Type Guard for checking if the given source is a FindingsRootGroupingAggregation */ @@ -189,6 +255,12 @@ export const useLatestFindingsGrouping = ({ size: pageSize, sort: [{ groupByField: { order: 'desc' } }, { complianceScore: { order: 'asc' } }], statsAggregations: getAggregationsByGroupField(currentSelectedGroup), + runtimeMappings: { + ...getRuntimeMappingsByGroupField(currentSelectedGroup), + 'result.evaluation': { + type: 'keyword', + }, + }, 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 0d0ea9ba5a22f..0b9cf6978c258 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 @@ -23,6 +23,7 @@ import { } from '@kbn/cloud-security-posture-common'; 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 { useKibana } from '../../../common/hooks/use_kibana'; import { getCaseInsensitiveSortScript } from '../utils/custom_sort_script'; @@ -52,6 +53,25 @@ 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 { + ...acc, + [field]: { + type, + }, + }; + }, {}); +}; + export const getVulnerabilitiesQuery = ( { query, sort }: VulnerabilitiesQuery, pageParam: number @@ -59,6 +79,7 @@ export const getVulnerabilitiesQuery = ( index: CDR_VULNERABILITIES_INDEX_PATTERN, ignore_unavailable: true, sort: getMultiFieldsSort(sort), + runtime_mappings: getRuntimeMappingsFromSort(sort), size: MAX_FINDINGS_TO_LOAD, query: { ...query, 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 516cbed0c3975..3c52590f8fd80 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 @@ -94,6 +94,51 @@ const getAggregationsByGroupField = (field: string): NamedAggregation[] => { return aggMetrics; }; +/** + * Get runtime mappings for the given group field + * Some fields require additional runtime mappings to aggregate additional information + * Fallback to keyword type to support custom fields grouping + */ +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]: { + type: 'keyword', + }, + }; + } +}; + /** * Type Guard for checking if the given source is a VulnerabilitiesRootGroupingAggregation */ @@ -163,6 +208,7 @@ export const useLatestVulnerabilitiesGrouping = ({ size: pageSize, sort: [{ groupByField: { order: 'desc' } }], statsAggregations: getAggregationsByGroupField(currentSelectedGroup), + runtimeMappings: getRuntimeMappingsByGroupField(currentSelectedGroup), }); const { data, isFetching } = useGroupedVulnerabilities({ diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/create_detection_rule_from_vulnerability.test.ts b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/create_detection_rule_from_vulnerability.test.ts index 209ec81168271..7dd0982cc58b5 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/create_detection_rule_from_vulnerability.test.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/create_detection_rule_from_vulnerability.test.ts @@ -89,7 +89,7 @@ describe('CreateDetectionRuleFromVulnerability', () => { } as Vulnerability; const currentTimestamp = new Date().toISOString(); - const query = generateVulnerabilitiesRuleQuery(mockVulnerability); + const query = generateVulnerabilitiesRuleQuery(mockVulnerability, currentTimestamp); expect(query).toEqual( `vulnerability.id: "CVE-2024-00005" AND event.ingested >= "${currentTimestamp}"` ); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/create_detection_rule_from_vulnerability.ts b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/create_detection_rule_from_vulnerability.ts index b723c60f9ee3d..804e89fad61d8 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/create_detection_rule_from_vulnerability.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/create_detection_rule_from_vulnerability.ts @@ -53,10 +53,11 @@ export const getVulnerabilityRuleName = (vulnerability: Vulnerability) => { }); }; -export const generateVulnerabilitiesRuleQuery = (vulnerability: Vulnerability) => { - const currentTimestamp = new Date().toISOString(); - - return `vulnerability.id: "${vulnerability.id}" AND event.ingested >= "${currentTimestamp}"`; +export const generateVulnerabilitiesRuleQuery = ( + vulnerability: Vulnerability, + startTimestamp = new Date().toISOString() +) => { + return `vulnerability.id: "${vulnerability.id}" AND event.ingested >= "${startTimestamp}"`; }; const CSP_RULE_TAG = 'Cloud Security'; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/custom_sort_script.ts b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/custom_sort_script.ts index 780cd539305b3..e517d622e71c5 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/custom_sort_script.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/custom_sort_script.ts @@ -14,7 +14,13 @@ export const getCaseInsensitiveSortScript = (field: string, direction: string) = type: 'string', order: direction, script: { - source: `doc["${field}"].value.toLowerCase()`, + source: ` + if (doc.containsKey('${field}') && !doc['${field}'].empty) { + return doc['${field}'].value.toLowerCase(); + } else { + return ""; + } + `, lang: 'painless', }, }, diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js index 351f1bd77592f..c0b6c0f4c9a09 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js @@ -30,8 +30,6 @@ import { EuiTextColor, EuiTitle, } from '@elastic/eui'; -import 'react-ace'; -import 'brace/theme/textmate'; import { getIndexListUri } from '@kbn/index-management-plugin/public'; import { routing } from '../../../../../services/routing'; diff --git a/x-pack/plugins/data_usage/common/rest_types/usage_metrics.test.ts b/x-pack/plugins/data_usage/common/rest_types/usage_metrics.test.ts index f6c08e2caddc0..473e64c6b03d9 100644 --- a/x-pack/plugins/data_usage/common/rest_types/usage_metrics.test.ts +++ b/x-pack/plugins/data_usage/common/rest_types/usage_metrics.test.ts @@ -10,48 +10,29 @@ import { UsageMetricsRequestSchema } from './usage_metrics'; describe('usage_metrics schemas', () => { it('should accept valid request query', () => { expect(() => - UsageMetricsRequestSchema.query.validate({ + UsageMetricsRequestSchema.validate({ from: new Date().toISOString(), to: new Date().toISOString(), metricTypes: ['storage_retained'], - }) - ).not.toThrow(); - }); - - it('should accept a single `metricTypes` in request query', () => { - expect(() => - UsageMetricsRequestSchema.query.validate({ - from: new Date().toISOString(), - to: new Date().toISOString(), - metricTypes: 'ingest_rate', + dataStreams: ['data_stream_1', 'data_stream_2', 'data_stream_3'], }) ).not.toThrow(); }); it('should accept multiple `metricTypes` in request query', () => { expect(() => - UsageMetricsRequestSchema.query.validate({ + UsageMetricsRequestSchema.validate({ from: new Date().toISOString(), to: new Date().toISOString(), metricTypes: ['ingest_rate', 'storage_retained', 'index_rate'], - }) - ).not.toThrow(); - }); - - it('should accept a single string as `dataStreams` in request query', () => { - expect(() => - UsageMetricsRequestSchema.query.validate({ - from: new Date().toISOString(), - to: new Date().toISOString(), - metricTypes: 'storage_retained', - dataStreams: 'data_stream_1', + dataStreams: ['data_stream_1', 'data_stream_2', 'data_stream_3'], }) ).not.toThrow(); }); it('should accept `dataStream` list', () => { expect(() => - UsageMetricsRequestSchema.query.validate({ + UsageMetricsRequestSchema.validate({ from: new Date().toISOString(), to: new Date().toISOString(), metricTypes: ['storage_retained'], @@ -62,74 +43,76 @@ describe('usage_metrics schemas', () => { it('should error if `dataStream` list is empty', () => { expect(() => - UsageMetricsRequestSchema.query.validate({ + UsageMetricsRequestSchema.validate({ from: new Date().toISOString(), to: new Date().toISOString(), metricTypes: ['storage_retained'], dataStreams: [], }) - ).toThrowError('expected value of type [string] but got [Array]'); + ).toThrowError('[dataStreams]: array size is [0], but cannot be smaller than [1]'); }); - it('should error if `dataStream` is given an empty string', () => { + it('should error if `dataStream` is given type not array', () => { expect(() => - UsageMetricsRequestSchema.query.validate({ + UsageMetricsRequestSchema.validate({ from: new Date().toISOString(), to: new Date().toISOString(), metricTypes: ['storage_retained'], dataStreams: ' ', }) - ).toThrow('[dataStreams] must have at least one value'); + ).toThrow('[dataStreams]: could not parse array value from json input'); }); it('should error if `dataStream` is given an empty item in the list', () => { expect(() => - UsageMetricsRequestSchema.query.validate({ + UsageMetricsRequestSchema.validate({ from: new Date().toISOString(), to: new Date().toISOString(), metricTypes: ['storage_retained'], dataStreams: ['ds_1', ' '], }) - ).toThrow('[dataStreams] list can not contain empty values'); + ).toThrow('[dataStreams]: [dataStreams] list cannot contain empty values'); }); it('should error if `metricTypes` is empty string', () => { expect(() => - UsageMetricsRequestSchema.query.validate({ + UsageMetricsRequestSchema.validate({ from: new Date().toISOString(), to: new Date().toISOString(), + dataStreams: ['data_stream_1', 'data_stream_2', 'data_stream_3'], metricTypes: ' ', }) ).toThrow(); }); - it('should error if `metricTypes` is empty item', () => { + it('should error if `metricTypes` contains an empty item', () => { expect(() => - UsageMetricsRequestSchema.query.validate({ + UsageMetricsRequestSchema.validate({ from: new Date().toISOString(), to: new Date().toISOString(), - metricTypes: [' ', 'storage_retained'], + dataStreams: ['data_stream_1', 'data_stream_2', 'data_stream_3'], + metricTypes: [' ', 'storage_retained'], // First item is invalid }) - ).toThrow('[metricTypes] list can not contain empty values'); + ).toThrowError(/list cannot contain empty values/); }); - it('should error if `metricTypes` is not a valid value', () => { + it('should error if `metricTypes` is not a valid type', () => { expect(() => - UsageMetricsRequestSchema.query.validate({ + UsageMetricsRequestSchema.validate({ from: new Date().toISOString(), to: new Date().toISOString(), + dataStreams: ['data_stream_1', 'data_stream_2', 'data_stream_3'], metricTypes: 'foo', }) - ).toThrow( - '[metricTypes] must be one of storage_retained, ingest_rate, search_vcu, ingest_vcu, ml_vcu, index_latency, index_rate, search_latency, search_rate' - ); + ).toThrow('[metricTypes]: could not parse array value from json input'); }); it('should error if `metricTypes` is not a valid list', () => { expect(() => - UsageMetricsRequestSchema.query.validate({ + UsageMetricsRequestSchema.validate({ from: new Date().toISOString(), to: new Date().toISOString(), + dataStreams: ['data_stream_1', 'data_stream_2', 'data_stream_3'], metricTypes: ['storage_retained', 'foo'], }) ).toThrow( @@ -139,9 +122,10 @@ describe('usage_metrics schemas', () => { it('should error if `from` is not a valid input', () => { expect(() => - UsageMetricsRequestSchema.query.validate({ + UsageMetricsRequestSchema.validate({ from: 1010, to: new Date().toISOString(), + dataStreams: ['data_stream_1', 'data_stream_2', 'data_stream_3'], metricTypes: ['storage_retained', 'foo'], }) ).toThrow('[from]: expected value of type [string] but got [number]'); @@ -149,9 +133,10 @@ describe('usage_metrics schemas', () => { it('should error if `to` is not a valid input', () => { expect(() => - UsageMetricsRequestSchema.query.validate({ + UsageMetricsRequestSchema.validate({ from: new Date().toISOString(), to: 1010, + dataStreams: ['data_stream_1', 'data_stream_2', 'data_stream_3'], metricTypes: ['storage_retained', 'foo'], }) ).toThrow('[to]: expected value of type [string] but got [number]'); @@ -159,9 +144,10 @@ describe('usage_metrics schemas', () => { it('should error if `from` is empty string', () => { expect(() => - UsageMetricsRequestSchema.query.validate({ + UsageMetricsRequestSchema.validate({ from: ' ', to: new Date().toISOString(), + dataStreams: ['data_stream_1', 'data_stream_2', 'data_stream_3'], metricTypes: ['storage_retained', 'foo'], }) ).toThrow('[from]: Date ISO string must not be empty'); @@ -169,9 +155,10 @@ describe('usage_metrics schemas', () => { it('should error if `to` is empty string', () => { expect(() => - UsageMetricsRequestSchema.query.validate({ + UsageMetricsRequestSchema.validate({ from: new Date().toISOString(), to: ' ', + dataStreams: ['data_stream_1', 'data_stream_2', 'data_stream_3'], metricTypes: ['storage_retained', 'foo'], }) ).toThrow('[to]: Date ISO string must not be empty'); 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 f2bbdb616fc79..3dceeadc198b0 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 @@ -37,51 +37,31 @@ const metricTypesSchema = schema.oneOf( // @ts-expect-error TS2769: No overload matches this call METRIC_TYPE_VALUES.map((metricType) => schema.literal(metricType)) // Create a oneOf schema for the keys ); -export const UsageMetricsRequestSchema = { - query: schema.object({ - from: DateSchema, - to: DateSchema, - metricTypes: schema.oneOf([ - schema.arrayOf(schema.string(), { - minSize: 1, - validate: (values) => { - if (values.map((v) => v.trim()).some((v) => !v.length)) { - return '[metricTypes] list can not contain empty values'; - } else if (values.map((v) => v.trim()).some((v) => !isValidMetricType(v))) { - return `[metricTypes] must be one of ${METRIC_TYPE_VALUES.join(', ')}`; - } - }, - }), - schema.string({ - validate: (v) => { - if (!v.trim().length) { - return '[metricTypes] must have at least one value'; - } else if (!isValidMetricType(v)) { - return `[metricTypes] must be one of ${METRIC_TYPE_VALUES.join(', ')}`; - } - }, - }), - ]), - dataStreams: schema.maybe( - schema.oneOf([ - schema.arrayOf(schema.string(), { - minSize: 1, - validate: (values) => { - if (values.map((v) => v.trim()).some((v) => !v.length)) { - return '[dataStreams] list can not contain empty values'; - } - }, - }), - schema.string({ - validate: (v) => - v.trim().length ? undefined : '[dataStreams] must have at least one value', - }), - ]) - ), +export const UsageMetricsRequestSchema = schema.object({ + from: DateSchema, + to: DateSchema, + metricTypes: schema.arrayOf(schema.string(), { + minSize: 1, + validate: (values) => { + const trimmedValues = values.map((v) => v.trim()); + if (trimmedValues.some((v) => !v.length)) { + return '[metricTypes] list cannot contain empty values'; + } else if (trimmedValues.some((v) => !isValidMetricType(v))) { + return `[metricTypes] must be one of ${METRIC_TYPE_VALUES.join(', ')}`; + } + }, }), -}; + dataStreams: schema.arrayOf(schema.string(), { + minSize: 1, + validate: (values) => { + if (values.map((v) => v.trim()).some((v) => !v.length)) { + return '[dataStreams] list cannot contain empty values'; + } + }, + }), +}); -export type UsageMetricsRequestSchemaQueryParams = TypeOf; +export type UsageMetricsRequestSchemaQueryParams = TypeOf; export const UsageMetricsResponseSchema = { body: () => @@ -92,11 +72,40 @@ export const UsageMetricsResponseSchema = { schema.object({ name: schema.string(), data: schema.arrayOf( - schema.arrayOf(schema.number(), { minSize: 2, maxSize: 2 }) // Each data point is an array of 2 numbers + schema.object({ + x: schema.number(), + y: schema.number(), + }) ), }) ) ), }), }; -export type UsageMetricsResponseSchemaBody = TypeOf; +export type UsageMetricsResponseSchemaBody = Omit< + TypeOf, + 'metrics' +> & { + metrics: Partial>; +}; +export type MetricSeries = TypeOf< + typeof UsageMetricsResponseSchema.body +>['metrics'][MetricTypes][number]; + +export const UsageMetricsAutoOpsResponseSchema = { + body: () => + schema.object({ + metrics: schema.recordOf( + metricTypesSchema, + schema.arrayOf( + schema.object({ + name: schema.string(), + data: schema.arrayOf(schema.arrayOf(schema.number(), { minSize: 2, maxSize: 2 })), + }) + ) + ), + }), +}; +export type UsageMetricsAutoOpsResponseSchemaBody = TypeOf< + typeof UsageMetricsAutoOpsResponseSchema.body +>; diff --git a/x-pack/plugins/data_usage/public/app/components/chart_panel.tsx b/x-pack/plugins/data_usage/public/app/components/chart_panel.tsx index c7937ae149de9..1ba3f0fe3f454 100644 --- a/x-pack/plugins/data_usage/public/app/components/chart_panel.tsx +++ b/x-pack/plugins/data_usage/public/app/components/chart_panel.tsx @@ -19,8 +19,7 @@ import { } from '@elastic/charts'; import { i18n } from '@kbn/i18n'; import { LegendAction } from './legend_action'; -import { MetricTypes } from '../../../common/rest_types'; -import { MetricSeries } from '../types'; +import { MetricTypes, MetricSeries } from '../../../common/rest_types'; // TODO: Remove this when we have a title for each metric type type ChartKey = Extract; @@ -50,7 +49,7 @@ export const ChartPanel: React.FC = ({ }) => { const theme = useEuiTheme(); - const chartTimestamps = series.flatMap((stream) => stream.data.map((d) => d[0])); + const chartTimestamps = series.flatMap((stream) => stream.data.map((d) => d.x)); const [minTimestamp, maxTimestamp] = [Math.min(...chartTimestamps), Math.max(...chartTimestamps)]; @@ -72,6 +71,7 @@ export const ChartPanel: React.FC = ({ }, [idx, popoverOpen, togglePopover] ); + return ( @@ -94,9 +94,9 @@ export const ChartPanel: React.FC = ({ data={stream.data} xScaleType={ScaleType.Time} yScaleType={ScaleType.Linear} - xAccessor={0} // x is the first element in the tuple - yAccessors={[1]} // y is the second element in the tuple - stackAccessors={[0]} + xAccessor="x" + yAccessors={['y']} + stackAccessors={['x']} /> ))} @@ -118,6 +118,7 @@ export const ChartPanel: React.FC = ({ ); }; + const formatBytes = (bytes: number) => { return numeral(bytes).format('0.0 b'); }; 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 6549f7e03830a..8d04324fb2246 100644 --- a/x-pack/plugins/data_usage/public/app/components/charts.tsx +++ b/x-pack/plugins/data_usage/public/app/components/charts.tsx @@ -6,11 +6,11 @@ */ import React, { useCallback, useState } from 'react'; import { EuiFlexGroup } from '@elastic/eui'; -import { MetricsResponse } from '../types'; import { MetricTypes } from '../../../common/rest_types'; import { ChartPanel } from './chart_panel'; +import { UsageMetricsResponseSchemaBody } from '../../../common/rest_types'; interface ChartsProps { - data: MetricsResponse; + data: UsageMetricsResponseSchemaBody; } export const Charts: React.FC = ({ data }) => { diff --git a/x-pack/plugins/data_usage/public/app/data_usage.tsx b/x-pack/plugins/data_usage/public/app/data_usage.tsx index c32f86d68b5bf..bea9f2b511a77 100644 --- a/x-pack/plugins/data_usage/public/app/data_usage.tsx +++ b/x-pack/plugins/data_usage/public/app/data_usage.tsx @@ -26,7 +26,6 @@ import { PLUGIN_NAME } from '../../common'; import { useGetDataUsageMetrics } from '../hooks/use_get_usage_metrics'; import { DEFAULT_DATE_RANGE_OPTIONS, useDateRangePicker } from './hooks/use_date_picker'; import { useDataUsageMetricsUrlParams } from './hooks/use_charts_url_params'; -import { MetricsResponse } from './types'; export const DataUsage = () => { const { @@ -42,37 +41,37 @@ export const DataUsage = () => { setUrlDateRangeFilter, } = useDataUsageMetricsUrlParams(); - const [queryParams, setQueryParams] = useState({ + const [metricsFilters, setMetricsFilters] = useState({ metricTypes: ['storage_retained', 'ingest_rate'], - dataStreams: [], + // TODO: Replace with data streams from /data_streams api + dataStreams: [ + '.alerts-ml.anomaly-detection-health.alerts-default', + '.alerts-stack.alerts-default', + ], from: DEFAULT_DATE_RANGE_OPTIONS.startDate, to: DEFAULT_DATE_RANGE_OPTIONS.endDate, }); useEffect(() => { if (!metricTypesFromUrl) { - setUrlMetricTypesFilter( - typeof queryParams.metricTypes !== 'string' - ? queryParams.metricTypes.join(',') - : queryParams.metricTypes - ); + setUrlMetricTypesFilter(metricsFilters.metricTypes.join(',')); } if (!startDateFromUrl || !endDateFromUrl) { - setUrlDateRangeFilter({ startDate: queryParams.from, endDate: queryParams.to }); + setUrlDateRangeFilter({ startDate: metricsFilters.from, endDate: metricsFilters.to }); } }, [ endDateFromUrl, metricTypesFromUrl, - queryParams.from, - queryParams.metricTypes, - queryParams.to, + metricsFilters.from, + metricsFilters.metricTypes, + metricsFilters.to, setUrlDateRangeFilter, setUrlMetricTypesFilter, startDateFromUrl, ]); useEffect(() => { - setQueryParams((prevState) => ({ + setMetricsFilters((prevState) => ({ ...prevState, metricTypes: metricTypesFromUrl?.length ? metricTypesFromUrl : prevState.metricTypes, dataStreams: dataStreamsFromUrl?.length ? dataStreamsFromUrl : prevState.dataStreams, @@ -89,7 +88,7 @@ export const DataUsage = () => { refetch: refetchDataUsageMetrics, } = useGetDataUsageMetrics( { - ...queryParams, + ...metricsFilters, from: dateRangePickerState.startDate, to: dateRangePickerState.endDate, }, @@ -140,7 +139,7 @@ export const DataUsage = () => { - {isFetched && data ? : } + {isFetched && data ? : } ); diff --git a/x-pack/plugins/data_usage/public/app/types.ts b/x-pack/plugins/data_usage/public/app/types.ts deleted file mode 100644 index 13f53bc2ea6dd..0000000000000 --- a/x-pack/plugins/data_usage/public/app/types.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 { MetricTypes } from '../../common/rest_types'; - -export type DataPoint = [number, number]; // [timestamp, value] - -export interface MetricSeries { - name: string; // Name of the data stream - data: DataPoint[]; // Array of data points in tuple format [timestamp, value] -} -// Use MetricTypes dynamically as keys for the Metrics interface -export type Metrics = Partial>; - -export interface MetricsResponse { - metrics: Metrics; -} -export interface MetricsResponse { - metrics: Metrics; -} 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 6b9860e997c12..3d648eb183f07 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,24 +21,24 @@ interface ErrorType { } export const useGetDataUsageMetrics = ( - query: UsageMetricsRequestSchemaQueryParams, + body: UsageMetricsRequestSchemaQueryParams, options: UseQueryOptions> = {} ): UseQueryResult> => { const http = useKibanaContextForPlugin().services.http; return useQuery>({ - queryKey: ['get-data-usage-metrics', query], + queryKey: ['get-data-usage-metrics', body], ...options, keepPreviousData: true, queryFn: async () => { - return http.get(DATA_USAGE_METRICS_API_ROUTE, { + return http.post(DATA_USAGE_METRICS_API_ROUTE, { version: '1', - query: { - from: query.from, - to: query.to, - metricTypes: query.metricTypes, - dataStreams: query.dataStreams, - }, + body: JSON.stringify({ + from: body.from, + to: body.to, + metricTypes: body.metricTypes, + dataStreams: body.dataStreams, + }), }); }, }); diff --git a/x-pack/plugins/data_usage/server/routes/internal/usage_metrics.ts b/x-pack/plugins/data_usage/server/routes/internal/usage_metrics.ts index 5bf3008ef668a..0013102f697fb 100644 --- a/x-pack/plugins/data_usage/server/routes/internal/usage_metrics.ts +++ b/x-pack/plugins/data_usage/server/routes/internal/usage_metrics.ts @@ -17,7 +17,7 @@ export const registerUsageMetricsRoute = ( ) => { if (dataUsageContext.serverConfig.enabled) { router.versioned - .get({ + .post({ access: 'internal', path: DATA_USAGE_METRICS_API_ROUTE, }) @@ -25,7 +25,9 @@ export const registerUsageMetricsRoute = ( { version: '1', validate: { - request: UsageMetricsRequestSchema, + request: { + body: UsageMetricsRequestSchema, + }, response: { 200: UsageMetricsResponseSchema, }, 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 6f992c9fb2a38..09e9f88721c63 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 @@ -9,8 +9,10 @@ import { RequestHandler } from '@kbn/core/server'; import { IndicesGetDataStreamResponse } from '@elastic/elasticsearch/lib/api/types'; import { MetricTypes, + UsageMetricsAutoOpsResponseSchema, + UsageMetricsAutoOpsResponseSchemaBody, UsageMetricsRequestSchemaQueryParams, - UsageMetricsResponseSchema, + UsageMetricsResponseSchemaBody, } from '../../../common/rest_types'; import { DataUsageContext, DataUsageRequestHandlerContext } from '../../types'; @@ -34,45 +36,26 @@ export const getUsageMetricsHandler = ( const core = await context.core; const esClient = core.elasticsearch.client.asCurrentUser; - // @ts-ignore - const { from, to, metricTypes, dataStreams: dsNames, size } = request.query; + const { from, to, metricTypes, dataStreams: requestDsNames } = request.query; logger.debug(`Retrieving usage metrics`); const { data_streams: dataStreamsResponse }: IndicesGetDataStreamResponse = await esClient.indices.getDataStream({ - name: '*', + name: requestDsNames, expand_wildcards: 'all', }); - const hasDataStreams = dataStreamsResponse.length > 0; - let userDsNames: string[] = []; - - if (dsNames?.length) { - userDsNames = typeof dsNames === 'string' ? [dsNames] : dsNames; - } else if (!userDsNames.length && hasDataStreams) { - userDsNames = dataStreamsResponse.map((ds) => ds.name); - } - - // If no data streams are found, return an empty response - if (!userDsNames.length) { - return response.ok({ - body: { - metrics: {}, - }, - }); - } - const metrics = await fetchMetricsFromAutoOps({ from, to, metricTypes: formatStringParams(metricTypes) as MetricTypes[], - dataStreams: formatStringParams(userDsNames), + dataStreams: formatStringParams(dataStreamsResponse.map((ds) => ds.name)), }); + const processedMetrics = transformMetricsData(metrics); + return response.ok({ - body: { - metrics, - }, + body: processedMetrics, }); } catch (error) { logger.error(`Error retrieving usage metrics: ${error.message}`); @@ -94,7 +77,7 @@ const fetchMetricsFromAutoOps = async ({ }) => { // TODO: fetch data from autoOps using userDsNames /* - const response = await axios.post('https://api.auto-ops.{region}.{csp}.cloud.elastic.co/monitoring/serverless/v1/projects/{project_id}/metrics', { + const response = await axios.post({AUTOOPS_URL}, { from: Date.parse(from), to: Date.parse(to), metric_types: metricTypes, @@ -231,7 +214,25 @@ const fetchMetricsFromAutoOps = async ({ }, }; // Make sure data is what we expect - const validatedData = UsageMetricsResponseSchema.body().validate(mockData); + const validatedData = UsageMetricsAutoOpsResponseSchema.body().validate(mockData); - return validatedData.metrics; + return validatedData; }; +function transformMetricsData( + data: UsageMetricsAutoOpsResponseSchemaBody +): UsageMetricsResponseSchemaBody { + return { + metrics: Object.fromEntries( + Object.entries(data.metrics).map(([metricType, series]) => [ + metricType, + series.map((metricSeries) => ({ + name: metricSeries.name, + data: (metricSeries.data as Array<[number, number]>).map(([timestamp, value]) => ({ + x: timestamp, + y: value, + })), + })), + ]) + ), + }; +} 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 7dac58ddecc9b..aef66d406bf74 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 @@ -12,10 +12,11 @@ import { DocumentEntryCreateFields, KnowledgeBaseEntryCreateProps, KnowledgeBaseEntryResponse, + KnowledgeBaseEntryUpdateProps, Metadata, } from '@kbn/elastic-assistant-common'; import { getKnowledgeBaseEntry } from './get_knowledge_base_entry'; -import { CreateKnowledgeBaseEntrySchema } from './types'; +import { CreateKnowledgeBaseEntrySchema, UpdateKnowledgeBaseEntrySchema } from './types'; export interface CreateKnowledgeBaseEntryParams { esClient: ElasticsearchClient; @@ -77,6 +78,111 @@ export const createKnowledgeBaseEntry = async ({ } }; +interface TransformToUpdateSchemaProps { + user: AuthenticatedUser; + updatedAt: string; + entry: KnowledgeBaseEntryUpdateProps; + global?: boolean; +} + +export const transformToUpdateSchema = ({ + user, + updatedAt, + entry, + global = false, +}: TransformToUpdateSchemaProps): UpdateKnowledgeBaseEntrySchema => { + const base = { + id: entry.id, + updated_at: updatedAt, + updated_by: user.profile_uid ?? 'unknown', + name: entry.name, + type: entry.type, + users: global + ? [] + : [ + { + id: user.profile_uid, + name: user.username, + }, + ], + }; + + if (entry.type === 'index') { + const { inputSchema, outputFields, queryDescription, ...restEntry } = entry; + return { + ...base, + ...restEntry, + query_description: queryDescription, + input_schema: + entry.inputSchema?.map((schema) => ({ + field_name: schema.fieldName, + field_type: schema.fieldType, + description: schema.description, + })) ?? undefined, + output_fields: outputFields ?? undefined, + }; + } + return { + ...base, + kb_resource: entry.kbResource, + required: entry.required ?? false, + source: entry.source, + text: entry.text, + vector: undefined, + }; +}; + +export const getUpdateScript = ({ + entry, + isPatch, +}: { + entry: UpdateKnowledgeBaseEntrySchema; + isPatch?: boolean; +}) => { + 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; + } + 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), + }, + }; +}; + interface TransformToCreateSchemaProps { createdAt: string; spaceId: string; 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 8ff8de6cfb408..de76a38135f0b 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 @@ -6,6 +6,7 @@ */ import { z } from '@kbn/zod'; +import { get } from 'lodash'; import { DynamicStructuredTool } from '@langchain/core/tools'; import { errors } from '@elastic/elasticsearch'; import { QueryDslQueryContainer, SearchRequest } from '@elastic/elasticsearch/lib/api/types'; @@ -189,7 +190,7 @@ export const getStructuredToolForIndexEntry = ({ standard: { query: { nested: { - path: 'semantic_text.inference.chunks', + path: `${indexEntry.field}.inference.chunks`, query: { sparse_vector: { inference_id: elserId, @@ -220,7 +221,7 @@ export const getStructuredToolForIndexEntry = ({ }, {}); } return { - text: (hit._source as { text: string }).text, + text: get(hit._source, `${indexEntry.field}.inference.chunks[0].text`), }; }); 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 a81e18630138e..1906f59ab4b32 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 @@ -15,6 +15,7 @@ import { Document } from 'langchain/document'; import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; import { DocumentEntryType, + DocumentEntry, IndexEntry, KnowledgeBaseEntryCreateProps, KnowledgeBaseEntryResponse, @@ -431,7 +432,9 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient { ); this.options.logger.debug( () => - `getKnowledgeBaseDocuments() - Similarity Search Results:\n ${JSON.stringify(results)}` + `getKnowledgeBaseDocuments() - Similarity Search returned [${JSON.stringify( + results.length + )}] results` ); return results; @@ -441,6 +444,47 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient { } }; + /** + * Returns all global and current user's private `required` document entries. + */ + public getRequiredKnowledgeBaseDocumentEntries = async (): Promise => { + const user = this.options.currentUser; + if (user == null) { + throw new Error( + 'Authenticated user not found! Ensure kbDataClient was initialized from a request.' + ); + } + + try { + 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 + // many historical KB entries. Ideally we'd query for all and token trim. + perPage: 100, + page: 1, + sortField: 'created_at', + sortOrder: 'asc', + filter: `${userFilter} AND type:document AND kb_resource:user AND required:true`, + }); + this.options.logger.debug( + `kbDataClient.getRequiredKnowledgeBaseDocumentEntries() - results:\n${JSON.stringify( + results + )}` + ); + + if (results) { + return transformESSearchToKnowledgeBaseEntry(results.data) as DocumentEntry[]; + } + } catch (e) { + this.options.logger.error( + `kbDataClient.getRequiredKnowledgeBaseDocumentEntries() - Failed to fetch DocumentEntries` + ); + return []; + } + + return []; + }; + /** * Creates a new Knowledge Base Entry. * @@ -479,7 +523,10 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient { }; /** - * Returns AssistantTools for any 'relevant' KB IndexEntries that exist in the knowledge base + * Returns AssistantTools for any 'relevant' KB IndexEntries that exist in the knowledge base. + * + * Note: Accepts esClient so retrieval can be scoped to the current user as esClient on kbDataClient + * is scoped to system user. */ public getAssistantTools = async ({ assistantToolParams, @@ -507,7 +554,7 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient { page: 1, sortField: 'created_at', sortOrder: 'asc', - filter: `${userFilter}${` AND type:index`}`, // TODO: Support global tools (no user filter), and filter by space as well + filter: `${userFilter} AND type:index`, }); this.options.logger.debug( `kbDataClient.getAssistantTools() - results:\n${JSON.stringify(results, null, 2)}` 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 ecf9260e999d2..3de1a15d79b2a 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 @@ -82,6 +82,39 @@ export interface LegacyEsKnowledgeBaseEntrySchema { model_id: string; }; } +export interface UpdateKnowledgeBaseEntrySchema { + id: string; + created_at?: string; + created_by?: string; + updated_at?: string; + updated_by?: string; + users?: Array<{ + id?: string; + name?: string; + }>; + name?: string; + type?: string; + // Document Entry Fields + kb_resource?: string; + required?: boolean; + source?: string; + text?: string; + vector?: { + tokens: Record; + model_id: string; + }; + // Index Entry Fields + index?: string; + field?: string; + description?: string; + query_description?: string; + input_schema?: Array<{ + field_name: string; + field_type: string; + description: string; + }>; + output_fields?: string[]; +} export interface CreateKnowledgeBaseEntrySchema { '@timestamp'?: string; 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 942f94c203873..08912f41a8bbc 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 @@ -84,6 +84,7 @@ export class AIAssistantService { private isKBSetupInProgress: boolean = false; // Temporary 'feature flag' to determine if we should initialize the new kb mappings, toggled when accessing kbDataClient private v2KnowledgeBaseEnabled: boolean = false; + private hasInitializedV2KnowledgeBase: boolean = false; constructor(private readonly options: AIAssistantServiceOpts) { this.initialized = false; @@ -363,8 +364,13 @@ export class AIAssistantService { // If either v2 KB or a modelIdOverride is provided, we need to reinitialize all persistence resources to make sure // they're using the correct model/mappings. Technically all existing KB data is stale since it was created // with a different model/mappings, but modelIdOverride is only intended for testing purposes at this time - if (opts.v2KnowledgeBaseEnabled || opts.modelIdOverride != null) { + // Added hasInitializedV2KnowledgeBase to prevent the console noise from re-init on each KB request + if ( + !this.hasInitializedV2KnowledgeBase && + (opts.v2KnowledgeBaseEnabled || opts.modelIdOverride != null) + ) { await this.initializeResources(); + this.hasInitializedV2KnowledgeBase = true; } const res = await this.checkResourcesInstallation(opts); diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/graph.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/graph.ts index dba756b9f3c9e..4688caa176b56 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/graph.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/graph.ts @@ -137,7 +137,7 @@ export const getDefaultAssistantGraph = ({ }) ) .addNode(NodeType.AGENT, (state: AgentState) => - runAgent({ ...nodeParams, state, agentRunnable }) + runAgent({ ...nodeParams, state, agentRunnable, kbDataClient: dataClients?.kbDataClient }) ) .addNode(NodeType.TOOLS, (state: AgentState) => executeTools({ ...nodeParams, state, tools })) .addNode(NodeType.RESPOND, (state: AgentState) => diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/index.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/index.ts index ada5b8a421441..4f043c681f8df 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/index.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/index.ts @@ -103,7 +103,6 @@ export const callAssistantGraph: AgentExecutor = async ({ isEnabledKnowledgeBase, kbDataClient: dataClients?.kbDataClient, logger, - modelExists: isEnabledKnowledgeBase, onNewReplacements, replacements, request, diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/nodes/run_agent.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/nodes/run_agent.ts index 2d076f6bd1472..053254a1d99b3 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/nodes/run_agent.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/nodes/run_agent.ts @@ -10,15 +10,20 @@ import { AgentRunnableSequence } from 'langchain/dist/agents/agent'; import { formatLatestUserMessage } from '../prompts'; import { AgentState, NodeParamsBase } from '../types'; import { NodeType } from '../constants'; +import { AIAssistantKnowledgeBaseDataClient } from '../../../../../ai_assistant_data_clients/knowledge_base'; export interface RunAgentParams extends NodeParamsBase { state: AgentState; config?: RunnableConfig; agentRunnable: AgentRunnableSequence; + kbDataClient?: AIAssistantKnowledgeBaseDataClient; } export const AGENT_NODE_TAG = 'agent_run'; +const KNOWLEDGE_HISTORY_PREFIX = 'Knowledge History:'; +const NO_KNOWLEDGE_HISTORY = '[No existing knowledge history]'; + /** * Node to run the agent * @@ -26,18 +31,27 @@ export const AGENT_NODE_TAG = 'agent_run'; * @param state - The current state of the graph * @param config - Any configuration that may've been supplied * @param agentRunnable - The agent to run + * @param kbDataClient - Data client for accessing the Knowledge Base on behalf of the current user */ export async function runAgent({ logger, state, agentRunnable, config, + kbDataClient, }: RunAgentParams): Promise> { logger.debug(() => `${NodeType.AGENT}: Node state:\n${JSON.stringify(state, null, 2)}`); + const knowledgeHistory = await kbDataClient?.getRequiredKnowledgeBaseDocumentEntries(); + const agentOutcome = await agentRunnable.withConfig({ tags: [AGENT_NODE_TAG] }).invoke( { ...state, + knowledge_history: `${KNOWLEDGE_HISTORY_PREFIX}\n${ + knowledgeHistory?.length + ? JSON.stringify(knowledgeHistory.map((e) => e.text)) + : NO_KNOWLEDGE_HISTORY + }`, // prepend any user prompt (gemini) input: formatLatestUserMessage(state.input, state.llmType), chat_history: state.messages, // TODO: Message de-dupe with ...state spread diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/nodes/translations.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/nodes/translations.ts index e55e1081e6474..e5a1c14846e23 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/nodes/translations.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/nodes/translations.ts @@ -8,8 +8,10 @@ const YOU_ARE_A_HELPFUL_EXPERT_ASSISTANT = 'You are a security analyst and expert in resolving security incidents. Your role is to assist by answering questions about Elastic Security.'; const IF_YOU_DONT_KNOW_THE_ANSWER = 'Do not answer questions unrelated to Elastic Security.'; +export const KNOWLEDGE_HISTORY = + 'If available, use the Knowledge History provided to try and answer the question. If not provided, you can try and query for additional knowledge via the KnowledgeBaseRetrievalTool.'; -export const DEFAULT_SYSTEM_PROMPT = `${YOU_ARE_A_HELPFUL_EXPERT_ASSISTANT} ${IF_YOU_DONT_KNOW_THE_ANSWER}`; +export const DEFAULT_SYSTEM_PROMPT = `${YOU_ARE_A_HELPFUL_EXPERT_ASSISTANT} ${IF_YOU_DONT_KNOW_THE_ANSWER} ${KNOWLEDGE_HISTORY}`; // system prompt from @afirstenberg const BASE_GEMINI_PROMPT = 'You are an assistant that is an expert at using tools and Elastic Security, doing your best to use these tools to answer questions or follow instructions. It is very important to use tools to answer the question or follow the instructions rather than coming up with your own answer. Tool calls are good. Sometimes you may need to make several tool calls to accomplish the task or get an answer to the question that was asked. Use as many tool calls as necessary.'; @@ -19,7 +21,7 @@ export const GEMINI_SYSTEM_PROMPT = `${BASE_GEMINI_PROMPT} ${KB_CATCH}`; export const BEDROCK_SYSTEM_PROMPT = `Use tools as often as possible, as they have access to the latest data and syntax. Always return value from ESQLKnowledgeBaseTool as is. Never return tags in the response, but make sure to include tags content in the response. Do not reflect on the quality of the returned search results in your response.`; export const GEMINI_USER_PROMPT = `Now, always using the tools at your disposal, step by step, come up with a response to this request:\n\n`; -export const STRUCTURED_SYSTEM_PROMPT = `Respond to the human as helpfully and accurately as possible. You have access to the following tools: +export const STRUCTURED_SYSTEM_PROMPT = `Respond to the human as helpfully and accurately as possible. ${KNOWLEDGE_HISTORY} You have access to the following tools: {tools} diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/prompts.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/prompts.ts index 883047ed7b9df..05cc8b50852f5 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/prompts.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/prompts.ts @@ -17,6 +17,7 @@ import { export const formatPrompt = (prompt: string, additionalPrompt?: string) => ChatPromptTemplate.fromMessages([ ['system', additionalPrompt ? `${prompt}\n\n${additionalPrompt}` : prompt], + ['placeholder', '{knowledge_history}'], ['placeholder', '{chat_history}'], ['human', '{input}'], ['placeholder', '{agent_scratchpad}'], @@ -39,6 +40,7 @@ export const geminiToolCallingAgentPrompt = formatPrompt(systemPrompts.gemini); export const formatPromptStructured = (prompt: string, additionalPrompt?: string) => ChatPromptTemplate.fromMessages([ ['system', additionalPrompt ? `${prompt}\n\n${additionalPrompt}` : prompt], + ['placeholder', '{knowledge_history}'], ['placeholder', '{chat_history}'], [ 'human', diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.test.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.test.ts index 15877e6727715..d5eaf7d159618 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.test.ts @@ -196,7 +196,6 @@ describe('helpers', () => { langChainTimeout, llm, logger: mockLogger, - modelExists: false, onNewReplacements, replacements: latestReplacements, request: mockRequest, @@ -231,7 +230,6 @@ describe('helpers', () => { langChainTimeout, llm, logger: mockLogger, - modelExists: false, onNewReplacements, replacements: latestReplacements, request: mockRequest, diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.ts index 2a1450a9f7b9b..f016d6ac29118 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.ts @@ -157,7 +157,6 @@ const formatAssistantToolParams = ({ langChainTimeout, llm, logger, - modelExists: false, // not required for attack discovery onNewReplacements, replacements: latestReplacements, request, diff --git a/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts b/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts index de154a1ddd96d..29a7527964677 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts @@ -236,7 +236,6 @@ export const postEvaluateRoute = ( llm, isOssModel, logger, - modelExists: isEnabledKnowledgeBase, request: skeletonRequest, alertsIndexPattern, // onNewReplacements, 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 96045b17e6171..ce3f0c8c92693 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 @@ -22,11 +22,18 @@ import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/ import { performChecks } from '../../helpers'; import { KNOWLEDGE_BASE_ENTRIES_TABLE_MAX_PAGE_SIZE } from '../../../../common/constants'; -import { EsKnowledgeBaseEntrySchema } from '../../../ai_assistant_data_clients/knowledge_base/types'; +import { + EsKnowledgeBaseEntrySchema, + UpdateKnowledgeBaseEntrySchema, +} from '../../../ai_assistant_data_clients/knowledge_base/types'; import { ElasticAssistantPluginRouter } from '../../../types'; import { buildResponse } from '../../utils'; import { transformESSearchToKnowledgeBaseEntry } from '../../../ai_assistant_data_clients/knowledge_base/transforms'; -import { transformToCreateSchema } from '../../../ai_assistant_data_clients/knowledge_base/create_knowledge_base_entry'; +import { + getUpdateScript, + transformToCreateSchema, + transformToUpdateSchema, +} from '../../../ai_assistant_data_clients/knowledge_base/create_knowledge_base_entry'; export interface BulkOperationError { message: string; @@ -210,7 +217,17 @@ export const bulkActionKnowledgeBaseEntriesRoute = (router: ElasticAssistantPlug }) ), documentsToDelete: body.delete?.ids, - documentsToUpdate: [], // TODO: Support bulk update + documentsToUpdate: body.update?.map((entry) => + // TODO: KB-RBAC check, required when users != null as entry will either be created globally if empty + transformToUpdateSchema({ + user: authenticatedUser, + updatedAt: changedAt, + entry, + global: entry.users != null && entry.users.length === 0, + }) + ), + getUpdateScript: (entry: UpdateKnowledgeBaseEntrySchema) => + getUpdateScript({ entry, isPatch: true }), authenticatedUser, }); const created = 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 3dbb5a9cf930e..51e3d48505ec2 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 @@ -66,7 +66,7 @@ export const createKnowledgeBaseEntryRoute = (router: ElasticAssistantPluginRout logger.debug(() => `Creating KB Entry:\n${JSON.stringify(request.body)}`); const createResponse = await kbDataClient?.createKnowledgeBaseEntry({ knowledgeBaseEntry: request.body, - // TODO: KB-RBAC check, required when users != null as entry will either be created globally if empty, or for specific users (only admin API feature) + // TODO: KB-RBAC check, required when users != null as entry will either be created globally if empty global: request.body.users != null && request.body.users.length === 0, }); 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 f10876c4be3ee..356d5d9150a67 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 @@ -74,7 +74,7 @@ export const findKnowledgeBaseEntriesRoute = (router: ElasticAssistantPluginRout }); const currentUser = ctx.elasticAssistant.getCurrentUser(); const userFilter = getKBUserFilter(currentUser); - const systemFilter = ` AND kb_resource:"user"`; + const systemFilter = ` AND (kb_resource:"user" OR type:"index")`; const additionalFilter = query.filter ? ` AND ${query.filter}` : ''; const result = await kbDataClient?.findDocuments({ @@ -160,7 +160,7 @@ export const findKnowledgeBaseEntriesRoute = (router: ElasticAssistantPluginRout body: { perPage: result.perPage, page: result.page, - total: result.total, + total: result.total + systemEntries.length, data: [...transformESSearchToKnowledgeBaseEntry(result.data), ...systemEntries], }, }); diff --git a/x-pack/plugins/elastic_assistant/server/routes/utils.test.ts b/x-pack/plugins/elastic_assistant/server/routes/utils.test.ts index 3ca1b8edb5036..9a77e645686dd 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/utils.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/utils.test.ts @@ -65,5 +65,17 @@ describe('Utils', () => { const isOpenModel = isOpenSourceModel(connector); expect(isOpenModel).toEqual(true); }); + + it('should return `true` when apiProvider of OpenAiProviderType.Other is specified', async () => { + const connector = { + actionTypeId: '.gen-ai', + config: { + apiUrl: OPENAI_CHAT_URL, + apiProvider: OpenAiProviderType.Other, + }, + } as unknown as Connector; + const isOpenModel = isOpenSourceModel(connector); + expect(isOpenModel).toEqual(true); + }); }); }); diff --git a/x-pack/plugins/elastic_assistant/server/routes/utils.ts b/x-pack/plugins/elastic_assistant/server/routes/utils.ts index ea05fc814ec69..0fb51c7364809 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/utils.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/utils.ts @@ -203,19 +203,25 @@ export const isOpenSourceModel = (connector?: Connector): boolean => { } const llmType = getLlmType(connector.actionTypeId); - const connectorApiUrl = connector.config?.apiUrl - ? (connector.config.apiUrl as string) - : undefined; + const isOpenAiType = llmType === 'openai'; + + if (!isOpenAiType) { + return false; + } const connectorApiProvider = connector.config?.apiProvider ? (connector.config?.apiProvider as OpenAiProviderType) : undefined; + if (connectorApiProvider === OpenAiProviderType.Other) { + return true; + } - const isOpenAiType = llmType === 'openai'; - const isOpenAI = - isOpenAiType && - (!connectorApiUrl || - connectorApiUrl === OPENAI_CHAT_URL || - connectorApiProvider === OpenAiProviderType.AzureAi); + const connectorApiUrl = connector.config?.apiUrl + ? (connector.config.apiUrl as string) + : undefined; - return isOpenAiType && !isOpenAI; + return ( + !!connectorApiUrl && + connectorApiUrl !== OPENAI_CHAT_URL && + connectorApiProvider !== OpenAiProviderType.AzureAi + ); }; diff --git a/x-pack/plugins/elastic_assistant/server/types.ts b/x-pack/plugins/elastic_assistant/server/types.ts index 3117295810877..45bd5a4149b58 100755 --- a/x-pack/plugins/elastic_assistant/server/types.ts +++ b/x-pack/plugins/elastic_assistant/server/types.ts @@ -244,7 +244,6 @@ export interface AssistantToolParams { llm?: ActionsClientLlm | AssistantToolLlm; isOssModel?: boolean; logger: Logger; - modelExists: boolean; onNewReplacements?: (newReplacements: Replacements) => void; replacements?: Replacements; request: KibanaRequest< diff --git a/x-pack/plugins/entity_manager/common/constants_entities.ts b/x-pack/plugins/entity_manager/common/constants_entities.ts index c53847afbb548..c17e6f33918c6 100644 --- a/x-pack/plugins/entity_manager/common/constants_entities.ts +++ b/x-pack/plugins/entity_manager/common/constants_entities.ts @@ -33,8 +33,6 @@ export const ENTITY_LATEST_PREFIX_V1 = `${ENTITY_BASE_PREFIX}-${ENTITY_SCHEMA_VERSION_V1}-${ENTITY_LATEST}` as const; // Transform constants -export const ENTITY_DEFAULT_HISTORY_FREQUENCY = '1m'; -export const ENTITY_DEFAULT_HISTORY_SYNC_DELAY = '60s'; -export const ENTITY_DEFAULT_LATEST_FREQUENCY = '30s'; -export const ENTITY_DEFAULT_LATEST_SYNC_DELAY = '1s'; -export const ENTITY_DEFAULT_METADATA_LIMIT = 1000; +export const ENTITY_DEFAULT_LATEST_FREQUENCY = '1m'; +export const ENTITY_DEFAULT_LATEST_SYNC_DELAY = '60s'; +export const ENTITY_DEFAULT_METADATA_LIMIT = 10; diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/containers_from_ecs_data.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/containers_from_ecs_data.ts index 6ce76e127c8e8..e3356c4826ae8 100644 --- a/x-pack/plugins/entity_manager/server/lib/entities/built_in/containers_from_ecs_data.ts +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/containers_from_ecs_data.ts @@ -20,9 +20,9 @@ export const builtInContainersFromEcsEntityDefinition: EntityDefinition = indexPatterns: ['filebeat-*', 'logs-*', 'metrics-*', 'metricbeat-*'], identityFields: ['container.id'], displayNameTemplate: '{{container.id}}', - history: { + latest: { timestampField: '@timestamp', - interval: '5m', + lookbackPeriod: '10m', settings: { frequency: '5m', }, diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/hosts_from_ecs_data.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/hosts_from_ecs_data.ts index 56f83d5fbaed6..5d7a30093419e 100644 --- a/x-pack/plugins/entity_manager/server/lib/entities/built_in/hosts_from_ecs_data.ts +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/hosts_from_ecs_data.ts @@ -19,9 +19,9 @@ export const builtInHostsFromEcsEntityDefinition: EntityDefinition = entityDefin indexPatterns: ['filebeat-*', 'logs-*', 'metrics-*', 'metricbeat-*'], identityFields: ['host.name'], displayNameTemplate: '{{host.name}}', - history: { + latest: { timestampField: '@timestamp', - interval: '5m', + lookbackPeriod: '10m', settings: { frequency: '5m', }, diff --git a/x-pack/plugins/entity_manager/server/lib/entities/built_in/services_from_ecs_data.ts b/x-pack/plugins/entity_manager/server/lib/entities/built_in/services_from_ecs_data.ts index 6caa209da02ca..d6aa4d08ad221 100644 --- a/x-pack/plugins/entity_manager/server/lib/entities/built_in/services_from_ecs_data.ts +++ b/x-pack/plugins/entity_manager/server/lib/entities/built_in/services_from_ecs_data.ts @@ -18,11 +18,10 @@ export const builtInServicesFromEcsEntityDefinition: EntityDefinition = type: 'service', managed: true, indexPatterns: ['logs-*', 'filebeat*', 'traces-apm*'], - history: { + latest: { timestampField: '@timestamp', - interval: '1m', + lookbackPeriod: '10m', settings: { - lookbackPeriod: '10m', frequency: '2m', syncDelay: '2m', }, diff --git a/x-pack/plugins/entity_manager/server/lib/entities/create_and_install_ingest_pipeline.ts b/x-pack/plugins/entity_manager/server/lib/entities/create_and_install_ingest_pipeline.ts index 360f416cd5a00..0b3900363c0c8 100644 --- a/x-pack/plugins/entity_manager/server/lib/entities/create_and_install_ingest_pipeline.ts +++ b/x-pack/plugins/entity_manager/server/lib/entities/create_and_install_ingest_pipeline.ts @@ -7,46 +7,15 @@ import { ElasticsearchClient, Logger } from '@kbn/core/server'; import { EntityDefinition } from '@kbn/entities-schema'; -import { - generateHistoryIngestPipelineId, - generateLatestIngestPipelineId, -} from './helpers/generate_component_id'; +import { generateLatestIngestPipelineId } from './helpers/generate_component_id'; import { retryTransientEsErrors } from './helpers/retry'; -import { generateHistoryProcessors } from './ingest_pipeline/generate_history_processors'; import { generateLatestProcessors } from './ingest_pipeline/generate_latest_processors'; -export async function createAndInstallHistoryIngestPipeline( +export async function createAndInstallIngestPipelines( esClient: ElasticsearchClient, definition: EntityDefinition, logger: Logger -) { - try { - const historyProcessors = generateHistoryProcessors(definition); - const historyId = generateHistoryIngestPipelineId(definition); - await retryTransientEsErrors( - () => - esClient.ingest.putPipeline({ - id: historyId, - processors: historyProcessors, - _meta: { - definitionVersion: definition.version, - managed: definition.managed, - }, - }), - { logger } - ); - } catch (e) { - logger.error( - `Cannot create entity history ingest pipelines for [${definition.id}] entity defintion` - ); - throw e; - } -} -export async function createAndInstallLatestIngestPipeline( - esClient: ElasticsearchClient, - definition: EntityDefinition, - logger: Logger -) { +): Promise> { try { const latestProcessors = generateLatestProcessors(definition); const latestId = generateLatestIngestPipelineId(definition); @@ -62,9 +31,10 @@ export async function createAndInstallLatestIngestPipeline( }), { logger } ); + return [{ type: 'ingest_pipeline', id: latestId }]; } catch (e) { logger.error( - `Cannot create entity latest ingest pipelines for [${definition.id}] entity defintion` + `Cannot create entity latest ingest pipelines for [${definition.id}] entity definition` ); throw e; } diff --git a/x-pack/plugins/entity_manager/server/lib/entities/create_and_install_transform.ts b/x-pack/plugins/entity_manager/server/lib/entities/create_and_install_transform.ts index d6379773479fc..779e0994a33b8 100644 --- a/x-pack/plugins/entity_manager/server/lib/entities/create_and_install_transform.ts +++ b/x-pack/plugins/entity_manager/server/lib/entities/create_and_install_transform.ts @@ -9,57 +9,20 @@ import { ElasticsearchClient, Logger } from '@kbn/core/server'; import { EntityDefinition } from '@kbn/entities-schema'; import { retryTransientEsErrors } from './helpers/retry'; import { generateLatestTransform } from './transform/generate_latest_transform'; -import { - generateBackfillHistoryTransform, - generateHistoryTransform, -} from './transform/generate_history_transform'; -export async function createAndInstallHistoryTransform( +export async function createAndInstallTransforms( esClient: ElasticsearchClient, definition: EntityDefinition, logger: Logger -) { - try { - const historyTransform = generateHistoryTransform(definition); - await retryTransientEsErrors(() => esClient.transform.putTransform(historyTransform), { - logger, - }); - } catch (e) { - logger.error(`Cannot create entity history transform for [${definition.id}] entity definition`); - throw e; - } -} - -export async function createAndInstallHistoryBackfillTransform( - esClient: ElasticsearchClient, - definition: EntityDefinition, - logger: Logger -) { - try { - const historyTransform = generateBackfillHistoryTransform(definition); - await retryTransientEsErrors(() => esClient.transform.putTransform(historyTransform), { - logger, - }); - } catch (e) { - logger.error( - `Cannot create entity history backfill transform for [${definition.id}] entity definition` - ); - throw e; - } -} - -export async function createAndInstallLatestTransform( - esClient: ElasticsearchClient, - definition: EntityDefinition, - logger: Logger -) { +): Promise> { try { const latestTransform = generateLatestTransform(definition); await retryTransientEsErrors(() => esClient.transform.putTransform(latestTransform), { logger, }); + return [{ type: 'transform', id: latestTransform.transform_id }]; } catch (e) { - logger.error(`Cannot create entity latest transform for [${definition.id}] entity definition`); + logger.error(`Cannot create entity history transform for [${definition.id}] entity definition`); throw e; } } diff --git a/x-pack/plugins/entity_manager/server/lib/entities/delete_ingest_pipeline.ts b/x-pack/plugins/entity_manager/server/lib/entities/delete_ingest_pipeline.ts index f4c46d8447d8f..a3b910dd4cb5e 100644 --- a/x-pack/plugins/entity_manager/server/lib/entities/delete_ingest_pipeline.ts +++ b/x-pack/plugins/entity_manager/server/lib/entities/delete_ingest_pipeline.ts @@ -7,24 +7,24 @@ import { ElasticsearchClient, Logger } from '@kbn/core/server'; import { EntityDefinition } from '@kbn/entities-schema'; -import { - generateHistoryIngestPipelineId, - generateLatestIngestPipelineId, -} from './helpers/generate_component_id'; import { retryTransientEsErrors } from './helpers/retry'; +import { generateLatestIngestPipelineId } from './helpers/generate_component_id'; -export async function deleteHistoryIngestPipeline( +export async function deleteIngestPipelines( esClient: ElasticsearchClient, definition: EntityDefinition, logger: Logger ) { try { - const historyPipelineId = generateHistoryIngestPipelineId(definition); - await retryTransientEsErrors(() => - esClient.ingest.deletePipeline({ id: historyPipelineId }, { ignore: [404] }) + await Promise.all( + (definition.installedComponents ?? []) + .filter(({ type }) => type === 'ingest_pipeline') + .map(({ id }) => + retryTransientEsErrors(() => esClient.ingest.deletePipeline({ id }, { ignore: [404] })) + ) ); } catch (e) { - logger.error(`Unable to delete history ingest pipeline [${definition.id}]: ${e}`); + logger.error(`Unable to delete ingest pipelines for definition [${definition.id}]: ${e}`); throw e; } } @@ -35,9 +35,11 @@ export async function deleteLatestIngestPipeline( logger: Logger ) { try { - const latestPipelineId = generateLatestIngestPipelineId(definition); await retryTransientEsErrors(() => - esClient.ingest.deletePipeline({ id: latestPipelineId }, { ignore: [404] }) + esClient.ingest.deletePipeline( + { id: generateLatestIngestPipelineId(definition) }, + { ignore: [404] } + ) ); } catch (e) { logger.error(`Unable to delete latest ingest pipeline [${definition.id}]: ${e}`); diff --git a/x-pack/plugins/entity_manager/server/lib/entities/delete_transforms.ts b/x-pack/plugins/entity_manager/server/lib/entities/delete_transforms.ts index a66c0998c014d..79b83998d38db 100644 --- a/x-pack/plugins/entity_manager/server/lib/entities/delete_transforms.ts +++ b/x-pack/plugins/entity_manager/server/lib/entities/delete_transforms.ts @@ -7,14 +7,8 @@ import { ElasticsearchClient, Logger } from '@kbn/core/server'; import { EntityDefinition } from '@kbn/entities-schema'; - -import { - generateHistoryTransformId, - generateHistoryBackfillTransformId, - generateLatestTransformId, -} from './helpers/generate_component_id'; import { retryTransientEsErrors } from './helpers/retry'; -import { isBackfillEnabled } from './helpers/is_backfill_enabled'; +import { generateLatestTransformId } from './helpers/generate_component_id'; export async function deleteTransforms( esClient: ElasticsearchClient, @@ -22,37 +16,42 @@ export async function deleteTransforms( logger: Logger ) { try { - const historyTransformId = generateHistoryTransformId(definition); - const latestTransformId = generateLatestTransformId(definition); - await retryTransientEsErrors( - () => - esClient.transform.deleteTransform( - { transform_id: historyTransformId, force: true }, - { ignore: [404] } - ), - { logger } + await Promise.all( + (definition.installedComponents ?? []) + .filter(({ type }) => type === 'transform') + .map(({ id }) => + retryTransientEsErrors( + () => + esClient.transform.deleteTransform( + { transform_id: id, force: true }, + { ignore: [404] } + ), + { logger } + ) + ) ); - if (isBackfillEnabled(definition)) { - const historyBackfillTransformId = generateHistoryBackfillTransformId(definition); - await retryTransientEsErrors( - () => - esClient.transform.deleteTransform( - { transform_id: historyBackfillTransformId, force: true }, - { ignore: [404] } - ), - { logger } - ); - } + } catch (e) { + logger.error(`Cannot delete transforms for definition [${definition.id}]: ${e}`); + throw e; + } +} + +export async function deleteLatestTransform( + esClient: ElasticsearchClient, + definition: EntityDefinition, + logger: Logger +) { + try { await retryTransientEsErrors( () => esClient.transform.deleteTransform( - { transform_id: latestTransformId, force: true }, + { transform_id: generateLatestTransformId(definition), force: true }, { ignore: [404] } ), { logger } ); } catch (e) { - logger.error(`Cannot delete history transform [${definition.id}]: ${e}`); + logger.error(`Cannot delete latest transform for definition [${definition.id}]: ${e}`); throw e; } } diff --git a/x-pack/plugins/entity_manager/server/lib/entities/find_entity_definition.ts b/x-pack/plugins/entity_manager/server/lib/entities/find_entity_definition.ts index d1d84f27414af..cfbb5a5ef5556 100644 --- a/x-pack/plugins/entity_manager/server/lib/entities/find_entity_definition.ts +++ b/x-pack/plugins/entity_manager/server/lib/entities/find_entity_definition.ts @@ -10,18 +10,8 @@ import { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/serve import { EntityDefinition } from '@kbn/entities-schema'; import { NodesIngestTotal } from '@elastic/elasticsearch/lib/api/types'; import { SO_ENTITY_DEFINITION_TYPE } from '../../saved_objects'; -import { - generateHistoryTransformId, - generateHistoryBackfillTransformId, - generateHistoryIngestPipelineId, - generateHistoryIndexTemplateId, - generateLatestTransformId, - generateLatestIngestPipelineId, - generateLatestIndexTemplateId, -} from './helpers/generate_component_id'; import { BUILT_IN_ID_PREFIX } from './built_in'; import { EntityDefinitionState, EntityDefinitionWithState } from './types'; -import { isBackfillEnabled } from './helpers/is_backfill_enabled'; export async function findEntityDefinitions({ soClient, @@ -120,11 +110,9 @@ async function getTransformState({ definition: EntityDefinition; esClient: ElasticsearchClient; }) { - const transformIds = [ - generateHistoryTransformId(definition), - generateLatestTransformId(definition), - ...(isBackfillEnabled(definition) ? [generateHistoryBackfillTransformId(definition)] : []), - ]; + const transformIds = (definition.installedComponents ?? []) + .filter(({ type }) => type === 'transform') + .map(({ id }) => id); const transformStats = await Promise.all( transformIds.map((id) => esClient.transform.getTransformStats({ transform_id: id })) @@ -152,10 +140,10 @@ async function getIngestPipelineState({ definition: EntityDefinition; esClient: ElasticsearchClient; }) { - const ingestPipelineIds = [ - generateHistoryIngestPipelineId(definition), - generateLatestIngestPipelineId(definition), - ]; + const ingestPipelineIds = (definition.installedComponents ?? []) + .filter(({ type }) => type === 'ingest_pipeline') + .map(({ id }) => id); + const [ingestPipelines, ingestPipelinesStats] = await Promise.all([ esClient.ingest.getPipeline({ id: ingestPipelineIds.join(',') }, { ignore: [404] }), esClient.nodes.stats({ @@ -193,10 +181,9 @@ async function getIndexTemplatesState({ definition: EntityDefinition; esClient: ElasticsearchClient; }) { - const indexTemplatesIds = [ - generateLatestIndexTemplateId(definition), - generateHistoryIndexTemplateId(definition), - ]; + const indexTemplatesIds = (definition.installedComponents ?? []) + .filter(({ type }) => type === 'template') + .map(({ id }) => id); const templates = await Promise.all( indexTemplatesIds.map((id) => esClient.indices diff --git a/x-pack/plugins/entity_manager/server/lib/entities/helpers/calculate_offset.ts b/x-pack/plugins/entity_manager/server/lib/entities/helpers/calculate_offset.ts deleted file mode 100644 index 3eba710561abf..0000000000000 --- a/x-pack/plugins/entity_manager/server/lib/entities/helpers/calculate_offset.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EntityDefinition } from '@kbn/entities-schema'; -import moment from 'moment'; -import { - ENTITY_DEFAULT_HISTORY_FREQUENCY, - ENTITY_DEFAULT_HISTORY_SYNC_DELAY, -} from '../../../../common/constants_entities'; - -const durationToSeconds = (dateMath: string) => { - const parts = dateMath.match(/(\d+)([m|s|h|d])/); - if (!parts) { - throw new Error(`Invalid date math supplied: ${dateMath}`); - } - const value = parseInt(parts[1], 10); - const unit = parts[2] as 'm' | 's' | 'h' | 'd'; - return moment.duration(value, unit).asSeconds(); -}; - -export function calculateOffset(definition: EntityDefinition) { - const syncDelay = durationToSeconds( - definition.history.settings.syncDelay || ENTITY_DEFAULT_HISTORY_SYNC_DELAY - ); - const frequency = - durationToSeconds(definition.history.settings.frequency || ENTITY_DEFAULT_HISTORY_FREQUENCY) * - 2; - - return syncDelay + frequency; -} diff --git a/x-pack/plugins/entity_manager/server/lib/entities/helpers/fixtures/builtin_entity_definition.ts b/x-pack/plugins/entity_manager/server/lib/entities/helpers/fixtures/builtin_entity_definition.ts index 5092e2caa5d78..b1e506150fb60 100644 --- a/x-pack/plugins/entity_manager/server/lib/entities/helpers/fixtures/builtin_entity_definition.ts +++ b/x-pack/plugins/entity_manager/server/lib/entities/helpers/fixtures/builtin_entity_definition.ts @@ -13,9 +13,8 @@ export const builtInEntityDefinition = entityDefinitionSchema.parse({ type: 'service', indexPatterns: ['kbn-data-forge-fake_stack.*'], managed: true, - history: { + latest: { timestampField: '@timestamp', - interval: '1m', }, identityFields: ['log.logger', { field: 'event.category', optional: true }], displayNameTemplate: '{{log.logger}}{{#event.category}}:{{.}}{{/event.category}}', diff --git a/x-pack/plugins/entity_manager/server/lib/entities/helpers/fixtures/entity_definition.ts b/x-pack/plugins/entity_manager/server/lib/entities/helpers/fixtures/entity_definition.ts index 940e209260c54..00ab9ac7759af 100644 --- a/x-pack/plugins/entity_manager/server/lib/entities/helpers/fixtures/entity_definition.ts +++ b/x-pack/plugins/entity_manager/server/lib/entities/helpers/fixtures/entity_definition.ts @@ -12,13 +12,12 @@ export const rawEntityDefinition = { name: 'Services for Admin Console', type: 'service', indexPatterns: ['kbn-data-forge-fake_stack.*'], - history: { + latest: { timestampField: '@timestamp', - interval: '1m', + lookbackPeriod: '10m', settings: { - lookbackPeriod: '10m', - frequency: '2m', - syncDelay: '2m', + frequency: '30s', + syncDelay: '10s', }, }, identityFields: ['log.logger', { field: 'event.category', optional: true }], diff --git a/x-pack/plugins/entity_manager/server/lib/entities/helpers/fixtures/entity_definition_with_backfill.ts b/x-pack/plugins/entity_manager/server/lib/entities/helpers/fixtures/entity_definition_with_backfill.ts deleted file mode 100644 index 66a79825fbfb0..0000000000000 --- a/x-pack/plugins/entity_manager/server/lib/entities/helpers/fixtures/entity_definition_with_backfill.ts +++ /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 { entityDefinitionSchema } from '@kbn/entities-schema'; -export const entityDefinitionWithBackfill = entityDefinitionSchema.parse({ - id: 'admin-console-services-backfill', - version: '999.999.999', - name: 'Services for Admin Console', - type: 'service', - indexPatterns: ['kbn-data-forge-fake_stack.*'], - history: { - timestampField: '@timestamp', - interval: '1m', - settings: { - backfillSyncDelay: '15m', - backfillLookbackPeriod: '72h', - backfillFrequency: '5m', - }, - }, - identityFields: ['log.logger', { field: 'event.category', optional: true }], - displayNameTemplate: '{{log.logger}}{{#event.category}}:{{.}}{{/event.category}}', - metadata: ['tags', 'host.name', 'host.os.name', { source: '_index', destination: 'sourceIndex' }], - metrics: [ - { - name: 'logRate', - equation: 'A', - metrics: [ - { - name: 'A', - aggregation: 'doc_count', - filter: 'log.level: *', - }, - ], - }, - { - name: 'errorRate', - equation: 'A', - metrics: [ - { - name: 'A', - aggregation: 'doc_count', - filter: 'log.level: "ERROR"', - }, - ], - }, - ], -}); diff --git a/x-pack/plugins/entity_manager/server/lib/entities/helpers/fixtures/index.ts b/x-pack/plugins/entity_manager/server/lib/entities/helpers/fixtures/index.ts index c24dcee1f8cf7..e841b1c8e23dd 100644 --- a/x-pack/plugins/entity_manager/server/lib/entities/helpers/fixtures/index.ts +++ b/x-pack/plugins/entity_manager/server/lib/entities/helpers/fixtures/index.ts @@ -6,5 +6,4 @@ */ export { entityDefinition } from './entity_definition'; -export { entityDefinitionWithBackfill } from './entity_definition_with_backfill'; export { builtInEntityDefinition } from './builtin_entity_definition'; diff --git a/x-pack/plugins/entity_manager/server/lib/entities/ingest_pipeline/__snapshots__/generate_history_processors.test.ts.snap b/x-pack/plugins/entity_manager/server/lib/entities/ingest_pipeline/__snapshots__/generate_history_processors.test.ts.snap deleted file mode 100644 index c2e4605e5f909..0000000000000 --- a/x-pack/plugins/entity_manager/server/lib/entities/ingest_pipeline/__snapshots__/generate_history_processors.test.ts.snap +++ /dev/null @@ -1,327 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`generateHistoryProcessors(definition) should generate a valid pipeline for builtin definition 1`] = ` -Array [ - Object { - "set": Object { - "field": "event.ingested", - "value": "{{{_ingest.timestamp}}}", - }, - }, - Object { - "set": Object { - "field": "entity.type", - "value": "service", - }, - }, - Object { - "set": Object { - "field": "entity.definitionId", - "value": "builtin_mock_entity_definition", - }, - }, - Object { - "set": Object { - "field": "entity.definitionVersion", - "value": "1.0.0", - }, - }, - Object { - "set": Object { - "field": "entity.schemaVersion", - "value": "v1", - }, - }, - Object { - "set": Object { - "field": "entity.identityFields", - "value": Array [ - "log.logger", - "event.category", - ], - }, - }, - Object { - "script": Object { - "description": "Generated the entity.id field", - "source": "// This function will recursively collect all the values of a HashMap of HashMaps -Collection collectValues(HashMap subject) { - Collection values = new ArrayList(); - // Iterate through the values - for(Object value: subject.values()) { - // If the value is a HashMap, recurse - if (value instanceof HashMap) { - values.addAll(collectValues((HashMap) value)); - } else { - values.add(String.valueOf(value)); - } - } - return values; -} -// Create the string builder -StringBuilder entityId = new StringBuilder(); -if (ctx[\\"entity\\"][\\"identity\\"] != null) { - // Get the values as a collection - Collection values = collectValues(ctx[\\"entity\\"][\\"identity\\"]); - // Convert to a list and sort - List sortedValues = new ArrayList(values); - Collections.sort(sortedValues); - // Create comma delimited string - for(String instanceValue: sortedValues) { - entityId.append(instanceValue); - entityId.append(\\":\\"); - } - // Assign the entity.id - ctx[\\"entity\\"][\\"id\\"] = entityId.length() > 0 ? entityId.substring(0, entityId.length() - 1) : \\"unknown\\"; -}", - }, - }, - Object { - "fingerprint": Object { - "fields": Array [ - "entity.id", - ], - "method": "MurmurHash3", - "target_field": "entity.id", - }, - }, - Object { - "script": Object { - "source": "if (ctx.entity?.metadata?.tags != null) { - ctx.tags = ctx.entity.metadata.tags.keySet(); -} -if (ctx.entity?.metadata?.host?.name != null) { - if (ctx.host == null) { - ctx.host = new HashMap(); - } - ctx.host.name = ctx.entity.metadata.host.name.keySet(); -} -if (ctx.entity?.metadata?.host?.os?.name != null) { - if (ctx.host == null) { - ctx.host = new HashMap(); - } - if (ctx.host.os == null) { - ctx.host.os = new HashMap(); - } - ctx.host.os.name = ctx.entity.metadata.host.os.name.keySet(); -} -if (ctx.entity?.metadata?.sourceIndex != null) { - ctx.sourceIndex = ctx.entity.metadata.sourceIndex.keySet(); -}", - }, - }, - Object { - "remove": Object { - "field": "entity.metadata", - "ignore_missing": true, - }, - }, - Object { - "set": Object { - "field": "log.logger", - "if": "ctx.entity?.identity?.log?.logger != null", - "value": "{{entity.identity.log.logger}}", - }, - }, - Object { - "set": Object { - "field": "event.category", - "if": "ctx.entity?.identity?.event?.category != null", - "value": "{{entity.identity.event.category}}", - }, - }, - Object { - "remove": Object { - "field": "entity.identity", - "ignore_missing": true, - }, - }, - Object { - "date_index_name": Object { - "date_formats": Array [ - "UNIX_MS", - "ISO8601", - "yyyy-MM-dd'T'HH:mm:ss.SSSXX", - ], - "date_rounding": "M", - "field": "@timestamp", - "index_name_prefix": ".entities.v1.history.builtin_mock_entity_definition.", - }, - }, -] -`; - -exports[`generateHistoryProcessors(definition) should generate a valid pipeline for custom definition 1`] = ` -Array [ - Object { - "set": Object { - "field": "event.ingested", - "value": "{{{_ingest.timestamp}}}", - }, - }, - Object { - "set": Object { - "field": "entity.type", - "value": "service", - }, - }, - Object { - "set": Object { - "field": "entity.definitionId", - "value": "admin-console-services", - }, - }, - Object { - "set": Object { - "field": "entity.definitionVersion", - "value": "1.0.0", - }, - }, - Object { - "set": Object { - "field": "entity.schemaVersion", - "value": "v1", - }, - }, - Object { - "set": Object { - "field": "entity.identityFields", - "value": Array [ - "log.logger", - "event.category", - ], - }, - }, - Object { - "script": Object { - "description": "Generated the entity.id field", - "source": "// This function will recursively collect all the values of a HashMap of HashMaps -Collection collectValues(HashMap subject) { - Collection values = new ArrayList(); - // Iterate through the values - for(Object value: subject.values()) { - // If the value is a HashMap, recurse - if (value instanceof HashMap) { - values.addAll(collectValues((HashMap) value)); - } else { - values.add(String.valueOf(value)); - } - } - return values; -} -// Create the string builder -StringBuilder entityId = new StringBuilder(); -if (ctx[\\"entity\\"][\\"identity\\"] != null) { - // Get the values as a collection - Collection values = collectValues(ctx[\\"entity\\"][\\"identity\\"]); - // Convert to a list and sort - List sortedValues = new ArrayList(values); - Collections.sort(sortedValues); - // Create comma delimited string - for(String instanceValue: sortedValues) { - entityId.append(instanceValue); - entityId.append(\\":\\"); - } - // Assign the entity.id - ctx[\\"entity\\"][\\"id\\"] = entityId.length() > 0 ? entityId.substring(0, entityId.length() - 1) : \\"unknown\\"; -}", - }, - }, - Object { - "fingerprint": Object { - "fields": Array [ - "entity.id", - ], - "method": "MurmurHash3", - "target_field": "entity.id", - }, - }, - Object { - "script": Object { - "source": "if (ctx.entity?.metadata?.tags != null) { - ctx.tags = ctx.entity.metadata.tags.keySet(); -} -if (ctx.entity?.metadata?.host?.name != null) { - if (ctx.host == null) { - ctx.host = new HashMap(); - } - ctx.host.name = ctx.entity.metadata.host.name.keySet(); -} -if (ctx.entity?.metadata?.host?.os?.name != null) { - if (ctx.host == null) { - ctx.host = new HashMap(); - } - if (ctx.host.os == null) { - ctx.host.os = new HashMap(); - } - ctx.host.os.name = ctx.entity.metadata.host.os.name.keySet(); -} -if (ctx.entity?.metadata?.sourceIndex != null) { - ctx.sourceIndex = ctx.entity.metadata.sourceIndex.keySet(); -}", - }, - }, - Object { - "remove": Object { - "field": "entity.metadata", - "ignore_missing": true, - }, - }, - Object { - "set": Object { - "field": "log.logger", - "if": "ctx.entity?.identity?.log?.logger != null", - "value": "{{entity.identity.log.logger}}", - }, - }, - Object { - "set": Object { - "field": "event.category", - "if": "ctx.entity?.identity?.event?.category != null", - "value": "{{entity.identity.event.category}}", - }, - }, - Object { - "remove": Object { - "field": "entity.identity", - "ignore_missing": true, - }, - }, - Object { - "date_index_name": Object { - "date_formats": Array [ - "UNIX_MS", - "ISO8601", - "yyyy-MM-dd'T'HH:mm:ss.SSSXX", - ], - "date_rounding": "M", - "field": "@timestamp", - "index_name_prefix": ".entities.v1.history.admin-console-services.", - }, - }, - Object { - "pipeline": Object { - "ignore_missing_pipeline": true, - "name": "admin-console-services@platform", - }, - }, - Object { - "pipeline": Object { - "ignore_missing_pipeline": true, - "name": "admin-console-services-history@platform", - }, - }, - Object { - "pipeline": Object { - "ignore_missing_pipeline": true, - "name": "admin-console-services@custom", - }, - }, - Object { - "pipeline": Object { - "ignore_missing_pipeline": true, - "name": "admin-console-services-history@custom", - }, - }, -] -`; diff --git a/x-pack/plugins/entity_manager/server/lib/entities/ingest_pipeline/__snapshots__/generate_latest_processors.test.ts.snap b/x-pack/plugins/entity_manager/server/lib/entities/ingest_pipeline/__snapshots__/generate_latest_processors.test.ts.snap index f277b3ac84ab8..218deda422fe2 100644 --- a/x-pack/plugins/entity_manager/server/lib/entities/ingest_pipeline/__snapshots__/generate_latest_processors.test.ts.snap +++ b/x-pack/plugins/entity_manager/server/lib/entities/ingest_pipeline/__snapshots__/generate_latest_processors.test.ts.snap @@ -43,16 +43,60 @@ Array [ }, Object { "script": Object { - "source": "if (ctx.entity?.metadata?.tags.data != null) { + "description": "Generated the entity.id field", + "source": "// This function will recursively collect all the values of a HashMap of HashMaps +Collection collectValues(HashMap subject) { + Collection values = new ArrayList(); + // Iterate through the values + for(Object value: subject.values()) { + // If the value is a HashMap, recurse + if (value instanceof HashMap) { + values.addAll(collectValues((HashMap) value)); + } else { + values.add(String.valueOf(value)); + } + } + return values; +} +// Create the string builder +StringBuilder entityId = new StringBuilder(); +if (ctx[\\"entity\\"][\\"identity\\"] != null) { + // Get the values as a collection + Collection values = collectValues(ctx[\\"entity\\"][\\"identity\\"]); + // Convert to a list and sort + List sortedValues = new ArrayList(values); + Collections.sort(sortedValues); + // Create comma delimited string + for(String instanceValue: sortedValues) { + entityId.append(instanceValue); + entityId.append(\\":\\"); + } + // Assign the entity.id + ctx[\\"entity\\"][\\"id\\"] = entityId.length() > 0 ? entityId.substring(0, entityId.length() - 1) : \\"unknown\\"; +}", + }, + }, + Object { + "fingerprint": Object { + "fields": Array [ + "entity.id", + ], + "method": "MurmurHash3", + "target_field": "entity.id", + }, + }, + Object { + "script": Object { + "source": "if (ctx.entity?.metadata?.tags?.data != null) { ctx.tags = ctx.entity.metadata.tags.data.keySet(); } -if (ctx.entity?.metadata?.host?.name.data != null) { +if (ctx.entity?.metadata?.host?.name?.data != null) { if (ctx.host == null) { ctx.host = new HashMap(); } ctx.host.name = ctx.entity.metadata.host.name.data.keySet(); } -if (ctx.entity?.metadata?.host?.os?.name.data != null) { +if (ctx.entity?.metadata?.host?.os?.name?.data != null) { if (ctx.host == null) { ctx.host = new HashMap(); } @@ -61,7 +105,7 @@ if (ctx.entity?.metadata?.host?.os?.name.data != null) { } ctx.host.os.name = ctx.entity.metadata.host.os.name.data.keySet(); } -if (ctx.entity?.metadata?.sourceIndex.data != null) { +if (ctx.entity?.metadata?.sourceIndex?.data != null) { ctx.sourceIndex = ctx.entity.metadata.sourceIndex.data.keySet(); }", }, @@ -72,28 +116,18 @@ if (ctx.entity?.metadata?.sourceIndex.data != null) { "ignore_missing": true, }, }, - Object { - "dot_expander": Object { - "field": "log.logger", - "path": "entity.identity.log.logger.top_metric", - }, - }, Object { "set": Object { "field": "log.logger", - "value": "{{entity.identity.log.logger.top_metric.log.logger}}", - }, - }, - Object { - "dot_expander": Object { - "field": "event.category", - "path": "entity.identity.event.category.top_metric", + "if": "ctx.entity?.identity?.log?.logger != null", + "value": "{{entity.identity.log.logger}}", }, }, Object { "set": Object { "field": "event.category", - "value": "{{entity.identity.event.category.top_metric.event.category}}", + "if": "ctx.entity?.identity?.event?.category != null", + "value": "{{entity.identity.event.category}}", }, }, Object { @@ -160,16 +194,60 @@ Array [ }, Object { "script": Object { - "source": "if (ctx.entity?.metadata?.tags.data != null) { + "description": "Generated the entity.id field", + "source": "// This function will recursively collect all the values of a HashMap of HashMaps +Collection collectValues(HashMap subject) { + Collection values = new ArrayList(); + // Iterate through the values + for(Object value: subject.values()) { + // If the value is a HashMap, recurse + if (value instanceof HashMap) { + values.addAll(collectValues((HashMap) value)); + } else { + values.add(String.valueOf(value)); + } + } + return values; +} +// Create the string builder +StringBuilder entityId = new StringBuilder(); +if (ctx[\\"entity\\"][\\"identity\\"] != null) { + // Get the values as a collection + Collection values = collectValues(ctx[\\"entity\\"][\\"identity\\"]); + // Convert to a list and sort + List sortedValues = new ArrayList(values); + Collections.sort(sortedValues); + // Create comma delimited string + for(String instanceValue: sortedValues) { + entityId.append(instanceValue); + entityId.append(\\":\\"); + } + // Assign the entity.id + ctx[\\"entity\\"][\\"id\\"] = entityId.length() > 0 ? entityId.substring(0, entityId.length() - 1) : \\"unknown\\"; +}", + }, + }, + Object { + "fingerprint": Object { + "fields": Array [ + "entity.id", + ], + "method": "MurmurHash3", + "target_field": "entity.id", + }, + }, + Object { + "script": Object { + "source": "if (ctx.entity?.metadata?.tags?.data != null) { ctx.tags = ctx.entity.metadata.tags.data.keySet(); } -if (ctx.entity?.metadata?.host?.name.data != null) { +if (ctx.entity?.metadata?.host?.name?.data != null) { if (ctx.host == null) { ctx.host = new HashMap(); } ctx.host.name = ctx.entity.metadata.host.name.data.keySet(); } -if (ctx.entity?.metadata?.host?.os?.name.data != null) { +if (ctx.entity?.metadata?.host?.os?.name?.data != null) { if (ctx.host == null) { ctx.host = new HashMap(); } @@ -178,7 +256,7 @@ if (ctx.entity?.metadata?.host?.os?.name.data != null) { } ctx.host.os.name = ctx.entity.metadata.host.os.name.data.keySet(); } -if (ctx.entity?.metadata?.sourceIndex.data != null) { +if (ctx.entity?.metadata?.sourceIndex?.data != null) { ctx.sourceIndex = ctx.entity.metadata.sourceIndex.data.keySet(); }", }, @@ -189,28 +267,18 @@ if (ctx.entity?.metadata?.sourceIndex.data != null) { "ignore_missing": true, }, }, - Object { - "dot_expander": Object { - "field": "log.logger", - "path": "entity.identity.log.logger.top_metric", - }, - }, Object { "set": Object { "field": "log.logger", - "value": "{{entity.identity.log.logger.top_metric.log.logger}}", - }, - }, - Object { - "dot_expander": Object { - "field": "event.category", - "path": "entity.identity.event.category.top_metric", + "if": "ctx.entity?.identity?.log?.logger != null", + "value": "{{entity.identity.log.logger}}", }, }, Object { "set": Object { "field": "event.category", - "value": "{{entity.identity.event.category.top_metric.event.category}}", + "if": "ctx.entity?.identity?.event?.category != null", + "value": "{{entity.identity.event.category}}", }, }, Object { diff --git a/x-pack/plugins/entity_manager/server/lib/entities/ingest_pipeline/generate_history_processors.test.ts b/x-pack/plugins/entity_manager/server/lib/entities/ingest_pipeline/generate_history_processors.test.ts deleted file mode 100644 index 717241b89143d..0000000000000 --- a/x-pack/plugins/entity_manager/server/lib/entities/ingest_pipeline/generate_history_processors.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { entityDefinition, builtInEntityDefinition } from '../helpers/fixtures'; -import { generateHistoryProcessors } from './generate_history_processors'; - -describe('generateHistoryProcessors(definition)', () => { - it('should generate a valid pipeline for custom definition', () => { - const processors = generateHistoryProcessors(entityDefinition); - expect(processors).toMatchSnapshot(); - }); - - it('should generate a valid pipeline for builtin definition', () => { - const processors = generateHistoryProcessors(builtInEntityDefinition); - expect(processors).toMatchSnapshot(); - }); -}); diff --git a/x-pack/plugins/entity_manager/server/lib/entities/ingest_pipeline/generate_history_processors.ts b/x-pack/plugins/entity_manager/server/lib/entities/ingest_pipeline/generate_history_processors.ts deleted file mode 100644 index d51ab0be75db1..0000000000000 --- a/x-pack/plugins/entity_manager/server/lib/entities/ingest_pipeline/generate_history_processors.ts +++ /dev/null @@ -1,222 +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 { EntityDefinition, ENTITY_SCHEMA_VERSION_V1, MetadataField } from '@kbn/entities-schema'; -import { - initializePathScript, - cleanScript, -} from '../helpers/ingest_pipeline_script_processor_helpers'; -import { generateHistoryIndexName } from '../helpers/generate_component_id'; -import { isBuiltinDefinition } from '../helpers/is_builtin_definition'; - -function getMetadataSourceField({ aggregation, destination, source }: MetadataField) { - if (aggregation.type === 'terms') { - return `ctx.entity.metadata.${destination}.keySet()`; - } else if (aggregation.type === 'top_value') { - return `ctx.entity.metadata.${destination}.top_value["${source}"]`; - } -} - -function mapDestinationToPainless(metadata: MetadataField) { - const field = metadata.destination; - return ` - ${initializePathScript(field)} - ctx.${field} = ${getMetadataSourceField(metadata)}; - `; -} - -function createMetadataPainlessScript(definition: EntityDefinition) { - if (!definition.metadata) { - return ''; - } - - return definition.metadata.reduce((acc, metadata) => { - const { destination, source } = metadata; - const optionalFieldPath = destination.replaceAll('.', '?.'); - - if (metadata.aggregation.type === 'terms') { - const next = ` - if (ctx.entity?.metadata?.${optionalFieldPath} != null) { - ${mapDestinationToPainless(metadata)} - } - `; - return `${acc}\n${next}`; - } else if (metadata.aggregation.type === 'top_value') { - const next = ` - if (ctx.entity?.metadata?.${optionalFieldPath}?.top_value["${source}"] != null) { - ${mapDestinationToPainless(metadata)} - } - `; - return `${acc}\n${next}`; - } - - return acc; - }, ''); -} - -function liftIdentityFieldsToDocumentRoot(definition: EntityDefinition) { - return definition.identityFields.map((key) => ({ - set: { - if: `ctx.entity?.identity?.${key.field.replaceAll('.', '?.')} != null`, - field: key.field, - value: `{{entity.identity.${key.field}}}`, - }, - })); -} - -function getCustomIngestPipelines(definition: EntityDefinition) { - if (isBuiltinDefinition(definition)) { - return []; - } - - return [ - { - pipeline: { - ignore_missing_pipeline: true, - name: `${definition.id}@platform`, - }, - }, - { - pipeline: { - ignore_missing_pipeline: true, - name: `${definition.id}-history@platform`, - }, - }, - { - pipeline: { - ignore_missing_pipeline: true, - name: `${definition.id}@custom`, - }, - }, - { - pipeline: { - ignore_missing_pipeline: true, - name: `${definition.id}-history@custom`, - }, - }, - ]; -} - -export function generateHistoryProcessors(definition: EntityDefinition) { - return [ - { - set: { - field: 'event.ingested', - value: '{{{_ingest.timestamp}}}', - }, - }, - { - set: { - field: 'entity.type', - value: definition.type, - }, - }, - { - set: { - field: 'entity.definitionId', - value: definition.id, - }, - }, - { - set: { - field: 'entity.definitionVersion', - value: definition.version, - }, - }, - { - set: { - field: 'entity.schemaVersion', - value: ENTITY_SCHEMA_VERSION_V1, - }, - }, - { - set: { - field: 'entity.identityFields', - value: definition.identityFields.map((identityField) => identityField.field), - }, - }, - { - script: { - description: 'Generated the entity.id field', - source: cleanScript(` - // This function will recursively collect all the values of a HashMap of HashMaps - Collection collectValues(HashMap subject) { - Collection values = new ArrayList(); - // Iterate through the values - for(Object value: subject.values()) { - // If the value is a HashMap, recurse - if (value instanceof HashMap) { - values.addAll(collectValues((HashMap) value)); - } else { - values.add(String.valueOf(value)); - } - } - return values; - } - - // Create the string builder - StringBuilder entityId = new StringBuilder(); - - if (ctx["entity"]["identity"] != null) { - // Get the values as a collection - Collection values = collectValues(ctx["entity"]["identity"]); - - // Convert to a list and sort - List sortedValues = new ArrayList(values); - Collections.sort(sortedValues); - - // Create comma delimited string - for(String instanceValue: sortedValues) { - entityId.append(instanceValue); - entityId.append(":"); - } - - // Assign the entity.id - ctx["entity"]["id"] = entityId.length() > 0 ? entityId.substring(0, entityId.length() - 1) : "unknown"; - } - `), - }, - }, - { - fingerprint: { - fields: ['entity.id'], - target_field: 'entity.id', - method: 'MurmurHash3', - }, - }, - ...(definition.staticFields != null - ? Object.keys(definition.staticFields).map((field) => ({ - set: { field, value: definition.staticFields![field] }, - })) - : []), - ...(definition.metadata != null - ? [{ script: { source: cleanScript(createMetadataPainlessScript(definition)) } }] - : []), - { - remove: { - field: 'entity.metadata', - ignore_missing: true, - }, - }, - ...liftIdentityFieldsToDocumentRoot(definition), - { - remove: { - field: 'entity.identity', - ignore_missing: true, - }, - }, - { - date_index_name: { - field: '@timestamp', - index_name_prefix: `${generateHistoryIndexName(definition)}.`, - date_rounding: 'M', - date_formats: ['UNIX_MS', 'ISO8601', "yyyy-MM-dd'T'HH:mm:ss.SSSXX"], - }, - }, - ...getCustomIngestPipelines(definition), - ]; -} diff --git a/x-pack/plugins/entity_manager/server/lib/entities/ingest_pipeline/generate_latest_processors.ts b/x-pack/plugins/entity_manager/server/lib/entities/ingest_pipeline/generate_latest_processors.ts index 16823221fffb3..0e3812de2e320 100644 --- a/x-pack/plugins/entity_manager/server/lib/entities/ingest_pipeline/generate_latest_processors.ts +++ b/x-pack/plugins/entity_manager/server/lib/entities/ingest_pipeline/generate_latest_processors.ts @@ -17,7 +17,7 @@ function getMetadataSourceField({ aggregation, destination, source }: MetadataFi if (aggregation.type === 'terms') { return `ctx.entity.metadata.${destination}.data.keySet()`; } else if (aggregation.type === 'top_value') { - return `ctx.entity.metadata.${destination}.top_value["${destination}"]`; + return `ctx.entity.metadata.${destination}.top_value["${source}"]`; } } @@ -35,19 +35,19 @@ function createMetadataPainlessScript(definition: EntityDefinition) { } return definition.metadata.reduce((acc, metadata) => { - const destination = metadata.destination; + const { destination, source } = metadata; const optionalFieldPath = destination.replaceAll('.', '?.'); if (metadata.aggregation.type === 'terms') { const next = ` - if (ctx.entity?.metadata?.${optionalFieldPath}.data != null) { + if (ctx.entity?.metadata?.${optionalFieldPath}?.data != null) { ${mapDestinationToPainless(metadata)} } `; return `${acc}\n${next}`; } else if (metadata.aggregation.type === 'top_value') { const next = ` - if (ctx.entity?.metadata?.${optionalFieldPath}?.top_value["${destination}"] != null) { + if (ctx.entity?.metadata?.${optionalFieldPath}?.top_value["${source}"] != null) { ${mapDestinationToPainless(metadata)} } `; @@ -59,30 +59,13 @@ function createMetadataPainlessScript(definition: EntityDefinition) { } function liftIdentityFieldsToDocumentRoot(definition: EntityDefinition) { - return definition.identityFields - .map((identityField) => { - const setProcessor = { - set: { - field: identityField.field, - value: `{{entity.identity.${identityField.field}.top_metric.${identityField.field}}}`, - }, - }; - - if (!identityField.field.includes('.')) { - return [setProcessor]; - } - - return [ - { - dot_expander: { - field: identityField.field, - path: `entity.identity.${identityField.field}.top_metric`, - }, - }, - setProcessor, - ]; - }) - .flat(); + return definition.identityFields.map((key) => ({ + set: { + if: `ctx.entity?.identity?.${key.field.replaceAll('.', '?.')} != null`, + field: key.field, + value: `{{entity.identity.${key.field}}}`, + }, + })); } function getCustomIngestPipelines(definition: EntityDefinition) { @@ -156,6 +139,55 @@ export function generateLatestProcessors(definition: EntityDefinition) { value: definition.identityFields.map((identityField) => identityField.field), }, }, + { + script: { + description: 'Generated the entity.id field', + source: cleanScript(` + // This function will recursively collect all the values of a HashMap of HashMaps + Collection collectValues(HashMap subject) { + Collection values = new ArrayList(); + // Iterate through the values + for(Object value: subject.values()) { + // If the value is a HashMap, recurse + if (value instanceof HashMap) { + values.addAll(collectValues((HashMap) value)); + } else { + values.add(String.valueOf(value)); + } + } + return values; + } + + // Create the string builder + StringBuilder entityId = new StringBuilder(); + + if (ctx["entity"]["identity"] != null) { + // Get the values as a collection + Collection values = collectValues(ctx["entity"]["identity"]); + + // Convert to a list and sort + List sortedValues = new ArrayList(values); + Collections.sort(sortedValues); + + // Create comma delimited string + for(String instanceValue: sortedValues) { + entityId.append(instanceValue); + entityId.append(":"); + } + + // Assign the entity.id + ctx["entity"]["id"] = entityId.length() > 0 ? entityId.substring(0, entityId.length() - 1) : "unknown"; + } + `), + }, + }, + { + fingerprint: { + fields: ['entity.id'], + target_field: 'entity.id', + method: 'MurmurHash3', + }, + }, ...(definition.staticFields != null ? Object.keys(definition.staticFields).map((field) => ({ set: { field, value: definition.staticFields![field] }, @@ -177,8 +209,8 @@ export function generateLatestProcessors(definition: EntityDefinition) { ignore_missing: true, }, }, + // This must happen AFTER we lift the identity fields into the root of the document { - // This must happen AFTER we lift the identity fields into the root of the document set: { field: 'entity.displayName', value: definition.displayNameTemplate, diff --git a/x-pack/plugins/entity_manager/server/lib/entities/install_entity_definition.test.ts b/x-pack/plugins/entity_manager/server/lib/entities/install_entity_definition.test.ts index 5cee21dc43a07..e07670c58fd9b 100644 --- a/x-pack/plugins/entity_manager/server/lib/entities/install_entity_definition.test.ts +++ b/x-pack/plugins/entity_manager/server/lib/entities/install_entity_definition.test.ts @@ -19,19 +19,23 @@ import { } from './install_entity_definition'; import { SO_ENTITY_DEFINITION_TYPE } from '../../saved_objects'; import { - generateHistoryIndexTemplateId, - generateHistoryIngestPipelineId, - generateHistoryTransformId, generateLatestIndexTemplateId, generateLatestIngestPipelineId, generateLatestTransformId, } from './helpers/generate_component_id'; -import { generateHistoryTransform } from './transform/generate_history_transform'; import { generateLatestTransform } from './transform/generate_latest_transform'; import { entityDefinition as mockEntityDefinition } from './helpers/fixtures/entity_definition'; import { EntityDefinitionIdInvalid } from './errors/entity_definition_id_invalid'; import { EntityIdConflict } from './errors/entity_id_conflict_error'; +const getExpectedInstalledComponents = (definition: EntityDefinition) => { + return [ + { type: 'template', id: generateLatestIndexTemplateId(definition) }, + { type: 'ingest_pipeline', id: generateLatestIngestPipelineId(definition) }, + { type: 'transform', id: generateLatestTransformId(definition) }, + ]; +}; + const assertHasCreatedDefinition = ( definition: EntityDefinition, soClient: SavedObjectsClientContract, @@ -44,6 +48,7 @@ const assertHasCreatedDefinition = ( ...definition, installStatus: 'installing', installStartedAt: expect.any(String), + installedComponents: [], }, { id: definition.id, @@ -54,29 +59,17 @@ const assertHasCreatedDefinition = ( expect(soClient.update).toBeCalledTimes(1); expect(soClient.update).toBeCalledWith(SO_ENTITY_DEFINITION_TYPE, definition.id, { installStatus: 'installed', + installedComponents: getExpectedInstalledComponents(definition), }); - expect(esClient.indices.putIndexTemplate).toBeCalledTimes(2); - expect(esClient.indices.putIndexTemplate).toBeCalledWith( - expect.objectContaining({ - name: `entities_v1_history_${definition.id}_index_template`, - }) - ); + expect(esClient.indices.putIndexTemplate).toBeCalledTimes(1); expect(esClient.indices.putIndexTemplate).toBeCalledWith( expect.objectContaining({ name: `entities_v1_latest_${definition.id}_index_template`, }) ); - expect(esClient.ingest.putPipeline).toBeCalledTimes(2); - expect(esClient.ingest.putPipeline).toBeCalledWith({ - id: generateHistoryIngestPipelineId(definition), - processors: expect.anything(), - _meta: { - definitionVersion: definition.version, - managed: definition.managed, - }, - }); + expect(esClient.ingest.putPipeline).toBeCalledTimes(1); expect(esClient.ingest.putPipeline).toBeCalledWith({ id: generateLatestIngestPipelineId(definition), processors: expect.anything(), @@ -86,8 +79,7 @@ const assertHasCreatedDefinition = ( }, }); - expect(esClient.transform.putTransform).toBeCalledTimes(2); - expect(esClient.transform.putTransform).toBeCalledWith(generateHistoryTransform(definition)); + expect(esClient.transform.putTransform).toBeCalledTimes(1); expect(esClient.transform.putTransform).toBeCalledWith(generateLatestTransform(definition)); }; @@ -101,32 +93,21 @@ const assertHasUpgradedDefinition = ( ...definition, installStatus: 'upgrading', installStartedAt: expect.any(String), + installedComponents: getExpectedInstalledComponents(definition), }); expect(soClient.update).toBeCalledWith(SO_ENTITY_DEFINITION_TYPE, definition.id, { installStatus: 'installed', + installedComponents: getExpectedInstalledComponents(definition), }); - expect(esClient.indices.putIndexTemplate).toBeCalledTimes(2); - expect(esClient.indices.putIndexTemplate).toBeCalledWith( - expect.objectContaining({ - name: `entities_v1_history_${definition.id}_index_template`, - }) - ); + expect(esClient.indices.putIndexTemplate).toBeCalledTimes(1); expect(esClient.indices.putIndexTemplate).toBeCalledWith( expect.objectContaining({ name: `entities_v1_latest_${definition.id}_index_template`, }) ); - expect(esClient.ingest.putPipeline).toBeCalledTimes(2); - expect(esClient.ingest.putPipeline).toBeCalledWith({ - id: generateHistoryIngestPipelineId(definition), - processors: expect.anything(), - _meta: { - definitionVersion: definition.version, - managed: definition.managed, - }, - }); + expect(esClient.ingest.putPipeline).toBeCalledTimes(1); expect(esClient.ingest.putPipeline).toBeCalledWith({ id: generateLatestIngestPipelineId(definition), processors: expect.anything(), @@ -136,8 +117,7 @@ const assertHasUpgradedDefinition = ( }, }); - expect(esClient.transform.putTransform).toBeCalledTimes(2); - expect(esClient.transform.putTransform).toBeCalledWith(generateHistoryTransform(definition)); + expect(esClient.transform.putTransform).toBeCalledTimes(1); expect(esClient.transform.putTransform).toBeCalledWith(generateLatestTransform(definition)); }; @@ -148,13 +128,7 @@ const assertHasDeletedDefinition = ( ) => { assertHasDeletedTransforms(definition, esClient); - expect(esClient.ingest.deletePipeline).toBeCalledTimes(2); - expect(esClient.ingest.deletePipeline).toBeCalledWith( - { - id: generateHistoryIngestPipelineId(definition), - }, - { ignore: [404] } - ); + expect(esClient.ingest.deletePipeline).toBeCalledTimes(1); expect(esClient.ingest.deletePipeline).toBeCalledWith( { id: generateLatestIngestPipelineId(definition), @@ -162,13 +136,7 @@ const assertHasDeletedDefinition = ( { ignore: [404] } ); - expect(esClient.indices.deleteIndexTemplate).toBeCalledTimes(2); - expect(esClient.indices.deleteIndexTemplate).toBeCalledWith( - { - name: generateHistoryIndexTemplateId(definition), - }, - { ignore: [404] } - ); + expect(esClient.indices.deleteIndexTemplate).toBeCalledTimes(1); expect(esClient.indices.deleteIndexTemplate).toBeCalledWith( { name: generateLatestIndexTemplateId(definition), @@ -184,33 +152,21 @@ const assertHasDeletedTransforms = ( definition: EntityDefinition, esClient: ElasticsearchClient ) => { - expect(esClient.transform.stopTransform).toBeCalledTimes(2); - expect(esClient.transform.stopTransform).toBeCalledWith( - expect.objectContaining({ - transform_id: generateHistoryTransformId(definition), - }), - expect.anything() - ); - expect(esClient.transform.deleteTransform).toBeCalledWith( - expect.objectContaining({ - transform_id: generateHistoryTransformId(definition), - }), - expect.anything() - ); + expect(esClient.transform.stopTransform).toBeCalledTimes(1); expect(esClient.transform.stopTransform).toBeCalledWith( expect.objectContaining({ transform_id: generateLatestTransformId(definition), }), expect.anything() ); + + expect(esClient.transform.deleteTransform).toBeCalledTimes(1); expect(esClient.transform.deleteTransform).toBeCalledWith( expect.objectContaining({ transform_id: generateLatestTransformId(definition), }), expect.anything() ); - - expect(esClient.transform.deleteTransform).toBeCalledTimes(2); }; describe('install_entity_definition', () => { @@ -223,7 +179,7 @@ describe('install_entity_definition', () => { installEntityDefinition({ esClient, soClient, - definition: { id: 'a'.repeat(40) } as EntityDefinition, + definition: { id: 'a'.repeat(50) } as EntityDefinition, logger: loggerMock.create(), }) ).rejects.toThrow(EntityDefinitionIdInvalid); @@ -242,6 +198,7 @@ describe('install_entity_definition', () => { attributes: { ...mockEntityDefinition, installStatus: 'installed', + installedComponents: [], }, }, ], @@ -264,6 +221,12 @@ describe('install_entity_definition', () => { const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; const soClient = savedObjectsClientMock.create(); soClient.find.mockResolvedValue({ saved_objects: [], total: 0, page: 1, per_page: 10 }); + soClient.update.mockResolvedValue({ + id: mockEntityDefinition.id, + type: 'entity-definition', + references: [], + attributes: {}, + }); await installEntityDefinition({ esClient, @@ -300,6 +263,12 @@ describe('install_entity_definition', () => { const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; const soClient = savedObjectsClientMock.create(); soClient.find.mockResolvedValue({ saved_objects: [], total: 0, page: 1, per_page: 10 }); + soClient.update.mockResolvedValue({ + id: mockEntityDefinition.id, + type: 'entity-definition', + references: [], + attributes: {}, + }); await installBuiltInEntityDefinitions({ esClient, @@ -329,6 +298,7 @@ describe('install_entity_definition', () => { attributes: { ...mockEntityDefinition, installStatus: 'installed', + installedComponents: getExpectedInstalledComponents(mockEntityDefinition), }, }, ], @@ -336,6 +306,12 @@ describe('install_entity_definition', () => { page: 1, per_page: 10, }); + soClient.update.mockResolvedValue({ + id: mockEntityDefinition.id, + type: 'entity-definition', + references: [], + attributes: {}, + }); await installBuiltInEntityDefinitions({ esClient, @@ -367,6 +343,7 @@ describe('install_entity_definition', () => { attributes: { ...mockEntityDefinition, installStatus: 'installed', + installedComponents: getExpectedInstalledComponents(mockEntityDefinition), }, }, ], @@ -374,6 +351,12 @@ describe('install_entity_definition', () => { page: 1, per_page: 10, }); + soClient.update.mockResolvedValue({ + id: mockEntityDefinition.id, + type: 'entity-definition', + references: [], + attributes: {}, + }); await installBuiltInEntityDefinitions({ esClient, @@ -407,6 +390,7 @@ describe('install_entity_definition', () => { // upgrading for 1h installStatus: 'upgrading', installStartedAt: moment().subtract(1, 'hour').toISOString(), + installedComponents: getExpectedInstalledComponents(mockEntityDefinition), }, }, ], @@ -414,6 +398,12 @@ describe('install_entity_definition', () => { page: 1, per_page: 10, }); + soClient.update.mockResolvedValue({ + id: mockEntityDefinition.id, + type: 'entity-definition', + references: [], + attributes: {}, + }); await installBuiltInEntityDefinitions({ esClient, @@ -442,6 +432,7 @@ describe('install_entity_definition', () => { ...mockEntityDefinition, installStatus: 'failed', installStartedAt: new Date().toISOString(), + installedComponents: getExpectedInstalledComponents(mockEntityDefinition), }, }, ], @@ -449,6 +440,12 @@ describe('install_entity_definition', () => { page: 1, per_page: 10, }); + soClient.update.mockResolvedValue({ + id: mockEntityDefinition.id, + type: 'entity-definition', + references: [], + attributes: {}, + }); await installBuiltInEntityDefinitions({ esClient, diff --git a/x-pack/plugins/entity_manager/server/lib/entities/install_entity_definition.ts b/x-pack/plugins/entity_manager/server/lib/entities/install_entity_definition.ts index 7d6dee4fb2ced..b4adedaf10374 100644 --- a/x-pack/plugins/entity_manager/server/lib/entities/install_entity_definition.ts +++ b/x-pack/plugins/entity_manager/server/lib/entities/install_entity_definition.ts @@ -10,39 +10,25 @@ import { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; import { EntityDefinition, EntityDefinitionUpdate } from '@kbn/entities-schema'; import { Logger } from '@kbn/logging'; -import { - generateHistoryIndexTemplateId, - generateLatestIndexTemplateId, -} from './helpers/generate_component_id'; -import { - createAndInstallHistoryIngestPipeline, - createAndInstallLatestIngestPipeline, -} from './create_and_install_ingest_pipeline'; -import { - createAndInstallHistoryBackfillTransform, - createAndInstallHistoryTransform, - createAndInstallLatestTransform, -} from './create_and_install_transform'; +import { generateLatestIndexTemplateId } from './helpers/generate_component_id'; +import { createAndInstallIngestPipelines } from './create_and_install_ingest_pipeline'; +import { createAndInstallTransforms } from './create_and_install_transform'; import { validateDefinitionCanCreateValidTransformIds } from './transform/validate_transform_ids'; import { deleteEntityDefinition } from './delete_entity_definition'; -import { deleteHistoryIngestPipeline, deleteLatestIngestPipeline } from './delete_ingest_pipeline'; +import { deleteLatestIngestPipeline } from './delete_ingest_pipeline'; import { findEntityDefinitionById } from './find_entity_definition'; import { entityDefinitionExists, saveEntityDefinition, updateEntityDefinition, } from './save_entity_definition'; - -import { isBackfillEnabled } from './helpers/is_backfill_enabled'; -import { deleteTemplate, upsertTemplate } from '../manage_index_templates'; -import { generateEntitiesLatestIndexTemplateConfig } from './templates/entities_latest_template'; -import { generateEntitiesHistoryIndexTemplateConfig } from './templates/entities_history_template'; +import { createAndInstallTemplates, deleteTemplate } from '../manage_index_templates'; import { EntityIdConflict } from './errors/entity_id_conflict_error'; import { EntityDefinitionNotFound } from './errors/entity_not_found'; import { mergeEntityDefinitionUpdate } from './helpers/merge_definition_update'; import { EntityDefinitionWithState } from './types'; -import { stopTransforms } from './stop_transforms'; -import { deleteTransforms } from './delete_transforms'; +import { stopLatestTransform, stopTransforms } from './stop_transforms'; +import { deleteLatestTransform, deleteTransforms } from './delete_transforms'; export interface InstallDefinitionParams { esClient: ElasticsearchClient; @@ -51,16 +37,6 @@ export interface InstallDefinitionParams { logger: Logger; } -const throwIfRejected = (values: Array | PromiseRejectedResult>) => { - const rejectedPromise = values.find( - (value) => value.status === 'rejected' - ) as PromiseRejectedResult; - if (rejectedPromise) { - throw new Error(rejectedPromise.reason); - } - return values; -}; - // install an entity definition from scratch with all its required components // after verifying that the definition id is valid and available. // attempt to remove all installed components if the installation fails. @@ -72,42 +48,35 @@ export async function installEntityDefinition({ }: InstallDefinitionParams): Promise { validateDefinitionCanCreateValidTransformIds(definition); - try { - if (await entityDefinitionExists(soClient, definition.id)) { - throw new EntityIdConflict( - `Entity definition with [${definition.id}] already exists.`, - definition - ); - } + if (await entityDefinitionExists(soClient, definition.id)) { + throw new EntityIdConflict( + `Entity definition with [${definition.id}] already exists.`, + definition + ); + } + try { const entityDefinition = await saveEntityDefinition(soClient, { ...definition, installStatus: 'installing', installStartedAt: new Date().toISOString(), + installedComponents: [], }); return await install({ esClient, soClient, logger, definition: entityDefinition }); } catch (e) { logger.error(`Failed to install entity definition ${definition.id}: ${e}`); - await stopAndDeleteTransforms(esClient, definition, logger); - await Promise.all([ - deleteHistoryIngestPipeline(esClient, definition, logger), - deleteLatestIngestPipeline(esClient, definition, logger), - ]); + await stopLatestTransform(esClient, definition, logger); + await deleteLatestTransform(esClient, definition, logger); - await Promise.all([ - deleteTemplate({ - esClient, - logger, - name: generateHistoryIndexTemplateId(definition), - }), - deleteTemplate({ - esClient, - logger, - name: generateLatestIndexTemplateId(definition), - }), - ]); + await deleteLatestIngestPipeline(esClient, definition, logger); + + await deleteTemplate({ + esClient, + logger, + name: generateLatestIndexTemplateId(definition), + }); await deleteEntityDefinition(soClient, definition).catch((err) => { if (err instanceof EntityDefinitionNotFound) { @@ -191,36 +160,19 @@ async function install({ ); logger.debug(`Installing index templates for definition ${definition.id}`); - await Promise.allSettled([ - upsertTemplate({ - esClient, - logger, - template: generateEntitiesHistoryIndexTemplateConfig(definition), - }), - upsertTemplate({ - esClient, - logger, - template: generateEntitiesLatestIndexTemplateConfig(definition), - }), - ]).then(throwIfRejected); + const templates = await createAndInstallTemplates(esClient, definition, logger); logger.debug(`Installing ingest pipelines for definition ${definition.id}`); - await Promise.allSettled([ - createAndInstallHistoryIngestPipeline(esClient, definition, logger), - createAndInstallLatestIngestPipeline(esClient, definition, logger), - ]).then(throwIfRejected); + const pipelines = await createAndInstallIngestPipelines(esClient, definition, logger); logger.debug(`Installing transforms for definition ${definition.id}`); - await Promise.allSettled([ - createAndInstallHistoryTransform(esClient, definition, logger), - isBackfillEnabled(definition) - ? createAndInstallHistoryBackfillTransform(esClient, definition, logger) - : Promise.resolve(), - createAndInstallLatestTransform(esClient, definition, logger), - ]).then(throwIfRejected); - - await updateEntityDefinition(soClient, definition.id, { installStatus: 'installed' }); - return { ...definition, installStatus: 'installed' }; + const transforms = await createAndInstallTransforms(esClient, definition, logger); + + const updatedProps = await updateEntityDefinition(soClient, definition.id, { + installStatus: 'installed', + installedComponents: [...templates, ...pipelines, ...transforms], + }); + return { ...definition, ...updatedProps.attributes }; } // stop and delete the current transforms and reinstall all the components diff --git a/x-pack/plugins/entity_manager/server/lib/entities/save_entity_definition.ts b/x-pack/plugins/entity_manager/server/lib/entities/save_entity_definition.ts index 2dff5178aeeaf..d32edfa146917 100644 --- a/x-pack/plugins/entity_manager/server/lib/entities/save_entity_definition.ts +++ b/x-pack/plugins/entity_manager/server/lib/entities/save_entity_definition.ts @@ -41,5 +41,5 @@ export async function updateEntityDefinition( id: string, definition: Partial ) { - await soClient.update(SO_ENTITY_DEFINITION_TYPE, id, definition); + return await soClient.update(SO_ENTITY_DEFINITION_TYPE, id, definition); } diff --git a/x-pack/plugins/entity_manager/server/lib/entities/start_transforms.ts b/x-pack/plugins/entity_manager/server/lib/entities/start_transforms.ts index ea2ec7adb5ddc..f4cd8fc89dd11 100644 --- a/x-pack/plugins/entity_manager/server/lib/entities/start_transforms.ts +++ b/x-pack/plugins/entity_manager/server/lib/entities/start_transforms.ts @@ -7,13 +7,7 @@ import { ElasticsearchClient, Logger } from '@kbn/core/server'; import { EntityDefinition } from '@kbn/entities-schema'; -import { - generateHistoryBackfillTransformId, - generateHistoryTransformId, - generateLatestTransformId, -} from './helpers/generate_component_id'; import { retryTransientEsErrors } from './helpers/retry'; -import { isBackfillEnabled } from './helpers/is_backfill_enabled'; export async function startTransforms( esClient: ElasticsearchClient, @@ -21,28 +15,15 @@ export async function startTransforms( logger: Logger ) { try { - const historyTransformId = generateHistoryTransformId(definition); - const latestTransformId = generateLatestTransformId(definition); - await retryTransientEsErrors( - () => - esClient.transform.startTransform({ transform_id: historyTransformId }, { ignore: [409] }), - { logger } - ); - if (isBackfillEnabled(definition)) { - const historyBackfillTransformId = generateHistoryBackfillTransformId(definition); - await retryTransientEsErrors( - () => - esClient.transform.startTransform( - { transform_id: historyBackfillTransformId }, - { ignore: [409] } - ), - { logger } - ); - } - await retryTransientEsErrors( - () => - esClient.transform.startTransform({ transform_id: latestTransformId }, { ignore: [409] }), - { logger } + await Promise.all( + (definition.installedComponents ?? []) + .filter(({ type }) => type === 'transform') + .map(({ id }) => + retryTransientEsErrors( + () => esClient.transform.startTransform({ transform_id: id }, { ignore: [409] }), + { logger } + ) + ) ); } catch (err) { logger.error(`Cannot start entity transforms [${definition.id}]: ${err}`); diff --git a/x-pack/plugins/entity_manager/server/lib/entities/stop_transforms.ts b/x-pack/plugins/entity_manager/server/lib/entities/stop_transforms.ts index 98f9ad351e377..9aabad926b239 100644 --- a/x-pack/plugins/entity_manager/server/lib/entities/stop_transforms.ts +++ b/x-pack/plugins/entity_manager/server/lib/entities/stop_transforms.ts @@ -8,14 +8,8 @@ import { ElasticsearchClient, Logger } from '@kbn/core/server'; import { EntityDefinition } from '@kbn/entities-schema'; -import { - generateHistoryTransformId, - generateHistoryBackfillTransformId, - generateLatestTransformId, -} from './helpers/generate_component_id'; import { retryTransientEsErrors } from './helpers/retry'; - -import { isBackfillEnabled } from './helpers/is_backfill_enabled'; +import { generateLatestTransformId } from './helpers/generate_component_id'; export async function stopTransforms( esClient: ElasticsearchClient, @@ -23,43 +17,46 @@ export async function stopTransforms( logger: Logger ) { try { - const historyTransformId = generateHistoryTransformId(definition); - const latestTransformId = generateLatestTransformId(definition); - - await retryTransientEsErrors( - () => - esClient.transform.stopTransform( - { transform_id: historyTransformId, wait_for_completion: true, force: true }, - { ignore: [409, 404] } - ), - { logger } + await Promise.all( + (definition.installedComponents ?? []) + .filter(({ type }) => type === 'transform') + .map(({ id }) => + retryTransientEsErrors( + () => + esClient.transform.stopTransform( + { transform_id: id, wait_for_completion: true, force: true }, + { ignore: [409, 404] } + ), + { logger } + ) + ) ); + } catch (e) { + logger.error(`Cannot stop transforms for definition [${definition.id}]: ${e}`); + throw e; + } +} - if (isBackfillEnabled(definition)) { - const historyBackfillTransformId = generateHistoryBackfillTransformId(definition); - await retryTransientEsErrors( - () => - esClient.transform.stopTransform( - { - transform_id: historyBackfillTransformId, - wait_for_completion: true, - force: true, - }, - { ignore: [409, 404] } - ), - { logger } - ); - } +export async function stopLatestTransform( + esClient: ElasticsearchClient, + definition: EntityDefinition, + logger: Logger +) { + try { await retryTransientEsErrors( () => esClient.transform.stopTransform( - { transform_id: latestTransformId, wait_for_completion: true, force: true }, + { + transform_id: generateLatestTransformId(definition), + wait_for_completion: true, + force: true, + }, { ignore: [409, 404] } ), { logger } ); } catch (e) { - logger.error(`Cannot stop entity transforms [${definition.id}]: ${e}`); + logger.error(`Cannot stop latest transform for definition [${definition.id}]: ${e}`); throw e; } } diff --git a/x-pack/plugins/entity_manager/server/lib/entities/templates/__snapshots__/entities_history_template.test.ts.snap b/x-pack/plugins/entity_manager/server/lib/entities/templates/__snapshots__/entities_history_template.test.ts.snap deleted file mode 100644 index fd4ed11f8cb94..0000000000000 --- a/x-pack/plugins/entity_manager/server/lib/entities/templates/__snapshots__/entities_history_template.test.ts.snap +++ /dev/null @@ -1,152 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`generateEntitiesHistoryIndexTemplateConfig(definition) should generate a valid index template for builtin definition 1`] = ` -Object { - "_meta": Object { - "description": "Index template for indices managed by the Elastic Entity Model's entity discovery framework for the history dataset", - "ecs_version": "8.0.0", - "managed": true, - "managed_by": "elastic_entity_model", - }, - "composed_of": Array [ - "entities_v1_history_base", - "entities_v1_entity", - "entities_v1_event", - ], - "ignore_missing_component_templates": Array [], - "index_patterns": Array [ - ".entities.v1.history.builtin_mock_entity_definition.*", - ], - "name": "entities_v1_history_builtin_mock_entity_definition_index_template", - "priority": 200, - "template": Object { - "aliases": Object { - "entities-service-history": Object {}, - }, - "mappings": Object { - "_meta": Object { - "version": "1.6.0", - }, - "date_detection": false, - "dynamic_templates": Array [ - Object { - "strings_as_keyword": Object { - "mapping": Object { - "fields": Object { - "text": Object { - "type": "text", - }, - }, - "ignore_above": 1024, - "type": "keyword", - }, - "match_mapping_type": "string", - }, - }, - Object { - "entity_metrics": Object { - "mapping": Object { - "type": "{dynamic_type}", - }, - "match_mapping_type": Array [ - "long", - "double", - ], - "path_match": "entity.metrics.*", - }, - }, - ], - }, - "settings": Object { - "index": Object { - "codec": "best_compression", - "mapping": Object { - "total_fields": Object { - "limit": 2000, - }, - }, - }, - }, - }, -} -`; - -exports[`generateEntitiesHistoryIndexTemplateConfig(definition) should generate a valid index template for custom definition 1`] = ` -Object { - "_meta": Object { - "description": "Index template for indices managed by the Elastic Entity Model's entity discovery framework for the history dataset", - "ecs_version": "8.0.0", - "managed": true, - "managed_by": "elastic_entity_model", - }, - "composed_of": Array [ - "entities_v1_history_base", - "entities_v1_entity", - "entities_v1_event", - "admin-console-services@platform", - "admin-console-services-history@platform", - "admin-console-services@custom", - "admin-console-services-history@custom", - ], - "ignore_missing_component_templates": Array [ - "admin-console-services@platform", - "admin-console-services-history@platform", - "admin-console-services@custom", - "admin-console-services-history@custom", - ], - "index_patterns": Array [ - ".entities.v1.history.admin-console-services.*", - ], - "name": "entities_v1_history_admin-console-services_index_template", - "priority": 200, - "template": Object { - "aliases": Object { - "entities-service-history": Object {}, - }, - "mappings": Object { - "_meta": Object { - "version": "1.6.0", - }, - "date_detection": false, - "dynamic_templates": Array [ - Object { - "strings_as_keyword": Object { - "mapping": Object { - "fields": Object { - "text": Object { - "type": "text", - }, - }, - "ignore_above": 1024, - "type": "keyword", - }, - "match_mapping_type": "string", - }, - }, - Object { - "entity_metrics": Object { - "mapping": Object { - "type": "{dynamic_type}", - }, - "match_mapping_type": Array [ - "long", - "double", - ], - "path_match": "entity.metrics.*", - }, - }, - ], - }, - "settings": Object { - "index": Object { - "codec": "best_compression", - "mapping": Object { - "total_fields": Object { - "limit": 2000, - }, - }, - }, - }, - }, -} -`; diff --git a/x-pack/plugins/entity_manager/server/lib/entities/templates/entities_history_template.test.ts b/x-pack/plugins/entity_manager/server/lib/entities/templates/entities_history_template.test.ts deleted file mode 100644 index 72e8d8591ab2d..0000000000000 --- a/x-pack/plugins/entity_manager/server/lib/entities/templates/entities_history_template.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { entityDefinition, builtInEntityDefinition } from '../helpers/fixtures'; -import { generateEntitiesHistoryIndexTemplateConfig } from './entities_history_template'; - -describe('generateEntitiesHistoryIndexTemplateConfig(definition)', () => { - it('should generate a valid index template for custom definition', () => { - const template = generateEntitiesHistoryIndexTemplateConfig(entityDefinition); - expect(template).toMatchSnapshot(); - }); - - it('should generate a valid index template for builtin definition', () => { - const template = generateEntitiesHistoryIndexTemplateConfig(builtInEntityDefinition); - expect(template).toMatchSnapshot(); - }); -}); diff --git a/x-pack/plugins/entity_manager/server/lib/entities/templates/entities_history_template.ts b/x-pack/plugins/entity_manager/server/lib/entities/templates/entities_history_template.ts deleted file mode 100644 index b1539d8108a6d..0000000000000 --- a/x-pack/plugins/entity_manager/server/lib/entities/templates/entities_history_template.ts +++ /dev/null @@ -1,96 +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 { IndicesPutIndexTemplateRequest } from '@elastic/elasticsearch/lib/api/types'; -import { - ENTITY_HISTORY, - EntityDefinition, - entitiesIndexPattern, - entitiesAliasPattern, - ENTITY_SCHEMA_VERSION_V1, -} from '@kbn/entities-schema'; -import { generateHistoryIndexTemplateId } from '../helpers/generate_component_id'; -import { - ENTITY_ENTITY_COMPONENT_TEMPLATE_V1, - ENTITY_EVENT_COMPONENT_TEMPLATE_V1, - ENTITY_HISTORY_BASE_COMPONENT_TEMPLATE_V1, -} from '../../../../common/constants_entities'; -import { getCustomHistoryTemplateComponents } from '../../../templates/components/helpers'; - -export const generateEntitiesHistoryIndexTemplateConfig = ( - definition: EntityDefinition -): IndicesPutIndexTemplateRequest => ({ - name: generateHistoryIndexTemplateId(definition), - _meta: { - description: - "Index template for indices managed by the Elastic Entity Model's entity discovery framework for the history dataset", - ecs_version: '8.0.0', - managed: true, - managed_by: 'elastic_entity_model', - }, - ignore_missing_component_templates: getCustomHistoryTemplateComponents(definition), - composed_of: [ - ENTITY_HISTORY_BASE_COMPONENT_TEMPLATE_V1, - ENTITY_ENTITY_COMPONENT_TEMPLATE_V1, - ENTITY_EVENT_COMPONENT_TEMPLATE_V1, - ...getCustomHistoryTemplateComponents(definition), - ], - index_patterns: [ - `${entitiesIndexPattern({ - schemaVersion: ENTITY_SCHEMA_VERSION_V1, - dataset: ENTITY_HISTORY, - definitionId: definition.id, - })}.*`, - ], - priority: 200, - template: { - aliases: { - [entitiesAliasPattern({ type: definition.type, dataset: ENTITY_HISTORY })]: {}, - }, - mappings: { - _meta: { - version: '1.6.0', - }, - date_detection: false, - dynamic_templates: [ - { - strings_as_keyword: { - mapping: { - ignore_above: 1024, - type: 'keyword', - fields: { - text: { - type: 'text', - }, - }, - }, - match_mapping_type: 'string', - }, - }, - { - entity_metrics: { - mapping: { - type: '{dynamic_type}', - }, - match_mapping_type: ['long', 'double'], - path_match: 'entity.metrics.*', - }, - }, - ], - }, - settings: { - index: { - codec: 'best_compression', - mapping: { - total_fields: { - limit: 2000, - }, - }, - }, - }, - }, -}); diff --git a/x-pack/plugins/entity_manager/server/lib/entities/templates/entities_latest_template.ts b/x-pack/plugins/entity_manager/server/lib/entities/templates/entities_latest_template.ts index ea476cf769644..e0c02c7471217 100644 --- a/x-pack/plugins/entity_manager/server/lib/entities/templates/entities_latest_template.ts +++ b/x-pack/plugins/entity_manager/server/lib/entities/templates/entities_latest_template.ts @@ -19,7 +19,7 @@ import { ENTITY_EVENT_COMPONENT_TEMPLATE_V1, ENTITY_LATEST_BASE_COMPONENT_TEMPLATE_V1, } from '../../../../common/constants_entities'; -import { getCustomLatestTemplateComponents } from '../../../templates/components/helpers'; +import { isBuiltinDefinition } from '../helpers/is_builtin_definition'; export const generateEntitiesLatestIndexTemplateConfig = ( definition: EntityDefinition @@ -94,3 +94,16 @@ export const generateEntitiesLatestIndexTemplateConfig = ( }, }, }); + +function getCustomLatestTemplateComponents(definition: EntityDefinition) { + if (isBuiltinDefinition(definition)) { + return []; + } + + return [ + `${definition.id}@platform`, // @platform goes before so it can be overwritten by custom + `${definition.id}-latest@platform`, + `${definition.id}@custom`, + `${definition.id}-latest@custom`, + ]; +} diff --git a/x-pack/plugins/entity_manager/server/lib/entities/transform/__snapshots__/generate_history_transform.test.ts.snap b/x-pack/plugins/entity_manager/server/lib/entities/transform/__snapshots__/generate_history_transform.test.ts.snap deleted file mode 100644 index b19a805b24b12..0000000000000 --- a/x-pack/plugins/entity_manager/server/lib/entities/transform/__snapshots__/generate_history_transform.test.ts.snap +++ /dev/null @@ -1,305 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`generateHistoryTransform(definition) should generate a valid history backfill transform 1`] = ` -Object { - "_meta": Object { - "definitionVersion": "999.999.999", - "managed": false, - }, - "defer_validation": true, - "dest": Object { - "index": ".entities.v1.history.noop", - "pipeline": "entities-v1-history-admin-console-services-backfill", - }, - "frequency": "5m", - "pivot": Object { - "aggs": Object { - "_errorRate_A": Object { - "filter": Object { - "bool": Object { - "minimum_should_match": 1, - "should": Array [ - Object { - "match_phrase": Object { - "log.level": "ERROR", - }, - }, - ], - }, - }, - }, - "_logRate_A": Object { - "filter": Object { - "bool": Object { - "minimum_should_match": 1, - "should": Array [ - Object { - "exists": Object { - "field": "log.level", - }, - }, - ], - }, - }, - }, - "entity.lastSeenTimestamp": Object { - "max": Object { - "field": "@timestamp", - }, - }, - "entity.metadata.host.name": Object { - "terms": Object { - "field": "host.name", - "size": 1000, - }, - }, - "entity.metadata.host.os.name": Object { - "terms": Object { - "field": "host.os.name", - "size": 1000, - }, - }, - "entity.metadata.sourceIndex": Object { - "terms": Object { - "field": "_index", - "size": 1000, - }, - }, - "entity.metadata.tags": Object { - "terms": Object { - "field": "tags", - "size": 1000, - }, - }, - "entity.metrics.errorRate": Object { - "bucket_script": Object { - "buckets_path": Object { - "A": "_errorRate_A>_count", - }, - "script": Object { - "lang": "painless", - "source": "params.A", - }, - }, - }, - "entity.metrics.logRate": Object { - "bucket_script": Object { - "buckets_path": Object { - "A": "_logRate_A>_count", - }, - "script": Object { - "lang": "painless", - "source": "params.A", - }, - }, - }, - }, - "group_by": Object { - "@timestamp": Object { - "date_histogram": Object { - "field": "@timestamp", - "fixed_interval": "1m", - }, - }, - "entity.identity.event.category": Object { - "terms": Object { - "field": "event.category", - "missing_bucket": true, - }, - }, - "entity.identity.log.logger": Object { - "terms": Object { - "field": "log.logger", - "missing_bucket": false, - }, - }, - }, - }, - "settings": Object { - "deduce_mappings": false, - "unattended": true, - }, - "source": Object { - "index": Array [ - "kbn-data-forge-fake_stack.*", - ], - "query": Object { - "bool": Object { - "filter": Array [ - Object { - "range": Object { - "@timestamp": Object { - "gte": "now-72h", - }, - }, - }, - Object { - "exists": Object { - "field": "log.logger", - }, - }, - ], - }, - }, - }, - "sync": Object { - "time": Object { - "delay": "15m", - "field": "@timestamp", - }, - }, - "transform_id": "entities-v1-history-backfill-admin-console-services-backfill", -} -`; - -exports[`generateHistoryTransform(definition) should generate a valid history transform 1`] = ` -Object { - "_meta": Object { - "definitionVersion": "1.0.0", - "managed": false, - }, - "defer_validation": true, - "dest": Object { - "index": ".entities.v1.history.noop", - "pipeline": "entities-v1-history-admin-console-services", - }, - "frequency": "2m", - "pivot": Object { - "aggs": Object { - "_errorRate_A": Object { - "filter": Object { - "bool": Object { - "minimum_should_match": 1, - "should": Array [ - Object { - "match_phrase": Object { - "log.level": "ERROR", - }, - }, - ], - }, - }, - }, - "_logRate_A": Object { - "filter": Object { - "bool": Object { - "minimum_should_match": 1, - "should": Array [ - Object { - "exists": Object { - "field": "log.level", - }, - }, - ], - }, - }, - }, - "entity.lastSeenTimestamp": Object { - "max": Object { - "field": "@timestamp", - }, - }, - "entity.metadata.host.name": Object { - "terms": Object { - "field": "host.name", - "size": 1000, - }, - }, - "entity.metadata.host.os.name": Object { - "terms": Object { - "field": "host.os.name", - "size": 1000, - }, - }, - "entity.metadata.sourceIndex": Object { - "terms": Object { - "field": "_index", - "size": 1000, - }, - }, - "entity.metadata.tags": Object { - "terms": Object { - "field": "tags", - "size": 1000, - }, - }, - "entity.metrics.errorRate": Object { - "bucket_script": Object { - "buckets_path": Object { - "A": "_errorRate_A>_count", - }, - "script": Object { - "lang": "painless", - "source": "params.A", - }, - }, - }, - "entity.metrics.logRate": Object { - "bucket_script": Object { - "buckets_path": Object { - "A": "_logRate_A>_count", - }, - "script": Object { - "lang": "painless", - "source": "params.A", - }, - }, - }, - }, - "group_by": Object { - "@timestamp": Object { - "date_histogram": Object { - "field": "@timestamp", - "fixed_interval": "1m", - }, - }, - "entity.identity.event.category": Object { - "terms": Object { - "field": "event.category", - "missing_bucket": true, - }, - }, - "entity.identity.log.logger": Object { - "terms": Object { - "field": "log.logger", - "missing_bucket": false, - }, - }, - }, - }, - "settings": Object { - "deduce_mappings": false, - "unattended": true, - }, - "source": Object { - "index": Array [ - "kbn-data-forge-fake_stack.*", - ], - "query": Object { - "bool": Object { - "filter": Array [ - Object { - "exists": Object { - "field": "log.logger", - }, - }, - Object { - "range": Object { - "@timestamp": Object { - "gte": "now-10m", - }, - }, - }, - ], - }, - }, - }, - "sync": Object { - "time": Object { - "delay": "2m", - "field": "@timestamp", - }, - }, - "transform_id": "entities-v1-history-admin-console-services", -} -`; diff --git a/x-pack/plugins/entity_manager/server/lib/entities/transform/__snapshots__/generate_latest_transform.test.ts.snap b/x-pack/plugins/entity_manager/server/lib/entities/transform/__snapshots__/generate_latest_transform.test.ts.snap index ab1224525f4d7..49f8ff4536120 100644 --- a/x-pack/plugins/entity_manager/server/lib/entities/transform/__snapshots__/generate_latest_transform.test.ts.snap +++ b/x-pack/plugins/entity_manager/server/lib/entities/transform/__snapshots__/generate_latest_transform.test.ts.snap @@ -14,76 +14,37 @@ Object { "frequency": "30s", "pivot": Object { "aggs": Object { - "_errorRate": Object { - "top_metrics": Object { - "metrics": Array [ - Object { - "field": "entity.metrics.errorRate", - }, - ], - "sort": Array [ - Object { - "@timestamp": "desc", - }, - ], - }, - }, - "_logRate": Object { - "top_metrics": Object { - "metrics": Array [ - Object { - "field": "entity.metrics.logRate", - }, - ], - "sort": Array [ - Object { - "@timestamp": "desc", - }, - ], - }, - }, - "entity.firstSeenTimestamp": Object { - "min": Object { - "field": "@timestamp", - }, - }, - "entity.identity.event.category": Object { - "aggs": Object { - "top_metric": Object { - "top_metrics": Object { - "metrics": Object { - "field": "event.category", - }, - "sort": "_score", - }, - }, - }, + "_errorRate_A": Object { "filter": Object { - "exists": Object { - "field": "event.category", - }, - }, - }, - "entity.identity.log.logger": Object { - "aggs": Object { - "top_metric": Object { - "top_metrics": Object { - "metrics": Object { - "field": "log.logger", + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match_phrase": Object { + "log.level": "ERROR", + }, }, - "sort": "_score", - }, + ], }, }, + }, + "_logRate_A": Object { "filter": Object { - "exists": Object { - "field": "log.logger", + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "exists": Object { + "field": "log.level", + }, + }, + ], }, }, }, "entity.lastSeenTimestamp": Object { "max": Object { - "field": "entity.lastSeenTimestamp", + "field": "@timestamp", }, }, "entity.metadata.host.name": Object { @@ -91,14 +52,14 @@ Object { "data": Object { "terms": Object { "field": "host.name", - "size": 1000, + "size": 10, }, }, }, "filter": Object { "range": Object { "@timestamp": Object { - "gte": "now-360s", + "gte": "now-10m", }, }, }, @@ -108,14 +69,14 @@ Object { "data": Object { "terms": Object { "field": "host.os.name", - "size": 1000, + "size": 10, }, }, }, "filter": Object { "range": Object { "@timestamp": Object { - "gte": "now-360s", + "gte": "now-10m", }, }, }, @@ -124,15 +85,15 @@ Object { "aggs": Object { "data": Object { "terms": Object { - "field": "sourceIndex", - "size": 1000, + "field": "_index", + "size": 10, }, }, }, "filter": Object { "range": Object { "@timestamp": Object { - "gte": "now-360s", + "gte": "now-10m", }, }, }, @@ -142,14 +103,14 @@ Object { "data": Object { "terms": Object { "field": "tags", - "size": 1000, + "size": 10, }, }, }, "filter": Object { "range": Object { "@timestamp": Object { - "gte": "now-360s", + "gte": "now-10m", }, }, }, @@ -157,24 +118,37 @@ Object { "entity.metrics.errorRate": Object { "bucket_script": Object { "buckets_path": Object { - "value": "_errorRate[entity.metrics.errorRate]", + "A": "_errorRate_A>_count", + }, + "script": Object { + "lang": "painless", + "source": "params.A", }, - "script": "params.value", }, }, "entity.metrics.logRate": Object { "bucket_script": Object { "buckets_path": Object { - "value": "_logRate[entity.metrics.logRate]", + "A": "_logRate_A>_count", + }, + "script": Object { + "lang": "painless", + "source": "params.A", }, - "script": "params.value", }, }, }, "group_by": Object { - "entity.id": Object { + "entity.identity.event.category": Object { + "terms": Object { + "field": "event.category", + "missing_bucket": true, + }, + }, + "entity.identity.log.logger": Object { "terms": Object { - "field": "entity.id", + "field": "log.logger", + "missing_bucket": false, }, }, }, @@ -184,12 +158,32 @@ Object { "unattended": true, }, "source": Object { - "index": ".entities.v1.history.admin-console-services.*", + "index": Array [ + "kbn-data-forge-fake_stack.*", + ], + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "exists": Object { + "field": "log.logger", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "gte": "now-10m", + }, + }, + }, + ], + }, + }, }, "sync": Object { "time": Object { - "delay": "1s", - "field": "event.ingested", + "delay": "10s", + "field": "@timestamp", }, }, "transform_id": "entities-v1-latest-admin-console-services", diff --git a/x-pack/plugins/entity_manager/server/lib/entities/transform/generate_history_transform.test.ts b/x-pack/plugins/entity_manager/server/lib/entities/transform/generate_history_transform.test.ts deleted file mode 100644 index f49ec0cd88a37..0000000000000 --- a/x-pack/plugins/entity_manager/server/lib/entities/transform/generate_history_transform.test.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 { entityDefinition } from '../helpers/fixtures/entity_definition'; -import { entityDefinitionWithBackfill } from '../helpers/fixtures/entity_definition_with_backfill'; -import { - generateBackfillHistoryTransform, - generateHistoryTransform, -} from './generate_history_transform'; - -describe('generateHistoryTransform(definition)', () => { - it('should generate a valid history transform', () => { - const transform = generateHistoryTransform(entityDefinition); - expect(transform).toMatchSnapshot(); - }); - it('should generate a valid history backfill transform', () => { - const transform = generateBackfillHistoryTransform(entityDefinitionWithBackfill); - expect(transform).toMatchSnapshot(); - }); -}); diff --git a/x-pack/plugins/entity_manager/server/lib/entities/transform/generate_history_transform.ts b/x-pack/plugins/entity_manager/server/lib/entities/transform/generate_history_transform.ts deleted file mode 100644 index 239359738624c..0000000000000 --- a/x-pack/plugins/entity_manager/server/lib/entities/transform/generate_history_transform.ts +++ /dev/null @@ -1,178 +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 { EntityDefinition } from '@kbn/entities-schema'; -import { - QueryDslQueryContainer, - TransformPutTransformRequest, -} from '@elastic/elasticsearch/lib/api/types'; -import { getElasticsearchQueryOrThrow } from '../helpers/get_elasticsearch_query_or_throw'; -import { generateHistoryMetricAggregations } from './generate_metric_aggregations'; -import { - ENTITY_DEFAULT_HISTORY_FREQUENCY, - ENTITY_DEFAULT_HISTORY_SYNC_DELAY, -} from '../../../../common/constants_entities'; -import { generateHistoryMetadataAggregations } from './generate_metadata_aggregations'; -import { - generateHistoryTransformId, - generateHistoryIngestPipelineId, - generateHistoryIndexName, - generateHistoryBackfillTransformId, -} from '../helpers/generate_component_id'; -import { isBackfillEnabled } from '../helpers/is_backfill_enabled'; - -export function generateHistoryTransform( - definition: EntityDefinition -): TransformPutTransformRequest { - const filter: QueryDslQueryContainer[] = []; - - if (definition.filter) { - filter.push(getElasticsearchQueryOrThrow(definition.filter)); - } - - if (definition.identityFields.some(({ optional }) => !optional)) { - definition.identityFields - .filter(({ optional }) => !optional) - .forEach(({ field }) => { - filter.push({ exists: { field } }); - }); - } - - filter.push({ - range: { - [definition.history.timestampField]: { - gte: `now-${definition.history.settings.lookbackPeriod}`, - }, - }, - }); - - return generateTransformPutRequest({ - definition, - filter, - transformId: generateHistoryTransformId(definition), - frequency: definition.history.settings.frequency, - syncDelay: definition.history.settings.syncDelay, - }); -} - -export function generateBackfillHistoryTransform( - definition: EntityDefinition -): TransformPutTransformRequest { - if (!isBackfillEnabled(definition)) { - throw new Error( - 'generateBackfillHistoryTransform called without history.settings.backfillSyncDelay set' - ); - } - - const filter: QueryDslQueryContainer[] = []; - - if (definition.filter) { - filter.push(getElasticsearchQueryOrThrow(definition.filter)); - } - - if (definition.history.settings.backfillLookbackPeriod) { - filter.push({ - range: { - [definition.history.timestampField]: { - gte: `now-${definition.history.settings.backfillLookbackPeriod}`, - }, - }, - }); - } - - if (definition.identityFields.some(({ optional }) => !optional)) { - definition.identityFields - .filter(({ optional }) => !optional) - .forEach(({ field }) => { - filter.push({ exists: { field } }); - }); - } - - return generateTransformPutRequest({ - definition, - filter, - transformId: generateHistoryBackfillTransformId(definition), - frequency: definition.history.settings.backfillFrequency, - syncDelay: definition.history.settings.backfillSyncDelay, - }); -} - -const generateTransformPutRequest = ({ - definition, - filter, - transformId, - frequency, - syncDelay, -}: { - definition: EntityDefinition; - transformId: string; - filter: QueryDslQueryContainer[]; - frequency?: string; - syncDelay?: string; -}) => { - return { - transform_id: transformId, - _meta: { - definitionVersion: definition.version, - managed: definition.managed, - }, - defer_validation: true, - source: { - index: definition.indexPatterns, - ...(filter.length > 0 && { - query: { - bool: { - filter, - }, - }, - }), - }, - dest: { - index: `${generateHistoryIndexName({ id: 'noop' } as EntityDefinition)}`, - pipeline: generateHistoryIngestPipelineId(definition), - }, - frequency: frequency || ENTITY_DEFAULT_HISTORY_FREQUENCY, - sync: { - time: { - field: definition.history.settings.syncField || definition.history.timestampField, - delay: syncDelay || ENTITY_DEFAULT_HISTORY_SYNC_DELAY, - }, - }, - settings: { - deduce_mappings: false, - unattended: true, - }, - pivot: { - group_by: { - ...definition.identityFields.reduce( - (acc, id) => ({ - ...acc, - [`entity.identity.${id.field}`]: { - terms: { field: id.field, missing_bucket: id.optional }, - }, - }), - {} - ), - ['@timestamp']: { - date_histogram: { - field: definition.history.timestampField, - fixed_interval: definition.history.interval, - }, - }, - }, - aggs: { - ...generateHistoryMetricAggregations(definition), - ...generateHistoryMetadataAggregations(definition), - 'entity.lastSeenTimestamp': { - max: { - field: definition.history.timestampField, - }, - }, - }, - }, - }; -}; diff --git a/x-pack/plugins/entity_manager/server/lib/entities/transform/generate_latest_transform.ts b/x-pack/plugins/entity_manager/server/lib/entities/transform/generate_latest_transform.ts index 85ee57fefea2c..573bb2225f183 100644 --- a/x-pack/plugins/entity_manager/server/lib/entities/transform/generate_latest_transform.ts +++ b/x-pack/plugins/entity_manager/server/lib/entities/transform/generate_latest_transform.ts @@ -5,44 +5,97 @@ * 2.0. */ -import { TransformPutTransformRequest } from '@elastic/elasticsearch/lib/api/types'; import { EntityDefinition } from '@kbn/entities-schema'; +import { + QueryDslQueryContainer, + TransformPutTransformRequest, +} from '@elastic/elasticsearch/lib/api/types'; +import { getElasticsearchQueryOrThrow } from '../helpers/get_elasticsearch_query_or_throw'; +import { generateLatestMetricAggregations } from './generate_metric_aggregations'; import { ENTITY_DEFAULT_LATEST_FREQUENCY, ENTITY_DEFAULT_LATEST_SYNC_DELAY, } from '../../../../common/constants_entities'; import { - generateHistoryIndexName, - generateLatestIndexName, - generateLatestIngestPipelineId, generateLatestTransformId, + generateLatestIngestPipelineId, + generateLatestIndexName, } from '../helpers/generate_component_id'; -import { generateIdentityAggregations } from './generate_identity_aggregations'; import { generateLatestMetadataAggregations } from './generate_metadata_aggregations'; -import { generateLatestMetricAggregations } from './generate_metric_aggregations'; export function generateLatestTransform( definition: EntityDefinition ): TransformPutTransformRequest { + const filter: QueryDslQueryContainer[] = []; + + if (definition.filter) { + filter.push(getElasticsearchQueryOrThrow(definition.filter)); + } + + if (definition.identityFields.some(({ optional }) => !optional)) { + definition.identityFields + .filter(({ optional }) => !optional) + .forEach(({ field }) => { + filter.push({ exists: { field } }); + }); + } + + filter.push({ + range: { + [definition.latest.timestampField]: { + gte: `now-${definition.latest.lookbackPeriod}`, + }, + }, + }); + + return generateTransformPutRequest({ + definition, + filter, + transformId: generateLatestTransformId(definition), + frequency: definition.latest.settings?.frequency ?? ENTITY_DEFAULT_LATEST_FREQUENCY, + syncDelay: definition.latest.settings?.syncDelay ?? ENTITY_DEFAULT_LATEST_SYNC_DELAY, + }); +} + +const generateTransformPutRequest = ({ + definition, + filter, + transformId, + frequency, + syncDelay, +}: { + definition: EntityDefinition; + transformId: string; + filter: QueryDslQueryContainer[]; + frequency: string; + syncDelay: string; +}) => { return { - transform_id: generateLatestTransformId(definition), + transform_id: transformId, _meta: { definitionVersion: definition.version, managed: definition.managed, }, defer_validation: true, source: { - index: `${generateHistoryIndexName(definition)}.*`, + index: definition.indexPatterns, + ...(filter.length > 0 && { + query: { + bool: { + filter, + }, + }, + }), }, dest: { index: `${generateLatestIndexName({ id: 'noop' } as EntityDefinition)}`, pipeline: generateLatestIngestPipelineId(definition), }, - frequency: definition.latest?.settings?.frequency ?? ENTITY_DEFAULT_LATEST_FREQUENCY, + frequency, sync: { time: { - field: definition.latest?.settings?.syncField ?? 'event.ingested', - delay: definition.latest?.settings?.syncDelay ?? ENTITY_DEFAULT_LATEST_SYNC_DELAY, + field: definition.latest.settings?.syncField || definition.latest.timestampField, + delay: syncDelay, }, }, settings: { @@ -51,25 +104,25 @@ export function generateLatestTransform( }, pivot: { group_by: { - ['entity.id']: { - terms: { field: 'entity.id' }, - }, + ...definition.identityFields.reduce( + (acc, id) => ({ + ...acc, + [`entity.identity.${id.field}`]: { + terms: { field: id.field, missing_bucket: id.optional }, + }, + }), + {} + ), }, aggs: { ...generateLatestMetricAggregations(definition), ...generateLatestMetadataAggregations(definition), - ...generateIdentityAggregations(definition), 'entity.lastSeenTimestamp': { max: { - field: 'entity.lastSeenTimestamp', - }, - }, - 'entity.firstSeenTimestamp': { - min: { - field: '@timestamp', + field: definition.latest.timestampField, }, }, }, }, }; -} +}; diff --git a/x-pack/plugins/entity_manager/server/lib/entities/transform/generate_metadata_aggregations.test.ts b/x-pack/plugins/entity_manager/server/lib/entities/transform/generate_metadata_aggregations.test.ts index 7746be66f5033..12535d313143b 100644 --- a/x-pack/plugins/entity_manager/server/lib/entities/transform/generate_metadata_aggregations.test.ts +++ b/x-pack/plugins/entity_manager/server/lib/entities/transform/generate_metadata_aggregations.test.ts @@ -7,134 +7,22 @@ import { entityDefinitionSchema } from '@kbn/entities-schema'; import { rawEntityDefinition } from '../helpers/fixtures/entity_definition'; -import { - generateHistoryMetadataAggregations, - generateLatestMetadataAggregations, -} from './generate_metadata_aggregations'; +import { generateLatestMetadataAggregations } from './generate_metadata_aggregations'; describe('Generate Metadata Aggregations for history and latest', () => { - describe('generateHistoryMetadataAggregations()', () => { - it('should generate metadata aggregations for string format', () => { - const definition = entityDefinitionSchema.parse({ - ...rawEntityDefinition, - metadata: ['host.name'], - }); - expect(generateHistoryMetadataAggregations(definition)).toEqual({ - 'entity.metadata.host.name': { - terms: { - field: 'host.name', - size: 1000, - }, - }, - }); - }); - - it('should generate metadata aggregations for object format with only source', () => { - const definition = entityDefinitionSchema.parse({ - ...rawEntityDefinition, - metadata: [{ source: 'host.name' }], - }); - expect(generateHistoryMetadataAggregations(definition)).toEqual({ - 'entity.metadata.host.name': { - terms: { - field: 'host.name', - size: 1000, - }, - }, - }); - }); - - it('should generate metadata aggregations for object format with source and aggregation', () => { - const definition = entityDefinitionSchema.parse({ - ...rawEntityDefinition, - metadata: [{ source: 'host.name', aggregation: { type: 'terms', limit: 10 } }], - }); - expect(generateHistoryMetadataAggregations(definition)).toEqual({ - 'entity.metadata.host.name': { - terms: { - field: 'host.name', - size: 10, - }, - }, - }); - }); - - it('should generate metadata aggregations for object format with source, aggregation, and destination', () => { - const definition = entityDefinitionSchema.parse({ - ...rawEntityDefinition, - metadata: [ - { - source: 'host.name', - aggregation: { type: 'terms', limit: 20 }, - destination: 'hostName', - }, - ], - }); - expect(generateHistoryMetadataAggregations(definition)).toEqual({ - 'entity.metadata.hostName': { - terms: { - field: 'host.name', - size: 20, - }, - }, - }); - }); - - it('should generate metadata aggregations for terms and top_value', () => { - const definition = entityDefinitionSchema.parse({ - ...rawEntityDefinition, - metadata: [ - { - source: 'host.name', - aggregation: { type: 'terms', limit: 10 }, - destination: 'hostName', - }, - { - source: 'agent.name', - aggregation: { type: 'top_value', sort: { '@timestamp': 'desc' } }, - destination: 'agentName', - }, - ], - }); - - expect(generateHistoryMetadataAggregations(definition)).toEqual({ - 'entity.metadata.hostName': { - terms: { - field: 'host.name', - size: 10, - }, - }, - 'entity.metadata.agentName': { - filter: { - exists: { - field: 'agent.name', - }, - }, - aggs: { - top_value: { - top_metrics: { - metrics: { field: 'agent.name' }, - sort: { '@timestamp': 'desc' }, - }, - }, - }, - }, - }); - }); - }); - describe('generateLatestMetadataAggregations()', () => { it('should generate metadata aggregations for string format', () => { const definition = entityDefinitionSchema.parse({ ...rawEntityDefinition, metadata: ['host.name'], }); + expect(generateLatestMetadataAggregations(definition)).toEqual({ 'entity.metadata.host.name': { filter: { range: { '@timestamp': { - gte: 'now-360s', + gte: 'now-10m', }, }, }, @@ -142,7 +30,7 @@ describe('Generate Metadata Aggregations for history and latest', () => { data: { terms: { field: 'host.name', - size: 1000, + size: 10, }, }, }, @@ -160,7 +48,7 @@ describe('Generate Metadata Aggregations for history and latest', () => { filter: { range: { '@timestamp': { - gte: 'now-360s', + gte: 'now-10m', }, }, }, @@ -168,7 +56,7 @@ describe('Generate Metadata Aggregations for history and latest', () => { data: { terms: { field: 'host.name', - size: 1000, + size: 10, }, }, }, @@ -179,14 +67,16 @@ describe('Generate Metadata Aggregations for history and latest', () => { it('should generate metadata aggregations for object format with source and aggregation', () => { const definition = entityDefinitionSchema.parse({ ...rawEntityDefinition, - metadata: [{ source: 'host.name', aggregation: { type: 'terms', limit: 10 } }], + metadata: [ + { source: 'host.name', aggregation: { type: 'terms', limit: 10, lookbackPeriod: '1h' } }, + ], }); expect(generateLatestMetadataAggregations(definition)).toEqual({ 'entity.metadata.host.name': { filter: { range: { '@timestamp': { - gte: 'now-360s', + gte: 'now-1h', }, }, }, @@ -218,14 +108,14 @@ describe('Generate Metadata Aggregations for history and latest', () => { filter: { range: { '@timestamp': { - gte: 'now-360s', + gte: 'now-10m', }, }, }, aggs: { data: { terms: { - field: 'hostName', + field: 'host.name', size: 10, }, }, @@ -255,14 +145,14 @@ describe('Generate Metadata Aggregations for history and latest', () => { filter: { range: { '@timestamp': { - gte: 'now-360s', + gte: 'now-10m', }, }, }, aggs: { data: { terms: { - field: 'hostName', + field: 'host.name', size: 10, }, }, @@ -275,13 +165,13 @@ describe('Generate Metadata Aggregations for history and latest', () => { { range: { '@timestamp': { - gte: 'now-360s', + gte: 'now-10m', }, }, }, { exists: { - field: 'agentName', + field: 'agent.name', }, }, ], @@ -291,7 +181,7 @@ describe('Generate Metadata Aggregations for history and latest', () => { top_value: { top_metrics: { metrics: { - field: 'agentName', + field: 'agent.name', }, sort: { '@timestamp': 'desc', diff --git a/x-pack/plugins/entity_manager/server/lib/entities/transform/generate_metadata_aggregations.ts b/x-pack/plugins/entity_manager/server/lib/entities/transform/generate_metadata_aggregations.ts index 0fc4464672219..796d1e25b55ec 100644 --- a/x-pack/plugins/entity_manager/server/lib/entities/transform/generate_metadata_aggregations.ts +++ b/x-pack/plugins/entity_manager/server/lib/entities/transform/generate_metadata_aggregations.ts @@ -6,70 +6,28 @@ */ import { EntityDefinition } from '@kbn/entities-schema'; -import { calculateOffset } from '../helpers/calculate_offset'; - -export function generateHistoryMetadataAggregations(definition: EntityDefinition) { - if (!definition.metadata) { - return {}; - } - return definition.metadata.reduce((aggs, metadata) => { - let agg; - if (metadata.aggregation.type === 'terms') { - agg = { - terms: { - field: metadata.source, - size: metadata.aggregation.limit, - }, - }; - } else if (metadata.aggregation.type === 'top_value') { - agg = { - filter: { - exists: { - field: metadata.source, - }, - }, - aggs: { - top_value: { - top_metrics: { - metrics: { - field: metadata.source, - }, - sort: metadata.aggregation.sort, - }, - }, - }, - }; - } - - return { - ...aggs, - [`entity.metadata.${metadata.destination}`]: agg, - }; - }, {}); -} export function generateLatestMetadataAggregations(definition: EntityDefinition) { if (!definition.metadata) { return {}; } - const offsetInSeconds = `${calculateOffset(definition)}s`; - return definition.metadata.reduce((aggs, metadata) => { + const lookbackPeriod = metadata.aggregation.lookbackPeriod || definition.latest.lookbackPeriod; let agg; if (metadata.aggregation.type === 'terms') { agg = { filter: { range: { '@timestamp': { - gte: `now-${offsetInSeconds}`, + gte: `now-${lookbackPeriod}`, }, }, }, aggs: { data: { terms: { - field: metadata.destination, + field: metadata.source, size: metadata.aggregation.limit, }, }, @@ -83,13 +41,13 @@ export function generateLatestMetadataAggregations(definition: EntityDefinition) { range: { '@timestamp': { - gte: `now-${metadata.aggregation.lookbackPeriod ?? offsetInSeconds}`, + gte: `now-${lookbackPeriod}`, }, }, }, { exists: { - field: metadata.destination, + field: metadata.source, }, }, ], @@ -99,7 +57,7 @@ export function generateLatestMetadataAggregations(definition: EntityDefinition) top_value: { top_metrics: { metrics: { - field: metadata.destination, + field: metadata.source, }, sort: metadata.aggregation.sort, }, diff --git a/x-pack/plugins/entity_manager/server/lib/entities/transform/generate_metric_aggregations.ts b/x-pack/plugins/entity_manager/server/lib/entities/transform/generate_metric_aggregations.ts index bd1af365116cb..d42dd69b37eff 100644 --- a/x-pack/plugins/entity_manager/server/lib/entities/transform/generate_metric_aggregations.ts +++ b/x-pack/plugins/entity_manager/server/lib/entities/transform/generate_metric_aggregations.ts @@ -104,41 +104,15 @@ function buildMetricEquation(keyMetric: KeyMetric) { }; } -export function generateHistoryMetricAggregations(definition: EntityDefinition) { - if (!definition.metrics) { - return {}; - } - return definition.metrics.reduce((aggs, keyMetric) => { - return { - ...aggs, - ...buildMetricAggregations(keyMetric, definition.history.timestampField), - [`entity.metrics.${keyMetric.name}`]: buildMetricEquation(keyMetric), - }; - }, {}); -} - export function generateLatestMetricAggregations(definition: EntityDefinition) { if (!definition.metrics) { return {}; } - return definition.metrics.reduce((aggs, keyMetric) => { return { ...aggs, - [`_${keyMetric.name}`]: { - top_metrics: { - metrics: [{ field: `entity.metrics.${keyMetric.name}` }], - sort: [{ '@timestamp': 'desc' }], - }, - }, - [`entity.metrics.${keyMetric.name}`]: { - bucket_script: { - buckets_path: { - value: `_${keyMetric.name}[entity.metrics.${keyMetric.name}]`, - }, - script: 'params.value', - }, - }, + ...buildMetricAggregations(keyMetric, definition.latest.timestampField), + [`entity.metrics.${keyMetric.name}`]: buildMetricEquation(keyMetric), }; }, {}); } diff --git a/x-pack/plugins/entity_manager/server/lib/entities/transform/validate_transform_ids.ts b/x-pack/plugins/entity_manager/server/lib/entities/transform/validate_transform_ids.ts index c16b7f126dded..c703124bdf082 100644 --- a/x-pack/plugins/entity_manager/server/lib/entities/transform/validate_transform_ids.ts +++ b/x-pack/plugins/entity_manager/server/lib/entities/transform/validate_transform_ids.ts @@ -7,26 +7,14 @@ import { EntityDefinition } from '@kbn/entities-schema'; import { EntityDefinitionIdInvalid } from '../errors/entity_definition_id_invalid'; -import { - generateHistoryBackfillTransformId, - generateHistoryTransformId, - generateLatestTransformId, -} from '../helpers/generate_component_id'; +import { generateLatestTransformId } from '../helpers/generate_component_id'; const TRANSFORM_ID_MAX_LENGTH = 64; export function validateDefinitionCanCreateValidTransformIds(definition: EntityDefinition) { - const historyTransformId = generateHistoryTransformId(definition); const latestTransformId = generateLatestTransformId(definition); - const historyBackfillTransformId = generateHistoryBackfillTransformId(definition); - const spareChars = - TRANSFORM_ID_MAX_LENGTH - - Math.max( - historyTransformId.length, - latestTransformId.length, - historyBackfillTransformId.length - ); + const spareChars = TRANSFORM_ID_MAX_LENGTH - latestTransformId.length; if (spareChars < 0) { throw new EntityDefinitionIdInvalid( diff --git a/x-pack/plugins/entity_manager/server/lib/entities/uninstall_entity_definition.ts b/x-pack/plugins/entity_manager/server/lib/entities/uninstall_entity_definition.ts index 8bc8efa3870aa..d0e0410b6e422 100644 --- a/x-pack/plugins/entity_manager/server/lib/entities/uninstall_entity_definition.ts +++ b/x-pack/plugins/entity_manager/server/lib/entities/uninstall_entity_definition.ts @@ -11,14 +11,10 @@ import { EntityDefinition } from '@kbn/entities-schema'; import { Logger } from '@kbn/logging'; import { deleteEntityDefinition } from './delete_entity_definition'; import { deleteIndices } from './delete_index'; -import { deleteHistoryIngestPipeline, deleteLatestIngestPipeline } from './delete_ingest_pipeline'; +import { deleteIngestPipelines } from './delete_ingest_pipeline'; import { findEntityDefinitions } from './find_entity_definition'; -import { - generateHistoryIndexTemplateId, - generateLatestIndexTemplateId, -} from './helpers/generate_component_id'; -import { deleteTemplate } from '../manage_index_templates'; +import { deleteTemplates } from '../manage_index_templates'; import { stopTransforms } from './stop_transforms'; @@ -40,19 +36,13 @@ export async function uninstallEntityDefinition({ await stopTransforms(esClient, definition, logger); await deleteTransforms(esClient, definition, logger); - await Promise.all([ - deleteHistoryIngestPipeline(esClient, definition, logger), - deleteLatestIngestPipeline(esClient, definition, logger), - ]); + await deleteIngestPipelines(esClient, definition, logger); if (deleteData) { await deleteIndices(esClient, definition, logger); } - await Promise.all([ - deleteTemplate({ esClient, logger, name: generateHistoryIndexTemplateId(definition) }), - deleteTemplate({ esClient, logger, name: generateLatestIndexTemplateId(definition) }), - ]); + await deleteTemplates(esClient, definition, logger); await deleteEntityDefinition(soClient, definition); } 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 ee6b59b0ae0ea..710872c04eda0 100644 --- a/x-pack/plugins/entity_manager/server/lib/entity_client.ts +++ b/x-pack/plugins/entity_manager/server/lib/entity_client.ts @@ -41,7 +41,7 @@ export class EntityClient { }); if (!installOnly) { - await startTransforms(this.options.esClient, definition, this.options.logger); + await startTransforms(this.options.esClient, installedDefinition, this.options.logger); } return installedDefinition; diff --git a/x-pack/plugins/entity_manager/server/lib/manage_index_templates.ts b/x-pack/plugins/entity_manager/server/lib/manage_index_templates.ts index b0789b6cf2769..ffa58cd9c0145 100644 --- a/x-pack/plugins/entity_manager/server/lib/manage_index_templates.ts +++ b/x-pack/plugins/entity_manager/server/lib/manage_index_templates.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { EntityDefinition } from '@kbn/entities-schema'; import { ClusterPutComponentTemplateRequest, IndicesPutIndexTemplateRequest, @@ -15,6 +16,7 @@ import { entitiesLatestBaseComponentTemplateConfig } from '../templates/componen import { entitiesEntityComponentTemplateConfig } from '../templates/components/entity'; import { entitiesEventComponentTemplateConfig } from '../templates/components/event'; import { retryTransientEsErrors } from './entities/helpers/retry'; +import { generateEntitiesLatestIndexTemplateConfig } from './entities/templates/entities_latest_template'; interface TemplateManagementOptions { esClient: ElasticsearchClient; @@ -67,14 +69,27 @@ interface DeleteTemplateOptions { export async function upsertTemplate({ esClient, template, logger }: TemplateManagementOptions) { try { - await retryTransientEsErrors(() => esClient.indices.putIndexTemplate(template), { logger }); + const result = await retryTransientEsErrors(() => esClient.indices.putIndexTemplate(template), { + logger, + }); logger.debug(() => `Installed entity manager index template: ${JSON.stringify(template)}`); + return result; } catch (error: any) { logger.error(`Error updating entity manager index template: ${error.message}`); throw error; } } +export async function createAndInstallTemplates( + esClient: ElasticsearchClient, + definition: EntityDefinition, + logger: Logger +): Promise> { + const template = generateEntitiesLatestIndexTemplateConfig(definition); + await upsertTemplate({ esClient, template, logger }); + return [{ type: 'template', id: template.name }]; +} + export async function deleteTemplate({ esClient, name, logger }: DeleteTemplateOptions) { try { await retryTransientEsErrors( @@ -87,6 +102,28 @@ export async function deleteTemplate({ esClient, name, logger }: DeleteTemplateO } } +export async function deleteTemplates( + esClient: ElasticsearchClient, + definition: EntityDefinition, + logger: Logger +) { + try { + await Promise.all( + (definition.installedComponents ?? []) + .filter(({ type }) => type === 'template') + .map(({ id }) => + retryTransientEsErrors( + () => esClient.indices.deleteIndexTemplate({ name: id }, { ignore: [404] }), + { logger } + ) + ) + ); + } catch (error: any) { + logger.error(`Error deleting entity manager index template: ${error.message}`); + throw error; + } +} + export async function upsertComponent({ esClient, component, logger }: ComponentManagementOptions) { try { await retryTransientEsErrors(() => esClient.cluster.putComponentTemplate(component), { diff --git a/x-pack/plugins/entity_manager/server/routes/enablement/disable.ts b/x-pack/plugins/entity_manager/server/routes/enablement/disable.ts index bde68eb85ba9f..9c1c4f403636b 100644 --- a/x-pack/plugins/entity_manager/server/routes/enablement/disable.ts +++ b/x-pack/plugins/entity_manager/server/routes/enablement/disable.ts @@ -51,8 +51,8 @@ export const disableEntityDiscoveryRoute = createEntityManagerServerRoute({ }), handler: async ({ context, response, params, logger, server }) => { try { - const esClient = (await context.core).elasticsearch.client.asCurrentUser; - const canDisable = await canDisableEntityDiscovery(esClient); + const esClientAsCurrentUser = (await context.core).elasticsearch.client.asCurrentUser; + const canDisable = await canDisableEntityDiscovery(esClientAsCurrentUser); if (!canDisable) { return response.forbidden({ body: { @@ -62,6 +62,7 @@ export const disableEntityDiscoveryRoute = createEntityManagerServerRoute({ }); } + const esClient = (await context.core).elasticsearch.client.asSecondaryAuthUser; const soClient = (await context.core).savedObjects.getClient({ includedHiddenTypes: [EntityDiscoveryApiKeyType.name], }); diff --git a/x-pack/plugins/entity_manager/server/routes/enablement/enable.ts b/x-pack/plugins/entity_manager/server/routes/enablement/enable.ts index 9814840d20a0b..1002c1e716df2 100644 --- a/x-pack/plugins/entity_manager/server/routes/enablement/enable.ts +++ b/x-pack/plugins/entity_manager/server/routes/enablement/enable.ts @@ -80,8 +80,10 @@ export const enableEntityDiscoveryRoute = createEntityManagerServerRoute({ }); } - const esClient = (await context.core).elasticsearch.client.asCurrentUser; - const canEnable = await canEnableEntityDiscovery(esClient); + const core = await context.core; + + const esClientAsCurrentUser = core.elasticsearch.client.asCurrentUser; + const canEnable = await canEnableEntityDiscovery(esClientAsCurrentUser); if (!canEnable) { return response.forbidden({ body: { @@ -91,7 +93,7 @@ export const enableEntityDiscoveryRoute = createEntityManagerServerRoute({ }); } - const soClient = (await context.core).savedObjects.getClient({ + const soClient = core.savedObjects.getClient({ includedHiddenTypes: [EntityDiscoveryApiKeyType.name], }); const existingApiKey = await readEntityDiscoveryAPIKey(server); @@ -117,6 +119,7 @@ export const enableEntityDiscoveryRoute = createEntityManagerServerRoute({ await saveEntityDiscoveryAPIKey(soClient, apiKey); + const esClient = core.elasticsearch.client.asSecondaryAuthUser; const installedDefinitions = await installBuiltInEntityDefinitions({ esClient, soClient, diff --git a/x-pack/plugins/entity_manager/server/routes/entities/reset.ts b/x-pack/plugins/entity_manager/server/routes/entities/reset.ts index a59c38b3acf7c..0b6942e335e51 100644 --- a/x-pack/plugins/entity_manager/server/routes/entities/reset.ts +++ b/x-pack/plugins/entity_manager/server/routes/entities/reset.ts @@ -12,25 +12,13 @@ import { EntitySecurityException } from '../../lib/entities/errors/entity_securi import { InvalidTransformError } from '../../lib/entities/errors/invalid_transform_error'; import { readEntityDefinition } from '../../lib/entities/read_entity_definition'; -import { - deleteHistoryIngestPipeline, - deleteLatestIngestPipeline, -} from '../../lib/entities/delete_ingest_pipeline'; +import { deleteIngestPipelines } from '../../lib/entities/delete_ingest_pipeline'; import { deleteIndices } from '../../lib/entities/delete_index'; -import { - createAndInstallHistoryIngestPipeline, - createAndInstallLatestIngestPipeline, -} from '../../lib/entities/create_and_install_ingest_pipeline'; -import { - createAndInstallHistoryBackfillTransform, - createAndInstallHistoryTransform, - createAndInstallLatestTransform, -} from '../../lib/entities/create_and_install_transform'; +import { createAndInstallIngestPipelines } from '../../lib/entities/create_and_install_ingest_pipeline'; +import { createAndInstallTransforms } from '../../lib/entities/create_and_install_transform'; import { startTransforms } from '../../lib/entities/start_transforms'; import { EntityDefinitionNotFound } from '../../lib/entities/errors/entity_not_found'; -import { isBackfillEnabled } from '../../lib/entities/helpers/is_backfill_enabled'; - import { createEntityManagerServerRoute } from '../create_entity_manager_server_route'; import { deleteTransforms } from '../../lib/entities/delete_transforms'; import { stopTransforms } from '../../lib/entities/stop_transforms'; @@ -51,18 +39,12 @@ export const resetEntityDefinitionRoute = createEntityManagerServerRoute({ await stopTransforms(esClient, definition, logger); await deleteTransforms(esClient, definition, logger); - await deleteHistoryIngestPipeline(esClient, definition, logger); - await deleteLatestIngestPipeline(esClient, definition, logger); + await deleteIngestPipelines(esClient, definition, logger); await deleteIndices(esClient, definition, logger); // Recreate everything - await createAndInstallHistoryIngestPipeline(esClient, definition, logger); - await createAndInstallLatestIngestPipeline(esClient, definition, logger); - await createAndInstallHistoryTransform(esClient, definition, logger); - if (isBackfillEnabled(definition)) { - await createAndInstallHistoryBackfillTransform(esClient, definition, logger); - } - await createAndInstallLatestTransform(esClient, definition, logger); + await createAndInstallIngestPipelines(esClient, definition, logger); + await createAndInstallTransforms(esClient, definition, logger); await startTransforms(esClient, definition, logger); return response.ok({ body: { acknowledged: true } }); diff --git a/x-pack/plugins/entity_manager/server/saved_objects/entity_definition.ts b/x-pack/plugins/entity_manager/server/saved_objects/entity_definition.ts index fdf2510e8627e..bdea2b71e4141 100644 --- a/x-pack/plugins/entity_manager/server/saved_objects/entity_definition.ts +++ b/x-pack/plugins/entity_manager/server/saved_objects/entity_definition.ts @@ -5,11 +5,36 @@ * 2.0. */ +import { SavedObjectModelDataBackfillFn } from '@kbn/core-saved-objects-server'; import { SavedObject, SavedObjectsType } from '@kbn/core/server'; import { EntityDefinition } from '@kbn/entities-schema'; +import { + generateHistoryIndexTemplateId, + generateHistoryIngestPipelineId, + generateHistoryTransformId, + generateLatestIndexTemplateId, + generateLatestIngestPipelineId, + generateLatestTransformId, +} from '../lib/entities/helpers/generate_component_id'; export const SO_ENTITY_DEFINITION_TYPE = 'entity-definition'; +export const backfillInstalledComponents: SavedObjectModelDataBackfillFn< + EntityDefinition, + EntityDefinition +> = (savedObject) => { + const definition = savedObject.attributes; + definition.installedComponents = [ + { type: 'transform', id: generateHistoryTransformId(definition) }, + { type: 'transform', id: generateLatestTransformId(definition) }, + { type: 'ingest_pipeline', id: generateHistoryIngestPipelineId(definition) }, + { type: 'ingest_pipeline', id: generateLatestIngestPipelineId(definition) }, + { type: 'template', id: generateHistoryIndexTemplateId(definition) }, + { type: 'template', id: generateLatestIndexTemplateId(definition) }, + ]; + return savedObject; +}; + export const entityDefinition: SavedObjectsType = { name: SO_ENTITY_DEFINITION_TYPE, hidden: false, @@ -64,5 +89,13 @@ export const entityDefinition: SavedObjectsType = { }, ], }, + '3': { + changes: [ + { + type: 'data_backfill', + backfillFn: backfillInstalledComponents, + }, + ], + }, }, }; diff --git a/x-pack/plugins/entity_manager/server/templates/components/helpers.test.ts b/x-pack/plugins/entity_manager/server/templates/components/helpers.test.ts deleted file mode 100644 index 90c5e90d43f3a..0000000000000 --- a/x-pack/plugins/entity_manager/server/templates/components/helpers.test.ts +++ /dev/null @@ -1,31 +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 { EntityDefinition } from '@kbn/entities-schema'; -import { getCustomHistoryTemplateComponents, getCustomLatestTemplateComponents } from './helpers'; - -describe('helpers', () => { - it('getCustomLatestTemplateComponents should return template component in the right sort order', () => { - const result = getCustomLatestTemplateComponents({ id: 'test' } as EntityDefinition); - expect(result).toEqual([ - 'test@platform', - 'test-latest@platform', - 'test@custom', - 'test-latest@custom', - ]); - }); - - it('getCustomHistoryTemplateComponents should return template component in the right sort order', () => { - const result = getCustomHistoryTemplateComponents({ id: 'test' } as EntityDefinition); - expect(result).toEqual([ - 'test@platform', - 'test-history@platform', - 'test@custom', - 'test-history@custom', - ]); - }); -}); diff --git a/x-pack/plugins/entity_manager/server/templates/components/helpers.ts b/x-pack/plugins/entity_manager/server/templates/components/helpers.ts deleted file mode 100644 index 23cc7cccb6a13..0000000000000 --- a/x-pack/plugins/entity_manager/server/templates/components/helpers.ts +++ /dev/null @@ -1,35 +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 { EntityDefinition } from '@kbn/entities-schema'; -import { isBuiltinDefinition } from '../../lib/entities/helpers/is_builtin_definition'; - -export const getCustomLatestTemplateComponents = (definition: EntityDefinition) => { - if (isBuiltinDefinition(definition)) { - return []; - } - - return [ - `${definition.id}@platform`, // @platform goes before so it can be overwritten by custom - `${definition.id}-latest@platform`, - `${definition.id}@custom`, - `${definition.id}-latest@custom`, - ]; -}; - -export const getCustomHistoryTemplateComponents = (definition: EntityDefinition) => { - if (isBuiltinDefinition(definition)) { - return []; - } - - return [ - `${definition.id}@platform`, // @platform goes before so it can be overwritten by custom - `${definition.id}-history@platform`, - `${definition.id}@custom`, - `${definition.id}-history@custom`, - ]; -}; diff --git a/x-pack/plugins/entity_manager/tsconfig.json b/x-pack/plugins/entity_manager/tsconfig.json index 29c100ee4c9d2..34c57a27dd829 100644 --- a/x-pack/plugins/entity_manager/tsconfig.json +++ b/x-pack/plugins/entity_manager/tsconfig.json @@ -34,5 +34,6 @@ "@kbn/zod-helpers", "@kbn/encrypted-saved-objects-plugin", "@kbn/licensing-plugin", + "@kbn/core-saved-objects-server", ] } diff --git a/x-pack/plugins/fleet/common/types/models/package_spec.ts b/x-pack/plugins/fleet/common/types/models/package_spec.ts index 24a592490137c..18c10e4617417 100644 --- a/x-pack/plugins/fleet/common/types/models/package_spec.ts +++ b/x-pack/plugins/fleet/common/types/models/package_spec.ts @@ -18,7 +18,7 @@ export interface PackageSpecManifest { source?: { license: string; }; - type?: 'integration' | 'input'; + type?: PackageSpecPackageType; release?: 'experimental' | 'beta' | 'ga'; categories?: Array; conditions?: PackageSpecConditions; @@ -35,6 +35,11 @@ export interface PackageSpecManifest { privileges?: { root?: boolean }; }; asset_tags?: PackageSpecTags[]; + discovery?: { + fields?: Array<{ + name: string; + }>; + }; } export interface PackageSpecTags { text: string; @@ -42,7 +47,7 @@ export interface PackageSpecTags { asset_ids?: string[]; } -export type PackageSpecPackageType = 'integration' | 'input'; +export type PackageSpecPackageType = 'integration' | 'input' | 'content'; export type PackageSpecCategory = | 'advanced_analytics_ueba' diff --git a/x-pack/plugins/fleet/cypress/e2e/integrations_automatic_import.cy.ts b/x-pack/plugins/fleet/cypress/e2e/integrations_automatic_import.cy.ts new file mode 100644 index 0000000000000..e2454cb1dcf77 --- /dev/null +++ b/x-pack/plugins/fleet/cypress/e2e/integrations_automatic_import.cy.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { deleteIntegrations } from '../tasks/integrations'; +import { + UPLOAD_PACKAGE_LINK, + ASSISTANT_BUTTON, + TECH_PREVIEW_BADGE, + CREATE_INTEGRATION_LANDING_PAGE, + BUTTON_FOOTER_NEXT, + INTEGRATION_TITLE_INPUT, + INTEGRATION_DESCRIPTION_INPUT, + DATASTREAM_TITLE_INPUT, + DATASTREAM_DESCRIPTION_INPUT, + DATASTREAM_NAME_INPUT, + DATA_COLLECTION_METHOD_INPUT, + LOGS_SAMPLE_FILE_PICKER, + EDIT_PIPELINE_BUTTON, + SAVE_PIPELINE_BUTTON, + VIEW_INTEGRATION_BUTTON, + INTEGRATION_SUCCESS_SECTION, + SAVE_ZIP_BUTTON, +} from '../screens/integrations_automatic_import'; +import { cleanupAgentPolicies } from '../tasks/cleanup'; +import { login, logout } from '../tasks/login'; +import { createBedrockConnector, deleteConnectors } from '../tasks/api_calls/connectors'; +import { + ecsResultsForJson, + categorizationResultsForJson, + relatedResultsForJson, +} from '../tasks/api_calls/graph_results'; + +describe('Add Integration - Automatic Import', () => { + beforeEach(() => { + login(); + + cleanupAgentPolicies(); + deleteIntegrations(); + + // Create a mock connector + deleteConnectors(); + createBedrockConnector(); + // Mock API Responses + cy.intercept('POST', '/api/integration_assistant/ecs', { + statusCode: 200, + body: { + results: ecsResultsForJson, + }, + }); + cy.intercept('POST', '/api/integration_assistant/categorization', { + statusCode: 200, + body: { + results: categorizationResultsForJson, + }, + }); + cy.intercept('POST', '/api/integration_assistant/related', { + statusCode: 200, + body: { + results: relatedResultsForJson, + }, + }); + }); + + afterEach(() => { + deleteConnectors(); + cleanupAgentPolicies(); + deleteIntegrations(); + logout(); + }); + + it('should create an integration', () => { + cy.visit(CREATE_INTEGRATION_LANDING_PAGE); + + cy.getBySel(ASSISTANT_BUTTON).should('exist'); + cy.getBySel(UPLOAD_PACKAGE_LINK).should('exist'); + cy.getBySel(TECH_PREVIEW_BADGE).should('exist'); + + // Create Integration Assistant Page + cy.getBySel(ASSISTANT_BUTTON).click(); + cy.getBySel(BUTTON_FOOTER_NEXT).click(); + + // Integration details Page + cy.getBySel(INTEGRATION_TITLE_INPUT).type('Test Integration'); + cy.getBySel(INTEGRATION_DESCRIPTION_INPUT).type('Test Integration Description'); + cy.getBySel(BUTTON_FOOTER_NEXT).click(); + + // Datastream details page + cy.getBySel(DATASTREAM_TITLE_INPUT).type('Audit'); + cy.getBySel(DATASTREAM_DESCRIPTION_INPUT).type('Test Datastream Description'); + cy.getBySel(DATASTREAM_NAME_INPUT).type('audit'); + cy.getBySel(DATA_COLLECTION_METHOD_INPUT).type('file stream'); + cy.get('body').click(0, 0); + + // Select sample logs file and Analyze logs + cy.fixture('teleport.ndjson', null).as('myFixture'); + cy.getBySel(LOGS_SAMPLE_FILE_PICKER).selectFile('@myFixture'); + cy.getBySel(BUTTON_FOOTER_NEXT).click(); + + // Edit Pipeline + cy.getBySel(EDIT_PIPELINE_BUTTON).click(); + cy.getBySel(SAVE_PIPELINE_BUTTON).click(); + + // Deploy + cy.getBySel(BUTTON_FOOTER_NEXT).click(); + cy.getBySel(INTEGRATION_SUCCESS_SECTION).should('exist'); + cy.getBySel(SAVE_ZIP_BUTTON).should('exist'); + + // View Integration + cy.getBySel(VIEW_INTEGRATION_BUTTON).click(); + }); +}); diff --git a/x-pack/plugins/fleet/cypress/e2e/privileges_integrations_automatic_import.cy.ts b/x-pack/plugins/fleet/cypress/e2e/privileges_integrations_automatic_import.cy.ts new file mode 100644 index 0000000000000..29eaab7eaca0a --- /dev/null +++ b/x-pack/plugins/fleet/cypress/e2e/privileges_integrations_automatic_import.cy.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { User } from '../tasks/privileges'; +import { + deleteUsersAndRoles, + getIntegrationsAutoImportRole, + createUsersAndRoles, + AutomaticImportConnectorNoneUser, + AutomaticImportConnectorNoneRole, + AutomaticImportConnectorAllUser, + AutomaticImportConnectorAllRole, + AutomaticImportConnectorReadUser, + AutomaticImportConnectorReadRole, +} from '../tasks/privileges'; +import { login, loginWithUserAndWaitForPage, logout } from '../tasks/login'; +import { + ASSISTANT_BUTTON, + CONNECTOR_BEDROCK, + CONNECTOR_GEMINI, + CONNECTOR_OPENAI, + CREATE_INTEGRATION_ASSISTANT, + CREATE_INTEGRATION_LANDING_PAGE, + CREATE_INTEGRATION_UPLOAD, + MISSING_PRIVILEGES, + UPLOAD_PACKAGE_LINK, +} from '../screens/integrations_automatic_import'; + +describe('When the user does not have enough previleges for Integrations', () => { + const runs = [ + { fleetRole: 'read', integrationsRole: 'read' }, + { fleetRole: 'read', integrationsRole: 'all' }, + { fleetRole: 'all', integrationsRole: 'read' }, + ]; + + runs.forEach(function (run) { + describe(`When the user has '${run.fleetRole}' role for fleet and '${run.integrationsRole}' role for Integrations`, () => { + const automaticImportIntegrRole = getIntegrationsAutoImportRole({ + fleetv2: [run.fleetRole], // fleet + fleet: [run.integrationsRole], // integrations + }); + const AutomaticImportIntegrUser: User = { + username: 'automatic_import_integrations_read_user', + password: 'password', + roles: [automaticImportIntegrRole.name], + }; + + before(() => { + createUsersAndRoles([AutomaticImportIntegrUser], [automaticImportIntegrRole]); + }); + + beforeEach(() => { + login(); + }); + + afterEach(() => { + logout(); + }); + + after(() => { + deleteUsersAndRoles([AutomaticImportIntegrUser], [automaticImportIntegrRole]); + }); + + it('Create Assistant is not accessible if user has read role in integrations', () => { + loginWithUserAndWaitForPage(CREATE_INTEGRATION_ASSISTANT, AutomaticImportIntegrUser); + cy.getBySel(MISSING_PRIVILEGES).should('exist'); + }); + + it('Create upload is not accessible if user has read role in integrations', () => { + loginWithUserAndWaitForPage(CREATE_INTEGRATION_UPLOAD, AutomaticImportIntegrUser); + cy.getBySel(MISSING_PRIVILEGES).should('exist'); + }); + }); + }); +}); + +describe('When the user has All permissions for Integrations and No permissions for actions', () => { + before(() => { + createUsersAndRoles([AutomaticImportConnectorNoneUser], [AutomaticImportConnectorNoneRole]); + }); + + beforeEach(() => { + login(); + }); + + afterEach(() => { + logout(); + }); + + after(() => { + deleteUsersAndRoles([AutomaticImportConnectorNoneUser], [AutomaticImportConnectorNoneRole]); + }); + + it('Create Assistant is not accessible but upload is accessible', () => { + loginWithUserAndWaitForPage(CREATE_INTEGRATION_LANDING_PAGE, AutomaticImportConnectorNoneUser); + cy.getBySel(ASSISTANT_BUTTON).should('not.exist'); + cy.getBySel(UPLOAD_PACKAGE_LINK).should('exist'); + }); +}); + +describe('When the user has All permissions for Integrations and read permissions for actions', () => { + before(() => { + createUsersAndRoles([AutomaticImportConnectorReadUser], [AutomaticImportConnectorReadRole]); + }); + + beforeEach(() => { + login(); + }); + + afterEach(() => { + logout(); + }); + + after(() => { + deleteUsersAndRoles([AutomaticImportConnectorReadUser], [AutomaticImportConnectorReadRole]); + }); + + it('Create Assistant is not accessible but upload is accessible', () => { + loginWithUserAndWaitForPage(CREATE_INTEGRATION_LANDING_PAGE, AutomaticImportConnectorReadUser); + cy.getBySel(ASSISTANT_BUTTON).should('exist'); + cy.getBySel(UPLOAD_PACKAGE_LINK).should('exist'); + }); + + it('Create Assistant is accessible but execute connector is not accessible', () => { + loginWithUserAndWaitForPage(CREATE_INTEGRATION_ASSISTANT, AutomaticImportConnectorReadUser); + cy.getBySel(CONNECTOR_BEDROCK).should('not.exist'); + cy.getBySel(CONNECTOR_OPENAI).should('not.exist'); + cy.getBySel(CONNECTOR_GEMINI).should('not.exist'); + }); +}); + +describe('When the user has All permissions for Integrations and All permissions for actions', () => { + before(() => { + createUsersAndRoles([AutomaticImportConnectorAllUser], [AutomaticImportConnectorAllRole]); + }); + + beforeEach(() => { + login(); + }); + + afterEach(() => { + logout(); + }); + + after(() => { + deleteUsersAndRoles([AutomaticImportConnectorAllUser], [AutomaticImportConnectorAllRole]); + }); + + it('Create Assistant is not accessible but upload is accessible', () => { + loginWithUserAndWaitForPage(CREATE_INTEGRATION_ASSISTANT, AutomaticImportConnectorAllUser); + cy.getBySel(CONNECTOR_BEDROCK).should('exist'); + cy.getBySel(CONNECTOR_OPENAI).should('exist'); + cy.getBySel(CONNECTOR_GEMINI).should('exist'); + }); +}); diff --git a/x-pack/plugins/fleet/cypress/fixtures/teleport.ndjson b/x-pack/plugins/fleet/cypress/fixtures/teleport.ndjson new file mode 100644 index 0000000000000..82774ac2297d6 --- /dev/null +++ b/x-pack/plugins/fleet/cypress/fixtures/teleport.ndjson @@ -0,0 +1 @@ +{"ei":0,"event":"cert.create","uid":"efd326fc-dd13-4df8-acef-3102c2d717d3","code":"TC000I","time":"2024-02-24T06:56:50.648137154Z"} \ No newline at end of file diff --git a/x-pack/plugins/fleet/cypress/screens/integrations_automatic_import.ts b/x-pack/plugins/fleet/cypress/screens/integrations_automatic_import.ts new file mode 100644 index 0000000000000..e549f88294a3b --- /dev/null +++ b/x-pack/plugins/fleet/cypress/screens/integrations_automatic_import.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. + */ + +export const UPLOAD_PACKAGE_LINK = 'uploadPackageLink'; +export const ASSISTANT_BUTTON = 'assistantButton'; +export const TECH_PREVIEW_BADGE = 'techPreviewBadge'; +export const MISSING_PRIVILEGES = 'missingPrivilegesCallOut'; + +export const CONNECTOR_BEDROCK = 'actionType-.bedrock'; +export const CONNECTOR_OPENAI = 'actionType-.gen-ai'; +export const CONNECTOR_GEMINI = 'actionType-.gemini'; + +export const BUTTON_FOOTER_NEXT = 'buttonsFooter-nextButton'; + +export const INTEGRATION_TITLE_INPUT = 'integrationTitleInput'; +export const INTEGRATION_DESCRIPTION_INPUT = 'integrationDescriptionInput'; +export const DATASTREAM_TITLE_INPUT = 'dataStreamTitleInput'; +export const DATASTREAM_DESCRIPTION_INPUT = 'dataStreamDescriptionInput'; +export const DATASTREAM_NAME_INPUT = 'dataStreamNameInput'; +export const DATA_COLLECTION_METHOD_INPUT = 'dataCollectionMethodInput'; +export const LOGS_SAMPLE_FILE_PICKER = 'logsSampleFilePicker'; + +export const EDIT_PIPELINE_BUTTON = 'editPipelineButton'; +export const SAVE_PIPELINE_BUTTON = 'savePipelineButton'; +export const VIEW_INTEGRATION_BUTTON = 'viewIntegrationButton'; +export const INTEGRATION_SUCCESS_SECTION = 'integrationSuccessSection'; +export const SAVE_ZIP_BUTTON = 'saveZipButton'; + +export const CREATE_INTEGRATION_LANDING_PAGE = '/app/integrations/create'; +export const CREATE_INTEGRATION_ASSISTANT = '/app/integrations/create/assistant'; +export const CREATE_INTEGRATION_UPLOAD = '/app/integrations/create/upload'; diff --git a/x-pack/plugins/fleet/cypress/tasks/api_calls/connectors.ts b/x-pack/plugins/fleet/cypress/tasks/api_calls/connectors.ts new file mode 100644 index 0000000000000..230fdcd124562 --- /dev/null +++ b/x-pack/plugins/fleet/cypress/tasks/api_calls/connectors.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AllConnectorsResponse } from '@kbn/actions-plugin/common/routes/connector/response'; + +import { v4 as uuidv4 } from 'uuid'; + +import { API_AUTH, COMMON_API_HEADERS } from '../common'; + +export const bedrockId = uuidv4(); +export const azureId = uuidv4(); + +// Replaces request - adds baseline authentication + global headers +export const request = ({ + headers, + ...options +}: Partial): Cypress.Chainable> => { + return cy.request({ + auth: API_AUTH, + headers: { ...COMMON_API_HEADERS, ...headers }, + ...options, + }); +}; +export const INTERNAL_CLOUD_CONNECTORS = ['Elastic-Cloud-SMTP']; + +export const getConnectors = () => + request({ + method: 'GET', + url: 'api/actions/connectors', + }); + +export const createConnector = (connector: Record, id: string) => + cy.request({ + method: 'POST', + url: `/api/actions/connector/${id}`, + body: connector, + headers: { 'kbn-xsrf': 'cypress-creds', 'x-elastic-internal-origin': 'security-solution' }, + }); + +export const deleteConnectors = () => { + getConnectors().then(($response) => { + if ($response.body.length > 0) { + const ids = $response.body.map((connector) => { + return connector.id; + }); + ids.forEach((id) => { + if (!INTERNAL_CLOUD_CONNECTORS.includes(id)) { + request({ + method: 'DELETE', + url: `api/actions/connector/${id}`, + }); + } + }); + } + }); +}; + +export const azureConnectorAPIPayload = { + connector_type_id: '.gen-ai', + secrets: { + apiKey: '123', + }, + config: { + apiUrl: + 'https://goodurl.com/openai/deployments/good-gpt4o/chat/completions?api-version=2024-02-15-preview', + apiProvider: 'Azure OpenAI', + }, + name: 'Azure OpenAI cypress test e2e connector', +}; + +export const bedrockConnectorAPIPayload = { + connector_type_id: '.bedrock', + secrets: { + accessKey: '123', + secret: '123', + }, + config: { + apiUrl: 'https://bedrock.com', + }, + name: 'Bedrock cypress test e2e connector', +}; + +export const createAzureConnector = () => createConnector(azureConnectorAPIPayload, azureId); +export const createBedrockConnector = () => createConnector(bedrockConnectorAPIPayload, bedrockId); diff --git a/x-pack/plugins/fleet/cypress/tasks/api_calls/graph_results.ts b/x-pack/plugins/fleet/cypress/tasks/api_calls/graph_results.ts new file mode 100644 index 0000000000000..3276b6ecf055f --- /dev/null +++ b/x-pack/plugins/fleet/cypress/tasks/api_calls/graph_results.ts @@ -0,0 +1,531 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 ecsResultsForJson = { + mapping: { + teleport2: { + audit: { + ei: null, + event: { + target: 'event.action', + confidence: 0.9, + type: 'string', + date_formats: [], + }, + uid: { + target: 'event.id', + confidence: 0.95, + type: 'string', + date_formats: [], + }, + code: { + target: 'event.code', + confidence: 0.9, + type: 'string', + date_formats: [], + }, + }, + }, + }, + pipeline: { + description: 'Pipeline to process teleport2 audit logs', + processors: [ + { + set: { + field: 'ecs.version', + tag: 'set_ecs_version', + value: '8.11.0', + }, + }, + { + remove: { + field: 'message', + ignore_missing: true, + tag: 'remove_message', + }, + }, + { + json: { + field: 'event.original', + tag: 'json_original', + target_field: 'teleport2.audit', + }, + }, + { + rename: { + field: 'teleport2.audit.event', + target_field: 'event.action', + ignore_missing: true, + }, + }, + { + script: { + description: 'Ensures the date processor does not receive an array value.', + tag: 'script_convert_array_to_string', + lang: 'painless', + source: + 'if (ctx.teleport2?.audit?.time != null &&\n ctx.teleport2.audit.time instanceof ArrayList){\n ctx.teleport2.audit.time = ctx.teleport2.audit.time[0];\n}\n', + }, + }, + { + date: { + field: 'teleport2.audit.time', + target_field: 'event.start', + formats: ["yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS'Z'", 'ISO8601'], + tag: 'date_processor_teleport2.audit.time', + if: 'ctx.teleport2?.audit?.time != null', + }, + }, + { + set: { + field: 'event.kind', + value: 'pipeline_error', + }, + }, + ], + }, +}; + +export const categorizationResultsForJson = { + docs: [ + { + ecs: { + version: '8.11.0', + }, + teleport2: { + audit: { + cert_type: 'user', + time: '2024-02-24T06:56:50.648137154Z', + ei: 0, + identity: { + expires: '2024-02-24T06:56:50.648137154Z', + traits: { + logins: ['root', 'ubuntu', 'ec2-user'], + }, + private_key_policy: 'none', + teleport_cluster: 'teleport.com', + prev_identity_expires: '0001-01-01T00:00:00Z', + route_to_cluster: 'teleport.com', + logins: ['root', 'ubuntu', 'ec2-user', '-teleport-internal-join'], + }, + }, + }, + organization: { + name: 'teleport.com', + }, + source: { + ip: '1.2.3.4', + }, + event: { + code: 'TC000I', + start: '2024-02-24T06:56:50.648Z', + action: 'cert.create', + end: '0001-01-01T00:00:00.000Z', + id: 'efd326fc-dd13-4df8-acef-3102c2d717d3', + category: ['iam', 'authentication'], + type: ['creation', 'start'], + }, + user: { + name: 'teleport-admin', + changes: { + name: '2024-02-24T06:56:50.648Z', + }, + roles: ['access', 'editor'], + }, + tags: [ + '_geoip_database_unavailable_GeoLite2-City.mmdb', + '_geoip_database_unavailable_GeoLite2-ASN.mmdb', + '_geoip_database_unavailable_GeoLite2-City.mmdb', + '_geoip_database_unavailable_GeoLite2-ASN.mmdb', + ], + }, + ], + pipeline: { + description: 'Pipeline to process teleport2 audit logs', + processors: [ + { + set: { + field: 'ecs.version', + tag: 'set_ecs_version', + value: '8.11.0', + }, + }, + { + remove: { + field: 'message', + ignore_missing: true, + tag: 'remove_message', + }, + }, + { + json: { + field: 'event.original', + tag: 'json_original', + target_field: 'teleport2.audit', + }, + }, + { + rename: { + field: 'teleport2.audit.event', + target_field: 'event.action', + ignore_missing: true, + }, + }, + { + script: { + description: 'Ensures the date processor does not receive an array value.', + tag: 'script_convert_array_to_string', + lang: 'painless', + source: + 'if (ctx.teleport2?.audit?.time != null &&\n ctx.teleport2.audit.time instanceof ArrayList){\n ctx.teleport2.audit.time = ctx.teleport2.audit.time[0];\n}\n', + }, + }, + { + date: { + field: 'teleport2.audit.time', + target_field: 'event.start', + formats: ["yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS'Z'", 'ISO8601'], + tag: 'date_processor_teleport2.audit.time', + if: 'ctx.teleport2?.audit?.time != null', + }, + }, + { + set: { + field: 'event.kind', + value: 'pipeline_error', + }, + }, + ], + }, +}; + +export const relatedResultsForJson = { + docs: [ + { + ecs: { + version: '8.11.0', + }, + related: { + user: ['teleport-admin'], + ip: ['1.2.3.4'], + }, + teleport2: { + audit: { + cert_type: 'user', + time: '2024-02-24T06:56:50.648137154Z', + ei: 0, + identity: { + expires: '2024-02-24T06:56:50.648137154Z', + traits: { + logins: ['root', 'ubuntu', 'ec2-user'], + }, + private_key_policy: 'none', + teleport_cluster: 'teleport.com', + prev_identity_expires: '0001-01-01T00:00:00Z', + route_to_cluster: 'teleport.com', + logins: ['root', 'ubuntu', 'ec2-user', '-teleport-internal-join'], + }, + }, + }, + organization: { + name: 'teleport.com', + }, + source: { + ip: '1.2.3.4', + }, + event: { + code: 'TC000I', + start: '2024-02-24T06:56:50.648Z', + action: 'cert.create', + end: '0001-01-01T00:00:00.000Z', + id: 'efd326fc-dd13-4df8-acef-3102c2d717d3', + category: ['iam', 'authentication'], + type: ['creation', 'start'], + }, + user: { + name: 'teleport-admin', + changes: { + name: '2024-02-24T06:56:50.648Z', + }, + roles: ['access', 'editor'], + }, + tags: [ + '_geoip_database_unavailable_GeoLite2-City.mmdb', + '_geoip_database_unavailable_GeoLite2-ASN.mmdb', + '_geoip_database_unavailable_GeoLite2-City.mmdb', + '_geoip_database_unavailable_GeoLite2-ASN.mmdb', + ], + }, + ], + pipeline: { + description: 'Pipeline to process teleport2 audit logs', + processors: [ + { + set: { + tag: 'set_ecs_version', + field: 'ecs.version', + value: '8.11.0', + }, + }, + { + set: { + tag: 'copy_original_message', + field: 'originalMessage', + copy_from: 'message', + }, + }, + { + rename: { + ignore_missing: true, + if: 'ctx.event?.original == null', + tag: 'rename_message', + field: 'originalMessage', + target_field: 'event.original', + }, + }, + { + rename: { + ignore_missing: true, + field: 'teleport2.audit.user', + target_field: 'user.name', + }, + }, + { + rename: { + ignore_missing: true, + field: 'teleport2.audit.login', + target_field: 'user.id', + }, + }, + { + rename: { + ignore_missing: true, + field: 'teleport2.audit.server_hostname', + target_field: 'destination.domain', + }, + }, + { + rename: { + ignore_missing: true, + field: 'teleport2.audit.addr.remote', + target_field: 'source.address', + }, + }, + { + rename: { + ignore_missing: true, + field: 'teleport2.audit.proto', + target_field: 'network.protocol', + }, + }, + { + script: { + tag: 'script_drop_null_empty_values', + description: 'Drops null/empty values recursively.', + lang: 'painless', + source: + 'boolean dropEmptyFields(Object object) {\n if (object == null || object == "") {\n return true;\n } else if (object instanceof Map) {\n ((Map) object).values().removeIf(value -> dropEmptyFields(value));\n return (((Map) object).size() == 0);\n } else if (object instanceof List) {\n ((List) object).removeIf(value -> dropEmptyFields(value));\n return (((List) object).length == 0);\n }\n return false;\n}\ndropEmptyFields(ctx);\n', + }, + }, + { + geoip: { + ignore_missing: true, + tag: 'geoip_source_ip', + field: 'source.ip', + target_field: 'source.geo', + }, + }, + { + geoip: { + ignore_missing: true, + tag: 'geoip_source_asn', + database_file: 'GeoLite2-ASN.mmdb', + field: 'source.ip', + target_field: 'source.as', + properties: ['asn', 'organization_name'], + }, + }, + { + rename: { + ignore_missing: true, + tag: 'rename_source_as_asn', + field: 'source.as.asn', + target_field: 'source.as.number', + }, + }, + { + rename: { + ignore_missing: true, + tag: 'rename_source_as_organization_name', + field: 'source.as.organization_name', + target_field: 'source.as.organization.name', + }, + }, + { + geoip: { + ignore_missing: true, + tag: 'geoip_destination_ip', + field: 'destination.ip', + target_field: 'destination.geo', + }, + }, + { + geoip: { + ignore_missing: true, + tag: 'geoip_destination_asn', + database_file: 'GeoLite2-ASN.mmdb', + field: 'destination.ip', + target_field: 'destination.as', + properties: ['asn', 'organization_name'], + }, + }, + { + rename: { + ignore_missing: true, + tag: 'rename_destination_as_asn', + field: 'destination.as.asn', + target_field: 'destination.as.number', + }, + }, + { + rename: { + ignore_missing: true, + tag: 'rename_destination_as_organization_name', + field: 'destination.as.organization_name', + target_field: 'destination.as.organization.name', + }, + }, + { + append: { + if: "ctx.event?.action == 'cert.create'", + field: 'event.category', + value: ['iam'], + allow_duplicates: false, + }, + }, + { + append: { + if: "ctx.event?.action == 'cert.create'", + field: 'event.type', + value: ['creation'], + allow_duplicates: false, + }, + }, + { + append: { + if: "ctx.event?.action == 'cert.create'", + field: 'event.category', + value: ['authentication'], + allow_duplicates: false, + }, + }, + { + append: { + if: "ctx.event?.action == 'cert.create'", + field: 'event.type', + value: ['start'], + allow_duplicates: false, + }, + }, + { + append: { + if: "ctx.event?.action == 'session.start'", + field: 'event.category', + value: ['session'], + allow_duplicates: false, + }, + }, + { + append: { + if: "ctx.event?.action == 'session.start'", + field: 'event.type', + value: ['start'], + allow_duplicates: false, + }, + }, + { + append: { + if: "ctx.network?.protocol == 'ssh'", + field: 'event.category', + value: ['network'], + allow_duplicates: false, + }, + }, + { + append: { + if: "ctx.network?.protocol == 'ssh'", + field: 'event.type', + value: ['connection', 'start'], + allow_duplicates: false, + }, + }, + { + append: { + field: 'related.ip', + value: '{{{source.ip}}}', + if: 'ctx.source?.ip != null', + allow_duplicates: false, + }, + }, + { + append: { + field: 'related.user', + value: '{{{user.name}}}', + if: 'ctx.user?.name != null', + allow_duplicates: false, + }, + }, + { + append: { + field: 'related.hosts', + value: '{{{destination.domain}}}', + if: 'ctx.destination?.domain != null', + allow_duplicates: false, + }, + }, + { + append: { + field: 'related.user', + value: '{{{user.id}}}', + if: 'ctx.user?.id != null', + allow_duplicates: false, + }, + }, + { + remove: { + ignore_missing: true, + tag: 'remove_fields', + field: ['teleport2.audit.identity.client_ip'], + }, + }, + { + remove: { + ignore_failure: true, + ignore_missing: true, + if: 'ctx?.tags == null || !(ctx.tags.contains("preserve_original_event"))', + tag: 'remove_original_event', + field: 'event.original', + }, + }, + ], + on_failure: [ + { + append: { + field: 'error.message', + value: + 'Processor {{{_ingest.on_failure_processor_type}}} with tag {{{_ingest.on_failure_processor_tag}}} in pipeline {{{_ingest.on_failure_pipeline}}} failed with message: {{{_ingest.on_failure_message}}}', + }, + }, + { + set: { + field: 'event.kind', + value: 'pipeline_error', + }, + }, + ], + }, +}; diff --git a/x-pack/plugins/fleet/cypress/tasks/privileges.ts b/x-pack/plugins/fleet/cypress/tasks/privileges.ts index 214bd0f14e6e6..876b88ac9d5b5 100644 --- a/x-pack/plugins/fleet/cypress/tasks/privileges.ts +++ b/x-pack/plugins/fleet/cypress/tasks/privileges.ts @@ -8,7 +8,7 @@ import { request } from './common'; import { constructUrlWithUser, getEnvAuth } from './login'; -interface User { +export interface User { username: string; password: string; description?: string; @@ -193,6 +193,117 @@ export const FleetNoneIntegrAllUser: User = { roles: [FleetNoneIntegrAllRole.name], }; +export const getIntegrationsAutoImportRole = (feature: FeaturesPrivileges): Role => ({ + name: 'automatic_import_integrations_read_role', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + cluster: ['manage_service_account'], + }, + kibana: [ + { + feature, + spaces: ['*'], + }, + ], + }, +}); + +export const AutomaticImportConnectorNoneRole: Role = { + name: 'automatic_import_connectors_none_role', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + cluster: ['manage_service_account'], + }, + kibana: [ + { + feature: { + fleetv2: ['all'], + fleet: ['all'], + actions: ['none'], + }, + spaces: ['*'], + }, + ], + }, +}; +export const AutomaticImportConnectorNoneUser: User = { + username: 'automatic_import_connectors_none_user', + password: 'password', + roles: [AutomaticImportConnectorNoneRole.name], +}; + +export const AutomaticImportConnectorReadRole: Role = { + name: 'automatic_import_connectors_read_role', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + cluster: ['manage_service_account'], + }, + kibana: [ + { + feature: { + fleetv2: ['all'], + fleet: ['all'], + actions: ['read'], + }, + spaces: ['*'], + }, + ], + }, +}; +export const AutomaticImportConnectorReadUser: User = { + username: 'automatic_import_connectors_read_user', + password: 'password', + roles: [AutomaticImportConnectorReadRole.name], +}; + +export const AutomaticImportConnectorAllRole: Role = { + name: 'automatic_import_connectors_all_role', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + cluster: ['manage_service_account'], + }, + kibana: [ + { + feature: { + fleetv2: ['all'], + fleet: ['all'], + actions: ['all'], + }, + spaces: ['*'], + }, + ], + }, +}; +export const AutomaticImportConnectorAllUser: User = { + username: 'automatic_import_connectors_all_user', + password: 'password', + roles: [AutomaticImportConnectorAllRole.name], +}; + export const BuiltInEditorUser: User = { username: 'editor_user', password: 'password', diff --git a/x-pack/plugins/fleet/cypress/tsconfig.json b/x-pack/plugins/fleet/cypress/tsconfig.json index ee3dd7cd1e246..6d1433482b1c2 100644 --- a/x-pack/plugins/fleet/cypress/tsconfig.json +++ b/x-pack/plugins/fleet/cypress/tsconfig.json @@ -29,5 +29,6 @@ "force": true }, "@kbn/rison", + "@kbn/actions-plugin", ] } 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 e55b883e80029..e7db96812749b 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 @@ -370,6 +370,82 @@ describe('Agentless Agent service', () => { ); }); + it('should delete agentless agent for ESS', async () => { + const returnValue = { + id: 'mocked', + }; + + (axios as jest.MockedFunction).mockResolvedValueOnce(returnValue); + jest.spyOn(appContextService, 'getConfig').mockReturnValue({ + agentless: { + enabled: true, + api: { + url: 'http://api.agentless.com', + tls: { + certificate: '/path/to/cert', + key: '/path/to/key', + ca: '/path/to/ca', + }, + }, + }, + } as any); + jest.spyOn(appContextService, 'getCloud').mockReturnValue({ isCloudEnabled: true } as any); + + const deleteAgentlessAgentReturnValue = await agentlessAgentService.deleteAgentlessAgent( + 'mocked-agentless-agent-policy-id' + ); + + expect(axios).toHaveBeenCalledTimes(1); + expect(deleteAgentlessAgentReturnValue).toEqual(returnValue); + expect(axios).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.anything(), + httpsAgent: expect.anything(), + method: 'DELETE', + url: 'http://api.agentless.com/api/v1/ess/deployments/mocked-agentless-agent-policy-id', + }) + ); + }); + + it('should delete agentless agent for serverless', async () => { + const returnValue = { + id: 'mocked', + }; + + (axios as jest.MockedFunction).mockResolvedValueOnce(returnValue); + jest.spyOn(appContextService, 'getConfig').mockReturnValue({ + agentless: { + enabled: true, + api: { + url: 'http://api.agentless.com', + tls: { + certificate: '/path/to/cert', + key: '/path/to/key', + ca: '/path/to/ca', + }, + }, + }, + } as any); + jest + .spyOn(appContextService, 'getCloud') + .mockReturnValue({ isCloudEnabled: true, isServerlessEnabled: true } as any); + + const deleteAgentlessAgentReturnValue = await agentlessAgentService.deleteAgentlessAgent( + 'mocked-agentless-agent-policy-id' + ); + + expect(axios).toHaveBeenCalledTimes(1); + expect(deleteAgentlessAgentReturnValue).toEqual(returnValue); + expect(axios).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.anything(), + httpsAgent: expect.anything(), + method: 'DELETE', + url: 'http://api.agentless.com/api/v1/serverless/deployments/mocked-agentless-agent-policy-id', + }) + ); + }); + it('should redact sensitive information from debug logs', async () => { const returnValue = { id: 'mocked', 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 3bf21c3bec0d1..617f3db7849f4 100644 --- a/x-pack/plugins/fleet/server/services/agents/agentless_agent.ts +++ b/x-pack/plugins/fleet/server/services/agents/agentless_agent.ts @@ -25,11 +25,7 @@ import { appContextService } from '../app_context'; import { listEnrollmentApiKeys } from '../api_keys'; import { listFleetServerHosts } from '../fleet_server_host'; import type { AgentlessConfig } from '../utils/agentless'; -import { - prependAgentlessApiBasePathToEndpoint, - isAgentlessApiEnabled, - getDeletionEndpointPath, -} from '../utils/agentless'; +import { prependAgentlessApiBasePathToEndpoint, isAgentlessApiEnabled } from '../utils/agentless'; class AgentlessAgentService { public async createAgentlessAgent( @@ -188,7 +184,10 @@ class AgentlessAgentService { const agentlessConfig = appContextService.getConfig()?.agentless; const tlsConfig = this.createTlsConfig(agentlessConfig); const requestConfig = { - url: getDeletionEndpointPath(agentlessConfig, `/deployments/${agentlessPolicyId}`), + url: prependAgentlessApiBasePathToEndpoint( + agentlessConfig, + `/deployments/${agentlessPolicyId}` + ), method: 'DELETE', headers: { 'Content-type': 'application/json', diff --git a/x-pack/plugins/fleet/server/services/utils/agentless.ts b/x-pack/plugins/fleet/server/services/utils/agentless.ts index c85e9cc991a6c..4c27d583d9a79 100644 --- a/x-pack/plugins/fleet/server/services/utils/agentless.ts +++ b/x-pack/plugins/fleet/server/services/utils/agentless.ts @@ -50,10 +50,3 @@ export const prependAgentlessApiBasePathToEndpoint = ( : AGENTLESS_ESS_API_BASE_PATH; return `${agentlessConfig.api.url}${endpointPrefix}${endpoint}`; }; - -export const getDeletionEndpointPath = ( - agentlessConfig: FleetConfigType['agentless'], - endpoint: AgentlessApiEndpoints -) => { - return `${agentlessConfig.api.url}${AGENTLESS_ESS_API_BASE_PATH}${endpoint}`; -}; diff --git a/x-pack/plugins/fleet/server/types/rest_spec/epm.ts b/x-pack/plugins/fleet/server/types/rest_spec/epm.ts index 2dc9606a5432d..f08ccd9ff1248 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/epm.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/epm.ts @@ -163,7 +163,13 @@ export const PackageInfoSchema = schema release: schema.maybe( schema.oneOf([schema.literal('ga'), schema.literal('beta'), schema.literal('experimental')]) ), - type: schema.maybe(schema.oneOf([schema.literal('integration'), schema.literal('input')])), + type: schema.maybe( + schema.oneOf([ + schema.literal('integration'), + schema.literal('input'), + schema.literal('content'), + ]) + ), path: schema.maybe(schema.string()), download: schema.maybe(schema.string()), internal: schema.maybe(schema.boolean()), @@ -192,6 +198,11 @@ export const PackageInfoSchema = schema format_version: schema.maybe(schema.string()), vars: schema.maybe(schema.arrayOf(schema.recordOf(schema.string(), schema.any()))), latestVersion: schema.maybe(schema.string()), + discovery: schema.maybe( + schema.object({ + fields: schema.maybe(schema.arrayOf(schema.object({ name: schema.string() }))), + }) + ), }) // sometimes package list response contains extra properties, e.g. installed_kibana .extendsDeep({ diff --git a/x-pack/plugins/index_lifecycle_management/server/plugin.ts b/x-pack/plugins/index_lifecycle_management/server/plugin.ts index 0d88acbaaa4ff..a5002cd36da44 100644 --- a/x-pack/plugins/index_lifecycle_management/server/plugin.ts +++ b/x-pack/plugins/index_lifecycle_management/server/plugin.ts @@ -27,7 +27,6 @@ const indexLifecycleDataEnricher = async ( const { indices: ilmIndicesData } = await client.asCurrentUser.ilm.explainLifecycle({ index: '*,.*', - only_managed: true, }); return indicesList.map((index: Index) => { return { diff --git a/x-pack/plugins/integration_assistant/public/common/components/authorization/missing_privileges_description.tsx b/x-pack/plugins/integration_assistant/public/common/components/authorization/missing_privileges_description.tsx index 15365aeb3a08e..ccc65a2e49f0e 100644 --- a/x-pack/plugins/integration_assistant/public/common/components/authorization/missing_privileges_description.tsx +++ b/x-pack/plugins/integration_assistant/public/common/components/authorization/missing_privileges_description.tsx @@ -13,7 +13,7 @@ type MissingPrivilegesDescriptionProps = Partial; export const MissingPrivilegesDescription = React.memo( ({ canCreateIntegrations, canCreateConnectors, canExecuteConnectors }) => { return ( - + {i18n.PRIVILEGES_REQUIRED_TITLE} diff --git a/x-pack/plugins/integration_assistant/public/common/components/success_section/success_section.tsx b/x-pack/plugins/integration_assistant/public/common/components/success_section/success_section.tsx index 62df4a8f98660..08da1329770cd 100644 --- a/x-pack/plugins/integration_assistant/public/common/components/success_section/success_section.tsx +++ b/x-pack/plugins/integration_assistant/public/common/components/success_section/success_section.tsx @@ -35,7 +35,13 @@ export const SuccessSection = React.memo(({ integrationName return ( - + (({ integrationName icon={} title={i18n.VIEW_INTEGRATION_TITLE} description={i18n.VIEW_INTEGRATION_DESCRIPTION} - footer={{i18n.VIEW_INTEGRATION_BUTTON}} + footer={ + + {i18n.VIEW_INTEGRATION_BUTTON} + + } /> diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/connector_step/connector_setup.tsx b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/connector_step/connector_setup.tsx index 8715f42eb8f58..e85481378f4dd 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/connector_step/connector_setup.tsx +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/connector_step/connector_setup.tsx @@ -104,10 +104,13 @@ export const ConnectorSetup = React.memo( size="xl" color="text" type={actionTypeRegistry.get(actionType.id).iconClass} + data-test-subj="connectorActionId" /> - {actionType.name} + + {actionType.name} + diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_landing/create_integration_landing.tsx b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_landing/create_integration_landing.tsx index 39cbd2cea1026..71706625f636f 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_landing/create_integration_landing.tsx +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_landing/create_integration_landing.tsx @@ -54,7 +54,10 @@ export const CreateIntegrationLanding = React.memo(() => { defaultMessage="If you have an existing integration package, {link}" values={{ link: ( - navigate(Page.upload)}> + navigate(Page.upload)} + data-test-subj="uploadPackageLink" + > { tooltipContent={i18n.TECH_PREVIEW_TOOLTIP} size="s" color="hollow" + data-test-subj="techPreviewBadge" /> @@ -64,7 +65,9 @@ export const IntegrationAssistantCard = React.memo(() => { {canExecuteConnectors ? ( - navigate(Page.assistant)}>{i18n.ASSISTANT_BUTTON} + navigate(Page.assistant)} data-test-subj="assistantButton"> + {i18n.ASSISTANT_BUTTON} + ) : ( {i18n.ASSISTANT_BUTTON} diff --git a/x-pack/plugins/integration_assistant/server/integration_builder/readme_files.ts b/x-pack/plugins/integration_assistant/server/integration_builder/readme_files.ts index 163b2b04b52f9..5467a1549cea2 100644 --- a/x-pack/plugins/integration_assistant/server/integration_builder/readme_files.ts +++ b/x-pack/plugins/integration_assistant/server/integration_builder/readme_files.ts @@ -5,7 +5,7 @@ * 2.0. */ -import nunjucks from 'nunjucks'; +import { Environment, FileSystemLoader } from 'nunjucks'; import { join as joinPath } from 'path'; import { createSync, ensureDirSync } from '../util'; @@ -17,6 +17,8 @@ export function createReadme(packageDir: string, integrationName: string, fields function createPackageReadme(packageDir: string, integrationName: string, fields: object[]) { const dirPath = joinPath(packageDir, 'docs/'); + // The readme nunjucks template files should be named in the format `somename_readme.md.njk` and not just `readme.md.njk` + // since any file with `readme.*` pattern is skipped in build process in buildkite. createReadmeFile(dirPath, 'package_readme.md.njk', integrationName, fields); } @@ -33,10 +35,17 @@ function createReadmeFile( ) { ensureDirSync(targetDir); - const template = nunjucks.render(templateName, { + const templatesPath = joinPath(__dirname, '../templates'); + const env = new Environment(new FileSystemLoader(templatesPath), { + autoescape: false, + }); + + const template = env.getTemplate(templateName); + + const renderedTemplate = template.render({ package_name: integrationName, fields, }); - createSync(joinPath(targetDir, 'README.md'), template); + createSync(joinPath(targetDir, 'README.md'), renderedTemplate); } diff --git a/x-pack/plugins/integration_assistant/server/templates/build_readme.md.njk b/x-pack/plugins/integration_assistant/server/templates/build_readme.md.njk index e23fa4af9efe8..1b58e55aebd37 100644 --- a/x-pack/plugins/integration_assistant/server/templates/build_readme.md.njk +++ b/x-pack/plugins/integration_assistant/server/templates/build_readme.md.njk @@ -1,4 +1,4 @@ -{% include "readme.njk" %} +{% include "./description_readme.njk" %} {% for data_stream in fields %} ### {{ data_stream.datastream }} diff --git a/x-pack/plugins/integration_assistant/server/templates/readme.njk b/x-pack/plugins/integration_assistant/server/templates/description_readme.njk similarity index 100% rename from x-pack/plugins/integration_assistant/server/templates/readme.njk rename to x-pack/plugins/integration_assistant/server/templates/description_readme.njk diff --git a/x-pack/plugins/integration_assistant/server/templates/package_readme.md.njk b/x-pack/plugins/integration_assistant/server/templates/package_readme.md.njk index b47e3491b5bc2..bd56aba5ac1e5 100644 --- a/x-pack/plugins/integration_assistant/server/templates/package_readme.md.njk +++ b/x-pack/plugins/integration_assistant/server/templates/package_readme.md.njk @@ -1,4 +1,4 @@ -{% include "readme.njk" %} +{% include "./description_readme.njk" %} {% for data_stream in fields %} ### {{ data_stream.datastream }} diff --git a/x-pack/plugins/lens/common/expressions/datatable/utils.ts b/x-pack/plugins/lens/common/expressions/datatable/utils.ts index 71c3d92126b33..bc617d931f500 100644 --- a/x-pack/plugins/lens/common/expressions/datatable/utils.ts +++ b/x-pack/plugins/lens/common/expressions/datatable/utils.ts @@ -5,14 +5,37 @@ * 2.0. */ -import type { Datatable } from '@kbn/expressions-plugin/common'; +import { type Datatable, type DatatableColumnMeta } from '@kbn/expressions-plugin/common'; import { getOriginalId } from './transpose_helpers'; +/** + * Returns true for numerical fields + * + * Excludes the following types: + * - `range` - Stringified range + * - `multi_terms` - Multiple values + * - `filters` - Arbitrary label + * - `filtered_metric` - Array of values + */ +export function isNumericField(meta?: DatatableColumnMeta): boolean { + return ( + meta?.type === 'number' && + meta.params?.id !== 'range' && + meta.params?.id !== 'multi_terms' && + meta.sourceParams?.type !== 'filters' && + meta.sourceParams?.type !== 'filtered_metric' + ); +} + +/** + * Returns true for numerical fields, excluding ranges + */ export function isNumericFieldForDatatable(table: Datatable | undefined, accessor: string) { - return getFieldTypeFromDatatable(table, accessor) === 'number'; + const meta = getFieldMetaFromDatatable(table, accessor); + return isNumericField(meta); } -export function getFieldTypeFromDatatable(table: Datatable | undefined, accessor: string) { +export function getFieldMetaFromDatatable(table: Datatable | undefined, accessor: string) { return table?.columns.find((col) => col.id === accessor || getOriginalId(col.id) === accessor) - ?.meta.type; + ?.meta; } 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 ecc392a7e56b7..fd0407513f869 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 @@ -337,6 +337,7 @@ export function LensEditConfigurationFlyout({ setErrors([]); updateSuggestion?.(attrs); } + prevQuery.current = q; setIsVisualizationLoading(false); }, [ @@ -481,7 +482,6 @@ export function LensEditConfigurationFlyout({ query={query} onTextLangQueryChange={(q) => { setQuery(q); - prevQuery.current = q; }} detectedTimestamp={adHocDataViews?.[0]?.timeFieldName} hideTimeFilterInfo={hideTimeFilterInfo} @@ -497,7 +497,8 @@ export function LensEditConfigurationFlyout({ editorIsInline hideRunQueryText onTextLangQuerySubmit={async (q, a) => { - if (q) { + // do not run the suggestions if the query is the same as the previous one + if (q && !isEqual(q, prevQuery.current)) { setIsVisualizationLoading(true); await runQuery(q, a); } diff --git a/x-pack/plugins/lens/public/shared_components/coloring/utils.test.ts b/x-pack/plugins/lens/public/shared_components/coloring/utils.test.ts index 5a126565c251f..cc6044fc0f624 100644 --- a/x-pack/plugins/lens/public/shared_components/coloring/utils.test.ts +++ b/x-pack/plugins/lens/public/shared_components/coloring/utils.test.ts @@ -110,7 +110,7 @@ describe('findMinMaxByColumnId', () => { { a: 'shoes', b: 53 }, ], }) - ).toEqual({ b: { min: 2, max: 53 } }); + ).toEqual(new Map([['b', { min: 2, max: 53 }]])); }); }); diff --git a/x-pack/plugins/lens/public/shared_components/coloring/utils.ts b/x-pack/plugins/lens/public/shared_components/coloring/utils.ts index 211628a096189..c58fec1ddb03e 100644 --- a/x-pack/plugins/lens/public/shared_components/coloring/utils.ts +++ b/x-pack/plugins/lens/public/shared_components/coloring/utils.ts @@ -95,12 +95,12 @@ export const findMinMaxByColumnId = ( table: Datatable | undefined, getOriginalId: (id: string) => string = (id: string) => id ) => { - const minMax: Record = {}; + const minMaxMap = new Map(); if (table != null) { for (const columnId of columnIds) { const originalId = getOriginalId(columnId); - minMax[originalId] = minMax[originalId] || { + const minMax = minMaxMap.get(originalId) ?? { max: Number.NEGATIVE_INFINITY, min: Number.POSITIVE_INFINITY, }; @@ -108,19 +108,22 @@ export const findMinMaxByColumnId = ( const rowValue = row[columnId]; const numericValue = getNumericValue(rowValue); if (numericValue != null) { - if (minMax[originalId].min > numericValue) { - minMax[originalId].min = numericValue; + if (minMax.min > numericValue) { + minMax.min = numericValue; } - if (minMax[originalId].max < numericValue) { - minMax[originalId].max = numericValue; + if (minMax.max < numericValue) { + minMax.max = numericValue; } } }); + // what happens when there's no data in the table? Fallback to a percent range - if (minMax[originalId].max === Number.NEGATIVE_INFINITY) { - minMax[originalId] = getFallbackDataBounds(); + if (minMax.max === Number.NEGATIVE_INFINITY) { + minMaxMap.set(originalId, getFallbackDataBounds()); + } else { + minMaxMap.set(originalId, minMax); } } } - return minMax; + return minMaxMap; }; diff --git a/x-pack/plugins/lens/public/visualizations/datatable/components/cell_value.test.tsx b/x-pack/plugins/lens/public/visualizations/datatable/components/cell_value.test.tsx index 76b8fc7b61740..e9f3caba9ec05 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/components/cell_value.test.tsx +++ b/x-pack/plugins/lens/public/visualizations/datatable/components/cell_value.test.tsx @@ -54,9 +54,7 @@ describe('datatable cell renderer', () => { @@ -217,7 +215,7 @@ describe('datatable cell renderer', () => { { wrapper: DataContextProviderWrapper({ table, - minMaxByColumnId: { a: { min: 12, max: 155 } }, + minMaxByColumnId: new Map([['a', { min: 12, max: 155 }]]), ...context, }), } diff --git a/x-pack/plugins/lens/public/visualizations/datatable/components/cell_value.tsx b/x-pack/plugins/lens/public/visualizations/datatable/components/cell_value.tsx index 0761c7904e75f..97e7e755ac36e 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/components/cell_value.tsx +++ b/x-pack/plugins/lens/public/visualizations/datatable/components/cell_value.tsx @@ -53,7 +53,7 @@ export const createGridCell = ( } = columnConfig.columns[colIndex] ?? {}; const filterOnClick = oneClickFilter && handleFilterClick; const content = formatters[columnId]?.convert(rawRowValue, filterOnClick ? 'text' : 'html'); - const currentAlignment = alignments && alignments[columnId]; + const currentAlignment = alignments?.get(columnId); useEffect(() => { let colorSet = false; diff --git a/x-pack/plugins/lens/public/visualizations/datatable/components/columns.test.tsx b/x-pack/plugins/lens/public/visualizations/datatable/components/columns.test.tsx index 3612317f7a565..76437743c5723 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/components/columns.test.tsx +++ b/x-pack/plugins/lens/public/visualizations/datatable/components/columns.test.tsx @@ -72,7 +72,7 @@ const callCreateGridColumns = ( params.formatFactory ?? (((x: unknown) => ({ convert: () => x })) as unknown as FormatFactory), params.onColumnResize ?? jest.fn(), params.onColumnHide ?? jest.fn(), - params.alignments ?? {}, + params.alignments ?? new Map(), params.headerRowHeight ?? RowHeightMode.auto, params.headerRowLines ?? 1, params.columnCellValueActions ?? [], diff --git a/x-pack/plugins/lens/public/visualizations/datatable/components/columns.tsx b/x-pack/plugins/lens/public/visualizations/datatable/components/columns.tsx index 6cd8c32db4b6d..8d2fcc9fac0c0 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/components/columns.tsx +++ b/x-pack/plugins/lens/public/visualizations/datatable/components/columns.tsx @@ -51,7 +51,7 @@ export const createGridColumns = ( formatFactory: FormatFactory, onColumnResize: (eventData: { columnId: string; width: number | undefined }) => void, onColumnHide: ((eventData: { columnId: string }) => void) | undefined, - alignments: Record, + alignments: Map, headerRowHeight: RowHeightMode, headerRowLines: number, columnCellValueActions: LensCellValueAction[][] | undefined, @@ -261,7 +261,7 @@ export const createGridColumns = ( }); } } - const currentAlignment = alignments && alignments[field]; + const currentAlignment = alignments && alignments.get(field); const hasMultipleRows = [RowHeightMode.auto, RowHeightMode.custom, undefined].includes( headerRowHeight ); diff --git a/x-pack/plugins/lens/public/visualizations/datatable/components/dimension_editor.test.tsx b/x-pack/plugins/lens/public/visualizations/datatable/components/dimension_editor.test.tsx index 09c7d95b309e7..738f7edab2a6e 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/components/dimension_editor.test.tsx +++ b/x-pack/plugins/lens/public/visualizations/datatable/components/dimension_editor.test.tsx @@ -6,25 +6,20 @@ */ import React from 'react'; -import { DEFAULT_COLOR_MAPPING_CONFIG, type PaletteRegistry } from '@kbn/coloring'; +import { DEFAULT_COLOR_MAPPING_CONFIG } from '@kbn/coloring'; import { act, render, screen } from '@testing-library/react'; import userEvent, { type UserEvent } from '@testing-library/user-event'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; import { LayerTypes } from '@kbn/expression-xy-plugin/public'; import { EuiButtonGroupTestHarness } from '@kbn/test-eui-helpers'; -import { - FramePublicAPI, - OperationDescriptor, - VisualizationDimensionEditorProps, - DatasourcePublicAPI, - DataType, -} from '../../../types'; +import { FramePublicAPI, DatasourcePublicAPI, OperationDescriptor } from '../../../types'; import { DatatableVisualizationState } from '../visualization'; import { createMockDatasource, createMockFramePublicAPI } from '../../../mocks'; -import { TableDimensionEditor } from './dimension_editor'; +import { TableDimensionEditor, TableDimensionEditorProps } from './dimension_editor'; import { ColumnState } from '../../../../common/expressions'; import { capitalize } from 'lodash'; import { I18nProvider } from '@kbn/i18n-react'; +import { DatatableColumnType } from '@kbn/expressions-plugin/common'; describe('data table dimension editor', () => { let user: UserEvent; @@ -35,10 +30,8 @@ describe('data table dimension editor', () => { alignment: EuiButtonGroupTestHarness; }; let mockOperationForFirstColumn: (overrides?: Partial) => void; - let props: VisualizationDimensionEditorProps & { - paletteService: PaletteRegistry; - isDarkMode: boolean; - }; + + let props: TableDimensionEditorProps; function testState(): DatatableVisualizationState { return { @@ -80,6 +73,7 @@ describe('data table dimension editor', () => { name: 'foo', meta: { type: 'string', + params: {}, }, }, ], @@ -114,13 +108,7 @@ describe('data table dimension editor', () => { mockOperationForFirstColumn(); }); - const renderTableDimensionEditor = ( - overrideProps?: Partial< - VisualizationDimensionEditorProps & { - paletteService: PaletteRegistry; - } - > - ) => { + const renderTableDimensionEditor = (overrideProps?: Partial) => { return render(, { wrapper: ({ children }) => ( @@ -137,11 +125,18 @@ describe('data table dimension editor', () => { }); it('should render default alignment for number', () => { - mockOperationForFirstColumn({ dataType: 'number' }); + frame.activeData!.first.columns[0].meta.type = 'number'; renderTableDimensionEditor(); expect(btnGroups.alignment.selected).toHaveTextContent('Right'); }); + it('should render default alignment for ranges', () => { + frame.activeData!.first.columns[0].meta.type = 'number'; + frame.activeData!.first.columns[0].meta.params = { id: 'range' }; + renderTableDimensionEditor(); + expect(btnGroups.alignment.selected).toHaveTextContent('Left'); + }); + it('should render specific alignment', () => { state.columns[0].alignment = 'center'; renderTableDimensionEditor(); @@ -181,10 +176,11 @@ describe('data table dimension editor', () => { expect(screen.queryByTestId('lns_dynamicColoring_edit')).not.toBeInTheDocument(); }); - it.each(['date'])( + it.each(['date'])( 'should not show the dynamic coloring option for "%s" columns', - (dataType) => { - mockOperationForFirstColumn({ dataType }); + (type) => { + frame.activeData!.first.columns[0].meta.type = type; + renderTableDimensionEditor(); expect(screen.queryByTestId('lnsDatatable_dynamicColoring_groups')).not.toBeInTheDocument(); expect(screen.queryByTestId('lns_dynamicColoring_edit')).not.toBeInTheDocument(); @@ -231,15 +227,16 @@ describe('data table dimension editor', () => { }); }); - it.each<{ flyout: 'terms' | 'values'; isBucketed: boolean; dataType: DataType }>([ - { flyout: 'terms', isBucketed: true, dataType: 'number' }, - { flyout: 'terms', isBucketed: false, dataType: 'string' }, - { flyout: 'values', isBucketed: false, dataType: 'number' }, + it.each<{ flyout: 'terms' | 'values'; isBucketed: boolean; type: DatatableColumnType }>([ + { flyout: 'terms', isBucketed: true, type: 'number' }, + { flyout: 'terms', isBucketed: false, type: 'string' }, + { flyout: 'values', isBucketed: false, type: 'number' }, ])( - 'should show color by $flyout flyout when bucketing is $isBucketed with $dataType column', - async ({ flyout, isBucketed, dataType }) => { + 'should show color by $flyout flyout when bucketing is $isBucketed with $type column', + async ({ flyout, isBucketed, type }) => { state.columns[0].colorMode = 'cell'; - mockOperationForFirstColumn({ isBucketed, dataType }); + frame.activeData!.first.columns[0].meta.type = type; + mockOperationForFirstColumn({ isBucketed }); renderTableDimensionEditor(); await user.click(screen.getByLabelText('Edit colors')); @@ -251,6 +248,7 @@ describe('data table dimension editor', () => { it('should show the dynamic coloring option for a bucketed operation', () => { state.columns[0].colorMode = 'cell'; + frame.activeData!.first.columns[0].meta.type = 'string'; mockOperationForFirstColumn({ isBucketed: true }); renderTableDimensionEditor(); diff --git a/x-pack/plugins/lens/public/visualizations/datatable/components/dimension_editor.tsx b/x-pack/plugins/lens/public/visualizations/datatable/components/dimension_editor.tsx index c1e097276cf3d..99fe3cc1c164e 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/components/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/visualizations/datatable/components/dimension_editor.tsx @@ -8,7 +8,7 @@ import React, { useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiSwitch, EuiButtonGroup, htmlIdGenerator } from '@elastic/eui'; -import { PaletteRegistry } from '@kbn/coloring'; +import { PaletteRegistry, getFallbackDataBounds } from '@kbn/coloring'; import { getColorCategories } from '@kbn/chart-expressions-common'; import { useDebouncedValue } from '@kbn/visualization-utils'; import type { VisualizationDimensionEditorProps } from '../../../types'; @@ -26,6 +26,11 @@ import './dimension_editor.scss'; import { CollapseSetting } from '../../../shared_components/collapse_setting'; import { ColorMappingByValues } from '../../../shared_components/coloring/color_mapping_by_values'; import { ColorMappingByTerms } from '../../../shared_components/coloring/color_mapping_by_terms'; +import { getColumnAlignment } from '../utils'; +import { + getFieldMetaFromDatatable, + isNumericField, +} from '../../../../common/expressions/datatable/utils'; const idPrefix = htmlIdGenerator()(); @@ -45,12 +50,13 @@ function updateColumn( }); } -export function TableDimensionEditor( - props: VisualizationDimensionEditorProps & { +export type TableDimensionEditorProps = + VisualizationDimensionEditorProps & { paletteService: PaletteRegistry; isDarkMode: boolean; - } -) { + }; + +export function TableDimensionEditor(props: TableDimensionEditorProps) { const { frame, accessor, isInlineEditing, isDarkMode } = props; const column = props.state.columns.find(({ columnId }) => accessor === columnId); const { inputValue: localState, handleInputChange: setLocalState } = @@ -74,12 +80,13 @@ export function TableDimensionEditor( const currentData = frame.activeData?.[localState.layerId]; const datasource = frame.datasourceLayers?.[localState.layerId]; - const { dataType, isBucketed } = datasource?.getOperationForColumnId(accessor) ?? {}; - const showColorByTerms = shouldColorByTerms(dataType, isBucketed); - const currentAlignment = column?.alignment || (dataType === 'number' ? 'right' : 'left'); + const { isBucketed } = datasource?.getOperationForColumnId(accessor) ?? {}; + const meta = getFieldMetaFromDatatable(currentData, accessor); + const showColorByTerms = shouldColorByTerms(meta?.type, isBucketed); + const currentAlignment = getColumnAlignment(column, isNumericField(meta)); const currentColorMode = column?.colorMode || 'none'; const hasDynamicColoring = currentColorMode !== 'none'; - const showDynamicColoringFeature = dataType !== 'date'; + const showDynamicColoringFeature = meta?.type !== 'date'; const visibleColumnsCount = localState.columns.filter((c) => !c.hidden).length; const hasTransposedColumn = localState.columns.some(({ isTransposed }) => isTransposed); @@ -88,7 +95,7 @@ export function TableDimensionEditor( [] : [accessor]; const minMaxByColumnId = findMinMaxByColumnId(columnsToCheck, currentData, getOriginalId); - const currentMinMax = minMaxByColumnId[accessor]; + const currentMinMax = minMaxByColumnId.get(accessor) ?? getFallbackDataBounds(); const activePalette = column?.palette ?? { type: 'palette', diff --git a/x-pack/plugins/lens/public/visualizations/datatable/components/table_basic.test.tsx b/x-pack/plugins/lens/public/visualizations/datatable/components/table_basic.test.tsx index 21361f874e83e..2358b9ec5b563 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/components/table_basic.test.tsx +++ b/x-pack/plugins/lens/public/visualizations/datatable/components/table_basic.test.tsx @@ -11,7 +11,6 @@ import userEvent from '@testing-library/user-event'; import { I18nProvider } from '@kbn/i18n-react'; import faker from 'faker'; import { act } from 'react-dom/test-utils'; -import { IAggType } from '@kbn/data-plugin/public'; import { IFieldFormat } from '@kbn/field-formats-plugin/common'; import { coreMock } from '@kbn/core/public/mocks'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; @@ -73,6 +72,17 @@ function sampleArgs() { sourceParams: { indexPatternId, type: 'count' }, }, }, + { + id: 'd', + name: 'd', + meta: { + type: 'number', + source: 'esaggs', + field: 'd', + params: { id: 'range' }, + sourceParams: { indexPatternId, type: 'range' }, + }, + }, ], rows: [{ a: 'shoes', b: 1588024800000, c: 3 }], }; @@ -119,7 +129,9 @@ describe('DatatableComponent', () => { args, formatFactory: () => ({ convert: (x) => x } as IFieldFormat), dispatchEvent: onDispatchEvent, - getType: jest.fn(() => ({ type: 'buckets' } as IAggType)), + getType: jest.fn().mockReturnValue({ + type: 'buckets', + }), paletteService: chartPluginMock.createPaletteRegistry(), theme: setUpMockTheme, renderMode: 'edit' as const, @@ -357,14 +369,39 @@ describe('DatatableComponent', () => { ]); }); - test('it adds alignment data to context', () => { + test('it adds explicit alignment to context', () => { renderDatatableComponent({ args: { ...args, columns: [ { columnId: 'a', alignment: 'center', type: 'lens_datatable_column', colorMode: 'none' }, + { columnId: 'b', alignment: 'center', type: 'lens_datatable_column', colorMode: 'none' }, + { columnId: 'c', alignment: 'center', type: 'lens_datatable_column', colorMode: 'none' }, + { columnId: 'd', alignment: 'center', type: 'lens_datatable_column', colorMode: 'none' }, + ], + }, + }); + const alignmentsClassNames = screen + .getAllByTestId('lnsTableCellContent') + .map((cell) => cell.className); + + expect(alignmentsClassNames).toEqual([ + 'lnsTableCell--center', // set via args + 'lnsTableCell--center', // set via args + 'lnsTableCell--center', // set via args + 'lnsTableCell--center', // set via args + ]); + }); + + test('it adds default alignment data to context', () => { + renderDatatableComponent({ + args: { + ...args, + columns: [ + { columnId: 'a', type: 'lens_datatable_column', colorMode: 'none' }, { columnId: 'b', type: 'lens_datatable_column', colorMode: 'none' }, { columnId: 'c', type: 'lens_datatable_column', colorMode: 'none' }, + { columnId: 'd', type: 'lens_datatable_column', colorMode: 'none' }, ], sortingColumnId: 'b', sortingDirection: 'desc', @@ -375,9 +412,10 @@ describe('DatatableComponent', () => { .map((cell) => cell.className); expect(alignmentsClassNames).toEqual([ - 'lnsTableCell--center', // set via args + 'lnsTableCell--left', // default for string 'lnsTableCell--left', // default for date 'lnsTableCell--right', // default for number + 'lnsTableCell--left', // default for range ]); }); diff --git a/x-pack/plugins/lens/public/visualizations/datatable/components/table_basic.tsx b/x-pack/plugins/lens/public/visualizations/datatable/components/table_basic.tsx index 83249f86ffa79..55e198b943e81 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/components/table_basic.tsx +++ b/x-pack/plugins/lens/public/visualizations/datatable/components/table_basic.tsx @@ -6,7 +6,7 @@ */ import './table_basic.scss'; -import { ColorMappingInputData, PaletteOutput } from '@kbn/coloring'; +import { ColorMappingInputData, PaletteOutput, getFallbackDataBounds } from '@kbn/coloring'; import React, { useLayoutEffect, useCallback, @@ -58,8 +58,12 @@ import { } from './table_actions'; import { getFinalSummaryConfiguration } from '../../../../common/expressions/datatable/summary'; import { DEFAULT_HEADER_ROW_HEIGHT, DEFAULT_HEADER_ROW_HEIGHT_LINES } from './constants'; -import { getFieldTypeFromDatatable } from '../../../../common/expressions/datatable/utils'; +import { + getFieldMetaFromDatatable, + isNumericField, +} from '../../../../common/expressions/datatable/utils'; import { CellColorFn, getCellColorFn } from '../../../shared_components/coloring/get_cell_color_fn'; +import { getColumnAlignment } from '../utils'; export const DataContext = React.createContext({}); @@ -229,10 +233,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { columnConfig.columns .filter((_col, index) => { const col = firstTableRef.current.columns[index]; - return ( - col?.meta?.sourceParams?.type && - getType(col.meta.sourceParams.type as string)?.type === 'buckets' - ); + return getType(col?.meta)?.type === 'buckets'; }) .map((col) => col.columnId), [firstTableRef, columnConfig, getType] @@ -240,7 +241,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { const isEmpty = firstLocalTable.rows.length === 0 || - (bucketedColumns.length && + (bucketedColumns.length > 0 && props.data.rows.every((row) => bucketedColumns.every((col) => row[col] == null))); const visibleColumns = useMemo( @@ -266,34 +267,26 @@ export const DatatableComponent = (props: DatatableRenderProps) => { [onEditAction, setColumnConfig, columnConfig, isInteractive] ); - const isNumericMap: Record = useMemo( + const isNumericMap: Map = useMemo( () => - firstLocalTable.columns.reduce>( - (map, column) => ({ - ...map, - [column.id]: column.meta.type === 'number', - }), - {} - ), - [firstLocalTable] + firstLocalTable.columns.reduce((acc, column) => { + acc.set(column.id, isNumericField(column.meta)); + return acc; + }, new Map()), + [firstLocalTable.columns] ); - const alignments: Record = useMemo(() => { - const alignmentMap: Record = {}; - columnConfig.columns.forEach((column) => { - if (column.alignment) { - alignmentMap[column.columnId] = column.alignment; - } else { - alignmentMap[column.columnId] = isNumericMap[column.columnId] ? 'right' : 'left'; - } - }); - return alignmentMap; - }, [columnConfig, isNumericMap]); + const alignments: Map = useMemo(() => { + return columnConfig.columns.reduce((acc, column) => { + acc.set(column.columnId, getColumnAlignment(column, isNumericMap.get(column.columnId))); + return acc; + }, new Map()); + }, [columnConfig.columns, isNumericMap]); - const minMaxByColumnId: Record = useMemo(() => { + const minMaxByColumnId: Map = useMemo(() => { return findMinMaxByColumnId( columnConfig.columns - .filter(({ columnId }) => isNumericMap[columnId]) + .filter(({ columnId }) => isNumericMap.get(columnId)) .map(({ columnId }) => columnId), props.data, getOriginalId @@ -402,7 +395,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { return cellColorFnMap.get(originalId)!; } - const dataType = getFieldTypeFromDatatable(firstLocalTable, originalId); + const dataType = getFieldMetaFromDatatable(firstLocalTable, originalId)?.type; const isBucketed = bucketedColumns.some((id) => id === columnId); const colorByTerms = shouldColorByTerms(dataType, isBucketed); @@ -419,7 +412,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { : { type: 'ranges', bins: 0, - ...minMaxByColumnId[originalId], + ...(minMaxByColumnId.get(originalId) ?? getFallbackDataBounds()), }; const colorFn = getCellColorFn( props.paletteService, @@ -491,7 +484,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { ]) ); return ({ columnId }: { columnId: string }) => { - const currentAlignment = alignments && alignments[columnId]; + const currentAlignment = alignments.get(columnId); const alignmentClassName = `lnsTableCell--${currentAlignment}`; const columnName = columns.find(({ id }) => id === columnId)?.displayAsText?.replace(/ /g, '-') || columnId; diff --git a/x-pack/plugins/lens/public/visualizations/datatable/components/types.ts b/x-pack/plugins/lens/public/visualizations/datatable/components/types.ts index b884a2c716be9..00d916bf956ae 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/components/types.ts +++ b/x-pack/plugins/lens/public/visualizations/datatable/components/types.ts @@ -8,7 +8,7 @@ import { CoreSetup } from '@kbn/core/public'; import type { PaletteRegistry } from '@kbn/coloring'; import type { IAggType } from '@kbn/data-plugin/public'; -import type { Datatable, RenderMode } from '@kbn/expressions-plugin/common'; +import type { Datatable, DatatableColumnMeta, RenderMode } from '@kbn/expressions-plugin/common'; import type { ILensInterpreterRenderHandlers, LensCellValueAction, @@ -49,7 +49,7 @@ export type LensPagesizeAction = LensEditEvent export type DatatableRenderProps = DatatableProps & { formatFactory: FormatFactory; dispatchEvent: ILensInterpreterRenderHandlers['event']; - getType: (name: string) => IAggType | undefined; + getType: (meta?: DatatableColumnMeta) => IAggType | undefined; renderMode: RenderMode; paletteService: PaletteRegistry; theme: CoreSetup['theme']; @@ -72,8 +72,8 @@ export type DatatableRenderProps = DatatableProps & { export interface DataContextType { table?: Datatable; rowHasRowClickTriggerActions?: boolean[]; - alignments?: Record; - minMaxByColumnId?: Record; + alignments?: Map; + minMaxByColumnId?: Map; handleFilterClick?: ( field: string, value: unknown, diff --git a/x-pack/plugins/lens/public/visualizations/datatable/expression.tsx b/x-pack/plugins/lens/public/visualizations/datatable/expression.tsx index 652abec75695e..a5927dd9183bf 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/expression.tsx +++ b/x-pack/plugins/lens/public/visualizations/datatable/expression.tsx @@ -13,6 +13,7 @@ import type { IAggType } from '@kbn/data-plugin/public'; import { CoreSetup, IUiSettingsClient } from '@kbn/core/public'; import type { Datatable, + DatatableColumnMeta, ExpressionRenderDefinition, IInterpreterRenderHandlers, } from '@kbn/expressions-plugin/common'; @@ -102,6 +103,11 @@ export const getDatatableRenderer = (dependencies: { handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode)); const resolvedGetType = await dependencies.getType; + const getType = (meta?: DatatableColumnMeta): IAggType | undefined => { + if (meta?.sourceParams?.type === undefined) return; + return resolvedGetType(String(meta.sourceParams.type)); + }; + const { hasCompatibleActions, isInteractive, getCompatibleCellValueActions } = handlers; const renderComplete = () => { @@ -161,7 +167,7 @@ export const getDatatableRenderer = (dependencies: { dispatchEvent={handlers.event} renderMode={handlers.getRenderMode()} paletteService={dependencies.paletteService} - getType={resolvedGetType} + getType={getType} rowHasRowClickTriggerActions={rowHasRowClickTriggerActions} columnCellValueActions={columnCellValueActions} columnFilterable={columnsFilterable} diff --git a/x-pack/plugins/lens/public/visualizations/datatable/index.ts b/x-pack/plugins/lens/public/visualizations/datatable/index.ts index f68f167ea5f02..93e5e38e03c3c 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/index.ts +++ b/x-pack/plugins/lens/public/visualizations/datatable/index.ts @@ -32,6 +32,7 @@ export class DatatableVisualization { '../../async_services' ); const palettes = await charts.palettes.getPalettes(); + expressions.registerRenderer(() => getDatatableRenderer({ formatFactory, @@ -44,7 +45,10 @@ export class DatatableVisualization { }) ); - return getDatatableVisualization({ paletteService: palettes, kibanaTheme: core.theme }); + return getDatatableVisualization({ + paletteService: palettes, + kibanaTheme: core.theme, + }); }); } } diff --git a/x-pack/plugins/lens/public/visualizations/datatable/utils.ts b/x-pack/plugins/lens/public/visualizations/datatable/utils.ts new file mode 100644 index 0000000000000..ab4d8f05f8d44 --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/datatable/utils.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export function getColumnAlignment( + { alignment }: C, + isNumeric = false +): 'left' | 'right' | 'center' { + if (alignment) return alignment; + return isNumeric ? 'right' : 'left'; +} diff --git a/x-pack/plugins/lens/public/visualizations/datatable/visualization.tsx b/x-pack/plugins/lens/public/visualizations/datatable/visualization.tsx index 0187776985a30..d2d23b2033f90 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/datatable/visualization.tsx @@ -147,8 +147,8 @@ export const getDatatableVisualization = ({ .map(({ id }) => id) || [] : [accessor]; const minMaxByColumnId = findMinMaxByColumnId(columnsToCheck, currentData, getOriginalId); - - if (palette && !showColorByTerms && !palette?.canDynamicColoring) { + const dataBounds = minMaxByColumnId.get(accessor); + if (palette && !showColorByTerms && !palette?.canDynamicColoring && dataBounds) { const newPalette: PaletteOutput = { type: 'palette', name: showColorByTerms ? 'default' : defaultPaletteParams.name, @@ -158,7 +158,7 @@ export const getDatatableVisualization = ({ palette: { ...newPalette, params: { - stops: applyPaletteParams(paletteService, newPalette, minMaxByColumnId[accessor]), + stops: applyPaletteParams(paletteService, newPalette, dataBounds), }, }, }; diff --git a/x-pack/plugins/lens/public/visualizations/heatmap/utils.ts b/x-pack/plugins/lens/public/visualizations/heatmap/utils.ts index 5e09ce2987bae..fe942dd40427c 100644 --- a/x-pack/plugins/lens/public/visualizations/heatmap/utils.ts +++ b/x-pack/plugins/lens/public/visualizations/heatmap/utils.ts @@ -26,7 +26,10 @@ export function getSafePaletteParams( accessor, }; const minMaxByColumnId = findMinMaxByColumnId([accessor], currentData); - const currentMinMax = minMaxByColumnId[accessor]; + const currentMinMax = minMaxByColumnId.get(accessor) ?? { + max: Number.NEGATIVE_INFINITY, + min: Number.POSITIVE_INFINITY, + }; // need to tell the helper that the colorStops are required to display return { diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.tsx index 34390075f927b..464b5bd196675 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.tsx @@ -18,6 +18,7 @@ import { apiHasExecutionContext, apiHasParentApi, apiPublishesTimeRange, + fetch$, initializeTimeRange, initializeTitles, useBatchedPublishingSubjects, @@ -26,7 +27,8 @@ import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import React, { useCallback, useState } from 'react'; import useUnmount from 'react-use/lib/useUnmount'; import type { Observable } from 'rxjs'; -import { BehaviorSubject, combineLatest, map, of, Subscription } from 'rxjs'; +import { BehaviorSubject, combineLatest, distinctUntilChanged, map, of, Subscription } from 'rxjs'; +import fastIsEqual from 'fast-deep-equal'; import type { AnomalySwimlaneEmbeddableServices } from '..'; import { ANOMALY_SWIMLANE_EMBEDDABLE_TYPE } from '..'; import type { MlDependencies } from '../../application/app'; @@ -235,6 +237,21 @@ export const getAnomalySwimLaneEmbeddableFactory = ( anomalySwimLaneServices ); + subscriptions.add( + fetch$(api) + .pipe( + map((fetchContext) => ({ + query: fetchContext.query, + filters: fetchContext.filters, + timeRange: fetchContext.timeRange, + })), + distinctUntilChanged(fastIsEqual) + ) + .subscribe(() => { + api.updatePagination({ fromPage: 1 }); + }) + ); + const onRenderComplete = () => {}; return { diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/initialize_swim_lane_data_fetcher.ts b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/initialize_swim_lane_data_fetcher.ts index 268a17fca4a81..be678af02a65b 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/initialize_swim_lane_data_fetcher.ts +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/initialize_swim_lane_data_fetcher.ts @@ -6,7 +6,7 @@ */ import type { estypes } from '@elastic/elasticsearch'; -import type { TimeRange } from '@kbn/es-query'; +import { type TimeRange } from '@kbn/es-query'; import type { PublishesUnifiedSearch } from '@kbn/presentation-publishing'; import { BehaviorSubject, @@ -29,7 +29,6 @@ import { SWIMLANE_TYPE, } from '../../application/explorer/explorer_constants'; import type { OverallSwimlaneData } from '../../application/explorer/explorer_utils'; -import { isViewBySwimLaneData } from '../../application/explorer/swimlane_container'; import { CONTROLLED_BY_SWIM_LANE_FILTER } from '../../ui_actions/constants'; import { getJobsObservable } from '../common/get_jobs_observable'; import { processFilters } from '../common/process_filters'; @@ -114,12 +113,7 @@ export const initializeSwimLaneDataFetcher = ( const { earliest, latest } = overallSwimlaneData; if (overallSwimlaneData && swimlaneType === SWIMLANE_TYPE.VIEW_BY) { - const swimlaneData = swimLaneData$.value; - - let swimLaneLimit = ANOMALY_SWIM_LANE_HARD_LIMIT; - if (isViewBySwimLaneData(swimlaneData) && viewBy === swimlaneData.fieldName) { - swimLaneLimit = swimlaneData.cardinality; - } + const swimLaneLimit = ANOMALY_SWIM_LANE_HARD_LIMIT; return from( anomalyTimelineService.loadViewBySwimlane( diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/service_logs/index.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/service_logs/index.tsx index 4df52758ceda3..a1dadbf186b91 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/app/service_logs/index.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/app/service_logs/index.tsx @@ -5,19 +5,36 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; import moment from 'moment'; +import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import { LogStream } from '@kbn/logs-shared-plugin/public'; import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; -import { useFetcher } from '../../../hooks/use_fetcher'; -import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; -import { APIReturnType } from '../../../services/rest/create_call_apm_api'; - import { CONTAINER_ID, SERVICE_ENVIRONMENT, SERVICE_NAME } from '../../../../common/es_fields/apm'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; +import { useKibana } from '../../../context/kibana_context/use_kibana'; import { useAnyOfApmParams } from '../../../hooks/use_apm_params'; +import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; import { useTimeRange } from '../../../hooks/use_time_range'; +import { APIReturnType } from '../../../services/rest/create_call_apm_api'; export function ServiceLogs() { + const { + services: { + logsShared: { LogsOverview }, + }, + } = useKibana(); + + const isLogsOverviewEnabled = LogsOverview.useIsEnabled(); + + if (isLogsOverviewEnabled) { + return ; + } else { + return ; + } +} + +export function ClassicServiceLogsStream() { const { serviceName } = useApmServiceContext(); const { @@ -58,6 +75,54 @@ export function ServiceLogs() { ); } +export function ServiceLogsOverview() { + const { + services: { logsShared }, + } = useKibana(); + const { serviceName } = useApmServiceContext(); + const { + query: { environment, kuery, rangeFrom, rangeTo }, + } = useAnyOfApmParams('/services/{serviceName}/logs'); + const { start, end } = useTimeRange({ rangeFrom, rangeTo }); + const timeRange = useMemo(() => ({ start, end }), [start, end]); + + const { data: logFilters, status } = useFetcher( + async (callApmApi) => { + if (start == null || end == null) { + return; + } + + const { containerIds } = await callApmApi( + 'GET /internal/apm/services/{serviceName}/infrastructure_attributes', + { + params: { + path: { serviceName }, + query: { + environment, + kuery, + start, + end, + }, + }, + } + ); + + return [getInfrastructureFilter({ containerIds, environment, serviceName })]; + }, + [environment, kuery, serviceName, start, end] + ); + + if (status === FETCH_STATUS.SUCCESS) { + return ; + } else if (status === FETCH_STATUS.FAILURE) { + return ( + + ); + } else { + return ; + } +} + export function getInfrastructureKQLFilter({ data, serviceName, @@ -84,3 +149,99 @@ export function getInfrastructureKQLFilter({ return [serviceNameAndEnvironmentCorrelation, ...containerIdCorrelation].join(' or '); } + +export function getInfrastructureFilter({ + containerIds, + environment, + serviceName, +}: { + containerIds: string[]; + environment: string; + serviceName: string; +}): QueryDslQueryContainer { + return { + bool: { + should: [ + ...getServiceShouldClauses({ environment, serviceName }), + ...getContainerShouldClauses({ containerIds }), + ], + minimum_should_match: 1, + }, + }; +} + +export function getServiceShouldClauses({ + environment, + serviceName, +}: { + environment: string; + serviceName: string; +}): QueryDslQueryContainer[] { + const serviceNameFilter: QueryDslQueryContainer = { + term: { + [SERVICE_NAME]: serviceName, + }, + }; + + if (environment === ENVIRONMENT_ALL.value) { + return [serviceNameFilter]; + } else { + return [ + { + bool: { + filter: [ + serviceNameFilter, + { + term: { + [SERVICE_ENVIRONMENT]: environment, + }, + }, + ], + }, + }, + { + bool: { + filter: [serviceNameFilter], + must_not: [ + { + exists: { + field: SERVICE_ENVIRONMENT, + }, + }, + ], + }, + }, + ]; + } +} + +export function getContainerShouldClauses({ + containerIds = [], +}: { + containerIds: string[]; +}): QueryDslQueryContainer[] { + if (containerIds.length === 0) { + return []; + } + + return [ + { + bool: { + filter: [ + { + terms: { + [CONTAINER_ID]: containerIds, + }, + }, + ], + must_not: [ + { + term: { + [SERVICE_NAME]: '*', + }, + }, + ], + }, + }, + ]; +} diff --git a/x-pack/plugins/observability_solution/apm/public/components/routing/service_detail/index.tsx b/x-pack/plugins/observability_solution/apm/public/components/routing/service_detail/index.tsx index d746e0464fd40..8a4a1c32877c5 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/routing/service_detail/index.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/routing/service_detail/index.tsx @@ -330,7 +330,7 @@ export const serviceDetailRoute = { }), element: , searchBarOptions: { - showUnifiedSearchBar: false, + showQueryInput: false, }, }), '/services/{serviceName}/infrastructure': { diff --git a/x-pack/plugins/observability_solution/apm/public/plugin.ts b/x-pack/plugins/observability_solution/apm/public/plugin.ts index 9a9f45f42a39e..b21bdedac9ef8 100644 --- a/x-pack/plugins/observability_solution/apm/public/plugin.ts +++ b/x-pack/plugins/observability_solution/apm/public/plugin.ts @@ -69,6 +69,7 @@ import { from } from 'rxjs'; import { map } from 'rxjs'; import type { CloudSetup } from '@kbn/cloud-plugin/public'; import type { ServerlessPluginStart } from '@kbn/serverless/public'; +import { LogsSharedClientStartExports } from '@kbn/logs-shared-plugin/public'; import type { ConfigSchema } from '.'; import { registerApmRuleTypes } from './components/alerting/rule_types/register_apm_rule_types'; import { registerEmbeddables } from './embeddable/register_embeddables'; @@ -142,6 +143,7 @@ export interface ApmPluginStartDeps { dashboard: DashboardStart; metricsDataAccess: MetricsDataPluginStart; uiSettings: IUiSettingsClient; + logsShared: LogsSharedClientStartExports; } const applicationsTitle = i18n.translate('xpack.apm.navigation.rootTitle', { diff --git a/x-pack/plugins/observability_solution/infra/public/components/logging/log_analysis_setup/missing_results_privileges_prompt.tsx b/x-pack/plugins/observability_solution/infra/public/components/logging/log_analysis_setup/missing_results_privileges_prompt.tsx index 97eeeabe8721b..dce819ffb0930 100644 --- a/x-pack/plugins/observability_solution/infra/public/components/logging/log_analysis_setup/missing_results_privileges_prompt.tsx +++ b/x-pack/plugins/observability_solution/infra/public/components/logging/log_analysis_setup/missing_results_privileges_prompt.tsx @@ -16,6 +16,7 @@ import { UserManagementLink } from './user_management_link'; export const MissingResultsPrivilegesPrompt: React.FunctionComponent = () => ( {missingMlPrivilegesTitle}} body={

    {missingMlResultsPrivilegesDescription}

    } actions={} diff --git a/x-pack/plugins/observability_solution/infra/public/components/logging/log_analysis_setup/missing_setup_privileges_prompt.tsx b/x-pack/plugins/observability_solution/infra/public/components/logging/log_analysis_setup/missing_setup_privileges_prompt.tsx index f959c5035d1a4..4e2a360b55ceb 100644 --- a/x-pack/plugins/observability_solution/infra/public/components/logging/log_analysis_setup/missing_setup_privileges_prompt.tsx +++ b/x-pack/plugins/observability_solution/infra/public/components/logging/log_analysis_setup/missing_setup_privileges_prompt.tsx @@ -16,6 +16,7 @@ import { UserManagementLink } from './user_management_link'; export const MissingSetupPrivilegesPrompt: React.FunctionComponent = () => ( {missingMlPrivilegesTitle}} body={

    {missingMlSetupPrivilegesDescription}

    } actions={} diff --git a/x-pack/plugins/observability_solution/infra/public/pages/logs/log_entry_categories/page.tsx b/x-pack/plugins/observability_solution/infra/public/pages/logs/log_entry_categories/page.tsx index f5b1e89c69e0b..650a5b119d755 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/logs/log_entry_categories/page.tsx +++ b/x-pack/plugins/observability_solution/infra/public/pages/logs/log_entry_categories/page.tsx @@ -7,8 +7,11 @@ import { EuiErrorBoundary } from '@elastic/eui'; import React from 'react'; +import { MissingResultsPrivilegesPrompt } from '../../../components/logging/log_analysis_setup'; +import { useLogAnalysisCapabilitiesContext } from '../../../containers/logs/log_analysis'; +import { SubscriptionSplashPage } from '../../../components/subscription_splash_content'; import { useLogsBreadcrumbs } from '../../../hooks/use_logs_breadcrumbs'; -import { LogEntryCategoriesPageContent } from './page_content'; +import { CategoriesPageTemplate, LogEntryCategoriesPageContent } from './page_content'; import { LogEntryCategoriesPageProviders } from './page_providers'; import { logCategoriesTitle } from '../../../translations'; import { LogMlJobIdFormatsShimProvider } from '../shared/use_log_ml_job_id_formats_shim'; @@ -20,6 +23,28 @@ export const LogEntryCategoriesPage = () => { }, ]); + const { hasLogAnalysisReadCapabilities, hasLogAnalysisCapabilites } = + useLogAnalysisCapabilitiesContext(); + + if (!hasLogAnalysisCapabilites) { + return ( + + ); + } + + if (!hasLogAnalysisReadCapabilities) { + return ( + + + + ); + } + return ( diff --git a/x-pack/plugins/observability_solution/infra/public/pages/logs/log_entry_categories/page_content.tsx b/x-pack/plugins/observability_solution/infra/public/pages/logs/log_entry_categories/page_content.tsx index c58ffc5f36e84..8059cdcb093e2 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/logs/log_entry_categories/page_content.tsx +++ b/x-pack/plugins/observability_solution/infra/public/pages/logs/log_entry_categories/page_content.tsx @@ -13,14 +13,12 @@ import { isJobStatusWithResults, logEntryCategoriesJobType } from '../../../../c import { LoadingPage } from '../../../components/loading_page'; import { LogAnalysisSetupStatusUnknownPrompt, - MissingResultsPrivilegesPrompt, MissingSetupPrivilegesPrompt, } from '../../../components/logging/log_analysis_setup'; import { LogAnalysisSetupFlyout, useLogAnalysisSetupFlyoutStateContext, } from '../../../components/logging/log_analysis_setup/setup_flyout'; -import { SubscriptionSplashPage } from '../../../components/subscription_splash_content'; import { useLogAnalysisCapabilitiesContext } from '../../../containers/logs/log_analysis'; import { useLogEntryCategoriesModuleContext } from '../../../containers/logs/log_analysis/modules/log_entry_categories'; import { LogsPageTemplate } from '../shared/page_template'; @@ -33,11 +31,8 @@ const logCategoriesTitle = i18n.translate('xpack.infra.logs.logCategoriesTitle', }); export const LogEntryCategoriesPageContent = () => { - const { - hasLogAnalysisCapabilites, - hasLogAnalysisReadCapabilities, - hasLogAnalysisSetupCapabilities, - } = useLogAnalysisCapabilitiesContext(); + const { hasLogAnalysisReadCapabilities, hasLogAnalysisSetupCapabilities } = + useLogAnalysisCapabilitiesContext(); const { fetchJobStatus, setupStatus, jobStatus } = useLogEntryCategoriesModuleContext(); @@ -55,22 +50,7 @@ export const LogEntryCategoriesPageContent = () => { const { idFormats } = useLogMlJobIdFormatsShimContext(); - if (!hasLogAnalysisCapabilites) { - return ( - - ); - } else if (!hasLogAnalysisReadCapabilities) { - return ( - - - - ); - } else if (setupStatus.type === 'initializing') { + if (setupStatus.type === 'initializing') { return ( { const allowedSetupModules = ['logs_ui_categories' as const]; -const CategoriesPageTemplate: React.FC = ({ +export const CategoriesPageTemplate: React.FC = ({ children, ...rest }) => { diff --git a/x-pack/plugins/observability_solution/infra/public/pages/logs/log_entry_rate/page.tsx b/x-pack/plugins/observability_solution/infra/public/pages/logs/log_entry_rate/page.tsx index 97841745ae13a..ed46ea9dc2680 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/logs/log_entry_rate/page.tsx +++ b/x-pack/plugins/observability_solution/infra/public/pages/logs/log_entry_rate/page.tsx @@ -7,7 +7,10 @@ import { EuiErrorBoundary } from '@elastic/eui'; import React from 'react'; -import { LogEntryRatePageContent } from './page_content'; +import { SubscriptionSplashPage } from '../../../components/subscription_splash_content'; +import { MissingResultsPrivilegesPrompt } from '../../../components/logging/log_analysis_setup'; +import { useLogAnalysisCapabilitiesContext } from '../../../containers/logs/log_analysis'; +import { AnomaliesPageTemplate, LogEntryRatePageContent } from './page_content'; import { LogEntryRatePageProviders } from './page_providers'; import { useLogsBreadcrumbs } from '../../../hooks/use_logs_breadcrumbs'; import { logsAnomaliesTitle } from '../../../translations'; @@ -19,6 +22,29 @@ export const LogEntryRatePage = () => { text: logsAnomaliesTitle, }, ]); + + const { hasLogAnalysisReadCapabilities, hasLogAnalysisCapabilites } = + useLogAnalysisCapabilitiesContext(); + + if (!hasLogAnalysisCapabilites) { + return ( + + ); + } + + if (!hasLogAnalysisReadCapabilities) { + return ( + + + + ); + } + return ( diff --git a/x-pack/plugins/observability_solution/infra/public/pages/logs/log_entry_rate/page_content.tsx b/x-pack/plugins/observability_solution/infra/public/pages/logs/log_entry_rate/page_content.tsx index 3ac8d7d9137d1..350094b5df6a3 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/logs/log_entry_rate/page_content.tsx +++ b/x-pack/plugins/observability_solution/infra/public/pages/logs/log_entry_rate/page_content.tsx @@ -18,14 +18,12 @@ import { import { LoadingPage } from '../../../components/loading_page'; import { LogAnalysisSetupStatusUnknownPrompt, - MissingResultsPrivilegesPrompt, MissingSetupPrivilegesPrompt, } from '../../../components/logging/log_analysis_setup'; import { LogAnalysisSetupFlyout, useLogAnalysisSetupFlyoutStateContext, } from '../../../components/logging/log_analysis_setup/setup_flyout'; -import { SubscriptionSplashPage } from '../../../components/subscription_splash_content'; import { useLogAnalysisCapabilitiesContext } from '../../../containers/logs/log_analysis'; import { useLogEntryCategoriesModuleContext } from '../../../containers/logs/log_analysis/modules/log_entry_categories'; import { useLogEntryRateModuleContext } from '../../../containers/logs/log_analysis/modules/log_entry_rate'; @@ -41,11 +39,8 @@ const logsAnomaliesTitle = i18n.translate('xpack.infra.logs.anomaliesPageTitle', }); export const LogEntryRatePageContent = memo(() => { - const { - hasLogAnalysisCapabilites, - hasLogAnalysisReadCapabilities, - hasLogAnalysisSetupCapabilities, - } = useLogAnalysisCapabilitiesContext(); + const { hasLogAnalysisReadCapabilities, hasLogAnalysisSetupCapabilities } = + useLogAnalysisCapabilitiesContext(); const { fetchJobStatus: fetchLogEntryCategoriesJobStatus, @@ -96,22 +91,7 @@ export const LogEntryRatePageContent = memo(() => { const { idFormats } = useLogMlJobIdFormatsShimContext(); - if (!hasLogAnalysisCapabilites) { - return ( - - ); - } else if (!hasLogAnalysisReadCapabilities) { - return ( - - - - ); - } else if ( + if ( logEntryCategoriesSetupStatus.type === 'initializing' || logEntryRateSetupStatus.type === 'initializing' ) { @@ -159,7 +139,7 @@ export const LogEntryRatePageContent = memo(() => { } }); -const AnomaliesPageTemplate: React.FC = ({ +export const AnomaliesPageTemplate: React.FC = ({ children, ...rest }) => { diff --git a/x-pack/plugins/observability_solution/infra/public/pages/metrics/hosts/components/tabs/logs/logs_tab_content.tsx b/x-pack/plugins/observability_solution/infra/public/pages/metrics/hosts/components/tabs/logs/logs_tab_content.tsx index 27344ccd1f108..68a5db6d4d484 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/metrics/hosts/components/tabs/logs/logs_tab_content.tsx +++ b/x-pack/plugins/observability_solution/infra/public/pages/metrics/hosts/components/tabs/logs/logs_tab_content.tsx @@ -5,21 +5,37 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { LogStream } from '@kbn/logs-shared-plugin/public'; -import { i18n } from '@kbn/i18n'; +import React, { useMemo } from 'react'; import { InfraLoadingPanel } from '../../../../../../components/loading'; +import { useKibanaContextForPlugin } from '../../../../../../hooks/use_kibana'; +import { useLogViewReference } from '../../../../../../hooks/use_log_view_reference'; +import { buildCombinedAssetFilter } from '../../../../../../utils/filters/build'; import { useHostsViewContext } from '../../../hooks/use_hosts_view'; -import { useUnifiedSearchContext } from '../../../hooks/use_unified_search'; import { useLogsSearchUrlState } from '../../../hooks/use_logs_search_url_state'; +import { useUnifiedSearchContext } from '../../../hooks/use_unified_search'; import { LogsLinkToStream } from './logs_link_to_stream'; import { LogsSearchBar } from './logs_search_bar'; -import { buildCombinedAssetFilter } from '../../../../../../utils/filters/build'; -import { useLogViewReference } from '../../../../../../hooks/use_log_view_reference'; export const LogsTabContent = () => { + const { + services: { + logsShared: { LogsOverview }, + }, + } = useKibanaContextForPlugin(); + const isLogsOverviewEnabled = LogsOverview.useIsEnabled(); + if (isLogsOverviewEnabled) { + return ; + } else { + return ; + } +}; + +export const LogsTabLogStreamContent = () => { const [filterQuery] = useLogsSearchUrlState(); const { getDateRangeAsTimestamp } = useUnifiedSearchContext(); const { from, to } = useMemo(() => getDateRangeAsTimestamp(), [getDateRangeAsTimestamp]); @@ -53,22 +69,7 @@ export const LogsTabContent = () => { }, [filterQuery.query, hostNodes]); if (loading || logViewLoading || !logView) { - return ( - - - - } - /> - - - ); + return ; } return ( @@ -112,3 +113,53 @@ const createHostsFilterQueryParam = (hostNodes: string[]): string => { return hostsQueryParam; }; + +const LogsTabLogsOverviewContent = () => { + const { + services: { + logsShared: { LogsOverview }, + }, + } = useKibanaContextForPlugin(); + + const { parsedDateRange } = useUnifiedSearchContext(); + const timeRange = useMemo( + () => ({ start: parsedDateRange.from, end: parsedDateRange.to }), + [parsedDateRange.from, parsedDateRange.to] + ); + + const { hostNodes, loading, error } = useHostsViewContext(); + const logFilters = useMemo( + () => [ + buildCombinedAssetFilter({ + field: 'host.name', + values: hostNodes.map((p) => p.name), + }).query as QueryDslQueryContainer, + ], + [hostNodes] + ); + + if (loading) { + return ; + } else if (error != null) { + return ; + } else { + return ; + } +}; + +const LogsTabLoadingContent = () => ( + + + + } + /> + + +); diff --git a/x-pack/plugins/observability_solution/inventory/.storybook/get_mock_inventory_context.tsx b/x-pack/plugins/observability_solution/inventory/.storybook/get_mock_inventory_context.tsx index 51aaeebc655f2..d90ce08aab1c6 100644 --- a/x-pack/plugins/observability_solution/inventory/.storybook/get_mock_inventory_context.tsx +++ b/x-pack/plugins/observability_solution/inventory/.storybook/get_mock_inventory_context.tsx @@ -13,6 +13,7 @@ import type { InferencePublicStart } from '@kbn/inference-plugin/public'; import type { ObservabilitySharedPluginStart } from '@kbn/observability-shared-plugin/public'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; import type { SharePluginStart } from '@kbn/share-plugin/public'; +import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; import type { InventoryKibanaContext } from '../public/hooks/use_kibana'; import type { ITelemetryClient } from '../public/services/telemetry/types'; @@ -33,5 +34,6 @@ export function getMockInventoryContext(): InventoryKibanaContext { fetch: jest.fn(), stream: jest.fn(), }, + spaces: {} as unknown as SpacesPluginStart, }; } diff --git a/x-pack/plugins/observability_solution/inventory/common/entities.ts b/x-pack/plugins/observability_solution/inventory/common/entities.ts index 218e3d50905a9..40fae48cb9dc3 100644 --- a/x-pack/plugins/observability_solution/inventory/common/entities.ts +++ b/x-pack/plugins/observability_solution/inventory/common/entities.ts @@ -78,26 +78,27 @@ interface BaseEntity { [ENTITY_TYPE]: EntityType; [ENTITY_DISPLAY_NAME]: string; [ENTITY_DEFINITION_ID]: string; - [ENTITY_IDENTITY_FIELDS]: string[]; + [ENTITY_IDENTITY_FIELDS]: string | string[]; + [key: string]: any; } /** * These types are based on service, host and container from the built in definition. */ -interface ServiceEntity extends BaseEntity { +export interface ServiceEntity extends BaseEntity { [ENTITY_TYPE]: 'service'; [SERVICE_NAME]: string; [SERVICE_ENVIRONMENT]?: string | string[] | null; [AGENT_NAME]: string | string[] | null; } -interface HostEntity extends BaseEntity { +export interface HostEntity extends BaseEntity { [ENTITY_TYPE]: 'host'; [HOST_NAME]: string; [CLOUD_PROVIDER]: string | string[] | null; } -interface ContainerEntity extends BaseEntity { +export interface ContainerEntity extends BaseEntity { [ENTITY_TYPE]: 'container'; [CONTAINER_ID]: string; [CLOUD_PROVIDER]: string | string[] | null; diff --git a/x-pack/plugins/observability_solution/inventory/kibana.jsonc b/x-pack/plugins/observability_solution/inventory/kibana.jsonc index f60cf36183b24..28556c7bcc583 100644 --- a/x-pack/plugins/observability_solution/inventory/kibana.jsonc +++ b/x-pack/plugins/observability_solution/inventory/kibana.jsonc @@ -19,7 +19,7 @@ "share" ], "requiredBundles": ["kibanaReact"], - "optionalPlugins": [], + "optionalPlugins": ["spaces"], "extraPublicDirs": [] } } diff --git a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entity_name/entity_name.test.tsx b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entity_name/entity_name.test.tsx new file mode 100644 index 0000000000000..36aad3d8e3a97 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entity_name/entity_name.test.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 { type KibanaReactContextValue } from '@kbn/kibana-react-plugin/public'; +import * as useKibana from '../../../hooks/use_kibana'; +import { EntityName } from '.'; +import { ContainerEntity, HostEntity, ServiceEntity } from '../../../../common/entities'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { ASSET_DETAILS_LOCATOR_ID } from '@kbn/observability-shared-plugin/common/locators/infra/asset_details_locator'; + +describe('EntityName', () => { + jest.spyOn(useKibana, 'useKibana').mockReturnValue({ + services: { + share: { + url: { + locators: { + get: (locatorId: string) => { + return { + getRedirectUrl: (params: { [key: string]: any }) => { + if (locatorId === ASSET_DETAILS_LOCATOR_ID) { + return `assets_url/${params.assetType}/${params.assetId}`; + } + return `services_url/${params.serviceName}?environment=${params.environment}`; + }, + }; + }, + }, + }, + }, + }, + } as unknown as KibanaReactContextValue); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('returns host link', () => { + const entity: HostEntity = { + 'entity.lastSeenTimestamp': 'foo', + 'entity.id': '1', + 'entity.type': 'host', + 'entity.displayName': 'foo', + 'entity.identityFields': 'host.name', + 'host.name': 'foo', + 'entity.definitionId': 'host', + 'cloud.provider': null, + }; + render(); + expect(screen.queryByTestId('entityNameLink')?.getAttribute('href')).toEqual( + 'assets_url/host/foo' + ); + expect(screen.queryByTestId('entityNameDisplayName')?.textContent).toEqual('foo'); + }); + + it('returns container link', () => { + const entity: ContainerEntity = { + 'entity.lastSeenTimestamp': 'foo', + 'entity.id': '1', + 'entity.type': 'container', + 'entity.displayName': 'foo', + 'entity.identityFields': 'container.id', + 'container.id': 'foo', + 'entity.definitionId': 'container', + 'cloud.provider': null, + }; + render(); + expect(screen.queryByTestId('entityNameLink')?.getAttribute('href')).toEqual( + 'assets_url/container/foo' + ); + expect(screen.queryByTestId('entityNameDisplayName')?.textContent).toEqual('foo'); + }); + + it('returns service link without environment', () => { + const entity: ServiceEntity = { + 'entity.lastSeenTimestamp': 'foo', + 'entity.id': '1', + 'entity.type': 'service', + 'entity.displayName': 'foo', + 'entity.identityFields': 'service.name', + 'service.name': 'foo', + 'entity.definitionId': 'service', + 'agent.name': 'bar', + }; + render(); + expect(screen.queryByTestId('entityNameLink')?.getAttribute('href')).toEqual( + 'services_url/foo?environment=undefined' + ); + expect(screen.queryByTestId('entityNameDisplayName')?.textContent).toEqual('foo'); + }); + + it('returns service link with environment', () => { + const entity: ServiceEntity = { + 'entity.lastSeenTimestamp': 'foo', + 'entity.id': '1', + 'entity.type': 'service', + 'entity.displayName': 'foo', + 'entity.identityFields': 'service.name', + 'service.name': 'foo', + 'entity.definitionId': 'service', + 'agent.name': 'bar', + 'service.environment': 'baz', + }; + render(); + expect(screen.queryByTestId('entityNameLink')?.getAttribute('href')).toEqual( + 'services_url/foo?environment=baz' + ); + expect(screen.queryByTestId('entityNameDisplayName')?.textContent).toEqual('foo'); + }); + + it('returns service link with first environment when it is an array', () => { + const entity: ServiceEntity = { + 'entity.lastSeenTimestamp': 'foo', + 'entity.id': '1', + 'entity.type': 'service', + 'entity.displayName': 'foo', + 'entity.identityFields': 'service.name', + 'service.name': 'foo', + 'entity.definitionId': 'service', + 'agent.name': 'bar', + 'service.environment': ['baz', 'bar', 'foo'], + }; + render(); + expect(screen.queryByTestId('entityNameLink')?.getAttribute('href')).toEqual( + 'services_url/foo?environment=baz' + ); + expect(screen.queryByTestId('entityNameDisplayName')?.textContent).toEqual('foo'); + }); + + it('returns service link identity fields is an array', () => { + const entity: ServiceEntity = { + 'entity.lastSeenTimestamp': 'foo', + 'entity.id': '1', + 'entity.type': 'service', + 'entity.displayName': 'foo', + 'entity.identityFields': ['service.name', 'service.environment'], + 'service.name': 'foo', + 'entity.definitionId': 'service', + 'agent.name': 'bar', + 'service.environment': 'baz', + }; + render(); + expect(screen.queryByTestId('entityNameLink')?.getAttribute('href')).toEqual( + 'services_url/foo?environment=baz' + ); + expect(screen.queryByTestId('entityNameDisplayName')?.textContent).toEqual('foo'); + }); +}); diff --git a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entity_name/index.tsx b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entity_name/index.tsx index debe91d52dec1..f3488dfddbc4e 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entity_name/index.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entity_name/index.tsx @@ -36,33 +36,37 @@ export function EntityName({ entity }: EntityNameProps) { const getEntityRedirectUrl = useCallback(() => { const type = entity[ENTITY_TYPE]; // For service, host and container type there is only one identity field - const identityField = entity[ENTITY_IDENTITY_FIELDS][0]; + const identityField = Array.isArray(entity[ENTITY_IDENTITY_FIELDS]) + ? entity[ENTITY_IDENTITY_FIELDS][0] + : entity[ENTITY_IDENTITY_FIELDS]; + const identityValue = entity[identityField]; - // Any unrecognised types will always return undefined switch (type) { case 'host': case 'container': return assetDetailsLocator?.getRedirectUrl({ - assetId: identityField, + assetId: identityValue, assetType: type, }); case 'service': return serviceOverviewLocator?.getRedirectUrl({ - serviceName: identityField, + serviceName: identityValue, environment: [entity[SERVICE_ENVIRONMENT] || undefined].flat()[0], }); } }, [entity, assetDetailsLocator, serviceOverviewLocator]); return ( - + - {entity[ENTITY_DISPLAY_NAME]} + + {entity[ENTITY_DISPLAY_NAME]} + diff --git a/x-pack/plugins/observability_solution/inventory/public/plugin.ts b/x-pack/plugins/observability_solution/inventory/public/plugin.ts index c02a57b45f691..30e3a1eed3681 100644 --- a/x-pack/plugins/observability_solution/inventory/public/plugin.ts +++ b/x-pack/plugins/observability_solution/inventory/public/plugin.ts @@ -17,7 +17,7 @@ import { import { INVENTORY_APP_ID } from '@kbn/deeplinks-observability/constants'; import { i18n } from '@kbn/i18n'; import type { Logger } from '@kbn/logging'; -import { from, map } from 'rxjs'; +import { from, map, mergeMap, of } from 'rxjs'; import { createCallInventoryAPI } from './api'; import { TelemetryService } from './services/telemetry/telemetry_service'; import { InventoryServices } from './services/types'; @@ -54,34 +54,53 @@ export class InventoryPlugin 'observability:entityCentricExperience', true ); + const getStartServices = coreSetup.getStartServices(); - if (isEntityCentricExperienceSettingEnabled) { - pluginsSetup.observabilityShared.navigation.registerSections( - from(coreSetup.getStartServices()).pipe( - map(([coreStart, pluginsStart]) => { - return [ - { - label: '', - sortKey: 300, - entries: [ - { - label: i18n.translate('xpack.inventory.inventoryLinkTitle', { - defaultMessage: 'Inventory', - }), - app: INVENTORY_APP_ID, - path: '/', - matchPath(currentPath: string) { - return ['/', ''].some((testPath) => currentPath.startsWith(testPath)); - }, - isTechnicalPreview: true, + const hideInventory$ = from(getStartServices).pipe( + mergeMap(([coreStart, pluginsStart]) => { + if (pluginsStart.spaces) { + return from(pluginsStart.spaces.getActiveSpace()).pipe( + map( + (space) => + space.disabledFeatures.includes(INVENTORY_APP_ID) || + !coreStart.application.capabilities.inventory.show + ) + ); + } + + return of(!coreStart.application.capabilities.inventory.show); + }) + ); + + const sections$ = hideInventory$.pipe( + map((hideInventory) => { + if (isEntityCentricExperienceSettingEnabled && !hideInventory) { + return [ + { + label: '', + sortKey: 300, + entries: [ + { + label: i18n.translate('xpack.inventory.inventoryLinkTitle', { + defaultMessage: 'Inventory', + }), + app: INVENTORY_APP_ID, + path: '/', + matchPath(currentPath: string) { + return ['/', ''].some((testPath) => currentPath.startsWith(testPath)); }, - ], - }, - ]; - }) - ) - ); - } + isTechnicalPreview: true, + }, + ], + }, + ]; + } + return []; + }) + ); + + pluginsSetup.observabilityShared.navigation.registerSections(sections$); + this.telemetry.setup({ analytics: coreSetup.analytics }); const telemetry = this.telemetry.start(); @@ -102,7 +121,7 @@ export class InventoryPlugin // Load application bundle and Get start services const [{ renderApp }, [coreStart, pluginsStart]] = await Promise.all([ import('./application'), - coreSetup.getStartServices(), + getStartServices, ]); const services: InventoryServices = { diff --git a/x-pack/plugins/observability_solution/inventory/public/types.ts b/x-pack/plugins/observability_solution/inventory/public/types.ts index 2393b1b55e2b6..48fe7e7eed1c7 100644 --- a/x-pack/plugins/observability_solution/inventory/public/types.ts +++ b/x-pack/plugins/observability_solution/inventory/public/types.ts @@ -17,6 +17,7 @@ import type { SharePluginSetup, SharePluginStart } from '@kbn/share-plugin/publi import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import type { DataPublicPluginSetup, DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; /* eslint-disable @typescript-eslint/no-empty-interface*/ @@ -38,6 +39,7 @@ export interface InventoryStartDependencies { data: DataPublicPluginStart; entityManager: EntityManagerPublicPluginStart; share: SharePluginStart; + spaces?: SpacesPluginStart; } export interface InventoryPublicSetup {} diff --git a/x-pack/plugins/observability_solution/inventory/tsconfig.json b/x-pack/plugins/observability_solution/inventory/tsconfig.json index 54fcfe7e3a11f..20b5e2e37232a 100644 --- a/x-pack/plugins/observability_solution/inventory/tsconfig.json +++ b/x-pack/plugins/observability_solution/inventory/tsconfig.json @@ -45,6 +45,7 @@ "@kbn/config-schema", "@kbn/elastic-agent-utils", "@kbn/custom-icons", - "@kbn/ui-theme" + "@kbn/ui-theme", + "@kbn/spaces-plugin" ] } diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/get_entities.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/get_entities.ts index 8f3a0abb62b67..00151f2029d21 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/services/get_entities.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/get_entities.ts @@ -61,7 +61,6 @@ export async function getEntitiesWithSource({ identityFields: entity?.entity.identityFields, id: entity?.entity.id, definitionId: entity?.entity.definitionId, - firstSeenTimestamp: entity?.entity.firstSeenTimestamp, lastSeenTimestamp: entity?.entity.lastSeenTimestamp, displayName: entity?.entity.displayName, metrics: entity?.entity.metrics, diff --git a/x-pack/plugins/observability_solution/logs_shared/kibana.jsonc b/x-pack/plugins/observability_solution/logs_shared/kibana.jsonc index ea93fd326dac7..10c8fe32cfe9c 100644 --- a/x-pack/plugins/observability_solution/logs_shared/kibana.jsonc +++ b/x-pack/plugins/observability_solution/logs_shared/kibana.jsonc @@ -9,13 +9,14 @@ "browser": true, "configPath": ["xpack", "logs_shared"], "requiredPlugins": [ + "charts", "data", "dataViews", "discoverShared", - "usageCollection", + "logsDataAccess", "observabilityShared", "share", - "logsDataAccess" + "usageCollection", ], "optionalPlugins": [ "observabilityAIAssistant", diff --git a/x-pack/plugins/observability_solution/logs_shared/public/components/logs_overview/index.tsx b/x-pack/plugins/observability_solution/logs_shared/public/components/logs_overview/index.tsx new file mode 100644 index 0000000000000..627cdc8447eea --- /dev/null +++ b/x-pack/plugins/observability_solution/logs_shared/public/components/logs_overview/index.tsx @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './logs_overview'; diff --git a/x-pack/plugins/observability_solution/logs_shared/public/components/logs_overview/logs_overview.mock.tsx b/x-pack/plugins/observability_solution/logs_shared/public/components/logs_overview/logs_overview.mock.tsx new file mode 100644 index 0000000000000..435766bff793d --- /dev/null +++ b/x-pack/plugins/observability_solution/logs_shared/public/components/logs_overview/logs_overview.mock.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 React from 'react'; +import type { + LogsOverviewProps, + SelfContainedLogsOverviewComponent, + SelfContainedLogsOverviewHelpers, +} from './logs_overview'; + +export const createLogsOverviewMock = () => { + const LogsOverviewMock = jest.fn(LogsOverviewMockImpl) as unknown as ILogsOverviewMock; + + LogsOverviewMock.useIsEnabled = jest.fn(() => true); + + LogsOverviewMock.ErrorContent = jest.fn(() =>
    ); + + LogsOverviewMock.LoadingContent = jest.fn(() =>
    ); + + return LogsOverviewMock; +}; + +const LogsOverviewMockImpl = (_props: LogsOverviewProps) => { + return
    ; +}; + +type ILogsOverviewMock = jest.Mocked & + jest.Mocked; diff --git a/x-pack/plugins/observability_solution/logs_shared/public/components/logs_overview/logs_overview.tsx b/x-pack/plugins/observability_solution/logs_shared/public/components/logs_overview/logs_overview.tsx new file mode 100644 index 0000000000000..7b60aee5be57c --- /dev/null +++ b/x-pack/plugins/observability_solution/logs_shared/public/components/logs_overview/logs_overview.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { OBSERVABILITY_LOGS_SHARED_NEW_LOGS_OVERVIEW_ID } from '@kbn/management-settings-ids'; +import type { + LogsOverviewProps as FullLogsOverviewProps, + LogsOverviewDependencies, + LogsOverviewErrorContentProps, +} from '@kbn/observability-logs-overview'; +import { dynamic } from '@kbn/shared-ux-utility'; +import React from 'react'; +import useObservable from 'react-use/lib/useObservable'; + +const LazyLogsOverview = dynamic(() => + import('@kbn/observability-logs-overview').then((mod) => ({ default: mod.LogsOverview })) +); + +const LazyLogsOverviewErrorContent = dynamic(() => + import('@kbn/observability-logs-overview').then((mod) => ({ + default: mod.LogsOverviewErrorContent, + })) +); + +const LazyLogsOverviewLoadingContent = dynamic(() => + import('@kbn/observability-logs-overview').then((mod) => ({ + default: mod.LogsOverviewLoadingContent, + })) +); + +export type LogsOverviewProps = Omit; + +export interface SelfContainedLogsOverviewHelpers { + useIsEnabled: () => boolean; + ErrorContent: React.ComponentType; + LoadingContent: React.ComponentType; +} + +export type SelfContainedLogsOverviewComponent = React.ComponentType; + +export type SelfContainedLogsOverview = SelfContainedLogsOverviewComponent & + SelfContainedLogsOverviewHelpers; + +export const createLogsOverview = ( + dependencies: LogsOverviewDependencies +): SelfContainedLogsOverview => { + const SelfContainedLogsOverview = (props: LogsOverviewProps) => { + return ; + }; + + const isEnabled$ = dependencies.uiSettings.client.get$( + OBSERVABILITY_LOGS_SHARED_NEW_LOGS_OVERVIEW_ID, + defaultIsEnabled + ); + + SelfContainedLogsOverview.useIsEnabled = (): boolean => { + return useObservable(isEnabled$, defaultIsEnabled); + }; + + SelfContainedLogsOverview.ErrorContent = LazyLogsOverviewErrorContent; + + SelfContainedLogsOverview.LoadingContent = LazyLogsOverviewLoadingContent; + + return SelfContainedLogsOverview; +}; + +const defaultIsEnabled = false; diff --git a/x-pack/plugins/observability_solution/logs_shared/public/index.ts b/x-pack/plugins/observability_solution/logs_shared/public/index.ts index a602b25786116..3d601c9936f2d 100644 --- a/x-pack/plugins/observability_solution/logs_shared/public/index.ts +++ b/x-pack/plugins/observability_solution/logs_shared/public/index.ts @@ -50,6 +50,7 @@ export type { UpdatedDateRange, VisibleInterval, } from './components/logging/log_text_stream/scrollable_log_text_stream_view'; +export type { LogsOverviewProps } from './components/logs_overview'; export const WithSummary = dynamic(() => import('./containers/logs/log_summary/with_summary')); export const LogEntryFlyout = dynamic( diff --git a/x-pack/plugins/observability_solution/logs_shared/public/mocks.tsx b/x-pack/plugins/observability_solution/logs_shared/public/mocks.tsx index a9b0ebd6a6aa3..ffb867abbcc17 100644 --- a/x-pack/plugins/observability_solution/logs_shared/public/mocks.tsx +++ b/x-pack/plugins/observability_solution/logs_shared/public/mocks.tsx @@ -6,12 +6,14 @@ */ import { createLogAIAssistantMock } from './components/log_ai_assistant/log_ai_assistant.mock'; +import { createLogsOverviewMock } from './components/logs_overview/logs_overview.mock'; import { createLogViewsServiceStartMock } from './services/log_views/log_views_service.mock'; import { LogsSharedClientStartExports } from './types'; export const createLogsSharedPluginStartMock = (): jest.Mocked => ({ logViews: createLogViewsServiceStartMock(), LogAIAssistant: createLogAIAssistantMock(), + LogsOverview: createLogsOverviewMock(), }); export const _ensureTypeCompatibility = (): LogsSharedClientStartExports => diff --git a/x-pack/plugins/observability_solution/logs_shared/public/plugin.ts b/x-pack/plugins/observability_solution/logs_shared/public/plugin.ts index d6f4ac81fe266..fc17e9b17cc82 100644 --- a/x-pack/plugins/observability_solution/logs_shared/public/plugin.ts +++ b/x-pack/plugins/observability_solution/logs_shared/public/plugin.ts @@ -12,6 +12,7 @@ import { TraceLogsLocatorDefinition, } from '../common/locators'; import { createLogAIAssistant, createLogsAIAssistantRenderer } from './components/log_ai_assistant'; +import { createLogsOverview } from './components/logs_overview'; import { LogViewsService } from './services/log_views'; import { LogsSharedClientCoreSetup, @@ -51,8 +52,16 @@ export class LogsSharedPlugin implements LogsSharedClientPluginClass { } public start(core: CoreStart, plugins: LogsSharedClientStartDeps) { - const { http } = core; - const { data, dataViews, discoverShared, observabilityAIAssistant, logsDataAccess } = plugins; + const { http, settings } = core; + const { + charts, + data, + dataViews, + discoverShared, + logsDataAccess, + observabilityAIAssistant, + share, + } = plugins; const logViews = this.logViews.start({ http, @@ -61,9 +70,18 @@ export class LogsSharedPlugin implements LogsSharedClientPluginClass { search: data.search, }); + const LogsOverview = createLogsOverview({ + charts, + logsDataAccess, + search: data.search.search, + uiSettings: settings, + share, + }); + if (!observabilityAIAssistant) { return { logViews, + LogsOverview, }; } @@ -77,6 +95,7 @@ export class LogsSharedPlugin implements LogsSharedClientPluginClass { return { logViews, LogAIAssistant, + LogsOverview, }; } diff --git a/x-pack/plugins/observability_solution/logs_shared/public/types.ts b/x-pack/plugins/observability_solution/logs_shared/public/types.ts index 58b180ee8b6ef..4237c28c621b8 100644 --- a/x-pack/plugins/observability_solution/logs_shared/public/types.ts +++ b/x-pack/plugins/observability_solution/logs_shared/public/types.ts @@ -5,19 +5,19 @@ * 2.0. */ +import type { ChartsPluginStart } from '@kbn/charts-plugin/public'; import type { CoreSetup, CoreStart, Plugin as PluginClass } from '@kbn/core/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import type { DiscoverSharedPublicStart } from '@kbn/discover-shared-plugin/public'; -import { LogsDataAccessPluginStart } from '@kbn/logs-data-access-plugin/public'; +import type { LogsDataAccessPluginStart } from '@kbn/logs-data-access-plugin/public'; import type { ObservabilityAIAssistantPublicStart } from '@kbn/observability-ai-assistant-plugin/public'; import type { SharePluginSetup, SharePluginStart } from '@kbn/share-plugin/public'; import type { UiActionsStart } from '@kbn/ui-actions-plugin/public'; - -import { LogsSharedLocators } from '../common/locators'; +import type { LogsSharedLocators } from '../common/locators'; import type { LogAIAssistantProps } from './components/log_ai_assistant/log_ai_assistant'; -// import type { OsqueryPluginStart } from '../../osquery/public'; -import { LogViewsServiceSetup, LogViewsServiceStart } from './services/log_views'; +import type { SelfContainedLogsOverview } from './components/logs_overview'; +import type { LogViewsServiceSetup, LogViewsServiceStart } from './services/log_views'; // Our own setup and start contract values export interface LogsSharedClientSetupExports { @@ -28,6 +28,7 @@ export interface LogsSharedClientSetupExports { export interface LogsSharedClientStartExports { logViews: LogViewsServiceStart; LogAIAssistant?: (props: Omit) => JSX.Element; + LogsOverview: SelfContainedLogsOverview; } export interface LogsSharedClientSetupDeps { @@ -35,6 +36,7 @@ export interface LogsSharedClientSetupDeps { } export interface LogsSharedClientStartDeps { + charts: ChartsPluginStart; data: DataPublicPluginStart; dataViews: DataViewsPublicPluginStart; discoverShared: DiscoverSharedPublicStart; diff --git a/x-pack/plugins/observability_solution/logs_shared/server/feature_flags.ts b/x-pack/plugins/observability_solution/logs_shared/server/feature_flags.ts new file mode 100644 index 0000000000000..0298416bd3f26 --- /dev/null +++ b/x-pack/plugins/observability_solution/logs_shared/server/feature_flags.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { UiSettingsParams } from '@kbn/core-ui-settings-common'; +import { i18n } from '@kbn/i18n'; +import { OBSERVABILITY_LOGS_SHARED_NEW_LOGS_OVERVIEW_ID } from '@kbn/management-settings-ids'; + +const technicalPreviewLabel = i18n.translate('xpack.logsShared.technicalPreviewSettingLabel', { + defaultMessage: 'Technical Preview', +}); + +export const featureFlagUiSettings: Record = { + [OBSERVABILITY_LOGS_SHARED_NEW_LOGS_OVERVIEW_ID]: { + category: ['observability'], + name: i18n.translate('xpack.logsShared.newLogsOverviewSettingName', { + defaultMessage: 'New logs overview', + }), + value: false, + description: i18n.translate('xpack.logsShared.newLogsOverviewSettingDescription', { + defaultMessage: '{technicalPreviewLabel} Enable the new logs overview experience.', + + values: { technicalPreviewLabel: `[${technicalPreviewLabel}]` }, + }), + type: 'boolean', + schema: schema.boolean(), + requiresPageReload: true, + }, +}; diff --git a/x-pack/plugins/observability_solution/logs_shared/server/plugin.ts b/x-pack/plugins/observability_solution/logs_shared/server/plugin.ts index 7c97e175ed64f..d1f6399104fc2 100644 --- a/x-pack/plugins/observability_solution/logs_shared/server/plugin.ts +++ b/x-pack/plugins/observability_solution/logs_shared/server/plugin.ts @@ -5,8 +5,19 @@ * 2.0. */ -import { PluginInitializerContext, CoreStart, Plugin, Logger } from '@kbn/core/server'; - +import { CoreStart, Logger, Plugin, PluginInitializerContext } from '@kbn/core/server'; +import { defaultLogViewId } from '../common/log_views'; +import { LogsSharedConfig } from '../common/plugin_config'; +import { registerDeprecations } from './deprecations'; +import { featureFlagUiSettings } from './feature_flags'; +import { KibanaFramework } from './lib/adapters/framework/kibana_framework_adapter'; +import { LogsSharedKibanaLogEntriesAdapter } from './lib/adapters/log_entries/kibana_log_entries_adapter'; +import { LogsSharedLogEntriesDomain } from './lib/domains/log_entries_domain'; +import { LogsSharedBackendLibs, LogsSharedDomainLibs } from './lib/logs_shared_types'; +import { initLogsSharedServer } from './logs_shared_server'; +import { logViewSavedObjectType } from './saved_objects'; +import { LogEntriesService } from './services/log_entries'; +import { LogViewsService } from './services/log_views'; import { LogsSharedPluginCoreSetup, LogsSharedPluginSetup, @@ -15,17 +26,6 @@ import { LogsSharedServerPluginStartDeps, UsageCollector, } from './types'; -import { logViewSavedObjectType } from './saved_objects'; -import { initLogsSharedServer } from './logs_shared_server'; -import { LogViewsService } from './services/log_views'; -import { KibanaFramework } from './lib/adapters/framework/kibana_framework_adapter'; -import { LogsSharedBackendLibs, LogsSharedDomainLibs } from './lib/logs_shared_types'; -import { LogsSharedLogEntriesDomain } from './lib/domains/log_entries_domain'; -import { LogsSharedKibanaLogEntriesAdapter } from './lib/adapters/log_entries/kibana_log_entries_adapter'; -import { LogEntriesService } from './services/log_entries'; -import { LogsSharedConfig } from '../common/plugin_config'; -import { registerDeprecations } from './deprecations'; -import { defaultLogViewId } from '../common/log_views'; export class LogsSharedPlugin implements @@ -88,6 +88,8 @@ export class LogsSharedPlugin registerDeprecations({ core }); + core.uiSettings.register(featureFlagUiSettings); + return { ...domainLibs, logViews, diff --git a/x-pack/plugins/observability_solution/logs_shared/tsconfig.json b/x-pack/plugins/observability_solution/logs_shared/tsconfig.json index 38cbba7c252c0..788f55c9b6fc5 100644 --- a/x-pack/plugins/observability_solution/logs_shared/tsconfig.json +++ b/x-pack/plugins/observability_solution/logs_shared/tsconfig.json @@ -44,5 +44,9 @@ "@kbn/logs-data-access-plugin", "@kbn/core-deprecations-common", "@kbn/core-deprecations-server", + "@kbn/management-settings-ids", + "@kbn/observability-logs-overview", + "@kbn/charts-plugin", + "@kbn/core-ui-settings-common", ] } diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/public/assets/elastic_ai_assistant.png b/x-pack/plugins/observability_solution/observability_ai_assistant/public/assets/elastic_ai_assistant.png new file mode 100644 index 0000000000000..af10645579683 Binary files /dev/null and b/x-pack/plugins/observability_solution/observability_ai_assistant/public/assets/elastic_ai_assistant.png differ diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/public/index.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/public/index.ts index ab2dea089dcf1..76e643c6ae0d5 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/public/index.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/public/index.ts @@ -19,6 +19,7 @@ import type { RenderFunction, DiscoveredDataset, } from './types'; +import elasticAiAssistantImg from './assets/elastic_ai_assistant.png'; export type { ObservabilityAIAssistantPublicSetup, @@ -101,6 +102,8 @@ export { aiAssistantPreferredAIAssistantType, } from '../common/ui_settings/settings_keys'; +export const elasticAiAssistantImage = elasticAiAssistantImg; + export const plugin: PluginInitializer< ObservabilityAIAssistantPublicSetup, ObservabilityAIAssistantPublicStart, diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/application.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/application.tsx index c554fc81d5de7..ce043ef395ee4 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/application.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/application.tsx @@ -9,10 +9,10 @@ import { RouteRenderer, RouterProvider } from '@kbn/typed-react-router-config'; import type { History } from 'history'; import React from 'react'; import type { Observable } from 'rxjs'; -import { observabilityAIAssistantRouter } from './routes/config'; -import type { ObservabilityAIAssistantAppService } from './service/create_app_service'; +import type { AIAssistantAppService } from '@kbn/ai-assistant'; import type { ObservabilityAIAssistantAppPluginStartDependencies } from './types'; import { SharedProviders } from './utils/shared_providers'; +import { observabilityAIAssistantRouter } from './routes/config'; // This is the Conversation application. @@ -26,7 +26,7 @@ export function Application({ coreStart: CoreStart; history: History; pluginsStart: ObservabilityAIAssistantAppPluginStartDependencies; - service: ObservabilityAIAssistantAppService; + service: AIAssistantAppService; theme$: Observable; }) { return ( @@ -36,7 +36,7 @@ export function Application({ service={service} theme$={theme$} > - + diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/nav_control/index.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/nav_control/index.tsx index 66a66ecc07dc0..2c2af65accb59 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/nav_control/index.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/nav_control/index.tsx @@ -12,17 +12,15 @@ import { v4 } from 'uuid'; import useObservable from 'react-use/lib/useObservable'; import { i18n } from '@kbn/i18n'; import { CoreStart } from '@kbn/core-lifecycle-browser'; -import { useObservabilityAIAssistantAppService } from '../../hooks/use_observability_ai_assistant_app_service'; -import { ChatFlyout } from '../chat/chat_flyout'; +import { AIAssistantAppService, useAIAssistantAppService, ChatFlyout } from '@kbn/ai-assistant'; import { useKibana } from '../../hooks/use_kibana'; import { useTheme } from '../../hooks/use_theme'; import { useNavControlScreenContext } from '../../hooks/use_nav_control_screen_context'; import { SharedProviders } from '../../utils/shared_providers'; -import { ObservabilityAIAssistantAppService } from '../../service/create_app_service'; import { ObservabilityAIAssistantAppPluginStartDependencies } from '../../types'; interface NavControlWithProviderDeps { - appService: ObservabilityAIAssistantAppService; + appService: AIAssistantAppService; coreStart: CoreStart; pluginsStart: ObservabilityAIAssistantAppPluginStartDependencies; } @@ -45,10 +43,12 @@ export const NavControlWithProvider = ({ }; export function NavControl() { - const service = useObservabilityAIAssistantAppService(); + const service = useAIAssistantAppService(); const { services: { + application, + http, notifications, plugins: { start: { @@ -162,6 +162,13 @@ export function NavControl() { onClose={() => { setIsOpen(false); }} + navigateToConversation={(conversationId: string) => { + application.navigateToUrl( + http.basePath.prepend( + `/app/observabilityAIAssistant/conversations/${conversationId || ''}` + ) + ); + }} /> ) : undefined} diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/nav_control/lazy_nav_control.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/nav_control/lazy_nav_control.tsx index bed86909af417..adef91ceea53e 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/nav_control/lazy_nav_control.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/nav_control/lazy_nav_control.tsx @@ -8,8 +8,8 @@ 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 { useIsNavControlVisible } from '../../hooks/is_nav_control_visible'; -import { ObservabilityAIAssistantAppService } from '../../service/create_app_service'; import { ObservabilityAIAssistantAppPluginStartDependencies } from '../../types'; const LazyNavControlWithProvider = dynamic(() => @@ -17,7 +17,7 @@ const LazyNavControlWithProvider = dynamic(() => ); interface NavControlInitiatorProps { - appService: ObservabilityAIAssistantAppService; + appService: AIAssistantAppService; coreStart: CoreStart; pluginsStart: ObservabilityAIAssistantAppPluginStartDependencies; } diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/context/observability_ai_assistant_app_service_provider.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/context/observability_ai_assistant_app_service_provider.tsx deleted file mode 100644 index 9de7f023b4d10..0000000000000 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/context/observability_ai_assistant_app_service_provider.tsx +++ /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. - */ - -import { createContext } from 'react'; -import type { ObservabilityAIAssistantAppService } from '../service/create_app_service'; - -export const ObservabilityAIAssistantAppServiceContext = createContext< - ObservabilityAIAssistantAppService | undefined ->(undefined); - -export const ObservabilityAIAssistantAppServiceProvider = - ObservabilityAIAssistantAppServiceContext.Provider; diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/__storybook_mocks__/use_kibana.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/__storybook_mocks__/use_kibana.ts index f836c3dac6159..deaabffeeb50d 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/__storybook_mocks__/use_kibana.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/__storybook_mocks__/use_kibana.ts @@ -7,10 +7,13 @@ import React from 'react'; import { Subject } from 'rxjs'; -import { useChat } from './use_chat'; const ObservabilityAIAssistantMultipaneFlyoutContext = React.createContext(undefined); +function useChat() { + return { next: () => {}, messages: [], setMessages: () => {}, state: undefined, stop: () => {} }; +} + export function useKibana() { return { services: { diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_nav_control_screen_context.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_nav_control_screen_context.ts index 10195bf38651e..d068f592c4310 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_nav_control_screen_context.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_nav_control_screen_context.ts @@ -8,11 +8,11 @@ import { useEffect, useState } from 'react'; import datemath from '@elastic/datemath'; import moment from 'moment'; +import { useAIAssistantAppService } from '@kbn/ai-assistant'; import { useKibana } from './use_kibana'; -import { useObservabilityAIAssistantAppService } from './use_observability_ai_assistant_app_service'; export function useNavControlScreenContext() { - const service = useObservabilityAIAssistantAppService(); + const service = useAIAssistantAppService(); const { services: { diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_observability_ai_assistant_app_service.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_observability_ai_assistant_app_service.ts deleted file mode 100644 index 9c86f29565f48..0000000000000 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_observability_ai_assistant_app_service.ts +++ /dev/null @@ -1,20 +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 { useContext } from 'react'; -import { ObservabilityAIAssistantAppServiceContext } from '../context/observability_ai_assistant_app_service_provider'; - -export function useObservabilityAIAssistantAppService() { - const services = useContext(ObservabilityAIAssistantAppServiceContext); - - if (!services) { - throw new Error( - 'ObservabilityAIAssistantContext not set. Did you wrap your component in ``?' - ); - } - - return services; -} diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/i18n.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/i18n.ts deleted file mode 100644 index dcc28d7ff531a..0000000000000 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/i18n.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; - -export const ASSISTANT_SETUP_TITLE = i18n.translate( - 'xpack.observabilityAiAssistant.assistantSetup.title', - { - defaultMessage: 'Welcome to Elastic AI Assistant', - } -); - -export const EMPTY_CONVERSATION_TITLE = i18n.translate( - 'xpack.observabilityAiAssistant.emptyConversationTitle', - { defaultMessage: 'New conversation' } -); - -export const UPGRADE_LICENSE_TITLE = i18n.translate( - 'xpack.observabilityAiAssistant.incorrectLicense.title', - { - defaultMessage: 'Upgrade your license', - } -); diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/plugin.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/plugin.tsx index 9817cc65362d6..1904eebffb2a8 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/plugin.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/plugin.tsx @@ -17,13 +17,13 @@ import { import type { Logger } from '@kbn/logging'; import { i18n } from '@kbn/i18n'; import { AI_ASSISTANT_APP_ID } from '@kbn/deeplinks-observability'; +import { createAppService, AIAssistantAppService } from '@kbn/ai-assistant'; import type { ObservabilityAIAssistantAppPluginSetupDependencies, ObservabilityAIAssistantAppPluginStartDependencies, ObservabilityAIAssistantAppPublicSetup, ObservabilityAIAssistantAppPublicStart, } from './types'; -import { createAppService, ObservabilityAIAssistantAppService } from './service/create_app_service'; import { getObsAIAssistantConnectorType } from './rule_connector'; import { NavControlInitiator } from './components/nav_control/lazy_nav_control'; @@ -40,7 +40,7 @@ export class ObservabilityAIAssistantAppPlugin > { logger: Logger; - appService: ObservabilityAIAssistantAppService | undefined; + appService: AIAssistantAppService | undefined; constructor(context: PluginInitializerContext) { this.logger = context.logger.get(); diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/routes/config.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/routes/config.tsx index ed0ac18302cc5..545c69a990ace 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/routes/config.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/routes/config.tsx @@ -9,8 +9,8 @@ import { createRouter, Outlet } from '@kbn/typed-react-router-config'; import * as t from 'io-ts'; import React from 'react'; import { Redirect } from 'react-router-dom'; +import { ConversationViewWithProps } from './conversations/conversation_view_with_props'; import { ObservabilityAIAssistantPageTemplate } from '../components/page_template'; -import { ConversationView } from './conversations/conversation_view'; /** * The array of route definitions to be used when the application @@ -28,7 +28,7 @@ const observabilityAIAssistantRoutes = { ), children: { '/conversations/new': { - element: , + element: , }, '/conversations/{conversationId}': { params: t.intersection([ @@ -43,7 +43,7 @@ const observabilityAIAssistantRoutes = { }), }), ]), - element: , + element: , }, '/conversations': { element: , diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/routes/conversations/conversation_view_with_props.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/routes/conversations/conversation_view_with_props.tsx new file mode 100644 index 0000000000000..c57b8e2c66c71 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/routes/conversations/conversation_view_with_props.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { ConversationView } from '@kbn/ai-assistant'; +import { useObservabilityAIAssistantParams } from '../../hooks/use_observability_ai_assistant_params'; +import { useObservabilityAIAssistantRouter } from '../../hooks/use_observability_ai_assistant_router'; + +export function ConversationViewWithProps() { + const { path } = useObservabilityAIAssistantParams('/conversations/*'); + const conversationId = 'conversationId' in path ? path.conversationId : undefined; + const observabilityAIAssistantRouter = useObservabilityAIAssistantRouter(); + function navigateToConversation(nextConversationId?: string) { + if (nextConversationId) { + observabilityAIAssistantRouter.push('/conversations/{conversationId}', { + path: { + conversationId: nextConversationId, + }, + query: {}, + }); + } else { + observabilityAIAssistantRouter.push('/conversations/new', { path: {}, query: {} }); + } + } + return ( + + observabilityAIAssistantRouter.link(`/conversations/{conversationId}`, { + path: { + conversationId: id, + }, + }) + } + /> + ); +} diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/utils/shared_providers.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/utils/shared_providers.tsx index eaa441b34a008..49776f4622250 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/utils/shared_providers.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/utils/shared_providers.tsx @@ -11,8 +11,7 @@ import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme'; import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; import React, { useMemo } from 'react'; import type { Observable } from 'rxjs'; -import { ObservabilityAIAssistantAppServiceProvider } from '../context/observability_ai_assistant_app_service_provider'; -import type { ObservabilityAIAssistantAppService } from '../service/create_app_service'; +import { AIAssistantAppService } from '@kbn/ai-assistant'; import type { ObservabilityAIAssistantAppPluginStartDependencies } from '../types'; export function SharedProviders({ @@ -25,7 +24,7 @@ export function SharedProviders({ children: React.ReactElement; coreStart: CoreStart; pluginsStart: ObservabilityAIAssistantAppPluginStartDependencies; - service: ObservabilityAIAssistantAppService; + service: AIAssistantAppService; theme$: Observable; }) { const theme = useMemo(() => { @@ -45,11 +44,7 @@ export function SharedProviders({ }} > - - - {children} - - + {children} diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/tsconfig.json b/x-pack/plugins/observability_solution/observability_ai_assistant_app/tsconfig.json index 84fe8f0b93911..f5b6d1db53885 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/tsconfig.json +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/tsconfig.json @@ -20,16 +20,8 @@ "@kbn/typed-react-router-config", "@kbn/i18n", "@kbn/management-settings-ids", - "@kbn/security-plugin", - "@kbn/ui-theme", - "@kbn/actions-plugin", - "@kbn/user-profile-components", - "@kbn/core-http-browser", "@kbn/triggers-actions-ui-plugin", "@kbn/shared-ux-utility", - "@kbn/i18n-react", - "@kbn/code-editor", - "@kbn/monaco", "@kbn/data-views-plugin", "@kbn/lens-embeddable-utils", "@kbn/lens-plugin", @@ -38,7 +30,6 @@ "@kbn/esql-utils", "@kbn/visualization-utils", "@kbn/ai-assistant-management-plugin", - "@kbn/utility-types-jest", "@kbn/kibana-react-plugin", "@kbn/licensing-plugin", "@kbn/logging", @@ -56,6 +47,11 @@ "@kbn/apm-synthtrace-client", "@kbn/alerting-plugin", "@kbn/apm-synthtrace", + "@kbn/esql-datagrid", + "@kbn/alerting-comparators", + "@kbn/core-lifecycle-browser", + "@kbn/inference-plugin", + "@kbn/ai-assistant", "@kbn/apm-utils", "@kbn/config-schema", "@kbn/es-query", @@ -63,17 +59,17 @@ "@kbn/esql-validation-autocomplete", "@kbn/esql-ast", "@kbn/field-types", + "@kbn/security-plugin", + "@kbn/observability-plugin", + "@kbn/actions-plugin", "@kbn/stack-connectors-plugin", "@kbn/features-plugin", "@kbn/serverless", "@kbn/task-manager-plugin", "@kbn/cloud-plugin", - "@kbn/observability-plugin", - "@kbn/esql-datagrid", - "@kbn/alerting-comparators", - "@kbn/core-lifecycle-browser", - "@kbn/inference-plugin", - "@kbn/logs-data-access-plugin" + "@kbn/logs-data-access-plugin", ], - "exclude": ["target/**/*"] + "exclude": [ + "target/**/*" + ] } diff --git a/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/service_api_client.ts b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/service_api_client.ts index 3d334f32e9407..73f286e40d310 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/service_api_client.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/service_api_client.ts @@ -143,6 +143,10 @@ export class ServiceAPIClient { cert: tlsConfig.certificate, key: tlsConfig.key, }); + } else if (!this.server.isDev) { + this.logger.warn( + 'TLS certificate and key are not provided. Falling back to default HTTPS agent.' + ); } return baseHttpsAgent; diff --git a/x-pack/plugins/search_assistant/kibana.jsonc b/x-pack/plugins/search_assistant/kibana.jsonc index 85579b76a1e80..8391ee14e0d88 100644 --- a/x-pack/plugins/search_assistant/kibana.jsonc +++ b/x-pack/plugins/search_assistant/kibana.jsonc @@ -12,13 +12,19 @@ "searchAssistant" ], "requiredPlugins": [ + "actions", + "licensing", "observabilityAIAssistant", - "observabilityAIAssistantApp" + "observabilityAIAssistantApp", + "triggersActionsUi", + "share" ], "optionalPlugins": [ "cloud", "usageCollection", ], - "requiredBundles": [] + "requiredBundles": [ + "kibanaReact" + ] } } diff --git a/x-pack/plugins/search_assistant/public/application.tsx b/x-pack/plugins/search_assistant/public/application.tsx index 071c51f4b6e13..1bbf7063ec373 100644 --- a/x-pack/plugins/search_assistant/public/application.tsx +++ b/x-pack/plugins/search_assistant/public/application.tsx @@ -7,31 +7,28 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import type { CoreStart } from '@kbn/core/public'; +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 { Router } from '@kbn/shared-ux-router'; import type { SearchAssistantPluginStartDependencies } from './types'; -import { SearchAssistantRouter } from './router'; +import { SearchAssistantRouter } from './components/routes/router'; export const renderApp = ( core: CoreStart, services: SearchAssistantPluginStartDependencies, - element: HTMLElement + appMountParameters: AppMountParameters ) => { ReactDOM.render( - - - + , - element + appMountParameters.element ); - return () => ReactDOM.unmountComponentAtNode(element); + return () => ReactDOM.unmountComponentAtNode(appMountParameters.element); }; diff --git a/x-pack/plugins/search_assistant/public/components/page_template.tsx b/x-pack/plugins/search_assistant/public/components/page_template.tsx new file mode 100644 index 0000000000000..e9fb3a45e9e2b --- /dev/null +++ b/x-pack/plugins/search_assistant/public/components/page_template.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 React from 'react'; +import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; + +export function SearchAIAssistantPageTemplate({ children }: { children: React.ReactNode }) { + return {children}; +} 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 new file mode 100644 index 0000000000000..545ff1ceb7370 --- /dev/null +++ b/x-pack/plugins/search_assistant/public/components/routes/conversations/conversation_view_with_props.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { 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 || ''}`) || '' + } + /> + ); +} diff --git a/x-pack/plugins/search_assistant/public/components/routes/router.tsx b/x-pack/plugins/search_assistant/public/components/routes/router.tsx new file mode 100644 index 0000000000000..154bc2ab46a3e --- /dev/null +++ b/x-pack/plugins/search_assistant/public/components/routes/router.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 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/components/search_assistant.tsx b/x-pack/plugins/search_assistant/public/components/search_assistant.tsx deleted file mode 100644 index 9c227a4e7b73f..0000000000000 --- a/x-pack/plugins/search_assistant/public/components/search_assistant.tsx +++ /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 { EuiPageTemplate } from '@elastic/eui'; -import React from 'react'; -import { App } from './app'; - -export const SearchAssistantPage: React.FC = () => { - return ( - - - - ); -}; diff --git a/x-pack/plugins/search_assistant/public/index.ts b/x-pack/plugins/search_assistant/public/index.ts index c2b16e857b53e..cb84f8519fd96 100644 --- a/x-pack/plugins/search_assistant/public/index.ts +++ b/x-pack/plugins/search_assistant/public/index.ts @@ -5,9 +5,19 @@ * 2.0. */ -import { SearchAssistantPlugin } from './plugin'; +import { PluginInitializer, PluginInitializerContext } from '@kbn/core/public'; +import { PublicConfigType, SearchAssistantPlugin } from './plugin'; +import { + SearchAssistantPluginSetup, + SearchAssistantPluginStart, + SearchAssistantPluginStartDependencies, +} from './types'; + +export const plugin: PluginInitializer< + SearchAssistantPluginSetup, + SearchAssistantPluginStart, + {}, + SearchAssistantPluginStartDependencies +> = (context: PluginInitializerContext) => new SearchAssistantPlugin(context); -export function plugin() { - return new SearchAssistantPlugin(); -} export type { SearchAssistantPluginSetup, SearchAssistantPluginStart } from './types'; diff --git a/x-pack/plugins/search_assistant/public/plugin.ts b/x-pack/plugins/search_assistant/public/plugin.ts index 8ba22a48df9ff..1c09502c154ad 100644 --- a/x-pack/plugins/search_assistant/public/plugin.ts +++ b/x-pack/plugins/search_assistant/public/plugin.ts @@ -5,19 +5,71 @@ * 2.0. */ -import type { CoreSetup, Plugin } from '@kbn/core/public'; +import { + DEFAULT_APP_CATEGORIES, + type CoreSetup, + type Plugin, + CoreStart, + AppMountParameters, + PluginInitializerContext, +} from '@kbn/core/public'; +import { i18n } from '@kbn/i18n'; import type { SearchAssistantPluginSetup, SearchAssistantPluginStart, SearchAssistantPluginStartDependencies, } from './types'; +export interface PublicConfigType { + ui: { + enabled: boolean; + }; +} + export class SearchAssistantPlugin - implements Plugin + implements + Plugin< + SearchAssistantPluginSetup, + SearchAssistantPluginStart, + {}, + SearchAssistantPluginStartDependencies + > { + private readonly config: PublicConfigType; + + constructor(private readonly context: PluginInitializerContext) { + this.config = this.context.config.get(); + } + public setup( core: CoreSetup ): SearchAssistantPluginSetup { + if (!this.config.ui.enabled) { + return {}; + } + + 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] + >, + ]); + + return renderApp(coreStart, pluginsStart, appMountParameters); + }, + }); return {}; } diff --git a/x-pack/plugins/search_assistant/public/router.tsx b/x-pack/plugins/search_assistant/public/router.tsx deleted file mode 100644 index a25f865b4f74a..0000000000000 --- a/x-pack/plugins/search_assistant/public/router.tsx +++ /dev/null @@ -1,20 +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 { Route, Routes } from '@kbn/shared-ux-router'; -import React from 'react'; -import { SearchAssistantPage } from './components/search_assistant'; - -export const SearchAssistantRouter: React.FC = () => { - return ( - - - - - - ); -}; diff --git a/x-pack/plugins/search_assistant/public/types.ts b/x-pack/plugins/search_assistant/public/types.ts index f05592414a9dc..b1a5d6164b1f1 100644 --- a/x-pack/plugins/search_assistant/public/types.ts +++ b/x-pack/plugins/search_assistant/public/types.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { AppMountParameters } from '@kbn/core/public'; import { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; import { ObservabilityAIAssistantPublicStart } from '@kbn/observability-ai-assistant-plugin/public'; @@ -16,7 +15,6 @@ export interface SearchAssistantPluginSetup {} export interface SearchAssistantPluginStart {} export interface SearchAssistantPluginStartDependencies { - history: AppMountParameters['history']; observabilityAIAssistant: ObservabilityAIAssistantPublicStart; usageCollection?: UsageCollectionStart; } diff --git a/x-pack/plugins/search_assistant/server/config.ts b/x-pack/plugins/search_assistant/server/config.ts index a09b7ac51b7b7..5ca081ec8a667 100644 --- a/x-pack/plugins/search_assistant/server/config.ts +++ b/x-pack/plugins/search_assistant/server/config.ts @@ -9,11 +9,19 @@ import { schema, TypeOf } from '@kbn/config-schema'; import { PluginConfigDescriptor } from '@kbn/core/server'; const configSchema = schema.object({ - enabled: schema.boolean({ defaultValue: false }), + enabled: schema.boolean({ defaultValue: true }), + ui: schema.object({ + enabled: schema.boolean({ defaultValue: false }), + }), }); export type SearchAssistantConfig = TypeOf; export const config: PluginConfigDescriptor = { + exposeToBrowser: { + ui: { + enabled: true, + }, + }, schema: configSchema, }; diff --git a/x-pack/plugins/search_assistant/tsconfig.json b/x-pack/plugins/search_assistant/tsconfig.json index 090356cf1f440..d865d2bdbff83 100644 --- a/x-pack/plugins/search_assistant/tsconfig.json +++ b/x-pack/plugins/search_assistant/tsconfig.json @@ -16,11 +16,13 @@ "@kbn/react-kibana-context-render", "@kbn/kibana-react-plugin", "@kbn/i18n-react", - "@kbn/shared-ux-router", "@kbn/shared-ux-page-kibana-template", "@kbn/usage-collection-plugin", "@kbn/observability-ai-assistant-plugin", - "@kbn/config-schema" + "@kbn/config-schema", + "@kbn/ai-assistant", + "@kbn/i18n", + "@kbn/shared-ux-router" ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/search_playground/common/types.ts b/x-pack/plugins/search_playground/common/types.ts index c239858b5b459..e2a0ae34c2ef3 100644 --- a/x-pack/plugins/search_playground/common/types.ts +++ b/x-pack/plugins/search_playground/common/types.ts @@ -57,6 +57,7 @@ export enum APIRoutes { export enum LLMs { openai = 'openai', openai_azure = 'openai_azure', + openai_other = 'openai_other', bedrock = 'bedrock', gemini = 'gemini', } 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 d661084306583..ebce3883a471b 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 @@ -15,9 +15,10 @@ jest.mock('./use_load_connectors', () => ({ })); const mockConnectors = [ - { id: 'connectorId1', title: 'OpenAI Connector', type: LLMs.openai }, - { id: 'connectorId2', title: 'OpenAI Azure Connector', type: LLMs.openai_azure }, - { id: 'connectorId2', title: 'Bedrock Connector', type: LLMs.bedrock }, + { id: 'connectorId1', name: 'OpenAI Connector', type: LLMs.openai }, + { id: 'connectorId2', name: 'OpenAI Azure Connector', type: LLMs.openai_azure }, + { id: 'connectorId2', name: 'Bedrock Connector', type: LLMs.bedrock }, + { id: 'connectorId3', name: 'OpenAI OSS Model Connector', type: LLMs.openai_other }, ]; const mockUseLoadConnectors = (data: any) => { (useLoadConnectors as jest.Mock).mockReturnValue({ data }); @@ -36,7 +37,7 @@ describe('useLLMsModels Hook', () => { expect(result.current).toEqual([ { connectorId: 'connectorId1', - connectorName: undefined, + connectorName: 'OpenAI Connector', connectorType: LLMs.openai, disabled: false, icon: expect.any(Function), @@ -48,7 +49,7 @@ describe('useLLMsModels Hook', () => { }, { connectorId: 'connectorId1', - connectorName: undefined, + connectorName: 'OpenAI Connector', connectorType: LLMs.openai, disabled: false, icon: expect.any(Function), @@ -60,7 +61,7 @@ describe('useLLMsModels Hook', () => { }, { connectorId: 'connectorId1', - connectorName: undefined, + connectorName: 'OpenAI Connector', connectorType: LLMs.openai, disabled: false, icon: expect.any(Function), @@ -72,19 +73,19 @@ describe('useLLMsModels Hook', () => { }, { connectorId: 'connectorId2', - connectorName: undefined, + connectorName: 'OpenAI Azure Connector', connectorType: LLMs.openai_azure, disabled: false, icon: expect.any(Function), - id: 'connectorId2Azure OpenAI ', - name: 'Azure OpenAI ', + id: 'connectorId2OpenAI Azure Connector (Azure OpenAI)', + name: 'OpenAI Azure Connector (Azure OpenAI)', showConnectorName: false, value: undefined, promptTokenLimit: undefined, }, { connectorId: 'connectorId2', - connectorName: undefined, + connectorName: 'Bedrock Connector', connectorType: LLMs.bedrock, disabled: false, icon: expect.any(Function), @@ -96,7 +97,7 @@ describe('useLLMsModels Hook', () => { }, { connectorId: 'connectorId2', - connectorName: undefined, + connectorName: 'Bedrock Connector', connectorType: LLMs.bedrock, disabled: false, icon: expect.any(Function), @@ -106,6 +107,18 @@ describe('useLLMsModels Hook', () => { value: 'anthropic.claude-3-5-sonnet-20240620-v1:0', promptTokenLimit: 200000, }, + { + connectorId: 'connectorId3', + connectorName: 'OpenAI OSS Model Connector', + connectorType: LLMs.openai_other, + disabled: false, + icon: expect.any(Function), + id: 'connectorId3OpenAI OSS Model Connector (OpenAI Compatible Service)', + name: 'OpenAI OSS Model Connector (OpenAI Compatible Service)', + showConnectorName: false, + value: undefined, + promptTokenLimit: undefined, + }, ]); }); diff --git a/x-pack/plugins/search_playground/public/hooks/use_llms_models.ts b/x-pack/plugins/search_playground/public/hooks/use_llms_models.ts index 7a9b01e085a6d..3d5cee7719f10 100644 --- a/x-pack/plugins/search_playground/public/hooks/use_llms_models.ts +++ b/x-pack/plugins/search_playground/public/hooks/use_llms_models.ts @@ -34,11 +34,22 @@ const mapLlmToModels: Record< }, [LLMs.openai_azure]: { icon: OpenAILogo, - getModels: (connectorName, includeName) => [ + getModels: (connectorName) => [ { label: i18n.translate('xpack.searchPlayground.openAIAzureModel', { - defaultMessage: 'Azure OpenAI {name}', - values: { name: includeName ? `(${connectorName})` : '' }, + defaultMessage: '{name} (Azure OpenAI)', + values: { name: connectorName }, + }), + }, + ], + }, + [LLMs.openai_other]: { + icon: OpenAILogo, + getModels: (connectorName) => [ + { + label: i18n.translate('xpack.searchPlayground.otherOpenAIModel', { + defaultMessage: '{name} (OpenAI Compatible Service)', + values: { name: connectorName }, }), }, ], diff --git a/x-pack/plugins/search_playground/public/hooks/use_load_connectors.test.ts b/x-pack/plugins/search_playground/public/hooks/use_load_connectors.test.ts index 3a68d91fd0246..eb2f36eb62e5f 100644 --- a/x-pack/plugins/search_playground/public/hooks/use_load_connectors.test.ts +++ b/x-pack/plugins/search_playground/public/hooks/use_load_connectors.test.ts @@ -71,6 +71,12 @@ describe('useLoadConnectors', () => { actionTypeId: '.bedrock', isMissingSecrets: false, }, + { + id: '5', + actionTypeId: '.gen-ai', + isMissingSecrets: false, + config: { apiProvider: OpenAiProviderType.Other }, + }, ]; mockedLoadConnectors.mockResolvedValue(connectors); @@ -106,6 +112,16 @@ describe('useLoadConnectors', () => { title: 'Bedrock', type: 'bedrock', }, + { + actionTypeId: '.gen-ai', + config: { + apiProvider: 'Other', + }, + id: '5', + isMissingSecrets: false, + title: 'OpenAI Other', + type: 'openai_other', + }, ]); }); }); diff --git a/x-pack/plugins/search_playground/public/hooks/use_load_connectors.ts b/x-pack/plugins/search_playground/public/hooks/use_load_connectors.ts index 94bb2da37b1ed..3d2a3e8c90b86 100644 --- a/x-pack/plugins/search_playground/public/hooks/use_load_connectors.ts +++ b/x-pack/plugins/search_playground/public/hooks/use_load_connectors.ts @@ -63,6 +63,20 @@ const connectorTypeToLLM: Array<{ type: LLMs.openai, }), }, + { + actionId: OPENAI_CONNECTOR_ID, + actionProvider: OpenAiProviderType.Other, + match: (connector) => + connector.actionTypeId === OPENAI_CONNECTOR_ID && + (connector as OpenAIConnector)?.config?.apiProvider === OpenAiProviderType.Other, + transform: (connector) => ({ + ...connector, + title: i18n.translate('xpack.searchPlayground.openAIOtherConnectorTitle', { + defaultMessage: 'OpenAI Other', + }), + type: LLMs.openai_other, + }), + }, { actionId: BEDROCK_CONNECTOR_ID, match: (connector) => connector.actionTypeId === BEDROCK_CONNECTOR_ID, diff --git a/x-pack/plugins/search_playground/server/lib/get_chat_params.test.ts b/x-pack/plugins/search_playground/server/lib/get_chat_params.test.ts index cbc696a50085e..614d00dc16e66 100644 --- a/x-pack/plugins/search_playground/server/lib/get_chat_params.test.ts +++ b/x-pack/plugins/search_playground/server/lib/get_chat_params.test.ts @@ -152,4 +152,41 @@ describe('getChatParams', () => { ) ).rejects.toThrow('Invalid connector id'); }); + + it('returns the correct chat model and uses the default model when not specified in the params', async () => { + mockActionsClient.get.mockResolvedValue({ + id: '2', + actionTypeId: OPENAI_CONNECTOR_ID, + config: { defaultModel: 'local' }, + }); + + const result = await getChatParams( + { + connectorId: '2', + prompt: 'How does it work?', + citations: false, + }, + { actions, request, logger } + ); + + expect(Prompt).toHaveBeenCalledWith('How does it work?', { + citations: false, + context: true, + type: 'openai', + }); + expect(QuestionRewritePrompt).toHaveBeenCalledWith({ + type: 'openai', + }); + expect(ActionsClientChatOpenAI).toHaveBeenCalledWith({ + logger: expect.anything(), + model: 'local', + connectorId: '2', + actionsClient: expect.anything(), + signal: expect.anything(), + traceId: 'test-uuid', + temperature: 0.2, + maxRetries: 0, + }); + expect(result.chatPrompt).toContain('How does it work?'); + }); }); diff --git a/x-pack/plugins/search_playground/server/lib/get_chat_params.ts b/x-pack/plugins/search_playground/server/lib/get_chat_params.ts index d2c4bb1afaa9d..34f902e0d1ca2 100644 --- a/x-pack/plugins/search_playground/server/lib/get_chat_params.ts +++ b/x-pack/plugins/search_playground/server/lib/get_chat_params.ts @@ -57,7 +57,7 @@ export const getChatParams = async ( actionsClient, logger, connectorId, - model, + model: model || connector?.config?.defaultModel, traceId: uuidv4(), signal: abortSignal, temperature: getDefaultArguments().temperature, diff --git a/x-pack/plugins/searchprofiler/public/application/components/_index.scss b/x-pack/plugins/searchprofiler/public/application/components/_index.scss index 9d6688a2d4d98..ee36c5e8e6567 100644 --- a/x-pack/plugins/searchprofiler/public/application/components/_index.scss +++ b/x-pack/plugins/searchprofiler/public/application/components/_index.scss @@ -3,5 +3,4 @@ $badgeSize: $euiSize * 5.5; @import 'highlight_details_flyout/highlight_details_flyout'; @import 'license_warning_notice/license_warning_notice'; @import 'percentage_badge/percentage_badge'; -@import 'profile_query_editor/profile_query_editor'; @import 'profile_tree/index'; diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_query_editor/_profile_query_editor.scss b/x-pack/plugins/searchprofiler/public/application/components/profile_query_editor/_profile_query_editor.scss deleted file mode 100644 index 035ff16c990bb..0000000000000 --- a/x-pack/plugins/searchprofiler/public/application/components/profile_query_editor/_profile_query_editor.scss +++ /dev/null @@ -1,25 +0,0 @@ - -.prfDevTool__sense { - order: 1; - // To anchor ace editor - position: relative; - - // Ace Editor overrides - .ace_editor { - min-height: $euiSize * 10; - flex-grow: 1; - margin-bottom: $euiSize; - margin-top: $euiSize; - outline: solid 1px $euiColorLightShade; - } - - .errorMarker { - position: absolute; - background: rgba($euiColorDanger, .5); - z-index: 20; - } -} - -.prfDevTool__profileButtonContainer { - flex-shrink: 1; -} diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_query_editor/editor/editor.test.tsx b/x-pack/plugins/searchprofiler/public/application/components/profile_query_editor/editor/editor.test.tsx index 34e0867df8ec6..483f0ef7f6106 100644 --- a/x-pack/plugins/searchprofiler/public/application/components/profile_query_editor/editor/editor.test.tsx +++ b/x-pack/plugins/searchprofiler/public/application/components/profile_query_editor/editor/editor.test.tsx @@ -5,20 +5,14 @@ * 2.0. */ -import 'brace'; -import 'brace/mode/json'; - -import { coreMock } from '@kbn/core/public/mocks'; import { registerTestBed } from '@kbn/test-jest-helpers'; import { Editor, Props } from './editor'; -const coreStart = coreMock.createStart(); - describe('Editor Component', () => { it('renders', async () => { const props: Props = { - ...coreStart, - initialValue: '', + editorValue: '', + setEditorValue: () => {}, licenseEnabled: true, onEditorReady: (e: any) => {}, }; diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_query_editor/editor/editor.tsx b/x-pack/plugins/searchprofiler/public/application/components/profile_query_editor/editor/editor.tsx index 068673d4ce4c1..3701323d414c2 100644 --- a/x-pack/plugins/searchprofiler/public/application/components/profile_query_editor/editor/editor.tsx +++ b/x-pack/plugins/searchprofiler/public/application/components/profile_query_editor/editor/editor.tsx @@ -5,67 +5,37 @@ * 2.0. */ -import React, { memo, useRef, useEffect, useState } from 'react'; +import React, { memo, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiScreenReaderOnly } from '@elastic/eui'; -import { Editor as AceEditor } from 'brace'; +import { EuiScreenReaderOnly, EuiSpacer } from '@elastic/eui'; +import { CodeEditor } from '@kbn/code-editor'; +import { monaco, XJsonLang } from '@kbn/monaco'; -import { SearchProfilerStartServices } from '../../../../types'; -import { ace } from '../../../../shared_imports'; -import { initializeEditor } from './init_editor'; - -const { useUIAceKeyboardMode } = ace; - -type EditorShim = ReturnType; - -export type EditorInstance = EditorShim; - -export interface Props extends SearchProfilerStartServices { +export interface Props { licenseEnabled: boolean; - initialValue: string; - onEditorReady: (editor: EditorShim) => void; + editorValue: string; + setEditorValue: (value: string) => void; + onEditorReady: (props: EditorProps) => void; } -const createEditorShim = (aceEditor: AceEditor) => { - return { - getValue() { - return aceEditor.getValue(); - }, - focus() { - aceEditor.focus(); - }, - }; -}; - const EDITOR_INPUT_ID = 'SearchProfilerTextArea'; -export const Editor = memo( - ({ licenseEnabled, initialValue, onEditorReady, ...startServices }: Props) => { - const containerRef = useRef(null as any); - const editorInstanceRef = useRef(null as any); - - const [textArea, setTextArea] = useState(null); - - useUIAceKeyboardMode(textArea, startServices); - - useEffect(() => { - const divEl = containerRef.current; - editorInstanceRef.current = initializeEditor({ el: divEl, licenseEnabled }); - editorInstanceRef.current.setValue(initialValue, 1); - const textarea = divEl.querySelector('textarea'); - if (textarea) { - textarea.setAttribute('id', EDITOR_INPUT_ID); - } - setTextArea(licenseEnabled ? containerRef.current!.querySelector('textarea') : null); - - onEditorReady(createEditorShim(editorInstanceRef.current)); +export interface EditorProps { + focus: () => void; +} - return () => { - if (editorInstanceRef.current) { - editorInstanceRef.current.destroy(); - } - }; - }, [initialValue, onEditorReady, licenseEnabled]); +export const Editor = memo( + ({ licenseEnabled, editorValue, setEditorValue, onEditorReady }: Props) => { + const editorDidMountCallback = useCallback( + (editor: monaco.editor.IStandaloneCodeEditor) => { + onEditorReady({ + focus: () => { + editor.focus(); + }, + } as EditorProps); + }, + [onEditorReady] + ); return ( <> @@ -76,7 +46,26 @@ export const Editor = memo( })} -
    + + + + ); } diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_query_editor/editor/index.ts b/x-pack/plugins/searchprofiler/public/application/components/profile_query_editor/editor/index.ts index 5d8be48041176..1ac3ec704bc5d 100644 --- a/x-pack/plugins/searchprofiler/public/application/components/profile_query_editor/editor/index.ts +++ b/x-pack/plugins/searchprofiler/public/application/components/profile_query_editor/editor/index.ts @@ -5,5 +5,4 @@ * 2.0. */ -export type { EditorInstance } from './editor'; -export { Editor } from './editor'; +export { Editor, type EditorProps } from './editor'; diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_query_editor/editor/init_editor.ts b/x-pack/plugins/searchprofiler/public/application/components/profile_query_editor/editor/init_editor.ts deleted file mode 100644 index 24d119254db78..0000000000000 --- a/x-pack/plugins/searchprofiler/public/application/components/profile_query_editor/editor/init_editor.ts +++ /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 ace from 'brace'; -import { installXJsonMode } from '@kbn/ace'; - -export function initializeEditor({ - el, - licenseEnabled, -}: { - el: HTMLDivElement; - licenseEnabled: boolean; -}) { - const editor: ace.Editor = ace.acequire('ace/ace').edit(el); - - installXJsonMode(editor); - editor.$blockScrolling = Infinity; - - if (!licenseEnabled) { - editor.setReadOnly(true); - editor.container.style.pointerEvents = 'none'; - editor.container.style.opacity = '0.5'; - const textArea = editor.container.querySelector('textarea'); - if (textArea) { - textArea.setAttribute('tabindex', '-1'); - } - editor.renderer.setStyle('disabled'); - editor.blur(); - } - - return editor; -} diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_query_editor/profile_query_editor.tsx b/x-pack/plugins/searchprofiler/public/application/components/profile_query_editor/profile_query_editor.tsx index 577c3e530e8cc..a88f1040caa3a 100644 --- a/x-pack/plugins/searchprofiler/public/application/components/profile_query_editor/profile_query_editor.tsx +++ b/x-pack/plugins/searchprofiler/public/application/components/profile_query_editor/profile_query_editor.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useRef, memo, useCallback } from 'react'; +import React, { useRef, memo, useCallback, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiForm, @@ -23,7 +23,7 @@ import { decompressFromEncodedURIComponent } from 'lz-string'; import { useRequestProfile } from '../../hooks'; import { useAppContext } from '../../contexts/app_context'; import { useProfilerActionContext } from '../../contexts/profiler_context'; -import { Editor, EditorInstance } from './editor'; +import { Editor, type EditorProps } from './editor'; const DEFAULT_INDEX_VALUE = '_all'; @@ -39,33 +39,36 @@ const INITIAL_EDITOR_VALUE = `{ * Drives state changes for mine via profiler action context. */ export const ProfileQueryEditor = memo(() => { - const editorRef = useRef(null as any); + const editorPropsRef = useRef(null as any); const indexInputRef = useRef(null as any); const dispatch = useProfilerActionContext(); - const { getLicenseStatus, notifications, location, ...startServices } = useAppContext(); + const { getLicenseStatus, notifications, location } = useAppContext(); const queryParams = new URLSearchParams(location.search); const indexName = queryParams.get('index'); const searchProfilerQueryURI = queryParams.get('load_from'); + const searchProfilerQuery = searchProfilerQueryURI && decompressFromEncodedURIComponent(searchProfilerQueryURI.replace(/^data:text\/plain,/, '')); + const [editorValue, setEditorValue] = useState( + searchProfilerQuery ? searchProfilerQuery : INITIAL_EDITOR_VALUE + ); const requestProfile = useRequestProfile(); const handleProfileClick = async () => { dispatch({ type: 'setProfiling', value: true }); try { - const { current: editor } = editorRef; const { data: result, error } = await requestProfile({ - query: editorRef.current.getValue(), + query: editorValue, index: indexInputRef.current.value, }); if (error) { notifications.addDanger(error); - editor.focus(); + editorPropsRef.current.focus(); return; } if (result === null) { @@ -78,18 +81,13 @@ export const ProfileQueryEditor = memo(() => { }; const onEditorReady = useCallback( - (editorInstance: any) => (editorRef.current = editorInstance), + (editorPropsInstance: EditorProps) => (editorPropsRef.current = editorPropsInstance), [] ); const licenseEnabled = getLicenseStatus().valid; return ( - + {/* Form */} @@ -120,9 +118,9 @@ export const ProfileQueryEditor = memo(() => { diff --git a/x-pack/plugins/searchprofiler/public/shared_imports.ts b/x-pack/plugins/searchprofiler/public/shared_imports.ts index b1af4bab9e62d..3daab65e28db8 100644 --- a/x-pack/plugins/searchprofiler/public/shared_imports.ts +++ b/x-pack/plugins/searchprofiler/public/shared_imports.ts @@ -5,6 +5,4 @@ * 2.0. */ -export { ace } from '@kbn/es-ui-shared-plugin/public'; - export { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; diff --git a/x-pack/plugins/searchprofiler/tsconfig.json b/x-pack/plugins/searchprofiler/tsconfig.json index b99b0962e39fc..063b7dfa63ce6 100644 --- a/x-pack/plugins/searchprofiler/tsconfig.json +++ b/x-pack/plugins/searchprofiler/tsconfig.json @@ -20,9 +20,10 @@ "@kbn/expect", "@kbn/test-jest-helpers", "@kbn/i18n-react", - "@kbn/ace", "@kbn/config-schema", "@kbn/react-kibana-context-render", + "@kbn/code-editor", + "@kbn/monaco", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/edit_role_mapping_page.test.tsx b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/edit_role_mapping_page.test.tsx index e74d2f7703f31..63c395b1f4bbc 100644 --- a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/edit_role_mapping_page.test.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/edit_role_mapping_page.test.tsx @@ -5,11 +5,6 @@ * 2.0. */ -// brace/ace uses the Worker class, which is not currently provided by JSDOM. -// This is not required for the tests to pass, but it rather suppresses lengthy -// warnings in the console which adds unnecessary noise to the test output. -import '@kbn/web-worker-stub'; - import React from 'react'; import { coreMock, scopedHistoryMock } from '@kbn/core/public/mocks'; diff --git a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/json_rule_editor.test.tsx b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/json_rule_editor.test.tsx index 64e332bd130bd..9db22a251779b 100644 --- a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/json_rule_editor.test.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/json_rule_editor.test.tsx @@ -5,13 +5,6 @@ * 2.0. */ -import 'brace'; -import 'brace/mode/json'; -// brace/ace uses the Worker class, which is not currently provided by JSDOM. -// This is not required for the tests to pass, but it rather suppresses lengthy -// warnings in the console which adds unnecessary noise to the test output. -import '@kbn/web-worker-stub'; - import React from 'react'; import { act } from 'react-dom/test-utils'; import '@kbn/code-editor-mock/jest_helper'; diff --git a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/json_rule_editor.tsx b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/json_rule_editor.tsx index 963bbf3a35cfc..121f694517a83 100644 --- a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/json_rule_editor.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/json_rule_editor.tsx @@ -5,10 +5,6 @@ * 2.0. */ -import 'react-ace'; -import 'brace/mode/json'; -import 'brace/theme/github'; - import { EuiButton, EuiFormRow, EuiLink, EuiSpacer, EuiText } from '@elastic/eui'; import React, { Fragment, useState } from 'react'; diff --git a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/rule_editor_panel.test.tsx b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/rule_editor_panel.test.tsx index d01229cdce8a9..21ece31571ae1 100644 --- a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/rule_editor_panel.test.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/rule_editor_panel.test.tsx @@ -5,11 +5,6 @@ * 2.0. */ -// brace/ace uses the Worker class, which is not currently provided by JSDOM. -// This is not required for the tests to pass, but it rather suppresses lengthy -// warnings in the console which adds unnecessary noise to the test output. -import '@kbn/web-worker-stub'; - import { EuiErrorBoundary } from '@elastic/eui'; import React from 'react'; diff --git a/x-pack/plugins/security/server/authorization/api_authorization.ts b/x-pack/plugins/security/server/authorization/api_authorization.ts index ba38d9ca0aa20..9c67ff8bdff8b 100644 --- a/x-pack/plugins/security/server/authorization/api_authorization.ts +++ b/x-pack/plugins/security/server/authorization/api_authorization.ts @@ -87,17 +87,17 @@ export function initAPIAuthorization( const missingPrivileges = Object.keys(kibanaPrivileges).filter( (key) => !kibanaPrivileges[key] ); - logger.warn( - `User not authorized for "${request.url.pathname}${ - request.url.search - }", responding with 403: missing privileges: ${missingPrivileges.join(', ')}` - ); + const forbiddenMessage = `API [${request.route.method.toUpperCase()} ${ + request.url.pathname + }${ + request.url.search + }] is unauthorized for user, this action is granted by the Kibana privileges [${missingPrivileges}]`; + + logger.warn(forbiddenMessage); return response.forbidden({ body: { - message: `User not authorized for ${request.url.pathname}${ - request.url.search - }, missing privileges: ${missingPrivileges.join(', ')}`, + message: forbiddenMessage, }, }); } diff --git a/x-pack/plugins/security/tsconfig.json b/x-pack/plugins/security/tsconfig.json index 535e221f8e5fb..2d5509d2d6d42 100644 --- a/x-pack/plugins/security/tsconfig.json +++ b/x-pack/plugins/security/tsconfig.json @@ -51,7 +51,6 @@ "@kbn/core-saved-objects-api-server-internal", "@kbn/core-saved-objects-api-server-mocks", "@kbn/logging-mocks", - "@kbn/web-worker-stub", "@kbn/core-saved-objects-utils-server", "@kbn/core-saved-objects-api-server", "@kbn/core-saved-objects-base-server-internal", diff --git a/x-pack/plugins/security_solution/common/detection_engine/constants.ts b/x-pack/plugins/security_solution/common/detection_engine/constants.ts index 7057e3c8b3091..270af1a91cf46 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/constants.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/constants.ts @@ -51,4 +51,12 @@ export const SUPPRESSIBLE_ALERT_RULES: Type[] = [ 'machine_learning', ]; -export const SUPPRESSIBLE_ALERT_RULES_GA: Type[] = ['saved_query', 'query']; +export const SUPPRESSIBLE_ALERT_RULES_GA: Type[] = [ + 'threshold', + 'esql', + 'saved_query', + 'query', + 'new_terms', + 'threat_match', + 'machine_learning', +]; diff --git a/x-pack/plugins/security_solution/common/detection_engine/utils.test.ts b/x-pack/plugins/security_solution/common/detection_engine/utils.test.ts index a4db006a67463..be0b6ce9c2927 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/utils.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/utils.test.ts @@ -250,14 +250,14 @@ describe('Alert Suppression Rules', () => { test('should return true for rule type suppression in global availability', () => { expect(isSuppressionRuleInGA('saved_query')).toBe(true); expect(isSuppressionRuleInGA('query')).toBe(true); + expect(isSuppressionRuleInGA('esql')).toBe(true); + expect(isSuppressionRuleInGA('threshold')).toBe(true); + expect(isSuppressionRuleInGA('threat_match')).toBe(true); + expect(isSuppressionRuleInGA('new_terms')).toBe(true); + expect(isSuppressionRuleInGA('machine_learning')).toBe(true); }); test('should return false for rule type suppression in tech preview', () => { - expect(isSuppressionRuleInGA('machine_learning')).toBe(false); - expect(isSuppressionRuleInGA('esql')).toBe(false); - expect(isSuppressionRuleInGA('threshold')).toBe(false); - expect(isSuppressionRuleInGA('threat_match')).toBe(false); - expect(isSuppressionRuleInGA('new_terms')).toBe(false); expect(isSuppressionRuleInGA('eql')).toBe(false); }); }); diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 67b9a57af1628..1e5ffee50afc7 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -138,11 +138,6 @@ export const allowedExperimentalValues = Object.freeze({ */ esqlRulesDisabled: false, - /** - * enables logging requests during rule preview - */ - loggingRequestsEnabled: false, - /** * Enables Protection Updates tab in the Endpoint Policy Details page */ @@ -230,11 +225,6 @@ export const allowedExperimentalValues = Object.freeze({ */ valueListItemsModalEnabled: true, - /** - * Enables the manual rule run - */ - manualRuleRunEnabled: false, - /** * Adds a new option to filter descendants of a process for Management / Event Filters */ diff --git a/x-pack/plugins/security_solution/public/assistant/stack_management/management_settings.test.tsx b/x-pack/plugins/security_solution/public/assistant/stack_management/management_settings.test.tsx index 1c988d14e845f..65a0ab84d3412 100644 --- a/x-pack/plugins/security_solution/public/assistant/stack_management/management_settings.test.tsx +++ b/x-pack/plugins/security_solution/public/assistant/stack_management/management_settings.test.tsx @@ -77,6 +77,11 @@ describe('ManagementSettings', () => { securitySolutionAssistant: { 'ai-assistant': false }, }, }, + data: { + dataViews: { + getIndices: jest.fn(), + }, + }, security: { userProfiles: { getCurrent: jest.fn().mockResolvedValue({ data: { color: 'blue', initials: 'P' } }), diff --git a/x-pack/plugins/security_solution/public/assistant/stack_management/management_settings.tsx b/x-pack/plugins/security_solution/public/assistant/stack_management/management_settings.tsx index 90e39398474ec..48d89e02dfc71 100644 --- a/x-pack/plugins/security_solution/public/assistant/stack_management/management_settings.tsx +++ b/x-pack/plugins/security_solution/public/assistant/stack_management/management_settings.tsx @@ -37,6 +37,7 @@ export const ManagementSettings = React.memo(() => { securitySolutionAssistant: { 'ai-assistant': securityAIAssistantEnabled }, }, }, + data: { dataViews }, security, } = useKibana().services; @@ -46,8 +47,8 @@ export const ManagementSettings = React.memo(() => { security?.userProfiles.getCurrent<{ avatar: UserAvatar }>({ dataPath: 'avatar', }), - select: (data) => { - return data.data.avatar; + select: (d) => { + return d.data.avatar; }, keepPreviousData: true, refetchOnWindowFocus: false, @@ -79,7 +80,12 @@ export const ManagementSettings = React.memo(() => { } if (conversations) { - return ; + return ( + + ); } return <>; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.ts b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.ts index f800651985217..97eb132bdaaeb 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.ts +++ b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.ts @@ -18,6 +18,7 @@ import { isEmpty } from 'lodash/fp'; enum OpenAiProviderType { OpenAi = 'OpenAI', AzureAi = 'Azure OpenAI', + Other = 'Other', } interface GenAiConfig { 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 a13a77a3562ff..a372ca4755fd8 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 @@ -35,7 +35,7 @@ const FIRST_RECORD_PAGINATION = { querySize: 1, }; -const getFindingsStats = (passedFindingsStats: number, failedFindingsStats: number) => { +export const getFindingsStats = (passedFindingsStats: number, failedFindingsStats: number) => { if (passedFindingsStats === 0 && failedFindingsStats === 0) return []; return [ { diff --git a/x-pack/plugins/security_solution/public/common/components/query_bar/index.tsx b/x-pack/plugins/security_solution/public/common/components/query_bar/index.tsx index 039860093e423..793ca853598b3 100644 --- a/x-pack/plugins/security_solution/public/common/components/query_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/query_bar/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { cloneDeep } from 'lodash'; +import { cloneDeep, isEmpty } from 'lodash'; import React, { memo, useMemo, useCallback, useState, useEffect } from 'react'; import deepEqual from 'fast-deep-equal'; @@ -125,7 +125,7 @@ export const QueryBar = memo( let dv: DataView; if (isDataView(indexPattern)) { setDataView(indexPattern); - } else if (!isEsql) { + } else if (!isEsql && !isEmpty(indexPattern.title)) { const createDataView = async () => { dv = await data.dataViews.create({ id: indexPattern.title, title: indexPattern.title }); setDataView(dv); diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/constants.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/constants.ts index f42f77f19a0f9..5126d75178f5f 100644 --- a/x-pack/plugins/security_solution/public/common/lib/telemetry/constants.ts +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/constants.ts @@ -86,6 +86,7 @@ export enum TelemetryEventTypes { EventLogShowSourceEventDateRange = 'Event Log -> Show Source -> Event Date Range', OpenNoteInExpandableFlyoutClicked = 'Open Note In Expandable Flyout Clicked', AddNoteFromExpandableFlyoutClicked = 'Add Note From Expandable Flyout Clicked', + PreviewRule = 'Preview rule', } export enum ML_JOB_TELEMETRY_STATUS { diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/events/preview_rule/index.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/events/preview_rule/index.ts new file mode 100644 index 0000000000000..12d721c45e2c0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/events/preview_rule/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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { TelemetryEvent } from '../../types'; +import { TelemetryEventTypes } from '../../constants'; + +export const previewRuleEvent: TelemetryEvent = { + eventType: TelemetryEventTypes.PreviewRule, + schema: { + ruleType: { + type: 'keyword', + _meta: { + description: 'Rule type', + optional: false, + }, + }, + loggedRequestsEnabled: { + type: 'boolean', + _meta: { + description: 'shows if preview executed with enabled logged requests', + optional: false, + }, + }, + }, +}; diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/events/preview_rule/types.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/events/preview_rule/types.ts new file mode 100644 index 0000000000000..e5523080088fc --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/events/preview_rule/types.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { Type } from '@kbn/securitysolution-io-ts-alerting-types'; + +import type { RootSchema } from '@kbn/core/public'; +import type { TelemetryEventTypes } from '../../constants'; + +export interface PreviewRuleParams { + ruleType: Type; + loggedRequestsEnabled: boolean; +} + +export interface PreviewRuleTelemetryEvent { + eventType: TelemetryEventTypes.PreviewRule; + 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 d1f9502346a04..a0328099b9ff7 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 @@ -48,6 +48,7 @@ import { addNoteFromExpandableFlyoutClickedEvent, openNoteInExpandableFlyoutClickedEvent, } from './notes'; +import { previewRuleEvent } from './preview_rule'; const mlJobUpdateEvent: TelemetryEvent = { eventType: TelemetryEventTypes.MLJobUpdate, @@ -192,4 +193,5 @@ export const telemetryEvents = [ eventLogShowSourceEventDateRangeEvent, openNoteInExpandableFlyoutClickedEvent, addNoteFromExpandableFlyoutClickedEvent, + previewRuleEvent, ]; 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 02342cb4257be..98d6aa64bb9cb 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 @@ -42,4 +42,5 @@ export const createTelemetryClientMock = (): jest.Mocked = reportManualRuleRunOpenModal: jest.fn(), reportOpenNoteInExpandableFlyoutClicked: jest.fn(), reportAddNoteFromExpandableFlyoutClicked: jest.fn(), + reportPreviewRule: 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 0023064adac69..e09f0a3c2eb66 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 @@ -44,6 +44,7 @@ import type { ReportManualRuleRunOpenModalParams, ReportEventLogShowSourceEventDateRangeParams, ReportEventLogFilterByRunTypeParams, + PreviewRuleParams, } from './types'; import { TelemetryEventTypes } from './constants'; @@ -211,4 +212,8 @@ export class TelemetryClient implements TelemetryClientStart { ) => { this.analytics.reportEvent(TelemetryEventTypes.AddNoteFromExpandableFlyoutClicked, params); }; + + public reportPreviewRule = (params: PreviewRuleParams) => { + this.analytics.reportEvent(TelemetryEventTypes.PreviewRule, 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 49c78dc50feeb..55b91837a2585 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 @@ -72,6 +72,7 @@ import type { NotesTelemetryEvents, OpenNoteInExpandableFlyoutClickedParams, } from './events/notes/types'; +import type { PreviewRuleParams, PreviewRuleTelemetryEvent } from './events/preview_rule/types'; export * from './events/ai_assistant/types'; export * from './events/alerts_grouping/types'; @@ -91,6 +92,7 @@ export type { export * from './events/document_details/types'; export * from './events/manual_rule_run/types'; export * from './events/event_log/types'; +export * from './events/preview_rule/types'; export interface TelemetryServiceSetupParams { analytics: AnalyticsServiceSetup; @@ -136,6 +138,7 @@ export type TelemetryEventParams = | OnboardingHubStepLinkClickedParams | ReportManualRuleRunTelemetryEventParams | ReportEventLogTelemetryEventParams + | PreviewRuleParams | NotesTelemetryEventParams; export interface TelemetryClientStart { @@ -194,6 +197,9 @@ export interface TelemetryClientStart { // new notes reportOpenNoteInExpandableFlyoutClicked(params: OpenNoteInExpandableFlyoutClickedParams): void; reportAddNoteFromExpandableFlyoutClicked(params: AddNoteFromExpandableFlyoutClickedParams): void; + + // preview rule + reportPreviewRule(params: PreviewRuleParams): void; } export type TelemetryEvent = @@ -221,4 +227,5 @@ export type TelemetryEvent = | OnboardingHubTelemetryEvent | ManualRuleRunTelemetryEvent | EventLogTelemetryEvent + | PreviewRuleTelemetryEvent | NotesTelemetryEvents; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/index.test.tsx index 4ebb460177476..25d5b90d5408a 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/index.test.tsx @@ -23,7 +23,6 @@ import { stepDefineDefaultValue, } from '../../../../detections/pages/detection_engine/rules/utils'; import { usePreviewInvocationCount } from './use_preview_invocation_count'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; jest.mock('../../../../common/lib/kibana'); jest.mock('./use_preview_route'); @@ -40,7 +39,6 @@ jest.mock('../../../../common/hooks/use_experimental_features', () => ({ useIsExperimentalFeatureEnabled: jest.fn(), })); -const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock; // rule types that do not support logged requests const doNotSupportLoggedRequests: Type[] = [ 'threshold', @@ -114,8 +112,6 @@ describe('PreviewQuery', () => { }); (usePreviewInvocationCount as jest.Mock).mockReturnValue({ invocationCount: 500 }); - - useIsExperimentalFeatureEnabledMock.mockReturnValue(true); }); afterEach(() => { @@ -172,23 +168,6 @@ describe('PreviewQuery', () => { }); }); - supportLoggedRequests.forEach((ruleType) => { - test(`does not render "Show Elasticsearch requests" for ${ruleType} rule type when feature is disabled`, () => { - useIsExperimentalFeatureEnabledMock.mockReturnValue(false); - - render( - - - - ); - - expect(screen.queryByTestId('show-elasticsearch-requests')).toBeNull(); - }); - }); - doNotSupportLoggedRequests.forEach((ruleType) => { test(`does not render "Show Elasticsearch requests" for ${ruleType} rule type`, () => { render( diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/index.tsx index 2a86600d94e7a..f941cad91d3a4 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/index.tsx @@ -40,7 +40,6 @@ import type { TimeframePreviewOptions, } from '../../../../detections/pages/detection_engine/rules/types'; import { usePreviewInvocationCount } from './use_preview_invocation_count'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; export const REASONABLE_INVOCATION_COUNT = 200; @@ -90,8 +89,6 @@ const RulePreviewComponent: React.FC = ({ const { indexPattern, ruleType } = defineRuleData; const { spaces } = useKibana().services; - const isLoggingRequestsFeatureEnabled = useIsExperimentalFeatureEnabled('loggingRequestsEnabled'); - const [spaceId, setSpaceId] = useState(''); useEffect(() => { if (spaces) { @@ -282,8 +279,7 @@ const RulePreviewComponent: React.FC = ({ - {isLoggingRequestsFeatureEnabled && - RULE_TYPES_SUPPORTING_LOGGED_REQUESTS.includes(ruleType) ? ( + {RULE_TYPES_SUPPORTING_LOGGED_REQUESTS.includes(ruleType) ? ( diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/use_preview_rule.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/use_preview_rule.ts index 05c3b9fe10299..018e2602aa170 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/use_preview_rule.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/use_preview_rule.ts @@ -12,7 +12,7 @@ import type { RuleCreateProps, RulePreviewResponse, } from '../../../../../common/api/detection_engine'; - +import { useKibana } from '../../../../common/lib/kibana'; import { previewRule } from '../../../rule_management/api/api'; import { transformOutput } from '../../../../detections/containers/detection_engine/rules/transforms'; import type { TimeframePreviewOptions } from '../../../../detections/pages/detection_engine/rules/types'; @@ -37,6 +37,7 @@ export const usePreviewRule = ({ const [isLoading, setIsLoading] = useState(false); const { addError } = useAppToasts(); const { invocationCount, interval, from } = usePreviewInvocationCount({ timeframeOptions }); + const { telemetry } = useKibana().services; const timeframeEnd = useMemo( () => timeframeOptions.timeframeEnd.toISOString(), @@ -57,6 +58,10 @@ export const usePreviewRule = ({ const createPreviewId = async () => { if (rule != null) { try { + telemetry.reportPreviewRule({ + loggedRequestsEnabled: enableLoggedRequests ?? false, + ruleType: rule.type, + }); setIsLoading(true); const previewRuleResponse = await previewRule({ rule: { @@ -90,7 +95,16 @@ export const usePreviewRule = ({ isSubscribed = false; abortCtrl.abort(); }; - }, [rule, addError, invocationCount, from, interval, timeframeEnd, enableLoggedRequests]); + }, [ + rule, + addError, + invocationCount, + from, + interval, + timeframeEnd, + enableLoggedRequests, + telemetry, + ]); return { isLoading, response, rule, setRule }; }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/translations.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/translations.tsx index 7d7bb9c4a9253..b212aa7c67dd4 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/translations.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/translations.tsx @@ -205,15 +205,15 @@ export const THRESHOLD_SUPPRESSION_PER_RULE_EXECUTION_WARNING = i18n.translate( export const getEnableThresholdSuppressionLabel = (fields: string[] | undefined) => fields?.length ? ( {fields.join(', ')} }} /> ) : ( i18n.translate( - 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.enableThresholdSuppressionLabel', + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.ga.enableThresholdSuppressionLabel', { - defaultMessage: 'Suppress alerts (Technical Preview)', + defaultMessage: 'Suppress alerts', } ) ); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/execution_log_table/__snapshots__/execution_log_search_bar.test.tsx.snap b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/execution_log_table/__snapshots__/execution_log_search_bar.test.tsx.snap index 009e6dcc58ace..4f5e9954cced0 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/execution_log_table/__snapshots__/execution_log_search_bar.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/execution_log_table/__snapshots__/execution_log_search_bar.test.tsx.snap @@ -13,6 +13,20 @@ exports[`ExecutionLogSearchBar snapshots renders correctly against snapshot 1`] + + + diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/execution_log_table/execution_log_search_bar.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/execution_log_table/execution_log_search_bar.tsx index db43b104ec713..3c70fa7c33c9c 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/execution_log_table/execution_log_search_bar.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/execution_log_table/execution_log_search_bar.tsx @@ -20,7 +20,6 @@ import { } from '../../../../../../common/detection_engine/rule_management/execution_log'; import { ExecutionStatusFilter, ExecutionRunTypeFilter } from '../../../../rule_monitoring'; -import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; import * as i18n from './translations'; export const EXECUTION_LOG_SCHEMA_MAPPING = { @@ -75,7 +74,6 @@ export const ExecutionLogSearchBar = React.memo( }, [onSearch] ); - const isManualRuleRunEnabled = useIsExperimentalFeatureEnabled('manualRuleRunEnabled'); return ( @@ -93,15 +91,14 @@ export const ExecutionLogSearchBar = React.memo( - {isManualRuleRunEnabled && ( - - - - )} + + + + = ({ timelines, telemetry, } = useKibana().services; - const isManualRuleRunEnabled = useIsExperimentalFeatureEnabled('manualRuleRunEnabled'); const { [RuleDetailTabs.executionResults]: { @@ -473,15 +470,10 @@ const ExecutionLogTableComponent: React.FC = ({ ); const executionLogColumns = useMemo(() => { - const columns = [...EXECUTION_LOG_COLUMNS].filter((item) => { - if ('field' in item) { - return item.field === 'type' ? isManualRuleRunEnabled : true; - } - return true; - }); + const columns = [...EXECUTION_LOG_COLUMNS]; let messageColumnWidth = 50; - if (showSourceEventTimeRange && isManualRuleRunEnabled) { + if (showSourceEventTimeRange) { columns.push(...getSourceEventTimeRangeColumns()); messageColumnWidth = 30; } @@ -506,7 +498,6 @@ const ExecutionLogTableComponent: React.FC = ({ return columns; }, [ - isManualRuleRunEnabled, actions, docLinks, showMetricColumns, @@ -583,14 +574,12 @@ const ExecutionLogTableComponent: React.FC = ({ updatedAt: dataUpdatedAt, })} - {isManualRuleRunEnabled && ( - - )} + {i18n.MANUAL_RULE_RUN_MODAL_TITLE} - + } diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/components/rule_backfills_info/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/components/rule_backfills_info/index.tsx index 2bacc44b15a76..2a0981e2f5259 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/components/rule_backfills_info/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_gaps/components/rule_backfills_info/index.tsx @@ -25,9 +25,8 @@ import { hasUserCRUDPermission } from '../../../../common/utils/privileges'; import { useUserData } from '../../../../detections/components/user_info'; import { getBackfillRowsFromResponse } from './utils'; import { HeaderSection } from '../../../../common/components/header_section'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { TableHeaderTooltipCell } from '../../../rule_management_ui/components/rules_table/table_header_tooltip_cell'; -import { TECHNICAL_PREVIEW, TECHNICAL_PREVIEW_TOOLTIP } from '../../../../common/translations'; +import { BETA, BETA_TOOLTIP } from '../../../../common/translations'; import { useKibana } from '../../../../common/lib/kibana'; const DEFAULT_PAGE_SIZE = 10; @@ -143,26 +142,16 @@ const getBackfillsTableColumns = (hasCRUDPermissions: boolean) => { }; export const RuleBackfillsInfo = React.memo<{ ruleId: string }>(({ ruleId }) => { - const isManualRuleRunEnabled = useIsExperimentalFeatureEnabled('manualRuleRunEnabled'); const [pageIndex, setPageIndex] = useState(0); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const [{ canUserCRUD }] = useUserData(); const hasCRUDPermissions = hasUserCRUDPermission(canUserCRUD); const { timelines } = useKibana().services; - const { data, isLoading, isError, refetch, dataUpdatedAt } = useFindBackfillsForRules( - { - ruleIds: [ruleId], - page: pageIndex + 1, - perPage: pageSize, - }, - { - enabled: isManualRuleRunEnabled, - } - ); - - if (!isManualRuleRunEnabled) { - return null; - } + const { data, isLoading, isError, refetch, dataUpdatedAt } = useFindBackfillsForRules({ + ruleIds: [ruleId], + page: pageIndex + 1, + perPage: pageSize, + }); const backfills: BackfillRow[] = getBackfillRowsFromResponse(data?.data ?? []); @@ -197,7 +186,7 @@ export const RuleBackfillsInfo = React.memo<{ ruleId: string }>(({ ruleId }) => title={i18n.BACKFILL_TABLE_TITLE} subtitle={i18n.BACKFILL_TABLE_SUBTITLE} /> - + diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/comparison_side/comparison_side.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/comparison_side/comparison_side.tsx index 9ef207b0bb998..2592469beaabb 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/comparison_side/comparison_side.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/comparison_side/comparison_side.tsx @@ -6,6 +6,7 @@ */ import React, { useState } from 'react'; +import { EuiFlexGroup, EuiTitle } from '@elastic/eui'; import { VersionsPicker } from '../versions_picker/versions_picker'; import type { Version } from '../versions_picker/constants'; import { SelectedVersions } from '../versions_picker/constants'; @@ -17,6 +18,8 @@ import type { import { getSubfieldChanges } from './get_subfield_changes'; import { SubfieldChanges } from './subfield_changes'; import { SideHeader } from '../components/side_header'; +import { ComparisonSideHelpInfo } from './comparison_side_help_info'; +import * as i18n from './translations'; interface ComparisonSideProps { fieldName: FieldName; @@ -43,11 +46,19 @@ export function ComparisonSide({ return ( <> - + + +

    + {i18n.TITLE} + +

    +
    + +
    diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/comparison_side/comparison_side_help_info.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/comparison_side/comparison_side_help_info.tsx new file mode 100644 index 0000000000000..a2b7e1a360150 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/comparison_side/comparison_side_help_info.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { useToggle } from 'react-use'; +import { EuiPopover, EuiText, EuiButtonIcon } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +/** + * Theme doesn't expose width variables. Using provided size variables will require + * multiplying it by another magic constant. + * + * 320px width looks + * like a [commonly used width in EUI](https://github.com/search?q=repo%3Aelastic%2Feui%20320&type=code). + */ +const POPOVER_WIDTH = 320; + +export function ComparisonSideHelpInfo(): JSX.Element { + const [isPopoverOpen, togglePopover] = useToggle(false); + + const button = ( + + ); + + return ( + + + + + + ); +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/comparison_side/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/comparison_side/translations.ts index d60c78646b5ad..8208892ac298d 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/comparison_side/translations.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/comparison_side/translations.ts @@ -7,6 +7,13 @@ import { i18n } from '@kbn/i18n'; +export const TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.upgradeRules.comparisonSide.title', + { + defaultMessage: 'Diff view', + } +); + export const NO_CHANGES = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.upgradeRules.comparisonSide.noChangesLabel', { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/field_upgrade_conflicts_resolver.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/field_upgrade_conflicts_resolver.tsx index eeafddfc21f03..a750c163814a0 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/field_upgrade_conflicts_resolver.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/field_upgrade_conflicts_resolver.tsx @@ -16,18 +16,21 @@ import type { ThreeWayDiff, } from '../../../../../../../common/api/detection_engine'; import { ThreeWayDiffConflict } from '../../../../../../../common/api/detection_engine'; +import type { FieldUpgradeState } from '../../../../model/prebuilt_rule_upgrade'; import { ComparisonSide } from '../comparison_side/comparison_side'; import { FinalSide } from '../final_side/final_side'; import { FieldUpgradeConflictsResolverHeader } from './field_upgrade_conflicts_resolver_header'; interface FieldUpgradeConflictsResolverProps { fieldName: FieldName; + fieldUpgradeState: FieldUpgradeState; fieldThreeWayDiff: RuleFieldsDiff[FieldName]; finalDiffableRule: DiffableRule; } export function FieldUpgradeConflictsResolver({ fieldName, + fieldUpgradeState, fieldThreeWayDiff, finalDiffableRule, }: FieldUpgradeConflictsResolverProps): JSX.Element { @@ -37,7 +40,12 @@ export function FieldUpgradeConflictsResolver } + header={ + + } initialIsOpen={hasConflict} data-test-subj="ruleUpgradePerFieldDiff" > diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/field_upgrade_conflicts_resolver_header.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/field_upgrade_conflicts_resolver_header.tsx index 2821a0a179b91..a096f025873a5 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/field_upgrade_conflicts_resolver_header.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/field_upgrade_conflicts_resolver_header.tsx @@ -7,19 +7,27 @@ import React from 'react'; import { camelCase, startCase } from 'lodash'; -import { EuiTitle } from '@elastic/eui'; +import { EuiFlexGroup, EuiTitle } from '@elastic/eui'; import { fieldToDisplayNameMap } from '../../diff_components/translations'; +import type { FieldUpgradeState } from '../../../../model/prebuilt_rule_upgrade'; +import { FieldUpgradeStateInfo } from './field_upgrade_state_info'; interface FieldUpgradeConflictsResolverHeaderProps { fieldName: string; + fieldUpgradeState: FieldUpgradeState; } export function FieldUpgradeConflictsResolverHeader({ fieldName, + fieldUpgradeState, }: FieldUpgradeConflictsResolverHeaderProps): JSX.Element { return ( - -
    {fieldToDisplayNameMap[fieldName] ?? startCase(camelCase(fieldName))}
    -
    + + +
    {fieldToDisplayNameMap[fieldName] ?? startCase(camelCase(fieldName))}
    +
    + + +
    ); } diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/field_upgrade_state_info/field_upgrade_state_info.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/field_upgrade_state_info/field_upgrade_state_info.tsx new file mode 100644 index 0000000000000..c49fc18e2c6ba --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/field_upgrade_state_info/field_upgrade_state_info.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiIcon, EuiText } from '@elastic/eui'; +import { FieldUpgradeState } from '../../../../../model/prebuilt_rule_upgrade'; +import * as i18n from './translations'; + +interface FieldUpgradeStateInfoProps { + state: FieldUpgradeState; +} + +export function FieldUpgradeStateInfo({ state }: FieldUpgradeStateInfoProps): JSX.Element { + switch (state) { + case FieldUpgradeState.Accepted: + return ( + <> + + +  {i18n.UPDATE_ACCEPTED} + {i18n.SEPARATOR} + {i18n.UPDATE_ACCEPTED_DESCRIPTION} + + + ); + + case FieldUpgradeState.SolvableConflict: + return ( + <> + + +  {i18n.SOLVABLE_CONFLICT} + {i18n.SEPARATOR} + {i18n.SOLVABLE_CONFLICT_DESCRIPTION} + + + ); + + case FieldUpgradeState.NonSolvableConflict: + return ( + <> + + +  {i18n.NON_SOLVABLE_CONFLICT} + {i18n.SEPARATOR} + {i18n.NON_SOLVABLE_CONFLICT_DESCRIPTION} + + + ); + } +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/field_upgrade_state_info/index.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/field_upgrade_state_info/index.ts new file mode 100644 index 0000000000000..69915cc64cdcc --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/field_upgrade_state_info/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 * from './field_upgrade_state_info'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/field_upgrade_state_info/translations.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/field_upgrade_state_info/translations.tsx new file mode 100644 index 0000000000000..36349b5029a87 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/field_upgrade_state_info/translations.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 UPDATE_ACCEPTED = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.upgradeRules.fieldUpgradeState.updateAccepted', + { + defaultMessage: 'Update accepted', + } +); + +export const UPDATE_ACCEPTED_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.upgradeRules.fieldUpgradeState.updateAcceptedDescription', + { + defaultMessage: + 'You can still make changes, please review/accept all other conflicts before updating the rule.', + } +); + +export const SOLVABLE_CONFLICT = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.upgradeRules.fieldUpgradeState.solvableConflict', + { + defaultMessage: 'Solved conflict', + } +); + +export const SOLVABLE_CONFLICT_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.upgradeRules.fieldUpgradeState.solvableConflictDescription', + { + defaultMessage: + 'We have suggested an update for this modified field, please review before accepting.', + } +); + +export const NON_SOLVABLE_CONFLICT = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.upgradeRules.fieldUpgradeState.nonSolvableConflict', + { + defaultMessage: 'Solved conflict', + } +); + +export const NON_SOLVABLE_CONFLICT_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.upgradeRules.fieldUpgradeState.nonSolvableConflictDescription', + { + defaultMessage: + 'We have suggested an update for this modified field, please review before accepting.', + } +); + +export const SEPARATOR = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.upgradeRules.fieldUpgradeState.separator', + { + defaultMessage: ' - ', + } +); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/rule_upgrade_callout/index.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/rule_upgrade_callout/index.ts new file mode 100644 index 0000000000000..75ff48ff541a1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/rule_upgrade_callout/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 * from './rule_upgrade_callout'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/rule_upgrade_callout/rule_upgrade_callout.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/rule_upgrade_callout/rule_upgrade_callout.tsx new file mode 100644 index 0000000000000..852ab0c91c58e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/rule_upgrade_callout/rule_upgrade_callout.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, { useMemo } from 'react'; +import { EuiCallOut } from '@elastic/eui'; +import type { RuleUpgradeState } from '../../../../../model/prebuilt_rule_upgrade'; +import { FieldUpgradeState } from '../../../../../model/prebuilt_rule_upgrade'; +import * as i18n from './translations'; + +interface RuleUpgradeCalloutProps { + ruleUpgradeState: RuleUpgradeState; +} + +export function RuleUpgradeCallout({ ruleUpgradeState }: RuleUpgradeCalloutProps): JSX.Element { + const fieldsUpgradeState = ruleUpgradeState.fieldsUpgradeState; + const { numOfNonSolvableConflicts, numOfSolvableConflicts } = useMemo(() => { + let numOfFieldsWithNonSolvableConflicts = 0; + let numOfFieldsWithSolvableConflicts = 0; + + for (const fieldName of Object.keys(fieldsUpgradeState)) { + if (fieldsUpgradeState[fieldName] === FieldUpgradeState.NonSolvableConflict) { + numOfFieldsWithNonSolvableConflicts++; + } + + if (fieldsUpgradeState[fieldName] === FieldUpgradeState.SolvableConflict) { + numOfFieldsWithSolvableConflicts++; + } + } + + return { + numOfNonSolvableConflicts: numOfFieldsWithNonSolvableConflicts, + numOfSolvableConflicts: numOfFieldsWithSolvableConflicts, + }; + }, [fieldsUpgradeState]); + + if (numOfNonSolvableConflicts > 0) { + return ( + +

    {i18n.RULE_HAS_NON_SOLVABLE_CONFLICTS_DESCRIPTION}

    +
    + ); + } + + if (numOfSolvableConflicts > 0) { + return ( + +

    {i18n.RULE_HAS_SOLVABLE_CONFLICTS_DESCRIPTION}

    +
    + ); + } + + return ( + +

    {i18n.RULE_IS_READY_FOR_UPGRADE_DESCRIPTION}

    +
    + ); +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/rule_upgrade_callout/translations.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/rule_upgrade_callout/translations.tsx new file mode 100644 index 0000000000000..be9ee761388d0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/rule_upgrade_callout/translations.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const RULE_HAS_NON_SOLVABLE_CONFLICTS = (count: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.upgradeRules.fieldUpgradeState.ruleHasNonSolvableConflicts', + { + values: { count }, + defaultMessage: + '{count} of the fields has a unsolved conflict. Please review and modify accordingly.', + } + ); + +export const RULE_HAS_NON_SOLVABLE_CONFLICTS_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.upgradeRules.fieldUpgradeState.ruleHasNonSolvableConflictsDescription', + { + defaultMessage: + 'Please provide an input for the unsolved conflict. You can also keep the current without the updates, or accept the Elastic update but lose your modifications.', + } +); + +export const RULE_HAS_SOLVABLE_CONFLICTS = (count: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.upgradeRules.fieldUpgradeState.ruleHasSolvableConflicts', + { + values: { count }, + defaultMessage: + '{count} of the fields has an update conflict, please review the suggested update being updating.', + } + ); + +export const RULE_HAS_SOLVABLE_CONFLICTS_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.upgradeRules.fieldUpgradeState.ruleHasSolvableConflictsDescription', + { + defaultMessage: + 'Please review the suggested updated version before accepting the update. You can edit and then save the field if you wish to change it.', + } +); + +export const RULE_IS_READY_FOR_UPGRADE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.upgradeRules.fieldUpgradeState.ruleIsReadyForUpgrade', + { + defaultMessage: 'The update is ready to be applied.', + } +); + +export const RULE_IS_READY_FOR_UPGRADE_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.upgradeRules.fieldUpgradeState.ruleIsReadyForUpgradeDescription', + { + defaultMessage: 'All conflicts have now been reviewed and solved please update the rule.', + } +); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/rule_upgrade_conflicts_resolver.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/rule_upgrade_conflicts_resolver.tsx index 57af1b340c776..f60af70c808f5 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/rule_upgrade_conflicts_resolver.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/rule_upgrade_conflicts_resolver.tsx @@ -9,7 +9,7 @@ import React from 'react'; import type { RuleUpgradeState, SetRuleFieldResolvedValueFn, -} from '../../../../../rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_prebuilt_rules_upgrade_state'; +} from '../../../../model/prebuilt_rule_upgrade'; import { FieldUpgradeConflictsResolver } from './field_upgrade_conflicts_resolver'; interface RuleUpgradeConflictsResolverProps { @@ -31,6 +31,7 @@ export function RuleUpgradeConflictsResolver({ diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/rule_upgrade_info_bar.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/rule_upgrade_info_bar.tsx index 7ecde8059cc2f..970f04f383274 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/rule_upgrade_info_bar.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/rule_upgrade_info_bar.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import type { RuleUpgradeState } from '../../../../../rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_prebuilt_rules_upgrade_state'; +import type { RuleUpgradeState } from '../../../../model/prebuilt_rule_upgrade'; import { UtilityBar, UtilityBarGroup, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/translations.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/translations.tsx index 620b3ac1c0ba8..27172cb98755c 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/translations.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/components/translations.tsx @@ -11,23 +11,21 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { useKibana } from '../../../../../../common/lib/kibana/kibana_react'; -export const NUM_OF_FIELDS_WITH_UPDATES = (count: number) => - i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.upgradeRules.diffTab.fieldsWithUpdates', - { - values: { count }, - defaultMessage: 'Upgrade has {count} {count, plural, one {field} other {fields}}', - } - ); +export const NUM_OF_FIELDS_WITH_UPDATES = (count: number) => ( + {count} }} + /> +); -export const NUM_OF_CONFLICTS = (count: number) => - i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.upgradeRules.diffTab.numOfConflicts', - { - values: { count }, - defaultMessage: '{count} {count, plural, one {conflict} other {conflicts}}', - } - ); +export const NUM_OF_CONFLICTS = (count: number) => ( + {count} }} + /> +); const UPGRADE_RULES_DOCS_LINK = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.upgradeRules.updateYourRulesDocsLink', diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_side/final_side.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_side/final_side.tsx index 0685d064b32d0..83190015ebc6d 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_side/final_side.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_side/final_side.tsx @@ -22,9 +22,9 @@ export function FinalSide({ fieldName, finalDiffableRule }: FinalSideProps): JSX return ( <> - +

    - {i18n.UPGRADED_VERSION} + {i18n.FINAL_UPDATE}

    diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_side/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_side/translations.ts index aa9b4885a964d..8f6a10b5681be 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_side/translations.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_side/translations.ts @@ -7,9 +7,9 @@ import { i18n } from '@kbn/i18n'; -export const UPGRADED_VERSION = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.upgradeRules.upgradedVersion', +export const FINAL_UPDATE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.upgradeRules.finalUpdate', { - defaultMessage: 'Upgraded version', + defaultMessage: 'Final update', } ); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/rule_upgrade_conflicts_resolver_tab.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/rule_upgrade_conflicts_resolver_tab.tsx index 10823b8045c96..547cd23c7e86e 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/rule_upgrade_conflicts_resolver_tab.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/rule_upgrade_conflicts_resolver_tab.tsx @@ -10,9 +10,10 @@ import { EuiSpacer } from '@elastic/eui'; import type { RuleUpgradeState, SetRuleFieldResolvedValueFn, -} from '../../../../rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_prebuilt_rules_upgrade_state'; +} from '../../../model/prebuilt_rule_upgrade'; import { RuleUpgradeInfoBar } from './components/rule_upgrade_info_bar'; import { RuleUpgradeConflictsResolver } from './components/rule_upgrade_conflicts_resolver'; +import { RuleUpgradeCallout } from './components/rule_upgrade_callout'; interface RuleUpgradeConflictsResolverTabProps { ruleUpgradeState: RuleUpgradeState; @@ -28,6 +29,8 @@ export function RuleUpgradeConflictsResolverTab({ + + ; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/model/prebuilt_rule_upgrade/index.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/model/prebuilt_rule_upgrade/index.ts new file mode 100644 index 0000000000000..57ee30f308f08 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/model/prebuilt_rule_upgrade/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 * from './field_upgrade_state'; +export * from './fields_upgrade_state'; +export * from './rule_upgrade_state'; +export * from './rules_upgrade_state'; +export * from './set_rule_field_resolved_value'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/model/prebuilt_rule_upgrade/rule_upgrade_state.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/model/prebuilt_rule_upgrade/rule_upgrade_state.ts new file mode 100644 index 0000000000000..0c72361bb29dc --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/model/prebuilt_rule_upgrade/rule_upgrade_state.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + type DiffableRule, + type RuleUpgradeInfoForReview, +} from '../../../../../common/api/detection_engine'; +import type { FieldsUpgradeState } from './fields_upgrade_state'; + +export interface RuleUpgradeState extends RuleUpgradeInfoForReview { + /** + * Rule containing desired values users expect to see in the upgraded rule. + */ + finalRule: DiffableRule; + /** + * Indicates whether there are conflicts blocking rule upgrading. + */ + hasUnresolvedConflicts: boolean; + /** + * Stores a record of field names mapped to field upgrade state. + */ + fieldsUpgradeState: FieldsUpgradeState; +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/model/prebuilt_rule_upgrade/rules_upgrade_state.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/model/prebuilt_rule_upgrade/rules_upgrade_state.ts new file mode 100644 index 0000000000000..66709ec34653e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/model/prebuilt_rule_upgrade/rules_upgrade_state.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { RuleSignatureId } from '../../../../../common/api/detection_engine'; +import type { RuleUpgradeState } from './rule_upgrade_state'; + +export type RulesUpgradeState = Record; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/model/prebuilt_rule_upgrade/set_rule_field_resolved_value.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/model/prebuilt_rule_upgrade/set_rule_field_resolved_value.ts new file mode 100644 index 0000000000000..c4bb65f162394 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/model/prebuilt_rule_upgrade/set_rule_field_resolved_value.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { DiffableAllFields, RuleObjectId } from '../../../../../common/api/detection_engine'; + +export type SetRuleFieldResolvedValueFn< + FieldName extends keyof DiffableAllFields = keyof DiffableAllFields +> = (params: { + ruleId: RuleObjectId; + fieldName: FieldName; + resolvedValue: DiffableAllFields[FieldName]; +}) => void; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_actions.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_actions.tsx index c2c176563ca48..68e58b4db073f 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_actions.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_actions.tsx @@ -16,7 +16,6 @@ import { MAX_MANUAL_RULE_RUN_BULK_SIZE } from '../../../../../../common/constant import type { TimeRange } from '../../../../rule_gaps/types'; import { useKibana } from '../../../../../common/lib/kibana'; import { convertRulesFilterToKQL } from '../../../../../../common/detection_engine/rule_management/rule_filtering'; -import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; import { DuplicateOptions } from '../../../../../../common/detection_engine/rule_management/constants'; import type { BulkActionEditPayload, @@ -89,7 +88,6 @@ export const useBulkActions = ({ actions: { clearRulesSelection, setIsPreflightInProgress }, } = rulesTableContext; - const isManualRuleRunEnabled = useIsExperimentalFeatureEnabled('manualRuleRunEnabled'); const getBulkItemsPopoverContent = useCallback( (closePopover: () => void): EuiContextMenuPanelDescriptor[] => { const selectedRules = rules.filter(({ id }) => selectedRuleIds.includes(id)); @@ -448,18 +446,14 @@ export const useBulkActions = ({ onClick: handleExportAction, icon: undefined, }, - ...(isManualRuleRunEnabled - ? [ - { - key: i18n.BULK_ACTION_MANUAL_RULE_RUN, - name: i18n.BULK_ACTION_MANUAL_RULE_RUN, - 'data-test-subj': 'scheduleRuleRunBulk', - disabled: containsLoading || (!containsEnabled && !isAllSelected), - onClick: handleScheduleRuleRunAction, - icon: undefined, - }, - ] - : []), + { + key: i18n.BULK_ACTION_MANUAL_RULE_RUN, + name: i18n.BULK_ACTION_MANUAL_RULE_RUN, + 'data-test-subj': 'scheduleRuleRunBulk', + disabled: containsLoading || (!containsEnabled && !isAllSelected), + onClick: handleScheduleRuleRunAction, + icon: undefined, + }, { key: i18n.BULK_ACTION_DISABLE, name: i18n.BULK_ACTION_DISABLE, @@ -600,7 +594,6 @@ export const useBulkActions = ({ filterOptions, completeBulkEditForm, startServices, - isManualRuleRunEnabled, ] ); 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.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/upgrade_prebuilt_rules_table.tsx index 16ba012313f34..2437a5e87866d 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/upgrade_prebuilt_rules_table.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/upgrade_prebuilt_rules_table.tsx @@ -16,6 +16,7 @@ import { EuiSkeletonTitle, } from '@elastic/eui'; import React, { useMemo, useState } from 'react'; +import type { RuleUpgradeState } from '../../../../rule_management/model/prebuilt_rule_upgrade'; import * as i18n from '../../../../../detections/pages/detection_engine/rules/translations'; import { RULES_TABLE_INITIAL_PAGE_SIZE, RULES_TABLE_PAGE_SIZE_OPTIONS } from '../constants'; import { RulesChangelogLink } from '../rules_changelog_link'; @@ -23,7 +24,6 @@ import { UpgradePrebuiltRulesTableButtons } from './upgrade_prebuilt_rules_table import { useUpgradePrebuiltRulesTableContext } from './upgrade_prebuilt_rules_table_context'; import { UpgradePrebuiltRulesTableFilters } from './upgrade_prebuilt_rules_table_filters'; import { useUpgradePrebuiltRulesTableColumns } from './use_upgrade_prebuilt_rules_table_columns'; -import type { RuleUpgradeState } from './use_prebuilt_rules_upgrade_state'; const NO_ITEMS_MESSAGE = ( ; -export type SetRuleFieldResolvedValueFn< - FieldName extends keyof DiffableAllFields = keyof DiffableAllFields -> = (params: { - ruleId: RuleObjectId; - fieldName: FieldName; - resolvedValue: DiffableAllFields[FieldName]; -}) => void; - type RuleResolvedConflicts = Partial; type RulesResolvedConflicts = Record; @@ -70,6 +55,10 @@ export function usePrebuiltRulesUpgradeState( ruleUpgradeInfo, rulesResolvedConflicts[ruleUpgradeInfo.rule_id] ?? {} ), + fieldsUpgradeState: calcFieldsState( + ruleUpgradeInfo.diff.fields, + rulesResolvedConflicts[ruleUpgradeInfo.rule_id] ?? {} + ), hasUnresolvedConflicts: getUnacceptedConflictsCount( ruleUpgradeInfo.diff.fields, @@ -113,6 +102,35 @@ function convertRuleFieldsDiffToDiffable( return mergeVersionRule; } +function calcFieldsState( + ruleFieldsDiff: FieldsDiff>, + ruleResolvedConflicts: RuleResolvedConflicts +): FieldsUpgradeState { + const fieldsState: FieldsUpgradeState = {}; + + for (const fieldName of Object.keys(ruleFieldsDiff)) { + switch (ruleFieldsDiff[fieldName].conflict) { + case ThreeWayDiffConflict.NONE: + fieldsState[fieldName] = FieldUpgradeState.Accepted; + break; + + case ThreeWayDiffConflict.SOLVABLE: + fieldsState[fieldName] = FieldUpgradeState.SolvableConflict; + break; + + case ThreeWayDiffConflict.NON_SOLVABLE: + fieldsState[fieldName] = FieldUpgradeState.NonSolvableConflict; + break; + } + } + + for (const fieldName of Object.keys(ruleResolvedConflicts)) { + fieldsState[fieldName] = FieldUpgradeState.Accepted; + } + + return fieldsState; +} + function getUnacceptedConflictsCount( ruleFieldsDiff: FieldsDiff>, ruleResolvedConflicts: RuleResolvedConflicts 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 e7267007d2348..09009c98c2858 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 @@ -8,6 +8,7 @@ import type { EuiBasicTableColumn } from '@elastic/eui'; import { EuiBadge, EuiButtonEmpty, EuiLink, EuiLoadingSpinner, EuiText } 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'; import { SHOW_RELATED_INTEGRATIONS_SETTING } from '../../../../../../common/constants'; import type { RuleSignatureId } from '../../../../../../common/api/detection_engine/model/rule_schema'; @@ -22,7 +23,6 @@ import type { Rule } from '../../../../rule_management/logic'; import { getNormalizedSeverity } from '../helpers'; import type { UpgradePrebuiltRulesTableActions } from './upgrade_prebuilt_rules_table_context'; import { useUpgradePrebuiltRulesTableContext } from './upgrade_prebuilt_rules_table_context'; -import type { RuleUpgradeState } from './use_prebuilt_rules_upgrade_state'; export type TableColumn = EuiBasicTableColumn; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_rules_table_actions.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_rules_table_actions.tsx index 984df06342a1a..4cc7a03426657 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_rules_table_actions.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_rules_table_actions.tsx @@ -8,7 +8,6 @@ import type { DefaultItemAction } from '@elastic/eui'; import { EuiToolTip } from '@elastic/eui'; import React from 'react'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { DuplicateOptions } from '../../../../../common/detection_engine/rule_management/constants'; import { BulkActionTypeEnum } from '../../../../../common/api/detection_engine/rule_management'; import { SINGLE_RULE_ACTIONS } from '../../../../common/lib/apm/user_actions'; @@ -47,8 +46,6 @@ export const useRulesTableActions = ({ const downloadExportedRules = useDownloadExportedRules(); const { scheduleRuleRun } = useScheduleRuleRun(); - const isManualRuleRunEnabled = useIsExperimentalFeatureEnabled('manualRuleRunEnabled'); - return [ { type: 'icon', @@ -120,33 +117,28 @@ export const useRulesTableActions = ({ }, enabled: (rule: Rule) => !rule.immutable, }, - ...(isManualRuleRunEnabled - ? [ - { - type: 'icon', - 'data-test-subj': 'manualRuleRunAction', - description: (rule) => - !rule.enabled ? i18n.MANUAL_RULE_RUN_TOOLTIP : i18n.MANUAL_RULE_RUN, - icon: 'play', - name: i18n.MANUAL_RULE_RUN, - onClick: async (rule: Rule) => { - startTransaction({ name: SINGLE_RULE_ACTIONS.MANUAL_RULE_RUN }); - const modalManualRuleRunConfirmationResult = await showManualRuleRunConfirmation(); - telemetry.reportManualRuleRunOpenModal({ - type: 'single', - }); - if (modalManualRuleRunConfirmationResult === null) { - return; - } - await scheduleRuleRun({ - ruleIds: [rule.id], - timeRange: modalManualRuleRunConfirmationResult, - }); - }, - enabled: (rule: Rule) => rule.enabled, - } as DefaultItemAction, - ] - : []), + { + type: 'icon', + 'data-test-subj': 'manualRuleRunAction', + description: (rule) => (!rule.enabled ? i18n.MANUAL_RULE_RUN_TOOLTIP : i18n.MANUAL_RULE_RUN), + icon: 'play', + name: i18n.MANUAL_RULE_RUN, + onClick: async (rule: Rule) => { + startTransaction({ name: SINGLE_RULE_ACTIONS.MANUAL_RULE_RUN }); + const modalManualRuleRunConfirmationResult = await showManualRuleRunConfirmation(); + telemetry.reportManualRuleRunOpenModal({ + type: 'single', + }); + if (modalManualRuleRunConfirmationResult === null) { + return; + } + await scheduleRuleRun({ + ruleIds: [rule.id], + timeRange: modalManualRuleRunConfirmationResult, + }); + }, + enabled: (rule: Rule) => rule.enabled, + }, { type: 'icon', 'data-test-subj': 'deleteRuleAction', diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_monitoring/components/execution_results_table/use_execution_results.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_monitoring/components/execution_results_table/use_execution_results.tsx index 8660139676351..e6ee5769ee822 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_monitoring/components/execution_results_table/use_execution_results.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_monitoring/components/execution_results_table/use_execution_results.tsx @@ -7,29 +7,20 @@ import { useQuery } from '@tanstack/react-query'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; - -import { RuleRunTypeEnum } from '../../../../../common/api/detection_engine/rule_monitoring'; import type { GetRuleExecutionResultsResponse } from '../../../../../common/api/detection_engine/rule_monitoring'; import type { FetchRuleExecutionResultsArgs } from '../../api'; import { api } from '../../api'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import * as i18n from './translations'; export type UseExecutionResultsArgs = Omit; export const useExecutionResults = (args: UseExecutionResultsArgs) => { const { addError } = useAppToasts(); - const isManualRuleRunEnabled = useIsExperimentalFeatureEnabled('manualRuleRunEnabled'); return useQuery( ['detectionEngine', 'ruleMonitoring', 'executionResults', args], ({ signal }) => { - let runTypeFilters = args.runTypeFilters; - - // if manual rule run is disabled, only show standard runs - if (!isManualRuleRunEnabled) { - runTypeFilters = [RuleRunTypeEnum.standard]; - } + const runTypeFilters = args.runTypeFilters; return api.fetchRuleExecutionResults({ ...args, runTypeFilters, signal }); }, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx index 298ae1c503533..e1ff950bc5e32 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx @@ -274,25 +274,6 @@ describe('RuleActionsOverflow', () => { expect(getByTestId('rules-details-popover')).not.toHaveTextContent(/.+/); }); - test('it does not show "Manual run" action item when feature flag "manualRuleRunEnabled" is set to false', () => { - useIsExperimentalFeatureEnabledMock.mockReturnValue(false); - - const { getByTestId } = render( - Promise.resolve(true)} - />, - { wrapper: TestProviders } - ); - fireEvent.click(getByTestId('rules-details-popover-button-icon')); - - expect(getByTestId('rules-details-menu-panel')).not.toHaveTextContent('Manual run'); - }); - test('it calls telemetry.reportManualRuleRunOpenModal when rules-details-manual-rule-run is clicked', async () => { const { getByTestId } = render( { navigateToApp(APP_UI_ID, { deepLinkId: SecurityPageName.rules, @@ -152,39 +149,32 @@ const RuleActionsOverflowComponent = ({ > {i18nActions.EXPORT_RULE} , - ...(isManualRuleRunEnabled - ? [ - { - startTransaction({ name: SINGLE_RULE_ACTIONS.MANUAL_RULE_RUN }); - closePopover(); - const modalManualRuleRunConfirmationResult = - await showManualRuleRunConfirmation(); - telemetry.reportManualRuleRunOpenModal({ - type: 'single', - }); - if (modalManualRuleRunConfirmationResult === null) { - return; - } - await scheduleRuleRun({ - ruleIds: [rule.id], - timeRange: modalManualRuleRunConfirmationResult, - }); - }} - > - {i18nActions.MANUAL_RULE_RUN} - , - ] - : []), + { + startTransaction({ name: SINGLE_RULE_ACTIONS.MANUAL_RULE_RUN }); + closePopover(); + const modalManualRuleRunConfirmationResult = await showManualRuleRunConfirmation(); + telemetry.reportManualRuleRunOpenModal({ + type: 'single', + }); + if (modalManualRuleRunConfirmationResult === null) { + return; + } + await scheduleRuleRun({ + ruleIds: [rule.id], + timeRange: modalManualRuleRunConfirmationResult, + }); + }} + > + {i18nActions.MANUAL_RULE_RUN} + , ', () => { mockUseRiskScore.mockReturnValue(mockRiskScoreResponse); mockUseHostsRelatedUsers.mockReturnValue(mockRelatedUsersResponse); mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + (useMisconfigurationPreview as jest.Mock).mockReturnValue({}); + (useVulnerabilitiesPreview as jest.Mock).mockReturnValue({}); + (useSummaryChartData as jest.Mock).mockReturnValue({ isLoading: false, items: [] }); }); it('should render host details correctly', () => { @@ -296,4 +311,41 @@ describe('', () => { }); }); }); + + describe('distribution bar insights', () => { + it('should not render if no data is available', () => { + const { queryByTestId } = renderHostDetails(mockContextValue); + expect(queryByTestId(HOST_DETAILS_MISCONFIGURATIONS_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(HOST_DETAILS_VULNERABILITIES_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(HOST_DETAILS_ALERT_COUNT_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should render alert count when data is available', () => { + (useSummaryChartData as jest.Mock).mockReturnValue({ + isLoading: false, + items: [{ key: 'high', value: 78, label: 'High' }], + }); + + const { getByTestId } = renderHostDetails(mockContextValue); + expect(getByTestId(HOST_DETAILS_ALERT_COUNT_TEST_ID)).toBeInTheDocument(); + }); + + it('should render misconfiguration when data is available', () => { + (useMisconfigurationPreview as jest.Mock).mockReturnValue({ + data: { count: { passed: 1, failed: 2 } }, + }); + + const { getByTestId } = renderHostDetails(mockContextValue); + expect(getByTestId(HOST_DETAILS_MISCONFIGURATIONS_TEST_ID)).toBeInTheDocument(); + }); + + it('should render vulnerabilities when data is available', () => { + (useVulnerabilitiesPreview as jest.Mock).mockReturnValue({ + data: { count: { CRITICAL: 0, HIGH: 1, MEDIUM: 1, LOW: 0, UNKNOWN: 0 } }, + }); + + const { getByTestId } = renderHostDetails(mockContextValue); + expect(getByTestId(HOST_DETAILS_VULNERABILITIES_TEST_ID)).toBeInTheDocument(); + }); + }); }); 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 33b8bb22fce53..122caa657b039 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 @@ -18,6 +18,8 @@ import { EuiToolTip, EuiIcon, EuiPanel, + EuiHorizontalRule, + EuiFlexGrid, } from '@elastic/eui'; import type { EuiBasicTableColumn } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -51,6 +53,9 @@ import { HOST_DETAILS_RELATED_USERS_TABLE_TEST_ID, HOST_DETAILS_RELATED_USERS_LINK_TEST_ID, HOST_DETAILS_RELATED_USERS_IP_LINK_TEST_ID, + HOST_DETAILS_ALERT_COUNT_TEST_ID, + HOST_DETAILS_MISCONFIGURATIONS_TEST_ID, + HOST_DETAILS_VULNERABILITIES_TEST_ID, } from './test_ids'; import { USER_NAME_FIELD_NAME, @@ -63,6 +68,9 @@ import { PreviewLink } from '../../../shared/components/preview_link'; import { HostPreviewPanelKey } from '../../../entity_details/host_right'; import { HOST_PREVIEW_BANNER } from '../../right/components/host_entity_overview'; import type { NarrowDateRange } from '../../../../common/components/ml/types'; +import { MisconfigurationsInsight } from '../../shared/components/misconfiguration_insight'; +import { VulnerabilitiesInsight } from '../../shared/components/vulnerabilities_insight'; +import { AlertCountInsight } from '../../shared/components/alert_count_insight'; const HOST_DETAILS_ID = 'entities-hosts-details'; const RELATED_USERS_ID = 'entities-hosts-related-users'; @@ -337,6 +345,28 @@ export const HostDetails: React.FC = ({ hostName, timestamp, s )} + + + + + + + + diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/test_ids.ts index 0779f3c135b2d..8669b504f6861 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/test_ids.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/test_ids.ts @@ -43,6 +43,9 @@ export const PREVALENCE_DETAILS_TABLE_UPSELL_CELL_TEST_ID = export const ENTITIES_DETAILS_TEST_ID = `${PREFIX}EntitiesDetails` as const; export const USER_DETAILS_TEST_ID = `${PREFIX}UsersDetails` as const; export const USER_DETAILS_LINK_TEST_ID = `${USER_DETAILS_TEST_ID}TitleLink` as const; +export const USER_DETAILS_ALERT_COUNT_TEST_ID = `${USER_DETAILS_TEST_ID}AlertCount` as const; +export const USER_DETAILS_MISCONFIGURATIONS_TEST_ID = + `${USER_DETAILS_TEST_ID}Misconfigurations` as const; export const USER_DETAILS_RELATED_HOSTS_TABLE_TEST_ID = `${USER_DETAILS_TEST_ID}RelatedHostsTable` as const; export const USER_DETAILS_RELATED_HOSTS_LINK_TEST_ID = @@ -53,6 +56,11 @@ export const USER_DETAILS_INFO_TEST_ID = 'user-overview' as const; export const HOST_DETAILS_TEST_ID = `${PREFIX}HostsDetails` as const; export const HOST_DETAILS_LINK_TEST_ID = `${HOST_DETAILS_TEST_ID}TitleLink` as const; +export const HOST_DETAILS_ALERT_COUNT_TEST_ID = `${HOST_DETAILS_TEST_ID}AlertCount` as const; +export const HOST_DETAILS_MISCONFIGURATIONS_TEST_ID = + `${HOST_DETAILS_TEST_ID}Misconfigurations` as const; +export const HOST_DETAILS_VULNERABILITIES_TEST_ID = + `${HOST_DETAILS_TEST_ID}Vulnerabilities` as const; export const HOST_DETAILS_RELATED_USERS_TABLE_TEST_ID = `${HOST_DETAILS_TEST_ID}RelatedUsersTable` as const; export const HOST_DETAILS_RELATED_USERS_LINK_TEST_ID = 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 c1ed881e80a95..a2c53afb8c3f3 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 @@ -7,6 +7,7 @@ import React from 'react'; import { render } from '@testing-library/react'; +import { useMisconfigurationPreview } from '@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'; import type { Anomalies } from '../../../../common/components/ml/types'; import { TestProviders } from '../../../../common/mock'; import { DocumentDetailsContext } from '../../shared/context'; @@ -24,6 +25,8 @@ import { USER_DETAILS_RELATED_HOSTS_TABLE_TEST_ID, USER_DETAILS_RELATED_HOSTS_LINK_TEST_ID, USER_DETAILS_RELATED_HOSTS_IP_LINK_TEST_ID, + 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 { useRiskScore } from '../../../../entity_analytics/api/hooks/use_risk_score'; @@ -35,8 +38,10 @@ import { HOST_PREVIEW_BANNER } from '../../right/components/host_entity_overview import { UserPreviewPanelKey } from '../../../entity_details/user_right'; import { USER_PREVIEW_BANNER } from '../../right/components/user_entity_overview'; import { NetworkPanelKey, NETWORK_PREVIEW_BANNER } from '../../../network_details'; +import { useSummaryChartData } from '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data'; jest.mock('@kbn/expandable-flyout'); +jest.mock('@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'); jest.mock('../../../../common/hooks/use_experimental_features'); const mockUseIsExperimentalFeatureEnabled = useIsExperimentalFeatureEnabled as jest.Mock; @@ -101,6 +106,10 @@ const mockUseUsersRelatedHosts = useUserRelatedHosts as jest.Mock; jest.mock('../../../../entity_analytics/api/hooks/use_risk_score'); const mockUseRiskScore = useRiskScore as jest.Mock; +jest.mock( + '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data' +); + const timestamp = '2022-07-25T08:20:18.966Z'; const defaultProps = { @@ -155,6 +164,8 @@ describe('', () => { mockUseRiskScore.mockReturnValue(mockRiskScoreResponse); mockUseUsersRelatedHosts.mockReturnValue(mockRelatedHostsResponse); mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + (useMisconfigurationPreview as jest.Mock).mockReturnValue({}); + (useSummaryChartData as jest.Mock).mockReturnValue({ isLoading: false, items: [] }); }); it('should render user details correctly', () => { @@ -278,4 +289,31 @@ describe('', () => { }); }); }); + + describe('distribution bar insights', () => { + it('should not render if no data is available', () => { + const { queryByTestId } = renderUserDetails(mockContextValue); + expect(queryByTestId(USER_DETAILS_MISCONFIGURATIONS_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(USER_DETAILS_ALERT_COUNT_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should render alert count when data is available', () => { + (useSummaryChartData as jest.Mock).mockReturnValue({ + isLoading: false, + items: [{ key: 'high', value: 78, label: 'High' }], + }); + + const { getByTestId } = renderUserDetails(mockContextValue); + expect(getByTestId(USER_DETAILS_ALERT_COUNT_TEST_ID)).toBeInTheDocument(); + }); + + it('should render misconfiguration when data is available', () => { + (useMisconfigurationPreview as jest.Mock).mockReturnValue({ + data: { count: { passed: 1, failed: 2 } }, + }); + + const { getByTestId } = renderUserDetails(mockContextValue); + expect(getByTestId(USER_DETAILS_MISCONFIGURATIONS_TEST_ID)).toBeInTheDocument(); + }); + }); }); 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 13d3e825053ba..c90d11f4b8bc2 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 @@ -18,6 +18,8 @@ import { EuiFlexItem, EuiToolTip, EuiPanel, + EuiHorizontalRule, + EuiFlexGrid, } from '@elastic/eui'; import type { EuiBasicTableColumn } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -51,6 +53,8 @@ import { USER_DETAILS_TEST_ID, USER_DETAILS_RELATED_HOSTS_LINK_TEST_ID, USER_DETAILS_RELATED_HOSTS_IP_LINK_TEST_ID, + USER_DETAILS_MISCONFIGURATIONS_TEST_ID, + USER_DETAILS_ALERT_COUNT_TEST_ID, } from './test_ids'; import { HOST_NAME_FIELD_NAME, @@ -63,6 +67,8 @@ import { UserPreviewPanelKey } from '../../../entity_details/user_right'; import { USER_PREVIEW_BANNER } from '../../right/components/user_entity_overview'; import { PreviewLink } from '../../../shared/components/preview_link'; import type { NarrowDateRange } from '../../../../common/components/ml/types'; +import { MisconfigurationsInsight } from '../../shared/components/misconfiguration_insight'; +import { AlertCountInsight } from '../../shared/components/alert_count_insight'; const USER_DETAILS_ID = 'entities-users-details'; const RELATED_HOSTS_ID = 'entities-users-related-hosts'; @@ -340,6 +346,22 @@ export const UserDetails: React.FC = ({ userName, timestamp, s )} + + + + + + diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.test.tsx index b710df84e1a13..6ad90adb28997 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.test.tsx @@ -6,6 +6,8 @@ */ import React from 'react'; import { render } from '@testing-library/react'; +import { useMisconfigurationPreview } from '@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'; +import { useVulnerabilitiesPreview } from '@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'; import { TestProviders } from '../../../../common/mock'; import { HostEntityOverview, HOST_PREVIEW_BANNER } from './host_entity_overview'; import { useHostDetails } from '../../../../explore/hosts/containers/hosts/details'; @@ -16,6 +18,9 @@ import { ENTITIES_HOST_OVERVIEW_LINK_TEST_ID, ENTITIES_HOST_OVERVIEW_RISK_LEVEL_TEST_ID, ENTITIES_HOST_OVERVIEW_LOADING_TEST_ID, + ENTITIES_HOST_OVERVIEW_MISCONFIGURATIONS_TEST_ID, + ENTITIES_HOST_OVERVIEW_VULNERABILITIES_TEST_ID, + ENTITIES_HOST_OVERVIEW_ALERT_COUNT_TEST_ID, } from './test_ids'; import { DocumentDetailsContext } from '../../shared/context'; import { mockContextValue } from '../../shared/mocks/mock_context'; @@ -29,6 +34,7 @@ import { ENTITIES_TAB_ID } from '../../left/components/entities_details'; import { useRiskScore } from '../../../../entity_analytics/api/hooks/use_risk_score'; import { mockFlyoutApi } from '../../shared/mocks/mock_flyout_context'; import { createTelemetryServiceMock } from '../../../../common/lib/telemetry/telemetry_service.mock'; +import { useSummaryChartData } from '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data'; const hostName = 'host'; const osFamily = 'Windows'; @@ -46,6 +52,17 @@ const panelContextValue = { }; jest.mock('@kbn/expandable-flyout'); +jest.mock('@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'); +jest.mock('@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'); + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); + +jest.mock( + '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data' +); const mockedTelemetry = createTelemetryServiceMock(); jest.mock('../../../../common/lib/kibana', () => { @@ -99,6 +116,9 @@ describe('', () => { beforeAll(() => { jest.mocked(useExpandableFlyoutApi).mockReturnValue(mockFlyoutApi); mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + (useMisconfigurationPreview as jest.Mock).mockReturnValue({}); + (useVulnerabilitiesPreview as jest.Mock).mockReturnValue({}); + (useSummaryChartData as jest.Mock).mockReturnValue({ isLoading: false, items: [] }); }); describe('license is valid', () => { @@ -150,6 +170,7 @@ describe('', () => { ); expect(getByTestId(ENTITIES_HOST_OVERVIEW_LOADING_TEST_ID)).toBeInTheDocument(); }); + describe('license is not valid', () => { it('should render os family and last seen', () => { mockUseHostDetails.mockReturnValue([false, { hostDetails: hostData }]); @@ -210,4 +231,48 @@ describe('', () => { }); }); }); + + describe('distribution bar insights', () => { + beforeEach(() => { + mockUseHostDetails.mockReturnValue([false, { hostDetails: hostData }]); + mockUseRiskScore.mockReturnValue({ data: riskLevel, isAuthorized: true }); + }); + + it('should not render if no data is available', () => { + const { queryByTestId } = renderHostEntityContent(); + expect( + queryByTestId(ENTITIES_HOST_OVERVIEW_MISCONFIGURATIONS_TEST_ID) + ).not.toBeInTheDocument(); + expect(queryByTestId(ENTITIES_HOST_OVERVIEW_VULNERABILITIES_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(ENTITIES_HOST_OVERVIEW_ALERT_COUNT_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should render alert count when data is available', () => { + (useSummaryChartData as jest.Mock).mockReturnValue({ + isLoading: false, + items: [{ key: 'high', value: 78, label: 'High' }], + }); + + const { getByTestId } = renderHostEntityContent(); + expect(getByTestId(ENTITIES_HOST_OVERVIEW_ALERT_COUNT_TEST_ID)).toBeInTheDocument(); + }); + + it('should render misconfiguration when data is available', () => { + (useMisconfigurationPreview as jest.Mock).mockReturnValue({ + data: { count: { passed: 1, failed: 2 } }, + }); + + const { getByTestId } = renderHostEntityContent(); + expect(getByTestId(ENTITIES_HOST_OVERVIEW_MISCONFIGURATIONS_TEST_ID)).toBeInTheDocument(); + }); + + it('should render vulnerabilities when data is available', () => { + (useVulnerabilitiesPreview as jest.Mock).mockReturnValue({ + data: { count: { CRITICAL: 0, HIGH: 1, MEDIUM: 1, LOW: 0, UNKNOWN: 0 } }, + }); + + const { getByTestId } = renderHostEntityContent(); + expect(getByTestId(ENTITIES_HOST_OVERVIEW_VULNERABILITIES_TEST_ID)).toBeInTheDocument(); + }); + }); }); 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 ca6a68eb23be8..90405286b004c 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 @@ -52,11 +52,17 @@ import { ENTITIES_HOST_OVERVIEW_RISK_LEVEL_TEST_ID, ENTITIES_HOST_OVERVIEW_LINK_TEST_ID, ENTITIES_HOST_OVERVIEW_LOADING_TEST_ID, + ENTITIES_HOST_OVERVIEW_ALERT_COUNT_TEST_ID, + ENTITIES_HOST_OVERVIEW_MISCONFIGURATIONS_TEST_ID, + ENTITIES_HOST_OVERVIEW_VULNERABILITIES_TEST_ID, } from './test_ids'; import { DocumentDetailsLeftPanelKey } from '../../shared/constants/panel_keys'; import { LeftPanelInsightsTab } from '../../left'; import { RiskScoreDocTooltip } from '../../../../overview/components/common'; import { PreviewLink } from '../../../shared/components/preview_link'; +import { MisconfigurationsInsight } from '../../shared/components/misconfiguration_insight'; +import { VulnerabilitiesInsight } from '../../shared/components/vulnerabilities_insight'; +import { AlertCountInsight } from '../../shared/components/alert_count_insight'; const HOST_ICON = 'storage'; @@ -196,12 +202,12 @@ export const HostEntityOverview: React.FC = ({ hostName return ( - + @@ -270,6 +276,20 @@ export const HostEntityOverview: React.FC = ({ hostName )} + + + ); }; 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 40670ddc7110a..e0d8bc6db0f5c 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 @@ -121,6 +121,10 @@ export const ENTITIES_USER_OVERVIEW_LAST_SEEN_TEST_ID = `${ENTITIES_USER_OVERVIEW_TEST_ID}LastSeen` as const; export const ENTITIES_USER_OVERVIEW_RISK_LEVEL_TEST_ID = `${ENTITIES_USER_OVERVIEW_TEST_ID}RiskLevel` as const; +export const ENTITIES_USER_OVERVIEW_ALERT_COUNT_TEST_ID = + `${ENTITIES_USER_OVERVIEW_TEST_ID}AlertCount` as const; +export const ENTITIES_USER_OVERVIEW_MISCONFIGURATIONS_TEST_ID = + `${ENTITIES_USER_OVERVIEW_TEST_ID}Misconfigurations` as const; export const ENTITIES_HOST_OVERVIEW_TEST_ID = `${INSIGHTS_ENTITIES_TEST_ID}HostOverview` as const; export const ENTITIES_HOST_OVERVIEW_LOADING_TEST_ID = @@ -132,6 +136,12 @@ export const ENTITIES_HOST_OVERVIEW_LAST_SEEN_TEST_ID = `${ENTITIES_HOST_OVERVIEW_TEST_ID}LastSeen` as const; export const ENTITIES_HOST_OVERVIEW_RISK_LEVEL_TEST_ID = `${ENTITIES_HOST_OVERVIEW_TEST_ID}RiskLevel` as const; +export const ENTITIES_HOST_OVERVIEW_ALERT_COUNT_TEST_ID = + `${ENTITIES_HOST_OVERVIEW_TEST_ID}AlertCount` as const; +export const ENTITIES_HOST_OVERVIEW_MISCONFIGURATIONS_TEST_ID = + `${ENTITIES_HOST_OVERVIEW_TEST_ID}Misconfigurations` as const; +export const ENTITIES_HOST_OVERVIEW_VULNERABILITIES_TEST_ID = + `${ENTITIES_HOST_OVERVIEW_TEST_ID}Vulnerabilities` as const; /* Threat intelligence */ diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.test.tsx index 000da8946ff61..95c399ca4362e 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { render } from '@testing-library/react'; import { TestProviders } from '../../../../common/mock'; +import { useMisconfigurationPreview } from '@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'; import { UserEntityOverview, USER_PREVIEW_BANNER } from './user_entity_overview'; import { useFirstLastSeen } from '../../../../common/containers/use_first_last_seen'; import { @@ -15,6 +16,8 @@ import { ENTITIES_USER_OVERVIEW_LINK_TEST_ID, ENTITIES_USER_OVERVIEW_RISK_LEVEL_TEST_ID, ENTITIES_USER_OVERVIEW_LOADING_TEST_ID, + ENTITIES_USER_OVERVIEW_MISCONFIGURATIONS_TEST_ID, + ENTITIES_USER_OVERVIEW_ALERT_COUNT_TEST_ID, } from './test_ids'; import { useObservedUserDetails } from '../../../../explore/users/containers/users/observed_details'; import { mockContextValue } from '../../shared/mocks/mock_context'; @@ -28,6 +31,7 @@ import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { mockFlyoutApi } from '../../shared/mocks/mock_flyout_context'; import { UserPreviewPanelKey } from '../../../entity_details/user_right'; +import { useSummaryChartData } from '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data'; const userName = 'user'; const domain = 'n54bg2lfc7'; @@ -45,6 +49,18 @@ const panelContextValue = { }; jest.mock('@kbn/expandable-flyout'); +jest.mock('@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'); + +jest.mock('../../../../common/lib/kibana'); + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); + +jest.mock( + '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data' +); jest.mock('../../../../common/hooks/use_experimental_features'); const mockUseIsExperimentalFeatureEnabled = useIsExperimentalFeatureEnabled as jest.Mock; @@ -85,6 +101,8 @@ describe('', () => { beforeAll(() => { jest.mocked(useExpandableFlyoutApi).mockReturnValue(mockFlyoutApi); mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + (useMisconfigurationPreview as jest.Mock).mockReturnValue({}); + (useSummaryChartData as jest.Mock).mockReturnValue({ isLoading: false, items: [] }); }); describe('license is valid', () => { @@ -211,4 +229,38 @@ describe('', () => { }); }); }); + + describe('distribution bar insights', () => { + beforeEach(() => { + mockUseUserDetails.mockReturnValue([false, { userDetails: userData }]); + mockUseRiskScore.mockReturnValue({ data: riskLevel, isAuthorized: true }); + }); + + it('should not render if no data is available', () => { + const { queryByTestId } = renderUserEntityOverview(); + expect( + queryByTestId(ENTITIES_USER_OVERVIEW_MISCONFIGURATIONS_TEST_ID) + ).not.toBeInTheDocument(); + expect(queryByTestId(ENTITIES_USER_OVERVIEW_ALERT_COUNT_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should render alert count when data is available', () => { + (useSummaryChartData as jest.Mock).mockReturnValue({ + isLoading: false, + items: [{ key: 'high', value: 78, label: 'High' }], + }); + + const { getByTestId } = renderUserEntityOverview(); + expect(getByTestId(ENTITIES_USER_OVERVIEW_ALERT_COUNT_TEST_ID)).toBeInTheDocument(); + }); + + it('should render misconfiguration when data is available', () => { + (useMisconfigurationPreview as jest.Mock).mockReturnValue({ + data: { count: { passed: 1, failed: 2 } }, + }); + + const { getByTestId } = renderUserEntityOverview(); + expect(getByTestId(ENTITIES_USER_OVERVIEW_MISCONFIGURATIONS_TEST_ID)).toBeInTheDocument(); + }); + }); }); 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 624b9e816c9e5..0019228d656cd 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 @@ -53,10 +53,14 @@ import { ENTITIES_USER_OVERVIEW_RISK_LEVEL_TEST_ID, ENTITIES_USER_OVERVIEW_LINK_TEST_ID, ENTITIES_USER_OVERVIEW_LOADING_TEST_ID, + ENTITIES_USER_OVERVIEW_MISCONFIGURATIONS_TEST_ID, + ENTITIES_USER_OVERVIEW_ALERT_COUNT_TEST_ID, } from './test_ids'; import { useObservedUserDetails } from '../../../../explore/users/containers/users/observed_details'; import { RiskScoreDocTooltip } from '../../../../overview/components/common'; import { PreviewLink } from '../../../shared/components/preview_link'; +import { MisconfigurationsInsight } from '../../shared/components/misconfiguration_insight'; +import { AlertCountInsight } from '../../shared/components/alert_count_insight'; const USER_ICON = 'user'; @@ -196,12 +200,12 @@ export const UserEntityOverview: React.FC = ({ userName return ( - + @@ -270,6 +274,16 @@ export const UserEntityOverview: React.FC = ({ userName )} + + ); }; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/alert_count_insight.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/alert_count_insight.test.tsx new file mode 100644 index 0000000000000..f0d16a418f2b2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/alert_count_insight.test.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import { AlertCountInsight } from './alert_count_insight'; +import { useSummaryChartData } from '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data'; + +jest.mock('../../../../common/lib/kibana'); + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); +jest.mock('@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'); +jest.mock( + '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data' +); + +const fieldName = 'host.name'; +const name = 'test host'; +const testId = 'test'; + +const renderAlertCountInsight = () => { + return render( + + + + ); +}; + +describe('AlertCountInsight', () => { + it('renders', () => { + (useSummaryChartData as jest.Mock).mockReturnValue({ + isLoading: false, + items: [ + { key: 'high', value: 78, label: 'High' }, + { key: 'low', value: 46, label: 'Low' }, + { key: 'medium', value: 32, label: 'Medium' }, + { key: 'critical', value: 21, label: 'Critical' }, + ], + }); + const { getByTestId } = renderAlertCountInsight(); + expect(getByTestId(testId)).toBeInTheDocument(); + expect(getByTestId(`${testId}-distribution-bar`)).toBeInTheDocument(); + }); + + it('renders loading spinner if data is being fetched', () => { + (useSummaryChartData as jest.Mock).mockReturnValue({ isLoading: true, items: [] }); + const { getByTestId } = renderAlertCountInsight(); + expect(getByTestId(`${testId}-loading-spinner`)).toBeInTheDocument(); + }); + + it('renders null if no misconfiguration data found', () => { + (useSummaryChartData as jest.Mock).mockReturnValue({ isLoading: false, items: [] }); + const { container } = renderAlertCountInsight(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/alert_count_insight.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/alert_count_insight.tsx new file mode 100644 index 0000000000000..566b77b5739a9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/alert_count_insight.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { v4 as uuid } from 'uuid'; +import { EuiLoadingSpinner, EuiFlexItem, type EuiFlexGroupProps } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { InsightDistributionBar } from './insight_distribution_bar'; +import { severityAggregations } from '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/aggregations'; +import { useSummaryChartData } from '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data'; +import { + getIsAlertsBySeverityData, + getSeverityColor, +} from '../../../../detections/components/alerts_kpis/severity_level_panel/helpers'; + +const ENTITY_ALERT_COUNT_ID = 'entity-alert-count'; + +interface AlertCountInsightProps { + /** + * The name of the entity to filter the alerts by. + */ + name: string; + /** + * The field name to filter the alerts by. + */ + fieldName: 'host.name' | 'user.name'; + /** + * The direction of the flex group. + */ + direction?: EuiFlexGroupProps['direction']; + /** + * The data-test-subj to use for the component. + */ + ['data-test-subj']?: string; +} + +/* + * Displays a distribution bar with the count of critical alerts for a given entity + */ +export const AlertCountInsight: React.FC = ({ + name, + fieldName, + direction, + 'data-test-subj': dataTestSubj, +}) => { + const uniqueQueryId = useMemo(() => `${ENTITY_ALERT_COUNT_ID}-${uuid()}`, []); + const entityFilter = useMemo(() => ({ field: fieldName, value: name }), [fieldName, name]); + + const { items, isLoading } = useSummaryChartData({ + aggregations: severityAggregations, + entityFilter, + uniqueQueryId, + signalIndexName: null, + }); + + const data = useMemo(() => (getIsAlertsBySeverityData(items) ? items : []), [items]); + + const alertStats = useMemo(() => { + return data.map((item) => ({ + key: item.key, + count: item.value, + color: getSeverityColor(item.key), + })); + }, [data]); + + const count = useMemo( + () => data.filter((item) => item.key === 'critical')[0]?.value ?? 0, + [data] + ); + + if (!isLoading && items.length === 0) return null; + + return ( + + {isLoading ? ( + + ) : ( + + } + stats={alertStats} + count={count} + direction={direction} + data-test-subj={`${dataTestSubj}-distribution-bar`} + /> + )} + + ); +}; + +AlertCountInsight.displayName = 'AlertCountInsight'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/insight_distribution_bar.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/insight_distribution_bar.test.tsx new file mode 100644 index 0000000000000..a775da8a7f73a --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/insight_distribution_bar.test.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { InsightDistributionBar } from './insight_distribution_bar'; +import { TestProviders } from '../../../../common/mock'; + +const title = 'test title'; +const count = 10; +const testId = 'test-id'; +const stats = [ + { + key: 'passed', + count: 90, + color: 'green', + }, + { + key: 'failed', + count: 10, + color: 'red', + }, +]; + +describe('', () => { + it('should render', () => { + const { getByTestId, getByText } = render( + + + + ); + expect(getByTestId(testId)).toBeInTheDocument(); + expect(getByText(title)).toBeInTheDocument(); + expect(getByTestId(`${testId}-badge`)).toHaveTextContent(`${count}`); + expect(getByTestId(`${testId}-distribution-bar`)).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/insight_distribution_bar.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/insight_distribution_bar.tsx new file mode 100644 index 0000000000000..006ec8c5dad4f --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/insight_distribution_bar.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { css } from '@emotion/css'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiBadge, + useEuiTheme, + useEuiFontSize, + type EuiFlexGroupProps, +} from '@elastic/eui'; +import { DistributionBar } from '@kbn/security-solution-distribution-bar'; +import { FormattedCount } from '../../../../common/components/formatted_number'; + +export interface InsightDistributionBarProps { + /** + * Title of the insight + */ + title: string | React.ReactNode; + /** + * Distribution stats to display + */ + stats: Array<{ key: string; count: number; color: string; label?: React.ReactNode }>; + /** + * Count to be displayed in the badge + */ + count: number; + /** + * Flex direction of the component + */ + direction?: EuiFlexGroupProps['direction']; + /** + * Optional test id + */ + ['data-test-subj']?: string; +} + +// Displays a distribution bar with a count badge +export const InsightDistributionBar: React.FC = ({ + title, + stats, + count, + direction = 'row', + 'data-test-subj': dataTestSubj, +}) => { + const { euiTheme } = useEuiTheme(); + const xsFontSize = useEuiFontSize('xs').fontSize; + + return ( + + + + {title} + + + + + + + + + + + + + + + + ); +}; + +InsightDistributionBar.displayName = 'InsightDistributionBar'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/misconfiguration_insight.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/misconfiguration_insight.test.tsx new file mode 100644 index 0000000000000..296a61f444a17 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/misconfiguration_insight.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import { MisconfigurationsInsight } from './misconfiguration_insight'; +import { useMisconfigurationPreview } from '@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'; + +jest.mock('@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'); + +const fieldName = 'host.name'; +const name = 'test host'; +const testId = 'test'; + +const renderMisconfigurationsInsight = () => { + return render( + + + + ); +}; + +describe('MisconfigurationsInsight', () => { + it('renders', () => { + (useMisconfigurationPreview as jest.Mock).mockReturnValue({ + data: { count: { passed: 1, failed: 2 } }, + }); + const { getByTestId } = renderMisconfigurationsInsight(); + expect(getByTestId(testId)).toBeInTheDocument(); + expect(getByTestId(`${testId}-distribution-bar`)).toBeInTheDocument(); + }); + + it('renders null if no misconfiguration data found', () => { + (useMisconfigurationPreview as jest.Mock).mockReturnValue({}); + const { container } = renderMisconfigurationsInsight(); + expect(container).toBeEmptyDOMElement(); + }); +}); 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 new file mode 100644 index 0000000000000..552a242c84893 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/misconfiguration_insight.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { EuiFlexItem, type EuiFlexGroupProps } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useMisconfigurationPreview } from '@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'; +import { buildEntityFlyoutPreviewQuery } from '@kbn/cloud-security-posture-common'; +import { InsightDistributionBar } from './insight_distribution_bar'; +import { getFindingsStats } from '../../../../cloud_security_posture/components/misconfiguration/misconfiguration_preview'; + +interface MisconfigurationsInsightProps { + /** + * Entity name to retrieve misconfigurations for + */ + name: string; + /** + * Indicator whether the entity is host or user + */ + fieldName: 'host.name' | 'user.name'; + /** + * The direction of the flex group + */ + direction?: EuiFlexGroupProps['direction']; + /** + * The data-test-subj to use for the component + */ + ['data-test-subj']?: string; +} + +/* + * Displays a distribution bar with the count of failed misconfigurations for a given entity + */ +export const MisconfigurationsInsight: React.FC = ({ + name, + fieldName, + direction, + 'data-test-subj': dataTestSubj, +}) => { + const { data } = useMisconfigurationPreview({ + query: buildEntityFlyoutPreviewQuery(fieldName, name), + sort: [], + enabled: true, + pageSize: 1, + }); + + const passedFindings = data?.count.passed || 0; + const failedFindings = data?.count.failed || 0; + const hasMisconfigurationFindings = passedFindings > 0 || failedFindings > 0; + + const misconfigurationsStats = useMemo( + () => getFindingsStats(passedFindings, failedFindings), + [passedFindings, failedFindings] + ); + + if (!hasMisconfigurationFindings) return null; + + return ( + + + } + stats={misconfigurationsStats} + count={failedFindings} + direction={direction} + data-test-subj={`${dataTestSubj}-distribution-bar`} + /> + + ); +}; + +MisconfigurationsInsight.displayName = 'MisconfigurationsInsight'; 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 8561df63d7199..7c2ce2ff5870b 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 @@ -12,3 +12,6 @@ export const FLYOUT_PREVIEW_LINK_TEST_ID = `${PREFIX}PreviewLink` as const; export const SESSION_VIEW_UPSELL_TEST_ID = `${PREFIX}SessionViewUpsell` as const; export const SESSION_VIEW_NO_DATA_TEST_ID = `${PREFIX}SessionViewNoData` as const; + +export const MISCONFIGURATIONS_TEST_ID = `${PREFIX}Misconfigurations` as const; +export const VULNERABILITIES_TEST_ID = `${PREFIX}Vulnerabilities` as const; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/vulnerabilities_insight.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/vulnerabilities_insight.test.tsx new file mode 100644 index 0000000000000..77c6737266b89 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/vulnerabilities_insight.test.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TestProviders } from '../../../../common/mock'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { VulnerabilitiesInsight } from './vulnerabilities_insight'; +import { useVulnerabilitiesPreview } from '@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'; + +jest.mock('@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'); + +const hostName = 'test host'; +const testId = 'test'; + +const renderVulnerabilitiesInsight = () => { + return render( + + + + ); +}; + +describe('VulnerabilitiesInsight', () => { + it('renders', () => { + (useVulnerabilitiesPreview as jest.Mock).mockReturnValue({ + data: { count: { CRITICAL: 0, HIGH: 1, MEDIUM: 1, LOW: 0, UNKNOWN: 0 } }, + }); + + const { getByTestId } = renderVulnerabilitiesInsight(); + expect(getByTestId(testId)).toBeInTheDocument(); + expect(getByTestId(`${testId}-distribution-bar`)).toBeInTheDocument(); + }); + + it('renders null when data is not available', () => { + (useVulnerabilitiesPreview as jest.Mock).mockReturnValue({}); + + const { container } = renderVulnerabilitiesInsight(); + expect(container).toBeEmptyDOMElement(); + }); +}); 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 new file mode 100644 index 0000000000000..4c581b6db57d0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/vulnerabilities_insight.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 React, { useMemo } from 'react'; +import { EuiFlexItem, type EuiFlexGroupProps } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +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 { InsightDistributionBar } from './insight_distribution_bar'; + +interface VulnerabilitiesInsightProps { + /** + * Host name to retrieve vulnerabilities for + */ + hostName: string; + /** + * The direction of the flex group + */ + direction?: EuiFlexGroupProps['direction']; + /** + * The data-test-subj to use for the component + */ + ['data-test-subj']?: string; +} + +/* + * Displays a distribution bar with the count of critical vulnerabilities for a given host + */ +export const VulnerabilitiesInsight: React.FC = ({ + hostName, + direction, + 'data-test-subj': dataTestSubj, +}) => { + const { data } = useVulnerabilitiesPreview({ + query: buildEntityFlyoutPreviewQuery('host.name', hostName), + sort: [], + enabled: true, + pageSize: 1, + }); + + const { CRITICAL = 0, HIGH = 0, MEDIUM = 0, LOW = 0, NONE = 0 } = data?.count || {}; + const hasVulnerabilitiesFindings = useMemo( + () => + hasVulnerabilitiesData({ + critical: CRITICAL, + high: HIGH, + medium: MEDIUM, + low: LOW, + none: NONE, + }), + [CRITICAL, HIGH, MEDIUM, LOW, NONE] + ); + + const vulnerabilitiesStats = useMemo( + () => + getVulnerabilityStats({ + critical: CRITICAL, + high: HIGH, + medium: MEDIUM, + low: LOW, + none: NONE, + }), + [CRITICAL, HIGH, MEDIUM, LOW, NONE] + ); + + if (!hasVulnerabilitiesFindings) return null; + + return ( + + + } + stats={vulnerabilitiesStats} + count={CRITICAL} + direction={direction} + data-test-subj={`${dataTestSubj}-distribution-bar`} + /> + + ); +}; + +VulnerabilitiesInsight.displayName = 'VulnerabilitiesInsight'; diff --git a/x-pack/plugins/security_solution/public/flyout/rule_details/preview/footer.test.tsx b/x-pack/plugins/security_solution/public/flyout/rule_details/preview/footer.test.tsx index f1e276011ca26..0f2a7dc74662f 100644 --- a/x-pack/plugins/security_solution/public/flyout/rule_details/preview/footer.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/rule_details/preview/footer.test.tsx @@ -9,20 +9,21 @@ import { render } from '@testing-library/react'; import React from 'react'; import { RULE_PREVIEW_FOOTER_TEST_ID, RULE_PREVIEW_OPEN_RULE_FLYOUT_TEST_ID } from './test_ids'; import { PreviewFooter } from './footer'; -import { mockFlyoutApi } from '../../document_details/shared/mocks/mock_flyout_context'; -import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; -import { RulePanelKey } from '../right'; +import { useRuleDetailsLink } from '../../document_details/shared/hooks/use_rule_details_link'; +import { TestProviders } from '../../../common/mock'; -jest.mock('@kbn/expandable-flyout'); +jest.mock('../../document_details/shared/hooks/use_rule_details_link'); -const renderRulePreviewFooter = () => render(); +const renderRulePreviewFooter = () => + render( + + + + ); describe('', () => { - beforeAll(() => { - jest.mocked(useExpandableFlyoutApi).mockReturnValue(mockFlyoutApi); - }); - it('should render rule details link correctly when ruleId is available', () => { + (useRuleDetailsLink as jest.Mock).mockReturnValue('rule_details_link'); const { getByTestId } = renderRulePreviewFooter(); expect(getByTestId(RULE_PREVIEW_FOOTER_TEST_ID)).toBeInTheDocument(); @@ -32,13 +33,9 @@ describe('', () => { ); }); - it('should open rule flyout when clicked', () => { - const { getByTestId } = renderRulePreviewFooter(); - - getByTestId(RULE_PREVIEW_OPEN_RULE_FLYOUT_TEST_ID).click(); - - expect(mockFlyoutApi.openFlyout).toHaveBeenCalledWith({ - right: { id: RulePanelKey, params: { ruleId: 'ruleid' } }, - }); + it('should not render the footer if rule link is not available', () => { + (useRuleDetailsLink as jest.Mock).mockReturnValue(null); + const { container } = renderRulePreviewFooter(); + expect(container).toBeEmptyDOMElement(); }); }); 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 1774c37d9e535..42c8c1a6d85b9 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 @@ -5,38 +5,27 @@ * 2.0. */ -import React, { memo, useCallback } from 'react'; +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 { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { RULE_PREVIEW_FOOTER_TEST_ID, RULE_PREVIEW_OPEN_RULE_FLYOUT_TEST_ID } from './test_ids'; -import { RulePanelKey } from '../right'; +import { useRuleDetailsLink } from '../../document_details/shared/hooks/use_rule_details_link'; /** * Footer in rule preview panel */ export const PreviewFooter = memo(({ ruleId }: { ruleId: string }) => { - const { openFlyout } = useExpandableFlyoutApi(); + const href = useRuleDetailsLink({ ruleId }); - const openRuleFlyout = useCallback(() => { - openFlyout({ - right: { - id: RulePanelKey, - params: { - ruleId, - }, - }, - }); - }, [openFlyout, ruleId]); - - return ( + return href ? ( {i18n.translate('xpack.securitySolution.flyout.preview.rule.viewDetailsLabel', { @@ -46,7 +35,7 @@ export const PreviewFooter = memo(({ ruleId }: { ruleId: string }) => { - ); + ) : null; }); PreviewFooter.displayName = 'PreviewFooter'; diff --git a/x-pack/plugins/security_solution/public/flyout/rule_details/right/index.test.tsx b/x-pack/plugins/security_solution/public/flyout/rule_details/right/index.test.tsx index 1ce755575450c..146da2be34346 100644 --- a/x-pack/plugins/security_solution/public/flyout/rule_details/right/index.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/rule_details/right/index.test.tsx @@ -10,7 +10,7 @@ import { render } from '@testing-library/react'; import { ThemeProvider } from 'styled-components'; import { getMockTheme } from '../../../common/lib/kibana/kibana_react.mock'; import { TestProviders } from '../../../common/mock'; -// import { TestProvider } from '@kbn/expandable-flyout/src/test/provider'; +import { useRuleDetailsLink } from '../../document_details/shared/hooks/use_rule_details_link'; import { RulePanel } from '.'; import { getStepsData } from '../../../detections/pages/detection_engine/rules/helpers'; import { useRuleDetails } from '../hooks/use_rule_details'; @@ -23,6 +23,8 @@ import type { RuleResponse } from '../../../../common/api/detection_engine'; import { BODY_TEST_ID, LOADING_TEST_ID } from './test_ids'; import { RULE_PREVIEW_FOOTER_TEST_ID } from '../preview/test_ids'; +jest.mock('../../document_details/shared/hooks/use_rule_details_link'); + const mockUseRuleDetails = useRuleDetails as jest.Mock; jest.mock('../hooks/use_rule_details'); @@ -89,6 +91,7 @@ describe('', () => { }); it('should render preview footer when isPreviewMode is true', () => { + (useRuleDetailsLink as jest.Mock).mockReturnValue('rule_details_link'); mockUseRuleDetails.mockReturnValue({ rule, loading: false, @@ -97,8 +100,6 @@ describe('', () => { mockGetStepsData.mockReturnValue({}); const { getByTestId } = renderRulePanel(true); - // await act(async () => { expect(getByTestId(RULE_PREVIEW_FOOTER_TEST_ID)).toBeInTheDocument(); - // }); }); }); diff --git a/x-pack/plugins/security_solution/public/notes/components/delete_confirm_modal.tsx b/x-pack/plugins/security_solution/public/notes/components/delete_confirm_modal.tsx index cba7e81b0fb2b..3c6d6da08e190 100644 --- a/x-pack/plugins/security_solution/public/notes/components/delete_confirm_modal.tsx +++ b/x-pack/plugins/security_solution/public/notes/components/delete_confirm_modal.tsx @@ -7,7 +7,7 @@ import React, { useCallback } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { EuiConfirmModal } from '@elastic/eui'; -import * as i18n from './translations'; +import { i18n } from '@kbn/i18n'; import { deleteNotes, userClosedDeleteModal, @@ -16,6 +16,25 @@ import { ReqStatus, } from '..'; +export const DELETE = i18n.translate('xpack.securitySolution.notes.management.deleteAction', { + defaultMessage: 'Delete', +}); +export const DELETE_NOTES_CONFIRM = (selectedNotes: number) => + i18n.translate('xpack.securitySolution.notes.management.deleteNotesConfirm', { + values: { selectedNotes }, + defaultMessage: + 'Are you sure you want to delete {selectedNotes} {selectedNotes, plural, one {note} other {notes}}?', + }); +export const DELETE_NOTES_CANCEL = i18n.translate( + 'xpack.securitySolution.notes.management.deleteNotesCancel', + { + defaultMessage: 'Cancel', + } +); + +/** + * Renders a confirmation modal to delete notes in the notes management page + */ export const DeleteConfirmModal = React.memo(() => { const dispatch = useDispatch(); const pendingDeleteIds = useSelector(selectNotesTablePendingDeleteIds); @@ -33,16 +52,16 @@ export const DeleteConfirmModal = React.memo(() => { return ( - {i18n.DELETE_NOTES_CONFIRM(pendingDeleteIds.length)} + {DELETE_NOTES_CONFIRM(pendingDeleteIds.length)} ); }); diff --git a/x-pack/plugins/security_solution/public/notes/components/delete_note_button.tsx b/x-pack/plugins/security_solution/public/notes/components/delete_note_button.tsx index 3f9e757d3f5a5..4744c362e469c 100644 --- a/x-pack/plugins/security_solution/public/notes/components/delete_note_button.tsx +++ b/x-pack/plugins/security_solution/public/notes/components/delete_note_button.tsx @@ -13,10 +13,10 @@ import { DELETE_NOTE_BUTTON_TEST_ID } from './test_ids'; import type { State } from '../../common/store'; import type { Note } from '../../../common/api/timeline'; import { - deleteNotes, ReqStatus, selectDeleteNotesError, selectDeleteNotesStatus, + userSelectedNotesForDeletion, } from '../store/notes.slice'; import { useAppToasts } from '../../common/hooks/use_app_toasts'; @@ -42,7 +42,8 @@ export interface DeleteNoteButtonIconProps { } /** - * Renders a button to delete a note + * Renders a button to delete a note. + * This button works in combination with the DeleteConfirmModal. */ export const DeleteNoteButtonIcon = memo(({ note, index }: DeleteNoteButtonIconProps) => { const dispatch = useDispatch(); @@ -54,8 +55,8 @@ export const DeleteNoteButtonIcon = memo(({ note, index }: DeleteNoteButtonIconP const deleteNoteFc = useCallback( (noteId: string) => { + dispatch(userSelectedNotesForDeletion(noteId)); setDeletingNoteId(noteId); - dispatch(deleteNotes({ ids: [noteId] })); }, [dispatch] ); diff --git a/x-pack/plugins/security_solution/public/notes/components/note_content.test.tsx b/x-pack/plugins/security_solution/public/notes/components/note_content.test.tsx new file mode 100644 index 0000000000000..6cc9d33d886b7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/note_content.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import { NoteContent } from './note_content'; +import { NOTE_CONTENT_BUTTON_TEST_ID, NOTE_CONTENT_POPOVER_TEST_ID } from './test_ids'; + +const note = 'note-text'; + +describe('NoteContent', () => { + it('should render a note and the popover', () => { + const { getByTestId, getByText } = render(); + + const button = getByTestId(NOTE_CONTENT_BUTTON_TEST_ID); + + expect(button).toBeInTheDocument(); + expect(getByText(note)).toBeInTheDocument(); + + button.click(); + + expect(getByTestId(NOTE_CONTENT_POPOVER_TEST_ID)).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/notes/components/note_content.tsx b/x-pack/plugins/security_solution/public/notes/components/note_content.tsx new file mode 100644 index 0000000000000..ba8710e85c215 --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/note_content.tsx @@ -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 React, { memo, useCallback, useMemo, useState } from 'react'; +import { EuiButtonEmpty, EuiMarkdownFormat, EuiPopover, useEuiTheme } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/react'; +import { NOTE_CONTENT_BUTTON_TEST_ID, NOTE_CONTENT_POPOVER_TEST_ID } from './test_ids'; + +const OPEN_POPOVER = i18n.translate('xpack.securitySolution.notes.expandRow.buttonLabel', { + defaultMessage: 'Expand', +}); + +export interface NoteContentProps { + /** + * The note content to display + */ + note: string; +} + +/** + * Renders the note content to be displayed in the notes management table. + * The content is truncated with an expand button to show the full content within the row. + */ +export const NoteContent = memo(({ note }: NoteContentProps) => { + const { euiTheme } = useEuiTheme(); + + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const togglePopover = useCallback(() => setIsPopoverOpen((value) => !value), []); + const closePopover = useCallback(() => setIsPopoverOpen(false), []); + + const button = useMemo( + () => ( + + {note} + + ), + [euiTheme.size.l, note, togglePopover] + ); + + return ( + + + {note} + + + ); +}); + +NoteContent.displayName = 'NoteContent'; diff --git a/x-pack/plugins/security_solution/public/notes/components/notes_list.tsx b/x-pack/plugins/security_solution/public/notes/components/notes_list.tsx index 47dcf89b06452..344935413731e 100644 --- a/x-pack/plugins/security_solution/public/notes/components/notes_list.tsx +++ b/x-pack/plugins/security_solution/public/notes/components/notes_list.tsx @@ -10,6 +10,7 @@ import { EuiAvatar, EuiComment, EuiCommentList, EuiLoadingElastic } from '@elast import { useSelector } from 'react-redux'; import { FormattedRelative } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; +import { DeleteConfirmModal } from './delete_confirm_modal'; import { OpenFlyoutButtonIcon } from './open_flyout_button'; import { OpenTimelineButtonIcon } from './open_timeline_button'; import { DeleteNoteButtonIcon } from './delete_note_button'; @@ -17,7 +18,11 @@ import { MarkdownRenderer } from '../../common/components/markdown_editor'; import { ADD_NOTE_LOADING_TEST_ID, NOTE_AVATAR_TEST_ID, NOTES_COMMENT_TEST_ID } from './test_ids'; import type { State } from '../../common/store'; import type { Note } from '../../../common/api/timeline'; -import { ReqStatus, selectCreateNoteStatus } from '../store/notes.slice'; +import { + ReqStatus, + selectCreateNoteStatus, + selectNotesTablePendingDeleteIds, +} from '../store/notes.slice'; import { useUserPrivileges } from '../../common/components/user_privileges'; export const ADDED_A_NOTE = i18n.translate('xpack.securitySolution.notes.addedANoteLabel', { @@ -59,41 +64,51 @@ export const NotesList = memo(({ notes, options }: NotesListProps) => { const createStatus = useSelector((state: State) => selectCreateNoteStatus(state)); + const pendingDeleteIds = useSelector(selectNotesTablePendingDeleteIds); + const isDeleteModalVisible = pendingDeleteIds.length > 0; + return ( - - {notes.map((note, index) => ( - {note.created && }} - event={ADDED_A_NOTE} - actions={ - <> - {note.eventId && !options?.hideFlyoutIcon && ( - - )} - {note.timelineId && note.timelineId.length > 0 && !options?.hideTimelineIcon && ( - - )} - {canDeleteNotes && } - - } - timelineAvatar={ - - } - > - {note.note || ''} - - ))} - {createStatus === ReqStatus.Loading && ( - - )} - + <> + + {notes.map((note, index) => ( + {note.created && }} + event={ADDED_A_NOTE} + actions={ + <> + {note.eventId && !options?.hideFlyoutIcon && ( + + )} + {note.timelineId && note.timelineId.length > 0 && !options?.hideTimelineIcon && ( + + )} + {canDeleteNotes && } + + } + timelineAvatar={ + + } + > + {note.note || ''} + + ))} + {createStatus === ReqStatus.Loading && ( + + )} + + {isDeleteModalVisible && } + ); }); diff --git a/x-pack/plugins/security_solution/public/notes/components/open_event_in_timeline.tsx b/x-pack/plugins/security_solution/public/notes/components/open_event_in_timeline.tsx deleted file mode 100644 index 43f039836ccad..0000000000000 --- a/x-pack/plugins/security_solution/public/notes/components/open_event_in_timeline.tsx +++ /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 React, { memo } from 'react'; -import { EuiLink } from '@elastic/eui'; -import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; -import { useInvestigateInTimeline } from '../../detections/components/alerts_table/timeline_actions/use_investigate_in_timeline'; -import * as i18n from './translations'; - -export const OpenEventInTimeline: React.FC<{ eventId?: string | null }> = memo(({ eventId }) => { - const ecsRowData = { event: { id: [eventId] }, _id: eventId } as Ecs; - const { investigateInTimelineAlertClick } = useInvestigateInTimeline({ ecsRowData }); - - return ( - - {i18n.VIEW_EVENT_IN_TIMELINE} - - ); -}); - -OpenEventInTimeline.displayName = 'OpenEventInTimeline'; diff --git a/x-pack/plugins/security_solution/public/notes/components/open_flyout_button.test.tsx b/x-pack/plugins/security_solution/public/notes/components/open_flyout_button.test.tsx index eed5e5bcbd5da..c22a0ebff3fce 100644 --- a/x-pack/plugins/security_solution/public/notes/components/open_flyout_button.test.tsx +++ b/x-pack/plugins/security_solution/public/notes/components/open_flyout_button.test.tsx @@ -13,6 +13,7 @@ import { OpenFlyoutButtonIcon } from './open_flyout_button'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { DocumentDetailsRightPanelKey } from '../../flyout/document_details/shared/constants/panel_keys'; import { useSourcererDataView } from '../../sourcerer/containers'; +import { TableId } from '@kbn/securitysolution-data-table'; jest.mock('@kbn/expandable-flyout'); jest.mock('../../sourcerer/containers'); @@ -27,7 +28,11 @@ describe('OpenFlyoutButtonIcon', () => { const { getByTestId } = render( - + ); @@ -41,7 +46,11 @@ describe('OpenFlyoutButtonIcon', () => { const { getByTestId } = render( - + ); @@ -54,7 +63,7 @@ describe('OpenFlyoutButtonIcon', () => { params: { id: mockEventId, indexName: 'test1,test2', - scopeId: mockTimelineId, + scopeId: TableId.alertsOnAlertsPage, }, }, }); diff --git a/x-pack/plugins/security_solution/public/notes/components/open_flyout_button.tsx b/x-pack/plugins/security_solution/public/notes/components/open_flyout_button.tsx index 0c541cc95740c..34ae9405fdf86 100644 --- a/x-pack/plugins/security_solution/public/notes/components/open_flyout_button.tsx +++ b/x-pack/plugins/security_solution/public/notes/components/open_flyout_button.tsx @@ -6,9 +6,11 @@ */ import React, { memo, useCallback } from 'react'; +import type { IconType } from '@elastic/eui'; import { EuiButtonIcon } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { TableId } from '@kbn/securitysolution-data-table'; import { OPEN_FLYOUT_BUTTON_TEST_ID } from './test_ids'; import { useSourcererDataView } from '../../sourcerer/containers'; import { SourcererScopeName } from '../../sourcerer/store/model'; @@ -31,44 +33,51 @@ export interface OpenFlyoutButtonIconProps { * Id of the timeline to pass to the flyout for scope */ timelineId: string; + /** + * Icon type to render in the button + */ + iconType: IconType; } /** - * Renders a button to open the alert and event details flyout + * Renders a button to open the alert and event details flyout. + * This component is meant to be used in timeline and the notes management page, where the cell actions are more basic (no filter in/out). */ -export const OpenFlyoutButtonIcon = memo(({ eventId, timelineId }: OpenFlyoutButtonIconProps) => { - const { selectedPatterns } = useSourcererDataView(SourcererScopeName.timeline); +export const OpenFlyoutButtonIcon = memo( + ({ eventId, timelineId, iconType }: OpenFlyoutButtonIconProps) => { + const { selectedPatterns } = useSourcererDataView(SourcererScopeName.timeline); - const { telemetry } = useKibana().services; - const { openFlyout } = useExpandableFlyoutApi(); + const { telemetry } = useKibana().services; + const { openFlyout } = useExpandableFlyoutApi(); - const handleClick = useCallback(() => { - openFlyout({ - right: { - id: DocumentDetailsRightPanelKey, - params: { - id: eventId, - indexName: selectedPatterns.join(','), - scopeId: timelineId, + const handleClick = useCallback(() => { + openFlyout({ + right: { + id: DocumentDetailsRightPanelKey, + params: { + id: eventId, + indexName: selectedPatterns.join(','), + scopeId: TableId.alertsOnAlertsPage, // TODO we should update the flyout's code to separate scopeId and preview + }, }, - }, - }); - telemetry.reportDetailsFlyoutOpened({ - location: timelineId, - panel: 'right', - }); - }, [eventId, openFlyout, selectedPatterns, telemetry, timelineId]); + }); + telemetry.reportDetailsFlyoutOpened({ + location: timelineId, + panel: 'right', + }); + }, [eventId, openFlyout, selectedPatterns, telemetry, timelineId]); - return ( - - ); -}); + return ( + + ); + } +); OpenFlyoutButtonIcon.displayName = 'OpenFlyoutButtonIcon'; diff --git a/x-pack/plugins/security_solution/public/notes/components/open_timeline_button.tsx b/x-pack/plugins/security_solution/public/notes/components/open_timeline_button.tsx index 531983429acd1..b44ffd55a767a 100644 --- a/x-pack/plugins/security_solution/public/notes/components/open_timeline_button.tsx +++ b/x-pack/plugins/security_solution/public/notes/components/open_timeline_button.tsx @@ -7,11 +7,16 @@ import React, { memo, useCallback } from 'react'; import { EuiButtonIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; import { useQueryTimelineById } from '../../timelines/components/open_timeline/helpers'; import { OPEN_TIMELINE_BUTTON_TEST_ID } from './test_ids'; import type { Note } from '../../../common/api/timeline'; +const OPEN_TIMELINE = i18n.translate('xpack.securitySolution.notes.management.openTimelineButton', { + defaultMessage: 'Open saved timeline', +}); + export interface OpenTimelineButtonIconProps { /** * The note that contains the id of the timeline to open @@ -20,7 +25,7 @@ export interface OpenTimelineButtonIconProps { /** * The index of the note in the list of notes (used to have unique data-test-subj) */ - index: number; + index?: number; } /** @@ -47,10 +52,10 @@ export const OpenTimelineButtonIcon = memo(({ note, index }: OpenTimelineButtonI return ( openTimeline(note)} /> ); diff --git a/x-pack/plugins/security_solution/public/notes/components/test_ids.ts b/x-pack/plugins/security_solution/public/notes/components/test_ids.ts index 6c63a43f365ac..ac4eeb1948748 100644 --- a/x-pack/plugins/security_solution/public/notes/components/test_ids.ts +++ b/x-pack/plugins/security_solution/public/notes/components/test_ids.ts @@ -17,3 +17,5 @@ export const DELETE_NOTE_BUTTON_TEST_ID = `${PREFIX}DeleteNotesButton` as const; export const OPEN_TIMELINE_BUTTON_TEST_ID = `${PREFIX}OpenTimelineButton` as const; export const OPEN_FLYOUT_BUTTON_TEST_ID = `${PREFIX}OpenFlyoutButton` as const; export const TIMELINE_DESCRIPTION_COMMENT_TEST_ID = `${PREFIX}TimelineDescriptionComment` as const; +export const NOTE_CONTENT_BUTTON_TEST_ID = `${PREFIX}NoteContentButton` as const; +export const NOTE_CONTENT_POPOVER_TEST_ID = `${PREFIX}NoteContentPopover` as const; diff --git a/x-pack/plugins/security_solution/public/notes/components/translations.ts b/x-pack/plugins/security_solution/public/notes/components/translations.ts deleted file mode 100644 index 8d7a5b4262815..0000000000000 --- a/x-pack/plugins/security_solution/public/notes/components/translations.ts +++ /dev/null @@ -1,58 +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 BATCH_ACTIONS = i18n.translate( - 'xpack.securitySolution.notes.management.batchActionsTitle', - { - defaultMessage: 'Bulk actions', - } -); - -export const DELETE = i18n.translate('xpack.securitySolution.notes.management.deleteAction', { - defaultMessage: 'Delete', -}); - -export const DELETE_NOTES_MODAL_TITLE = i18n.translate( - 'xpack.securitySolution.notes.management.deleteNotesModalTitle', - { - defaultMessage: 'Delete notes?', - } -); - -export const DELETE_NOTES_CONFIRM = (selectedNotes: number) => - i18n.translate('xpack.securitySolution.notes.management.deleteNotesConfirm', { - values: { selectedNotes }, - defaultMessage: - 'Are you sure you want to delete {selectedNotes} {selectedNotes, plural, one {note} other {notes}}?', - }); - -export const DELETE_NOTES_CANCEL = i18n.translate( - 'xpack.securitySolution.notes.management.deleteNotesCancel', - { - defaultMessage: 'Cancel', - } -); - -export const DELETE_SELECTED = i18n.translate( - 'xpack.securitySolution.notes.management.deleteSelected', - { - defaultMessage: 'Delete selected notes', - } -); - -export const REFRESH = i18n.translate('xpack.securitySolution.notes.management.refresh', { - defaultMessage: 'Refresh', -}); - -export const VIEW_EVENT_IN_TIMELINE = i18n.translate( - 'xpack.securitySolution.notes.management.viewEventInTimeline', - { - defaultMessage: 'View event in timeline', - } -); diff --git a/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx b/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx index 0c09f6393f668..f0a337cb6c217 100644 --- a/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx +++ b/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx @@ -4,9 +4,11 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import React, { useMemo, useCallback } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { EuiContextMenuItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { UtilityBarGroup, UtilityBarText, @@ -22,8 +24,28 @@ import { selectNotesTableSearch, userSelectedBulkDelete, } from '..'; -import * as i18n from './translations'; +export const BATCH_ACTIONS = i18n.translate( + 'xpack.securitySolution.notes.management.batchActionsTitle', + { + defaultMessage: 'Bulk actions', + } +); + +export const DELETE_SELECTED = i18n.translate( + 'xpack.securitySolution.notes.management.deleteSelected', + { + defaultMessage: 'Delete selected notes', + } +); + +export const REFRESH = i18n.translate('xpack.securitySolution.notes.management.refresh', { + defaultMessage: 'Refresh', +}); + +/** + * Renders the utility bar for the notes management page + */ export const NotesUtilityBar = React.memo(() => { const dispatch = useDispatch(); const pagination = useSelector(selectNotesPagination); @@ -49,7 +71,7 @@ export const NotesUtilityBar = React.memo(() => { icon="trash" key="DeleteItemKey" > - {i18n.DELETE_SELECTED} + {DELETE_SELECTED} ); }, [deleteSelectedNotes, selectedItems.length]); @@ -83,9 +105,7 @@ export const NotesUtilityBar = React.memo(() => { iconType="arrowDown" popoverContent={BulkActionPopoverContent} > - - {i18n.BATCH_ACTIONS} - + {BATCH_ACTIONS} { iconType="refresh" onClick={refresh} > - {i18n.REFRESH} + {REFRESH} diff --git a/x-pack/plugins/security_solution/public/notes/hooks/use_fetch_notes.ts b/x-pack/plugins/security_solution/public/notes/hooks/use_fetch_notes.ts index c9f64bc382454..2cf599e76bcc9 100644 --- a/x-pack/plugins/security_solution/public/notes/hooks/use_fetch_notes.ts +++ b/x-pack/plugins/security_solution/public/notes/hooks/use_fetch_notes.ts @@ -4,12 +4,23 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import { useCallback } from 'react'; import { useDispatch } from 'react-redux'; import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; -import { fetchNotesByDocumentIds } from '..'; +import { fetchNotesByDocumentIds } from '../store/notes.slice'; + +export interface UseFetchNotesResult { + /** + * Function to fetch the notes for an array of documents + */ + onLoad: (events: Array>) => void; +} -export const useFetchNotes = () => { +/** + * Hook that returns a function to fetch the notes for an array of documents + */ +export const useFetchNotes = (): UseFetchNotesResult => { const dispatch = useDispatch(); const securitySolutionNotesEnabled = useIsExperimentalFeatureEnabled( 'securitySolutionNotesEnabled' diff --git a/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx b/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx index ddfed3fbb6287..2b7f0f690532c 100644 --- a/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx +++ b/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx @@ -6,11 +6,18 @@ */ import React, { useCallback, useMemo, useEffect } from 'react'; -import type { DefaultItemAction, EuiBasicTableColumn } from '@elastic/eui'; -import { EuiBasicTable, EuiEmptyPrompt, EuiLink, EuiSpacer } from '@elastic/eui'; +import type { EuiBasicTableColumn } from '@elastic/eui'; +import { + EuiAvatar, + EuiBasicTable, + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, +} from '@elastic/eui'; import { useDispatch, useSelector } from 'react-redux'; -import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; -import { useQueryTimelineById } from '../../timelines/components/open_timeline/helpers'; +import { css } from '@emotion/react'; +import { DeleteNoteButtonIcon } from '../components/delete_note_button'; import { Title } from '../../common/components/header_page/title'; // TODO unify this type from the api with the one in public/common/lib/note import type { Note } from '../../../common/api/timeline'; @@ -27,7 +34,6 @@ import { selectNotesTableSearch, selectFetchNotesStatus, selectNotesTablePendingDeleteIds, - userSelectedRowForDeletion, selectFetchNotesError, ReqStatus, } from '..'; @@ -36,42 +42,69 @@ import { SearchRow } from '../components/search_row'; import { NotesUtilityBar } from '../components/utility_bar'; import { DeleteConfirmModal } from '../components/delete_confirm_modal'; import * as i18n from './translations'; -import { OpenEventInTimeline } from '../components/open_event_in_timeline'; - -const columns: ( - onOpenTimeline: (timelineId: string) => void -) => Array> = (onOpenTimeline) => { - return [ - { - field: 'created', - name: i18n.CREATED_COLUMN, - sortable: true, - render: (created: Note['created']) => , - }, - { - field: 'createdBy', - name: i18n.CREATED_BY_COLUMN, - }, - { - field: 'eventId', - name: i18n.EVENT_ID_COLUMN, - sortable: true, - render: (eventId: Note['eventId']) => , - }, - { - field: 'timelineId', - name: i18n.TIMELINE_ID_COLUMN, - render: (timelineId: Note['timelineId']) => - timelineId ? ( - onOpenTimeline(timelineId)}>{i18n.OPEN_TIMELINE} - ) : null, - }, - { - field: 'note', - name: i18n.NOTE_CONTENT_COLUMN, - }, - ]; -}; +import { OpenFlyoutButtonIcon } from '../components/open_flyout_button'; +import { OpenTimelineButtonIcon } from '../components/open_timeline_button'; +import { NoteContent } from '../components/note_content'; + +const columns: Array> = [ + { + name: i18n.ACTIONS_COLUMN, + render: (note: Note) => ( + + + {note.eventId ? ( + + ) : null} + + + <>{note.timelineId ? : null} + + + + + + ), + width: '72px', + }, + { + field: 'createdBy', + name: i18n.CREATED_BY_COLUMN, + render: (createdBy: Note['createdBy']) => , + width: '100px', + align: 'center', + }, + { + field: 'note', + name: i18n.NOTE_CONTENT_COLUMN, + render: (note: Note['note']) => <>{note && }, + }, + { + field: 'created', + name: i18n.CREATED_COLUMN, + sortable: true, + render: (created: Note['created']) => , + width: '225px', + }, +]; const pageSizeOptions = [10, 25, 50, 100]; @@ -129,13 +162,6 @@ export const NoteManagementPage = () => { [dispatch] ); - const selectRowForDeletion = useCallback( - (id: string) => { - dispatch(userSelectedRowForDeletion(id)); - }, - [dispatch] - ); - const onSelectionChange = useCallback( (selection: Note[]) => { const rowIds = selection.map((item) => item.noteId); @@ -148,39 +174,6 @@ export const NoteManagementPage = () => { return item.noteId; }, []); - const unifiedComponentsInTimelineDisabled = useIsExperimentalFeatureEnabled( - 'unifiedComponentsInTimelineDisabled' - ); - const queryTimelineById = useQueryTimelineById(); - const openTimeline = useCallback( - (timelineId: string) => - queryTimelineById({ - timelineId, - unifiedComponentsInTimelineDisabled, - }), - [queryTimelineById, unifiedComponentsInTimelineDisabled] - ); - - const columnWithActions = useMemo(() => { - const actions: Array> = [ - { - name: i18n.DELETE, - description: i18n.DELETE_SINGLE_NOTE_DESCRIPTION, - color: 'primary', - icon: 'trash', - type: 'icon', - onClick: (note: Note) => selectRowForDeletion(note.noteId), - }, - ]; - return [ - ...columns(openTimeline), - { - name: 'actions', - actions, - }, - ]; - }, [selectRowForDeletion, openTimeline]); - const currentPagination = useMemo(() => { return { pageIndex: pagination.page - 1, @@ -223,7 +216,7 @@ export const NoteManagementPage = () => { { }); }); - describe('userSelectedRowForDeletion', () => { - it('should set correct id when user selects a row', () => { - const action = { type: userSelectedRowForDeletion.type, payload: '1' }; + describe('userSelectedNotesForDeletion', () => { + it('should set correct id when user selects a note to delete', () => { + const action = { type: userSelectedNotesForDeletion.type, payload: '1' }; expect(notesReducer(initalEmptyState, action)).toEqual({ ...initalEmptyState, diff --git a/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts b/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts index 6732f9491676e..2d24ab838ee06 100644 --- a/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts +++ b/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts @@ -193,7 +193,7 @@ const notesSlice = createSlice({ userClosedDeleteModal: (state) => { state.pendingDeleteIds = []; }, - userSelectedRowForDeletion: (state, action: { payload: string }) => { + userSelectedNotesForDeletion: (state, action: { payload: string }) => { state.pendingDeleteIds = [action.payload]; }, userSelectedBulkDelete: (state) => { @@ -391,6 +391,6 @@ export const { userSearchedNotes, userSelectedRow, userClosedDeleteModal, - userSelectedRowForDeletion, + userSelectedNotesForDeletion, userSelectedBulkDelete, } = notesSlice.actions; diff --git a/x-pack/plugins/security_solution/scripts/telemetry/saved_objects/security_solution_ebt_kibana_browser.ndjson b/x-pack/plugins/security_solution/scripts/telemetry/saved_objects/security_solution_ebt_kibana_browser.ndjson index be4eb8f1e7785..f0df277ff5223 100644 --- a/x-pack/plugins/security_solution/scripts/telemetry/saved_objects/security_solution_ebt_kibana_browser.ndjson +++ b/x-pack/plugins/security_solution/scripts/telemetry/saved_objects/security_solution_ebt_kibana_browser.ndjson @@ -1,2 +1,2 @@ -{"attributes":{"allowHidden":false,"fieldAttrs":"{\"properties.groupingId\":{\"count\":1},\"properties.target\":{\"count\":1},\"properties.groupName\":{\"count\":2},\"properties.metadata.telemetry.component\":{\"count\":2},\"properties.unallowedMappingFields\":{\"count\":2},\"properties.unallowedValueFields\":{\"count\":1},\"context.labels.serverless\":{\"count\":4},\"properties.tableId\":{\"count\":1},\"properties.groupNumber\":{\"count\":1},\"properties.groupByField\":{\"count\":4},\"properties.status\":{\"count\":1},\"properties.conversationId\":{\"count\":17},\"properties.invokedBy\":{\"count\":7},\"properties.role\":{\"count\":3},\"properties.isEnabledKnowledgeBase\":{\"count\":1},\"properties.isEnabledRAGAlerts\":{\"count\":1},\"properties.promptTitle\":{\"count\":3},\"properties.fieldName\":{\"count\":1},\"properties.actionId\":{\"count\":1},\"properties.displayName\":{\"count\":1},\"properties.batchId\":{\"count\":8},\"properties.indexId\":{\"count\":1},\"properties.indexName\":{\"count\":2},\"properties.numberOfIndices\":{\"count\":1},\"properties.timeConsumedMs\":{\"count\":1},\"properties.ecsVersion\":{\"count\":1},\"properties.errorCount\":{\"count\":1},\"properties.numberOfIncompatibleFields\":{\"count\":1},\"properties.numberOfDocuments\":{\"count\":1},\"properties.sizeInBytes\":{\"count\":4},\"properties.isCheckAll\":{\"count\":5},\"properties.ilmPhase\":{\"count\":2},\"properties.title\":{\"count\":1},\"properties.location\":{\"count\":1},\"context.applicationId\":{\"count\":6},\"context.cloudId\":{\"count\":6},\"context.cluster_name\":{\"count\":13},\"context.cluster_uuid\":{\"count\":28},\"context.cluster_version\":{\"count\":2},\"context.license_type\":{\"count\":1},\"context.page\":{\"count\":8},\"context.pageName\":{\"count\":6},\"context.page_title\":{\"count\":1},\"context.page_url\":{\"count\":1},\"context.session_id\":{\"count\":2},\"event_type\":{\"count\":36},\"properties\":{\"count\":8},\"properties.pattern\":{\"count\":2},\"peoperties.indexName\":{\"count\":1},\"properties.stepId\":{},\"properties.trigger\":{},\"properties.stepLinkId\":{},\"properties.originStepId\":{},\"properties.durationMs\":{},\"properties.isOpen\":{},\"properties.actionTypeId\":{},\"properties.model\":{},\"properties.provider\":{},\"properties.assistantStreamingEnabled\":{},\"properties.alertsContextCount\":{},\"properties.alertsCount\":{},\"properties.configuredAlertsCount\":{},\"properties.entity\":{},\"properties.selectedSeverity\":{},\"properties.file.size\":{},\"properties.processing.startTime\":{},\"properties.processing.endTime\":{},\"properties.processing.tookMs\":{},\"properties.stats.validLines\":{},\"properties.stats.invalidLines\":{},\"properties.stats.totalLines\":{},\"properties.valid\":{},\"properties.errorCode\":{},\"properties.action\":{},\"properties.quantity\":{},\"properties.jobId\":{},\"properties.isElasticJob\":{},\"properties.moduleId\":{},\"properties.errorMessage\":{},\"properties.count\":{},\"properties.numberOfIndicesChecked\":{},\"properties.numberOfSameFamily\":{},\"properties.numberOfFields\":{},\"properties.numberOfEcsFields\":{},\"properties.numberOfCustomFields\":{},\"properties.panel\":{},\"properties.tabId\":{}}","fieldFormatMap":"{}","fields":"[]","name":"security-solution-ebt-kibana-browser","runtimeFieldMap":"{\"properties.groupingId\":{\"type\":\"keyword\"},\"properties.target\":{\"type\":\"keyword\"},\"property.stackByField\":{\"type\":\"keyword\"},\"properties.groupName\":{\"type\":\"keyword\"},\"context.prebuiltRulesPackageVersion\":{\"type\":\"keyword\"},\"properties.metadata.telemetry.component\":{\"type\":\"keyword\"},\"properties.unallowedMappingFields\":{\"type\":\"keyword\"},\"properties.unallowedValueFields\":{\"type\":\"keyword\"},\"context.labels.serverless\":{\"type\":\"keyword\"},\"properties.resourceAccessed\":{\"type\":\"keyword\"},\"properties.resultCount\":{\"type\":\"long\"},\"properties.responseTime\":{\"type\":\"long\"},\"day_of_week\":{\"type\":\"keyword\",\"script\":{\"source\":\"emit(doc['timestamp'].value.getDayOfWeek().getDisplayName(TextStyle.FULL, Locale.getDefault()))\"}},\"properties.isOpen\":{\"type\":\"boolean\"},\"properties.tableId\":{\"type\":\"keyword\"},\"properties.groupNumber\":{\"type\":\"long\"},\"properties.groupByField\":{\"type\":\"keyword\"},\"properties.status\":{\"type\":\"keyword\"},\"properties.conversationId\":{\"type\":\"keyword\"},\"properties.invokedBy\":{\"type\":\"keyword\"},\"properties.role\":{\"type\":\"keyword\"},\"properties.isEnabledKnowledgeBase\":{\"type\":\"boolean\"},\"properties.isEnabledRAGAlerts\":{\"type\":\"boolean\"},\"properties.actionTypeId\":{\"type\":\"keyword\"},\"properties.model\":{\"type\":\"keyword\"},\"properties.provider\":{\"type\":\"keyword\"},\"properties.promptTitle\":{\"type\":\"keyword\"},\"properties.assistantStreamingEnabled\":{\"type\":\"boolean\"},\"properties.durationMs\":{\"type\":\"long\"},\"properties.alertsContextCount\":{\"type\":\"long\"},\"properties.alertsCount\":{\"type\":\"long\"},\"properties.configuredAlertsCount\":{\"type\":\"long\"},\"properties.entity\":{\"type\":\"keyword\"},\"properties.selectedSeverity\":{\"type\":\"keyword\"},\"properties.file.size\":{\"type\":\"long\"},\"properties.processing.startTime\":{\"type\":\"date\"},\"properties.processing.endTime\":{\"type\":\"date\"},\"properties.processing.tookMs\":{\"type\":\"long\"},\"properties.stats.validLines\":{\"type\":\"long\"},\"properties.stats.invalidLines\":{\"type\":\"long\"},\"properties.stats.totalLines\":{\"type\":\"long\"},\"properties.valid\":{\"type\":\"boolean\"},\"properties.errorCode\":{\"type\":\"keyword\"},\"properties.action\":{\"type\":\"keyword\"},\"properties.quantity\":{\"type\":\"long\"},\"properties.jobId\":{\"type\":\"keyword\"},\"properties.isElasticJob\":{\"type\":\"boolean\"},\"properties.moduleId\":{\"type\":\"keyword\"},\"properties.errorMessage\":{\"type\":\"keyword\"},\"properties.fieldName\":{\"type\":\"keyword\"},\"properties.actionId\":{\"type\":\"keyword\"},\"properties.displayName\":{\"type\":\"keyword\"},\"properties.count\":{\"type\":\"long\"},\"properties.batchId\":{\"type\":\"keyword\"},\"properties.indexId\":{\"type\":\"keyword\"},\"properties.indexName\":{\"type\":\"keyword\"},\"properties.numberOfIndices\":{\"type\":\"long\"},\"properties.numberOfIndicesChecked\":{\"type\":\"long\"},\"properties.numberOfSameFamily\":{\"type\":\"long\"},\"properties.timeConsumedMs\":{\"type\":\"long\"},\"properties.ecsVersion\":{\"type\":\"keyword\"},\"properties.errorCount\":{\"type\":\"long\"},\"properties.numberOfFields\":{\"type\":\"long\"},\"properties.numberOfIncompatibleFields\":{\"type\":\"long\"},\"properties.numberOfEcsFields\":{\"type\":\"long\"},\"properties.numberOfCustomFields\":{\"type\":\"long\"},\"properties.numberOfDocuments\":{\"type\":\"long\"},\"properties.sizeInBytes\":{\"type\":\"long\"},\"properties.isCheckAll\":{\"type\":\"boolean\"},\"properties.ilmPhase\":{\"type\":\"keyword\"},\"properties.title\":{\"type\":\"keyword\"},\"properties.location\":{\"type\":\"keyword\"},\"properties.panel\":{\"type\":\"keyword\"},\"properties.tabId\":{\"type\":\"keyword\"},\"properties.stepId\":{\"type\":\"keyword\"},\"properties.trigger\":{\"type\":\"keyword\"},\"properties.originStepId\":{\"type\":\"keyword\"},\"properties.stepLinkId\":{\"type\":\"keyword\"}}","sourceFilters":"[]","timeFieldName":"timestamp","title":"ebt-kibana-browser","typeMeta":"{}"},"coreMigrationVersion":"8.8.0","created_at":"2024-05-30T16:12:33.003Z","id":"security-solution-ebt-kibana-browser","managed":false,"references":[],"type":"index-pattern","typeMigrationVersion":"8.0.0","updated_at":"2024-05-30T16:52:03.990Z","version":"WzMwNTU0LDVd"} -{"excludedObjects":[],"excludedObjectsCount":0,"exportedCount":1,"missingRefCount":0,"missingReferences":[]} +{"attributes":{"allowHidden":false,"fieldAttrs":"{\"properties.groupingId\":{\"count\":1},\"properties.target\":{\"count\":1},\"properties.groupName\":{\"count\":2},\"properties.metadata.telemetry.component\":{\"count\":2},\"properties.unallowedMappingFields\":{\"count\":2},\"properties.unallowedValueFields\":{\"count\":1},\"context.labels.serverless\":{\"count\":4},\"properties.isEnabledRAGAlerts\":{\"count\":1},\"properties.tableId\":{\"count\":1},\"properties.groupNumber\":{\"count\":1},\"properties.groupByField\":{\"count\":4},\"properties.status\":{\"count\":1},\"properties.conversationId\":{\"count\":17},\"properties.invokedBy\":{\"count\":7},\"properties.role\":{\"count\":3},\"properties.isEnabledKnowledgeBase\":{\"count\":1},\"properties.promptTitle\":{\"count\":3},\"properties.fieldName\":{\"count\":1},\"properties.actionId\":{\"count\":1},\"properties.displayName\":{\"count\":1},\"properties.batchId\":{\"count\":8},\"properties.indexId\":{\"count\":1},\"properties.indexName\":{\"count\":2},\"properties.numberOfIndices\":{\"count\":1},\"properties.timeConsumedMs\":{\"count\":1},\"properties.ecsVersion\":{\"count\":1},\"properties.errorCount\":{\"count\":1},\"properties.numberOfIncompatibleFields\":{\"count\":1},\"properties.numberOfDocuments\":{\"count\":1},\"properties.sizeInBytes\":{\"count\":4},\"properties.isCheckAll\":{\"count\":5},\"properties.ilmPhase\":{\"count\":2},\"properties.title\":{\"count\":1},\"properties.location\":{\"count\":1},\"context.applicationId\":{\"count\":6},\"context.cloudId\":{\"count\":6},\"context.cluster_name\":{\"count\":13},\"context.cluster_uuid\":{\"count\":28},\"context.cluster_version\":{\"count\":2},\"context.license_type\":{\"count\":1},\"context.page\":{\"count\":8},\"context.pageName\":{\"count\":6},\"context.page_title\":{\"count\":1},\"context.page_url\":{\"count\":1},\"context.session_id\":{\"count\":2},\"event_type\":{\"count\":36},\"properties\":{\"count\":8},\"properties.pattern\":{\"count\":2},\"peoperties.indexName\":{\"count\":1},\"properties.stepId\":{},\"properties.trigger\":{},\"properties.stepLinkId\":{},\"properties.originStepId\":{},\"properties.durationMs\":{},\"properties.isOpen\":{},\"properties.actionTypeId\":{},\"properties.model\":{},\"properties.provider\":{},\"properties.assistantStreamingEnabled\":{},\"properties.alertsContextCount\":{},\"properties.alertsCount\":{},\"properties.configuredAlertsCount\":{},\"properties.entity\":{},\"properties.selectedSeverity\":{},\"properties.file.size\":{},\"properties.processing.startTime\":{},\"properties.processing.endTime\":{},\"properties.processing.tookMs\":{},\"properties.stats.validLines\":{},\"properties.stats.invalidLines\":{},\"properties.stats.totalLines\":{},\"properties.valid\":{},\"properties.errorCode\":{},\"properties.action\":{},\"properties.quantity\":{},\"properties.jobId\":{},\"properties.isElasticJob\":{},\"properties.moduleId\":{},\"properties.errorMessage\":{},\"properties.count\":{},\"properties.numberOfIndicesChecked\":{},\"properties.numberOfSameFamily\":{},\"properties.numberOfFields\":{},\"properties.numberOfEcsFields\":{},\"properties.numberOfCustomFields\":{},\"properties.panel\":{},\"properties.tabId\":{},\"properties.totalTasks\":{},\"properties.completedTasks\":{},\"properties.errorTasks\":{},\"properties.rangeInMs\":{},\"properties.type\":{},\"properties.runType\":{},\"properties.isVisible\":{},\"properties.alertsCountUpdated\":{},\"properties.rulesCount\":{},\"properties.isRelatedToATimeline\":{},\"propeties.loggedRequestsEnabled\":{},\"properties.ruleType\":{},\"properties.loggedRequestsEnabled\":{}}","fieldFormatMap":"{}","fields":"[]","name":"security-solution-ebt-kibana-browser","runtimeFieldMap":"{\"properties.groupingId\":{\"type\":\"keyword\"},\"properties.target\":{\"type\":\"keyword\"},\"property.stackByField\":{\"type\":\"keyword\"},\"properties.groupName\":{\"type\":\"keyword\"},\"context.prebuiltRulesPackageVersion\":{\"type\":\"keyword\"},\"properties.metadata.telemetry.component\":{\"type\":\"keyword\"},\"properties.unallowedMappingFields\":{\"type\":\"keyword\"},\"properties.unallowedValueFields\":{\"type\":\"keyword\"},\"context.labels.serverless\":{\"type\":\"keyword\"},\"properties.resourceAccessed\":{\"type\":\"keyword\"},\"properties.resultCount\":{\"type\":\"long\"},\"properties.responseTime\":{\"type\":\"long\"},\"day_of_week\":{\"type\":\"keyword\",\"script\":{\"source\":\"emit(doc['timestamp'].value.getDayOfWeek().getDisplayName(TextStyle.FULL, Locale.getDefault()))\"}},\"properties.isEnabledRAGAlerts\":{\"type\":\"boolean\"},\"properties.durationMs\":{\"type\":\"long\"},\"properties.alertsContextCount\":{\"type\":\"long\"},\"properties.alertsCount\":{\"type\":\"long\"},\"properties.configuredAlertsCount\":{\"type\":\"long\"},\"properties.runType\":{\"type\":\"keyword\"},\"properties.isOpen\":{\"type\":\"boolean\"},\"properties.tableId\":{\"type\":\"keyword\"},\"properties.groupNumber\":{\"type\":\"long\"},\"properties.groupByField\":{\"type\":\"keyword\"},\"properties.status\":{\"type\":\"keyword\"},\"properties.conversationId\":{\"type\":\"keyword\"},\"properties.invokedBy\":{\"type\":\"keyword\"},\"properties.role\":{\"type\":\"keyword\"},\"properties.actionTypeId\":{\"type\":\"keyword\"},\"properties.model\":{\"type\":\"keyword\"},\"properties.provider\":{\"type\":\"keyword\"},\"properties.isEnabledKnowledgeBase\":{\"type\":\"boolean\"},\"properties.promptTitle\":{\"type\":\"keyword\"},\"properties.alertsCountUpdated\":{\"type\":\"boolean\"},\"properties.assistantStreamingEnabled\":{\"type\":\"boolean\"},\"properties.entity\":{\"type\":\"keyword\"},\"properties.selectedSeverity\":{\"type\":\"keyword\"},\"properties.file.size\":{\"type\":\"long\"},\"properties.processing.startTime\":{\"type\":\"date\"},\"properties.processing.endTime\":{\"type\":\"date\"},\"properties.processing.tookMs\":{\"type\":\"long\"},\"properties.stats.validLines\":{\"type\":\"long\"},\"properties.stats.invalidLines\":{\"type\":\"long\"},\"properties.stats.totalLines\":{\"type\":\"long\"},\"properties.valid\":{\"type\":\"boolean\"},\"properties.errorCode\":{\"type\":\"keyword\"},\"properties.action\":{\"type\":\"keyword\"},\"properties.quantity\":{\"type\":\"long\"},\"properties.jobId\":{\"type\":\"keyword\"},\"properties.isElasticJob\":{\"type\":\"boolean\"},\"properties.moduleId\":{\"type\":\"keyword\"},\"properties.errorMessage\":{\"type\":\"keyword\"},\"properties.fieldName\":{\"type\":\"keyword\"},\"properties.actionId\":{\"type\":\"keyword\"},\"properties.displayName\":{\"type\":\"keyword\"},\"properties.count\":{\"type\":\"long\"},\"properties.batchId\":{\"type\":\"keyword\"},\"properties.indexId\":{\"type\":\"keyword\"},\"properties.indexName\":{\"type\":\"keyword\"},\"properties.numberOfIndices\":{\"type\":\"long\"},\"properties.numberOfIndicesChecked\":{\"type\":\"long\"},\"properties.numberOfSameFamily\":{\"type\":\"long\"},\"properties.timeConsumedMs\":{\"type\":\"long\"},\"properties.ecsVersion\":{\"type\":\"keyword\"},\"properties.errorCount\":{\"type\":\"long\"},\"properties.numberOfFields\":{\"type\":\"long\"},\"properties.numberOfIncompatibleFields\":{\"type\":\"long\"},\"properties.numberOfEcsFields\":{\"type\":\"long\"},\"properties.numberOfCustomFields\":{\"type\":\"long\"},\"properties.numberOfDocuments\":{\"type\":\"long\"},\"properties.sizeInBytes\":{\"type\":\"long\"},\"properties.isCheckAll\":{\"type\":\"boolean\"},\"properties.ilmPhase\":{\"type\":\"keyword\"},\"properties.title\":{\"type\":\"keyword\"},\"properties.location\":{\"type\":\"keyword\"},\"properties.panel\":{\"type\":\"keyword\"},\"properties.tabId\":{\"type\":\"keyword\"},\"properties.stepId\":{\"type\":\"keyword\"},\"properties.trigger\":{\"type\":\"keyword\"},\"properties.originStepId\":{\"type\":\"keyword\"},\"properties.stepLinkId\":{\"type\":\"keyword\"},\"properties.totalTasks\":{\"type\":\"long\"},\"properties.completedTasks\":{\"type\":\"long\"},\"properties.errorTasks\":{\"type\":\"long\"},\"properties.rangeInMs\":{\"type\":\"long\"},\"properties.rulesCount\":{\"type\":\"long\"},\"properties.type\":{\"type\":\"keyword\"},\"properties.isVisible\":{\"type\":\"boolean\"},\"properties.isRelatedToATimeline\":{\"type\":\"boolean\"},\"properties.ruleType\":{\"type\":\"keyword\"},\"properties.loggedRequestsEnabled\":{\"type\":\"boolean\"}}","sourceFilters":"[]","timeFieldName":"timestamp","title":"ebt-kibana-browser","typeMeta":"{}"},"coreMigrationVersion":"8.8.0","created_at":"2024-05-30T16:12:33.003Z","id":"security-solution-ebt-kibana-browser","managed":false,"references":[],"type":"index-pattern","typeMigrationVersion":"8.0.0","updated_at":"2024-10-09T14:55:41.854Z","version":"WzUyMTQ4LDld"} +{"excludedObjects":[],"excludedObjectsCount":0,"exportedCount":1,"missingRefCount":0,"missingReferences":[]} \ No newline at end of file diff --git a/x-pack/plugins/security_solution/server/assistant/tools/alert_counts/alert_counts_tool.test.ts b/x-pack/plugins/security_solution/server/assistant/tools/alert_counts/alert_counts_tool.test.ts index 752f8e472a755..814a00853927f 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/alert_counts/alert_counts_tool.test.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/alert_counts/alert_counts_tool.test.ts @@ -29,13 +29,11 @@ describe('AlertCountsTool', () => { } as unknown as KibanaRequest; const isEnabledKnowledgeBase = true; const chain = {} as unknown as RetrievalQAChain; - const modelExists = true; const logger = loggerMock.create(); const rest = { isEnabledKnowledgeBase, chain, logger, - modelExists, }; beforeEach(() => { diff --git a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/attack_discovery_tool.test.ts b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/attack_discovery_tool.test.ts index 5d8fb0b51739a..4d06751f57d7d 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/attack_discovery_tool.test.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/attack_discovery_tool.test.ts @@ -75,7 +75,6 @@ describe('AttackDiscoveryTool', () => { isEnabledKnowledgeBase: false, llm, logger, - modelExists: false, onNewReplacements: jest.fn(), size, }; diff --git a/x-pack/plugins/security_solution/server/assistant/tools/esql/nl_to_esql_tool.test.ts b/x-pack/plugins/security_solution/server/assistant/tools/esql/nl_to_esql_tool.test.ts index f078bccb24a36..10b1fa21daefe 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/esql/nl_to_esql_tool.test.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/esql/nl_to_esql_tool.test.ts @@ -40,65 +40,18 @@ describe('NaturalLanguageESQLTool', () => { request, inference, connectorId, + isEnabledKnowledgeBase: true, }; describe('isSupported', () => { - it('returns false if isEnabledKnowledgeBase is false', () => { - const params = { - isEnabledKnowledgeBase: false, - modelExists: true, - ...rest, - }; - - expect(NL_TO_ESQL_TOOL.isSupported(params)).toBe(false); - }); - - it('returns false if modelExists is false (the ELSER model is not installed)', () => { - const params = { - isEnabledKnowledgeBase: true, - modelExists: false, // <-- ELSER model is not installed - ...rest, - }; - - expect(NL_TO_ESQL_TOOL.isSupported(params)).toBe(false); - }); - - it('returns true if isEnabledKnowledgeBase and modelExists are true', () => { - const params = { - isEnabledKnowledgeBase: true, - modelExists: true, - ...rest, - }; - - expect(NL_TO_ESQL_TOOL.isSupported(params)).toBe(true); + it('returns true if connectorId and inference have values', () => { + expect(NL_TO_ESQL_TOOL.isSupported(rest)).toBe(true); }); }); describe('getTool', () => { - it('returns null if isEnabledKnowledgeBase is false', () => { - const tool = NL_TO_ESQL_TOOL.getTool({ - isEnabledKnowledgeBase: false, - modelExists: true, - ...rest, - }); - - expect(tool).toBeNull(); - }); - - it('returns null if modelExists is false (the ELSER model is not installed)', () => { - const tool = NL_TO_ESQL_TOOL.getTool({ - isEnabledKnowledgeBase: true, - modelExists: false, // <-- ELSER model is not installed - ...rest, - }); - - expect(tool).toBeNull(); - }); - it('returns null if inference plugin is not provided', () => { const tool = NL_TO_ESQL_TOOL.getTool({ - isEnabledKnowledgeBase: true, - modelExists: true, ...rest, inference: undefined, }); @@ -108,8 +61,6 @@ describe('NaturalLanguageESQLTool', () => { it('returns null if connectorId is not provided', () => { const tool = NL_TO_ESQL_TOOL.getTool({ - isEnabledKnowledgeBase: true, - modelExists: true, ...rest, connectorId: undefined, }); @@ -117,10 +68,8 @@ describe('NaturalLanguageESQLTool', () => { expect(tool).toBeNull(); }); - it('should return a Tool instance if isEnabledKnowledgeBase and modelExists are true', () => { + it('should return a Tool instance when given required properties', () => { const tool = NL_TO_ESQL_TOOL.getTool({ - isEnabledKnowledgeBase: true, - modelExists: true, ...rest, }); @@ -129,8 +78,6 @@ describe('NaturalLanguageESQLTool', () => { it('should return a tool with the expected tags', () => { const tool = NL_TO_ESQL_TOOL.getTool({ - isEnabledKnowledgeBase: true, - modelExists: true, ...rest, }) as DynamicTool; @@ -139,8 +86,6 @@ describe('NaturalLanguageESQLTool', () => { it('should return tool with the expected description for OSS model', () => { const tool = NL_TO_ESQL_TOOL.getTool({ - isEnabledKnowledgeBase: true, - modelExists: true, isOssModel: true, ...rest, }) as DynamicTool; @@ -150,8 +95,6 @@ describe('NaturalLanguageESQLTool', () => { it('should return tool with the expected description for non-OSS model', () => { const tool = NL_TO_ESQL_TOOL.getTool({ - isEnabledKnowledgeBase: true, - modelExists: true, isOssModel: false, ...rest, }) as DynamicTool; 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 96b865efeaed4..1205fb03b0458 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 @@ -13,6 +13,7 @@ import { naturalLanguageToEsql } from '@kbn/inference-plugin/server'; import { APP_UI_ID } from '../../../../common'; import { getPromptSuffixForOssModel } from './common'; +// select only some properties of AssistantToolParams export type ESQLToolParams = AssistantToolParams; const TOOL_NAME = 'NaturalLanguageESQLTool'; @@ -32,8 +33,8 @@ export const NL_TO_ESQL_TOOL: AssistantTool = { ...toolDetails, sourceRegister: APP_UI_ID, isSupported: (params: ESQLToolParams): params is ESQLToolParams => { - const { inference, connectorId, isEnabledKnowledgeBase, modelExists } = params; - return isEnabledKnowledgeBase && modelExists && inference != null && connectorId != null; + const { inference, connectorId } = params; + return inference != null && connectorId != null; }, getTool(params: ESQLToolParams) { if (!this.isSupported(params)) return null; diff --git a/x-pack/plugins/security_solution/server/assistant/tools/knowledge_base/knowledge_base_retrieval_tool.ts b/x-pack/plugins/security_solution/server/assistant/tools/knowledge_base/knowledge_base_retrieval_tool.ts index 7739de18857aa..cea2bdadf5970 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/knowledge_base/knowledge_base_retrieval_tool.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/knowledge_base/knowledge_base_retrieval_tool.ts @@ -25,8 +25,8 @@ export const KNOWLEDGE_BASE_RETRIEVAL_TOOL: AssistantTool = { ...toolDetails, sourceRegister: APP_UI_ID, isSupported: (params: AssistantToolParams): params is KnowledgeBaseRetrievalToolParams => { - const { kbDataClient, isEnabledKnowledgeBase, modelExists } = params; - return isEnabledKnowledgeBase && modelExists && kbDataClient != null; + const { kbDataClient, isEnabledKnowledgeBase } = params; + return isEnabledKnowledgeBase && kbDataClient != null; }, getTool(params: AssistantToolParams) { if (!this.isSupported(params)) return null; diff --git a/x-pack/plugins/security_solution/server/assistant/tools/knowledge_base/knowledge_base_write_tool.ts b/x-pack/plugins/security_solution/server/assistant/tools/knowledge_base/knowledge_base_write_tool.ts index 9b46c625e115b..4069eeeef5b97 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/knowledge_base/knowledge_base_write_tool.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/knowledge_base/knowledge_base_write_tool.ts @@ -28,8 +28,8 @@ export const KNOWLEDGE_BASE_WRITE_TOOL: AssistantTool = { ...toolDetails, sourceRegister: APP_UI_ID, isSupported: (params: AssistantToolParams): params is KnowledgeBaseWriteToolParams => { - const { isEnabledKnowledgeBase, kbDataClient, modelExists } = params; - return isEnabledKnowledgeBase && modelExists && kbDataClient != null; + const { isEnabledKnowledgeBase, kbDataClient } = params; + return isEnabledKnowledgeBase && kbDataClient != null; }, getTool(params: AssistantToolParams) { if (!this.isSupported(params)) return null; diff --git a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.test.ts b/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.test.ts index 2b134dfd86335..09bae1639f1b1 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.test.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.test.ts @@ -32,14 +32,12 @@ describe('OpenAndAcknowledgedAlertsTool', () => { } as unknown as KibanaRequest; const isEnabledKnowledgeBase = true; const chain = {} as unknown as RetrievalQAChain; - const modelExists = true; const logger = loggerMock.create(); const rest = { isEnabledKnowledgeBase, esClient, chain, logger, - modelExists, }; const anonymizationFields = [ diff --git a/x-pack/plugins/security_solution/server/assistant/tools/security_labs/security_labs_tool.ts b/x-pack/plugins/security_solution/server/assistant/tools/security_labs/security_labs_tool.ts index 70e955dda8470..48e1619c2f00f 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/security_labs/security_labs_tool.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/security_labs/security_labs_tool.ts @@ -22,8 +22,8 @@ export const SECURITY_LABS_KNOWLEDGE_BASE_TOOL: AssistantTool = { ...toolDetails, sourceRegister: APP_UI_ID, isSupported: (params: AssistantToolParams): params is AssistantToolParams => { - const { kbDataClient, isEnabledKnowledgeBase, modelExists } = params; - return isEnabledKnowledgeBase && modelExists && kbDataClient != null; + const { kbDataClient, isEnabledKnowledgeBase } = params; + return isEnabledKnowledgeBase && kbDataClient != null; }, getTool(params: AssistantToolParams) { if (!this.isSupported(params)) return null; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.mock.ts index c73203c2871ab..8f9c1a6a32357 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.mock.ts @@ -18,6 +18,7 @@ export const getPrebuiltRuleMock = (rewrites?: Partial): Preb language: 'kuery', rule_id: 'rule-1', version: 1, + author: [], ...rewrites, } as PrebuiltRuleAsset); @@ -51,6 +52,7 @@ export const getPrebuiltThreatMatchRuleMock = (): PrebuiltRuleAsset => ({ language: 'kuery', rule_id: 'rule-1', version: 1, + author: [], threat_query: '*:*', threat_index: ['list-index'], threat_mapping: [ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/bulk_actions/validations.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/bulk_actions/validations.ts index d89c78be6b846..8e732ffe6509f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/bulk_actions/validations.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/bulk_actions/validations.ts @@ -90,11 +90,6 @@ export const validateBulkScheduleBackfill = async ({ experimentalFeatures, }: DryRunManualRuleRunBulkActionsValidationArgs) => { // check whether "manual rule run" feature is enabled - await throwDryRunError( - () => - invariant(experimentalFeatures?.manualRuleRunEnabled, 'Manual rule run feature is disabled.'), - BulkActionsDryRunErrCode.MANUAL_RULE_RUN_FEATURE - ); await throwDryRunError( () => invariant(rule.enabled, 'Cannot schedule manual rule run for a disabled rule'), diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.patch_rule.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.patch_rule.test.ts index e460581c02a1c..448df6b581a3b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.patch_rule.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.patch_rule.test.ts @@ -277,6 +277,27 @@ describe('DetectionRulesClient.patchRule', () => { expect(rulesClient.create).not.toHaveBeenCalled(); }); + it('throws an error if rule has external rule source and non-customizable fields are changed', async () => { + // Mock the existing rule + const existingRule = { + ...getRulesSchemaMock(), + rule_source: { type: 'external', is_customized: true }, + }; + (getRuleByRuleId as jest.Mock).mockResolvedValueOnce(existingRule); + + // Mock the rule update + const rulePatch = getCreateRulesSchemaMock('query-rule-id'); + rulePatch.license = 'new license'; + + // Mock the rule returned after update; not used for this test directly but + // needed so that the patchRule method does not throw + rulesClient.update.mockResolvedValue(getRuleMock(getQueryRuleParams())); + + await expect(detectionRulesClient.patchRule({ rulePatch })).rejects.toThrow( + 'Cannot update "license" field for prebuilt rules' + ); + }); + describe('actions', () => { it("updates the rule's actions if provided", async () => { // Mock the existing rule diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.update_rule.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.update_rule.test.ts index a660e5c5e8747..cbd0fb1fe3680 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.update_rule.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.update_rule.test.ts @@ -498,5 +498,26 @@ describe('DetectionRulesClient.updateRule', () => { }) ); }); + + it('throws an error if rule has external rule source and non-customizable fields are changed', async () => { + // Mock the existing rule + const existingRule = { + ...getRulesSchemaMock(), + rule_source: { type: 'external', is_customized: true }, + }; + + (getRuleByRuleId as jest.Mock).mockResolvedValueOnce(existingRule); + + // Mock the rule update + const ruleUpdate = { ...getCreateRulesSchemaMock(), author: ['new user'] }; + + // Mock the rule returned after update; not used for this test directly but + // needed so that the patchRule method does not throw + rulesClient.update.mockResolvedValue(getRuleMock(getQueryRuleParams())); + + await expect(detectionRulesClient.updateRule({ ruleUpdate })).rejects.toThrow( + 'Cannot update "author" field for prebuilt rules' + ); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/patch_rule.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/patch_rule.ts index 1218991bf388e..113576e8d02e2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/patch_rule.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/patch_rule.ts @@ -16,6 +16,7 @@ import type { MlAuthz } from '../../../../../machine_learning/authz'; import type { IPrebuiltRuleAssetsClient } from '../../../../prebuilt_rules/logic/rule_assets/prebuilt_rule_assets_client'; import { applyRulePatch } from '../mergers/apply_rule_patch'; import { getIdError } from '../../../utils/utils'; +import { validateNonCustomizablePatchFields } from '../../../utils/validate'; import { convertAlertingRuleToRuleResponse } from '../converters/convert_alerting_rule_to_rule_response'; import { convertRuleResponseToAlertingRule } from '../converters/convert_rule_response_to_alerting_rule'; import { ClientError, toggleRuleEnabledOnUpdate, validateMlAuth } from '../utils'; @@ -51,6 +52,8 @@ export const patchRule = async ({ await validateMlAuth(mlAuthz, rulePatch.type ?? existingRule.type); + validateNonCustomizablePatchFields(rulePatch, existingRule); + const patchedRule = await applyRulePatch({ prebuiltRuleAssetClient, existingRule, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/update_rule.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/update_rule.ts index cd84788026870..8fd7f7a89dcb7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/update_rule.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/update_rule.ts @@ -11,6 +11,7 @@ import type { RuleResponse } from '../../../../../../../common/api/detection_eng import type { MlAuthz } from '../../../../../machine_learning/authz'; import { applyRuleUpdate } from '../mergers/apply_rule_update'; import { getIdError } from '../../../utils/utils'; +import { validateNonCustomizableUpdateFields } from '../../../utils/validate'; import { convertRuleResponseToAlertingRule } from '../converters/convert_rule_response_to_alerting_rule'; import { ClientError, toggleRuleEnabledOnUpdate, validateMlAuth } from '../utils'; @@ -50,6 +51,8 @@ export const updateRule = async ({ throw new ClientError(error.message, error.statusCode); } + validateNonCustomizableUpdateFields(ruleUpdate, existingRule); + const ruleWithUpdates = await applyRuleUpdate({ prebuiltRuleAssetClient, existingRule, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/validate.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/validate.ts index 3d07f935deb7b..5ff9d2d97f2b0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/validate.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/validate.ts @@ -15,6 +15,7 @@ import { RuleResponse, type RuleResponseAction, type RuleUpdateProps, + type RulePatchProps, } from '../../../../../common/api/detection_engine'; import { RESPONSE_ACTION_API_COMMAND_TO_CONSOLE_COMMAND_MAP, @@ -25,6 +26,7 @@ import { CustomHttpRequestError } from '../../../../utils/custom_http_request_er import { hasValidRuleType, type RuleAlertType, type RuleParams } from '../../rule_schema'; import { type BulkError, createBulkErrorObject } from '../../routes/utils'; import { internalRuleToAPIResponse } from '../logic/detection_rules_client/converters/internal_rule_to_api_response'; +import { ClientError } from '../logic/detection_rules_client/utils'; export const transformValidateBulkError = ( ruleId: string, @@ -117,3 +119,31 @@ function rulePayloadContainsResponseActions(rule: RuleCreateProps | RuleUpdatePr function ruleObjectContainsResponseActions(rule?: RuleAlertType) { return rule != null && 'params' in rule && 'responseActions' in rule?.params; } + +export const validateNonCustomizableUpdateFields = ( + ruleUpdate: RuleUpdateProps, + existingRule: RuleResponse +) => { + // We don't allow non-customizable fields to be changed for prebuilt rules + if (existingRule.rule_source && existingRule.rule_source.type === 'external') { + if (!isEqual(ruleUpdate.author, existingRule.author)) { + throw new ClientError(`Cannot update "author" field for prebuilt rules`, 400); + } else if (ruleUpdate.license !== existingRule.license) { + throw new ClientError(`Cannot update "license" field for prebuilt rules`, 400); + } + } +}; + +export const validateNonCustomizablePatchFields = ( + rulePatch: RulePatchProps, + existingRule: RuleResponse +) => { + // We don't allow non-customizable fields to be changed for prebuilt rules + if (existingRule.rule_source && existingRule.rule_source.type === 'external') { + if (rulePatch.author && !isEqual(rulePatch.author, existingRule.author)) { + throw new ClientError(`Cannot update "author" field for prebuilt rules`, 400); + } else if (rulePatch.license != null && rulePatch.license !== existingRule.license) { + throw new ClientError(`Cannot update "license" field for prebuilt rules`, 400); + } + } +}; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/definition.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/definition.ts index a72e00bf7aceb..09dea151a050a 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/definition.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/definition.ts @@ -27,9 +27,8 @@ export const buildHostEntityDefinition = (space: string): EntityDefinition => 'host.type', 'host.architecture', ], - history: { + latest: { timestampField: '@timestamp', - interval: '1m', }, version: '1.0.0', managed: true, @@ -44,9 +43,8 @@ export const buildUserEntityDefinition = (space: string): EntityDefinition => identityFields: ['user.name'], displayNameTemplate: '{{user.name}}', metadata: ['user.email', 'user.full_name', 'user.hash', 'user.id', 'user.name', 'user.roles'], - history: { + latest: { timestampField: '@timestamp', - interval: '1m', }, version: '1.0.0', managed: true, diff --git a/x-pack/plugins/security_solution_serverless/server/common/services/usage_reporting_service.test.ts b/x-pack/plugins/security_solution_serverless/server/common/services/usage_reporting_service.test.ts new file mode 100644 index 0000000000000..e43df68cc200b --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/server/common/services/usage_reporting_service.test.ts @@ -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 fetch from 'node-fetch'; +import https from 'https'; +import { merge } from 'lodash'; + +import { KBN_CERT_PATH, KBN_KEY_PATH, CA_CERT_PATH } from '@kbn/dev-utils'; + +import type { UsageApiConfigSchema } from '../../config'; +import type { UsageRecord } from '../../types'; + +import { UsageReportingService } from './usage_reporting_service'; +import { USAGE_REPORTING_ENDPOINT, USAGE_SERVICE_USAGE_URL } from '../../constants'; + +jest.mock('node-fetch'); +const { Response } = jest.requireActual('node-fetch'); + +describe('UsageReportingService', () => { + let usageApiConfig: UsageApiConfigSchema; + let service: UsageReportingService; + + function generateUsageApiConfig(overrides?: Partial): UsageApiConfigSchema { + const DEFAULT_USAGE_API_CONFIG = { enabled: false }; + usageApiConfig = merge(DEFAULT_USAGE_API_CONFIG, overrides); + + return usageApiConfig; + } + + function setupService( + usageApi: UsageApiConfigSchema = generateUsageApiConfig() + ): UsageReportingService { + service = new UsageReportingService(usageApi); + return service; + } + + function generateUsageRecord(overrides?: Partial): UsageRecord { + const date = new Date().toISOString(); + const DEFAULT_USAGE_RECORD = { + id: `usage-record-id-${date}`, + usage_timestamp: date, + creation_timestamp: date, + usage: {}, + source: {}, + } as UsageRecord; + return merge(DEFAULT_USAGE_RECORD, overrides); + } + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('usageApi configs not provided', () => { + beforeEach(() => { + setupService(); + }); + + it('should still work if usageApi.url is not provided', async () => { + const usageRecord = generateUsageRecord(); + const records: UsageRecord[] = [usageRecord]; + const mockResponse = new Response(null, { status: 200 }); + (fetch as jest.MockedFunction).mockResolvedValueOnce(mockResponse); + + const response = await service.reportUsage(records); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith(USAGE_SERVICE_USAGE_URL, { + method: 'post', + body: JSON.stringify(records), + headers: { 'Content-Type': 'application/json' }, + agent: expect.any(https.Agent), + }); + expect(response).toBe(mockResponse); + }); + + it('should use an agent with rejectUnauthorized false if config.enabled is false', async () => { + const usageRecord = generateUsageRecord(); + const records: UsageRecord[] = [usageRecord]; + const mockResponse = new Response(null, { status: 200 }); + (fetch as jest.MockedFunction).mockResolvedValueOnce(mockResponse); + + const response = await service.reportUsage(records); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith(USAGE_SERVICE_USAGE_URL, { + method: 'post', + body: JSON.stringify(records), + headers: { 'Content-Type': 'application/json' }, + agent: expect.objectContaining({ + options: expect.objectContaining({ rejectUnauthorized: false }), + }), + }); + expect(response).toBe(mockResponse); + }); + + it('should not set agent if the URL is not https', async () => { + const url = 'http://usage-api.example'; + setupService(generateUsageApiConfig({ url })); + const usageRecord = generateUsageRecord(); + const records: UsageRecord[] = [usageRecord]; + const mockResponse = new Response(null, { status: 200 }); + (fetch as jest.MockedFunction).mockResolvedValue(mockResponse); + + const response = await service.reportUsage(records); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith(`${url}${USAGE_REPORTING_ENDPOINT}`, { + method: 'post', + body: JSON.stringify(records), + headers: { 'Content-Type': 'application/json' }, + }); + expect(response).toBe(mockResponse); + }); + }); + + describe('usageApi configs provided', () => { + const DEFAULT_CONFIG = { + enabled: true, + url: 'https://usage-api.example', + tls: { + certificate: KBN_CERT_PATH, + key: KBN_KEY_PATH, + ca: CA_CERT_PATH, + }, + }; + + beforeEach(() => { + setupService(generateUsageApiConfig(DEFAULT_CONFIG)); + }); + + it('should use usageApi.url if provided', async () => { + const usageRecord = generateUsageRecord(); + const records: UsageRecord[] = [usageRecord]; + const mockResponse = new Response(null, { status: 200 }); + (fetch as jest.MockedFunction).mockResolvedValueOnce(mockResponse); + + const response = await service.reportUsage(records); + const url = `${DEFAULT_CONFIG.url}${USAGE_REPORTING_ENDPOINT}`; + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith(url, { + method: 'post', + body: JSON.stringify(records), + headers: { 'Content-Type': 'application/json' }, + agent: expect.any(https.Agent), + }); + expect(response).toBe(mockResponse); + }); + + it('should use an agent with TLS configuration if config.enabled is true', async () => { + const usageRecord = generateUsageRecord(); + const records: UsageRecord[] = [usageRecord]; + const mockResponse = new Response(null, { status: 200 }); + (fetch as jest.MockedFunction).mockResolvedValueOnce(mockResponse); + + const response = await service.reportUsage(records); + const url = `${DEFAULT_CONFIG.url}${USAGE_REPORTING_ENDPOINT}`; + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith(url, { + method: 'post', + body: JSON.stringify(records), + headers: { 'Content-Type': 'application/json' }, + agent: expect.objectContaining({ + options: expect.objectContaining({ + cert: expect.any(String), + key: expect.any(String), + ca: expect.arrayContaining([expect.any(String)]), + }), + }), + }); + expect(response).toBe(mockResponse); + }); + }); +}); diff --git a/x-pack/plugins/security_solution_serverless/server/common/services/usage_reporting_service.ts b/x-pack/plugins/security_solution_serverless/server/common/services/usage_reporting_service.ts index 0e47b982e692e..ee402872ef33a 100644 --- a/x-pack/plugins/security_solution_serverless/server/common/services/usage_reporting_service.ts +++ b/x-pack/plugins/security_solution_serverless/server/common/services/usage_reporting_service.ts @@ -5,29 +5,77 @@ * 2.0. */ -import type { Response } from 'node-fetch'; +import type { RequestInit, Response } from 'node-fetch'; + import fetch from 'node-fetch'; import https from 'https'; -import { USAGE_SERVICE_USAGE_URL } from '../../constants'; +import { SslConfig, sslSchema } from '@kbn/server-http-tools'; + import type { UsageRecord } from '../../types'; +import type { UsageApiConfigSchema, TlsConfigSchema } from '../../config'; + +import { USAGE_REPORTING_ENDPOINT, USAGE_SERVICE_USAGE_URL } from '../../constants'; -// TODO remove once we have the CA available -const agent = new https.Agent({ rejectUnauthorized: false }); export class UsageReportingService { - public async reportUsage( - records: UsageRecord[], - url = USAGE_SERVICE_USAGE_URL - ): Promise { - const isHttps = url.includes('https'); + private agent: https.Agent | undefined; - return fetch(url, { + constructor(private readonly config: UsageApiConfigSchema) {} + + public async reportUsage(records: UsageRecord[]): Promise { + const reqArgs: RequestInit = { method: 'post', body: JSON.stringify(records), headers: { 'Content-Type': 'application/json' }, - agent: isHttps ? agent : undefined, // Conditionally add agent if URL is HTTPS for supporting integration tests. + }; + if (this.usageApiUrl.includes('https')) { + reqArgs.agent = this.httpAgent; + } + return fetch(this.usageApiUrl, reqArgs); + } + + private get tlsConfigs(): NonNullable { + if (!this.config.tls) { + throw new Error('UsageReportingService: usageApi.tls configs not provided'); + } + + return this.config.tls; + } + + private get usageApiUrl(): string { + if (!this.config.url) { + return USAGE_SERVICE_USAGE_URL; + } + + return `${this.config.url}${USAGE_REPORTING_ENDPOINT}`; + } + + private get httpAgent(): https.Agent { + if (this.agent) { + return this.agent; + } + + if (!this.config.enabled) { + this.agent = new https.Agent({ rejectUnauthorized: false }); + return this.agent; + } + + const tlsConfig = new SslConfig( + sslSchema.validate({ + enabled: true, + certificate: this.tlsConfigs.certificate, + key: this.tlsConfigs.key, + certificateAuthorities: this.tlsConfigs.ca, + }) + ); + + this.agent = new https.Agent({ + rejectUnauthorized: tlsConfig.rejectUnauthorized, + cert: tlsConfig.certificate, + key: tlsConfig.key, + ca: tlsConfig.certificateAuthorities, }); + + return this.agent; } } - -export const usageReportingService = new UsageReportingService(); diff --git a/x-pack/plugins/security_solution_serverless/server/config.ts b/x-pack/plugins/security_solution_serverless/server/config.ts index 96e743a59b425..d4bafd9b9ddb9 100644 --- a/x-pack/plugins/security_solution_serverless/server/config.ts +++ b/x-pack/plugins/security_solution_serverless/server/config.ts @@ -16,19 +16,19 @@ import type { ExperimentalFeatures } from '../common/experimental_features'; import { productTypes } from '../common/config'; import { parseExperimentalConfigValue } from '../common/experimental_features'; -const usageApiConfig = schema.maybe( - schema.object({ - enabled: schema.maybe(schema.boolean()), - url: schema.string(), - tls: schema.maybe( - schema.object({ - certificate: schema.string(), - key: schema.string(), - ca: schema.string(), - }) - ), - }) -); +const tlsConfig = schema.object({ + certificate: schema.string(), + key: schema.string(), + ca: schema.string(), +}); +export type TlsConfigSchema = TypeOf; + +const usageApiConfig = schema.object({ + enabled: schema.boolean({ defaultValue: false }), + url: schema.maybe(schema.string()), + tls: schema.maybe(tlsConfig), +}); +export type UsageApiConfigSchema = TypeOf; export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: false }), diff --git a/x-pack/plugins/security_solution_serverless/server/constants.ts b/x-pack/plugins/security_solution_serverless/server/constants.ts index f4fcad6b760c6..411a7209682de 100644 --- a/x-pack/plugins/security_solution_serverless/server/constants.ts +++ b/x-pack/plugins/security_solution_serverless/server/constants.ts @@ -9,4 +9,5 @@ const namespace = 'elastic-system'; const USAGE_SERVICE_BASE_API_URL = `https://usage-api.${namespace}/api`; const USAGE_SERVICE_BASE_API_URL_V1 = `${USAGE_SERVICE_BASE_API_URL}/v1`; export const USAGE_SERVICE_USAGE_URL = `${USAGE_SERVICE_BASE_API_URL_V1}/usage`; +export const USAGE_REPORTING_ENDPOINT = '/api/v1/usage'; export const METERING_SERVICE_BATCH_SIZE = 1000; diff --git a/x-pack/plugins/security_solution_serverless/server/plugin.ts b/x-pack/plugins/security_solution_serverless/server/plugin.ts index 7161c5b684505..c249e48ca13a0 100644 --- a/x-pack/plugins/security_solution_serverless/server/plugin.ts +++ b/x-pack/plugins/security_solution_serverless/server/plugin.ts @@ -34,6 +34,7 @@ import { } from './endpoint/services'; import { NLPCleanupTask } from './task_manager/nlp_cleanup_task/nlp_cleanup_task'; import { telemetryEvents } from './telemetry/event_based_telemetry'; +import { UsageReportingService } from './common/services/usage_reporting_service'; export class SecuritySolutionServerlessPlugin implements @@ -49,11 +50,14 @@ export class SecuritySolutionServerlessPlugin private endpointUsageReportingTask: SecurityUsageReportingTask | undefined; private nlpCleanupTask: NLPCleanupTask | undefined; private readonly logger: Logger; + private readonly usageReportingService: UsageReportingService; constructor(private readonly initializerContext: PluginInitializerContext) { this.config = this.initializerContext.config.get(); this.logger = this.initializerContext.logger.get(); + this.usageReportingService = new UsageReportingService(this.config.usageApi); + const productTypesStr = JSON.stringify(this.config.productTypes, null, 2); this.logger.info(`Security Solution running with product types:\n${productTypesStr}`); } @@ -83,6 +87,7 @@ export class SecuritySolutionServerlessPlugin taskTitle: cloudSecurityMetringTaskProperties.taskTitle, version: cloudSecurityMetringTaskProperties.version, meteringCallback: cloudSecurityMetringTaskProperties.meteringCallback, + usageReportingService: this.usageReportingService, }); this.endpointUsageReportingTask = new SecurityUsageReportingTask({ @@ -95,6 +100,7 @@ export class SecuritySolutionServerlessPlugin meteringCallback: endpointMeteringService.getUsageRecords, taskManager: pluginsSetup.taskManager, cloudSetup: pluginsSetup.cloud, + usageReportingService: this.usageReportingService, }); this.nlpCleanupTask = new NLPCleanupTask({ diff --git a/x-pack/plugins/security_solution_serverless/server/task_manager/usage_reporting_task.test.ts b/x-pack/plugins/security_solution_serverless/server/task_manager/usage_reporting_task.test.ts index 66307e8f8a693..01c38ed6eed31 100644 --- a/x-pack/plugins/security_solution_serverless/server/task_manager/usage_reporting_task.test.ts +++ b/x-pack/plugins/security_solution_serverless/server/task_manager/usage_reporting_task.test.ts @@ -7,28 +7,26 @@ import { assign } from 'lodash'; +import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; import type { CoreSetup, ElasticsearchClient } from '@kbn/core/server'; import type { TaskManagerSetupContract, ConcreteTaskInstance, } from '@kbn/task-manager-plugin/server'; import type { CloudSetup } from '@kbn/cloud-plugin/server'; + import { TaskStatus } from '@kbn/task-manager-plugin/server'; import { getDeleteTaskRunResult } from '@kbn/task-manager-plugin/server/task'; import { coreMock } from '@kbn/core/server/mocks'; import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; -import { ProductLine, ProductTier } from '../../common/product'; - -import { usageReportingService } from '../common/services'; import type { ServerlessSecurityConfig } from '../config'; import type { SecurityUsageReportingTaskSetupContract, UsageRecord } from '../types'; +import { ProductLine, ProductTier } from '../../common/product'; import { SecurityUsageReportingTask } from './usage_reporting_task'; import { endpointMeteringService } from '../endpoint/services'; -import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; -import { USAGE_SERVICE_USAGE_URL } from '../constants'; describe('SecurityUsageReportingTask', () => { const TITLE = 'test-task-title'; @@ -45,7 +43,7 @@ describe('SecurityUsageReportingTask', () => { let mockEsClient: jest.Mocked; let mockCore: CoreSetup; let mockTaskManagerSetup: jest.Mocked; - let reportUsageSpy: jest.SpyInstance; + let reportUsageMock: jest.Mock; let meteringCallbackMock: jest.Mock; let taskArgs: SecurityUsageReportingTaskSetupContract; let usageRecord: UsageRecord; @@ -118,11 +116,24 @@ describe('SecurityUsageReportingTask', () => { taskTitle: TITLE, version: VERSION, meteringCallback: meteringCallbackMock, + usageReportingService: { + reportUsage: reportUsageMock, + }, }, overrides ); } + const USAGE_API_CONFIG = { + enabled: true, + url: 'https://usage-api-url', + tls: { + certificate: '', + key: '', + ca: '', + }, + }; + async function runTask(taskInstance = buildMockTaskInstance(), callNum: number = 0) { const mockTaskManagerStart = tmStartMock(); await mockTask.start({ taskManager: mockTaskManagerStart, interval: '5m' }); @@ -138,7 +149,7 @@ describe('SecurityUsageReportingTask', () => { .asInternalUser as jest.Mocked; mockTaskManagerSetup = tmSetupMock(); usageRecord = buildUsageRecord(); - reportUsageSpy = jest.spyOn(usageReportingService, 'reportUsage'); + reportUsageMock = jest.fn(); } describe('meteringCallback integration', () => { @@ -150,7 +161,7 @@ describe('SecurityUsageReportingTask', () => { productTypes: [ { product_line: ProductLine.endpoint, product_tier: ProductTier.complete }, ], - usageApi: { url: USAGE_SERVICE_USAGE_URL }, + usageApi: USAGE_API_CONFIG, } as ServerlessSecurityConfig, }); mockTask = new SecurityUsageReportingTask(taskArgs); @@ -199,9 +210,9 @@ describe('SecurityUsageReportingTask', () => { await runTasksUntilNoRunAt(); - expect(reportUsageSpy).toHaveBeenCalledTimes(3); + expect(reportUsageMock).toHaveBeenCalledTimes(3); batches.forEach((batch, i) => { - expect(reportUsageSpy).toHaveBeenNthCalledWith( + expect(reportUsageMock).toHaveBeenNthCalledWith( i + 1, expect.arrayContaining( batch.map(({ _source }) => @@ -209,8 +220,7 @@ describe('SecurityUsageReportingTask', () => { id: `endpoint-${_source.agent.id}-2021-09-01T00:00:00.000Z`, }) ) - ), - USAGE_SERVICE_USAGE_URL + ) ); }); }); @@ -227,7 +237,7 @@ describe('SecurityUsageReportingTask', () => { }); taskArgs = buildTaskArgs({ config: { - usageApi: { url: USAGE_SERVICE_USAGE_URL }, + usageApi: USAGE_API_CONFIG, } as ServerlessSecurityConfig, }); mockTask = new SecurityUsageReportingTask(taskArgs); @@ -273,7 +283,7 @@ describe('SecurityUsageReportingTask', () => { it('should report metering records', async () => { await runTask(); - expect(reportUsageSpy).toHaveBeenCalledWith( + expect(reportUsageMock).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ creation_timestamp: usageRecord.creation_timestamp, @@ -286,8 +296,7 @@ describe('SecurityUsageReportingTask', () => { usage: { period_seconds: 3600, quantity: 1, type: USAGE_TYPE }, usage_timestamp: usageRecord.usage_timestamp, }), - ]), - USAGE_SERVICE_USAGE_URL + ]) ); }); @@ -296,12 +305,12 @@ describe('SecurityUsageReportingTask', () => { expect(result).toEqual(getDeleteTaskRunResult()); - expect(reportUsageSpy).not.toHaveBeenCalled(); + expect(reportUsageMock).not.toHaveBeenCalled(); expect(meteringCallbackMock).not.toHaveBeenCalled(); }); describe('lastSuccessfulReport', () => { it('should set lastSuccessfulReport correctly if report success', async () => { - reportUsageSpy.mockResolvedValueOnce({ status: 201 }); + reportUsageMock.mockResolvedValueOnce({ status: 201 }); const taskInstance = buildMockTaskInstance(); const task = await runTask(taskInstance); const newLastSuccessfulReport = task?.state.lastSuccessfulReport; @@ -320,7 +329,7 @@ describe('SecurityUsageReportingTask', () => { describe('and response is NOT 201', () => { beforeEach(() => { - reportUsageSpy.mockResolvedValueOnce({ status: 500 }); + reportUsageMock.mockResolvedValueOnce({ status: 500 }); }); it('should set lastSuccessfulReport correctly', async () => { diff --git a/x-pack/plugins/security_solution_serverless/server/task_manager/usage_reporting_task.ts b/x-pack/plugins/security_solution_serverless/server/task_manager/usage_reporting_task.ts index 83ef25a849f2d..6eb682a84d474 100644 --- a/x-pack/plugins/security_solution_serverless/server/task_manager/usage_reporting_task.ts +++ b/x-pack/plugins/security_solution_serverless/server/task_manager/usage_reporting_task.ts @@ -8,10 +8,10 @@ import type { Response } from 'node-fetch'; import type { CoreSetup, Logger } from '@kbn/core/server'; import type { ConcreteTaskInstance } from '@kbn/task-manager-plugin/server'; -import { getDeleteTaskRunResult } from '@kbn/task-manager-plugin/server/task'; import type { CloudSetup } from '@kbn/cloud-plugin/server'; -import { usageReportingService } from '../common/services'; +import { getDeleteTaskRunResult } from '@kbn/task-manager-plugin/server/task'; + import type { MeteringCallback, SecurityUsageReportingTaskStartContract, @@ -19,6 +19,7 @@ import type { UsageRecord, } from '../types'; import type { ServerlessSecurityConfig } from '../config'; +import type { UsageReportingService } from '../common/services/usage_reporting_service'; import { stateSchemaByVersion, emptyState } from './task_state'; @@ -34,6 +35,7 @@ export class SecurityUsageReportingTask { private readonly version: string; private readonly logger: Logger; private readonly config: ServerlessSecurityConfig; + private readonly usageReportingService: UsageReportingService; constructor(setupContract: SecurityUsageReportingTaskSetupContract) { const { @@ -46,6 +48,7 @@ export class SecurityUsageReportingTask { taskTitle, version, meteringCallback, + usageReportingService, } = setupContract; this.cloudSetup = cloudSetup; @@ -53,6 +56,7 @@ export class SecurityUsageReportingTask { this.version = version; this.logger = logFactory.get(this.taskId); this.config = config; + this.usageReportingService = usageReportingService; try { taskManager.registerTaskDefinitions({ @@ -163,10 +167,7 @@ export class SecurityUsageReportingTask { try { this.logger.debug(`Sending ${usageRecords.length} usage records to the API`); - usageReportResponse = await usageReportingService.reportUsage( - usageRecords, - this.config.usageApi?.url - ); + usageReportResponse = await this.usageReportingService.reportUsage(usageRecords); if (!usageReportResponse.ok) { const errorResponse = await usageReportResponse.json(); diff --git a/x-pack/plugins/security_solution_serverless/server/types.ts b/x-pack/plugins/security_solution_serverless/server/types.ts index 4f3a7bf3c3db0..a838c410793c3 100644 --- a/x-pack/plugins/security_solution_serverless/server/types.ts +++ b/x-pack/plugins/security_solution_serverless/server/types.ts @@ -25,6 +25,7 @@ import type { IntegrationAssistantPluginSetup } from '@kbn/integration-assistant import type { ProductTier } from '../common/product'; import type { ServerlessSecurityConfig } from './config'; +import type { UsageReportingService } from './common/services/usage_reporting_service'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface SecuritySolutionServerlessPluginSetup {} @@ -86,6 +87,7 @@ export interface SecurityUsageReportingTaskSetupContract { taskTitle: string; version: string; meteringCallback: MeteringCallback; + usageReportingService: UsageReportingService; } export interface SecurityUsageReportingTaskStartContract { diff --git a/x-pack/plugins/security_solution_serverless/tsconfig.json b/x-pack/plugins/security_solution_serverless/tsconfig.json index 55a4882655dc7..cb0518fc4dcd5 100644 --- a/x-pack/plugins/security_solution_serverless/tsconfig.json +++ b/x-pack/plugins/security_solution_serverless/tsconfig.json @@ -19,6 +19,7 @@ "@kbn/security-plugin", "@kbn/security-solution-ess", "@kbn/security-solution-plugin", + "@kbn/server-http-tools", "@kbn/serverless", "@kbn/security-solution-navigation", "@kbn/security-solution-upselling", @@ -46,5 +47,6 @@ "@kbn/logging", "@kbn/integration-assistant-plugin", "@kbn/cloud-security-posture-common", + "@kbn/dev-utils" ] } diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/es_query_expression.test.tsx b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/es_query_expression.test.tsx index 6686d56173de4..e1d17b79e612d 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/es_query_expression.test.tsx +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/es_query_expression.test.tsx @@ -6,7 +6,6 @@ */ import React from 'react'; -import 'brace'; import { of, Subject } from 'rxjs'; import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers'; import { act } from 'react-dom/test-utils'; diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/expression.test.tsx b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/expression.test.tsx index 64d075b7ba723..568a8cf226ae2 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/expression.test.tsx +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/expression.test.tsx @@ -6,7 +6,6 @@ */ import type { DataView } from '@kbn/data-views-plugin/public'; import { mountWithIntl } from '@kbn/test-jest-helpers'; -import 'brace'; import React, { useState } from 'react'; import { docLinksServiceMock } from '@kbn/core/public/mocks'; import { httpServiceMock } from '@kbn/core/public/mocks'; diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/expression.tsx b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/expression.tsx index 196d138c68964..2f0c46a5e34c5 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/expression.tsx +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/expression.tsx @@ -7,7 +7,6 @@ import React, { memo, PropsWithChildren, useCallback } from 'react'; import deepEqual from 'fast-deep-equal'; -import 'brace/theme/github'; import { EuiCallOut, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public'; diff --git a/x-pack/plugins/stack_connectors/common/openai/constants.ts b/x-pack/plugins/stack_connectors/common/openai/constants.ts index c57720d9847af..3d629360d03f3 100644 --- a/x-pack/plugins/stack_connectors/common/openai/constants.ts +++ b/x-pack/plugins/stack_connectors/common/openai/constants.ts @@ -27,6 +27,7 @@ export enum SUB_ACTION { export enum OpenAiProviderType { OpenAi = 'OpenAI', AzureAi = 'Azure OpenAI', + Other = 'Other', } export const DEFAULT_TIMEOUT_MS = 120000; diff --git a/x-pack/plugins/stack_connectors/common/openai/schema.ts b/x-pack/plugins/stack_connectors/common/openai/schema.ts index f62ee1f35174c..8a08da157b163 100644 --- a/x-pack/plugins/stack_connectors/common/openai/schema.ts +++ b/x-pack/plugins/stack_connectors/common/openai/schema.ts @@ -21,6 +21,12 @@ export const ConfigSchema = schema.oneOf([ defaultModel: schema.string({ defaultValue: DEFAULT_OPENAI_MODEL }), headers: schema.maybe(schema.recordOf(schema.string(), schema.string())), }), + schema.object({ + apiProvider: schema.oneOf([schema.literal(OpenAiProviderType.Other)]), + apiUrl: schema.string(), + defaultModel: schema.string(), + headers: schema.maybe(schema.recordOf(schema.string(), schema.string())), + }), ]); export const SecretsSchema = schema.object({ apiKey: schema.string() }); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/lib/gen_ai/use_get_dashboard.test.ts b/x-pack/plugins/stack_connectors/public/connector_types/lib/gen_ai/use_get_dashboard.test.ts index 8ca9b97292fa3..18bcdc6232792 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/lib/gen_ai/use_get_dashboard.test.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/lib/gen_ai/use_get_dashboard.test.ts @@ -53,6 +53,7 @@ describe('useGetDashboard', () => { it.each([ ['Azure OpenAI', 'openai'], ['OpenAI', 'openai'], + ['Other', 'openai'], ['Bedrock', 'bedrock'], ])( 'fetches the %p dashboard and sets the dashboard URL with %p', diff --git a/x-pack/plugins/stack_connectors/public/connector_types/openai/connector.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/openai/connector.test.tsx index 03d41dd01caa9..2c8eaf8a76257 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/openai/connector.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/openai/connector.test.tsx @@ -50,6 +50,17 @@ const azureConnector = { apiKey: 'thats-a-nice-looking-key', }, }; +const otherOpenAiConnector = { + ...openAiConnector, + config: { + apiUrl: 'https://localhost/oss-llm', + apiProvider: OpenAiProviderType.Other, + defaultModel: 'local-model', + }, + secrets: { + apiKey: 'thats-a-nice-looking-key', + }, +}; const navigateToUrl = jest.fn(); @@ -93,6 +104,24 @@ describe('ConnectorFields renders', () => { expect(getAllByTestId('azure-ai-api-keys-doc')[0]).toBeInTheDocument(); }); + test('other open ai connector fields are rendered', async () => { + const { getAllByTestId } = render( + + {}} /> + + ); + expect(getAllByTestId('config.apiUrl-input')[0]).toBeInTheDocument(); + expect(getAllByTestId('config.apiUrl-input')[0]).toHaveValue( + otherOpenAiConnector.config.apiUrl + ); + expect(getAllByTestId('config.apiProvider-select')[0]).toBeInTheDocument(); + expect(getAllByTestId('config.apiProvider-select')[0]).toHaveValue( + otherOpenAiConnector.config.apiProvider + ); + expect(getAllByTestId('other-ai-api-doc')[0]).toBeInTheDocument(); + expect(getAllByTestId('other-ai-api-keys-doc')[0]).toBeInTheDocument(); + }); + describe('Dashboard link', () => { it('Does not render if isEdit is false and dashboardUrl is defined', async () => { const { queryByTestId } = render( diff --git a/x-pack/plugins/stack_connectors/public/connector_types/openai/connector.tsx b/x-pack/plugins/stack_connectors/public/connector_types/openai/connector.tsx index c940ad76e3643..27cbb9a4dac08 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/openai/connector.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/openai/connector.tsx @@ -24,6 +24,8 @@ import * as i18n from './translations'; import { azureAiConfig, azureAiSecrets, + otherOpenAiConfig, + otherOpenAiSecrets, openAiConfig, openAiSecrets, providerOptions, @@ -85,6 +87,14 @@ const ConnectorFields: React.FC = ({ readOnly, isEdi secretsFormSchema={azureAiSecrets} /> )} + {config != null && config.apiProvider === OpenAiProviderType.Other && ( + + )} {isEdit && ( + {`${i18n.OTHER_OPENAI} ${i18n.DOCUMENTATION}`} + + ), + }} + /> + ), + }, + { + id: 'defaultModel', + label: i18n.DEFAULT_MODEL_LABEL, + helpText: ( + + ), + }, +]; + export const openAiSecrets: SecretsFieldSchema[] = [ { id: 'apiKey', @@ -142,6 +177,31 @@ export const azureAiSecrets: SecretsFieldSchema[] = [ }, ]; +export const otherOpenAiSecrets: SecretsFieldSchema[] = [ + { + id: 'apiKey', + label: i18n.API_KEY_LABEL, + isPasswordField: true, + helpText: ( + + {`${i18n.OTHER_OPENAI} ${i18n.DOCUMENTATION}`} + + ), + }} + /> + ), + }, +]; + export const providerOptions = [ { value: OpenAiProviderType.OpenAi, @@ -153,4 +213,9 @@ export const providerOptions = [ text: i18n.AZURE_AI, label: i18n.AZURE_AI, }, + { + value: OpenAiProviderType.Other, + text: i18n.OTHER_OPENAI, + label: i18n.OTHER_OPENAI, + }, ]; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/openai/params.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/openai/params.test.tsx index 09a2652ad8f1d..7539cc6bf6373 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/openai/params.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/openai/params.test.tsx @@ -37,7 +37,7 @@ describe('Gen AI Params Fields renders', () => { expect(getByTestId('bodyJsonEditor')).toHaveProperty('value', '{"message": "test"}'); expect(getByTestId('bodyAddVariableButton')).toBeInTheDocument(); }); - test.each([OpenAiProviderType.OpenAi, OpenAiProviderType.AzureAi])( + test.each([OpenAiProviderType.OpenAi, OpenAiProviderType.AzureAi, OpenAiProviderType.Other])( 'useEffect handles the case when subAction and subActionParams are undefined and apiProvider is %p', (apiProvider) => { const actionParams = { @@ -79,6 +79,9 @@ describe('Gen AI Params Fields renders', () => { if (apiProvider === OpenAiProviderType.AzureAi) { expect(editAction).toHaveBeenCalledWith('subActionParams', { body: DEFAULT_BODY_AZURE }, 0); } + if (apiProvider === OpenAiProviderType.Other) { + expect(editAction).toHaveBeenCalledWith('subActionParams', { body: DEFAULT_BODY }, 0); + } } ); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/openai/translations.ts b/x-pack/plugins/stack_connectors/public/connector_types/openai/translations.ts index 4c72866c6ece4..55815faac1c8e 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/openai/translations.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/openai/translations.ts @@ -47,6 +47,10 @@ export const AZURE_AI = i18n.translate('xpack.stackConnectors.components.genAi.a defaultMessage: 'Azure OpenAI', }); +export const OTHER_OPENAI = i18n.translate('xpack.stackConnectors.components.genAi.otherAi', { + defaultMessage: 'Other (OpenAI Compatible Service)', +}); + export const DOCUMENTATION = i18n.translate( 'xpack.stackConnectors.components.genAi.documentation', { diff --git a/x-pack/plugins/stack_connectors/server/connector_types/openai/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/openai/index.ts index f8a3a3d32ddb2..5bf0ba6c3a562 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/openai/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/openai/index.ts @@ -53,7 +53,11 @@ export const configValidator = (configObject: Config, validatorServices: Validat const { apiProvider } = configObject; - if (apiProvider !== OpenAiProviderType.OpenAi && apiProvider !== OpenAiProviderType.AzureAi) { + if ( + apiProvider !== OpenAiProviderType.OpenAi && + apiProvider !== OpenAiProviderType.AzureAi && + apiProvider !== OpenAiProviderType.Other + ) { throw new Error( `API Provider is not supported${ apiProvider && (apiProvider as OpenAiProviderType).length ? `: ${apiProvider}` : `` diff --git a/x-pack/plugins/stack_connectors/server/connector_types/openai/lib/other_openai_utils.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/openai/lib/other_openai_utils.test.ts new file mode 100644 index 0000000000000..33722314f5422 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/openai/lib/other_openai_utils.test.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 { sanitizeRequest, getRequestWithStreamOption } from './other_openai_utils'; + +describe('Other (OpenAI Compatible Service) Utils', () => { + describe('sanitizeRequest', () => { + it('sets stream to false when stream is set to true in the body', () => { + const body = { + model: 'mistral', + stream: true, + messages: [ + { + role: 'user', + content: 'This is a test', + }, + ], + }; + + const sanitizedBodyString = sanitizeRequest(JSON.stringify(body)); + expect(sanitizedBodyString).toEqual( + `{\"model\":\"mistral\",\"stream\":false,\"messages\":[{\"role\":\"user\",\"content\":\"This is a test\"}]}` + ); + }); + + it('sets stream to false when stream is not defined in the body', () => { + const body = { + model: 'mistral', + messages: [ + { + role: 'user', + content: 'This is a test', + }, + ], + }; + + const sanitizedBodyString = sanitizeRequest(JSON.stringify(body)); + expect(sanitizedBodyString).toEqual( + `{\"model\":\"mistral\",\"messages\":[{\"role\":\"user\",\"content\":\"This is a test\"}],\"stream\":false}` + ); + }); + + it('sets stream to false when stream is set to false in the body', () => { + const body = { + model: 'mistral', + stream: false, + messages: [ + { + role: 'user', + content: 'This is a test', + }, + ], + }; + + const sanitizedBodyString = sanitizeRequest(JSON.stringify(body)); + expect(sanitizedBodyString).toEqual( + `{\"model\":\"mistral\",\"stream\":false,\"messages\":[{\"role\":\"user\",\"content\":\"This is a test\"}]}` + ); + }); + + it('does nothing when body is malformed JSON', () => { + const bodyString = `{\"model\":\"mistral\",\"messages\":[{\"role\":\"user\",\"content\":\"This is a test\"}],,}`; + + const sanitizedBodyString = sanitizeRequest(bodyString); + expect(sanitizedBodyString).toEqual(bodyString); + }); + }); + + describe('getRequestWithStreamOption', () => { + it('sets stream parameter when stream is not defined in the body', () => { + const body = { + model: 'mistral', + messages: [ + { + role: 'user', + content: 'This is a test', + }, + ], + }; + + const sanitizedBodyString = getRequestWithStreamOption(JSON.stringify(body), true); + expect(sanitizedBodyString).toEqual( + `{\"model\":\"mistral\",\"messages\":[{\"role\":\"user\",\"content\":\"This is a test\"}],\"stream\":true}` + ); + }); + + it('overrides stream parameter if defined in body', () => { + const body = { + model: 'mistral', + stream: true, + messages: [ + { + role: 'user', + content: 'This is a test', + }, + ], + }; + + const sanitizedBodyString = getRequestWithStreamOption(JSON.stringify(body), false); + expect(sanitizedBodyString).toEqual( + `{\"model\":\"mistral\",\"stream\":false,\"messages\":[{\"role\":\"user\",\"content\":\"This is a test\"}]}` + ); + }); + + it('does nothing when body is malformed JSON', () => { + const bodyString = `{\"model\":\"mistral\",\"messages\":[{\"role\":\"user\",\"content\":\"This is a test\"}],,}`; + + const sanitizedBodyString = getRequestWithStreamOption(bodyString, false); + expect(sanitizedBodyString).toEqual(bodyString); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/openai/lib/other_openai_utils.ts b/x-pack/plugins/stack_connectors/server/connector_types/openai/lib/other_openai_utils.ts new file mode 100644 index 0000000000000..8288e0dba9ad1 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/openai/lib/other_openai_utils.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. + */ + +/** + * Sanitizes the Other (OpenAI Compatible Service) request body to set stream to false + * so users cannot specify a streaming response when the framework + * is not prepared to handle streaming + * + * The stream parameter is accepted in the ChatCompletion + * API and the Completion API only + */ +export const sanitizeRequest = (body: string): string => { + return getRequestWithStreamOption(body, false); +}; + +/** + * Intercepts the Other (OpenAI Compatible Service) request body to set the stream parameter + * + * The stream parameter is accepted in the ChatCompletion + * API and the Completion API only + */ +export const getRequestWithStreamOption = (body: string, stream: boolean): string => { + try { + const jsonBody = JSON.parse(body); + if (jsonBody) { + jsonBody.stream = stream; + } + + return JSON.stringify(jsonBody); + } catch (err) { + // swallow the error + } + + return body; +}; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/openai/lib/utils.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/openai/lib/utils.test.ts index 9dffaab3e5e00..142f3a319eeb6 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/openai/lib/utils.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/openai/lib/utils.test.ts @@ -19,8 +19,14 @@ import { sanitizeRequest as azureAiSanitizeRequest, getRequestWithStreamOption as azureAiGetRequestWithStreamOption, } from './azure_openai_utils'; +import { + sanitizeRequest as otherOpenAiSanitizeRequest, + getRequestWithStreamOption as otherOpenAiGetRequestWithStreamOption, +} from './other_openai_utils'; + jest.mock('./openai_utils'); jest.mock('./azure_openai_utils'); +jest.mock('./other_openai_utils'); describe('Utils', () => { const azureAiUrl = @@ -38,6 +44,7 @@ describe('Utils', () => { describe('sanitizeRequest', () => { const mockOpenAiSanitizeRequest = openAiSanitizeRequest as jest.Mock; const mockAzureAiSanitizeRequest = azureAiSanitizeRequest as jest.Mock; + const mockOtherOpenAiSanitizeRequest = otherOpenAiSanitizeRequest as jest.Mock; beforeEach(() => { jest.clearAllMocks(); }); @@ -50,24 +57,36 @@ describe('Utils', () => { DEFAULT_OPENAI_MODEL ); expect(mockAzureAiSanitizeRequest).not.toHaveBeenCalled(); + expect(mockOtherOpenAiSanitizeRequest).not.toHaveBeenCalled(); + }); + + it('calls other_openai_utils sanitizeRequest when provider is Other OpenAi', () => { + sanitizeRequest(OpenAiProviderType.Other, OPENAI_CHAT_URL, bodyString, DEFAULT_OPENAI_MODEL); + expect(mockOtherOpenAiSanitizeRequest).toHaveBeenCalledWith(bodyString); + expect(mockOpenAiSanitizeRequest).not.toHaveBeenCalled(); + expect(mockAzureAiSanitizeRequest).not.toHaveBeenCalled(); }); it('calls azure_openai_utils sanitizeRequest when provider is AzureAi', () => { sanitizeRequest(OpenAiProviderType.AzureAi, azureAiUrl, bodyString); expect(mockAzureAiSanitizeRequest).toHaveBeenCalledWith(azureAiUrl, bodyString); expect(mockOpenAiSanitizeRequest).not.toHaveBeenCalled(); + expect(mockOtherOpenAiSanitizeRequest).not.toHaveBeenCalled(); }); it('does not call any helper fns when provider is unrecognized', () => { sanitizeRequest('foo', OPENAI_CHAT_URL, bodyString); expect(mockOpenAiSanitizeRequest).not.toHaveBeenCalled(); expect(mockAzureAiSanitizeRequest).not.toHaveBeenCalled(); + expect(mockOtherOpenAiSanitizeRequest).not.toHaveBeenCalled(); }); }); describe('getRequestWithStreamOption', () => { const mockOpenAiGetRequestWithStreamOption = openAiGetRequestWithStreamOption as jest.Mock; const mockAzureAiGetRequestWithStreamOption = azureAiGetRequestWithStreamOption as jest.Mock; + const mockOtherOpenAiGetRequestWithStreamOption = + otherOpenAiGetRequestWithStreamOption as jest.Mock; beforeEach(() => { jest.clearAllMocks(); }); @@ -88,6 +107,15 @@ describe('Utils', () => { DEFAULT_OPENAI_MODEL ); expect(mockAzureAiGetRequestWithStreamOption).not.toHaveBeenCalled(); + expect(mockOtherOpenAiGetRequestWithStreamOption).not.toHaveBeenCalled(); + }); + + it('calls other_openai_utils getRequestWithStreamOption when provider is Other OpenAi', () => { + getRequestWithStreamOption(OpenAiProviderType.Other, OPENAI_CHAT_URL, bodyString, true); + + expect(mockOtherOpenAiGetRequestWithStreamOption).toHaveBeenCalledWith(bodyString, true); + expect(mockOpenAiGetRequestWithStreamOption).not.toHaveBeenCalled(); + expect(mockAzureAiGetRequestWithStreamOption).not.toHaveBeenCalled(); }); it('calls azure_openai_utils getRequestWithStreamOption when provider is AzureAi', () => { @@ -99,6 +127,7 @@ describe('Utils', () => { true ); expect(mockOpenAiGetRequestWithStreamOption).not.toHaveBeenCalled(); + expect(mockOtherOpenAiGetRequestWithStreamOption).not.toHaveBeenCalled(); }); it('does not call any helper fns when provider is unrecognized', () => { @@ -110,6 +139,7 @@ describe('Utils', () => { ); expect(mockOpenAiGetRequestWithStreamOption).not.toHaveBeenCalled(); expect(mockAzureAiGetRequestWithStreamOption).not.toHaveBeenCalled(); + expect(mockOtherOpenAiGetRequestWithStreamOption).not.toHaveBeenCalled(); }); }); @@ -127,6 +157,19 @@ describe('Utils', () => { }); }); + it('returns correct axios options when provider is other openai and stream is false', () => { + expect(getAxiosOptions(OpenAiProviderType.Other, 'api-abc', false)).toEqual({ + headers: { Authorization: `Bearer api-abc`, ['content-type']: 'application/json' }, + }); + }); + + it('returns correct axios options when provider is other openai and stream is true', () => { + expect(getAxiosOptions(OpenAiProviderType.Other, 'api-abc', true)).toEqual({ + headers: { Authorization: `Bearer api-abc`, ['content-type']: 'application/json' }, + responseType: 'stream', + }); + }); + it('returns correct axios options when provider is azure openai and stream is false', () => { expect(getAxiosOptions(OpenAiProviderType.AzureAi, 'api-abc', false)).toEqual({ headers: { ['api-key']: `api-abc`, ['content-type']: 'application/json' }, diff --git a/x-pack/plugins/stack_connectors/server/connector_types/openai/lib/utils.ts b/x-pack/plugins/stack_connectors/server/connector_types/openai/lib/utils.ts index 811dfd4ce63b4..3028433656503 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/openai/lib/utils.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/openai/lib/utils.ts @@ -16,6 +16,10 @@ import { sanitizeRequest as azureAiSanitizeRequest, getRequestWithStreamOption as azureAiGetRequestWithStreamOption, } from './azure_openai_utils'; +import { + sanitizeRequest as otherOpenAiSanitizeRequest, + getRequestWithStreamOption as otherOpenAiGetRequestWithStreamOption, +} from './other_openai_utils'; export const sanitizeRequest = ( provider: string, @@ -28,6 +32,8 @@ export const sanitizeRequest = ( return openAiSanitizeRequest(url, body, defaultModel!); case OpenAiProviderType.AzureAi: return azureAiSanitizeRequest(url, body); + case OpenAiProviderType.Other: + return otherOpenAiSanitizeRequest(body); default: return body; } @@ -42,7 +48,7 @@ export function getRequestWithStreamOption( ): string; export function getRequestWithStreamOption( - provider: OpenAiProviderType.AzureAi, + provider: OpenAiProviderType.AzureAi | OpenAiProviderType.Other, url: string, body: string, stream: boolean @@ -68,6 +74,8 @@ export function getRequestWithStreamOption( return openAiGetRequestWithStreamOption(url, body, stream, defaultModel!); case OpenAiProviderType.AzureAi: return azureAiGetRequestWithStreamOption(url, body, stream); + case OpenAiProviderType.Other: + return otherOpenAiGetRequestWithStreamOption(body, stream); default: return body; } @@ -81,6 +89,7 @@ export const getAxiosOptions = ( const responseType = stream ? { responseType: 'stream' as ResponseType } : {}; switch (provider) { case OpenAiProviderType.OpenAi: + case OpenAiProviderType.Other: return { headers: { Authorization: `Bearer ${apiKey}`, ['content-type']: 'application/json' }, ...responseType, diff --git a/x-pack/plugins/stack_connectors/server/connector_types/openai/openai.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/openai/openai.test.ts index 87dacaf4e6f17..1362b7610e2cd 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/openai/openai.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/openai/openai.test.ts @@ -20,6 +20,9 @@ import { RunActionResponseSchema, StreamingResponseSchema } from '../../../commo import { initDashboard } from '../lib/gen_ai/create_gen_ai_dashboard'; import { PassThrough, Transform } from 'stream'; import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; + +const DEFAULT_OTHER_OPENAI_MODEL = 'local-model'; + jest.mock('../lib/gen_ai/create_gen_ai_dashboard'); const mockTee = jest.fn(); @@ -713,6 +716,431 @@ describe('OpenAIConnector', () => { }); }); + describe('Other OpenAI', () => { + const connector = new OpenAIConnector({ + configurationUtilities: actionsConfigMock.create(), + connector: { id: '1', type: OPENAI_CONNECTOR_ID }, + config: { + apiUrl: 'http://localhost:1234/v1/chat/completions', + apiProvider: OpenAiProviderType.Other, + defaultModel: DEFAULT_OTHER_OPENAI_MODEL, + headers: { + 'X-My-Custom-Header': 'foo', + Authorization: 'override', + }, + }, + secrets: { apiKey: '123' }, + logger, + services: actionsMock.createServices(), + }); + + const sampleOpenAiBody = { + model: DEFAULT_OTHER_OPENAI_MODEL, + messages: [ + { + role: 'user', + content: 'Hello world', + }, + ], + }; + + beforeEach(() => { + // @ts-ignore + connector.request = mockRequest; + jest.clearAllMocks(); + }); + + describe('runApi', () => { + it('the Other OpenAI API call is successful with correct parameters', async () => { + const response = await connector.runApi( + { body: JSON.stringify(sampleOpenAiBody) }, + connectorUsageCollector + ); + expect(mockRequest).toBeCalledTimes(1); + expect(mockRequest).toHaveBeenCalledWith( + { + ...mockDefaults, + url: 'http://localhost:1234/v1/chat/completions', + data: JSON.stringify({ + ...sampleOpenAiBody, + stream: false, + model: DEFAULT_OTHER_OPENAI_MODEL, + }), + headers: { + Authorization: 'Bearer 123', + 'X-My-Custom-Header': 'foo', + 'content-type': 'application/json', + }, + }, + connectorUsageCollector + ); + expect(response).toEqual(mockResponse.data); + }); + + it('overrides stream parameter if set in the body', async () => { + const body = { + model: 'llama-3.1', + messages: [ + { + role: 'user', + content: 'Hello world', + }, + ], + }; + const response = await connector.runApi( + { + body: JSON.stringify({ + ...body, + stream: true, + }), + }, + connectorUsageCollector + ); + expect(mockRequest).toBeCalledTimes(1); + expect(mockRequest).toHaveBeenCalledWith( + { + ...mockDefaults, + url: 'http://localhost:1234/v1/chat/completions', + data: JSON.stringify({ + ...body, + stream: false, + }), + headers: { + Authorization: 'Bearer 123', + 'X-My-Custom-Header': 'foo', + 'content-type': 'application/json', + }, + }, + connectorUsageCollector + ); + expect(response).toEqual(mockResponse.data); + }); + + it('errors during API calls are properly handled', async () => { + // @ts-ignore + connector.request = mockError; + + await expect( + connector.runApi({ body: JSON.stringify(sampleOpenAiBody) }, connectorUsageCollector) + ).rejects.toThrow('API Error'); + }); + }); + + describe('streamApi', () => { + it('the Other OpenAI API call is successful with correct parameters when stream = false', async () => { + const response = await connector.streamApi( + { + body: JSON.stringify(sampleOpenAiBody), + stream: false, + }, + connectorUsageCollector + ); + expect(mockRequest).toBeCalledTimes(1); + expect(mockRequest).toHaveBeenCalledWith( + { + url: 'http://localhost:1234/v1/chat/completions', + method: 'post', + responseSchema: RunActionResponseSchema, + data: JSON.stringify({ + ...sampleOpenAiBody, + stream: false, + }), + headers: { + Authorization: 'Bearer 123', + 'X-My-Custom-Header': 'foo', + 'content-type': 'application/json', + }, + }, + connectorUsageCollector + ); + expect(response).toEqual(mockResponse.data); + }); + + it('the Other OpenAI API call is successful with correct parameters when stream = true', async () => { + const response = await connector.streamApi( + { + body: JSON.stringify(sampleOpenAiBody), + stream: true, + }, + connectorUsageCollector + ); + expect(mockRequest).toBeCalledTimes(1); + expect(mockRequest).toHaveBeenCalledWith( + { + responseType: 'stream', + url: 'http://localhost:1234/v1/chat/completions', + method: 'post', + responseSchema: StreamingResponseSchema, + data: JSON.stringify({ + ...sampleOpenAiBody, + stream: true, + model: DEFAULT_OTHER_OPENAI_MODEL, + }), + headers: { + Authorization: 'Bearer 123', + 'X-My-Custom-Header': 'foo', + 'content-type': 'application/json', + }, + }, + connectorUsageCollector + ); + expect(response).toEqual({ + headers: { 'Content-Type': 'dont-compress-this' }, + ...mockResponse.data, + }); + }); + + it('overrides stream parameter if set in the body with explicit stream parameter', async () => { + const body = { + model: 'llama-3.1', + messages: [ + { + role: 'user', + content: 'Hello world', + }, + ], + }; + const response = await connector.streamApi( + { + body: JSON.stringify({ + ...body, + stream: false, + }), + stream: true, + }, + connectorUsageCollector + ); + expect(mockRequest).toBeCalledTimes(1); + expect(mockRequest).toHaveBeenCalledWith( + { + responseType: 'stream', + url: 'http://localhost:1234/v1/chat/completions', + method: 'post', + responseSchema: StreamingResponseSchema, + data: JSON.stringify({ + ...body, + stream: true, + }), + headers: { + Authorization: 'Bearer 123', + 'X-My-Custom-Header': 'foo', + 'content-type': 'application/json', + }, + }, + connectorUsageCollector + ); + expect(response).toEqual({ + headers: { 'Content-Type': 'dont-compress-this' }, + ...mockResponse.data, + }); + }); + + it('errors during API calls are properly handled', async () => { + // @ts-ignore + connector.request = mockError; + + await expect( + connector.streamApi( + { body: JSON.stringify(sampleOpenAiBody), stream: true }, + connectorUsageCollector + ) + ).rejects.toThrow('API Error'); + }); + }); + + describe('invokeStream', () => { + const mockStream = ( + dataToStream: string[] = [ + 'data: {"object":"chat.completion.chunk","choices":[{"delta":{"content":"My"}}]}\ndata: {"object":"chat.completion.chunk","choices":[{"delta":{"content":" new"}}]}', + ] + ) => { + const streamMock = createStreamMock(); + dataToStream.forEach((chunk) => { + streamMock.write(chunk); + }); + streamMock.complete(); + mockRequest = jest.fn().mockResolvedValue({ ...mockResponse, data: streamMock.transform }); + return mockRequest; + }; + beforeEach(() => { + // @ts-ignore + connector.request = mockStream(); + }); + + it('the API call is successful with correct request parameters', async () => { + await connector.invokeStream(sampleOpenAiBody, connectorUsageCollector); + expect(mockRequest).toBeCalledTimes(1); + expect(mockRequest).toHaveBeenCalledWith( + { + url: 'http://localhost:1234/v1/chat/completions', + method: 'post', + responseSchema: StreamingResponseSchema, + responseType: 'stream', + data: JSON.stringify({ + ...sampleOpenAiBody, + stream: true, + }), + headers: { + Authorization: 'Bearer 123', + 'X-My-Custom-Header': 'foo', + 'content-type': 'application/json', + }, + }, + connectorUsageCollector + ); + }); + + it('signal is properly passed to streamApi', async () => { + const signal = jest.fn(); + await connector.invokeStream({ ...sampleOpenAiBody, signal }, connectorUsageCollector); + + expect(mockRequest).toHaveBeenCalledWith( + { + url: 'http://localhost:1234/v1/chat/completions', + method: 'post', + responseSchema: StreamingResponseSchema, + responseType: 'stream', + data: JSON.stringify({ + ...sampleOpenAiBody, + stream: true, + }), + headers: { + Authorization: 'Bearer 123', + 'X-My-Custom-Header': 'foo', + 'content-type': 'application/json', + }, + signal, + }, + connectorUsageCollector + ); + }); + + it('timeout is properly passed to streamApi', async () => { + const timeout = 180000; + await connector.invokeStream({ ...sampleOpenAiBody, timeout }, connectorUsageCollector); + + expect(mockRequest).toHaveBeenCalledWith( + { + url: 'http://localhost:1234/v1/chat/completions', + method: 'post', + responseSchema: StreamingResponseSchema, + responseType: 'stream', + data: JSON.stringify({ + ...sampleOpenAiBody, + stream: true, + }), + headers: { + Authorization: 'Bearer 123', + 'X-My-Custom-Header': 'foo', + 'content-type': 'application/json', + }, + timeout, + }, + connectorUsageCollector + ); + }); + + it('errors during API calls are properly handled', async () => { + // @ts-ignore + connector.request = mockError; + + await expect( + connector.invokeStream(sampleOpenAiBody, connectorUsageCollector) + ).rejects.toThrow('API Error'); + }); + + it('responds with a readable stream', async () => { + // @ts-ignore + connector.request = mockStream(); + const response = await connector.invokeStream(sampleOpenAiBody, connectorUsageCollector); + expect(response instanceof PassThrough).toEqual(true); + }); + }); + + describe('invokeAI', () => { + it('the API call is successful with correct parameters', async () => { + const response = await connector.invokeAI(sampleOpenAiBody, connectorUsageCollector); + expect(mockRequest).toBeCalledTimes(1); + expect(mockRequest).toHaveBeenCalledWith( + { + ...mockDefaults, + url: 'http://localhost:1234/v1/chat/completions', + data: JSON.stringify({ + ...sampleOpenAiBody, + stream: false, + model: DEFAULT_OTHER_OPENAI_MODEL, + }), + headers: { + Authorization: 'Bearer 123', + 'X-My-Custom-Header': 'foo', + 'content-type': 'application/json', + }, + }, + connectorUsageCollector + ); + expect(response.message).toEqual(mockResponseString); + expect(response.usage.total_tokens).toEqual(9); + }); + + it('signal is properly passed to runApi', async () => { + const signal = jest.fn(); + await connector.invokeAI({ ...sampleOpenAiBody, signal }, connectorUsageCollector); + + expect(mockRequest).toHaveBeenCalledWith( + { + ...mockDefaults, + url: 'http://localhost:1234/v1/chat/completions', + data: JSON.stringify({ + ...sampleOpenAiBody, + stream: false, + model: DEFAULT_OTHER_OPENAI_MODEL, + }), + headers: { + Authorization: 'Bearer 123', + 'X-My-Custom-Header': 'foo', + 'content-type': 'application/json', + }, + signal, + }, + connectorUsageCollector + ); + }); + + it('timeout is properly passed to runApi', async () => { + const timeout = 180000; + await connector.invokeAI({ ...sampleOpenAiBody, timeout }, connectorUsageCollector); + + expect(mockRequest).toHaveBeenCalledWith( + { + ...mockDefaults, + url: 'http://localhost:1234/v1/chat/completions', + data: JSON.stringify({ + ...sampleOpenAiBody, + stream: false, + model: DEFAULT_OTHER_OPENAI_MODEL, + }), + headers: { + Authorization: 'Bearer 123', + 'X-My-Custom-Header': 'foo', + 'content-type': 'application/json', + }, + timeout, + }, + connectorUsageCollector + ); + }); + + it('errors during API calls are properly handled', async () => { + // @ts-ignore + connector.request = mockError; + + await expect(connector.invokeAI(sampleOpenAiBody, connectorUsageCollector)).rejects.toThrow( + 'API Error' + ); + }); + }); + }); + describe('AzureAI', () => { const connector = new OpenAIConnector({ configurationUtilities: actionsConfigMock.create(), diff --git a/x-pack/plugins/task_manager/server/integration_tests/removed_types.test.ts b/x-pack/plugins/task_manager/server/integration_tests/removed_types.test.ts index 69bf717b95fc6..aeb182c4794e6 100644 --- a/x-pack/plugins/task_manager/server/integration_tests/removed_types.test.ts +++ b/x-pack/plugins/task_manager/server/integration_tests/removed_types.test.ts @@ -121,7 +121,15 @@ describe('unrecognized task types', () => { // so we want to wait that long to let it refresh await new Promise((r) => setTimeout(r, 5100)); - expect(errorLogSpy).not.toHaveBeenCalled(); + const errorLogCalls = errorLogSpy.mock.calls[0]; + + // if there are any error logs, none of them should be workload aggregator errors + if (errorLogCalls) { + // should be no workload aggregator errors + for (const elog of errorLogCalls) { + expect(elog).not.toMatch(/^\[WorkloadAggregator\]: Error: Unsupported task type/i); + } + } }); }); diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index 0de2cbd77db7b..0e5d4156d9760 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -73,6 +73,9 @@ }, "[OpenAI]": { "type": "long" + }, + "[Other]": { + "type": "long" } } }, diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index ea995a275449d..41d1b6ab8b3d1 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -320,8 +320,6 @@ "coloring.dynamicColoring.rangeType.label": "Type de valeur", "coloring.dynamicColoring.rangeType.number": "Numéro", "coloring.dynamicColoring.rangeType.percent": "Pourcent", - "console.autocomplete.addMethodMetaText": "méthode", - "console.autocomplete.fieldsFetchingAnnotation": "La récupération des champs est en cours", "console.autocompleteSuggestions.apiLabel": "API", "console.autocompleteSuggestions.endpointLabel": "point de terminaison", "console.autocompleteSuggestions.methodLabel": "méthode", @@ -362,10 +360,6 @@ "console.loadingError.title": "Impossible de charger la console", "console.notification.clearHistory": "Effacer l'historique", "console.notification.disableSavingToHistory": "Désactiver l'enregistrement", - "console.notification.error.couldNotSaveRequestTitle": "Impossible d'enregistrer la requête dans l'historique de la console.", - "console.notification.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.error.noRequestSelectedTitle": "Aucune requête sélectionnée. Sélectionnez une requête en positionnant le curseur dessus.", - "console.notification.error.unknownErrorTitle": "Erreur de requête inconnue", "console.pageHeading": "Console", "console.requestInProgressBadgeText": "Requête en cours", "console.requestOptions.autoIndentButtonLabel": "Appliquer les indentations", @@ -7274,9 +7268,6 @@ "sharedUXPackages.fileUpload.uploadCompleteButtonLabel": "Chargement terminé", "sharedUXPackages.fileUpload.uploadDoneToolTipContent": "Votre fichier a bien été chargé !", "sharedUXPackages.fileUpload.uploadingButtonLabel": "Chargement", - "sharedUXPackages.no_data_views.esqlButtonLabel": "Langue : ES|QL", - "sharedUXPackages.no_data_views.esqlDocsLink": "En savoir plus.", - "sharedUXPackages.no_data_views.esqlMessage": "Vous pouvez aussi rechercher vos données en utilisant directement ES|QL. {docsLink}", "sharedUXPackages.noDataConfig.addIntegrationsDescription": "Utilisez Elastic Agent pour collecter des données et créer des solutions Analytics.", "sharedUXPackages.noDataConfig.addIntegrationsTitle": "Ajouter des intégrations", "sharedUXPackages.noDataConfig.analytics": "Analyse", @@ -7298,8 +7289,6 @@ "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.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.noPermission.title": "Vous devez disposer d'une autorisation pour pouvoir créer des vues de données", - "sharedUXPackages.noDataViewsPrompt.nowCreate": "Créez à présent une vue de données.", "sharedUXPackages.noDataViewsPrompt.readDocumentation": "Lisez les documents", "sharedUXPackages.noDataViewsPrompt.youHaveData": "Vous avez des données dans 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é.", @@ -9387,6 +9376,94 @@ "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.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.chatActionsMenu.euiButtonIcon.menuLabel": "Menu", + "xpack.aiAssistant.chatActionsMenu.euiToolTip.moreActionsLabel": "Plus d'actions", + "xpack.aiAssistant.chatCollapsedItems.hideEvents": "Masquer {count} événements", + "xpack.aiAssistant.chatCollapsedItems.showEvents": "Montrer {count} événements", + "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.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", + "xpack.aiAssistant.chatHeader.actions.connector": "Connecteur", + "xpack.aiAssistant.chatHeader.actions.copyConversation": "Copier la conversation", + "xpack.aiAssistant.chatHeader.actions.knowledgeBase": "Gérer la base de connaissances", + "xpack.aiAssistant.chatHeader.actions.settings": "Réglages de l'assistant d'IA", + "xpack.aiAssistant.chatHeader.actions.title": "Actions", + "xpack.aiAssistant.chatHeader.editConversationInput": "Modifier la conversation", + "xpack.aiAssistant.chatHeader.euiButtonIcon.navigateToConversationsLabel": "Accéder aux conversations", + "xpack.aiAssistant.chatHeader.euiButtonIcon.toggleFlyoutModeLabel": "Afficher / Masquer le mode menu volant", + "xpack.aiAssistant.chatHeader.euiToolTip.flyoutModeLabel.dock": "Ancrer le chat", + "xpack.aiAssistant.chatHeader.euiToolTip.flyoutModeLabel.undock": "Désancrer le chat", + "xpack.aiAssistant.chatHeader.euiToolTip.navigateToConversationsLabel": "Accéder aux conversations", + "xpack.aiAssistant.chatPromptEditor.codeEditor.payloadEditorLabel": "payloadEditor", + "xpack.aiAssistant.chatPromptEditor.euiButtonIcon.submitLabel": "Envoyer", + "xpack.aiAssistant.chatTimeline.actions.copyMessage": "Copier le message", + "xpack.aiAssistant.chatTimeline.actions.copyMessageSuccessful": "Message copié", + "xpack.aiAssistant.chatTimeline.actions.editPrompt": "Modifier l'invite", + "xpack.aiAssistant.chatTimeline.actions.inspectPrompt": "Inspecter l'invite", + "xpack.aiAssistant.chatTimeline.messages.elasticAssistant.label": "Assistant d'Elastic", + "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.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", + "xpack.aiAssistant.disclaimer.disclaimerLabel": "Ce chat est soutenu par une intégration avec votre fournisseur LLM. Il arrive que les grands modèles de langage (LLM) présentent comme correctes des informations incorrectes. Elastic prend en charge la configuration ainsi que la connexion au fournisseur LLM et à votre base de connaissances, mais n'est pas responsable des réponses fournies par le LLM.", + "xpack.aiAssistant.emptyConversationTitle": "Nouvelle conversation", + "xpack.aiAssistant.errorSettingUpKnowledgeBase": "Impossible de configurer la base de connaissances", + "xpack.aiAssistant.errorUpdatingConversation": "Impossible de mettre à jour la conversation", + "xpack.aiAssistant.executedFunctionFailureEvent": "impossible d'exécuter la fonction {functionName}", + "xpack.aiAssistant.failedToGetStatus": "Échec de l'obtention du statut du modèle.", + "xpack.aiAssistant.failedToSetupKnowledgeBase": "Échec de la configuration de la base de connaissances.", + "xpack.aiAssistant.flyout.confirmDeleteButtonText": "Supprimer la conversation", + "xpack.aiAssistant.flyout.confirmDeleteConversationContent": "Cette action ne peut pas être annulée.", + "xpack.aiAssistant.flyout.confirmDeleteConversationTitle": "Supprimer cette conversation ?", + "xpack.aiAssistant.flyout.failedToDeleteConversation": "Impossible de supprimer la conversation", + "xpack.aiAssistant.functionListPopover.euiButtonIcon.selectAFunctionLabel": "Sélectionner la fonction", + "xpack.aiAssistant.functionListPopover.euiToolTip.clearFunction": "Effacer la fonction", + "xpack.aiAssistant.functionListPopover.euiToolTip.selectAFunctionLabel": "Sélectionner une fonction", + "xpack.aiAssistant.hideExpandConversationButton.hide": "Masquer les chats", + "xpack.aiAssistant.hideExpandConversationButton.show": "Afficher les chats", + "xpack.aiAssistant.incorrectLicense.body": "Une licence d'entreprise est requise pour utiliser l'assistant d'intelligence artificielle d'Elastic.", + "xpack.aiAssistant.incorrectLicense.manageLicense": "Gérer la licence", + "xpack.aiAssistant.incorrectLicense.subscriptionPlansButton": "Plans d'abonnement", + "xpack.aiAssistant.incorrectLicense.title": "Mettez votre licence à niveau", + "xpack.aiAssistant.initialSetupPanel.setupConnector.buttonLabel": "Configurer un connecteur GenAI", + "xpack.aiAssistant.initialSetupPanel.setupConnector.description2": "Commencez à travailler avec l'assistant AI Elastic en configurant un connecteur pour votre fournisseur d'IA. Le modèle doit prendre en charge les appels de fonction. Lorsque vous utilisez OpenAI ou Azure, nous vous recommandons d'utiliser GPT4.", + "xpack.aiAssistant.installingKb": "Configuration de la base de connaissances", + "xpack.aiAssistant.newChatButton": "Nouveau chat", + "xpack.aiAssistant.poweredByModel": "Alimenté par {model}", + "xpack.aiAssistant.prompt.functionList.filter": "Filtre", + "xpack.aiAssistant.prompt.functionList.functionList": "Liste de fonctions", + "xpack.aiAssistant.prompt.placeholder": "Envoyer un message à l'assistant", + "xpack.aiAssistant.promptEditorNaturalLanguage.euiSelectable.selectAnOptionLabel": "Sélectionner une option", + "xpack.aiAssistant.settingsPage.goToConnectorsButtonLabel": "Gérer les connecteurs", + "xpack.aiAssistant.setupKb": "Améliorez votre expérience en configurant la base de connaissances.", + "xpack.aiAssistant.simulatedFunctionCallingCalloutLabel": "L'appel de fonctions simulées est activé. Vous risquez de voir les performances se dégrader.", + "xpack.aiAssistant.suggestedFunctionEvent": "a demandé la fonction {functionName}", + "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.settingUpKnowledgeBaseLabel": "Configuration de la base de connaissances", + "xpack.aiAssistant.welcomeMessage.inspectErrorsButtonEmptyLabel": "Inspecter les problèmes", + "xpack.aiAssistant.welcomeMessage.issuesDescriptionListTitleLabel": "Problèmes", + "xpack.aiAssistant.welcomeMessage.knowledgeBaseSuccessfullyInstalledLabel": "La base de connaissances a été installée avec succès", + "xpack.aiAssistant.welcomeMessage.modelIsNotDeployedLabel": "Le modèle {modelName} n'est pas déployé", + "xpack.aiAssistant.welcomeMessage.modelIsNotFullyAllocatedLabel": "L'état d'allocation de {modelName} est {allocationState}", + "xpack.aiAssistant.welcomeMessage.modelIsNotStartedLabel": "L'état de déploiement de {modelName} est {deploymentState}", + "xpack.aiAssistant.welcomeMessage.retryButtonLabel": "Installer la base de connaissances", + "xpack.aiAssistant.welcomeMessage.trainedModelsLinkLabel": "Modèles entraînés", + "xpack.aiAssistant.welcomeMessage.weAreSettingUpTextLabel": "Nous configurons votre base de connaissances. Cette opération peut prendre quelques minutes. Vous pouvez continuer à utiliser l'Assistant lors de ce processus.", + "xpack.aiAssistant.welcomeMessageConnectors.connectorsErrorTextLabel": "Impossible de charger les connecteurs", + "xpack.aiAssistant.welcomeMessageConnectors.connectorsForbiddenTextLabel": "Vous n'avez pas les autorisations requises pour charger les connecteurs", + "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.columnSelectorAriaLabel": "Filtrer les colonnes", "xpack.aiops.analysis.columnSelectorNotEnoughColumnsSelected": "Au moins une colonne doit être sélectionnée.", @@ -32553,92 +32630,27 @@ "xpack.observabilityAiAssistant.app.starterPrompts.whatAreSlos.prompt": "Que signifie \"SLO\" ?", "xpack.observabilityAiAssistant.app.starterPrompts.whatAreSlos.title": "SLO", "xpack.observabilityAiAssistant.appTitle": "Assistant d'intelligence artificielle d'Observability", - "xpack.observabilityAiAssistant.askAssistantButton.buttonLabel": "Demander à l'assistant", - "xpack.observabilityAiAssistant.askAssistantButton.popoverContent": "Obtenez des informations relatives à vos données grâce à l'assistant d'Elastic", - "xpack.observabilityAiAssistant.askAssistantButton.popoverTitle": "Assistant d'IA pour Observability", - "xpack.observabilityAiAssistant.assistantSetup.title": "Bienvenue sur l'assistant d'intelligence artificielle d'Elastic", "xpack.observabilityAiAssistant.changesList.dotImpactHigh": "Élevé", "xpack.observabilityAiAssistant.changesList.dotImpactLow": "Bas", "xpack.observabilityAiAssistant.changesList.dotImpactMedium": "Moyenne", "xpack.observabilityAiAssistant.changesList.labelColumnTitle": "Étiquette", "xpack.observabilityAiAssistant.changesList.noChangesDetected": "Aucun changement détecté", "xpack.observabilityAiAssistant.changesList.trendColumnTitle": "Tendance", - "xpack.observabilityAiAssistant.chatActionsMenu.euiButtonIcon.menuLabel": "Menu", - "xpack.observabilityAiAssistant.chatActionsMenu.euiToolTip.moreActionsLabel": "Plus d'actions", - "xpack.observabilityAiAssistant.chatCollapsedItems.hideEvents": "Masquer {count} événements", - "xpack.observabilityAiAssistant.chatCollapsedItems.showEvents": "Montrer {count} événements", - "xpack.observabilityAiAssistant.chatCollapsedItems.toggleButtonLabel": "Afficher/masquer les éléments", "xpack.observabilityAiAssistant.chatCompletionError.conversationNotFoundError": "Conversation introuvable", "xpack.observabilityAiAssistant.chatCompletionError.internalServerError": "Une erreur s'est produite au niveau du serveur interne", "xpack.observabilityAiAssistant.chatCompletionError.tokenLimitReachedError": "Limite de token atteinte. La limite de token est {tokenLimit}, mais la conversation actuelle a {tokenCount} tokens.", - "xpack.observabilityAiAssistant.chatFlyout.euiButtonIcon.expandConversationListLabel": "Développer la liste des conversations", - "xpack.observabilityAiAssistant.chatFlyout.euiButtonIcon.newChatLabel": "Nouveau chat", - "xpack.observabilityAiAssistant.chatFlyout.euiFlyoutResizable.aiAssistantForObservabilityLabel": "Menu volant du chat de l'assistant d'IA pour Observability", - "xpack.observabilityAiAssistant.chatFlyout.euiToolTip.collapseConversationListLabel": "Réduire la liste des conversations", - "xpack.observabilityAiAssistant.chatFlyout.euiToolTip.expandConversationListLabel": "Développer la liste des conversations", - "xpack.observabilityAiAssistant.chatFlyout.euiToolTip.newChatLabel": "Nouveau chat", - "xpack.observabilityAiAssistant.chatHeader.actions.connector": "Connecteur", - "xpack.observabilityAiAssistant.chatHeader.actions.copyConversation": "Copier la conversation", - "xpack.observabilityAiAssistant.chatHeader.actions.knowledgeBase": "Gérer la base de connaissances", - "xpack.observabilityAiAssistant.chatHeader.actions.settings": "Réglages de l'assistant d'IA", - "xpack.observabilityAiAssistant.chatHeader.actions.title": "Actions", - "xpack.observabilityAiAssistant.chatHeader.editConversationInput": "Modifier la conversation", - "xpack.observabilityAiAssistant.chatHeader.euiButtonIcon.navigateToConversationsLabel": "Accéder aux conversations", - "xpack.observabilityAiAssistant.chatHeader.euiButtonIcon.toggleFlyoutModeLabel": "Afficher / Masquer le mode menu volant", - "xpack.observabilityAiAssistant.chatHeader.euiToolTip.flyoutModeLabel.dock": "Ancrer le chat", - "xpack.observabilityAiAssistant.chatHeader.euiToolTip.flyoutModeLabel.undock": "Désancrer le chat", - "xpack.observabilityAiAssistant.chatHeader.euiToolTip.navigateToConversationsLabel": "Accéder aux conversations", - "xpack.observabilityAiAssistant.chatPromptEditor.codeEditor.payloadEditorLabel": "payloadEditor", - "xpack.observabilityAiAssistant.chatPromptEditor.euiButtonIcon.submitLabel": "Envoyer", "xpack.observabilityAiAssistant.chatService.div.helloLabel": "Bonjour", - "xpack.observabilityAiAssistant.chatTimeline.actions.copyMessage": "Copier le message", - "xpack.observabilityAiAssistant.chatTimeline.actions.copyMessageSuccessful": "Message copié", - "xpack.observabilityAiAssistant.chatTimeline.actions.editPrompt": "Modifier l'invite", - "xpack.observabilityAiAssistant.chatTimeline.actions.inspectPrompt": "Inspecter l'invite", - "xpack.observabilityAiAssistant.chatTimeline.messages.elasticAssistant.label": "Assistant d'Elastic", - "xpack.observabilityAiAssistant.chatTimeline.messages.system.label": "Système", - "xpack.observabilityAiAssistant.chatTimeline.messages.user.label": "Vous", - "xpack.observabilityAiAssistant.checkingKbAvailability": "Vérification de la disponibilité de la base de connaissances", "xpack.observabilityAiAssistant.connectorSelector.connectorSelectLabel": "Connecteur :", "xpack.observabilityAiAssistant.connectorSelector.empty": "Aucun connecteur", "xpack.observabilityAiAssistant.connectorSelector.error": "Impossible de charger les connecteurs", - "xpack.observabilityAiAssistant.conversationList.deleteConversationIconLabel": "Supprimer", - "xpack.observabilityAiAssistant.conversationList.errorMessage": "Échec de chargement", - "xpack.observabilityAiAssistant.conversationList.noConversations": "Aucune conversation", - "xpack.observabilityAiAssistant.conversationList.title": "Précédemment", "xpack.observabilityAiAssistant.conversationsDeepLinkTitle": "Conversations", - "xpack.observabilityAiAssistant.conversationStartTitle": "a démarré une conversation", - "xpack.observabilityAiAssistant.couldNotFindConversationContent": "Impossible de trouver une conversation avec l'ID {conversationId}. Assurez-vous que la conversation existe et que vous y avez accès.", - "xpack.observabilityAiAssistant.couldNotFindConversationTitle": "Conversation introuvable", - "xpack.observabilityAiAssistant.disclaimer.disclaimerLabel": "Ce chat est soutenu par une intégration avec votre fournisseur LLM. Il arrive que les grands modèles de langage (LLM) présentent comme correctes des informations incorrectes. Elastic prend en charge la configuration ainsi que la connexion au fournisseur LLM et à votre base de connaissances, mais n'est pas responsable des réponses fournies par le LLM.", - "xpack.observabilityAiAssistant.emptyConversationTitle": "Nouvelle conversation", - "xpack.observabilityAiAssistant.errorSettingUpKnowledgeBase": "Impossible de configurer la base de connaissances", - "xpack.observabilityAiAssistant.errorUpdatingConversation": "Impossible de mettre à jour la conversation", - "xpack.observabilityAiAssistant.executedFunctionFailureEvent": "impossible d'exécuter la fonction {functionName}", "xpack.observabilityAiAssistant.experimentalTitle": "Version d'évaluation technique", "xpack.observabilityAiAssistant.experimentalTooltip": "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.observabilityAiAssistant.failedLoadingResponseText": "Échec de chargement de la réponse", - "xpack.observabilityAiAssistant.failedToGetStatus": "Échec de l'obtention du statut du modèle.", "xpack.observabilityAiAssistant.failedToLoadResponse": "Échec du chargement d'une réponse de l'assistant d'intelligence artificielle", - "xpack.observabilityAiAssistant.failedToSetupKnowledgeBase": "Échec de la configuration de la base de connaissances.", "xpack.observabilityAiAssistant.featureRegistry.featureName": "Assistant d'intelligence artificielle d'Observability", "xpack.observabilityAiAssistant.feedbackButtons.em.thanksForYourFeedbackLabel": "Merci pour vos retours", - "xpack.observabilityAiAssistant.flyout.confirmDeleteButtonText": "Supprimer la conversation", - "xpack.observabilityAiAssistant.flyout.confirmDeleteConversationContent": "Cette action ne peut pas être annulée.", - "xpack.observabilityAiAssistant.flyout.confirmDeleteConversationTitle": "Supprimer cette conversation ?", - "xpack.observabilityAiAssistant.flyout.failedToDeleteConversation": "Impossible de supprimer la conversation", "xpack.observabilityAiAssistant.functionCallLimitExceeded": "\n\nRemarque : l'Assistant a essayé d'appeler une fonction, même si la limite a été dépassée", - "xpack.observabilityAiAssistant.functionListPopover.euiButtonIcon.selectAFunctionLabel": "Sélectionner la fonction", - "xpack.observabilityAiAssistant.functionListPopover.euiToolTip.clearFunction": "Effacer la fonction", - "xpack.observabilityAiAssistant.functionListPopover.euiToolTip.selectAFunctionLabel": "Sélectionner une fonction", - "xpack.observabilityAiAssistant.hideExpandConversationButton.hide": "Masquer les chats", - "xpack.observabilityAiAssistant.hideExpandConversationButton.show": "Afficher les chats", - "xpack.observabilityAiAssistant.incorrectLicense.body": "Une licence d'entreprise est requise pour utiliser l'assistant d'intelligence artificielle d'Elastic.", - "xpack.observabilityAiAssistant.incorrectLicense.manageLicense": "Gérer la licence", - "xpack.observabilityAiAssistant.incorrectLicense.subscriptionPlansButton": "Plans d'abonnement", - "xpack.observabilityAiAssistant.incorrectLicense.title": "Mettez votre licence à niveau", - "xpack.observabilityAiAssistant.initialSetupPanel.setupConnector.buttonLabel": "Configurer un connecteur GenAI", - "xpack.observabilityAiAssistant.initialSetupPanel.setupConnector.description2": "Commencez à travailler avec l'assistant AI Elastic en configurant un connecteur pour votre fournisseur d'IA. Le modèle doit prendre en charge les appels de fonction. Lorsque vous utilisez OpenAI ou Azure, nous vous recommandons d'utiliser GPT4.", "xpack.observabilityAiAssistant.insight.actions": "Actions", "xpack.observabilityAiAssistant.insight.actions.connector": "Connecteur", "xpack.observabilityAiAssistant.insight.actions.editPrompt": "Modifier l'invite", @@ -32656,7 +32668,6 @@ "xpack.observabilityAiAssistant.insight.response.startChat": "Lancer le chat", "xpack.observabilityAiAssistant.insight.sendPromptEdit": "Envoyer l'invite", "xpack.observabilityAiAssistant.insightModifiedPrompt": "Cette information a été modifiée.", - "xpack.observabilityAiAssistant.installingKb": "Configuration de la base de connaissances", "xpack.observabilityAiAssistant.lensESQLFunction.displayChart": "Afficher le graphique", "xpack.observabilityAiAssistant.lensESQLFunction.displayTable": "Afficher le tableau", "xpack.observabilityAiAssistant.lensESQLFunction.edit": "Modifier la visualisation", @@ -32671,42 +32682,14 @@ "xpack.observabilityAiAssistant.missingCredentialsCallout.title": "Informations d'identification manquantes", "xpack.observabilityAiAssistant.navControl.initFailureErrorTitle": "Échec de l'initialisation de l'assistant d'IA d'Observability", "xpack.observabilityAiAssistant.navControl.openTheAIAssistantPopoverLabel": "Ouvrir l'assistant d'IA", - "xpack.observabilityAiAssistant.newChatButton": "Nouveau chat", - "xpack.observabilityAiAssistant.poweredByModel": "Alimenté par {model}", - "xpack.observabilityAiAssistant.prompt.functionList.filter": "Filtre", - "xpack.observabilityAiAssistant.prompt.functionList.functionList": "Liste de fonctions", - "xpack.observabilityAiAssistant.prompt.placeholder": "Envoyer un message à l'assistant", - "xpack.observabilityAiAssistant.promptEditorNaturalLanguage.euiSelectable.selectAnOptionLabel": "Sélectionner une option", "xpack.observabilityAiAssistant.regenerateResponseButtonLabel": "Régénérer", "xpack.observabilityAiAssistant.requiredConnectorField": "Connecteur obligatoire.", "xpack.observabilityAiAssistant.requiredMessageTextField": "Le message est requis.", "xpack.observabilityAiAssistant.resetDefaultPrompt": "Réinitialiser à la valeur par défaut", "xpack.observabilityAiAssistant.runThisQuery": "Afficher les résultats", - "xpack.observabilityAiAssistant.settingsPage.goToConnectorsButtonLabel": "Gérer les connecteurs", - "xpack.observabilityAiAssistant.setupKb": "Améliorez votre expérience en configurant la base de connaissances.", - "xpack.observabilityAiAssistant.simulatedFunctionCallingCalloutLabel": "L'appel de fonctions simulées est activé. Vous risquez de voir les performances se dégrader.", "xpack.observabilityAiAssistant.stopGeneratingButtonLabel": "Arrêter la génération", - "xpack.observabilityAiAssistant.suggestedFunctionEvent": "a demandé la fonction {functionName}", - "xpack.observabilityAiAssistant.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.observabilityAiAssistant.tokenLimitError": "La conversation dépasse la limite de token. La limite de token maximale est **{tokenLimit}**, mais la conversation a **{tokenCount}** tokens. Veuillez démarrer une nouvelle conversation pour continuer.", - "xpack.observabilityAiAssistant.userExecutedFunctionEvent": "a exécuté la fonction {functionName}", - "xpack.observabilityAiAssistant.userSuggestedFunctionEvent": "a demandé la fonction {functionName}", "xpack.observabilityAiAssistant.visualizeThisQuery": "Visualiser cette requête", - "xpack.observabilityAiAssistant.welcomeMessage.div.checkTrainedModelsToLabel": " {retryInstallingLink} ou vérifiez {trainedModelsLink} pour vous assurer que {modelName} est déployé et en cours d'exécution.", - "xpack.observabilityAiAssistant.welcomeMessage.div.settingUpKnowledgeBaseLabel": "Configuration de la base de connaissances", - "xpack.observabilityAiAssistant.welcomeMessage.inspectErrorsButtonEmptyLabel": "Inspecter les problèmes", - "xpack.observabilityAiAssistant.welcomeMessage.issuesDescriptionListTitleLabel": "Problèmes", - "xpack.observabilityAiAssistant.welcomeMessage.knowledgeBaseSuccessfullyInstalledLabel": "La base de connaissances a été installée avec succès", - "xpack.observabilityAiAssistant.welcomeMessage.modelIsNotDeployedLabel": "Le modèle {modelName} n'est pas déployé", - "xpack.observabilityAiAssistant.welcomeMessage.modelIsNotFullyAllocatedLabel": "L'état d'allocation de {modelName} est {allocationState}", - "xpack.observabilityAiAssistant.welcomeMessage.modelIsNotStartedLabel": "L'état de déploiement de {modelName} est {deploymentState}", - "xpack.observabilityAiAssistant.welcomeMessage.retryButtonLabel": "Installer la base de connaissances", - "xpack.observabilityAiAssistant.welcomeMessage.trainedModelsLinkLabel": "Modèles entraînés", - "xpack.observabilityAiAssistant.welcomeMessage.weAreSettingUpTextLabel": "Nous configurons votre base de connaissances. Cette opération peut prendre quelques minutes. Vous pouvez continuer à utiliser l'Assistant lors de ce processus.", - "xpack.observabilityAiAssistant.welcomeMessageConnectors.connectorsErrorTextLabel": "Impossible de charger les connecteurs", - "xpack.observabilityAiAssistant.welcomeMessageConnectors.connectorsForbiddenTextLabel": "Vous n'avez pas les autorisations requises pour charger les connecteurs", - "xpack.observabilityAiAssistant.welcomeMessageKnowledgeBase.yourKnowledgeBaseIsNotSetUpCorrectlyLabel": "Votre base de connaissances n'a pas été configurée.", - "xpack.observabilityAiAssistant.welcomeMessageKnowledgeBaseSetupErrorPanel.retryInstallingLinkLabel": "Réessayer l'installation", "xpack.observabilityAiAssistantManagement.apmSettings.save.error": "Une erreur s'est produite lors de l'enregistrement des paramètres", "xpack.observabilityAiAssistantManagement.app.description": "Gérer l'Assistant d'IA pour Observability.", "xpack.observabilityAiAssistantManagement.app.title": "Assistant d'IA pour Observability", @@ -36140,8 +36123,6 @@ "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.customThreatQueryFieldRequiredEmptyError": "Toutes les correspondances requièrent un champ et un champ d'index des menaces.", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.customThreatQueryFieldRequiredError": "Au moins une correspondance d'indicateur est requise.", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.dataViewSelectorFieldRequired": "Veuillez sélectionner une vue des données ou un modèle d'index disponible.", - "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.enableThresholdSuppressionForFieldsLabel": "Supprimer les alertes par champs sélectionnés : {fieldsString} (version d'évaluation technique)", - "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.enableThresholdSuppressionLabel": "Supprimer les alertes (version d'évaluation technique)", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.EqlQueryBarLabel": "Requête EQL", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.eqlQueryFieldRequiredError": "Une requête EQL est requise.", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.eqlSequenceSuppressionDisableText": "La suppression n'est pas prise en charge pour les requêtes de séquence EQL.", @@ -39624,18 +39605,13 @@ "xpack.securitySolution.notes.management.createdByColumnTitle": "Créé par", "xpack.securitySolution.notes.management.createdColumnTitle": "Créé", "xpack.securitySolution.notes.management.deleteAction": "Supprimer", - "xpack.securitySolution.notes.management.deleteDescription": "Supprimer cette note", "xpack.securitySolution.notes.management.deleteNotesCancel": "Annuler", "xpack.securitySolution.notes.management.deleteNotesConfirm": "Voulez-vous vraiment supprimer {selectedNotes} {selectedNotes, plural, one {note} other {notes}} ?", - "xpack.securitySolution.notes.management.deleteNotesModalTitle": "Supprimer les notes ?", "xpack.securitySolution.notes.management.deleteSelected": "Supprimer les notes sélectionnées", - "xpack.securitySolution.notes.management.eventIdColumnTitle": "Afficher le document", "xpack.securitySolution.notes.management.noteContentColumnTitle": "Contenu de la note", "xpack.securitySolution.notes.management.openTimeline": "Ouvrir la chronologie", "xpack.securitySolution.notes.management.refresh": "Actualiser", "xpack.securitySolution.notes.management.tableError": "Impossible de charger les notes", - "xpack.securitySolution.notes.management.timelineColumnTitle": "Chronologie", - "xpack.securitySolution.notes.management.viewEventInTimeline": "Afficher l'événement dans la chronologie", "xpack.securitySolution.notes.noteLabel": "Note", "xpack.securitySolution.notes.notesTitle": "Notes", "xpack.securitySolution.notes.search.FilterByUserOrNotePlaceholder": "Filtre par utilisateur ou note", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 2ec8bc11bc0c8..9361689702bb4 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -320,8 +320,6 @@ "coloring.dynamicColoring.rangeType.label": "値型", "coloring.dynamicColoring.rangeType.number": "Number", "coloring.dynamicColoring.rangeType.percent": "割合(%)", - "console.autocomplete.addMethodMetaText": "メソド", - "console.autocomplete.fieldsFetchingAnnotation": "フィールドの取得を実行しています", "console.autocompleteSuggestions.apiLabel": "API", "console.autocompleteSuggestions.endpointLabel": "エンドポイント", "console.autocompleteSuggestions.methodLabel": "メソド", @@ -362,10 +360,6 @@ "console.loadingError.title": "コンソールを読み込めません", "console.notification.clearHistory": "履歴を消去", "console.notification.disableSavingToHistory": "保存を無効にする", - "console.notification.error.couldNotSaveRequestTitle": "リクエストをコンソール履歴に保存できませんでした。", - "console.notification.error.historyQuotaReachedMessage": "リクエスト履歴が満杯です。コンソール履歴を消去するか、新しいリクエストの保存を無効にしてください。", - "console.notification.error.noRequestSelectedTitle": "リクエストを選択していません。リクエストの中にカーソルを置いて選択します。", - "console.notification.error.unknownErrorTitle": "不明なリクエストエラー", "console.pageHeading": "コンソール", "console.requestInProgressBadgeText": "リクエストが進行中", "console.requestOptions.autoIndentButtonLabel": "インデントを適用", @@ -7028,9 +7022,6 @@ "sharedUXPackages.fileUpload.uploadCompleteButtonLabel": "アップロード完了", "sharedUXPackages.fileUpload.uploadDoneToolTipContent": "ファイルは正常にアップロードされました。", "sharedUXPackages.fileUpload.uploadingButtonLabel": "アップロード中", - "sharedUXPackages.no_data_views.esqlButtonLabel": "言語:ES|QL", - "sharedUXPackages.no_data_views.esqlDocsLink": "詳細情報", - "sharedUXPackages.no_data_views.esqlMessage": "あるいは、直接ES|QLを使用してデータをクエリできます。{docsLink}", "sharedUXPackages.noDataConfig.addIntegrationsDescription": "Elasticエージェントを使用して、データを収集し、分析ソリューションを構築します。", "sharedUXPackages.noDataConfig.addIntegrationsTitle": "統合の追加", "sharedUXPackages.noDataConfig.analytics": "分析", @@ -7052,8 +7043,6 @@ "sharedUXPackages.noDataViewsPrompt.dataViewExplanation": "データビューは、探索するElasticsearchデータを特定します。昨日からのログデータ、ログデータを含むすべてのインデックスなど、1つ以上のデータストリーム、インデックス、インデックスエイリアスをデータビューで参照できます。", "sharedUXPackages.noDataViewsPrompt.learnMore": "詳細について", "sharedUXPackages.noDataViewsPrompt.noPermission.dataViewExplanation": "データビューは、探索するElasticsearchデータを特定します。データビューを作成するには、必要な権限を管理者に依頼してください。", - "sharedUXPackages.noDataViewsPrompt.noPermission.title": "データビューを作成するための権限が必要です。", - "sharedUXPackages.noDataViewsPrompt.nowCreate": "ここでデータビューを作成します。", "sharedUXPackages.noDataViewsPrompt.readDocumentation": "ドキュメントを読む", "sharedUXPackages.noDataViewsPrompt.youHaveData": "Elasticsearchにデータがあります。", "sharedUXPackages.prompt.errors.notFound.body": "申し訳ございません。お探しのページは見つかりませんでした。削除または名前変更されたか、そもそも存在していなかった可能性があります。", @@ -9141,6 +9130,94 @@ "xpack.actions.serverSideErrors.unavailableLicenseInformationErrorMessage": "グラフを利用できません。現在ライセンス情報が利用できません。", "xpack.actions.subActionsFramework.urlValidationError": "URLの検証エラー:{message}", "xpack.actions.urlAllowedHostsConfigurationError": "ターゲット{field}「{value}」はKibana構成xpack.actions.allowedHostsに追加されていません", + "xpack.aiAssistant.askAssistantButton.buttonLabel": "アシスタントに聞く", + "xpack.aiAssistant.askAssistantButton.popoverContent": "Elastic Assistantでデータに関するインサイトを得ましょう", + "xpack.aiAssistant.assistantSetup.title": "Elastic AI Assistantへようこそ", + "xpack.aiAssistant.chatActionsMenu.euiButtonIcon.menuLabel": "メニュー", + "xpack.aiAssistant.chatActionsMenu.euiToolTip.moreActionsLabel": "さらにアクションを表示", + "xpack.aiAssistant.chatCollapsedItems.hideEvents": "{count}件のイベントを非表示", + "xpack.aiAssistant.chatCollapsedItems.showEvents": "{count}件のイベントを表示", + "xpack.aiAssistant.chatCollapsedItems.toggleButtonLabel": "アイテムを表示/非表示", + "xpack.aiAssistant.chatFlyout.euiButtonIcon.expandConversationListLabel": "会話リストを展開", + "xpack.aiAssistant.chatFlyout.euiButtonIcon.newChatLabel": "新しいチャット", + "xpack.aiAssistant.chatFlyout.euiToolTip.collapseConversationListLabel": "会話リストを折りたたむ", + "xpack.aiAssistant.chatFlyout.euiToolTip.expandConversationListLabel": "会話リストを展開", + "xpack.aiAssistant.chatFlyout.euiToolTip.newChatLabel": "新しいチャット", + "xpack.aiAssistant.chatHeader.actions.connector": "コネクター", + "xpack.aiAssistant.chatHeader.actions.copyConversation": "会話をコピー", + "xpack.aiAssistant.chatHeader.actions.knowledgeBase": "ナレッジベースを管理", + "xpack.aiAssistant.chatHeader.actions.settings": "AI Assistant設定", + "xpack.aiAssistant.chatHeader.actions.title": "アクション", + "xpack.aiAssistant.chatHeader.editConversationInput": "会話を編集", + "xpack.aiAssistant.chatHeader.euiButtonIcon.navigateToConversationsLabel": "会話に移動", + "xpack.aiAssistant.chatHeader.euiButtonIcon.toggleFlyoutModeLabel": "フライアウトモードを切り替え", + "xpack.aiAssistant.chatHeader.euiToolTip.flyoutModeLabel.dock": "チャットを固定", + "xpack.aiAssistant.chatHeader.euiToolTip.flyoutModeLabel.undock": "チャットを固定解除", + "xpack.aiAssistant.chatHeader.euiToolTip.navigateToConversationsLabel": "会話に移動", + "xpack.aiAssistant.chatPromptEditor.codeEditor.payloadEditorLabel": "payloadEditor", + "xpack.aiAssistant.chatPromptEditor.euiButtonIcon.submitLabel": "送信", + "xpack.aiAssistant.chatTimeline.actions.copyMessage": "メッセージをコピー", + "xpack.aiAssistant.chatTimeline.actions.copyMessageSuccessful": "コピーされたメッセージ", + "xpack.aiAssistant.chatTimeline.actions.editPrompt": "プロンプトを編集", + "xpack.aiAssistant.chatTimeline.actions.inspectPrompt": "プロンプトを検査", + "xpack.aiAssistant.chatTimeline.messages.elasticAssistant.label": "Elastic Assistant", + "xpack.aiAssistant.chatTimeline.messages.system.label": "システム", + "xpack.aiAssistant.chatTimeline.messages.user.label": "あなた", + "xpack.aiAssistant.checkingKbAvailability": "ナレッジベースの利用可能性を確認中", + "xpack.aiAssistant.conversationStartTitle": "会話を開始しました", + "xpack.aiAssistant.couldNotFindConversationContent": "id {conversationId}の会話が見つかりませんでした。会話が存在し、それにアクセスできることを確認してください。", + "xpack.aiAssistant.couldNotFindConversationTitle": "会話が見つかりません", + "xpack.aiAssistant.disclaimer.disclaimerLabel": "このチャットは、LLMプロバイダーとの統合によって提供されています。LLMは、正しくない情報を正しい情報であるかのように表示する場合があることが知られています。Elasticは、構成やLLMプロバイダーへの接続、お客様のナレッジベースへの接続はサポートしますが、LLMの応答については責任を負いません。", + "xpack.aiAssistant.emptyConversationTitle": "新しい会話", + "xpack.aiAssistant.errorSettingUpKnowledgeBase": "ナレッジベースをセットアップできませんでした", + "xpack.aiAssistant.errorUpdatingConversation": "会話を更新できませんでした", + "xpack.aiAssistant.executedFunctionFailureEvent": "関数{functionName}の実行に失敗しました", + "xpack.aiAssistant.failedToGetStatus": "モデルステータスを取得できませんでした。", + "xpack.aiAssistant.failedToSetupKnowledgeBase": "ナレッジベースをセットアップできませんでした。", + "xpack.aiAssistant.flyout.confirmDeleteButtonText": "会話を削除", + "xpack.aiAssistant.flyout.confirmDeleteConversationContent": "この操作は元に戻すことができません。", + "xpack.aiAssistant.flyout.confirmDeleteConversationTitle": "この会話を削除しますか?", + "xpack.aiAssistant.flyout.failedToDeleteConversation": "会話を削除できませんでした", + "xpack.aiAssistant.functionListPopover.euiButtonIcon.selectAFunctionLabel": "関数を選択", + "xpack.aiAssistant.functionListPopover.euiToolTip.clearFunction": "関数を消去", + "xpack.aiAssistant.functionListPopover.euiToolTip.selectAFunctionLabel": "関数を選択", + "xpack.aiAssistant.hideExpandConversationButton.hide": "チャットを非表示", + "xpack.aiAssistant.hideExpandConversationButton.show": "チャットを表示", + "xpack.aiAssistant.incorrectLicense.body": "Elastic AI Assistantを使用するにはEnterpriseライセンスが必要です。", + "xpack.aiAssistant.incorrectLicense.manageLicense": "ライセンスの管理", + "xpack.aiAssistant.incorrectLicense.subscriptionPlansButton": "サブスクリプションオプション", + "xpack.aiAssistant.incorrectLicense.title": "ライセンスをアップグレード", + "xpack.aiAssistant.initialSetupPanel.setupConnector.buttonLabel": "GenAIコネクターをセットアップ", + "xpack.aiAssistant.initialSetupPanel.setupConnector.description2": "Elastic AI Assistantの使用を開始するには、AIプロバイダーのコネクターを設定します。モデルは関数呼び出しをサポートしている必要があります。OpenAIまたはAzureを使用するときには、GPT4を使用することをお勧めします。", + "xpack.aiAssistant.installingKb": "ナレッジベースをセットアップ中", + "xpack.aiAssistant.newChatButton": "新しいチャット", + "xpack.aiAssistant.poweredByModel": "{model}で駆動", + "xpack.aiAssistant.prompt.functionList.filter": "フィルター", + "xpack.aiAssistant.prompt.functionList.functionList": "関数リスト", + "xpack.aiAssistant.prompt.placeholder": "アシスタントにメッセージを送信", + "xpack.aiAssistant.promptEditorNaturalLanguage.euiSelectable.selectAnOptionLabel": "オプションを選択", + "xpack.aiAssistant.settingsPage.goToConnectorsButtonLabel": "コネクターを管理", + "xpack.aiAssistant.setupKb": "ナレッジベースを設定することで、エクスペリエンスが改善されます。", + "xpack.aiAssistant.simulatedFunctionCallingCalloutLabel": "シミュレートされた関数呼び出しが有効です。パフォーマンスが劣化する場合があります。", + "xpack.aiAssistant.suggestedFunctionEvent": "関数{functionName}を要求しました", + "xpack.aiAssistant.technicalPreviewBadgeDescription": "関数呼び出し(根本原因分析やデータの視覚化など)を使用する際に、より一貫性のあるエクスペリエンスを実現するために、GPT4が必要です。GPT3.5は、エラーの説明などのシンプルなワークフローの一部や、頻繁な関数呼び出しの使用が必要とされないKibana内のエクスペリエンスなどのChatGPTで機能します。", + "xpack.aiAssistant.userExecutedFunctionEvent": "関数{functionName}を実行しました", + "xpack.aiAssistant.userSuggestedFunctionEvent": "関数{functionName}を要求しました", + "xpack.aiAssistant.welcomeMessage.div.checkTrainedModelsToLabel": " {retryInstallingLink}か、{trainedModelsLink}を確認して、{modelName}がデプロイされ、実行中であることを確かめてください。", + "xpack.aiAssistant.welcomeMessage.div.settingUpKnowledgeBaseLabel": "ナレッジベースをセットアップ中", + "xpack.aiAssistant.welcomeMessage.inspectErrorsButtonEmptyLabel": "問題を検査", + "xpack.aiAssistant.welcomeMessage.issuesDescriptionListTitleLabel": "問題", + "xpack.aiAssistant.welcomeMessage.knowledgeBaseSuccessfullyInstalledLabel": "ナレッジベースは正常にインストールされました", + "xpack.aiAssistant.welcomeMessage.modelIsNotDeployedLabel": "モデル\"{modelName}\"はデプロイされていません", + "xpack.aiAssistant.welcomeMessage.modelIsNotFullyAllocatedLabel": "\"{modelName}\"の割り当て状態は{allocationState}です", + "xpack.aiAssistant.welcomeMessage.modelIsNotStartedLabel": "\"{modelName}\"のデプロイ状態は{deploymentState}です", + "xpack.aiAssistant.welcomeMessage.retryButtonLabel": "ナレッジベースをインストール", + "xpack.aiAssistant.welcomeMessage.trainedModelsLinkLabel": "学習済みモデル", + "xpack.aiAssistant.welcomeMessage.weAreSettingUpTextLabel": "ナレッジベースをセットアップしています。これには数分かかる場合があります。この処理の実行中には、アシスタントを使用し続けることができます。", + "xpack.aiAssistant.welcomeMessageConnectors.connectorsErrorTextLabel": "コネクターを読み込めませんでした", + "xpack.aiAssistant.welcomeMessageConnectors.connectorsForbiddenTextLabel": "コネクターを取得するために必要な権限が不足しています", + "xpack.aiAssistant.welcomeMessageKnowledgeBase.yourKnowledgeBaseIsNotSetUpCorrectlyLabel": "ナレッジベースはセットアップされていません。", + "xpack.aiAssistant.welcomeMessageKnowledgeBaseSetupErrorPanel.retryInstallingLinkLabel": "インストールを再試行", "xpack.aiops.actions.openChangePointInMlAppName": "AIOps Labsで開く", "xpack.aiops.analysis.columnSelectorAriaLabel": "列のフィルタリング", "xpack.aiops.analysis.columnSelectorNotEnoughColumnsSelected": "1つ以上の列を選択する必要があります。", @@ -32300,92 +32377,27 @@ "xpack.observabilityAiAssistant.app.starterPrompts.whatAreSlos.prompt": "SLOとは何ですか?", "xpack.observabilityAiAssistant.app.starterPrompts.whatAreSlos.title": "SLO", "xpack.observabilityAiAssistant.appTitle": "オブザーバビリティAI Assistant", - "xpack.observabilityAiAssistant.askAssistantButton.buttonLabel": "アシスタントに聞く", - "xpack.observabilityAiAssistant.askAssistantButton.popoverContent": "Elastic Assistantでデータに関するインサイトを得ましょう", - "xpack.observabilityAiAssistant.askAssistantButton.popoverTitle": "AI Assistant for Observability", - "xpack.observabilityAiAssistant.assistantSetup.title": "Elastic AI Assistantへようこそ", "xpack.observabilityAiAssistant.changesList.dotImpactHigh": "高", "xpack.observabilityAiAssistant.changesList.dotImpactLow": "低", "xpack.observabilityAiAssistant.changesList.dotImpactMedium": "中", "xpack.observabilityAiAssistant.changesList.labelColumnTitle": "ラベル", "xpack.observabilityAiAssistant.changesList.noChangesDetected": "変更が検出されません", "xpack.observabilityAiAssistant.changesList.trendColumnTitle": "傾向", - "xpack.observabilityAiAssistant.chatActionsMenu.euiButtonIcon.menuLabel": "メニュー", - "xpack.observabilityAiAssistant.chatActionsMenu.euiToolTip.moreActionsLabel": "さらにアクションを表示", - "xpack.observabilityAiAssistant.chatCollapsedItems.hideEvents": "{count}件のイベントを非表示", - "xpack.observabilityAiAssistant.chatCollapsedItems.showEvents": "{count}件のイベントを表示", - "xpack.observabilityAiAssistant.chatCollapsedItems.toggleButtonLabel": "アイテムを表示/非表示", "xpack.observabilityAiAssistant.chatCompletionError.conversationNotFoundError": "会話が見つかりません", "xpack.observabilityAiAssistant.chatCompletionError.internalServerError": "内部サーバーエラーが発生しました", "xpack.observabilityAiAssistant.chatCompletionError.tokenLimitReachedError": "トークンの上限に達しました。トークンの上限は{tokenLimit}ですが、現在の会話には{tokenCount}個のトークンがあります。", - "xpack.observabilityAiAssistant.chatFlyout.euiButtonIcon.expandConversationListLabel": "会話リストを展開", - "xpack.observabilityAiAssistant.chatFlyout.euiButtonIcon.newChatLabel": "新しいチャット", - "xpack.observabilityAiAssistant.chatFlyout.euiFlyoutResizable.aiAssistantForObservabilityLabel": "AI assistant for Observabilityチャットフライアウト", - "xpack.observabilityAiAssistant.chatFlyout.euiToolTip.collapseConversationListLabel": "会話リストを折りたたむ", - "xpack.observabilityAiAssistant.chatFlyout.euiToolTip.expandConversationListLabel": "会話リストを展開", - "xpack.observabilityAiAssistant.chatFlyout.euiToolTip.newChatLabel": "新しいチャット", - "xpack.observabilityAiAssistant.chatHeader.actions.connector": "コネクター", - "xpack.observabilityAiAssistant.chatHeader.actions.copyConversation": "会話をコピー", - "xpack.observabilityAiAssistant.chatHeader.actions.knowledgeBase": "ナレッジベースを管理", - "xpack.observabilityAiAssistant.chatHeader.actions.settings": "AI Assistant設定", - "xpack.observabilityAiAssistant.chatHeader.actions.title": "アクション", - "xpack.observabilityAiAssistant.chatHeader.editConversationInput": "会話を編集", - "xpack.observabilityAiAssistant.chatHeader.euiButtonIcon.navigateToConversationsLabel": "会話に移動", - "xpack.observabilityAiAssistant.chatHeader.euiButtonIcon.toggleFlyoutModeLabel": "フライアウトモードを切り替え", - "xpack.observabilityAiAssistant.chatHeader.euiToolTip.flyoutModeLabel.dock": "チャットを固定", - "xpack.observabilityAiAssistant.chatHeader.euiToolTip.flyoutModeLabel.undock": "チャットを固定解除", - "xpack.observabilityAiAssistant.chatHeader.euiToolTip.navigateToConversationsLabel": "会話に移動", - "xpack.observabilityAiAssistant.chatPromptEditor.codeEditor.payloadEditorLabel": "payloadEditor", - "xpack.observabilityAiAssistant.chatPromptEditor.euiButtonIcon.submitLabel": "送信", "xpack.observabilityAiAssistant.chatService.div.helloLabel": "こんにちは", - "xpack.observabilityAiAssistant.chatTimeline.actions.copyMessage": "メッセージをコピー", - "xpack.observabilityAiAssistant.chatTimeline.actions.copyMessageSuccessful": "コピーされたメッセージ", - "xpack.observabilityAiAssistant.chatTimeline.actions.editPrompt": "プロンプトを編集", - "xpack.observabilityAiAssistant.chatTimeline.actions.inspectPrompt": "プロンプトを検査", - "xpack.observabilityAiAssistant.chatTimeline.messages.elasticAssistant.label": "Elastic Assistant", - "xpack.observabilityAiAssistant.chatTimeline.messages.system.label": "システム", - "xpack.observabilityAiAssistant.chatTimeline.messages.user.label": "あなた", - "xpack.observabilityAiAssistant.checkingKbAvailability": "ナレッジベースの利用可能性を確認中", "xpack.observabilityAiAssistant.connectorSelector.connectorSelectLabel": "コネクター:", "xpack.observabilityAiAssistant.connectorSelector.empty": "コネクターなし", "xpack.observabilityAiAssistant.connectorSelector.error": "コネクターを読み込めませんでした", - "xpack.observabilityAiAssistant.conversationList.deleteConversationIconLabel": "削除", - "xpack.observabilityAiAssistant.conversationList.errorMessage": "の読み込みに失敗しました", - "xpack.observabilityAiAssistant.conversationList.noConversations": "会話なし", - "xpack.observabilityAiAssistant.conversationList.title": "以前", "xpack.observabilityAiAssistant.conversationsDeepLinkTitle": "会話", - "xpack.observabilityAiAssistant.conversationStartTitle": "会話を開始しました", - "xpack.observabilityAiAssistant.couldNotFindConversationContent": "id {conversationId}の会話が見つかりませんでした。会話が存在し、それにアクセスできることを確認してください。", - "xpack.observabilityAiAssistant.couldNotFindConversationTitle": "会話が見つかりません", - "xpack.observabilityAiAssistant.disclaimer.disclaimerLabel": "このチャットは、LLMプロバイダーとの統合によって提供されています。LLMは、正しくない情報を正しい情報であるかのように表示する場合があることが知られています。Elasticは、構成やLLMプロバイダーへの接続、お客様のナレッジベースへの接続はサポートしますが、LLMの応答については責任を負いません。", - "xpack.observabilityAiAssistant.emptyConversationTitle": "新しい会話", - "xpack.observabilityAiAssistant.errorSettingUpKnowledgeBase": "ナレッジベースをセットアップできませんでした", - "xpack.observabilityAiAssistant.errorUpdatingConversation": "会話を更新できませんでした", - "xpack.observabilityAiAssistant.executedFunctionFailureEvent": "関数{functionName}の実行に失敗しました", "xpack.observabilityAiAssistant.experimentalTitle": "テクニカルプレビュー", "xpack.observabilityAiAssistant.experimentalTooltip": "この機能はテクニカルプレビュー中であり、将来のリリースでは変更されたり完全に削除されたりする場合があります。Elasticはすべての問題の修正に努めますが、テクニカルプレビュー中の機能には正式なGA機能のサポートSLAが適用されません。", "xpack.observabilityAiAssistant.failedLoadingResponseText": "応答の読み込みに失敗しました", - "xpack.observabilityAiAssistant.failedToGetStatus": "モデルステータスを取得できませんでした。", "xpack.observabilityAiAssistant.failedToLoadResponse": "AIアシスタントからの応答を読み込めませんでした", - "xpack.observabilityAiAssistant.failedToSetupKnowledgeBase": "ナレッジベースをセットアップできませんでした。", "xpack.observabilityAiAssistant.featureRegistry.featureName": "オブザーバビリティAI Assistant", "xpack.observabilityAiAssistant.feedbackButtons.em.thanksForYourFeedbackLabel": "フィードバックをご提供いただき、ありがとうございました。", - "xpack.observabilityAiAssistant.flyout.confirmDeleteButtonText": "会話を削除", - "xpack.observabilityAiAssistant.flyout.confirmDeleteConversationContent": "この操作は元に戻すことができません。", - "xpack.observabilityAiAssistant.flyout.confirmDeleteConversationTitle": "この会話を削除しますか?", - "xpack.observabilityAiAssistant.flyout.failedToDeleteConversation": "会話を削除できませんでした", "xpack.observabilityAiAssistant.functionCallLimitExceeded": "\n\n注:Assistantは、上限を超過しても、関数を呼び出そうとします。", - "xpack.observabilityAiAssistant.functionListPopover.euiButtonIcon.selectAFunctionLabel": "関数を選択", - "xpack.observabilityAiAssistant.functionListPopover.euiToolTip.clearFunction": "関数を消去", - "xpack.observabilityAiAssistant.functionListPopover.euiToolTip.selectAFunctionLabel": "関数を選択", - "xpack.observabilityAiAssistant.hideExpandConversationButton.hide": "チャットを非表示", - "xpack.observabilityAiAssistant.hideExpandConversationButton.show": "チャットを表示", - "xpack.observabilityAiAssistant.incorrectLicense.body": "Elastic AI Assistantを使用するにはEnterpriseライセンスが必要です。", - "xpack.observabilityAiAssistant.incorrectLicense.manageLicense": "ライセンスの管理", - "xpack.observabilityAiAssistant.incorrectLicense.subscriptionPlansButton": "サブスクリプションオプション", - "xpack.observabilityAiAssistant.incorrectLicense.title": "ライセンスをアップグレード", - "xpack.observabilityAiAssistant.initialSetupPanel.setupConnector.buttonLabel": "GenAIコネクターをセットアップ", - "xpack.observabilityAiAssistant.initialSetupPanel.setupConnector.description2": "Elastic AI Assistantの使用を開始するには、AIプロバイダーのコネクターを設定します。モデルは関数呼び出しをサポートしている必要があります。OpenAIまたはAzureを使用するときには、GPT4を使用することをお勧めします。", "xpack.observabilityAiAssistant.insight.actions": "アクション", "xpack.observabilityAiAssistant.insight.actions.connector": "コネクター", "xpack.observabilityAiAssistant.insight.actions.editPrompt": "プロンプトを編集", @@ -32403,7 +32415,6 @@ "xpack.observabilityAiAssistant.insight.response.startChat": "チャットを開始", "xpack.observabilityAiAssistant.insight.sendPromptEdit": "プロンプトを送信", "xpack.observabilityAiAssistant.insightModifiedPrompt": "このインサイトは修正されました。", - "xpack.observabilityAiAssistant.installingKb": "ナレッジベースをセットアップ中", "xpack.observabilityAiAssistant.lensESQLFunction.displayChart": "グラフを表示", "xpack.observabilityAiAssistant.lensESQLFunction.displayTable": "表を表示", "xpack.observabilityAiAssistant.lensESQLFunction.edit": "ビジュアライゼーションを編集", @@ -32418,42 +32429,14 @@ "xpack.observabilityAiAssistant.missingCredentialsCallout.title": "資格情報がありません", "xpack.observabilityAiAssistant.navControl.initFailureErrorTitle": "オブザーバビリティAI Assistantを初期化できませんでした", "xpack.observabilityAiAssistant.navControl.openTheAIAssistantPopoverLabel": "AI Assistantを開く", - "xpack.observabilityAiAssistant.newChatButton": "新しいチャット", - "xpack.observabilityAiAssistant.poweredByModel": "{model}で駆動", - "xpack.observabilityAiAssistant.prompt.functionList.filter": "フィルター", - "xpack.observabilityAiAssistant.prompt.functionList.functionList": "関数リスト", - "xpack.observabilityAiAssistant.prompt.placeholder": "アシスタントにメッセージを送信", - "xpack.observabilityAiAssistant.promptEditorNaturalLanguage.euiSelectable.selectAnOptionLabel": "オプションを選択", "xpack.observabilityAiAssistant.regenerateResponseButtonLabel": "再生成", "xpack.observabilityAiAssistant.requiredConnectorField": "コネクターが必要です。", "xpack.observabilityAiAssistant.requiredMessageTextField": "メッセージが必要です。", "xpack.observabilityAiAssistant.resetDefaultPrompt": "デフォルトにリセット", "xpack.observabilityAiAssistant.runThisQuery": "結果を表示", - "xpack.observabilityAiAssistant.settingsPage.goToConnectorsButtonLabel": "コネクターを管理", - "xpack.observabilityAiAssistant.setupKb": "ナレッジベースを設定することで、エクスペリエンスが改善されます。", - "xpack.observabilityAiAssistant.simulatedFunctionCallingCalloutLabel": "シミュレートされた関数呼び出しが有効です。パフォーマンスが劣化する場合があります。", "xpack.observabilityAiAssistant.stopGeneratingButtonLabel": "生成を停止", - "xpack.observabilityAiAssistant.suggestedFunctionEvent": "関数{functionName}を要求しました", - "xpack.observabilityAiAssistant.technicalPreviewBadgeDescription": "関数呼び出し(根本原因分析やデータの視覚化など)を使用する際に、より一貫性のあるエクスペリエンスを実現するために、GPT4が必要です。GPT3.5は、エラーの説明などのシンプルなワークフローの一部や、頻繁な関数呼び出しの使用が必要とされないKibana内のエクスペリエンスなどのChatGPTで機能します。", "xpack.observabilityAiAssistant.tokenLimitError": "会話はトークンの上限を超えました。トークンの最大上限は**{tokenLimit}**ですが、現在の会話には**{tokenCount}**個のトークンがあります。続行するには、新しい会話を開始してください。", - "xpack.observabilityAiAssistant.userExecutedFunctionEvent": "関数{functionName}を実行しました", - "xpack.observabilityAiAssistant.userSuggestedFunctionEvent": "関数{functionName}を要求しました", "xpack.observabilityAiAssistant.visualizeThisQuery": "このクエリーを可視化", - "xpack.observabilityAiAssistant.welcomeMessage.div.checkTrainedModelsToLabel": " {retryInstallingLink}か、{trainedModelsLink}を確認して、{modelName}がデプロイされ、実行中であることを確かめてください。", - "xpack.observabilityAiAssistant.welcomeMessage.div.settingUpKnowledgeBaseLabel": "ナレッジベースをセットアップ中", - "xpack.observabilityAiAssistant.welcomeMessage.inspectErrorsButtonEmptyLabel": "問題を検査", - "xpack.observabilityAiAssistant.welcomeMessage.issuesDescriptionListTitleLabel": "問題", - "xpack.observabilityAiAssistant.welcomeMessage.knowledgeBaseSuccessfullyInstalledLabel": "ナレッジベースは正常にインストールされました", - "xpack.observabilityAiAssistant.welcomeMessage.modelIsNotDeployedLabel": "モデル\"{modelName}\"はデプロイされていません", - "xpack.observabilityAiAssistant.welcomeMessage.modelIsNotFullyAllocatedLabel": "\"{modelName}\"の割り当て状態は{allocationState}です", - "xpack.observabilityAiAssistant.welcomeMessage.modelIsNotStartedLabel": "\"{modelName}\"のデプロイ状態は{deploymentState}です", - "xpack.observabilityAiAssistant.welcomeMessage.retryButtonLabel": "ナレッジベースをインストール", - "xpack.observabilityAiAssistant.welcomeMessage.trainedModelsLinkLabel": "学習済みモデル", - "xpack.observabilityAiAssistant.welcomeMessage.weAreSettingUpTextLabel": "ナレッジベースをセットアップしています。これには数分かかる場合があります。この処理の実行中には、アシスタントを使用し続けることができます。", - "xpack.observabilityAiAssistant.welcomeMessageConnectors.connectorsErrorTextLabel": "コネクターを読み込めませんでした", - "xpack.observabilityAiAssistant.welcomeMessageConnectors.connectorsForbiddenTextLabel": "コネクターを取得するために必要な権限が不足しています", - "xpack.observabilityAiAssistant.welcomeMessageKnowledgeBase.yourKnowledgeBaseIsNotSetUpCorrectlyLabel": "ナレッジベースはセットアップされていません。", - "xpack.observabilityAiAssistant.welcomeMessageKnowledgeBaseSetupErrorPanel.retryInstallingLinkLabel": "インストールを再試行", "xpack.observabilityAiAssistantManagement.apmSettings.save.error": "設定の保存中にエラーが発生しました", "xpack.observabilityAiAssistantManagement.app.description": "AI Assistant for Observabilityを管理します。", "xpack.observabilityAiAssistantManagement.app.title": "AI Assistant for Observability", @@ -35884,8 +35867,6 @@ "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.customThreatQueryFieldRequiredEmptyError": "すべての一致には、フィールドと脅威インデックスフィールドの両方が必要です。", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.customThreatQueryFieldRequiredError": "1 つ以上のインジケーター一致が必要です。", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.dataViewSelectorFieldRequired": "使用可能なデータビューまたはインデックスパターンを選択してください。", - "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.enableThresholdSuppressionForFieldsLabel": "選択したフィールドでアラートを非表示:{fieldsString}(テクニカルプレビュー)", - "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.enableThresholdSuppressionLabel": "アラートを抑制(テクニカルプレビュー)", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.EqlQueryBarLabel": "EQL クエリ", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.eqlQueryFieldRequiredError": "EQLクエリは必須です。", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.eqlSequenceSuppressionDisableText": "EQLシーケンスクエリでは抑制はサポートされていません。", @@ -39368,18 +39349,13 @@ "xpack.securitySolution.notes.management.createdByColumnTitle": "作成者", "xpack.securitySolution.notes.management.createdColumnTitle": "作成済み", "xpack.securitySolution.notes.management.deleteAction": "削除", - "xpack.securitySolution.notes.management.deleteDescription": "このメモを削除", "xpack.securitySolution.notes.management.deleteNotesCancel": "キャンセル", "xpack.securitySolution.notes.management.deleteNotesConfirm": "{selectedNotes} {selectedNotes, plural, other {件のメモ}}を削除しますか?", - "xpack.securitySolution.notes.management.deleteNotesModalTitle": "メモを削除しますか?", "xpack.securitySolution.notes.management.deleteSelected": "選択したメモを削除", - "xpack.securitySolution.notes.management.eventIdColumnTitle": "ドキュメンテーションを表示", "xpack.securitySolution.notes.management.noteContentColumnTitle": "メモコンテンツ", "xpack.securitySolution.notes.management.openTimeline": "タイムラインを開く", "xpack.securitySolution.notes.management.refresh": "更新", "xpack.securitySolution.notes.management.tableError": "メモを読み込めません", - "xpack.securitySolution.notes.management.timelineColumnTitle": "Timeline", - "xpack.securitySolution.notes.management.viewEventInTimeline": "タイムラインでイベントを表示", "xpack.securitySolution.notes.noteLabel": "注", "xpack.securitySolution.notes.notesTitle": "メモ", "xpack.securitySolution.notes.search.FilterByUserOrNotePlaceholder": "ユーザーまたはメモでフィルター", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 12ee59bb6fc9c..0a17edfeb80ce 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -319,8 +319,6 @@ "coloring.dynamicColoring.rangeType.label": "值类型", "coloring.dynamicColoring.rangeType.number": "数字", "coloring.dynamicColoring.rangeType.percent": "百分比", - "console.autocomplete.addMethodMetaText": "方法", - "console.autocomplete.fieldsFetchingAnnotation": "正在提取字段", "console.autocompleteSuggestions.apiLabel": "API", "console.autocompleteSuggestions.endpointLabel": "终端", "console.autocompleteSuggestions.methodLabel": "方法", @@ -361,10 +359,6 @@ "console.loadingError.title": "无法加载控制台", "console.notification.clearHistory": "清除历史记录", "console.notification.disableSavingToHistory": "禁止保存", - "console.notification.error.couldNotSaveRequestTitle": "无法将请求保存到控制台历史记录。", - "console.notification.error.historyQuotaReachedMessage": "请求历史记录已满。请清除控制台历史记录或禁止保存新的请求。", - "console.notification.error.noRequestSelectedTitle": "未选择任何请求。将鼠标置于请求内即可选择。", - "console.notification.error.unknownErrorTitle": "未知请求错误", "console.pageHeading": "控制台", "console.requestInProgressBadgeText": "进行中的请求", "console.requestOptions.autoIndentButtonLabel": "应用行首缩进", @@ -7043,9 +7037,6 @@ "sharedUXPackages.fileUpload.uploadCompleteButtonLabel": "上传完成", "sharedUXPackages.fileUpload.uploadDoneToolTipContent": "您的文件已成功上传!", "sharedUXPackages.fileUpload.uploadingButtonLabel": "正在上传", - "sharedUXPackages.no_data_views.esqlButtonLabel": "语言:ES|QL", - "sharedUXPackages.no_data_views.esqlDocsLink": "了解详情。", - "sharedUXPackages.no_data_views.esqlMessage": "或者,您可以直接使用 ES|QL 查询数据。{docsLink}", "sharedUXPackages.noDataConfig.addIntegrationsDescription": "使用 Elastic 代理收集数据并增建分析解决方案。", "sharedUXPackages.noDataConfig.addIntegrationsTitle": "添加集成", "sharedUXPackages.noDataConfig.analytics": "分析", @@ -7067,8 +7058,6 @@ "sharedUXPackages.noDataViewsPrompt.dataViewExplanation": "数据视图标识您要浏览的 Elasticsearch 数据。您可以将数据视图指向一个或多个数据流、索引和索引别名(例如昨天的日志数据),或包含日志数据的所有索引。", "sharedUXPackages.noDataViewsPrompt.learnMore": "希望了解详情?", "sharedUXPackages.noDataViewsPrompt.noPermission.dataViewExplanation": "数据视图标识您要浏览的 Elasticsearch 数据。要创建数据视图,请联系管理员获得所需权限。", - "sharedUXPackages.noDataViewsPrompt.noPermission.title": "您需要权限以创建数据视图", - "sharedUXPackages.noDataViewsPrompt.nowCreate": "现在,创建数据视图。", "sharedUXPackages.noDataViewsPrompt.readDocumentation": "阅读文档", "sharedUXPackages.noDataViewsPrompt.youHaveData": "您在 Elasticsearch 中有数据。", "sharedUXPackages.prompt.errors.notFound.body": "抱歉,找不到您要查找的页面。该页面可能已移除、重命名,或可能根本不存在。", @@ -9158,6 +9147,94 @@ "xpack.actions.serverSideErrors.unavailableLicenseInformationErrorMessage": "操作不可用 - 许可信息当前不可用。", "xpack.actions.subActionsFramework.urlValidationError": "验证 URL 时出错:{message}", "xpack.actions.urlAllowedHostsConfigurationError": "目标 {field} 的“{value}”未添加到 Kibana 配置 xpack.actions.allowedHosts", + "xpack.aiAssistant.askAssistantButton.buttonLabel": "询问助手", + "xpack.aiAssistant.askAssistantButton.popoverContent": "使用 Elastic 助手深入了解您的数据", + "xpack.aiAssistant.assistantSetup.title": "欢迎使用 Elastic AI 助手", + "xpack.aiAssistant.chatActionsMenu.euiButtonIcon.menuLabel": "菜单", + "xpack.aiAssistant.chatActionsMenu.euiToolTip.moreActionsLabel": "更多操作", + "xpack.aiAssistant.chatCollapsedItems.hideEvents": "隐藏 {count} 个事件", + "xpack.aiAssistant.chatCollapsedItems.showEvents": "显示 {count} 个事件", + "xpack.aiAssistant.chatCollapsedItems.toggleButtonLabel": "显示/隐藏项目", + "xpack.aiAssistant.chatFlyout.euiButtonIcon.expandConversationListLabel": "展开对话列表", + "xpack.aiAssistant.chatFlyout.euiButtonIcon.newChatLabel": "新聊天", + "xpack.aiAssistant.chatFlyout.euiToolTip.collapseConversationListLabel": "折叠对话列表", + "xpack.aiAssistant.chatFlyout.euiToolTip.expandConversationListLabel": "展开对话列表", + "xpack.aiAssistant.chatFlyout.euiToolTip.newChatLabel": "新聊天", + "xpack.aiAssistant.chatHeader.actions.connector": "连接器", + "xpack.aiAssistant.chatHeader.actions.copyConversation": "复制对话", + "xpack.aiAssistant.chatHeader.actions.knowledgeBase": "管理知识库", + "xpack.aiAssistant.chatHeader.actions.settings": "AI 助手设置", + "xpack.aiAssistant.chatHeader.actions.title": "操作", + "xpack.aiAssistant.chatHeader.editConversationInput": "编辑对话", + "xpack.aiAssistant.chatHeader.euiButtonIcon.navigateToConversationsLabel": "导航到对话", + "xpack.aiAssistant.chatHeader.euiButtonIcon.toggleFlyoutModeLabel": "切换浮出控件模式", + "xpack.aiAssistant.chatHeader.euiToolTip.flyoutModeLabel.dock": "停靠聊天", + "xpack.aiAssistant.chatHeader.euiToolTip.flyoutModeLabel.undock": "取消停靠聊天", + "xpack.aiAssistant.chatHeader.euiToolTip.navigateToConversationsLabel": "导航到对话", + "xpack.aiAssistant.chatPromptEditor.codeEditor.payloadEditorLabel": "payloadEditor", + "xpack.aiAssistant.chatPromptEditor.euiButtonIcon.submitLabel": "提交", + "xpack.aiAssistant.chatTimeline.actions.copyMessage": "复制消息", + "xpack.aiAssistant.chatTimeline.actions.copyMessageSuccessful": "已复制消息", + "xpack.aiAssistant.chatTimeline.actions.editPrompt": "编辑提示", + "xpack.aiAssistant.chatTimeline.actions.inspectPrompt": "检查提示", + "xpack.aiAssistant.chatTimeline.messages.elasticAssistant.label": "Elastic 助手", + "xpack.aiAssistant.chatTimeline.messages.system.label": "系统", + "xpack.aiAssistant.chatTimeline.messages.user.label": "您", + "xpack.aiAssistant.checkingKbAvailability": "正在检查知识库的可用性", + "xpack.aiAssistant.conversationStartTitle": "已开始对话", + "xpack.aiAssistant.couldNotFindConversationContent": "找不到 ID 为 {conversationId} 的对话。请确保该对话存在并且您具有访问权限。", + "xpack.aiAssistant.couldNotFindConversationTitle": "未找到对话", + "xpack.aiAssistant.disclaimer.disclaimerLabel": "通过集成 LLM 提供商来支持此聊天。众所周知,LLM 有时会提供错误信息,好像它是正确的。Elastic 支持配置并连接到 LLM 提供商和知识库,但不对 LLM 响应负责。", + "xpack.aiAssistant.emptyConversationTitle": "新对话", + "xpack.aiAssistant.errorSettingUpKnowledgeBase": "无法设置知识库", + "xpack.aiAssistant.errorUpdatingConversation": "无法更新对话", + "xpack.aiAssistant.executedFunctionFailureEvent": "无法执行函数 {functionName}", + "xpack.aiAssistant.failedToGetStatus": "无法获取模型状态。", + "xpack.aiAssistant.failedToSetupKnowledgeBase": "无法设置知识库。", + "xpack.aiAssistant.flyout.confirmDeleteButtonText": "删除对话", + "xpack.aiAssistant.flyout.confirmDeleteConversationContent": "此操作无法撤消。", + "xpack.aiAssistant.flyout.confirmDeleteConversationTitle": "删除此对话?", + "xpack.aiAssistant.flyout.failedToDeleteConversation": "无法删除对话", + "xpack.aiAssistant.functionListPopover.euiButtonIcon.selectAFunctionLabel": "选择函数", + "xpack.aiAssistant.functionListPopover.euiToolTip.clearFunction": "清除函数", + "xpack.aiAssistant.functionListPopover.euiToolTip.selectAFunctionLabel": "选择函数", + "xpack.aiAssistant.hideExpandConversationButton.hide": "隐藏聊天", + "xpack.aiAssistant.hideExpandConversationButton.show": "显示聊天", + "xpack.aiAssistant.incorrectLicense.body": "您需要企业级许可证才能使用 Elastic AI 助手。", + "xpack.aiAssistant.incorrectLicense.manageLicense": "管理许可证", + "xpack.aiAssistant.incorrectLicense.subscriptionPlansButton": "订阅计划", + "xpack.aiAssistant.incorrectLicense.title": "升级您的许可证", + "xpack.aiAssistant.initialSetupPanel.setupConnector.buttonLabel": "设置 GenAI 连接器", + "xpack.aiAssistant.initialSetupPanel.setupConnector.description2": "通过为您的 AI 提供商设置连接器,开始使用 Elastic AI 助手。此模型需要支持函数调用。使用 OpenAI 或 Azure 时,建议使用 GPT4。", + "xpack.aiAssistant.installingKb": "正在设置知识库", + "xpack.aiAssistant.newChatButton": "新聊天", + "xpack.aiAssistant.poweredByModel": "由 {model} 提供支持", + "xpack.aiAssistant.prompt.functionList.filter": "筛选", + "xpack.aiAssistant.prompt.functionList.functionList": "函数列表", + "xpack.aiAssistant.prompt.placeholder": "向助手发送消息", + "xpack.aiAssistant.promptEditorNaturalLanguage.euiSelectable.selectAnOptionLabel": "选择选项", + "xpack.aiAssistant.settingsPage.goToConnectorsButtonLabel": "管理连接器", + "xpack.aiAssistant.setupKb": "通过设置知识库来改进体验。", + "xpack.aiAssistant.simulatedFunctionCallingCalloutLabel": "模拟函数调用已启用。您可能会面临性能降级。", + "xpack.aiAssistant.suggestedFunctionEvent": "已请求函数 {functionName}", + "xpack.aiAssistant.technicalPreviewBadgeDescription": "需要 GPT4 以在使用函数调用时(例如,执行根本原因分析、数据可视化等时候)获得更加一致的体验。GPT3.5 可作用于某些更简单的工作流(如解释错误),或在 Kibana 中获得不需要频繁使用函数调用的与 ChatGPT 类似的体验。", + "xpack.aiAssistant.userExecutedFunctionEvent": "已执行函数 {functionName}", + "xpack.aiAssistant.userSuggestedFunctionEvent": "已请求函数 {functionName}", + "xpack.aiAssistant.welcomeMessage.div.checkTrainedModelsToLabel": " {retryInstallingLink} 或检查 {trainedModelsLink},确保 {modelName} 已部署并正在运行。", + "xpack.aiAssistant.welcomeMessage.div.settingUpKnowledgeBaseLabel": "正在设置知识库", + "xpack.aiAssistant.welcomeMessage.inspectErrorsButtonEmptyLabel": "检查问题", + "xpack.aiAssistant.welcomeMessage.issuesDescriptionListTitleLabel": "问题", + "xpack.aiAssistant.welcomeMessage.knowledgeBaseSuccessfullyInstalledLabel": "已成功安装知识库", + "xpack.aiAssistant.welcomeMessage.modelIsNotDeployedLabel": "未部署模型 {modelName}", + "xpack.aiAssistant.welcomeMessage.modelIsNotFullyAllocatedLabel": "{modelName} 的分配状态为 {allocationState}", + "xpack.aiAssistant.welcomeMessage.modelIsNotStartedLabel": "{modelName} 的部署状态为 {deploymentState}", + "xpack.aiAssistant.welcomeMessage.retryButtonLabel": "安装知识库", + "xpack.aiAssistant.welcomeMessage.trainedModelsLinkLabel": "已训练模型", + "xpack.aiAssistant.welcomeMessage.weAreSettingUpTextLabel": "我们正在设置您的知识库。这可能需要若干分钟。此进程处于运行状态时,您可以继续使用该助手。", + "xpack.aiAssistant.welcomeMessageConnectors.connectorsErrorTextLabel": "无法加载连接器", + "xpack.aiAssistant.welcomeMessageConnectors.connectorsForbiddenTextLabel": "缺少获取连接器所需的权限", + "xpack.aiAssistant.welcomeMessageKnowledgeBase.yourKnowledgeBaseIsNotSetUpCorrectlyLabel": "尚未设置您的知识库。", + "xpack.aiAssistant.welcomeMessageKnowledgeBaseSetupErrorPanel.retryInstallingLinkLabel": "重试安装", "xpack.aiops.actions.openChangePointInMlAppName": "在 Aiops 实验室中打开", "xpack.aiops.analysis.columnSelectorAriaLabel": "筛选列", "xpack.aiops.analysis.columnSelectorNotEnoughColumnsSelected": "必须至少选择一列。", @@ -32342,92 +32419,27 @@ "xpack.observabilityAiAssistant.app.starterPrompts.whatAreSlos.prompt": "什么是 SLO?", "xpack.observabilityAiAssistant.app.starterPrompts.whatAreSlos.title": "SLO", "xpack.observabilityAiAssistant.appTitle": "Observability AI 助手", - "xpack.observabilityAiAssistant.askAssistantButton.buttonLabel": "询问助手", - "xpack.observabilityAiAssistant.askAssistantButton.popoverContent": "使用 Elastic 助手深入了解您的数据", - "xpack.observabilityAiAssistant.askAssistantButton.popoverTitle": "适用于 Observability 的 AI 助手", - "xpack.observabilityAiAssistant.assistantSetup.title": "欢迎使用 Elastic AI 助手", "xpack.observabilityAiAssistant.changesList.dotImpactHigh": "高", "xpack.observabilityAiAssistant.changesList.dotImpactLow": "低", "xpack.observabilityAiAssistant.changesList.dotImpactMedium": "中", "xpack.observabilityAiAssistant.changesList.labelColumnTitle": "标签", "xpack.observabilityAiAssistant.changesList.noChangesDetected": "未检测到更改", "xpack.observabilityAiAssistant.changesList.trendColumnTitle": "趋势", - "xpack.observabilityAiAssistant.chatActionsMenu.euiButtonIcon.menuLabel": "菜单", - "xpack.observabilityAiAssistant.chatActionsMenu.euiToolTip.moreActionsLabel": "更多操作", - "xpack.observabilityAiAssistant.chatCollapsedItems.hideEvents": "隐藏 {count} 个事件", - "xpack.observabilityAiAssistant.chatCollapsedItems.showEvents": "显示 {count} 个事件", - "xpack.observabilityAiAssistant.chatCollapsedItems.toggleButtonLabel": "显示/隐藏项目", "xpack.observabilityAiAssistant.chatCompletionError.conversationNotFoundError": "未找到对话", "xpack.observabilityAiAssistant.chatCompletionError.internalServerError": "发生内部服务器错误", "xpack.observabilityAiAssistant.chatCompletionError.tokenLimitReachedError": "达到了词元限制。词元限制为 {tokenLimit},但当前对话具有 {tokenCount} 个词元。", - "xpack.observabilityAiAssistant.chatFlyout.euiButtonIcon.expandConversationListLabel": "展开对话列表", - "xpack.observabilityAiAssistant.chatFlyout.euiButtonIcon.newChatLabel": "新聊天", - "xpack.observabilityAiAssistant.chatFlyout.euiFlyoutResizable.aiAssistantForObservabilityLabel": "适用于 Observability 聊天浮出控件的 AI 助手", - "xpack.observabilityAiAssistant.chatFlyout.euiToolTip.collapseConversationListLabel": "折叠对话列表", - "xpack.observabilityAiAssistant.chatFlyout.euiToolTip.expandConversationListLabel": "展开对话列表", - "xpack.observabilityAiAssistant.chatFlyout.euiToolTip.newChatLabel": "新聊天", - "xpack.observabilityAiAssistant.chatHeader.actions.connector": "连接器", - "xpack.observabilityAiAssistant.chatHeader.actions.copyConversation": "复制对话", - "xpack.observabilityAiAssistant.chatHeader.actions.knowledgeBase": "管理知识库", - "xpack.observabilityAiAssistant.chatHeader.actions.settings": "AI 助手设置", - "xpack.observabilityAiAssistant.chatHeader.actions.title": "操作", - "xpack.observabilityAiAssistant.chatHeader.editConversationInput": "编辑对话", - "xpack.observabilityAiAssistant.chatHeader.euiButtonIcon.navigateToConversationsLabel": "导航到对话", - "xpack.observabilityAiAssistant.chatHeader.euiButtonIcon.toggleFlyoutModeLabel": "切换浮出控件模式", - "xpack.observabilityAiAssistant.chatHeader.euiToolTip.flyoutModeLabel.dock": "停靠聊天", - "xpack.observabilityAiAssistant.chatHeader.euiToolTip.flyoutModeLabel.undock": "取消停靠聊天", - "xpack.observabilityAiAssistant.chatHeader.euiToolTip.navigateToConversationsLabel": "导航到对话", - "xpack.observabilityAiAssistant.chatPromptEditor.codeEditor.payloadEditorLabel": "payloadEditor", - "xpack.observabilityAiAssistant.chatPromptEditor.euiButtonIcon.submitLabel": "提交", "xpack.observabilityAiAssistant.chatService.div.helloLabel": "您好", - "xpack.observabilityAiAssistant.chatTimeline.actions.copyMessage": "复制消息", - "xpack.observabilityAiAssistant.chatTimeline.actions.copyMessageSuccessful": "已复制消息", - "xpack.observabilityAiAssistant.chatTimeline.actions.editPrompt": "编辑提示", - "xpack.observabilityAiAssistant.chatTimeline.actions.inspectPrompt": "检查提示", - "xpack.observabilityAiAssistant.chatTimeline.messages.elasticAssistant.label": "Elastic 助手", - "xpack.observabilityAiAssistant.chatTimeline.messages.system.label": "系统", - "xpack.observabilityAiAssistant.chatTimeline.messages.user.label": "您", - "xpack.observabilityAiAssistant.checkingKbAvailability": "正在检查知识库的可用性", "xpack.observabilityAiAssistant.connectorSelector.connectorSelectLabel": "连接器:", "xpack.observabilityAiAssistant.connectorSelector.empty": "无连接器", "xpack.observabilityAiAssistant.connectorSelector.error": "无法加载连接器", - "xpack.observabilityAiAssistant.conversationList.deleteConversationIconLabel": "删除", - "xpack.observabilityAiAssistant.conversationList.errorMessage": "无法加载", - "xpack.observabilityAiAssistant.conversationList.noConversations": "无对话", - "xpack.observabilityAiAssistant.conversationList.title": "以前", "xpack.observabilityAiAssistant.conversationsDeepLinkTitle": "对话", - "xpack.observabilityAiAssistant.conversationStartTitle": "已开始对话", - "xpack.observabilityAiAssistant.couldNotFindConversationContent": "找不到 ID 为 {conversationId} 的对话。请确保该对话存在并且您具有访问权限。", - "xpack.observabilityAiAssistant.couldNotFindConversationTitle": "未找到对话", - "xpack.observabilityAiAssistant.disclaimer.disclaimerLabel": "通过集成 LLM 提供商来支持此聊天。众所周知,LLM 有时会提供错误信息,好像它是正确的。Elastic 支持配置并连接到 LLM 提供商和知识库,但不对 LLM 响应负责。", - "xpack.observabilityAiAssistant.emptyConversationTitle": "新对话", - "xpack.observabilityAiAssistant.errorSettingUpKnowledgeBase": "无法设置知识库", - "xpack.observabilityAiAssistant.errorUpdatingConversation": "无法更新对话", - "xpack.observabilityAiAssistant.executedFunctionFailureEvent": "无法执行函数 {functionName}", "xpack.observabilityAiAssistant.experimentalTitle": "技术预览", "xpack.observabilityAiAssistant.experimentalTooltip": "此功能处于技术预览状态,在未来版本中可能会更改或完全移除。Elastic 将努力修复任何问题,但处于技术预览状态的功能不受正式 GA 功能支持 SLA 的约束。", "xpack.observabilityAiAssistant.failedLoadingResponseText": "无法加载响应", - "xpack.observabilityAiAssistant.failedToGetStatus": "无法获取模型状态。", "xpack.observabilityAiAssistant.failedToLoadResponse": "无法加载来自 AI 助手的响应", - "xpack.observabilityAiAssistant.failedToSetupKnowledgeBase": "无法设置知识库。", "xpack.observabilityAiAssistant.featureRegistry.featureName": "Observability AI 助手", "xpack.observabilityAiAssistant.feedbackButtons.em.thanksForYourFeedbackLabel": "感谢您提供反馈", - "xpack.observabilityAiAssistant.flyout.confirmDeleteButtonText": "删除对话", - "xpack.observabilityAiAssistant.flyout.confirmDeleteConversationContent": "此操作无法撤消。", - "xpack.observabilityAiAssistant.flyout.confirmDeleteConversationTitle": "删除此对话?", - "xpack.observabilityAiAssistant.flyout.failedToDeleteConversation": "无法删除对话", "xpack.observabilityAiAssistant.functionCallLimitExceeded": "\n\n注意:即使超出了限制,助手仍尝试调用了函数", - "xpack.observabilityAiAssistant.functionListPopover.euiButtonIcon.selectAFunctionLabel": "选择函数", - "xpack.observabilityAiAssistant.functionListPopover.euiToolTip.clearFunction": "清除函数", - "xpack.observabilityAiAssistant.functionListPopover.euiToolTip.selectAFunctionLabel": "选择函数", - "xpack.observabilityAiAssistant.hideExpandConversationButton.hide": "隐藏聊天", - "xpack.observabilityAiAssistant.hideExpandConversationButton.show": "显示聊天", - "xpack.observabilityAiAssistant.incorrectLicense.body": "您需要企业级许可证才能使用 Elastic AI 助手。", - "xpack.observabilityAiAssistant.incorrectLicense.manageLicense": "管理许可证", - "xpack.observabilityAiAssistant.incorrectLicense.subscriptionPlansButton": "订阅计划", - "xpack.observabilityAiAssistant.incorrectLicense.title": "升级您的许可证", - "xpack.observabilityAiAssistant.initialSetupPanel.setupConnector.buttonLabel": "设置 GenAI 连接器", - "xpack.observabilityAiAssistant.initialSetupPanel.setupConnector.description2": "通过为您的 AI 提供商设置连接器,开始使用 Elastic AI 助手。此模型需要支持函数调用。使用 OpenAI 或 Azure 时,建议使用 GPT4。", "xpack.observabilityAiAssistant.insight.actions": "操作", "xpack.observabilityAiAssistant.insight.actions.connector": "连接器", "xpack.observabilityAiAssistant.insight.actions.editPrompt": "编辑提示", @@ -32445,7 +32457,6 @@ "xpack.observabilityAiAssistant.insight.response.startChat": "开始聊天", "xpack.observabilityAiAssistant.insight.sendPromptEdit": "发送提示", "xpack.observabilityAiAssistant.insightModifiedPrompt": "此洞察已被修改。", - "xpack.observabilityAiAssistant.installingKb": "正在设置知识库", "xpack.observabilityAiAssistant.lensESQLFunction.displayChart": "显示图表", "xpack.observabilityAiAssistant.lensESQLFunction.displayTable": "显示表", "xpack.observabilityAiAssistant.lensESQLFunction.edit": "编辑可视化", @@ -32460,42 +32471,14 @@ "xpack.observabilityAiAssistant.missingCredentialsCallout.title": "凭据缺失", "xpack.observabilityAiAssistant.navControl.initFailureErrorTitle": "无法初始化 Observability AI 助手", "xpack.observabilityAiAssistant.navControl.openTheAIAssistantPopoverLabel": "打开 AI 助手", - "xpack.observabilityAiAssistant.newChatButton": "新聊天", - "xpack.observabilityAiAssistant.poweredByModel": "由 {model} 提供支持", - "xpack.observabilityAiAssistant.prompt.functionList.filter": "筛选", - "xpack.observabilityAiAssistant.prompt.functionList.functionList": "函数列表", - "xpack.observabilityAiAssistant.prompt.placeholder": "向助手发送消息", - "xpack.observabilityAiAssistant.promptEditorNaturalLanguage.euiSelectable.selectAnOptionLabel": "选择选项", "xpack.observabilityAiAssistant.regenerateResponseButtonLabel": "重新生成", "xpack.observabilityAiAssistant.requiredConnectorField": "“连接器”必填。", "xpack.observabilityAiAssistant.requiredMessageTextField": "“消息”必填。", "xpack.observabilityAiAssistant.resetDefaultPrompt": "重置为默认值", "xpack.observabilityAiAssistant.runThisQuery": "显示结果", - "xpack.observabilityAiAssistant.settingsPage.goToConnectorsButtonLabel": "管理连接器", - "xpack.observabilityAiAssistant.setupKb": "通过设置知识库来改进体验。", - "xpack.observabilityAiAssistant.simulatedFunctionCallingCalloutLabel": "模拟函数调用已启用。您可能会面临性能降级。", "xpack.observabilityAiAssistant.stopGeneratingButtonLabel": "停止生成", - "xpack.observabilityAiAssistant.suggestedFunctionEvent": "已请求函数 {functionName}", - "xpack.observabilityAiAssistant.technicalPreviewBadgeDescription": "需要 GPT4 以在使用函数调用时(例如,执行根本原因分析、数据可视化等时候)获得更加一致的体验。GPT3.5 可作用于某些更简单的工作流(如解释错误),或在 Kibana 中获得不需要频繁使用函数调用的与 ChatGPT 类似的体验。", "xpack.observabilityAiAssistant.tokenLimitError": "此对话已超出词元限制。最大词元限制为 **{tokenLimit}**,但当前对话具有 **{tokenCount}** 个词元。请启动新对话以继续。", - "xpack.observabilityAiAssistant.userExecutedFunctionEvent": "已执行函数 {functionName}", - "xpack.observabilityAiAssistant.userSuggestedFunctionEvent": "已请求函数 {functionName}", "xpack.observabilityAiAssistant.visualizeThisQuery": "可视化此查询", - "xpack.observabilityAiAssistant.welcomeMessage.div.checkTrainedModelsToLabel": " {retryInstallingLink} 或检查 {trainedModelsLink},确保 {modelName} 已部署并正在运行。", - "xpack.observabilityAiAssistant.welcomeMessage.div.settingUpKnowledgeBaseLabel": "正在设置知识库", - "xpack.observabilityAiAssistant.welcomeMessage.inspectErrorsButtonEmptyLabel": "检查问题", - "xpack.observabilityAiAssistant.welcomeMessage.issuesDescriptionListTitleLabel": "问题", - "xpack.observabilityAiAssistant.welcomeMessage.knowledgeBaseSuccessfullyInstalledLabel": "已成功安装知识库", - "xpack.observabilityAiAssistant.welcomeMessage.modelIsNotDeployedLabel": "未部署模型 {modelName}", - "xpack.observabilityAiAssistant.welcomeMessage.modelIsNotFullyAllocatedLabel": "{modelName} 的分配状态为 {allocationState}", - "xpack.observabilityAiAssistant.welcomeMessage.modelIsNotStartedLabel": "{modelName} 的部署状态为 {deploymentState}", - "xpack.observabilityAiAssistant.welcomeMessage.retryButtonLabel": "安装知识库", - "xpack.observabilityAiAssistant.welcomeMessage.trainedModelsLinkLabel": "已训练模型", - "xpack.observabilityAiAssistant.welcomeMessage.weAreSettingUpTextLabel": "我们正在设置您的知识库。这可能需要若干分钟。此进程处于运行状态时,您可以继续使用该助手。", - "xpack.observabilityAiAssistant.welcomeMessageConnectors.connectorsErrorTextLabel": "无法加载连接器", - "xpack.observabilityAiAssistant.welcomeMessageConnectors.connectorsForbiddenTextLabel": "缺少获取连接器所需的权限", - "xpack.observabilityAiAssistant.welcomeMessageKnowledgeBase.yourKnowledgeBaseIsNotSetUpCorrectlyLabel": "尚未设置您的知识库。", - "xpack.observabilityAiAssistant.welcomeMessageKnowledgeBaseSetupErrorPanel.retryInstallingLinkLabel": "重试安装", "xpack.observabilityAiAssistantManagement.apmSettings.save.error": "保存设置时出错", "xpack.observabilityAiAssistantManagement.app.description": "管理适用于 Observability 的 AI 助手。", "xpack.observabilityAiAssistantManagement.app.title": "适用于 Observability 的 AI 助手", @@ -35928,8 +35911,6 @@ "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.customThreatQueryFieldRequiredEmptyError": "所有匹配项都需要字段和威胁索引字段。", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.customThreatQueryFieldRequiredError": "至少需要一个指标匹配。", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.dataViewSelectorFieldRequired": "请选择可用的数据视图或索引模式。", - "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.enableThresholdSuppressionForFieldsLabel": "选定字段阻止告警:{fieldsString}(技术预览)", - "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.enableThresholdSuppressionLabel": "阻止告警(技术预览)", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.EqlQueryBarLabel": "EQL 查询", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.eqlQueryFieldRequiredError": "EQL 查询必填。", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.eqlSequenceSuppressionDisableText": "EQL 序列查询不支持阻止。", @@ -39413,18 +39394,13 @@ "xpack.securitySolution.notes.management.createdByColumnTitle": "创建者", "xpack.securitySolution.notes.management.createdColumnTitle": "创建时间", "xpack.securitySolution.notes.management.deleteAction": "删除", - "xpack.securitySolution.notes.management.deleteDescription": "删除此备注", "xpack.securitySolution.notes.management.deleteNotesCancel": "取消", "xpack.securitySolution.notes.management.deleteNotesConfirm": "是否确定要删除 {selectedNotes} 个{selectedNotes, plural, other {备注}}?", - "xpack.securitySolution.notes.management.deleteNotesModalTitle": "删除备注?", "xpack.securitySolution.notes.management.deleteSelected": "删除所选备注", - "xpack.securitySolution.notes.management.eventIdColumnTitle": "查看文档", "xpack.securitySolution.notes.management.noteContentColumnTitle": "备注内容", "xpack.securitySolution.notes.management.openTimeline": "打开时间线", "xpack.securitySolution.notes.management.refresh": "刷新", "xpack.securitySolution.notes.management.tableError": "无法加载备注", - "xpack.securitySolution.notes.management.timelineColumnTitle": "时间线", - "xpack.securitySolution.notes.management.viewEventInTimeline": "在时间线中查看事件", "xpack.securitySolution.notes.noteLabel": "备注", "xpack.securitySolution.notes.notesTitle": "备注", "xpack.securitySolution.notes.search.FilterByUserOrNotePlaceholder": "按用户或备注筛选", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/flapping/rules_settings_flapping_form_section.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/flapping/rules_settings_flapping_form_section.tsx index a78658044a192..1b38eede40e68 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/flapping/rules_settings_flapping_form_section.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/flapping/rules_settings_flapping_form_section.tsx @@ -82,6 +82,7 @@ export const RulesSettingsFlappingFormSection = memo( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_link.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_link.test.tsx index 8d32eb2c9940c..e1cdf5a8ee150 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_link.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_link.test.tsx @@ -14,12 +14,12 @@ import { coreMock } from '@kbn/core/public/mocks'; import { RulesSettingsFlapping, RulesSettingsQueryDelay } from '@kbn/alerting-plugin/common'; import { RulesSettingsLink } from './rules_settings_link'; import { useKibana } from '../../../common/lib/kibana'; -import { getFlappingSettings } from '../../lib/rule_api/get_flapping_settings'; +import { fetchFlappingSettings } from '@kbn/alerts-ui-shared/src/common/apis/fetch_flapping_settings'; import { getQueryDelaySettings } from '../../lib/rule_api/get_query_delay_settings'; jest.mock('../../../common/lib/kibana'); -jest.mock('../../lib/rule_api/get_flapping_settings', () => ({ - getFlappingSettings: jest.fn(), +jest.mock('@kbn/alerts-ui-shared/src/common/apis/fetch_flapping_settings', () => ({ + fetchFlappingSettings: jest.fn(), })); jest.mock('../../lib/rule_api/get_query_delay_settings', () => ({ getQueryDelaySettings: jest.fn(), @@ -38,8 +38,8 @@ const useKibanaMock = useKibana as jest.Mocked; const mocks = coreMock.createSetup(); -const getFlappingSettingsMock = getFlappingSettings as unknown as jest.MockedFunction< - typeof getFlappingSettings +const fetchFlappingSettingsMock = fetchFlappingSettings as unknown as jest.MockedFunction< + typeof fetchFlappingSettings >; const getQueryDelaySettingsMock = getQueryDelaySettings as unknown as jest.MockedFunction< typeof getQueryDelaySettings @@ -88,7 +88,7 @@ describe('rules_settings_link', () => { readQueryDelaySettingsUI: true, }, }; - getFlappingSettingsMock.mockResolvedValue(mockFlappingSetting); + fetchFlappingSettingsMock.mockResolvedValue(mockFlappingSetting); getQueryDelaySettingsMock.mockResolvedValue(mockQueryDelaySetting); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_modal.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_modal.test.tsx index 592705b56984d..1dea8bdf88a6e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_modal.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_modal.test.tsx @@ -15,14 +15,14 @@ import { IToasts } from '@kbn/core/public'; import { RulesSettingsFlapping, RulesSettingsQueryDelay } from '@kbn/alerting-plugin/common'; import { RulesSettingsModal, RulesSettingsModalProps } from './rules_settings_modal'; import { useKibana } from '../../../common/lib/kibana'; -import { getFlappingSettings } from '../../lib/rule_api/get_flapping_settings'; +import { fetchFlappingSettings } from '@kbn/alerts-ui-shared/src/common/apis/fetch_flapping_settings'; import { updateFlappingSettings } from '../../lib/rule_api/update_flapping_settings'; import { getQueryDelaySettings } from '../../lib/rule_api/get_query_delay_settings'; import { updateQueryDelaySettings } from '../../lib/rule_api/update_query_delay_settings'; jest.mock('../../../common/lib/kibana'); -jest.mock('../../lib/rule_api/get_flapping_settings', () => ({ - getFlappingSettings: jest.fn(), +jest.mock('@kbn/alerts-ui-shared/src/common/apis/fetch_flapping_settings', () => ({ + fetchFlappingSettings: jest.fn(), })); jest.mock('../../lib/rule_api/update_flapping_settings', () => ({ updateFlappingSettings: jest.fn(), @@ -47,8 +47,8 @@ const useKibanaMock = useKibana as jest.Mocked; const mocks = coreMock.createSetup(); -const getFlappingSettingsMock = getFlappingSettings as unknown as jest.MockedFunction< - typeof getFlappingSettings +const fetchFlappingSettingsMock = fetchFlappingSettings as unknown as jest.MockedFunction< + typeof fetchFlappingSettings >; const updateFlappingSettingsMock = updateFlappingSettings as unknown as jest.MockedFunction< typeof updateFlappingSettings @@ -142,7 +142,7 @@ describe('rules_settings_modal', () => { useKibanaMock().services.isServerless = true; - getFlappingSettingsMock.mockResolvedValue(mockFlappingSetting); + fetchFlappingSettingsMock.mockResolvedValue(mockFlappingSetting); updateFlappingSettingsMock.mockResolvedValue(mockFlappingSetting); getQueryDelaySettingsMock.mockResolvedValue(mockQueryDelaySetting); updateQueryDelaySettingsMock.mockResolvedValue(mockQueryDelaySetting); @@ -156,7 +156,7 @@ describe('rules_settings_modal', () => { test('renders flapping settings correctly', async () => { const result = render(); - expect(getFlappingSettingsMock).toHaveBeenCalledTimes(1); + expect(fetchFlappingSettingsMock).toHaveBeenCalledTimes(1); await waitForModalLoad(); expect( result.getByTestId('rulesSettingsFlappingEnableSwitch').getAttribute('aria-checked') @@ -204,7 +204,7 @@ describe('rules_settings_modal', () => { test('reset flapping settings to initial state on cancel without triggering another server reload', async () => { const result = render(); - expect(getFlappingSettingsMock).toHaveBeenCalledTimes(1); + expect(fetchFlappingSettingsMock).toHaveBeenCalledTimes(1); expect(getQueryDelaySettingsMock).toHaveBeenCalledTimes(1); await waitForModalLoad(); @@ -228,7 +228,7 @@ describe('rules_settings_modal', () => { expect(lookBackWindowInput.getAttribute('value')).toBe('10'); expect(statusChangeThresholdInput.getAttribute('value')).toBe('10'); - expect(getFlappingSettingsMock).toHaveBeenCalledTimes(1); + expect(fetchFlappingSettingsMock).toHaveBeenCalledTimes(1); expect(getQueryDelaySettingsMock).toHaveBeenCalledTimes(1); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_modal.tsx index 4431f05975906..09828e067369b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_modal.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_modal.tsx @@ -26,8 +26,8 @@ import { EuiSpacer, EuiEmptyPrompt, } from '@elastic/eui'; +import { useFetchFlappingSettings } from '@kbn/alerts-ui-shared/src/common/hooks/use_fetch_flapping_settings'; import { useKibana } from '../../../common/lib/kibana'; -import { useGetFlappingSettings } from '../../hooks/use_get_flapping_settings'; import { RulesSettingsFlappingSection } from './flapping/rules_settings_flapping_section'; import { RulesSettingsQueryDelaySection } from './query_delay/rules_settings_query_delay_section'; import { useGetQueryDelaySettings } from '../../hooks/use_get_query_delay_settings'; @@ -93,6 +93,7 @@ export const RulesSettingsModal = memo((props: RulesSettingsModalProps) => { const { application: { capabilities }, isServerless, + http, } = useKibana().services; const { rulesSettings: { @@ -109,7 +110,8 @@ export const RulesSettingsModal = memo((props: RulesSettingsModalProps) => { const [queryDelaySettings, hasQueryDelayChanged, setQueryDelaySettings, resetQueryDelaySettings] = useResettableState(); - const { isLoading: isFlappingLoading, isError: hasFlappingError } = useGetFlappingSettings({ + const { isLoading: isFlappingLoading, isError: hasFlappingError } = useFetchFlappingSettings({ + http, enabled: isVisible, onSuccess: (fetchedSettings) => { if (!flappingSettings) { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_get_flapping_settings.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_get_flapping_settings.ts deleted file mode 100644 index 26b9fdcaeb1c2..0000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_get_flapping_settings.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 { useQuery } from '@tanstack/react-query'; -import { RulesSettingsFlapping } from '@kbn/alerting-plugin/common'; -import { useKibana } from '../../common/lib/kibana'; -import { getFlappingSettings } from '../lib/rule_api/get_flapping_settings'; - -interface UseGetFlappingSettingsProps { - enabled: boolean; - onSuccess?: (settings: RulesSettingsFlapping) => void; -} - -export const useGetFlappingSettings = (props: UseGetFlappingSettingsProps) => { - const { enabled, onSuccess } = props; - const { http } = useKibana().services; - - const queryFn = () => { - return getFlappingSettings({ http }); - }; - - const { data, isFetching, isError, isLoadingError, isLoading, isInitialLoading } = useQuery({ - queryKey: ['getFlappingSettings'], - queryFn, - onSuccess, - enabled, - refetchOnWindowFocus: false, - retry: false, - }); - - return { - isInitialLoading, - isLoading: isLoading || isFetching, - isError: isError || isLoadingError, - data, - }; -}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/get_flapping_settings.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/get_flapping_settings.ts deleted file mode 100644 index 931b1037ef729..0000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/get_flapping_settings.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 { HttpSetup } from '@kbn/core/public'; -import { AsApiContract, RewriteRequestCase } from '@kbn/actions-plugin/common'; -import { RulesSettingsFlapping } from '@kbn/alerting-plugin/common'; -import { INTERNAL_BASE_ALERTING_API_PATH } from '../../constants'; - -const rewriteBodyRes: RewriteRequestCase = ({ - look_back_window: lookBackWindow, - status_change_threshold: statusChangeThreshold, - ...rest -}: any) => ({ - ...rest, - lookBackWindow, - statusChangeThreshold, -}); - -export const getFlappingSettings = async ({ http }: { http: HttpSetup }) => { - const res = await http.get>( - `${INTERNAL_BASE_ALERTING_API_PATH}/rules/settings/_flapping` - ); - return rewriteBodyRes(res); -}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.test.tsx index af8bda5704b0f..c7b2876d83d84 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.test.tsx @@ -67,8 +67,8 @@ jest.mock('../../lib/action_connector_api', () => ({ loadAllActions: jest.fn(), })); -jest.mock('../../lib/rule_api/get_flapping_settings', () => ({ - getFlappingSettings: jest.fn().mockResolvedValue({ +jest.mock('@kbn/alerts-ui-shared/src/common/apis/fetch_flapping_settings', () => ({ + fetchFlappingSettings: jest.fn().mockResolvedValue({ lookBackWindow: 20, statusChangeThreshold: 20, }), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.tsx index 8657248a29df3..ccdca1bd1250d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.tsx @@ -14,6 +14,7 @@ import { toMountPoint } from '@kbn/react-kibana-mount'; import { parseRuleCircuitBreakerErrorMessage } from '@kbn/alerting-plugin/common'; import { createRule, CreateRuleBody } from '@kbn/alerts-ui-shared/src/common/apis/create_rule'; import { fetchUiConfig as triggersActionsUiConfig } from '@kbn/alerts-ui-shared/src/common/apis/fetch_ui_config'; +import { IS_RULE_SPECIFIC_FLAPPING_ENABLED } from '@kbn/alerts-ui-shared/src/common/constants/rule_flapping'; import { Rule, RuleTypeParams, @@ -37,7 +38,6 @@ import { hasShowActionsCapability } from '../../lib/capabilities'; import RuleAddFooter from './rule_add_footer'; import { HealthContextProvider } from '../../context/health_context'; import { useKibana } from '../../../common/lib/kibana'; -import { IS_RULE_SPECIFIC_FLAPPING_ENABLED } from '../../../common/constants'; import { hasRuleChanged, haveRuleParamsChanged } from './has_rule_changed'; import { getRuleWithInvalidatedFields } from '../../lib/value_validators'; import { DEFAULT_RULE_INTERVAL, MULTI_CONSUMER_RULE_TYPE_IDS } from '../../constants'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_edit.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_edit.test.tsx index 331b10505a5d7..243236d7f6b93 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_edit.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_edit.test.tsx @@ -63,8 +63,8 @@ jest.mock('@kbn/alerts-ui-shared/src/common/apis/fetch_ui_health_status', () => fetchUiHealthStatus: jest.fn(() => ({ isRulesAvailable: true })), })); -jest.mock('../../lib/rule_api/get_flapping_settings', () => ({ - getFlappingSettings: jest.fn().mockResolvedValue({ +jest.mock('@kbn/alerts-ui-shared/src/common/apis/fetch_flapping_settings', () => ({ + fetchFlappingSettings: jest.fn().mockResolvedValue({ lookBackWindow: 20, statusChangeThreshold: 20, }), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_edit.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_edit.tsx index 72eab243ad0c8..a24fd0eec2eb1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_edit.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_edit.tsx @@ -30,7 +30,7 @@ import { toMountPoint } from '@kbn/react-kibana-mount'; import { parseRuleCircuitBreakerErrorMessage } from '@kbn/alerting-plugin/common'; import { updateRule } from '@kbn/alerts-ui-shared/src/common/apis/update_rule'; import { fetchUiConfig as triggersActionsUiConfig } from '@kbn/alerts-ui-shared/src/common/apis/fetch_ui_config'; -import { IS_RULE_SPECIFIC_FLAPPING_ENABLED } from '../../../common/constants'; +import { IS_RULE_SPECIFIC_FLAPPING_ENABLED } from '@kbn/alerts-ui-shared/src/common/constants/rule_flapping'; import { Rule, RuleFlyoutCloseReason, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.test.tsx index 38ee1c73ac40b..17bdcc92997ca 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.test.tsx @@ -71,8 +71,8 @@ jest.mock('../../lib/capabilities', () => ({ hasShowActionsCapability: jest.fn(() => true), hasExecuteActionsCapability: jest.fn(() => true), })); -jest.mock('../../lib/rule_api/get_flapping_settings', () => ({ - getFlappingSettings: jest.fn().mockResolvedValue({ +jest.mock('@kbn/alerts-ui-shared/src/common/apis/fetch_flapping_settings', () => ({ + fetchFlappingSettings: jest.fn().mockResolvedValue({ lookBackWindow: 20, statusChangeThreshold: 20, }), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx index c3f79c3458374..665dd93325c2b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx @@ -62,9 +62,11 @@ import { isActionGroupDisabledForActionTypeId, RuleActionAlertsFilterProperty, RuleActionKey, + Flapping, } from '@kbn/alerting-plugin/common'; import { AlertingConnectorFeatureId } from '@kbn/actions-plugin/common'; import { AlertConsumers } from '@kbn/rule-data-utils'; +import { IS_RULE_SPECIFIC_FLAPPING_ENABLED } from '@kbn/alerts-ui-shared/src/common/constants/rule_flapping'; import { RuleReducerAction, InitialRule } from './rule_reducer'; import { RuleTypeModel, @@ -91,10 +93,7 @@ import { ruleTypeGroupCompare, ruleTypeUngroupedCompare, } from '../../lib/rule_type_compare'; -import { - IS_RULE_SPECIFIC_FLAPPING_ENABLED, - VIEW_LICENSE_OPTIONS_LINK, -} from '../../../common/constants'; +import { VIEW_LICENSE_OPTIONS_LINK } from '../../../common/constants'; import { MULTI_CONSUMER_RULE_TYPE_IDS } from '../../constants'; import { SectionLoading } from '../../components/section_loading'; import { RuleFormConsumerSelection, VALID_CONSUMERS } from './rule_form_consumer_selection'; @@ -882,7 +881,7 @@ export const RuleForm = ({ alertDelay={alertDelay} flappingSettings={rule.flapping} onAlertDelayChange={onAlertDelayChange} - onFlappingChange={(flapping) => setRuleProperty('flapping', flapping)} + onFlappingChange={(flapping) => setRuleProperty('flapping', flapping as Flapping)} enabledFlapping={IS_RULE_SPECIFIC_FLAPPING_ENABLED} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form_advanced_options.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form_advanced_options.test.tsx index f6534f7451405..25c6de0225edb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form_advanced_options.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form_advanced_options.test.tsx @@ -88,7 +88,7 @@ describe('ruleFormAdvancedOptions', () => { expect(screen.getByTestId('ruleFormAdvancedOptionsOverrideSwitch')).not.toBeChecked(); expect(screen.queryByText('Custom')).not.toBeInTheDocument(); expect(screen.getByTestId('ruleSettingsFlappingMessage')).toHaveTextContent( - 'An alert is flapping if it changes status at least 3 times in the last 10 rule runs.' + 'All rules (in this space) detect an alert is flapping when it changes status at least 3 times in the last 10 rule runs.' ); await userEvent.click(screen.getByTestId('ruleFormAdvancedOptionsOverrideSwitch')); @@ -121,7 +121,7 @@ describe('ruleFormAdvancedOptions', () => { expect(screen.getByTestId('lookBackWindowRangeInput')).toHaveValue('6'); expect(screen.getByTestId('statusChangeThresholdRangeInput')).toHaveValue('4'); expect(screen.getByTestId('ruleSettingsFlappingMessage')).toHaveTextContent( - 'An alert is flapping if it changes status at least 4 times in the last 6 rule runs.' + 'This rule detects an alert is flapping if it changes status at least 4 times in the last 6 rule runs.' ); await userEvent.click(screen.getByTestId('ruleFormAdvancedOptionsOverrideSwitch')); @@ -157,6 +157,10 @@ describe('ruleFormAdvancedOptions', () => { expect(screen.queryByText('Custom')).not.toBeInTheDocument(); expect(screen.queryByTestId('ruleFormAdvancedOptionsOverrideSwitch')).not.toBeInTheDocument(); expect(screen.queryByTestId('ruleSettingsFlappingMessage')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByTestId('ruleSettingsFlappingFormTooltipButton')); + + expect(screen.getByTestId('ruleSettingsFlappingFormTooltipContent')).toBeInTheDocument(); }); test('should allow for flapping inputs to be modified', async () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form_advanced_options.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form_advanced_options.tsx index ca6e17451c1aa..00ad6186d58e8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form_advanced_options.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form_advanced_options.tsx @@ -5,36 +5,21 @@ * 2.0. */ -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { - EuiBadge, EuiFieldNumber, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiIconTip, EuiPanel, - EuiSwitch, - EuiText, - useIsWithinMinBreakpoint, - useEuiTheme, - EuiHorizontalRule, - EuiSpacer, - EuiSplitPanel, EuiLoadingSpinner, - EuiLink, - EuiButtonIcon, - EuiPopover, - EuiPopoverTitle, - EuiOutsideClickDetector, } from '@elastic/eui'; -import { RuleSettingsFlappingInputs } from '@kbn/alerts-ui-shared/src/rule_settings/rule_settings_flapping_inputs'; -import { RuleSettingsFlappingMessage } from '@kbn/alerts-ui-shared/src/rule_settings/rule_settings_flapping_message'; -import { Rule } from '@kbn/alerts-ui-shared'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { Flapping } from '@kbn/alerting-plugin/common'; -import { useGetFlappingSettings } from '../../hooks/use_get_flapping_settings'; +import { RuleSpecificFlappingProperties } from '@kbn/alerting-types/rule_settings'; +import { RuleSettingsFlappingForm } from '@kbn/alerts-ui-shared/src/rule_settings/rule_settings_flapping_form'; +import { RuleSettingsFlappingTitleTooltip } from '@kbn/alerts-ui-shared/src/rule_settings/rule_settings_flapping_title_tooltip'; +import { useFetchFlappingSettings } from '@kbn/alerts-ui-shared/src/common/hooks/use_fetch_flapping_settings'; import { useKibana } from '../../../common/lib/kibana'; const alertDelayFormRowLabel = i18n.translate( @@ -66,45 +51,6 @@ const alertDelayAppendLabel = i18n.translate( } ); -const flappingLabel = i18n.translate( - 'xpack.triggersActionsUI.ruleFormAdvancedOptions.flappingLabel', - { - defaultMessage: 'Flapping Detection', - } -); - -const flappingOnLabel = i18n.translate('xpack.triggersActionsUI.ruleFormAdvancedOptions.onLabel', { - defaultMessage: 'ON', -}); - -const flappingOffLabel = i18n.translate( - 'xpack.triggersActionsUI.ruleFormAdvancedOptions.offLabel', - { - defaultMessage: 'OFF', - } -); - -const flappingOverrideLabel = i18n.translate( - 'xpack.triggersActionsUI.ruleFormAdvancedOptions.overrideLabel', - { - defaultMessage: 'Custom', - } -); - -const flappingOverrideConfiguration = i18n.translate( - 'xpack.triggersActionsUI.ruleFormAdvancedOptions.flappingOverrideConfiguration', - { - defaultMessage: 'Override Configuration', - } -); - -const flappingExternalLinkLabel = i18n.translate( - 'xpack.triggersActionsUI.ruleFormAdvancedOptions.flappingExternalLinkLabel', - { - defaultMessage: "What's this?", - } -); - const flappingFormRowLabel = i18n.translate( 'xpack.triggersActionsUI.sections.ruleForm.flappingLabel', { @@ -112,58 +58,13 @@ const flappingFormRowLabel = i18n.translate( } ); -const flappingOffContentRules = i18n.translate( - 'xpack.triggersActionsUI.ruleFormAdvancedOptions.flappingOffContentRules', - { - defaultMessage: 'Rules', - } -); - -const flappingOffContentSettings = i18n.translate( - 'xpack.triggersActionsUI.ruleFormAdvancedOptions.flappingOffContentSettings', - { - defaultMessage: 'Settings', - } -); - -const flappingTitlePopoverFlappingDetection = i18n.translate( - 'xpack.triggersActionsUI.ruleFormAdvancedOptions.flappingTitlePopoverFlappingDetection', - { - defaultMessage: 'flapping detection', - } -); - -const flappingTitlePopoverAlertStatus = i18n.translate( - 'xpack.triggersActionsUI.ruleFormAdvancedOptions.flappingTitlePopoverAlertStatus', - { - defaultMessage: 'alert status change threshold', - } -); - -const flappingTitlePopoverLookBack = i18n.translate( - 'xpack.triggersActionsUI.ruleFormAdvancedOptions.flappingTitlePopoverLookBack', - { - defaultMessage: 'rule run look back window', - } -); - -const clampFlappingValues = (flapping: Rule['flapping']) => { - if (!flapping) { - return; - } - return { - ...flapping, - statusChangeThreshold: Math.min(flapping.lookBackWindow, flapping.statusChangeThreshold), - }; -}; - const INTEGER_REGEX = /^[1-9][0-9]*$/; export interface RuleFormAdvancedOptionsProps { alertDelay?: number; - flappingSettings?: Flapping | null; + flappingSettings?: RuleSpecificFlappingProperties | null; onAlertDelayChange: (value: string) => void; - onFlappingChange: (value: Flapping | null) => void; + onFlappingChange: (value: RuleSpecificFlappingProperties | null) => void; enabledFlapping?: boolean; } @@ -180,20 +81,15 @@ export const RuleFormAdvancedOptions = (props: RuleFormAdvancedOptionsProps) => application: { capabilities: { rulesSettings }, }, + http, } = useKibana().services; - const { writeFlappingSettingsUI = false } = rulesSettings || {}; + const { writeFlappingSettingsUI } = rulesSettings || {}; - const [isFlappingOffPopoverOpen, setIsFlappingOffPopoverOpen] = useState(false); const [isFlappingTitlePopoverOpen, setIsFlappingTitlePopoverOpen] = useState(false); - const cachedFlappingSettings = useRef(); - - const isDesktop = useIsWithinMinBreakpoint('xl'); - - const { euiTheme } = useEuiTheme(); - - const { data: spaceFlappingSettings, isInitialLoading } = useGetFlappingSettings({ + const { data: spaceFlappingSettings, isInitialLoading } = useFetchFlappingSettings({ + http, enabled: enabledFlapping, }); @@ -207,274 +103,6 @@ export const RuleFormAdvancedOptions = (props: RuleFormAdvancedOptionsProps) => [onAlertDelayChange] ); - const internalOnFlappingChange = useCallback( - (flapping: Flapping) => { - const clampedValue = clampFlappingValues(flapping); - if (!clampedValue) { - return; - } - onFlappingChange(clampedValue); - cachedFlappingSettings.current = clampedValue; - }, - [onFlappingChange] - ); - - const onLookBackWindowChange = useCallback( - (value: number) => { - if (!flappingSettings) { - return; - } - internalOnFlappingChange({ - ...flappingSettings, - lookBackWindow: value, - }); - }, - [flappingSettings, internalOnFlappingChange] - ); - - const onStatusChangeThresholdChange = useCallback( - (value: number) => { - if (!flappingSettings) { - return; - } - internalOnFlappingChange({ - ...flappingSettings, - statusChangeThreshold: value, - }); - }, - [flappingSettings, internalOnFlappingChange] - ); - - const onFlappingToggle = useCallback(() => { - if (!spaceFlappingSettings) { - return; - } - if (flappingSettings) { - cachedFlappingSettings.current = flappingSettings; - return onFlappingChange(null); - } - const initialFlappingSettings = cachedFlappingSettings.current || spaceFlappingSettings; - onFlappingChange({ - lookBackWindow: initialFlappingSettings.lookBackWindow, - statusChangeThreshold: initialFlappingSettings.statusChangeThreshold, - }); - }, [spaceFlappingSettings, flappingSettings, onFlappingChange]); - - const flappingTitleTooltip = useMemo(() => { - return ( - setIsFlappingTitlePopoverOpen(false)}> - setIsFlappingTitlePopoverOpen(!isFlappingTitlePopoverOpen)} - /> - } - > - Alert flapping detection - - {flappingTitlePopoverFlappingDetection}, - }} - /> - - - - {flappingTitlePopoverAlertStatus}, - }} - /> - - - - {flappingTitlePopoverLookBack}, - }} - /> - - - - {flappingOffContentRules}, - settings: {flappingOffContentSettings}, - }} - /> - - - - ); - }, [isFlappingTitlePopoverOpen]); - - const flappingOffTooltip = useMemo(() => { - if (!spaceFlappingSettings) { - return null; - } - const { enabled } = spaceFlappingSettings; - if (enabled) { - return null; - } - - if (writeFlappingSettingsUI) { - return ( - setIsFlappingOffPopoverOpen(false)}> - setIsFlappingOffPopoverOpen(!isFlappingOffPopoverOpen)} - /> - } - > - - {flappingOffContentRules}, - settings: {flappingOffContentSettings}, - }} - /> - - - - ); - } - // TODO: Add the external doc link here! - return ( - - {flappingExternalLinkLabel} - - ); - }, [writeFlappingSettingsUI, isFlappingOffPopoverOpen, spaceFlappingSettings]); - - const flappingFormHeader = useMemo(() => { - if (!spaceFlappingSettings) { - return null; - } - const { enabled } = spaceFlappingSettings; - - return ( - - - - - {flappingLabel} - - - {enabled ? flappingOnLabel : flappingOffLabel} - - {flappingSettings && enabled && ( - {flappingOverrideLabel} - )} - - - {enabled && ( - - )} - {flappingOffTooltip} - - - {flappingSettings && enabled && ( - <> - - - - )} - - ); - }, [ - isDesktop, - euiTheme, - spaceFlappingSettings, - flappingSettings, - flappingOffTooltip, - onFlappingToggle, - ]); - - const flappingFormBody = useMemo(() => { - if (!spaceFlappingSettings || !spaceFlappingSettings.enabled) { - return null; - } - if (!flappingSettings) { - return null; - } - return ( - - - - ); - }, [ - flappingSettings, - spaceFlappingSettings, - onLookBackWindowChange, - onStatusChangeThresholdChange, - ]); - - const flappingFormMessage = useMemo(() => { - if (!spaceFlappingSettings || !spaceFlappingSettings.enabled) { - return null; - } - const settingsToUse = flappingSettings || spaceFlappingSettings; - return ( - - - - ); - }, [spaceFlappingSettings, flappingSettings, euiTheme]); - return ( @@ -512,21 +140,23 @@ export const RuleFormAdvancedOptions = (props: RuleFormAdvancedOptionsProps) => label={ {flappingFormRowLabel} - {flappingTitleTooltip} + + + } data-test-subj="alertFlappingFormRow" display="rowCompressed" > - - - - {flappingFormHeader} - {flappingFormBody} - - - {flappingFormMessage} - + )} diff --git a/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts b/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts index 2d6548062eed9..ca87ba3522042 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts @@ -25,9 +25,6 @@ export { I18N_WEEKDAY_OPTIONS_DDD, } from '@kbn/alerts-ui-shared/src/common/constants/i18n_weekdays'; -// Feature flag for frontend rule specific flapping in rule flyout -export const IS_RULE_SPECIFIC_FLAPPING_ENABLED = false; - export const builtInComparators: { [key: string]: Comparator } = { [COMPARATORS.GREATER_THAN]: { text: i18n.translate('xpack.triggersActionsUI.common.constants.comparators.isAboveLabel', { diff --git a/x-pack/test/accessibility/apps/group1/search_profiler.ts b/x-pack/test/accessibility/apps/group1/search_profiler.ts index 522c5e4cf730e..fbd3649120ea1 100644 --- a/x-pack/test/accessibility/apps/group1/search_profiler.ts +++ b/x-pack/test/accessibility/apps/group1/search_profiler.ts @@ -11,7 +11,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'security']); const testSubjects = getService('testSubjects'); - const aceEditor = getService('aceEditor'); + const monacoEditor = getService('monacoEditor'); const a11y = getService('a11y'); const esArchiver = getService('esArchiver'); @@ -27,7 +27,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await esArchiver.unload('x-pack/test/functional/es_archives/logstash_functional'); }); - it('input the JSON in the aceeditor', async () => { + it('input the JSON in the editor', async () => { const input = { query: { bool: { @@ -54,7 +54,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }, }; - await aceEditor.setValue('searchProfilerEditor', JSON.stringify(input)); + await monacoEditor.setCodeEditorValue(JSON.stringify(input), 0); await a11y.testAppSnapshot(); }); diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index fb0194b01be99..3ff3def3f4b70 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -77,6 +77,7 @@ const enabledActionTypes = [ 'test.system-action', 'test.system-action-kibana-privileges', 'test.system-action-connector-adapter', + 'test.connector-with-hooks', ]; export function createTestConfig(name: string, options: CreateTestConfigOptions) { diff --git a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/action_types.ts b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/action_types.ts index f6903da3c62bc..8d5caf79a4c89 100644 --- a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/action_types.ts +++ b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/action_types.ts @@ -76,6 +76,7 @@ export function defineActionTypes( actions.registerType(getNoAttemptsRateLimitedActionType()); actions.registerType(getAuthorizationActionType(core)); actions.registerType(getExcludedActionType()); + actions.registerType(getHookedActionType()); /** * System actions @@ -139,6 +140,96 @@ function getIndexRecordActionType() { return result; } +function getHookedActionType() { + const paramsSchema = schema.object({}); + type ParamsType = TypeOf; + const configSchema = schema.object({ + index: schema.string(), + source: schema.string(), + }); + type ConfigType = TypeOf; + const secretsSchema = schema.object({ + encrypted: schema.string(), + }); + type SecretsType = TypeOf; + const result: ActionType = { + id: 'test.connector-with-hooks', + name: 'Test: Connector with hooks', + minimumLicenseRequired: 'gold', + supportedFeatureIds: ['alerting'], + validate: { + params: { schema: paramsSchema }, + config: { schema: configSchema }, + secrets: { schema: secretsSchema }, + }, + async executor({ config, secrets, params, services, actionId }) { + return { status: 'ok', actionId }; + }, + async preSaveHook({ connectorId, config, secrets, services, isUpdate, logger }) { + const body = { + state: { + connectorId, + config, + secrets, + isUpdate, + }, + reference: 'pre-save', + source: config.source, + }; + logger.info(`running hook pre-save for ${JSON.stringify(body)}`); + await services.scopedClusterClient.asInternalUser.index({ + index: config.index, + refresh: 'wait_for', + body, + }); + }, + async postSaveHook({ + connectorId, + config, + secrets, + services, + logger, + isUpdate, + wasSuccessful, + }) { + const body = { + state: { + connectorId, + config, + secrets, + isUpdate, + wasSuccessful, + }, + reference: 'post-save', + source: config.source, + }; + logger.info(`running hook post-save for ${JSON.stringify(body)}`); + await services.scopedClusterClient.asInternalUser.index({ + index: config.index, + refresh: 'wait_for', + body, + }); + }, + async postDeleteHook({ connectorId, config, services, logger }) { + const body = { + state: { + connectorId, + config, + }, + reference: 'post-delete', + source: config.source, + }; + logger.info(`running hook post-delete for ${JSON.stringify(body)}`); + await services.scopedClusterClient.asInternalUser.index({ + index: config.index, + refresh: 'wait_for', + body, + }); + }, + }; + return result; +} + function getDelayedActionType() { const paramsSchema = schema.object({ delayInMs: schema.number({ defaultValue: 1000 }), diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/openai.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/openai.ts index 05dfc61dd59e3..8a47b6a882456 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/openai.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/openai.ts @@ -147,7 +147,7 @@ export default function genAiTest({ getService }: FtrProviderContext) { statusCode: 400, error: 'Bad Request', message: - 'error validating action type config: types that failed validation:\n- [0.apiProvider]: expected at least one defined value but got [undefined]\n- [1.apiProvider]: expected at least one defined value but got [undefined]', + 'error validating action type config: types that failed validation:\n- [0.apiProvider]: expected at least one defined value but got [undefined]\n- [1.apiProvider]: expected at least one defined value but got [undefined]\n- [2.apiProvider]: expected at least one defined value but got [undefined]', }); }); }); @@ -168,7 +168,7 @@ export default function genAiTest({ getService }: FtrProviderContext) { statusCode: 400, error: 'Bad Request', message: - 'error validating action type config: types that failed validation:\n- [0.apiProvider]: expected value to equal [Azure OpenAI]\n- [1.apiUrl]: expected value of type [string] but got [undefined]', + 'error validating action type config: types that failed validation:\n- [0.apiProvider]: expected value to equal [Azure OpenAI]\n- [1.apiUrl]: expected value of type [string] but got [undefined]\n- [2.apiProvider]: expected value to equal [Other]', }); }); }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/create.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/create.ts index 017fd3e45999b..e05a1ea9e0350 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/create.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/create.ts @@ -7,6 +7,7 @@ import { v4 as uuidv4 } from 'uuid'; import expect from '@kbn/expect'; +import { ESTestIndexTool, ES_TEST_INDEX_NAME } from '@kbn/alerting-api-integration-helpers'; import { UserAtSpaceScenarios } from '../../../scenarios'; import { checkAAD, getUrlPrefix, ObjectRemover } from '../../../../common/lib'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; @@ -15,11 +16,21 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context'; export default function createActionTests({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const supertestWithoutAuth = getService('supertestWithoutAuth'); + const es = getService('es'); + const retry = getService('retry'); + const esTestIndexTool = new ESTestIndexTool(es, retry); describe('create', () => { const objectRemover = new ObjectRemover(supertest); - after(() => objectRemover.removeAll()); + before(async () => { + await esTestIndexTool.destroy(); + await esTestIndexTool.setup(); + }); + after(async () => { + await esTestIndexTool.destroy(); + await objectRemover.removeAll(); + }); for (const scenario of UserAtSpaceScenarios) { const { user, space } = scenario; @@ -396,6 +407,74 @@ export default function createActionTests({ getService }: FtrProviderContext) { throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); } }); + + it('should handle save hooks appropriately', async () => { + const source = uuidv4(); + const encryptedValue = 'This value should be encrypted'; + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/actions/connector`) + .auth(user.username, user.password) + .set('kbn-xsrf', 'foo') + .send({ + name: 'Hooked action', + connector_type_id: 'test.connector-with-hooks', + config: { + index: ES_TEST_INDEX_NAME, + source, + }, + secrets: { + encrypted: encryptedValue, + }, + }); + + const searchResult = await esTestIndexTool.search(source); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'global_read at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(searchResult.body.hits.hits.length).to.eql(0); + break; + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + objectRemover.add(space.id, response.body.id, 'action', 'actions'); + + const refs: string[] = []; + for (const hit of searchResult.body.hits.hits) { + const doc = hit._source as any; + + const reference = doc.reference; + delete doc.reference; + refs.push(reference); + + if (reference === 'post-save') { + expect(doc.state.wasSuccessful).to.be(true); + delete doc.state.wasSuccessful; + } + + const expected = { + state: { + connectorId: response.body.id, + config: { index: ES_TEST_INDEX_NAME, source }, + secrets: { encrypted: encryptedValue }, + isUpdate: false, + }, + source, + }; + expect(doc).to.eql(expected); + } + + refs.sort(); + expect(refs).to.eql(['post-save', 'pre-save']); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); }); } }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/delete.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/delete.ts index b5b11036a3dfd..edb9821418f8d 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/delete.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/delete.ts @@ -5,7 +5,9 @@ * 2.0. */ +import { v4 as uuidv4 } from 'uuid'; import expect from '@kbn/expect'; +import { ESTestIndexTool, ES_TEST_INDEX_NAME } from '@kbn/alerting-api-integration-helpers'; import { UserAtSpaceScenarios } from '../../../scenarios'; import { getUrlPrefix, ObjectRemover } from '../../../../common/lib'; @@ -15,11 +17,21 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context'; export default function deleteActionTests({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const supertestWithoutAuth = getService('supertestWithoutAuth'); + const es = getService('es'); + const retry = getService('retry'); + const esTestIndexTool = new ESTestIndexTool(es, retry); describe('delete', () => { const objectRemover = new ObjectRemover(supertest); - after(() => objectRemover.removeAll()); + before(async () => { + await esTestIndexTool.destroy(); + await esTestIndexTool.setup(); + }); + after(async () => { + await esTestIndexTool.destroy(); + await objectRemover.removeAll(); + }); for (const scenario of UserAtSpaceScenarios) { const { user, space } = scenario; @@ -212,6 +224,77 @@ export default function deleteActionTests({ getService }: FtrProviderContext) { throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); } }); + + it('should handle delete hooks appropriately', async () => { + const source = uuidv4(); + const encryptedValue = 'This value should be encrypted'; + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'Hooked action', + connector_type_id: 'test.connector-with-hooks', + config: { + index: ES_TEST_INDEX_NAME, + source, + }, + secrets: { + encrypted: encryptedValue, + }, + }) + .expect(200); + + // clear out docs from create + await esTestIndexTool.destroy(); + await esTestIndexTool.setup(); + + const response = await supertestWithoutAuth + .delete(`${getUrlPrefix(space.id)}/api/actions/connector/${createdAction.id}`) + .auth(user.username, user.password) + .set('kbn-xsrf', 'foo'); + + const searchResult = await esTestIndexTool.search(source); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'global_read at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(searchResult.body.hits.hits.length).to.eql(0); + objectRemover.add(space.id, createdAction.id, 'action', 'actions'); + break; + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(204); + + const refs: string[] = []; + for (const hit of searchResult.body.hits.hits) { + const doc = hit._source as any; + + const reference = doc.reference; + delete doc.reference; + refs.push(reference); + + const expected = { + state: { + connectorId: createdAction.id, + config: { index: ES_TEST_INDEX_NAME, source }, + }, + source, + }; + expect(doc).to.eql(expected); + } + + refs.sort(); + expect(refs).to.eql(['post-delete']); + break; + default: + objectRemover.add(space.id, createdAction.id, 'action', 'actions'); + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); }); } }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/update.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/update.ts index 7c3c00534f11d..cb9fe8a94c8c0 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/update.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/update.ts @@ -5,7 +5,10 @@ * 2.0. */ +import { v4 as uuidv4 } from 'uuid'; import expect from '@kbn/expect'; +import { ESTestIndexTool, ES_TEST_INDEX_NAME } from '@kbn/alerting-api-integration-helpers'; + import { UserAtSpaceScenarios } from '../../../scenarios'; import { checkAAD, getUrlPrefix, ObjectRemover } from '../../../../common/lib'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; @@ -14,11 +17,21 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context'; export default function updateActionTests({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const supertestWithoutAuth = getService('supertestWithoutAuth'); + const es = getService('es'); + const retry = getService('retry'); + const esTestIndexTool = new ESTestIndexTool(es, retry); describe('update', () => { const objectRemover = new ObjectRemover(supertest); - after(() => objectRemover.removeAll()); + before(async () => { + await esTestIndexTool.destroy(); + await esTestIndexTool.setup(); + }); + after(async () => { + await esTestIndexTool.destroy(); + await objectRemover.removeAll(); + }); for (const scenario of UserAtSpaceScenarios) { const { user, space } = scenario; @@ -430,6 +443,94 @@ export default function updateActionTests({ getService }: FtrProviderContext) { throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); } }); + + it('should handle save hooks appropriately', async () => { + const source = uuidv4(); + const encryptedValue = 'This value should be encrypted'; + + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'Hooked action', + connector_type_id: 'test.connector-with-hooks', + config: { + index: ES_TEST_INDEX_NAME, + source, + }, + secrets: { + encrypted: encryptedValue, + }, + }) + .expect(200); + objectRemover.add(space.id, createdAction.id, 'action', 'actions'); + + // clear out docs from create + await esTestIndexTool.destroy(); + await esTestIndexTool.setup(); + + const response = await supertestWithoutAuth + .put(`${getUrlPrefix(space.id)}/api/actions/connector/${createdAction.id}`) + .auth(user.username, user.password) + .set('kbn-xsrf', 'foo') + .send({ + name: 'Hooked action', + config: { + index: ES_TEST_INDEX_NAME, + source, + }, + secrets: { + encrypted: encryptedValue, + }, + }); + + const searchResult = await esTestIndexTool.search(source); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'global_read at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(searchResult.body.hits.hits.length).to.eql(0); + break; + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + + const refs: string[] = []; + for (const hit of searchResult.body.hits.hits) { + const doc = hit._source as any; + + const reference = doc.reference; + delete doc.reference; + refs.push(reference); + + if (reference === 'post-save') { + expect(doc.state.wasSuccessful).to.be(true); + delete doc.state.wasSuccessful; + } + + const expected = { + state: { + connectorId: response.body.id, + config: { index: ES_TEST_INDEX_NAME, source }, + secrets: { encrypted: encryptedValue }, + isUpdate: true, + }, + source, + }; + expect(doc).to.eql(expected); + } + + refs.sort(); + expect(refs).to.eql(['post-save', 'pre-save']); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); }); } }); diff --git a/x-pack/test/api_integration/apis/cloud_security_posture/helper.ts b/x-pack/test/api_integration/apis/cloud_security_posture/helper.ts index 51f98b5389a9d..3ad0ef88ef75a 100644 --- a/x-pack/test/api_integration/apis/cloud_security_posture/helper.ts +++ b/x-pack/test/api_integration/apis/cloud_security_posture/helper.ts @@ -6,59 +6,12 @@ */ import type { Agent as SuperTestAgent } from 'supertest'; -import { Client } from '@elastic/elasticsearch'; -import expect from '@kbn/expect'; + import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; -import type { IndexDetails } from '@kbn/cloud-security-posture-common'; import { CLOUD_SECURITY_PLUGIN_VERSION } from '@kbn/cloud-security-posture-plugin/common/constants'; import { SecurityService } from '@kbn/ftr-common-functional-ui-services'; import { RoleCredentials } from '@kbn/ftr-common-functional-services'; -export const deleteIndex = async (es: Client, indexToBeDeleted: string[]) => { - return Promise.all([ - ...indexToBeDeleted.map((indexes) => - es.deleteByQuery({ - index: indexes, - query: { - match_all: {}, - }, - ignore_unavailable: true, - refresh: true, - }) - ), - ]); -}; - -export const bulkIndex = async (es: Client, findingsMock: T[], indexName: string) => { - const operations = findingsMock.flatMap((finding) => [ - { create: { _index: indexName } }, // Action description - { - ...finding, - '@timestamp': new Date().toISOString(), - }, // Data to index - ]); - - await es.bulk({ - body: operations, // Bulk API expects 'body' for operations - refresh: true, - }); -}; - -export const addIndex = async (es: Client, findingsMock: T[], indexName: string) => { - await Promise.all([ - ...findingsMock.map((finding) => - es.index({ - index: indexName, - body: { - ...finding, - '@timestamp': new Date().toISOString(), - }, - refresh: true, - }) - ), - ]); -}; - export async function createPackagePolicy( supertest: SuperTestAgent, agentPolicyId: string, @@ -233,10 +186,10 @@ export const createUser = async (security: SecurityService, userName: string, ro }); }; -export const createCSPOnlyRole = async ( +export const createCSPRole = async ( security: SecurityService, roleName: string, - indicesName: string + indicesName?: string[] ) => { await security.role.create(roleName, { kibana: [ @@ -245,12 +198,12 @@ export const createCSPOnlyRole = async ( spaces: ['*'], }, ], - ...(indicesName.length !== 0 + ...(indicesName && indicesName.length > 0 ? { elasticsearch: { indices: [ { - names: [indicesName], + names: indicesName, privileges: ['read'], }, ], @@ -267,15 +220,3 @@ export const deleteRole = async (security: SecurityService, roleName: string) => export const deleteUser = async (security: SecurityService, userName: string) => { await security.user.delete(userName); }; - -export const assertIndexStatus = ( - indicesDetails: IndexDetails[], - indexName: string, - expectedStatus: string -) => { - const actualValue = indicesDetails.find((idx) => idx.index === indexName)?.status; - expect(actualValue).to.eql( - expectedStatus, - `expected ${indexName} status to be ${expectedStatus} but got ${actualValue} instead` - ); -}; diff --git a/x-pack/test/api_integration/apis/cloud_security_posture/status/status_index_timeout.ts b/x-pack/test/api_integration/apis/cloud_security_posture/status/status_index_timeout.ts index ce0c9014478dc..a2949a9f35253 100644 --- a/x-pack/test/api_integration/apis/cloud_security_posture/status/status_index_timeout.ts +++ b/x-pack/test/api_integration/apis/cloud_security_posture/status/status_index_timeout.ts @@ -13,16 +13,10 @@ import { LATEST_FINDINGS_INDEX_DEFAULT_NS, VULNERABILITIES_INDEX_DEFAULT_NS, } from '@kbn/cloud-security-posture-plugin/common/constants'; +import { EsIndexDataProvider } from '../../../../cloud_security_posture_api/utils'; import { generateAgent } from '../../../../fleet_api_integration/helpers'; import { FtrProviderContext } from '../../../ftr_provider_context'; -import { deleteIndex, createPackagePolicy } from '../helper'; - -const INDEX_ARRAY = [ - FINDINGS_INDEX_DEFAULT_NS, - LATEST_FINDINGS_INDEX_DEFAULT_NS, - CDR_LATEST_NATIVE_VULNERABILITIES_INDEX_PATTERN, - VULNERABILITIES_INDEX_DEFAULT_NS, -]; +import { createPackagePolicy } from '../helper'; const currentTimeMinusFourHours = new Date(Date.now() - 21600000).toISOString(); const currentTimeMinusTenMinutes = new Date(Date.now() - 600000).toISOString(); @@ -35,6 +29,13 @@ export default function (providerContext: FtrProviderContext) { const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const fleetAndAgents = getService('fleetAndAgents'); + const findingsIndex = new EsIndexDataProvider(es, FINDINGS_INDEX_DEFAULT_NS); + const latestFindingsIndex = new EsIndexDataProvider(es, LATEST_FINDINGS_INDEX_DEFAULT_NS); + const vulnerabilitiesIndex = new EsIndexDataProvider(es, VULNERABILITIES_INDEX_DEFAULT_NS); + const cdrVulnerabilitiesIndex = new EsIndexDataProvider( + es, + CDR_LATEST_NATIVE_VULNERABILITIES_INDEX_PATTERN + ); describe('GET /internal/cloud_security_posture/status', () => { let agentPolicyId: string; @@ -84,12 +85,20 @@ export default function (providerContext: FtrProviderContext) { .expect(200); await generateAgent(providerContext, 'healthy', `Agent policy test 2`, agentPolicyId); - await deleteIndex(es, INDEX_ARRAY); + await findingsIndex.deleteAll(); + await latestFindingsIndex.deleteAll(); + await vulnerabilitiesIndex.deleteAll(); + await cdrVulnerabilitiesIndex.deleteAll(); }); afterEach(async () => { await kibanaServer.savedObjects.cleanStandardList(); await esArchiver.unload('x-pack/test/functional/es_archives/fleet/empty_fleet_server'); + + await findingsIndex.deleteAll(); + await latestFindingsIndex.deleteAll(); + await vulnerabilitiesIndex.deleteAll(); + await cdrVulnerabilitiesIndex.deleteAll(); }); it(`Should return index-timeout when installed kspm, has findings only on logs-cloud_security_posture.findings-default* and it has been more than 10 minutes since the installation`, async () => { diff --git a/x-pack/test/api_integration/apis/cloud_security_posture/status/status_indexed.ts b/x-pack/test/api_integration/apis/cloud_security_posture/status/status_indexed.ts index 504bb9f504516..ec8b6a09f8bb2 100644 --- a/x-pack/test/api_integration/apis/cloud_security_posture/status/status_indexed.ts +++ b/x-pack/test/api_integration/apis/cloud_security_posture/status/status_indexed.ts @@ -8,28 +8,25 @@ import expect from '@kbn/expect'; import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; import { CDR_LATEST_NATIVE_VULNERABILITIES_INDEX_PATTERN } from '@kbn/cloud-security-posture-common'; import type { CspSetupStatus } from '@kbn/cloud-security-posture-common'; -import { - FINDINGS_INDEX_DEFAULT_NS, - LATEST_FINDINGS_INDEX_DEFAULT_NS, - VULNERABILITIES_INDEX_DEFAULT_NS, -} from '@kbn/cloud-security-posture-plugin/common/constants'; +import { LATEST_FINDINGS_INDEX_DEFAULT_NS } from '@kbn/cloud-security-posture-plugin/common/constants'; import { FtrProviderContext } from '../../../ftr_provider_context'; -import { deleteIndex, addIndex, createPackagePolicy } from '../helper'; +import { EsIndexDataProvider } from '../../../../cloud_security_posture_api/utils'; +import { createPackagePolicy } from '../helper'; import { findingsMockData, vulnerabilityMockData } from '../mock_data'; -const INDEX_ARRAY = [ - FINDINGS_INDEX_DEFAULT_NS, - LATEST_FINDINGS_INDEX_DEFAULT_NS, - CDR_LATEST_NATIVE_VULNERABILITIES_INDEX_PATTERN, - VULNERABILITIES_INDEX_DEFAULT_NS, -]; - export default function (providerContext: FtrProviderContext) { const { getService } = providerContext; const supertest = getService('supertest'); const es = getService('es'); const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); + const latestFindingsIndex = new EsIndexDataProvider(es, LATEST_FINDINGS_INDEX_DEFAULT_NS); + const latestVulnerabilitiesIndex = new EsIndexDataProvider( + es, + CDR_LATEST_NATIVE_VULNERABILITIES_INDEX_PATTERN + ); + const mock3PIndex = 'security_solution-mock-3p-integration.misconfiguration_latest'; + const _3pIndex = new EsIndexDataProvider(es, mock3PIndex); describe('GET /internal/cloud_security_posture/status', () => { let agentPolicyId: string; @@ -50,19 +47,21 @@ export default function (providerContext: FtrProviderContext) { agentPolicyId = agentPolicyResponse.item.id; - await deleteIndex(es, INDEX_ARRAY); - await addIndex(es, findingsMockData, LATEST_FINDINGS_INDEX_DEFAULT_NS); - await addIndex(es, vulnerabilityMockData, CDR_LATEST_NATIVE_VULNERABILITIES_INDEX_PATTERN); + await latestFindingsIndex.deleteAll(); + await latestVulnerabilitiesIndex.deleteAll(); + await _3pIndex.deleteAll(); }); afterEach(async () => { - await deleteIndex(es, INDEX_ARRAY); + await latestFindingsIndex.deleteAll(); + await latestVulnerabilitiesIndex.deleteAll(); + await _3pIndex.destroyIndex(); await kibanaServer.savedObjects.cleanStandardList(); await esArchiver.unload('x-pack/test/functional/es_archives/fleet/empty_fleet_server'); }); it(`Return hasMisconfigurationsFindings true when there are latest findings but no installed integrations`, async () => { - await addIndex(es, findingsMockData, LATEST_FINDINGS_INDEX_DEFAULT_NS); + await latestFindingsIndex.addBulk(findingsMockData); const { body: res }: { body: CspSetupStatus } = await supertest .get(`/internal/cloud_security_posture/status`) @@ -77,9 +76,7 @@ export default function (providerContext: FtrProviderContext) { }); it(`Return hasMisconfigurationsFindings true when there are only findings in third party index`, async () => { - await deleteIndex(es, INDEX_ARRAY); - const mock3PIndex = 'security_solution-mock-3p-integration.misconfiguration_latest'; - await addIndex(es, findingsMockData, mock3PIndex); + await _3pIndex.addBulk(findingsMockData); const { body: res }: { body: CspSetupStatus } = await supertest .get(`/internal/cloud_security_posture/status`) @@ -91,13 +88,9 @@ export default function (providerContext: FtrProviderContext) { true, `expected hasMisconfigurationsFindings to be true but got ${res.hasMisconfigurationsFindings} instead` ); - - await deleteIndex(es, [mock3PIndex]); }); it(`Return hasMisconfigurationsFindings false when there are no findings`, async () => { - await deleteIndex(es, INDEX_ARRAY); - const { body: res }: { body: CspSetupStatus } = await supertest .get(`/internal/cloud_security_posture/status`) .set(ELASTIC_HTTP_VERSION_HEADER, '1') @@ -120,6 +113,8 @@ export default function (providerContext: FtrProviderContext) { 'kspm' ); + await latestFindingsIndex.addBulk(findingsMockData); + const { body: res }: { body: CspSetupStatus } = await supertest .get(`/internal/cloud_security_posture/status`) .set(ELASTIC_HTTP_VERSION_HEADER, '1') @@ -142,6 +137,8 @@ export default function (providerContext: FtrProviderContext) { 'cspm' ); + await latestFindingsIndex.addBulk(findingsMockData); + const { body: res }: { body: CspSetupStatus } = await supertest .get(`/internal/cloud_security_posture/status`) .set(ELASTIC_HTTP_VERSION_HEADER, '1') @@ -164,6 +161,8 @@ export default function (providerContext: FtrProviderContext) { 'vuln_mgmt' ); + await latestVulnerabilitiesIndex.addBulk(vulnerabilityMockData); + const { body: res }: { body: CspSetupStatus } = await supertest .get(`/internal/cloud_security_posture/status`) .set(ELASTIC_HTTP_VERSION_HEADER, '1') diff --git a/x-pack/test/api_integration/apis/cloud_security_posture/status/status_indexing.ts b/x-pack/test/api_integration/apis/cloud_security_posture/status/status_indexing.ts index 4d66d8460b9a4..16ee02083e34c 100644 --- a/x-pack/test/api_integration/apis/cloud_security_posture/status/status_indexing.ts +++ b/x-pack/test/api_integration/apis/cloud_security_posture/status/status_indexing.ts @@ -7,29 +7,23 @@ import expect from '@kbn/expect'; import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; import type { CspSetupStatus } from '@kbn/cloud-security-posture-common'; -import { CDR_LATEST_NATIVE_VULNERABILITIES_INDEX_PATTERN } from '@kbn/cloud-security-posture-common'; import { FINDINGS_INDEX_DEFAULT_NS, - LATEST_FINDINGS_INDEX_DEFAULT_NS, VULNERABILITIES_INDEX_DEFAULT_NS, } from '@kbn/cloud-security-posture-plugin/common/constants'; import { FtrProviderContext } from '../../../ftr_provider_context'; -import { deleteIndex, addIndex, createPackagePolicy } from '../helper'; +import { EsIndexDataProvider } from '../../../../cloud_security_posture_api/utils'; +import { createPackagePolicy } from '../helper'; import { findingsMockData, vulnerabilityMockData } from '../mock_data'; -const INDEX_ARRAY = [ - FINDINGS_INDEX_DEFAULT_NS, - LATEST_FINDINGS_INDEX_DEFAULT_NS, - CDR_LATEST_NATIVE_VULNERABILITIES_INDEX_PATTERN, - VULNERABILITIES_INDEX_DEFAULT_NS, -]; - export default function (providerContext: FtrProviderContext) { const { getService } = providerContext; const supertest = getService('supertest'); const es = getService('es'); const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); + const findingsIndex = new EsIndexDataProvider(es, FINDINGS_INDEX_DEFAULT_NS); + const vulnerabilitiesIndex = new EsIndexDataProvider(es, VULNERABILITIES_INDEX_DEFAULT_NS); describe('GET /internal/cloud_security_posture/status', () => { let agentPolicyId: string; @@ -49,13 +43,13 @@ export default function (providerContext: FtrProviderContext) { }); agentPolicyId = agentPolicyResponse.item.id; - await deleteIndex(es, INDEX_ARRAY); - await addIndex(es, findingsMockData, FINDINGS_INDEX_DEFAULT_NS); - await addIndex(es, vulnerabilityMockData, VULNERABILITIES_INDEX_DEFAULT_NS); + await findingsIndex.deleteAll(); + await vulnerabilitiesIndex.deleteAll(); }); afterEach(async () => { - await deleteIndex(es, INDEX_ARRAY); + await findingsIndex.deleteAll(); + await vulnerabilitiesIndex.deleteAll(); await kibanaServer.savedObjects.cleanStandardList(); await esArchiver.unload('x-pack/test/functional/es_archives/fleet/empty_fleet_server'); }); @@ -70,6 +64,8 @@ export default function (providerContext: FtrProviderContext) { 'kspm' ); + await findingsIndex.addBulk(findingsMockData); + const { body: res }: { body: CspSetupStatus } = await supertest .get(`/internal/cloud_security_posture/status`) .set(ELASTIC_HTTP_VERSION_HEADER, '1') @@ -92,6 +88,8 @@ export default function (providerContext: FtrProviderContext) { 'cspm' ); + await findingsIndex.addBulk(findingsMockData); + const { body: res }: { body: CspSetupStatus } = await supertest .get(`/internal/cloud_security_posture/status`) .set(ELASTIC_HTTP_VERSION_HEADER, '1') @@ -114,6 +112,8 @@ export default function (providerContext: FtrProviderContext) { 'vuln_mgmt' ); + await vulnerabilitiesIndex.addBulk(vulnerabilityMockData); + const { body: res }: { body: CspSetupStatus } = await supertest .get(`/internal/cloud_security_posture/status`) .set(ELASTIC_HTTP_VERSION_HEADER, '1') diff --git a/x-pack/test/api_integration/apis/cloud_security_posture/status/status_unprivileged.ts b/x-pack/test/api_integration/apis/cloud_security_posture/status/status_unprivileged.ts index 7c09e4b51f679..5d0f6207e904a 100644 --- a/x-pack/test/api_integration/apis/cloud_security_posture/status/status_unprivileged.ts +++ b/x-pack/test/api_integration/apis/cloud_security_posture/status/status_unprivileged.ts @@ -13,16 +13,9 @@ import { LATEST_FINDINGS_INDEX_DEFAULT_NS, FINDINGS_INDEX_PATTERN, } from '@kbn/cloud-security-posture-plugin/common/constants'; +import { find, without } from 'lodash'; import { FtrProviderContext } from '../../../ftr_provider_context'; -import { - createPackagePolicy, - createUser, - createCSPOnlyRole, - deleteRole, - deleteUser, - deleteIndex, - assertIndexStatus, -} from '../helper'; +import { createPackagePolicy, createUser, createCSPRole, deleteRole, deleteUser } from '../helper'; const UNPRIVILEGED_ROLE = 'unprivileged_test_role'; const UNPRIVILEGED_USERNAME = 'unprivileged_test_user'; @@ -32,27 +25,36 @@ export default function (providerContext: FtrProviderContext) { const supertest = getService('supertest'); const supertestWithoutAuth = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); - const es = getService('es'); const kibanaServer = getService('kibanaServer'); const security = getService('security'); + const allIndices = [ + LATEST_FINDINGS_INDEX_DEFAULT_NS, + FINDINGS_INDEX_PATTERN, + BENCHMARK_SCORE_INDEX_DEFAULT_NS, + CDR_LATEST_NATIVE_VULNERABILITIES_INDEX_PATTERN, + ]; + describe('GET /internal/cloud_security_posture/status', () => { let agentPolicyId: string; describe('STATUS = UNPRIVILEGED TEST', () => { before(async () => { - await createCSPOnlyRole(security, UNPRIVILEGED_ROLE, ''); + await createCSPRole(security, UNPRIVILEGED_ROLE); await createUser(security, UNPRIVILEGED_USERNAME, UNPRIVILEGED_ROLE); + await esArchiver.loadIfNeeded( + 'x-pack/test/functional/es_archives/fleet/empty_fleet_server' + ); }); after(async () => { await deleteUser(security, UNPRIVILEGED_USERNAME); await deleteRole(security, UNPRIVILEGED_ROLE); + await esArchiver.unload('x-pack/test/functional/es_archives/fleet/empty_fleet_server'); }); beforeEach(async () => { await kibanaServer.savedObjects.cleanStandardList(); - await esArchiver.load('x-pack/test/functional/es_archives/fleet/empty_fleet_server'); const { body: agentPolicyResponse } = await supertest .post(`/api/fleet/agent_policies`) @@ -67,7 +69,6 @@ export default function (providerContext: FtrProviderContext) { }); afterEach(async () => { - await esArchiver.unload('x-pack/test/functional/es_archives/fleet/empty_fleet_server'); await kibanaServer.savedObjects.cleanStandardList(); }); @@ -106,7 +107,6 @@ export default function (providerContext: FtrProviderContext) { describe('status = unprivileged test indices', () => { beforeEach(async () => { await kibanaServer.savedObjects.cleanStandardList(); - await esArchiver.load('x-pack/test/functional/es_archives/fleet/empty_fleet_server'); const { body: agentPolicyResponse } = await supertest .post(`/api/fleet/agent_policies`) @@ -124,11 +124,21 @@ export default function (providerContext: FtrProviderContext) { await deleteUser(security, UNPRIVILEGED_USERNAME); await deleteRole(security, UNPRIVILEGED_ROLE); await kibanaServer.savedObjects.cleanStandardList(); + }); + + before(async () => { + await esArchiver.loadIfNeeded( + 'x-pack/test/functional/es_archives/fleet/empty_fleet_server' + ); + }); + + after(async () => { await esArchiver.unload('x-pack/test/functional/es_archives/fleet/empty_fleet_server'); }); it(`Return unprivileged when missing access to findings_latest index`, async () => { - await createCSPOnlyRole(security, UNPRIVILEGED_ROLE, LATEST_FINDINGS_INDEX_DEFAULT_NS); + const privilegedIndices = without(allIndices, LATEST_FINDINGS_INDEX_DEFAULT_NS); + await createCSPRole(security, UNPRIVILEGED_ROLE, privilegedIndices); await createUser(security, UNPRIVILEGED_USERNAME, UNPRIVILEGED_ROLE); await createPackagePolicy( @@ -149,30 +159,30 @@ export default function (providerContext: FtrProviderContext) { expect(res.kspm.status).to.eql( 'unprivileged', - `expected unprivileged but got ${res.kspm.status} instead` + `kspm status expected unprivileged but got ${res.kspm.status} instead` ); expect(res.cspm.status).to.eql( 'unprivileged', - `expected unprivileged but got ${res.cspm.status} instead` + `cspm status expected unprivileged but got ${res.cspm.status} instead` ); expect(res.vuln_mgmt.status).to.eql( - 'unprivileged', - `expected unprivileged but got ${res.vuln_mgmt.status} instead` + 'not-installed', + `cnvm status expected not_installed but got ${res.vuln_mgmt.status} instead` ); - assertIndexStatus(res.indicesDetails, LATEST_FINDINGS_INDEX_DEFAULT_NS, 'empty'); - assertIndexStatus(res.indicesDetails, FINDINGS_INDEX_PATTERN, 'empty'); - assertIndexStatus(res.indicesDetails, BENCHMARK_SCORE_INDEX_DEFAULT_NS, 'unprivileged'); - assertIndexStatus( - res.indicesDetails, - CDR_LATEST_NATIVE_VULNERABILITIES_INDEX_PATTERN, + expect(res).to.have.property('indicesDetails'); + expect(find(res.indicesDetails, { index: LATEST_FINDINGS_INDEX_DEFAULT_NS })?.status).eql( 'unprivileged' ); + + privilegedIndices.forEach((index) => { + expect(find(res.indicesDetails, { index })?.status).not.eql('unprivileged'); + }); }); it(`Return unprivileged when missing access to score index`, async () => { - await deleteIndex(es, [BENCHMARK_SCORE_INDEX_DEFAULT_NS]); - await createCSPOnlyRole(security, UNPRIVILEGED_ROLE, BENCHMARK_SCORE_INDEX_DEFAULT_NS); + const privilegedIndices = without(allIndices, BENCHMARK_SCORE_INDEX_DEFAULT_NS); + await createCSPRole(security, UNPRIVILEGED_ROLE, privilegedIndices); await createUser(security, UNPRIVILEGED_USERNAME, UNPRIVILEGED_ROLE); await createPackagePolicy( @@ -193,33 +203,33 @@ export default function (providerContext: FtrProviderContext) { expect(res.kspm.status).to.eql( 'unprivileged', - `expected unprivileged but got ${res.kspm.status} instead` + `kspm status expected unprivileged but got ${res.kspm.status} instead` ); expect(res.cspm.status).to.eql( 'unprivileged', - `expected unprivileged but got ${res.cspm.status} instead` + `cspm status expected unprivileged but got ${res.cspm.status} instead` ); expect(res.vuln_mgmt.status).to.eql( 'unprivileged', - `expected unprivileged but got ${res.vuln_mgmt.status} instead` + `cnvm status expected unprivileged but got ${res.vuln_mgmt.status} instead` ); - assertIndexStatus(res.indicesDetails, LATEST_FINDINGS_INDEX_DEFAULT_NS, 'unprivileged'); - assertIndexStatus(res.indicesDetails, FINDINGS_INDEX_PATTERN, 'empty'); - assertIndexStatus(res.indicesDetails, BENCHMARK_SCORE_INDEX_DEFAULT_NS, 'empty'); - assertIndexStatus( - res.indicesDetails, - CDR_LATEST_NATIVE_VULNERABILITIES_INDEX_PATTERN, + expect(res).to.have.property('indicesDetails'); + expect(find(res.indicesDetails, { index: BENCHMARK_SCORE_INDEX_DEFAULT_NS })?.status).eql( 'unprivileged' ); + + privilegedIndices.forEach((index) => { + expect(find(res.indicesDetails, { index })?.status).not.eql('unprivileged'); + }); }); it(`Return unprivileged when missing access to vulnerabilities_latest index`, async () => { - await createCSPOnlyRole( - security, - UNPRIVILEGED_ROLE, + const privilegedIndices = without( + allIndices, CDR_LATEST_NATIVE_VULNERABILITIES_INDEX_PATTERN ); + await createCSPRole(security, UNPRIVILEGED_ROLE, privilegedIndices); await createUser(security, UNPRIVILEGED_USERNAME, UNPRIVILEGED_ROLE); await createPackagePolicy( @@ -239,26 +249,27 @@ export default function (providerContext: FtrProviderContext) { .expect(200); expect(res.kspm.status).to.eql( - 'unprivileged', - `expected unprivileged but got ${res.kspm.status} instead` + 'not-deployed', + `kspm status expected unprivileged but got ${res.kspm.status} instead` ); expect(res.cspm.status).to.eql( - 'unprivileged', - `expected unprivileged but got ${res.cspm.status} instead` + 'not-installed', + `cspm status expected unprivileged but got ${res.cspm.status} instead` ); expect(res.vuln_mgmt.status).to.eql( 'unprivileged', - `expected unprivileged but got ${res.vuln_mgmt.status} instead` + `cnvm status expected unprivileged but got ${res.vuln_mgmt.status} instead` ); - assertIndexStatus(res.indicesDetails, LATEST_FINDINGS_INDEX_DEFAULT_NS, 'unprivileged'); - assertIndexStatus(res.indicesDetails, FINDINGS_INDEX_PATTERN, 'empty'); - assertIndexStatus(res.indicesDetails, BENCHMARK_SCORE_INDEX_DEFAULT_NS, 'unprivileged'); - assertIndexStatus( - res.indicesDetails, - CDR_LATEST_NATIVE_VULNERABILITIES_INDEX_PATTERN, - 'empty' - ); + expect(res).to.have.property('indicesDetails'); + expect( + find(res.indicesDetails, { index: CDR_LATEST_NATIVE_VULNERABILITIES_INDEX_PATTERN }) + ?.status + ).eql('unprivileged'); + + privilegedIndices.forEach((index) => { + expect(find(res.indicesDetails, { index })?.status).not.eql('unprivileged'); + }); }); }); }); diff --git a/x-pack/test/api_integration/apis/entity_manager/definitions.ts b/x-pack/test/api_integration/apis/entity_manager/definitions.ts index 466b5e0232bf0..b51a26ad7b5ad 100644 --- a/x-pack/test/api_integration/apis/entity_manager/definitions.ts +++ b/x-pack/test/api_integration/apis/entity_manager/definitions.ts @@ -8,10 +8,7 @@ import semver from 'semver'; import expect from '@kbn/expect'; import { entityLatestSchema } from '@kbn/entities-schema'; -import { - entityDefinition as mockDefinition, - entityDefinitionWithBackfill as mockBackfillDefinition, -} from '@kbn/entityManager-plugin/server/lib/entities/helpers/fixtures'; +import { entityDefinition as mockDefinition } from '@kbn/entityManager-plugin/server/lib/entities/helpers/fixtures'; import { PartialConfig, cleanup, generate } from '@kbn/data-forge'; import { generateLatestIndexName } from '@kbn/entityManager-plugin/server/lib/entities/helpers/generate_component_id'; import { FtrProviderContext } from '../../ftr_provider_context'; @@ -33,8 +30,9 @@ export default function ({ getService }: FtrProviderContext) { describe('Entity definitions', () => { describe('definitions installations', () => { it('can install multiple definitions', async () => { + const mockDefinitionDup = { ...mockDefinition, id: 'mock_definition_dup' }; await installDefinition(supertest, { definition: mockDefinition }); - await installDefinition(supertest, { definition: mockBackfillDefinition }); + await installDefinition(supertest, { definition: mockDefinitionDup }); const { definitions } = await getInstalledDefinitions(supertest); expect(definitions.length).to.eql(2); @@ -49,7 +47,7 @@ export default function ({ getService }: FtrProviderContext) { expect( definitions.some( (definition) => - definition.id === mockBackfillDefinition.id && + definition.id === mockDefinitionDup.id && definition.state.installed === true && definition.state.running === true ) @@ -57,7 +55,7 @@ export default function ({ getService }: FtrProviderContext) { await Promise.all([ uninstallDefinition(supertest, { id: mockDefinition.id, deleteData: true }), - uninstallDefinition(supertest, { id: mockBackfillDefinition.id, deleteData: true }), + uninstallDefinition(supertest, { id: mockDefinitionDup.id, deleteData: true }), ]); }); @@ -89,7 +87,7 @@ export default function ({ getService }: FtrProviderContext) { id: mockDefinition.id, update: { version: incVersion!, - history: { + latest: { timestampField: '@updatedTimestampField', }, }, @@ -99,7 +97,7 @@ export default function ({ getService }: FtrProviderContext) { definitions: [updatedDefinition], } = await getInstalledDefinitions(supertest); expect(updatedDefinition.version).to.eql(incVersion); - expect(updatedDefinition.history.timestampField).to.eql('@updatedTimestampField'); + expect(updatedDefinition.latest.timestampField).to.eql('@updatedTimestampField'); await uninstallDefinition(supertest, { id: mockDefinition.id }); }); @@ -114,7 +112,7 @@ export default function ({ getService }: FtrProviderContext) { id: mockDefinition.id, update: { version: '1.0.0', - history: { + latest: { timestampField: '@updatedTimestampField', }, }, diff --git a/x-pack/test/api_integration/apis/management/index_management/data_enrichers/ilm.ts b/x-pack/test/api_integration/apis/management/index_management/data_enrichers/ilm.ts index 234a1518a9c59..3ae9b554bf3ee 100644 --- a/x-pack/test/api_integration/apis/management/index_management/data_enrichers/ilm.ts +++ b/x-pack/test/api_integration/apis/management/index_management/data_enrichers/ilm.ts @@ -55,16 +55,17 @@ export default function ({ getService }: FtrProviderContext) { const testAlias = 'test_alias'; const testIlmPolicy = 'test_policy'; describe('GET indices with data enrichers', () => { - before(async () => { + beforeEach(async () => { await createIndex(testIndex); - await createIlmPolicy('test_policy'); - await addPolicyToIndex(testIlmPolicy, testIndex, testAlias); }); - after(async () => { + afterEach(async () => { await esDeleteAllIndices([testIndex]); }); it(`ILM data is fetched by the ILM data enricher`, async () => { + await createIlmPolicy('test_policy'); + await addPolicyToIndex(testIlmPolicy, testIndex, testAlias); + const { body: indices } = await supertest .get(`${API_BASE_PATH}/indices`) .set('kbn-xsrf', 'xxx') @@ -75,5 +76,18 @@ export default function ({ getService }: FtrProviderContext) { const { ilm } = index; expect(ilm.policy).to.eql(testIlmPolicy); }); + + it(`ILM data is not empty even if the index unmanaged`, async () => { + const { body: indices } = await supertest + .get(`${API_BASE_PATH}/indices`) + .set('kbn-xsrf', 'xxx') + .expect(200); + + const index = indices.find((item: Index) => item.name === testIndex); + + const { ilm } = index; + expect(ilm.index).to.eql(testIndex); + expect(ilm.managed).to.eql(false); + }); }); } diff --git a/x-pack/test/api_integration/apis/ml/anomaly_detectors/forecast_with_spaces.ts b/x-pack/test/api_integration/apis/ml/anomaly_detectors/forecast_with_spaces.ts index a330edd9a41d7..32e82c67e348d 100644 --- a/x-pack/test/api_integration/apis/ml/anomaly_detectors/forecast_with_spaces.ts +++ b/x-pack/test/api_integration/apis/ml/anomaly_detectors/forecast_with_spaces.ts @@ -58,7 +58,8 @@ export default ({ getService }: FtrProviderContext) => { return body; } - describe('POST anomaly_detectors _forecast with spaces', function () { + // Failing see: https://github.com/elastic/kibana/issues/195602 + describe.skip('POST anomaly_detectors _forecast with spaces', function () { let forecastId: string; before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); diff --git a/x-pack/test/cloud_security_posture_api/utils.ts b/x-pack/test/cloud_security_posture_api/utils.ts index 6f0d86419a349..9f0805c2e85c1 100644 --- a/x-pack/test/cloud_security_posture_api/utils.ts +++ b/x-pack/test/cloud_security_posture_api/utils.ts @@ -23,7 +23,7 @@ export const waitForPluginInitialized = ({ }: { retry: RetryService; logger: ToolingLog; - supertest: Agent; + supertest: Pick; }): Promise => retry.try(async () => { logger.debug('Check CSP plugin is initialized'); @@ -44,13 +44,16 @@ export class EsIndexDataProvider { this.index = index; } - addBulk(docs: Array>, overrideTimestamp = true) { + async addBulk(docs: Array>, overrideTimestamp = true) { const operations = docs.flatMap((doc) => [ - { index: { _index: this.index } }, + { create: { _index: this.index } }, { ...doc, ...(overrideTimestamp ? { '@timestamp': new Date().toISOString() } : {}) }, ]); - return this.es.bulk({ refresh: 'wait_for', index: this.index, operations }); + const resp = await this.es.bulk({ refresh: 'wait_for', index: this.index, operations }); + expect(resp.errors).eql(false, `Error in bulk indexing: ${JSON.stringify(resp)}`); + + return resp; } async deleteAll() { diff --git a/x-pack/test/functional/apps/data_views/feature_controls/security.ts b/x-pack/test/functional/apps/data_views/feature_controls/security.ts index 1cc62baf0abba..34317932a6b21 100644 --- a/x-pack/test/functional/apps/data_views/feature_controls/security.ts +++ b/x-pack/test/functional/apps/data_views/feature_controls/security.ts @@ -131,10 +131,12 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(navLinks).to.eql(['Stack Management']); }); - it(`index pattern listing doesn't show create button`, async () => { + it(`index pattern listing shows disabled create button`, async () => { await settings.clickKibanaIndexPatterns(); await testSubjects.existOrFail('noDataViewsPrompt'); - await testSubjects.missingOrFail('createDataViewButton'); + const createDataViewButton = await testSubjects.find('createDataViewButton'); + const isDisabled = await createDataViewButton.getAttribute('disabled'); + expect(isDisabled).to.be('true'); }); it(`shows read-only badge`, async () => { diff --git a/x-pack/test/functional/apps/dev_tools/searchprofiler_editor.ts b/x-pack/test/functional/apps/dev_tools/searchprofiler_editor.ts index 174f3d4527178..87c36de62bba6 100644 --- a/x-pack/test/functional/apps/dev_tools/searchprofiler_editor.ts +++ b/x-pack/test/functional/apps/dev_tools/searchprofiler_editor.ts @@ -67,7 +67,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { `parser errors to match expectation: HAS ${expectation ? 'ERRORS' : 'NO ERRORS'}`, async () => { const actual = await PageObjects.searchProfiler.editorHasParseErrors(); - return expectation === actual; + return expectation === actual?.length > 0; } ); } diff --git a/x-pack/test/functional/apps/infra/logs/log_entry_categories_tab.ts b/x-pack/test/functional/apps/infra/logs/log_entry_categories_tab.ts index 33396497fc83c..0d4a5440ebd58 100644 --- a/x-pack/test/functional/apps/infra/logs/log_entry_categories_tab.ts +++ b/x-pack/test/functional/apps/infra/logs/log_entry_categories_tab.ts @@ -9,14 +9,54 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; -export default ({ getService }: FtrProviderContext) => { +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const PageObjects = getPageObjects(['security']); const esArchiver = getService('esArchiver'); const logsUi = getService('logsUi'); const retry = getService('retry'); + const security = getService('security'); describe('Log Entry Categories Tab', function () { this.tags('includeFirefox'); + const loginWithMLPrivileges = async (privileges: Record) => { + await security.role.create('global_logs_role', { + elasticsearch: { + cluster: ['all'], + indices: [{ names: ['*'], privileges: ['read', 'view_index_metadata'] }], + }, + kibana: [ + { + feature: { + logs: ['read'], + ...privileges, + }, + spaces: ['*'], + }, + ], + }); + + await security.user.create('global_logs_read_user', { + password: 'global_logs_read_user-password', + roles: ['global_logs_role'], + full_name: 'logs test user', + }); + + await PageObjects.security.forceLogout(); + + await PageObjects.security.login('global_logs_read_user', 'global_logs_read_user-password', { + expectSpaceSelector: false, + }); + }; + + const logoutAndDeleteUser = async () => { + await PageObjects.security.forceLogout(); + await Promise.all([ + security.role.delete('global_logs_role'), + security.user.delete('global_logs_read_user'), + ]); + }; + describe('with a trial license', () => { it('Shows no data page when indices do not exist', async () => { await logsUi.logEntryCategoriesPage.navigateTo(); @@ -26,14 +66,42 @@ export default ({ getService }: FtrProviderContext) => { }); }); - it('shows setup page when indices exist', async () => { - await esArchiver.load('x-pack/test/functional/es_archives/infra/simple_logs'); - await logsUi.logEntryCategoriesPage.navigateTo(); + describe('when indices exists', () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/infra/metrics_and_logs'); + }); - await retry.try(async () => { - expect(await logsUi.logEntryCategoriesPage.getSetupScreen()).to.be.ok(); + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/infra/metrics_and_logs'); + }); + + it('shows setup page when indices exist', async () => { + await logsUi.logEntryCategoriesPage.navigateTo(); + + await retry.try(async () => { + expect(await logsUi.logEntryCategoriesPage.getSetupScreen()).to.be.ok(); + }); + }); + + it('shows required ml read privileges prompt when the user has not any ml privileges', async () => { + await loginWithMLPrivileges({}); + await logsUi.logEntryCategoriesPage.navigateTo(); + + await retry.try(async () => { + expect(await logsUi.logEntryCategoriesPage.getNoMlReadPrivilegesPrompt()).to.be.ok(); + }); + await logoutAndDeleteUser(); + }); + + it('shows required ml all privileges prompt when the user has only ml read privileges', async () => { + await loginWithMLPrivileges({ ml: ['read'] }); + await logsUi.logEntryCategoriesPage.navigateTo(); + + await retry.try(async () => { + expect(await logsUi.logEntryCategoriesPage.getNoMlAllPrivilegesPrompt()).to.be.ok(); + }); + await logoutAndDeleteUser(); }); - await esArchiver.unload('x-pack/test/functional/es_archives/infra/simple_logs'); }); }); }); diff --git a/x-pack/test/functional/apps/infra/logs/log_entry_rate_tab.ts b/x-pack/test/functional/apps/infra/logs/log_entry_rate_tab.ts index b2b4b5bcfc0be..35aa6ec6ca4ae 100644 --- a/x-pack/test/functional/apps/infra/logs/log_entry_rate_tab.ts +++ b/x-pack/test/functional/apps/infra/logs/log_entry_rate_tab.ts @@ -9,16 +9,56 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; -export default ({ getService }: FtrProviderContext) => { +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const PageObjects = getPageObjects(['security']); const logsUi = getService('logsUi'); const retry = getService('retry'); const esArchiver = getService('esArchiver'); + const security = getService('security'); describe('Log Entry Rate Tab', function () { this.tags('includeFirefox'); + const loginWithMLPrivileges = async (privileges: Record) => { + await security.role.create('global_logs_role', { + elasticsearch: { + cluster: ['all'], + indices: [{ names: ['*'], privileges: ['read', 'view_index_metadata'] }], + }, + kibana: [ + { + feature: { + logs: ['read'], + ...privileges, + }, + spaces: ['*'], + }, + ], + }); + + await security.user.create('global_logs_read_user', { + password: 'global_logs_read_user-password', + roles: ['global_logs_role'], + full_name: 'logs test user', + }); + + await PageObjects.security.forceLogout(); + + await PageObjects.security.login('global_logs_read_user', 'global_logs_read_user-password', { + expectSpaceSelector: false, + }); + }; + + const logoutAndDeleteUser = async () => { + await PageObjects.security.forceLogout(); + await Promise.all([ + security.role.delete('global_logs_role'), + security.user.delete('global_logs_read_user'), + ]); + }; + describe('with a trial license', () => { - it('Shows no data page when indices do not exist', async () => { + it('shows no data page when indices do not exist', async () => { await logsUi.logEntryRatePage.navigateTo(); await retry.try(async () => { @@ -26,14 +66,42 @@ export default ({ getService }: FtrProviderContext) => { }); }); - it('shows setup page when indices exist', async () => { - await esArchiver.load('x-pack/test/functional/es_archives/infra/simple_logs'); - await logsUi.logEntryRatePage.navigateTo(); + describe('when indices exists', () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/infra/metrics_and_logs'); + }); - await retry.try(async () => { - expect(await logsUi.logEntryRatePage.getSetupScreen()).to.be.ok(); + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/infra/metrics_and_logs'); + }); + + it('shows setup page when indices exist', async () => { + await logsUi.logEntryRatePage.navigateTo(); + + await retry.try(async () => { + expect(await logsUi.logEntryRatePage.getSetupScreen()).to.be.ok(); + }); + }); + + it('shows required ml read privileges prompt when the user has not any ml privileges', async () => { + await loginWithMLPrivileges({}); + await logsUi.logEntryRatePage.navigateTo(); + + await retry.try(async () => { + expect(await logsUi.logEntryRatePage.getNoMlReadPrivilegesPrompt()).to.be.ok(); + }); + await logoutAndDeleteUser(); + }); + + it('shows required ml all privileges prompt when the user has only ml read privileges', async () => { + await loginWithMLPrivileges({ ml: ['read'] }); + await logsUi.logEntryRatePage.navigateTo(); + + await retry.try(async () => { + expect(await logsUi.logEntryRatePage.getNoMlAllPrivilegesPrompt()).to.be.ok(); + }); + await logoutAndDeleteUser(); }); - await esArchiver.unload('x-pack/test/functional/es_archives/infra/simple_logs'); }); }); }); diff --git a/x-pack/test/functional/page_objects/search_profiler_page.ts b/x-pack/test/functional/page_objects/search_profiler_page.ts index a110bd16eeafe..151b9a613c356 100644 --- a/x-pack/test/functional/page_objects/search_profiler_page.ts +++ b/x-pack/test/functional/page_objects/search_profiler_page.ts @@ -11,7 +11,7 @@ import { FtrProviderContext } from '../ftr_provider_context'; export function SearchProfilerPageProvider({ getService }: FtrProviderContext) { const find = getService('find'); const testSubjects = getService('testSubjects'); - const aceEditor = getService('aceEditor'); + const monacoEditor = getService('monacoEditor'); const editorTestSubjectSelector = 'searchProfilerEditor'; return { @@ -19,10 +19,10 @@ export function SearchProfilerPageProvider({ getService }: FtrProviderContext) { return await testSubjects.exists(editorTestSubjectSelector); }, async setQuery(query: any) { - await aceEditor.setValue(editorTestSubjectSelector, JSON.stringify(query)); + await monacoEditor.setCodeEditorValue(JSON.stringify(query), 0); }, async getQuery() { - return JSON.parse(await aceEditor.getValue(editorTestSubjectSelector)); + return JSON.parse(await monacoEditor.getCodeEditorValue(0)); }, async setIndexName(indexName: string) { await testSubjects.setValue('indexName', indexName); @@ -36,6 +36,7 @@ export function SearchProfilerPageProvider({ getService }: FtrProviderContext) { }, async getProfileContent() { const profileTree = await find.byClassName('prfDevTool__main__profiletree'); + // const profileTree = await find.byClassName('prfDevTool__page'); return profileTree.getVisibleText(); }, getUrlWithIndexAndQuery({ indexName, query }: { indexName: string; query: any }) { @@ -43,7 +44,7 @@ export function SearchProfilerPageProvider({ getService }: FtrProviderContext) { return `/searchprofiler?index=${indexName}&load_from=${searchQueryURI}`; }, async editorHasParseErrors() { - return await aceEditor.hasParseErrors(editorTestSubjectSelector); + return await monacoEditor.getCurrentMarkers(editorTestSubjectSelector); }, async editorHasErrorNotification() { const notification = await testSubjects.find('noShardsNotification'); diff --git a/x-pack/test/functional/services/logs_ui/log_entry_categories.ts b/x-pack/test/functional/services/logs_ui/log_entry_categories.ts index 77098bd918ea6..d270b510bffbd 100644 --- a/x-pack/test/functional/services/logs_ui/log_entry_categories.ts +++ b/x-pack/test/functional/services/logs_ui/log_entry_categories.ts @@ -24,5 +24,13 @@ export function LogEntryCategoriesPageProvider({ getPageObjects, getService }: F async getSetupScreen(): Promise { return await testSubjects.find('logEntryCategoriesSetupPage'); }, + + getNoMlReadPrivilegesPrompt() { + return testSubjects.find('logsMissingMLReadPrivileges'); + }, + + getNoMlAllPrivilegesPrompt() { + return testSubjects.find('logsMissingMLAllPrivileges'); + }, }; } diff --git a/x-pack/test/functional/services/logs_ui/log_entry_rate.ts b/x-pack/test/functional/services/logs_ui/log_entry_rate.ts index f8a68f6c924e0..9b704db9eb021 100644 --- a/x-pack/test/functional/services/logs_ui/log_entry_rate.ts +++ b/x-pack/test/functional/services/logs_ui/log_entry_rate.ts @@ -29,6 +29,14 @@ export function LogEntryRatePageProvider({ getPageObjects, getService }: FtrProv return await testSubjects.find('noDataPage'); }, + getNoMlReadPrivilegesPrompt() { + return testSubjects.find('logsMissingMLReadPrivileges'); + }, + + getNoMlAllPrivilegesPrompt() { + return testSubjects.find('logsMissingMLAllPrivileges'); + }, + async startJobSetup() { await testSubjects.click('infraLogEntryRateSetupContentMlSetupButton'); }, diff --git a/x-pack/test/functional_execution_context/tests/browser.ts b/x-pack/test/functional_execution_context/tests/browser.ts index e1d7ba6a3b965..c7228528ee756 100644 --- a/x-pack/test/functional_execution_context/tests/browser.ts +++ b/x-pack/test/functional_execution_context/tests/browser.ts @@ -85,14 +85,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { type: 'application', name: 'discover', url: '/app/discover', - child: { - name: 'discover', - url: '/app/discover', - type: 'application', - page: 'app', - id: 'new', - description: 'fetch documents', - }, + page: 'app', + id: 'new', + description: 'fetch documents', }), }); }); @@ -105,20 +100,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { type: 'application', name: 'discover', url: '/app/discover', + page: 'app', + id: 'new', + description: 'fetch chart data and total hits', child: { - name: 'discover', - url: '/app/discover', - type: 'application', - page: 'app', - id: 'new', - description: 'fetch chart data and total hits', - child: { - type: 'lens', - name: 'lnsXY', - id: 'unifiedHistogramLensComponent', - description: 'Edit visualization', - url: '/app/lens#/edit_by_value', - }, + type: 'lens', + name: 'lnsXY', + id: 'unifiedHistogramLensComponent', + description: 'Edit visualization', + url: '/app/lens#/edit_by_value', }, }), }); @@ -185,9 +175,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('propagates to Elasticsearch via "x-opaque-id" header', async () => { await logContains({ description: 'execution context propagates to Elasticsearch via "x-opaque-id" header', - predicate: checkHttpRequestId( - 'dashboard:dashboards:7adfa750-4c81-11e8-b3d7-01146121b73d;lens:lnsXY:086ac2e9-dd16-4b45-92b8-1e43ff7e3f65' - ), + predicate: checkHttpRequestId('lens:lnsXY:086ac2e9-dd16-4b45-92b8-1e43ff7e3f65'), }); }); @@ -195,23 +183,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await logContains({ description: 'execution context propagates to Kibana logs', predicate: checkExecutionContextEntry({ - type: 'application', + type: 'dashboard', name: 'dashboards', url: '/app/dashboards', + page: 'app', + id: '7adfa750-4c81-11e8-b3d7-01146121b73d', + description: '[Flights] Global Flight Dashboard', child: { - name: 'dashboards', - url: '/app/dashboards', - type: 'dashboard', - page: 'app', - id: '7adfa750-4c81-11e8-b3d7-01146121b73d', - description: '[Flights] Global Flight Dashboard', - child: { - type: 'lens', - name: 'lnsXY', - id: '086ac2e9-dd16-4b45-92b8-1e43ff7e3f65', - description: '[Flights] Flight count', - url: '/app/lens#/edit_by_value', - }, + type: 'lens', + name: 'lnsXY', + id: '086ac2e9-dd16-4b45-92b8-1e43ff7e3f65', + description: '[Flights] Flight count', + url: '/app/lens#/edit_by_value', }, }), }); @@ -222,9 +205,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('propagates to Elasticsearch via "x-opaque-id" header', async () => { await logContains({ description: 'execution context propagates to Elasticsearch via "x-opaque-id" header', - predicate: checkHttpRequestId( - 'dashboard:dashboards:7adfa750-4c81-11e8-b3d7-01146121b73d;lens:lnsMetric:b766e3b8-4544-46ed-99e6-9ecc4847e2a2' - ), + predicate: checkHttpRequestId('lens:lnsMetric:b766e3b8-4544-46ed-99e6-9ecc4847e2a2'), }); }); @@ -232,23 +213,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await logContains({ description: 'execution context propagates to Kibana logs', predicate: checkExecutionContextEntry({ + type: 'dashboard', name: 'dashboards', url: '/app/dashboards', - type: 'application', + page: 'app', + id: '7adfa750-4c81-11e8-b3d7-01146121b73d', + description: '[Flights] Global Flight Dashboard', child: { - name: 'dashboards', - url: '/app/dashboards', - type: 'dashboard', - page: 'app', - id: '7adfa750-4c81-11e8-b3d7-01146121b73d', - description: '[Flights] Global Flight Dashboard', - child: { - type: 'lens', - name: 'lnsMetric', - id: 'b766e3b8-4544-46ed-99e6-9ecc4847e2a2', - description: '', - url: '/app/lens#/edit_by_value', - }, + type: 'lens', + name: 'lnsMetric', + id: 'b766e3b8-4544-46ed-99e6-9ecc4847e2a2', + description: '', + url: '/app/lens#/edit_by_value', }, }), }); @@ -260,7 +236,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await logContains({ description: 'execution context propagates to Elasticsearch via "x-opaque-id" header', predicate: checkHttpRequestId( - 'dashboard:dashboards:7adfa750-4c81-11e8-b3d7-01146121b73d;lens:lnsDatatable:fb86b32f-fb7a-45cf-9511-f366fef51bbd' + 'lens:lnsDatatable:fb86b32f-fb7a-45cf-9511-f366fef51bbd' ), }); }); @@ -269,23 +245,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await logContains({ description: 'execution context propagates to Kibana logs', predicate: checkExecutionContextEntry({ + type: 'dashboard', name: 'dashboards', url: '/app/dashboards', - type: 'application', + page: 'app', + id: '7adfa750-4c81-11e8-b3d7-01146121b73d', + description: '[Flights] Global Flight Dashboard', child: { - name: 'dashboards', - url: '/app/dashboards', - type: 'dashboard', - page: 'app', - id: '7adfa750-4c81-11e8-b3d7-01146121b73d', - description: '[Flights] Global Flight Dashboard', - child: { - type: 'lens', - name: 'lnsDatatable', - id: 'fb86b32f-fb7a-45cf-9511-f366fef51bbd', - description: 'Cities by delay, cancellation', - url: '/app/lens#/edit_by_value', - }, + type: 'lens', + name: 'lnsDatatable', + id: 'fb86b32f-fb7a-45cf-9511-f366fef51bbd', + description: 'Cities by delay, cancellation', + url: '/app/lens#/edit_by_value', }, }), }); @@ -296,9 +267,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('propagates to Elasticsearch via "x-opaque-id" header', async () => { await logContains({ description: 'execution context propagates to Elasticsearch via "x-opaque-id" header', - predicate: checkHttpRequestId( - 'dashboard:dashboards:7adfa750-4c81-11e8-b3d7-01146121b73d;lens:lnsPie:5d53db36-2d5a-4adc-af7b-cec4c1a294e0' - ), + predicate: checkHttpRequestId('lens:lnsPie:5d53db36-2d5a-4adc-af7b-cec4c1a294e0'), }); }); @@ -306,23 +275,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await logContains({ description: 'execution context propagates to Kibana logs', predicate: checkExecutionContextEntry({ + type: 'dashboard', name: 'dashboards', url: '/app/dashboards', - type: 'application', + page: 'app', + id: '7adfa750-4c81-11e8-b3d7-01146121b73d', + description: '[Flights] Global Flight Dashboard', child: { - name: 'dashboards', - url: '/app/dashboards', - type: 'dashboard', - page: 'app', - id: '7adfa750-4c81-11e8-b3d7-01146121b73d', - description: '[Flights] Global Flight Dashboard', - child: { - type: 'lens', - name: 'lnsPie', - id: '5d53db36-2d5a-4adc-af7b-cec4c1a294e0', - description: '[Flights] Delay Type', - url: '/app/lens#/edit_by_value', - }, + type: 'lens', + name: 'lnsPie', + id: '5d53db36-2d5a-4adc-af7b-cec4c1a294e0', + description: '[Flights] Delay Type', + url: '/app/lens#/edit_by_value', }, }), }); @@ -334,9 +298,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('propagates to Elasticsearch via "x-opaque-id" header', async () => { await logContains({ description: 'execution context propagates to Elasticsearch via "x-opaque-id" header', - predicate: checkHttpRequestId( - 'dashboard:dashboards:7adfa750-4c81-11e8-b3d7-01146121b73d;search:discover:571aaf70-4c88-11e8-b3d7-01146121b73d' - ), + predicate: checkHttpRequestId('search:discover:571aaf70-4c88-11e8-b3d7-01146121b73d'), }); }); @@ -344,23 +306,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await logContains({ description: 'execution context propagates to Kibana logs', predicate: checkExecutionContextEntry({ - type: 'application', + type: 'dashboard', name: 'dashboards', url: '/app/dashboards', + page: 'app', + id: '7adfa750-4c81-11e8-b3d7-01146121b73d', + description: '[Flights] Global Flight Dashboard', child: { - type: 'dashboard', - name: 'dashboards', - url: '/app/dashboards', - page: 'app', - id: '7adfa750-4c81-11e8-b3d7-01146121b73d', - description: '[Flights] Global Flight Dashboard', - child: { - type: 'search', - name: 'discover', - id: '571aaf70-4c88-11e8-b3d7-01146121b73d', - description: '[Flights] Flight Log', - url: '/app/discover#/view/571aaf70-4c88-11e8-b3d7-01146121b73d', - }, + type: 'search', + name: 'discover', + id: '571aaf70-4c88-11e8-b3d7-01146121b73d', + description: '[Flights] Flight Log', + url: '/app/discover#/view/571aaf70-4c88-11e8-b3d7-01146121b73d', }, }), }); diff --git a/x-pack/test/reporting_functional/reporting_and_security/management.ts b/x-pack/test/reporting_functional/reporting_and_security/management.ts index 570c1bbdda4c7..b1a6c107b9bb7 100644 --- a/x-pack/test/reporting_functional/reporting_and_security/management.ts +++ b/x-pack/test/reporting_functional/reporting_and_security/management.ts @@ -57,6 +57,7 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => { }); // FLAKY: https://github.com/elastic/kibana/issues/195144 + // FLAKY: https://github.com/elastic/kibana/issues/194731 describe.skip('Download report', () => { // use archived reports to allow reporting_user to view report jobs they've created before('log in as reporting user', async () => { diff --git a/x-pack/test/security_solution_api_integration/config/ess/config.base.ts b/x-pack/test/security_solution_api_integration/config/ess/config.base.ts index 705c0b8686dd0..a0d2ee79a7b46 100644 --- a/x-pack/test/security_solution_api_integration/config/ess/config.base.ts +++ b/x-pack/test/security_solution_api_integration/config/ess/config.base.ts @@ -82,10 +82,8 @@ export function createTestConfig(options: CreateTestConfigOptions, testFiles?: s '--xpack.ruleRegistry.unsafe.legacyMultiTenancy.enabled=true', `--xpack.securitySolution.enableExperimental=${JSON.stringify([ 'previewTelemetryUrlEnabled', - 'loggingRequestsEnabled', 'riskScoringPersistence', 'riskScoringRoutesEnabled', - 'manualRuleRunEnabled', ])}`, '--xpack.task_manager.poll_interval=1000', `--xpack.actions.preconfigured=${JSON.stringify(PRECONFIGURED_ACTION_CONNECTORS)}`, diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/configs/serverless.config.ts index 8f64a859b7002..137ee1f67b9b3 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/configs/serverless.config.ts @@ -17,9 +17,5 @@ export default createTestConfig({ 'testing_ignored.constant', '/testing_regex*/', ])}`, // See tests within the file "ignore_fields.ts" which use these values in "alertIgnoreFields" - `--xpack.securitySolution.enableExperimental=${JSON.stringify([ - 'manualRuleRunEnabled', - 'loggingRequestsEnabled', - ])}`, ], }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/eql.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/eql.ts index aff2ccc6bccb3..9077873274fa5 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/eql.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/eql.ts @@ -1190,8 +1190,7 @@ export default ({ getService }: FtrProviderContext) => { }); }); - // skipped on MKI since feature flags are not supported there - describe('@skipInServerlessMKI preview logged requests', () => { + describe('preview logged requests', () => { it('should not return requests property when not enabled', async () => { const { logs } = await previewRule({ supertest, diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/esql.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/esql.ts index 166a62b9b08ad..ee976de14186d 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/esql.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/esql.ts @@ -1409,8 +1409,7 @@ export default ({ getService }: FtrProviderContext) => { }); }); - // skipped on MKI since feature flags are not supported there - describe('@skipInServerlessMKI preview logged requests', () => { + describe('preview logged requests', () => { let rule: EsqlRuleCreateProps; let id: string; beforeEach(async () => { diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/index.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/index.ts index 2dc37a8b900f7..ffb728e23d31b 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/index.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/index.ts @@ -23,6 +23,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./indicator_match_alert_suppression')); loadTestFile(require.resolve('./threshold')); loadTestFile(require.resolve('./threshold_alert_suppression')); + loadTestFile(require.resolve('./synthetic_source')); loadTestFile(require.resolve('./non_ecs_fields')); loadTestFile(require.resolve('./custom_query')); }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/synthetic_source.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/synthetic_source.ts new file mode 100644 index 0000000000000..e70fa226213d5 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/synthetic_source.ts @@ -0,0 +1,465 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from 'expect'; +import { v4 as uuidv4 } from 'uuid'; + +import { QueryRuleCreateProps } from '@kbn/security-solution-plugin/common/api/detection_engine'; +import { + getPreviewAlerts, + previewRule, + dataGeneratorFactory, + setSyntheticSource, +} from '../../../../utils'; +import { + deleteAllRules, + deleteAllAlerts, + getRuleForAlertTesting, +} from '../../../../../../../common/utils/security_solution'; +import { FtrProviderContext } from '../../../../../../ftr_provider_context'; + +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + const log = getService('log'); + + const getRuleProps = (id: string, index: string): QueryRuleCreateProps => { + return { + ...getRuleForAlertTesting([index]), + query: `id:${id}`, + from: 'now-1h', + interval: '1h', + }; + }; + + describe('@ess @serverless synthetic source', () => { + describe('synthetic source limitations', () => { + const index = 'ecs_compliant'; + const { indexListOfDocuments } = dataGeneratorFactory({ es, index, log }); + + before(async () => { + await esArchiver.load(`x-pack/test/functional/es_archives/security_solution/${index}`); + await setSyntheticSource({ es, index }); + }); + + after(async () => { + await esArchiver.unload(`x-pack/test/functional/es_archives/security_solution/${index}`); + await deleteAllAlerts(supertest, log, es); + await deleteAllRules(supertest, log); + }); + + it('should convert dot-notation to nested objects', async () => { + const id = uuidv4(); + const timestamp = '2020-10-28T06:00:00.000Z'; + + const firstDoc = { + id, + '@timestamp': timestamp, + 'agent.name': 'agent-1', + }; + + await indexListOfDocuments([firstDoc]); + + const { previewId } = await previewRule({ + supertest, + rule: getRuleProps(id, index), + timeframeEnd: new Date('2020-10-28T06:30:00.000Z'), + invocationCount: 1, + }); + + const previewAlerts = await getPreviewAlerts({ + es, + previewId, + }); + + expect(previewAlerts.length).toEqual(1); + expect(previewAlerts[0]._source).toEqual({ + ...previewAlerts[0]._source, + // agent.name returned as nested object, but was indexed in original document with dot-notation + agent: { name: 'agent-1' }, + }); + }); + + it('should removed duplicated values in array', async () => { + const id = uuidv4(); + const timestamp = '2020-10-28T06:00:00.000Z'; + + const firstDoc = { + id, + '@timestamp': timestamp, + client: { ip: ['127.0.0.1', '127.0.0.1', '127.0.0.2'] }, + }; + + await indexListOfDocuments([firstDoc]); + + const { previewId } = await previewRule({ + supertest, + rule: getRuleProps(id, index), + timeframeEnd: new Date('2020-10-28T06:30:00.000Z'), + invocationCount: 1, + }); + + const previewAlerts = await getPreviewAlerts({ + es, + previewId, + }); + + expect(previewAlerts.length).toEqual(1); + expect(previewAlerts[0]._source).toEqual({ + ...previewAlerts[0]._source, + client: { ip: ['127.0.0.1', '127.0.0.2'] }, + }); + }); + + it('should sort duplicated values in array', async () => { + const id = uuidv4(); + const timestamp = '2020-10-28T06:00:00.000Z'; + + const firstDoc = { + id, + '@timestamp': timestamp, + client: { ip: ['127.0.0.3', '211.0.0.2', '127.0.0.1'] }, + }; + + await indexListOfDocuments([firstDoc]); + + const { previewId } = await previewRule({ + supertest, + rule: getRuleProps(id, index), + timeframeEnd: new Date('2020-10-28T06:30:00.000Z'), + invocationCount: 1, + }); + + const previewAlerts = await getPreviewAlerts({ + es, + previewId, + }); + + expect(previewAlerts.length).toEqual(1); + expect(previewAlerts[0]._source).toEqual({ + ...previewAlerts[0]._source, + client: { ip: ['127.0.0.1', '127.0.0.3', '211.0.0.2'] }, + }); + }); + + it('should convert array of objects to leaf structure', async () => { + const id = uuidv4(); + const timestamp = '2020-10-28T06:00:00.000Z'; + + const firstDoc = { + id, + '@timestamp': timestamp, + client: [{ ip: ['127.0.0.1'] }, { ip: ['127.0.0.2'] }], + }; + + await indexListOfDocuments([firstDoc]); + + const { previewId } = await previewRule({ + supertest, + rule: getRuleProps(id, index), + timeframeEnd: new Date('2020-10-28T06:30:00.000Z'), + invocationCount: 1, + }); + + const previewAlerts = await getPreviewAlerts({ + es, + previewId, + }); + + expect(previewAlerts.length).toEqual(1); + expect(previewAlerts[0]._source).toEqual({ + ...previewAlerts[0]._source, + client: { ip: ['127.0.0.1', '127.0.0.2'] }, + }); + }); + }); + + // this set of tests represent corrected failed test suits in https://github.com/elastic/kibana/pull/191527#issuecomment-2360684346 + // and ensures non-ecs fields are stripped when source mode is synthetic + describe('non ecs fields', () => { + const index = 'ecs_non_compliant'; + const { indexListOfDocuments } = dataGeneratorFactory({ es, index, log }); + const timestamp = '2020-10-28T06:00:00.000Z'; + + before(async () => { + await esArchiver.load(`x-pack/test/functional/es_archives/security_solution/${index}`); + await setSyntheticSource({ es, index }); + }); + + after(async () => { + await esArchiver.unload(`x-pack/test/functional/es_archives/security_solution/${index}`); + await deleteAllAlerts(supertest, log, es); + await deleteAllRules(supertest, log); + }); + + it('should not add multi field .text to ecs compliant flattened source', async () => { + const id = uuidv4(); + + const firstDoc = { + id, + '@timestamp': timestamp, + 'process.command_line': 'string longer than 10 characters', + }; + + await indexListOfDocuments([firstDoc]); + + const { previewId } = await previewRule({ + supertest, + rule: getRuleProps(id, index), + timeframeEnd: new Date('2020-10-28T06:30:00.000Z'), + invocationCount: 1, + }); + + const previewAlerts = await getPreviewAlerts({ + es, + previewId, + }); + + expect(previewAlerts[0]?._source?.process).toEqual({ + command_line: 'string longer than 10 characters', + }); + expect(previewAlerts[0]?._source).not.toHaveProperty('process.command_line.text'); + }); + + it('should not add multi field .text to ecs non compliant flattened source', async () => { + const id = uuidv4(); + + const firstDoc = { + id, + '@timestamp': timestamp, + 'nonEcs.command_line': 'string longer than 10 characters', + }; + + await indexListOfDocuments([firstDoc]); + + const { previewId } = await previewRule({ + supertest, + rule: getRuleProps(id, index), + timeframeEnd: new Date('2020-10-28T06:30:00.000Z'), + invocationCount: 1, + }); + + const previewAlerts = await getPreviewAlerts({ + es, + previewId, + }); + + expect(previewAlerts[0]?._source?.nonEcs).toEqual({ + command_line: 'string longer than 10 characters', + }); + expect(previewAlerts[0]?._source).not.toHaveProperty('process.nonEcs.text'); + }); + + it('should remove text field if the length of the string is more than 32766 bytes', async () => { + const id = uuidv4(); + + const document = { + id, + '@timestamp': timestamp, + 'event.original': 'z'.repeat(32767), + 'event.module': 'z'.repeat(32767), + 'event.action': 'z'.repeat(32767), + }; + + await indexListOfDocuments([document]); + + const { previewId } = await previewRule({ + supertest, + rule: getRuleProps(id, index), + timeframeEnd: new Date('2020-10-28T06:30:00.000Z'), + invocationCount: 1, + }); + + const previewAlerts = await getPreviewAlerts({ + es, + previewId, + }); + + const alertSource = previewAlerts[0]?._source; + + // keywords with `ignore_above` attribute which allows long text to be stored + expect(alertSource).toHaveProperty(['kibana.alert.original_event.module']); + expect(alertSource).toHaveProperty(['kibana.alert.original_event.original']); + expect(alertSource).toHaveProperty(['kibana.alert.original_event.action']); + + expect(alertSource?.event).toHaveProperty(['module']); + expect(alertSource?.event).toHaveProperty(['original']); + expect(alertSource?.event).toHaveProperty(['action']); + }); + + it('should not remove valid dates from ECS source field', async () => { + const id = uuidv4(); + + const validDates = [ + '2015-01-01T12:10:30.666Z', + '2015-01-01T12:10:30.666', + '2015-01-01T12:10:30Z', + '2015-01-01T12:10:30', + '2015-01-01T12:10Z', + '2015-01-01T12:10', + '2015-01-01T12Z', + '2015-01-01T12', + '2015-01-01', + '2015-01', + '2015-01-02T', + 123.3, + '23242', + -1, + '-1', + 0, + '0', + ]; + const document = { + id, + '@timestamp': timestamp, + event: { + created: validDates, + }, + }; + await indexListOfDocuments([document]); + + const { previewId } = await previewRule({ + supertest, + rule: getRuleProps(id, index), + timeframeEnd: new Date('2020-10-28T06:30:00.000Z'), + invocationCount: 1, + }); + + const previewAlerts = await getPreviewAlerts({ + es, + previewId, + }); + + // array of dates became sorted and duplicates removed + expect(previewAlerts[0]?._source).toHaveProperty( + ['event', 'created'], + [ + '-1', + '0', + '123.3', + '2015-01', + '2015-01-01', + '2015-01-01T12', + '2015-01-01T12:10', + '2015-01-01T12:10:30', + '2015-01-01T12:10:30.666', + '2015-01-01T12:10:30.666Z', + '2015-01-01T12:10:30Z', + '2015-01-01T12:10Z', + '2015-01-01T12Z', + '2015-01-02T', + '23242', + ] + ); + }); + + it('should not remove valid ips from ECS source field', async () => { + const id = uuidv4(); + const ip = [ + '127.0.0.1', + '::afff:4567:890a', + '::', + '::11.22.33.44', + '1111:2222:3333:4444:AAAA:BBBB:CCCC:DDDD', + ]; + + const document = { + id, + '@timestamp': timestamp, + client: { ip }, + }; + await indexListOfDocuments([document]); + + const { previewId } = await previewRule({ + supertest, + rule: getRuleProps(id, index), + timeframeEnd: new Date('2020-10-28T06:30:00.000Z'), + invocationCount: 1, + }); + + const previewAlerts = await getPreviewAlerts({ + es, + previewId, + }); + + // array of dates became sorted + expect(previewAlerts[0]?._source).toHaveProperty('client.ip', [ + '1111:2222:3333:4444:AAAA:BBBB:CCCC:DDDD', + '127.0.0.1', + '::', + '::11.22.33.44', + '::afff:4567:890a', + ]); + }); + + it('should remove source array of keywords field from alert if ECS field mapping is nested', async () => { + const id = uuidv4(); + + const document = { + id, + '@timestamp': timestamp, + threat: { + enrichments: ['non-valid-threat-1', 'non-valid-threat-2'], + 'indicator.port': 443, + }, + }; + await indexListOfDocuments([document]); + + const { previewId } = await previewRule({ + supertest, + rule: getRuleProps(id, index), + timeframeEnd: new Date('2020-10-28T06:30:00.000Z'), + invocationCount: 1, + }); + + const previewAlerts = await getPreviewAlerts({ + es, + previewId, + }); + + expect(previewAlerts[0]?._source).not.toHaveProperty('threat.enrichments'); + + expect(previewAlerts[0]?._source).toHaveProperty(['threat', 'indicator', 'port'], 443); + }); + + it('should strip invalid boolean values and left valid ones', async () => { + const id = uuidv4(); + + const document = { + id, + '@timestamp': timestamp, + dll: { + code_signature: { + valid: ['non-valid', 'true', 'false', [true, false], '', 'False', 'True', 1], + }, + }, + }; + await indexListOfDocuments([document]); + + const { previewId } = await previewRule({ + supertest, + rule: getRuleProps(id, index), + timeframeEnd: new Date('2020-10-28T06:30:00.000Z'), + invocationCount: 1, + }); + + const previewAlerts = await getPreviewAlerts({ + es, + previewId, + }); + + // invalid ECS values is getting removed, duplicates not stored in synthetic source + expect(previewAlerts[0]?._source).toHaveProperty('dll.code_signature.valid', [ + '', + 'false', + 'true', + ]); + }); + }); + }); +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_gaps/trial_license_complete_tier/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_gaps/trial_license_complete_tier/configs/serverless.config.ts index 783adb64f6c2e..43904f7c217f3 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_gaps/trial_license_complete_tier/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_gaps/trial_license_complete_tier/configs/serverless.config.ts @@ -16,6 +16,5 @@ export default createTestConfig({ 'testing_ignored.constant', '/testing_regex*/', ])}`, // See tests within the file "ignore_fields.ts" which use these values in "alertIgnoreFields" - `--xpack.securitySolution.enableExperimental=${JSON.stringify(['manualRuleRunEnabled'])}`, ], }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_gaps/trial_license_complete_tier/manual_rule_run.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_gaps/trial_license_complete_tier/manual_rule_run.ts index 8a6167fc69301..153185456544d 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_gaps/trial_license_complete_tier/manual_rule_run.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_gaps/trial_license_complete_tier/manual_rule_run.ts @@ -42,9 +42,7 @@ export default ({ getService }: FtrProviderContext) => { const log = getService('log'); const es = getService('es'); - // Currently FF are not supported on MKI environments, so this test should be skipped from MKI environments. - // Once `manualRuleRunEnabled` FF is removed, we can remove `@skipInServerlessMKI` as well - describe('@ess @serverless @skipInServerlessMKI manual_rule_run', () => { + describe('@ess @serverless manual_rule_run', () => { beforeEach(async () => { await createAlertsIndex(supertest, log); }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_management/trial_license_complete_tier/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_management/trial_license_complete_tier/configs/serverless.config.ts index 52a1074c87904..ca9396db04661 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_management/trial_license_complete_tier/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_management/trial_license_complete_tier/configs/serverless.config.ts @@ -12,7 +12,4 @@ export default createTestConfig({ reportName: 'Rules Management - Rule Management Integration Tests - Serverless Env - Complete Tier', }, - kbnTestServerArgs: [ - `--xpack.securitySolution.enableExperimental=${JSON.stringify(['manualRuleRunEnabled'])}`, - ], }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules.ts index a567eb78a776d..41f207c90f319 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules.ts @@ -16,6 +16,9 @@ import { removeServerGeneratedPropertiesIncludingRuleId, getSimpleRuleOutputWithoutRuleId, updateUsername, + createHistoricalPrebuiltRuleAssetSavedObjects, + installPrebuiltRules, + createRuleAssetSavedObject, } from '../../../utils'; import { createAlertsIndex, @@ -238,6 +241,25 @@ export default ({ getService }: FtrProviderContext) => { }); }); + it('throws an error if rule has external rule source and non-customizable fields are changed', async () => { + // Install base prebuilt detection rule + await createHistoricalPrebuiltRuleAssetSavedObjects(es, [ + createRuleAssetSavedObject({ rule_id: 'rule-1', author: ['elastic'] }), + ]); + await installPrebuiltRules(es, supertest); + + const { body } = await securitySolutionApi + .patchRule({ + body: { + rule_id: 'rule-1', + author: ['new user'], + }, + }) + .expect(400); + + expect(body.message).toEqual('Cannot update "author" field for prebuilt rules'); + }); + describe('max signals', () => { afterEach(async () => { await deleteAllRules(supertest, log); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules_bulk.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules_bulk.ts index 086909fc4945b..7929b912768ff 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules_bulk.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules_bulk.ts @@ -16,6 +16,9 @@ import { getSimpleRuleOutputWithoutRuleId, removeServerGeneratedPropertiesIncludingRuleId, updateUsername, + createHistoricalPrebuiltRuleAssetSavedObjects, + installPrebuiltRules, + createRuleAssetSavedObject, } from '../../../utils'; import { createAlertsIndex, @@ -347,6 +350,41 @@ export default ({ getService }: FtrProviderContext) => { }, ]); }); + + it('throws an error if rule has external rule source and non-customizable fields are changed', async () => { + // Install base prebuilt detection rule + await createHistoricalPrebuiltRuleAssetSavedObjects(es, [ + createRuleAssetSavedObject({ rule_id: 'rule-1', author: ['elastic'] }), + createRuleAssetSavedObject({ rule_id: 'rule-2', license: 'basic' }), + ]); + await installPrebuiltRules(es, supertest); + + const { body } = await securitySolutionApi + .bulkPatchRules({ + body: [ + { rule_id: 'rule-1', author: ['new user'] }, + { rule_id: 'rule-2', license: 'new license' }, + ], + }) + .expect(200); + + expect([body[0], body[1]]).toEqual([ + { + error: { + message: 'Cannot update "author" field for prebuilt rules', + status_code: 400, + }, + rule_id: 'rule-1', + }, + { + error: { + message: 'Cannot update "license" field for prebuilt rules', + status_code: 400, + }, + rule_id: 'rule-2', + }, + ]); + }); }); }); }; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules.ts index 60e7bfe3ff88f..c84236a14eb37 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules.ts @@ -18,6 +18,9 @@ import { getSimpleMlRuleUpdate, getSimpleRule, updateUsername, + createHistoricalPrebuiltRuleAssetSavedObjects, + installPrebuiltRules, + createRuleAssetSavedObject, } from '../../../utils'; import { createAlertsIndex, @@ -309,6 +312,33 @@ export default ({ getService }: FtrProviderContext) => { expect(updatedRuleResponse).toMatchObject(expectedRule); }); }); + + it('throws an error if rule has external rule source and non-customizable fields are changed', async () => { + // Install base prebuilt detection rule + await createHistoricalPrebuiltRuleAssetSavedObjects(es, [ + createRuleAssetSavedObject({ rule_id: 'rule-1', license: 'elastic' }), + ]); + await installPrebuiltRules(es, supertest); + + const { body: existingRule } = await securitySolutionApi + .readRule({ + query: { rule_id: 'rule-1' }, + }) + .expect(200); + + const { body } = await securitySolutionApi + .updateRule({ + body: getCustomQueryRuleParams({ + ...existingRule, + rule_id: 'rule-1', + id: undefined, + license: 'new license', + }), + }) + .expect(400); + + expect(body.message).toEqual('Cannot update "license" field for prebuilt rules'); + }); }); }); }; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules_bulk.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules_bulk.ts index f9faee0481bf6..cdca9e3ca6e1a 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules_bulk.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules_bulk.ts @@ -17,6 +17,9 @@ import { getSimpleRuleUpdate, getSimpleRule, updateUsername, + createHistoricalPrebuiltRuleAssetSavedObjects, + installPrebuiltRules, + createRuleAssetSavedObject, } from '../../../utils'; import { createAlertsIndex, @@ -370,6 +373,30 @@ export default ({ getService }: FtrProviderContext) => { }, ]); }); + + it('throws an error if rule has external rule source and non-customizable fields are changed', async () => { + // Install base prebuilt detection rule + await createHistoricalPrebuiltRuleAssetSavedObjects(es, [ + createRuleAssetSavedObject({ rule_id: 'rule-1', author: ['elastic'] }), + ]); + await installPrebuiltRules(es, supertest); + + const { body } = await securitySolutionApi + .bulkUpdateRules({ + body: [getCustomQueryRuleParams({ rule_id: 'rule-1', author: ['new user'] })], + }) + .expect(200); + + expect([body[0]]).toEqual([ + { + error: { + message: 'Cannot update "author" field for prebuilt rules', + status_code: 400, + }, + rule_id: 'rule-1', + }, + ]); + }); }); }); }; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/telemetry/trial_license_complete_tier/usage_collector/detection_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/telemetry/trial_license_complete_tier/usage_collector/detection_rules.ts index b3b58ac7880f8..c43d08a805ca8 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/telemetry/trial_license_complete_tier/usage_collector/detection_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/telemetry/trial_license_complete_tier/usage_collector/detection_rules.ts @@ -31,6 +31,7 @@ import { getRuleSavedObjectWithLegacyInvestigationFields, getRuleSavedObjectWithLegacyInvestigationFieldsEmptyArray, createRuleThroughAlertingEndpoint, + getCustomQueryRuleParams, } from '../../../utils'; import { createRule, @@ -1140,7 +1141,7 @@ export default ({ getService }: FtrProviderContext) => { await installMockPrebuiltRules(supertest, es); const immutableRule = await fetchRule(supertest, { ruleId: ELASTIC_SECURITY_RULE_ID }); const hookAction = await createWebHookRuleAction(supertest); - const newRuleToUpdate = getSimpleRule(immutableRule.rule_id); + const newRuleToUpdate = getCustomQueryRuleParams({ rule_id: immutableRule.rule_id }); const ruleToUpdate = getRuleWithWebHookAction(hookAction.id, false, newRuleToUpdate); await updateRule(supertest, ruleToUpdate); @@ -1161,7 +1162,7 @@ export default ({ getService }: FtrProviderContext) => { ...omittedFields } = foundRule; expect(omittedFields).to.eql({ - rule_name: 'Simple Rule Query', + rule_name: 'Custom query rule', rule_type: 'query', enabled: false, elastic_rule: true, @@ -1197,7 +1198,7 @@ export default ({ getService }: FtrProviderContext) => { await installMockPrebuiltRules(supertest, es); const immutableRule = await fetchRule(supertest, { ruleId: ELASTIC_SECURITY_RULE_ID }); const hookAction = await createWebHookRuleAction(supertest); - const newRuleToUpdate = getSimpleRule(immutableRule.rule_id); + const newRuleToUpdate = getCustomQueryRuleParams({ rule_id: immutableRule.rule_id }); const ruleToUpdate = getRuleWithWebHookAction(hookAction.id, true, newRuleToUpdate); await updateRule(supertest, ruleToUpdate); @@ -1218,7 +1219,7 @@ export default ({ getService }: FtrProviderContext) => { ...omittedFields } = foundRule; expect(omittedFields).to.eql({ - rule_name: 'Simple Rule Query', + rule_name: 'Custom query rule', rule_type: 'query', enabled: true, elastic_rule: true, @@ -1254,7 +1255,7 @@ export default ({ getService }: FtrProviderContext) => { await installMockPrebuiltRules(supertest, es); const immutableRule = await fetchRule(supertest, { ruleId: ELASTIC_SECURITY_RULE_ID }); const hookAction = await createWebHookRuleAction(supertest); - const newRuleToUpdate = getSimpleRule(immutableRule.rule_id, false); + const newRuleToUpdate = getCustomQueryRuleParams({ rule_id: immutableRule.rule_id }); await updateRule(supertest, newRuleToUpdate); await createLegacyRuleAction(supertest, immutableRule.id, hookAction.id); @@ -1275,7 +1276,7 @@ export default ({ getService }: FtrProviderContext) => { ...omittedFields } = foundRule; expect(omittedFields).to.eql({ - rule_name: 'Simple Rule Query', + rule_name: 'Custom query rule', rule_type: 'query', enabled: false, elastic_rule: true, @@ -1311,7 +1312,10 @@ export default ({ getService }: FtrProviderContext) => { await installMockPrebuiltRules(supertest, es); const immutableRule = await fetchRule(supertest, { ruleId: ELASTIC_SECURITY_RULE_ID }); const hookAction = await createWebHookRuleAction(supertest); - const newRuleToUpdate = getSimpleRule(immutableRule.rule_id, true); + const newRuleToUpdate = getCustomQueryRuleParams({ + rule_id: immutableRule.rule_id, + enabled: true, + }); await updateRule(supertest, newRuleToUpdate); await createLegacyRuleAction(supertest, immutableRule.id, hookAction.id); @@ -1332,7 +1336,7 @@ export default ({ getService }: FtrProviderContext) => { ...omittedFields } = foundRule; expect(omittedFields).to.eql({ - rule_name: 'Simple Rule Query', + rule_name: 'Custom query rule', rule_type: 'query', enabled: true, elastic_rule: true, diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/telemetry/trial_license_complete_tier/usage_collector/detection_rules_legacy_action.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/telemetry/trial_license_complete_tier/usage_collector/detection_rules_legacy_action.ts index e3754d9a09b60..f85f317e2da07 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/telemetry/trial_license_complete_tier/usage_collector/detection_rules_legacy_action.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/telemetry/trial_license_complete_tier/usage_collector/detection_rules_legacy_action.ts @@ -21,13 +21,13 @@ import { fetchRule, getRuleWithWebHookAction, getSimpleMlRule, - getSimpleRule, getSimpleThreatMatch, getStats, getThresholdRuleForAlertTesting, installMockPrebuiltRules, updateRule, deleteAllEventLogExecutionEvents, + getCustomQueryRuleParams, } from '../../../utils'; import { createRule, @@ -408,7 +408,7 @@ export default ({ getService }: FtrProviderContext) => { await installMockPrebuiltRules(supertest, es); const immutableRule = await fetchRule(supertest, { ruleId: ELASTIC_SECURITY_RULE_ID }); const hookAction = await createWebHookRuleAction(supertest); - const newRuleToUpdate = getSimpleRule(immutableRule.rule_id, false); + const newRuleToUpdate = getCustomQueryRuleParams({ rule_id: immutableRule.rule_id }); await updateRule(supertest, newRuleToUpdate); await createLegacyRuleAction(supertest, immutableRule.id, hookAction.id); @@ -429,7 +429,7 @@ export default ({ getService }: FtrProviderContext) => { ...omittedFields } = foundRule; expect(omittedFields).to.eql({ - rule_name: 'Simple Rule Query', + rule_name: 'Custom query rule', rule_type: 'query', enabled: false, elastic_rule: true, @@ -465,7 +465,10 @@ export default ({ getService }: FtrProviderContext) => { await installMockPrebuiltRules(supertest, es); const immutableRule = await fetchRule(supertest, { ruleId: ELASTIC_SECURITY_RULE_ID }); const hookAction = await createWebHookRuleAction(supertest); - const newRuleToUpdate = getSimpleRule(immutableRule.rule_id, true); + const newRuleToUpdate = getCustomQueryRuleParams({ + rule_id: immutableRule.rule_id, + enabled: true, + }); await updateRule(supertest, newRuleToUpdate); await createLegacyRuleAction(supertest, immutableRule.id, hookAction.id); @@ -486,7 +489,7 @@ export default ({ getService }: FtrProviderContext) => { ...omittedFields } = foundRule; expect(omittedFields).to.eql({ - rule_name: 'Simple Rule Query', + rule_name: 'Custom query rule', rule_type: 'query', enabled: true, elastic_rule: true, diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/index.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/index.ts index 2ce85256b0fbf..5667762ce95c4 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/index.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/index.ts @@ -12,6 +12,7 @@ export * from './data_generator'; export * from './telemetry'; export * from './event_log'; export * from './machine_learning'; +export * from './indices'; export * from './binary_to_string'; export * from './get_index_name_from_load'; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/indices/index.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/indices/index.ts new file mode 100644 index 0000000000000..79cad822b8f36 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/indices/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 * from './set_synthetic_source'; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/indices/set_synthetic_source.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/indices/set_synthetic_source.ts new file mode 100644 index 0000000000000..b37bcd7664319 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/indices/set_synthetic_source.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Client } from '@elastic/elasticsearch'; + +interface UpdateMappingsProps { + es: Client; + index: string | string[]; +} + +export const setSyntheticSource = async ({ es, index }: UpdateMappingsProps) => { + await es.indices.putMapping({ _source: { mode: 'synthetic' }, index }); +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/get_rule_params/get_custom_query_rule_params.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/get_rule_params/get_custom_query_rule_params.ts index b561d3e8dc023..a5c5fe00ed700 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/get_rule_params/get_custom_query_rule_params.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/get_rule_params/get_custom_query_rule_params.ts @@ -29,6 +29,7 @@ export function getCustomQueryRuleParams( index: ['logs-*'], interval: '100m', from: 'now-6m', + author: [], enabled: false, ...rewrites, }; diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/engine.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/engine.ts index 99d84fbc5427b..4fb2360a049cf 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/engine.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/engine.ts @@ -27,10 +27,7 @@ export default ({ getService }: FtrProviderContext) => { it('should have installed the expected user resources', async () => { await utils.initEntityEngineForEntityType('user'); - const expectedTransforms = [ - 'entities-v1-history-ea_default_user_entity_store', - 'entities-v1-latest-ea_default_user_entity_store', - ]; + const expectedTransforms = ['entities-v1-latest-ea_default_user_entity_store']; await utils.expectTransformsExist(expectedTransforms); }); @@ -38,10 +35,7 @@ export default ({ getService }: FtrProviderContext) => { it('should have installed the expected host resources', async () => { await utils.initEntityEngineForEntityType('host'); - const expectedTransforms = [ - 'entities-v1-history-ea_default_host_entity_store', - 'entities-v1-latest-ea_default_host_entity_store', - ]; + const expectedTransforms = ['entities-v1-latest-ea_default_host_entity_store']; await utils.expectTransformsExist(expectedTransforms); }); @@ -173,7 +167,6 @@ export default ({ getService }: FtrProviderContext) => { }) .expect(200); - await utils.expectTransformNotFound('entities-v1-history-ea_host_entity_store'); await utils.expectTransformNotFound('entities-v1-latest-ea_host_entity_store'); }); @@ -187,7 +180,6 @@ export default ({ getService }: FtrProviderContext) => { }) .expect(200); - await utils.expectTransformNotFound('entities-v1-history-ea_user_entity_store'); await utils.expectTransformNotFound('entities-v1-latest-ea_user_entity_store'); }); }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/engine_nondefault_spaces.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/engine_nondefault_spaces.ts index 112c8b8b21511..e3ef29d937183 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/engine_nondefault_spaces.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/engine_nondefault_spaces.ts @@ -38,10 +38,7 @@ export default ({ getService }: FtrProviderContextWithSpaces) => { it('should have installed the expected user resources', async () => { await utils.initEntityEngineForEntityType('user'); - const expectedTransforms = [ - `entities-v1-history-ea_${namespace}_user_entity_store`, - `entities-v1-latest-ea_${namespace}_user_entity_store`, - ]; + const expectedTransforms = [`entities-v1-latest-ea_${namespace}_user_entity_store`]; await utils.expectTransformsExist(expectedTransforms); }); @@ -49,10 +46,7 @@ export default ({ getService }: FtrProviderContextWithSpaces) => { it('should have installed the expected host resources', async () => { await utils.initEntityEngineForEntityType('host'); - const expectedTransforms = [ - `entities-v1-history-ea_${namespace}_host_entity_store`, - `entities-v1-latest-ea_${namespace}_host_entity_store`, - ]; + const expectedTransforms = [`entities-v1-latest-ea_${namespace}_host_entity_store`]; await utils.expectTransformsExist(expectedTransforms); }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/init_and_status_apis.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/init_and_status_apis.ts index bd3493b82d348..19a9bb85326fa 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/init_and_status_apis.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/init_and_status_apis.ts @@ -26,8 +26,7 @@ export default ({ getService }: FtrProviderContext) => { const riskEngineRoutes = riskEngineRouteHelpersFactory(supertest); const log = getService('log'); - // Failing: See https://github.com/elastic/kibana/issues/191637 - describe.skip('@ess @serverless @serverlessQA init_and_status_apis', () => { + describe('@ess @serverless @serverlessQA init_and_status_apis', () => { before(async () => { await riskEngineRoutes.cleanUp(); }); @@ -298,8 +297,8 @@ export default ({ getService }: FtrProviderContext) => { firstResponse?.saved_objects?.[0]?.id ); }); - - describe('remove legacy risk score transform', function () { + // Failing: See https://github.com/elastic/kibana/issues/191637 + describe.skip('remove legacy risk score transform', function () { this.tags('skipFIPS'); it('should remove legacy risk score transform if it exists', async () => { await installLegacyRiskScore({ supertest }); diff --git a/x-pack/test/security_solution_cypress/config.ts b/x-pack/test/security_solution_cypress/config.ts index 88752eb1b5f93..f02968945087d 100644 --- a/x-pack/test/security_solution_cypress/config.ts +++ b/x-pack/test/security_solution_cypress/config.ts @@ -44,10 +44,6 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { // See https://github.com/elastic/kibana/pull/125396 for details '--xpack.alerting.rules.minimumScheduleInterval.value=1s', '--xpack.ruleRegistry.unsafe.legacyMultiTenancy.enabled=true', - `--xpack.securitySolution.enableExperimental=${JSON.stringify([ - 'manualRuleRunEnabled', - 'loggingRequestsEnabled', - ])}`, // mock cloud to enable the guided onboarding tour in e2e tests '--xpack.cloud.id=test', `--home.disableWelcomeScreen=true`, diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/esql_rule.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/esql_rule.cy.ts index 0045a79ff4394..64423a921e595 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/esql_rule.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/esql_rule.cy.ts @@ -67,7 +67,8 @@ const workaroundForResizeObserver = () => } }); -describe( +// Failing: See https://github.com/elastic/kibana/issues/184558 +describe.skip( 'Detection ES|QL rules, creation', { tags: ['@ess', '@serverless', '@skipInServerlessMKI'], diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/indicator_match_rule_suppression.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/indicator_match_rule_suppression.cy.ts index 42fb37184da1c..d0539683e5a64 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/indicator_match_rule_suppression.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/indicator_match_rule_suppression.cy.ts @@ -12,7 +12,6 @@ import { SUPPRESS_FOR_DETAILS, SUPPRESS_BY_DETAILS, SUPPRESS_MISSING_FIELD, - DETAILS_TITLE, } from '../../../../screens/rule_details'; import { @@ -67,9 +66,6 @@ describe( 'have.text', 'Suppress and group alerts for events with missing fields' ); - - // suppression functionality should be under Tech Preview - cy.contains(DETAILS_TITLE, SUPPRESS_FOR_DETAILS).contains('Technical Preview'); }); fillAboutRuleMinimumAndContinue(rule); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/indicator_match_rule_suppression_ess_basic.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/indicator_match_rule_suppression_ess_basic.cy.ts index dd3c086224e49..6223ac017281d 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/indicator_match_rule_suppression_ess_basic.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/indicator_match_rule_suppression_ess_basic.cy.ts @@ -9,7 +9,6 @@ import { getNewThreatIndicatorRule } from '../../../../objects/rule'; import { SUPPRESS_FOR_DETAILS, - DETAILS_TITLE, SUPPRESS_BY_DETAILS, SUPPRESS_MISSING_FIELD, DEFINITION_DETAILS, @@ -62,9 +61,6 @@ describe( 'have.text', 'Do not suppress alerts for events with missing fields' ); - - // suppression functionality should be under Tech Preview - cy.contains(DETAILS_TITLE, SUPPRESS_FOR_DETAILS).contains('Technical Preview'); }); // Platinum license is required for configuration to apply diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/machine_learning_rule_suppression.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/machine_learning_rule_suppression.cy.ts index c38a6ef43150a..45ccc2c5aba8d 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/machine_learning_rule_suppression.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/machine_learning_rule_suppression.cy.ts @@ -13,7 +13,6 @@ import { } from '../../../../screens/create_new_rule'; import { DEFINITION_DETAILS, - DETAILS_TITLE, SUPPRESS_BY_DETAILS, SUPPRESS_FOR_DETAILS, SUPPRESS_MISSING_FIELD, @@ -129,9 +128,6 @@ describe( 'have.text', 'Suppress and group alerts for events with missing fields' ); - - // suppression functionality should be under Tech Preview - cy.contains(DETAILS_TITLE, SUPPRESS_FOR_DETAILS).contains('Technical Preview'); }); fillAboutRuleMinimumAndContinue(mlRule); @@ -163,9 +159,6 @@ describe( 'have.text', 'Do not suppress alerts for events with missing fields' ); - - // suppression functionality should be under Tech Preview - cy.contains(DETAILS_TITLE, SUPPRESS_FOR_DETAILS).contains('Technical Preview'); }); fillAboutRuleMinimumAndContinue(mlRule); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/esql_rule.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/esql_rule.cy.ts index 511ea42c06767..34f301602b692 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/esql_rule.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/esql_rule.cy.ts @@ -14,7 +14,6 @@ import { DEFINITION_DETAILS, SUPPRESS_MISSING_FIELD, SUPPRESS_BY_DETAILS, - DETAILS_TITLE, } from '../../../../screens/rule_details'; import { @@ -56,7 +55,9 @@ const expectedValidEsqlQuery = 'from auditbeat* | stats _count=count(event.category) by event.category'; // Skipping in MKI due to flake -describe( +// Failing: See https://github.com/elastic/kibana/issues/184557 +// Failing: See https://github.com/elastic/kibana/issues/184556 +describe.skip( 'Detection ES|QL rules, edit', { tags: ['@ess', '@serverless', '@skipInServerlessMKI'], @@ -191,9 +192,6 @@ describe( 'have.text', 'Suppress and group alerts for events with missing fields' ); - - // suppression functionality should be under Tech Preview - cy.contains(DETAILS_TITLE, SUPPRESS_FOR_DETAILS).contains('Technical Preview'); }); }); }); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/indicator_match_rule.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/indicator_match_rule.cy.ts index 62d9a95398797..fe616f6ba1969 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/indicator_match_rule.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/indicator_match_rule.cy.ts @@ -9,7 +9,6 @@ import { getNewThreatIndicatorRule } from '../../../../objects/rule'; import { SUPPRESS_FOR_DETAILS, - DETAILS_TITLE, SUPPRESS_BY_DETAILS, SUPPRESS_MISSING_FIELD, DEFINITION_DETAILS, @@ -81,9 +80,6 @@ describe( 'have.text', 'Suppress and group alerts for events with missing fields' ); - - // suppression functionality should be under Tech Preview - cy.contains(DETAILS_TITLE, SUPPRESS_FOR_DETAILS).contains('Technical Preview'); }); }); }); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/machine_learning_rule.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/machine_learning_rule.cy.ts index e89e4b6afb817..7410d9fefae6d 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/machine_learning_rule.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/machine_learning_rule.cy.ts @@ -13,7 +13,6 @@ import { } from '../../../../screens/create_new_rule'; import { DEFINITION_DETAILS, - DETAILS_TITLE, SUPPRESS_BY_DETAILS, SUPPRESS_FOR_DETAILS, SUPPRESS_MISSING_FIELD, @@ -88,9 +87,6 @@ describe( 'have.text', 'Suppress and group alerts for events with missing fields' ); - - // suppression functionality should be under Tech Preview - cy.contains(DETAILS_TITLE, SUPPRESS_FOR_DETAILS).contains('Technical Preview'); }); }); }); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/preview.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/preview.cy.ts index ce298bafbfea0..268968c76ecc0 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/preview.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/preview.cy.ts @@ -32,14 +32,7 @@ const expectedValidEsqlQuery = 'from auditbeat* METADATA _id'; describe( 'Detection rules, preview', { - // Currently FF are not supported on MKI environments, so this test should be skipped from MKI environments. - // Once `manualRuleRunEnabled` FF is removed, we can remove `@skipInServerlessMKI` as well - tags: ['@ess', '@serverless', '@skipInServerlessMKI'], - env: { - kbnServerArgs: [ - `--xpack.securitySolution.enableExperimental=${JSON.stringify(['loggingRequestsEnabled'])}`, - ], - }, + tags: ['@ess', '@serverless'], }, () => { beforeEach(() => { diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/threshold_rule.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/threshold_rule.cy.ts index 8d4bdf2d34976..dcc35a9e00080 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/threshold_rule.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/threshold_rule.cy.ts @@ -9,7 +9,6 @@ import { getNewThresholdRule } from '../../../../objects/rule'; import { SUPPRESS_FOR_DETAILS, - DETAILS_TITLE, SUPPRESS_BY_DETAILS, SUPPRESS_MISSING_FIELD, } from '../../../../screens/rule_details'; @@ -63,8 +62,6 @@ describe( // ensure typed interval is displayed on details page getDetails(SUPPRESS_FOR_DETAILS).should('have.text', '60m'); - // suppression functionality should be under Tech Preview - cy.contains(DETAILS_TITLE, SUPPRESS_FOR_DETAILS).contains('Technical Preview'); // the rest of suppress properties do not exist for threshold rule assertDetailsNotExist(SUPPRESS_BY_DETAILS); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_gaps/bulk_manual_rule_run.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_gaps/bulk_manual_rule_run.cy.ts index 17cde9485a13c..5a66dcdc0de84 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_gaps/bulk_manual_rule_run.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_gaps/bulk_manual_rule_run.cy.ts @@ -19,9 +19,7 @@ import { deleteAlertsAndRules } from '../../../../tasks/api_calls/common'; import { createRule } from '../../../../tasks/api_calls/rules'; import { login } from '../../../../tasks/login'; -// Currently FF are not supported on MKI environments, so this test should be skipped from MKI environments. -// Once `manualRuleRunEnabled` FF is removed, we can remove `@skipInServerlessMKI` as well -describe('Manual rule run', { tags: ['@ess', '@serverless', '@skipInServerlessMKI'] }, () => { +describe('Manual rule run', { tags: ['@ess', '@serverless'] }, () => { beforeEach(() => { login(); deleteAlertsAndRules(); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_gaps/manual_rule_run.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_gaps/manual_rule_run.cy.ts index 29e2379367c0b..f40f4284b84b5 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_gaps/manual_rule_run.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_gaps/manual_rule_run.cy.ts @@ -18,9 +18,7 @@ import { deleteAlertsAndRules } from '../../../../tasks/api_calls/common'; import { createRule } from '../../../../tasks/api_calls/rules'; import { login } from '../../../../tasks/login'; -// Currently FF are not supported on MKI environments, so this test should be skipped from MKI environments. -// Once `manualRuleRunEnabled` FF is removed, we can remove `@skipInServerlessMKI` as well -describe('Manual rule run', { tags: ['@ess', '@serverless', '@skipInServerlessMKI'] }, () => { +describe('Manual rule run', { tags: ['@ess', '@serverless'] }, () => { beforeEach(() => { login(); deleteAlertsAndRules(); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rule_details/backfill_group.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rule_details/backfill_group.cy.ts index 2f97e2f3c0721..6466c20dfde21 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rule_details/backfill_group.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rule_details/backfill_group.cy.ts @@ -35,13 +35,6 @@ describe( 'Backfill groups', { tags: ['@ess', '@serverless', '@skipInServerlessMKI'], - env: { - ftrConfig: { - kbnServerArgs: [ - `--xpack.securitySolution.enableExperimental=${JSON.stringify(['manualRuleRunEnabled'])}`, - ], - }, - }, }, function () { before(() => { diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rule_details/execution_log.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rule_details/execution_log.cy.ts index a34826d2c8cb4..dc9e3e5719d27 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rule_details/execution_log.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rule_details/execution_log.cy.ts @@ -27,13 +27,6 @@ describe.skip( 'Event log', { tags: ['@ess', '@serverless'], - env: { - ftrConfig: { - kbnServerArgs: [ - `--xpack.securitySolution.enableExperimental=${JSON.stringify(['manualRuleRunEnabled'])}`, - ], - }, - }, }, function () { before(() => { diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/alerts/expandable_flyout/vulnerabilities_contextual_flyout.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/alerts/expandable_flyout/vulnerabilities_contextual_flyout.cy.ts index 591d458af56c1..fb83df1c79141 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/alerts/expandable_flyout/vulnerabilities_contextual_flyout.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/alerts/expandable_flyout/vulnerabilities_contextual_flyout.cy.ts @@ -138,7 +138,8 @@ const deleteDataStream = () => { }); }; -describe('Alert Host details expandable flyout', { tags: ['@ess', '@serverless'] }, () => { +// skipping because failure on MKI environment (https://buildkite.com/elastic/kibana-serverless-security-solution-quality-gate-investigations/builds/1390#01927579-caed-41bc-9440-3cf29629a263) +describe.skip('Alert Host details expandable flyout', { tags: ['@ess', '@serverless'] }, () => { beforeEach(() => { deleteAlertsAndRules(); login(); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/threat_intelligence/indicators.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/threat_intelligence/indicators.cy.ts index ebfc5d4e9a0cb..b0e5764469459 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/threat_intelligence/indicators.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/threat_intelligence/indicators.cy.ts @@ -298,7 +298,7 @@ describe('Multiple indicators', { tags: ['@ess'] }, () => { cy.log('should reload the data when refresh button is pressed'); - cy.intercept(/bsearch/).as('search'); + cy.intercept('POST', '/internal/search/threatIntelligenceSearchStrategy').as('search'); cy.get(REFRESH_BUTTON).should('exist').click(); cy.wait('@search'); }); diff --git a/x-pack/test/security_solution_cypress/serverless_config.ts b/x-pack/test/security_solution_cypress/serverless_config.ts index 13877fcbf5af4..f3f04dda79dbb 100644 --- a/x-pack/test/security_solution_cypress/serverless_config.ts +++ b/x-pack/test/security_solution_cypress/serverless_config.ts @@ -34,10 +34,6 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { { product_line: 'endpoint', product_tier: 'complete' }, { product_line: 'cloud', product_tier: 'complete' }, ])}`, - `--xpack.securitySolution.enableExperimental=${JSON.stringify([ - 'manualRuleRunEnabled', - 'loggingRequestsEnabled', - ])}`, '--csp.strict=false', '--csp.warnLegacyBrowsers=false', ], diff --git a/x-pack/test_serverless/api_integration/test_suites/common/platform_security/authentication.ts b/x-pack/test_serverless/api_integration/test_suites/common/platform_security/authentication.ts index 7f31db43a3f00..041c005855d0f 100644 --- a/x-pack/test_serverless/api_integration/test_suites/common/platform_security/authentication.ts +++ b/x-pack/test_serverless/api_integration/test_suites/common/platform_security/authentication.ts @@ -6,51 +6,58 @@ */ import expect from 'expect'; +import { SupertestWithRoleScopeType } from '@kbn/test-suites-xpack/api_integration/deployment_agnostic/services'; import { FtrProviderContext } from '../../../ftr_provider_context'; -import { RoleCredentials } from '../../../../shared/services'; export default function ({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - const config = getService('config'); - + const roleScopedSupertest = getService('roleScopedSupertest'); const svlCommonApi = getService('svlCommonApi'); - const svlUserManager = getService('svlUserManager'); - const supertestWithoutAuth = getService('supertestWithoutAuth'); - let roleAuthc: RoleCredentials; + let supertestAdminWithApiKey: SupertestWithRoleScopeType; + let supertestViewerWithApiKey: SupertestWithRoleScopeType; + let supertestViewerWithCookieCredentials: SupertestWithRoleScopeType; + describe('security/authentication', function () { before(async () => { - roleAuthc = await svlUserManager.createM2mApiKeyWithRoleScope('admin'); + supertestAdminWithApiKey = await roleScopedSupertest.getSupertestWithRoleScope('admin'); + supertestViewerWithApiKey = await roleScopedSupertest.getSupertestWithRoleScope('viewer'); + supertestViewerWithCookieCredentials = await roleScopedSupertest.getSupertestWithRoleScope( + 'viewer', + { + useCookieHeader: true, + withCommonHeaders: true, + } + ); }); after(async () => { - await svlUserManager.invalidateM2mApiKeyWithRoleScope(roleAuthc); + await supertestAdminWithApiKey.destroy(); + await supertestViewerWithApiKey.destroy(); + await supertestViewerWithCookieCredentials.destroy(); }); describe('route access', () => { describe('disabled', () => { // ToDo: uncomment when we disable login // it('login', async () => { - // const { body, status } = await supertestWithoutAuth - // .post('/internal/security/login') - // .set(svlCommonApi.getInternalRequestHeader()).set(roleAuthc.apiKeyHeader) + // const { body, status } = await supertestAdminWithApiKey + // .post('/internal/security/login'); // svlCommonApi.assertApiNotFound(body, status); // }); it('logout (deprecated)', async () => { - const { body, status } = await supertestWithoutAuth + const { body, status } = await supertestAdminWithApiKey .get('/api/security/v1/logout') - .set(svlCommonApi.getInternalRequestHeader()) - .set(roleAuthc.apiKeyHeader); + .set(svlCommonApi.getInternalRequestHeader()); svlCommonApi.assertApiNotFound(body, status); }); it('get current user (deprecated)', async () => { - const { body, status } = await supertest + const { body, status } = await supertestAdminWithApiKey .get('/internal/security/v1/me') .set(svlCommonApi.getInternalRequestHeader()); svlCommonApi.assertApiNotFound(body, status); }); it('acknowledge access agreement', async () => { - const { body, status } = await supertest + const { body, status } = await supertestAdminWithApiKey .post('/internal/security/access_agreement/acknowledge') .set(svlCommonApi.getInternalRequestHeader()); svlCommonApi.assertApiNotFound(body, status); @@ -58,56 +65,56 @@ export default function ({ getService }: FtrProviderContext) { describe('OIDC', () => { it('OIDC implicit', async () => { - const { body, status } = await supertest + const { body, status } = await supertestAdminWithApiKey .get('/api/security/oidc/implicit') .set(svlCommonApi.getInternalRequestHeader()); svlCommonApi.assertApiNotFound(body, status); }); it('OIDC implicit (deprecated)', async () => { - const { body, status } = await supertest + const { body, status } = await supertestAdminWithApiKey .get('/api/security/v1/oidc/implicit') .set(svlCommonApi.getInternalRequestHeader()); svlCommonApi.assertApiNotFound(body, status); }); it('OIDC implicit.js', async () => { - const { body, status } = await supertest + const { body, status } = await supertestAdminWithApiKey .get('/internal/security/oidc/implicit.js') .set(svlCommonApi.getInternalRequestHeader()); svlCommonApi.assertApiNotFound(body, status); }); it('OIDC callback', async () => { - const { body, status } = await supertest + const { body, status } = await supertestAdminWithApiKey .get('/api/security/oidc/callback') .set(svlCommonApi.getInternalRequestHeader()); svlCommonApi.assertApiNotFound(body, status); }); it('OIDC callback (deprecated)', async () => { - const { body, status } = await supertest + const { body, status } = await supertestAdminWithApiKey .get('/api/security/v1/oidc') .set(svlCommonApi.getInternalRequestHeader()); svlCommonApi.assertApiNotFound(body, status); }); it('OIDC login', async () => { - const { body, status } = await supertest + const { body, status } = await supertestAdminWithApiKey .post('/api/security/oidc/initiate_login') .set(svlCommonApi.getInternalRequestHeader()); svlCommonApi.assertApiNotFound(body, status); }); it('OIDC login (deprecated)', async () => { - const { body, status } = await supertest + const { body, status } = await supertestAdminWithApiKey .post('/api/security/v1/oidc') .set(svlCommonApi.getInternalRequestHeader()); svlCommonApi.assertApiNotFound(body, status); }); it('OIDC 3rd party login', async () => { - const { body, status } = await supertest + const { body, status } = await supertestAdminWithApiKey .get('/api/security/oidc/initiate_login') .set(svlCommonApi.getInternalRequestHeader()); svlCommonApi.assertApiNotFound(body, status); @@ -115,7 +122,7 @@ export default function ({ getService }: FtrProviderContext) { }); it('SAML callback (deprecated)', async () => { - const { body, status } = await supertest + const { body, status } = await supertestAdminWithApiKey .post('/api/security/v1/saml') .set(svlCommonApi.getInternalRequestHeader()); svlCommonApi.assertApiNotFound(body, status); @@ -127,9 +134,9 @@ export default function ({ getService }: FtrProviderContext) { let body: any; let status: number; - ({ body, status } = await supertest - .get('/internal/security/me') - .set(svlCommonApi.getCommonRequestHeader())); + ({ body, status } = await supertestViewerWithCookieCredentials.get( + '/internal/security/me' + )); // expect a rejection because we're not using the internal header expect(body).toEqual({ statusCode: 400, @@ -140,24 +147,22 @@ export default function ({ getService }: FtrProviderContext) { }); expect(status).toBe(400); - ({ body, status } = await supertest + ({ body, status } = await supertestViewerWithCookieCredentials .get('/internal/security/me') .set(svlCommonApi.getInternalRequestHeader())); // expect success because we're using the internal header - expect(body).toEqual({ - authentication_provider: { name: '__http__', type: 'http' }, - authentication_realm: { name: 'file1', type: 'file' }, - authentication_type: 'realm', - elastic_cloud_user: false, - email: null, - enabled: true, - full_name: null, - lookup_realm: { name: 'file1', type: 'file' }, - metadata: {}, - operator: true, - roles: ['superuser'], - username: config.get('servers.kibana.username'), - }); + expect(body).toEqual( + expect.objectContaining({ + authentication_provider: { name: 'cloud-saml-kibana', type: 'saml' }, + authentication_type: 'token', + authentication_realm: { + name: 'cloud-saml-kibana', + type: 'saml', + }, + enabled: true, + full_name: 'test viewer', + }) + ); expect(status).toBe(200); }); @@ -166,9 +171,9 @@ export default function ({ getService }: FtrProviderContext) { let body: any; let status: number; - ({ body, status } = await supertest - .post('/internal/security/login') - .set(svlCommonApi.getCommonRequestHeader())); + ({ body, status } = await supertestViewerWithCookieCredentials.post( + '/internal/security/login' + )); // expect a rejection because we're not using the internal header expect(body).toEqual({ statusCode: 400, @@ -179,7 +184,7 @@ export default function ({ getService }: FtrProviderContext) { }); expect(status).toBe(400); - ({ body, status } = await supertest + ({ body, status } = await supertestViewerWithCookieCredentials .post('/internal/security/login') .set(svlCommonApi.getInternalRequestHeader())); expect(status).not.toBe(404); @@ -188,12 +193,12 @@ export default function ({ getService }: FtrProviderContext) { describe('public', () => { it('logout', async () => { - const { status } = await supertest.get('/api/security/logout'); + const { status } = await supertestViewerWithApiKey.get('/api/security/logout'); expect(status).toBe(302); }); it('SAML callback', async () => { - const { body, status } = await supertest + const { body, status } = await supertestViewerWithApiKey .post('/api/security/saml/callback') .set(svlCommonApi.getCommonRequestHeader()) .send({ diff --git a/x-pack/test_serverless/api_integration/test_suites/common/platform_security/authorization.ts b/x-pack/test_serverless/api_integration/test_suites/common/platform_security/authorization.ts index bc01b14848eff..bd706132d4874 100644 --- a/x-pack/test_serverless/api_integration/test_suites/common/platform_security/authorization.ts +++ b/x-pack/test_serverless/api_integration/test_suites/common/platform_security/authorization.ts @@ -75,7 +75,7 @@ export default function ({ getService }: FtrProviderContext) { it('get role', async () => { const { body, status } = await supertestAdminWithApiKey.get( - '/api/security/role/superuser' + '/api/security/role/someRole' // mame of the role doesn't matter, we're checking the endpoint doesn't exist ); svlCommonApi.assertApiNotFound(body, status); }); @@ -87,7 +87,7 @@ export default function ({ getService }: FtrProviderContext) { it('delete role', async () => { const { body, status } = await supertestAdminWithApiKey.delete( - '/api/security/role/superuser' + '/api/security/role/someRole' // mame of the role doesn't matter, we're checking the endpoint doesn't exist ); svlCommonApi.assertApiNotFound(body, status); }); diff --git a/x-pack/test_serverless/api_integration/test_suites/security/cloud_security_posture/serverless_metering/cloud_security_metering.ts b/x-pack/test_serverless/api_integration/test_suites/security/cloud_security_posture/serverless_metering/cloud_security_metering.ts index b3db98c829afd..f3d613a41d590 100644 --- a/x-pack/test_serverless/api_integration/test_suites/security/cloud_security_posture/serverless_metering/cloud_security_metering.ts +++ b/x-pack/test_serverless/api_integration/test_suites/security/cloud_security_posture/serverless_metering/cloud_security_metering.ts @@ -10,11 +10,10 @@ import { CDR_LATEST_NATIVE_VULNERABILITIES_INDEX_PATTERN } from '@kbn/cloud-secu import { LATEST_FINDINGS_INDEX_DEFAULT_NS } from '@kbn/cloud-security-posture-plugin/common/constants'; import * as http from 'http'; import { - deleteIndex, createPackagePolicy, createCloudDefendPackagePolicy, - bulkIndex, } from '@kbn/test-suites-xpack/api_integration/apis/cloud_security_posture/helper'; +import { EsIndexDataProvider } from '@kbn/test-suites-xpack/cloud_security_posture_api/utils'; import { RoleCredentials } from '../../../../../shared/services'; import { getMockFindings, getMockDefendForContainersHeartbeats } from './mock_data'; import type { FtrProviderContext } from '../../../../ftr_provider_context'; @@ -32,6 +31,12 @@ export default function (providerContext: FtrProviderContext) { const svlCommonApi = getService('svlCommonApi'); const svlUserManager = getService('svlUserManager'); const supertestWithoutAuth = getService('supertestWithoutAuth'); + const findingsIndex = new EsIndexDataProvider(es, LATEST_FINDINGS_INDEX_DEFAULT_NS); + const cloudDefinedIndex = new EsIndexDataProvider(es, CLOUD_DEFEND_HEARTBEAT_INDEX_DEFAULT_NS); + const vulnerabilitiesIndex = new EsIndexDataProvider( + es, + CDR_LATEST_NATIVE_VULNERABILITIES_INDEX_PATTERN + ); /* This test aims to intercept the usage API request sent by the metering background task manager. @@ -67,25 +72,17 @@ export default function (providerContext: FtrProviderContext) { agentPolicyId = agentPolicyResponse.item.id; - await deleteIndex(es, [ - LATEST_FINDINGS_INDEX_DEFAULT_NS, - CDR_LATEST_NATIVE_VULNERABILITIES_INDEX_PATTERN, - CLOUD_DEFEND_HEARTBEAT_INDEX_DEFAULT_NS, - ]); + await findingsIndex.deleteAll(); + await vulnerabilitiesIndex.deleteAll(); + await cloudDefinedIndex.deleteAll(); }); afterEach(async () => { - await deleteIndex(es, [ - LATEST_FINDINGS_INDEX_DEFAULT_NS, - CDR_LATEST_NATIVE_VULNERABILITIES_INDEX_PATTERN, - ]); await kibanaServer.savedObjects.cleanStandardList(); await esArchiver.unload('x-pack/test/functional/es_archives/fleet/empty_fleet_server'); - await deleteIndex(es, [ - LATEST_FINDINGS_INDEX_DEFAULT_NS, - CDR_LATEST_NATIVE_VULNERABILITIES_INDEX_PATTERN, - CLOUD_DEFEND_HEARTBEAT_INDEX_DEFAULT_NS, - ]); + await findingsIndex.deleteAll(); + await vulnerabilitiesIndex.deleteAll(); + await cloudDefinedIndex.deleteAll(); }); after(async () => { await svlUserManager.invalidateM2mApiKeyWithRoleScope(roleAuthc); @@ -116,11 +113,7 @@ export default function (providerContext: FtrProviderContext) { numberOfFindings: 10, }); - await bulkIndex( - es, - [...billableFindings, ...notBillableFindings], - LATEST_FINDINGS_INDEX_DEFAULT_NS - ); + await findingsIndex.addBulk([...billableFindings, ...notBillableFindings]); let interceptedRequestBody: UsageRecord[] = []; await retry.try(async () => { @@ -160,11 +153,7 @@ export default function (providerContext: FtrProviderContext) { numberOfFindings: 11, }); - await bulkIndex( - es, - [...billableFindings, ...notBillableFindings], - LATEST_FINDINGS_INDEX_DEFAULT_NS - ); + await findingsIndex.addBulk([...billableFindings, ...notBillableFindings]); let interceptedRequestBody: UsageRecord[] = []; @@ -199,7 +188,7 @@ export default function (providerContext: FtrProviderContext) { numberOfFindings: 2, }); - await bulkIndex(es, billableFindings, CDR_LATEST_NATIVE_VULNERABILITIES_INDEX_PATTERN); + await vulnerabilitiesIndex.addBulk(billableFindings); let interceptedRequestBody: UsageRecord[] = []; @@ -233,11 +222,11 @@ export default function (providerContext: FtrProviderContext) { isBlockActionEnables: false, numberOfHearbeats: 2, }); - await bulkIndex( - es, - [...blockActionEnabledHeartbeats, ...blockActionDisabledHeartbeats], - CLOUD_DEFEND_HEARTBEAT_INDEX_DEFAULT_NS - ); + + await cloudDefinedIndex.addBulk([ + ...blockActionEnabledHeartbeats, + ...blockActionDisabledHeartbeats, + ]); let interceptedRequestBody: UsageRecord[] = []; @@ -315,22 +304,17 @@ export default function (providerContext: FtrProviderContext) { }); await Promise.all([ - bulkIndex( - es, - [ - ...billableFindingsCSPM, - ...notBillableFindingsCSPM, - ...billableFindingsKSPM, - ...notBillableFindingsKSPM, - ], - LATEST_FINDINGS_INDEX_DEFAULT_NS - ), - bulkIndex(es, [...billableFindingsCNVM], CDR_LATEST_NATIVE_VULNERABILITIES_INDEX_PATTERN), - bulkIndex( - es, - [...blockActionEnabledHeartbeats, ...blockActionDisabledHeartbeats], - CLOUD_DEFEND_HEARTBEAT_INDEX_DEFAULT_NS - ), + findingsIndex.addBulk([ + ...billableFindingsCSPM, + ...notBillableFindingsCSPM, + ...billableFindingsKSPM, + ...notBillableFindingsKSPM, + ]), + vulnerabilitiesIndex.addBulk([...billableFindingsCNVM]), + cloudDefinedIndex.addBulk([ + ...blockActionEnabledHeartbeats, + ...blockActionDisabledHeartbeats, + ]), ]); // Intercept and verify usage API request diff --git a/x-pack/test_serverless/api_integration/test_suites/security/cloud_security_posture/serverless_metering/mock_data.ts b/x-pack/test_serverless/api_integration/test_suites/security/cloud_security_posture/serverless_metering/mock_data.ts index 5e5844eaaf3b5..1991b53b85b35 100644 --- a/x-pack/test_serverless/api_integration/test_suites/security/cloud_security_posture/serverless_metering/mock_data.ts +++ b/x-pack/test_serverless/api_integration/test_suites/security/cloud_security_posture/serverless_metering/mock_data.ts @@ -82,6 +82,8 @@ const mockFiniding = (postureType: string, isBillableAsset?: boolean) => { }, }; } + + throw new Error('Invalid posture type'); }; export const getMockDefendForContainersHeartbeats = ({ diff --git a/x-pack/test_serverless/api_integration/test_suites/security/cloud_security_posture/status/status_indexed.ts b/x-pack/test_serverless/api_integration/test_suites/security/cloud_security_posture/status/status_indexed.ts index a9da3a42cdfc8..b53163796a6ee 100644 --- a/x-pack/test_serverless/api_integration/test_suites/security/cloud_security_posture/status/status_indexed.ts +++ b/x-pack/test_serverless/api_integration/test_suites/security/cloud_security_posture/status/status_indexed.ts @@ -8,16 +8,9 @@ import expect from '@kbn/expect'; import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; import type { CspSetupStatus } from '@kbn/cloud-security-posture-common'; import { CDR_LATEST_NATIVE_VULNERABILITIES_INDEX_PATTERN } from '@kbn/cloud-security-posture-common'; -import { - FINDINGS_INDEX_DEFAULT_NS, - LATEST_FINDINGS_INDEX_DEFAULT_NS, - VULNERABILITIES_INDEX_DEFAULT_NS, -} from '@kbn/cloud-security-posture-plugin/common/constants'; -import { - deleteIndex, - addIndex, - createPackagePolicy, -} from '@kbn/test-suites-xpack/api_integration/apis/cloud_security_posture/helper'; +import { LATEST_FINDINGS_INDEX_DEFAULT_NS } from '@kbn/cloud-security-posture-plugin/common/constants'; +import { createPackagePolicy } from '@kbn/test-suites-xpack/api_integration/apis/cloud_security_posture/helper'; +import { EsIndexDataProvider } from '@kbn/test-suites-xpack/cloud_security_posture_api/utils'; import { findingsMockData, vulnerabilityMockData, @@ -25,13 +18,6 @@ import { import { FtrProviderContext } from '../../../../ftr_provider_context'; import { RoleCredentials } from '../../../../../shared/services'; -const INDEX_ARRAY = [ - FINDINGS_INDEX_DEFAULT_NS, - LATEST_FINDINGS_INDEX_DEFAULT_NS, - CDR_LATEST_NATIVE_VULNERABILITIES_INDEX_PATTERN, - VULNERABILITIES_INDEX_DEFAULT_NS, -]; - export default function (providerContext: FtrProviderContext) { const { getService } = providerContext; const es = getService('es'); @@ -40,6 +26,11 @@ export default function (providerContext: FtrProviderContext) { const supertestWithoutAuth = getService('supertestWithoutAuth'); const svlCommonApi = getService('svlCommonApi'); const svlUserManager = getService('svlUserManager'); + const latestFindingsIndex = new EsIndexDataProvider(es, LATEST_FINDINGS_INDEX_DEFAULT_NS); + const latestVulnerabilitiesIndex = new EsIndexDataProvider( + es, + CDR_LATEST_NATIVE_VULNERABILITIES_INDEX_PATTERN + ); describe('GET /internal/cloud_security_posture/status', function () { // security_exception: action [indices:admin/create] is unauthorized for user [elastic] with effective roles [superuser] on restricted indices [.fleet-actions-7], this action is granted by the index privileges [create_index,manage,all] @@ -74,13 +65,13 @@ export default function (providerContext: FtrProviderContext) { agentPolicyId = agentPolicyResponse.item.id; - await deleteIndex(es, INDEX_ARRAY); - await addIndex(es, findingsMockData, LATEST_FINDINGS_INDEX_DEFAULT_NS); - await addIndex(es, vulnerabilityMockData, CDR_LATEST_NATIVE_VULNERABILITIES_INDEX_PATTERN); + await latestFindingsIndex.deleteAll(); + await latestVulnerabilitiesIndex.deleteAll(); }); afterEach(async () => { - await deleteIndex(es, INDEX_ARRAY); + await latestFindingsIndex.deleteAll(); + await latestVulnerabilitiesIndex.deleteAll(); await kibanaServer.savedObjects.cleanStandardList(); await esArchiver.unload('x-pack/test/functional/es_archives/fleet/empty_fleet_server'); }); @@ -98,6 +89,8 @@ export default function (providerContext: FtrProviderContext) { internalRequestHeader ); + await latestFindingsIndex.addBulk(findingsMockData); + const { body: res }: { body: CspSetupStatus } = await supertestWithoutAuth .get(`/internal/cloud_security_posture/status`) .set(ELASTIC_HTTP_VERSION_HEADER, '1') @@ -124,6 +117,8 @@ export default function (providerContext: FtrProviderContext) { internalRequestHeader ); + await latestFindingsIndex.addBulk(findingsMockData); + const { body: res }: { body: CspSetupStatus } = await supertestWithoutAuth .get(`/internal/cloud_security_posture/status`) .set(ELASTIC_HTTP_VERSION_HEADER, '1') @@ -150,6 +145,8 @@ export default function (providerContext: FtrProviderContext) { internalRequestHeader ); + await latestVulnerabilitiesIndex.addBulk(vulnerabilityMockData); + const { body: res }: { body: CspSetupStatus } = await supertestWithoutAuth .get(`/internal/cloud_security_posture/status`) .set(ELASTIC_HTTP_VERSION_HEADER, '1') diff --git a/x-pack/test_serverless/api_integration/test_suites/security/cloud_security_posture/status/status_indexing.ts b/x-pack/test_serverless/api_integration/test_suites/security/cloud_security_posture/status/status_indexing.ts index ec6a5835e6aa3..e531f2a5cc14e 100644 --- a/x-pack/test_serverless/api_integration/test_suites/security/cloud_security_posture/status/status_indexing.ts +++ b/x-pack/test_serverless/api_integration/test_suites/security/cloud_security_posture/status/status_indexing.ts @@ -7,31 +7,19 @@ import expect from '@kbn/expect'; import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; import type { CspSetupStatus } from '@kbn/cloud-security-posture-common'; -import { CDR_LATEST_NATIVE_VULNERABILITIES_INDEX_PATTERN } from '@kbn/cloud-security-posture-common'; import { FINDINGS_INDEX_DEFAULT_NS, - LATEST_FINDINGS_INDEX_DEFAULT_NS, VULNERABILITIES_INDEX_DEFAULT_NS, } from '@kbn/cloud-security-posture-plugin/common/constants'; -import { - deleteIndex, - addIndex, - createPackagePolicy, -} from '@kbn/test-suites-xpack/api_integration/apis/cloud_security_posture/helper'; +import { createPackagePolicy } from '@kbn/test-suites-xpack/api_integration/apis/cloud_security_posture/helper'; import { findingsMockData, vulnerabilityMockData, } from '@kbn/test-suites-xpack/api_integration/apis/cloud_security_posture/mock_data'; +import { EsIndexDataProvider } from '@kbn/test-suites-xpack/cloud_security_posture_api/utils'; import { FtrProviderContext } from '../../../../ftr_provider_context'; import { RoleCredentials } from '../../../../../shared/services'; -const INDEX_ARRAY = [ - FINDINGS_INDEX_DEFAULT_NS, - LATEST_FINDINGS_INDEX_DEFAULT_NS, - CDR_LATEST_NATIVE_VULNERABILITIES_INDEX_PATTERN, - VULNERABILITIES_INDEX_DEFAULT_NS, -]; - export default function (providerContext: FtrProviderContext) { const { getService } = providerContext; const es = getService('es'); @@ -40,6 +28,8 @@ export default function (providerContext: FtrProviderContext) { const supertestWithoutAuth = getService('supertestWithoutAuth'); const svlCommonApi = getService('svlCommonApi'); const svlUserManager = getService('svlUserManager'); + const findingsIndex = new EsIndexDataProvider(es, FINDINGS_INDEX_DEFAULT_NS); + const vulnerabilitiesIndex = new EsIndexDataProvider(es, VULNERABILITIES_INDEX_DEFAULT_NS); describe('GET /internal/cloud_security_posture/status', function () { // security_exception: action [indices:admin/create] is unauthorized for user [elastic] with effective roles [superuser] on restricted indices [.fleet-actions-7], this action is granted by the index privileges [create_index,manage,all] @@ -73,13 +63,13 @@ export default function (providerContext: FtrProviderContext) { }); agentPolicyId = agentPolicyResponse.item.id; - await deleteIndex(es, INDEX_ARRAY); - await addIndex(es, findingsMockData, FINDINGS_INDEX_DEFAULT_NS); - await addIndex(es, vulnerabilityMockData, VULNERABILITIES_INDEX_DEFAULT_NS); + await findingsIndex.deleteAll(); + await vulnerabilitiesIndex.deleteAll(); }); afterEach(async () => { - await deleteIndex(es, INDEX_ARRAY); + await findingsIndex.deleteAll(); + await vulnerabilitiesIndex.deleteAll(); await kibanaServer.savedObjects.cleanStandardList(); await esArchiver.unload('x-pack/test/functional/es_archives/fleet/empty_fleet_server'); }); @@ -97,6 +87,8 @@ export default function (providerContext: FtrProviderContext) { internalRequestHeader ); + await findingsIndex.addBulk(findingsMockData); + const { body: res }: { body: CspSetupStatus } = await supertestWithoutAuth .get(`/internal/cloud_security_posture/status`) .set(ELASTIC_HTTP_VERSION_HEADER, '1') @@ -123,6 +115,8 @@ export default function (providerContext: FtrProviderContext) { internalRequestHeader ); + await findingsIndex.addBulk(findingsMockData); + const { body: res }: { body: CspSetupStatus } = await supertestWithoutAuth .get(`/internal/cloud_security_posture/status`) .set(ELASTIC_HTTP_VERSION_HEADER, '1') @@ -149,6 +143,8 @@ export default function (providerContext: FtrProviderContext) { internalRequestHeader ); + await vulnerabilitiesIndex.addBulk(vulnerabilityMockData); + const { body: res }: { body: CspSetupStatus } = await supertestWithoutAuth .get(`/internal/cloud_security_posture/status`) .set(ELASTIC_HTTP_VERSION_HEADER, '1') diff --git a/x-pack/test_serverless/api_integration/test_suites/security/cloud_security_posture/telemetry.ts b/x-pack/test_serverless/api_integration/test_suites/security/cloud_security_posture/telemetry.ts index 62cf85b47d997..15700419a7e96 100644 --- a/x-pack/test_serverless/api_integration/test_suites/security/cloud_security_posture/telemetry.ts +++ b/x-pack/test_serverless/api_integration/test_suites/security/cloud_security_posture/telemetry.ts @@ -7,11 +7,12 @@ import expect from '@kbn/expect'; import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; -import { - data as telemetryMockData, - MockTelemetryFindings, -} from '@kbn/test-suites-xpack/cloud_security_posture_api/telemetry/data'; +import { data as telemetryMockData } from '@kbn/test-suites-xpack/cloud_security_posture_api/telemetry/data'; import { createPackagePolicy } from '@kbn/test-suites-xpack/api_integration/apis/cloud_security_posture/helper'; +import { + waitForPluginInitialized, + EsIndexDataProvider, +} from '@kbn/test-suites-xpack/cloud_security_posture_api/utils'; import { SupertestWithRoleScopeType } from '@kbn/test-suites-xpack/api_integration/deployment_agnostic/services'; import type { FtrProviderContext } from '../../../ftr_provider_context'; import { RoleCredentials } from '../../../../shared/services'; @@ -21,7 +22,7 @@ const FINDINGS_INDEX = 'logs-cloud_security_posture.findings_latest-default'; export default function ({ getService }: FtrProviderContext) { const retry = getService('retry'); const es = getService('es'); - const log = getService('log'); + const logger = getService('log'); const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const supertestWithoutAuth = getService('supertestWithoutAuth'); @@ -33,24 +34,7 @@ export default function ({ getService }: FtrProviderContext) { let roleAuthc: RoleCredentials; let internalRequestHeader: { 'x-elastic-internal-origin': string; 'kbn-xsrf': string }; - const index = { - remove: () => - es.deleteByQuery({ - index: FINDINGS_INDEX, - query: { match_all: {} }, - refresh: true, - }), - - add: async (mockTelemetryFindings: MockTelemetryFindings[]) => { - const operations = mockTelemetryFindings.flatMap((doc) => [ - { index: { _index: FINDINGS_INDEX } }, - doc, - ]); - - const response = await es.bulk({ refresh: 'wait_for', index: FINDINGS_INDEX, operations }); - expect(response.errors).to.eql(false); - }, - }; + const findingsIndex = new EsIndexDataProvider(es, FINDINGS_INDEX); describe('Verify cloud_security_posture telemetry payloads', function () { // security_exception: action [indices:admin/create] is unauthorized for user [elastic] with effective roles [superuser] on restricted indices [.fleet-actions-7], this action is granted by the index privileges [create_index,manage,all] @@ -95,22 +79,11 @@ export default function ({ getService }: FtrProviderContext) { internalRequestHeader ); - log.debug('Check CSP plugin is initialized'); - await retry.try(async () => { - const supertestAdminWithHttpHeaderV1 = await roleScopedSupertest.getSupertestWithRoleScope( - 'admin', - { - useCookieHeader: true, - withInternalHeaders: true, - withCustomHeaders: { [ELASTIC_HTTP_VERSION_HEADER]: '1' }, - } - ); - const response = await supertestAdminWithHttpHeaderV1 - .get('/internal/cloud_security_posture/status?check=init') - .expect(200); - expect(response.body).to.eql({ isPluginInitialized: true }); - log.debug('CSP plugin is initialized'); + const supertestAdmin = await roleScopedSupertest.getSupertestWithRoleScope('admin', { + useCookieHeader: true, + withInternalHeaders: true, }); + await waitForPluginInitialized({ logger, retry, supertest: supertestAdmin }); }); after(async () => { @@ -120,11 +93,11 @@ export default function ({ getService }: FtrProviderContext) { }); afterEach(async () => { - await index.remove(); + await findingsIndex.deleteAll(); }); it('includes only KSPM findings', async () => { - await index.add(telemetryMockData.kspmFindings); + await findingsIndex.addBulk(telemetryMockData.kspmFindings); const { body: [{ stats: apiResponse }], @@ -175,7 +148,7 @@ export default function ({ getService }: FtrProviderContext) { }); it('includes only CSPM findings', async () => { - await index.add(telemetryMockData.cspmFindings); + await findingsIndex.addBulk(telemetryMockData.cspmFindings); const { body: [{ stats: apiResponse }], @@ -218,8 +191,10 @@ export default function ({ getService }: FtrProviderContext) { }); it('includes CSPM and KSPM findings', async () => { - await index.add(telemetryMockData.kspmFindings); - await index.add(telemetryMockData.cspmFindings); + await findingsIndex.addBulk([ + ...telemetryMockData.kspmFindings, + ...telemetryMockData.cspmFindings, + ]); const { body: [{ stats: apiResponse }], @@ -294,7 +269,7 @@ export default function ({ getService }: FtrProviderContext) { }); it(`'includes only KSPM findings without posture_type'`, async () => { - await index.add(telemetryMockData.kspmFindingsNoPostureType); + await findingsIndex.addBulk(telemetryMockData.kspmFindingsNoPostureType); const { body: [{ stats: apiResponse }], @@ -346,8 +321,10 @@ export default function ({ getService }: FtrProviderContext) { }); it('includes KSPM findings without posture_type and CSPM findings as well', async () => { - await index.add(telemetryMockData.kspmFindingsNoPostureType); - await index.add(telemetryMockData.cspmFindings); + await findingsIndex.addBulk([ + ...telemetryMockData.kspmFindingsNoPostureType, + ...telemetryMockData.cspmFindings, + ]); const { body: [{ stats: apiResponse }], diff --git a/x-pack/test_serverless/api_integration/test_suites/security/config.ts b/x-pack/test_serverless/api_integration/test_suites/security/config.ts index d40cde3c25837..0b24438b81591 100644 --- a/x-pack/test_serverless/api_integration/test_suites/security/config.ts +++ b/x-pack/test_serverless/api_integration/test_suites/security/config.ts @@ -24,6 +24,6 @@ export default createTestConfig({ // useful for testing (also enabled in MKI QA) '--coreApp.allowDynamicConfigOverrides=true', `--xpack.securitySolutionServerless.cloudSecurityUsageReportingTaskInterval=5s`, - `--xpack.securitySolutionServerless.usageApi.url=http://localhost:8081/api/v1/usage`, + `--xpack.securitySolutionServerless.usageApi.url=http://localhost:8081`, ], }); diff --git a/x-pack/test_serverless/functional/test_suites/common/dev_tools/search_profiler.ts b/x-pack/test_serverless/functional/test_suites/common/dev_tools/search_profiler.ts index 6a908ce4e0fe8..979943ffa602c 100644 --- a/x-pack/test_serverless/functional/test_suites/common/dev_tools/search_profiler.ts +++ b/x-pack/test_serverless/functional/test_suites/common/dev_tools/search_profiler.ts @@ -8,7 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; -const testIndex = 'test-index'; +const indexName = 'my_index'; const testQuery = { query: { match_all: {}, @@ -53,10 +53,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }, }; - // Since we're not actually running the query in the test, - // this index name is just an input placeholder and does not exist - const indexName = 'my_index'; - await PageObjects.common.navigateToUrl( 'searchProfiler', PageObjects.searchProfiler.getUrlWithIndexAndQuery({ indexName, query }), @@ -77,21 +73,21 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { describe('With a test index', () => { before(async () => { - await es.indices.create({ index: testIndex }); + await es.indices.create({ index: indexName }); }); after(async () => { - await es.indices.delete({ index: testIndex }); + await es.indices.delete({ index: indexName }); }); it('profiles a simple query', async () => { - await PageObjects.searchProfiler.setIndexName(testIndex); + await PageObjects.searchProfiler.setIndexName(indexName); await PageObjects.searchProfiler.setQuery(testQuery); await PageObjects.searchProfiler.clickProfileButton(); const content = await PageObjects.searchProfiler.getProfileContent(); - expect(content).to.contain(testIndex); + expect(content).to.contain(indexName); }); }); }); diff --git a/yarn.lock b/yarn.lock index 54a38b2c0e5d3..abc5b5ee2874d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3295,10 +3295,6 @@ version "0.0.0" uid "" -"@kbn/ace@link:packages/kbn-ace": - version "0.0.0" - uid "" - "@kbn/actions-plugin@link:x-pack/plugins/actions": version "0.0.0" uid "" @@ -3319,6 +3315,10 @@ version "0.0.0" uid "" +"@kbn/ai-assistant@link:x-pack/packages/kbn-ai-assistant": + version "0.0.0" + uid "" + "@kbn/aiops-change-point-detection@link:x-pack/packages/ml/aiops_change_point_detection": version "0.0.0" uid "" @@ -5879,6 +5879,10 @@ version "0.0.0" uid "" +"@kbn/observability-logs-overview@link:x-pack/packages/observability/logs_overview": + version "0.0.0" + uid "" + "@kbn/observability-onboarding-e2e@link:x-pack/plugins/observability_solution/observability_onboarding/e2e": version "0.0.0" uid "" @@ -12105,6 +12109,14 @@ use-isomorphic-layout-effect "^1.1.2" use-sync-external-store "^1.0.0" +"@xstate5/react@npm:@xstate/react@^4.1.2": + version "4.1.2" + resolved "https://registry.yarnpkg.com/@xstate/react/-/react-4.1.2.tgz#4bfcdf2d9e9ef1eaea7388d1896649345e6679cd" + integrity sha512-orAidFrKCrU0ZwN5l/ABPlBfW2ziRDT2RrYoktRlZ0WRoLvA2E/uAC1JpZt43mCLtc8jrdwYCgJiqx1V8NvGTw== + dependencies: + use-isomorphic-layout-effect "^1.1.2" + use-sync-external-store "^1.2.0" + "@xtuc/ieee754@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" @@ -13567,11 +13579,6 @@ brace-expansion@^2.0.1: dependencies: balanced-match "^1.0.0" -brace@0.11.1, brace@^0.11.1: - version "0.11.1" - resolved "https://registry.yarnpkg.com/brace/-/brace-0.11.1.tgz#4896fcc9d544eef45f4bb7660db320d3b379fe58" - integrity sha1-SJb8ydVE7vRfS7dmDbMg07N5/lg= - braces@^2.3.1: version "2.3.2" resolved "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz" @@ -16314,7 +16321,7 @@ diacritics@^1.3.0: resolved "https://registry.yarnpkg.com/diacritics/-/diacritics-1.3.0.tgz#3efa87323ebb863e6696cebb0082d48ff3d6f7a1" integrity sha1-PvqHMj67hj5mls67AILUj/PW96E= -diff-match-patch@^1.0.0, diff-match-patch@^1.0.4, diff-match-patch@^1.0.5: +diff-match-patch@^1.0.0, diff-match-patch@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/diff-match-patch/-/diff-match-patch-1.0.5.tgz#abb584d5f10cd1196dfc55aa03701592ae3f7b37" integrity sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw== @@ -26605,17 +26612,6 @@ re2js@0.4.2: resolved "https://registry.yarnpkg.com/re2js/-/re2js-0.4.2.tgz#e344697e64d128ea65c121d6581e67ee5bfa5feb" integrity sha512-wuv0p0BGbrVIkobV8zh82WjDurXko0QNCgaif6DdRAljgVm2iio4PVYCwjAxGaWen1/QZXWDM67dIslmz7AIbA== -react-ace@^7.0.5: - version "7.0.5" - resolved "https://registry.yarnpkg.com/react-ace/-/react-ace-7.0.5.tgz#798299fd52ddf3a3dcc92afc5865538463544f01" - integrity sha512-3iI+Rg2bZXCn9K984ll2OF4u9SGcJH96Q1KsUgs9v4M2WePS4YeEHfW2nrxuqJrAkE5kZbxaCE79k6kqK0YBjg== - dependencies: - brace "^0.11.1" - diff-match-patch "^1.0.4" - lodash.get "^4.4.2" - lodash.isequal "^4.5.0" - prop-types "^15.7.2" - react-clientside-effect@^1.2.6: version "1.2.6" resolved "https://registry.yarnpkg.com/react-clientside-effect/-/react-clientside-effect-1.2.6.tgz#29f9b14e944a376b03fb650eed2a754dd128ea3a" @@ -32800,6 +32796,11 @@ xpath@^0.0.33: resolved "https://registry.yarnpkg.com/xpath/-/xpath-0.0.33.tgz#5136b6094227c5df92002e7c3a13516a5074eb07" integrity sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA== +"xstate5@npm:xstate@^5.18.1", xstate@^5.18.1: + version "5.18.1" + resolved "https://registry.yarnpkg.com/xstate/-/xstate-5.18.1.tgz#c4d43ceaba6e6c31705d36bd96e285de4be4f7f4" + integrity sha512-m02IqcCQbaE/kBQLunwub/5i8epvkD2mFutnL17Oeg1eXTShe1sRF4D5mhv1dlaFO4vbW5gRGRhraeAD5c938g== + xstate@^4.38.2: version "4.38.2" resolved "https://registry.yarnpkg.com/xstate/-/xstate-4.38.2.tgz#1b74544fc9c8c6c713ba77f81c6017e65aa89804"